package qemuimgutil
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"strconv"
"github.com/lima-vm/go-qcow2reader/image"
"github.com/lima-vm/go-qcow2reader/image/raw"
"github.com/sirupsen/logrus"
)
const QemuImgFormat = "qcow2"
type QemuImageUtil struct {
DefaultFormat string
}
type Info struct {
Filename string `json:"filename,omitempty"`
Format string `json:"format,omitempty"`
VSize int64 `json:"virtual-size,omitempty"`
ActualSize int64 `json:"actual-size,omitempty"`
DirtyFlag bool `json:"dirty-flag,omitempty"`
ClusterSize int `json:"cluster-size,omitempty"`
BackingFilename string `json:"backing-filename,omitempty"`
FullBackingFilename string `json:"full-backing-filename,omitempty"`
BackingFilenameFormat string `json:"backing-filename-format,omitempty"`
FormatSpecific *InfoFormatSpecific `json:"format-specific,omitempty"`
Children []InfoChild `json:"children,omitempty"`
}
type InfoChild struct {
Name string `json:"name,omitempty"`
Info Info `json:"info,omitempty"`
}
type InfoFormatSpecific struct {
Type string `json:"type,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
}
func resizeDisk(ctx context.Context, disk, format string, size int64) error {
args := []string{"resize", "-f", format, disk, strconv.FormatInt(size, 10)}
cmd := exec.CommandContext(ctx, "qemu-img", args...)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to run %v: %q: %w", cmd.Args, string(out), err)
}
return nil
}
func (sp *InfoFormatSpecific) Qcow2() *InfoFormatSpecificDataQcow2 {
if sp.Type != "qcow2" {
return nil
}
var x InfoFormatSpecificDataQcow2
if err := json.Unmarshal(sp.Data, &x); err != nil {
panic(err)
}
return &x
}
func (sp *InfoFormatSpecific) Vmdk() *InfoFormatSpecificDataVmdk {
if sp.Type != "vmdk" {
return nil
}
var x InfoFormatSpecificDataVmdk
if err := json.Unmarshal(sp.Data, &x); err != nil {
panic(err)
}
return &x
}
type InfoFormatSpecificDataQcow2 struct {
Compat string `json:"compat,omitempty"`
LazyRefcounts bool `json:"lazy-refcounts,omitempty"`
Corrupt bool `json:"corrupt,omitempty"`
RefcountBits int `json:"refcount-bits,omitempty"`
CompressionType string `json:"compression-type,omitempty"`
ExtendedL2 bool `json:"extended-l2,omitempty"`
}
type InfoFormatSpecificDataVmdk struct {
CreateType string `json:"create-type,omitempty"`
CID int `json:"cid,omitempty"`
ParentCID int `json:"parent-cid,omitempty"`
Extents []InfoFormatSpecificDataVmdkExtent `json:"extents,omitempty"`
}
type InfoFormatSpecificDataVmdkExtent struct {
Filename string `json:"filename,omitempty"`
Format string `json:"format,omitempty"`
VSize int64 `json:"virtual-size,omitempty"`
ClusterSize int `json:"cluster-size,omitempty"`
}
func convertToRaw(ctx context.Context, source, dest string) error {
if source != dest {
return execQemuImgConvert(ctx, source, dest)
}
info, err := getInfo(ctx, source)
if err != nil {
return fmt.Errorf("failed to get info for source disk %q: %w", source, err)
}
if info.Format == "raw" {
return nil
}
tempFile := dest + ".lima-qemu-convert.tmp"
defer os.Remove(tempFile)
if err := execQemuImgConvert(ctx, source, tempFile); err != nil {
return err
}
return os.Rename(tempFile, dest)
}
func execQemuImgConvert(ctx context.Context, source, dest string) error {
var stdout, stderr bytes.Buffer
cmd := exec.CommandContext(ctx, "qemu-img", "convert", "-O", "raw", source, dest)
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to run %v: stdout=%q, stderr=%q: %w",
cmd.Args, stdout.String(), stderr.String(), err)
}
return nil
}
func parseInfo(b []byte) (*Info, error) {
var imgInfo Info
if err := json.Unmarshal(b, &imgInfo); err != nil {
return nil, err
}
return &imgInfo, nil
}
func getInfo(ctx context.Context, f string) (*Info, error) {
var stdout, stderr bytes.Buffer
cmd := exec.CommandContext(ctx, "qemu-img", "info", "--output=json", "--force-share", f)
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("failed to run %v: stdout=%q, stderr=%q: %w",
cmd.Args, stdout.String(), stderr.String(), err)
}
return parseInfo(stdout.Bytes())
}
func (q *QemuImageUtil) CreateDisk(ctx context.Context, disk string, size int64) error {
if _, err := os.Stat(disk); err == nil || !errors.Is(err, fs.ErrNotExist) {
return err
}
args := []string{"create", "-f", q.DefaultFormat, disk, strconv.FormatInt(size, 10)}
cmd := exec.CommandContext(ctx, "qemu-img", args...)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to run %v: %q: %w", cmd.Args, string(out), err)
}
return nil
}
func (q *QemuImageUtil) ResizeDisk(ctx context.Context, disk string, size int64) error {
info, err := getInfo(ctx, disk)
if err != nil {
return fmt.Errorf("failed to get info for disk %q: %w", disk, err)
}
return resizeDisk(ctx, disk, info.Format, size)
}
func (q *QemuImageUtil) MakeSparse(_ context.Context, _ *os.File, _ int64) error {
return nil
}
func GetInfo(ctx context.Context, path string) (*Info, error) {
qemuInfo, err := getInfo(ctx, path)
if err != nil {
return nil, err
}
return qemuInfo, nil
}
func (q *QemuImageUtil) Convert(ctx context.Context, imageType image.Type, source, dest string, size *int64, allowSourceWithBackingFile bool) error {
if imageType != raw.Type {
return fmt.Errorf("QemuImageUtil.Convert only supports raw.Type, got %q", imageType)
}
if !allowSourceWithBackingFile {
info, err := getInfo(ctx, source)
if err != nil {
return fmt.Errorf("failed to get info for source disk %q: %w", source, err)
}
if info.BackingFilename != "" || info.FullBackingFilename != "" {
return fmt.Errorf("qcow2 image %q has an unexpected backing file: %q", source, info.BackingFilename)
}
}
if err := convertToRaw(ctx, source, dest); err != nil {
return err
}
if size != nil {
destInfo, err := getInfo(ctx, dest)
if err != nil {
return fmt.Errorf("failed to get info for converted disk %q: %w", dest, err)
}
if *size > destInfo.VSize {
return resizeDisk(ctx, dest, "raw", *size)
}
}
return nil
}
func AcceptableAsBaseDisk(info *Info) error {
switch info.Format {
case "qcow2", "raw":
default:
logrus.WithField("filename", info.Filename).
Warnf("Unsupported image format %q. The image may not boot, or may have an extra privilege to access the host filesystem. Use with caution.", info.Format)
}
if info.BackingFilename != "" {
return fmt.Errorf("base disk (%q) must not have a backing file (%q)", info.Filename, info.BackingFilename)
}
if info.FullBackingFilename != "" {
return fmt.Errorf("base disk (%q) must not have a backing file (%q)", info.Filename, info.FullBackingFilename)
}
if info.FormatSpecific != nil {
if vmdk := info.FormatSpecific.Vmdk(); vmdk != nil {
for _, e := range vmdk.Extents {
if e.Filename != info.Filename {
return fmt.Errorf("base disk (%q) must not have an extent file (%q)", info.Filename, e.Filename)
}
}
}
}
switch len(info.Children) {
case 0:
case 1:
if info.Filename != info.Children[0].Info.Filename {
return fmt.Errorf("base disk (%q) child must not have a different filename (%q)", info.Filename, info.Children[0].Info.Filename)
}
if len(info.Children[0].Info.Children) > 0 {
return fmt.Errorf("base disk (%q) child must not have children of its own", info.Filename)
}
default:
return fmt.Errorf("base disk (%q) must not have multiple children: %+v", info.Filename, info.Children)
}
return nil
}