diff --git a/go.mod b/go.mod index 7158ea1dfd0..10059ee4b31 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( github.com/google/go-cmp v0.7.0 github.com/google/yamlfmt v0.20.0 github.com/invopop/jsonschema v0.13.0 - github.com/lima-vm/go-qcow2reader v0.6.0 + github.com/lima-vm/go-qcow2reader v0.7.0 github.com/lima-vm/sshocker v0.3.8 // gomodjail:unconfined github.com/mattn/go-isatty v0.0.20 github.com/mattn/go-shellwords v1.0.12 diff --git a/go.sum b/go.sum index e50e5a6415e..f6250e8701a 100644 --- a/go.sum +++ b/go.sum @@ -162,8 +162,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lima-vm/go-qcow2reader v0.6.0 h1:dNstUGQxEUPbmiiVnu/cek2x7scrHe2VJy5JseLLflo= -github.com/lima-vm/go-qcow2reader v0.6.0/go.mod h1:ay45SlGOzU+2Vc21g5/lmQgPn7Hmf0JpPhm8cuOK1FI= +github.com/lima-vm/go-qcow2reader v0.7.0 h1:p+t0U7aRyEl5cc7t5Z54XzbaYeLTy4s3bn+Wl6+j4iA= +github.com/lima-vm/go-qcow2reader v0.7.0/go.mod h1:ay45SlGOzU+2Vc21g5/lmQgPn7Hmf0JpPhm8cuOK1FI= github.com/lima-vm/sshocker v0.3.8 h1:nnIaqi1G1hvWihm++53YBXeI8/x7CQNzhEswPC0M2E0= github.com/lima-vm/sshocker v0.3.8/go.mod h1:sO9dTE0i+I0BHHVpyoKZWkPFtHtTkrqcfCkQguRMsx8= github.com/linuxkit/virtsock v0.0.0-20220523201153-1a23e78aa7a2 h1:DZMFueDbfz6PNc1GwDRA8+6lBx1TB9UnxDQliCqR73Y= diff --git a/pkg/driver/vz/disk.go b/pkg/driver/vz/disk.go index 9cb81e06c5c..af729a6d1eb 100644 --- a/pkg/driver/vz/disk.go +++ b/pkg/driver/vz/disk.go @@ -9,13 +9,17 @@ import ( "fmt" "os" "path/filepath" + "strconv" + "github.com/coreos/go-semver/semver" "github.com/docker/go-units" + "github.com/sirupsen/logrus" "github.com/lima-vm/lima/v2/pkg/imgutil/proxyimgutil" "github.com/lima-vm/lima/v2/pkg/iso9660util" "github.com/lima-vm/lima/v2/pkg/limatype" "github.com/lima-vm/lima/v2/pkg/limatype/filenames" + "github.com/lima-vm/lima/v2/pkg/osutil" ) func EnsureDisk(ctx context.Context, inst *limatype.Instance) error { @@ -51,8 +55,45 @@ func EnsureDisk(ctx context.Context, inst *limatype.Instance) error { } return diffDiskF.Close() } - if err = diskUtil.ConvertToRaw(ctx, baseDisk, diffDisk, &diskSize, false); err != nil { - return fmt.Errorf("failed to convert %q to a raw disk %q: %w", baseDisk, diffDisk, err) + // Check whether to use ASIF format + converter := diskUtil.ConvertToASIF + if !determineUseASIF() { + converter = diskUtil.ConvertToRaw + } + if err = converter(ctx, baseDisk, diffDisk, &diskSize, false); err != nil { + return fmt.Errorf("failed to convert %q to a disk %q: %w", baseDisk, diffDisk, err) } return err } + +func determineUseASIF() bool { + var useASIF bool + if macOSProductVersion, err := osutil.ProductVersion(); err != nil { + logrus.WithError(err).Warn("Failed to get macOS product version; using raw format instead of ASIF") + } else if macOSProductVersion.LessThan(*semver.New("26.0.0")) { + logrus.Infof("macOS version %q does not support ASIF format; using raw format instead", macOSProductVersion) + } else { + // TODO: change default to true, + // if the conversion from ASIF to raw while preserving sparsity is implemented, + // or if enough testing is done to confirm that interoperability issues won't happen with ASIF. + useASIF = false + // allow overriding via LIMA_VZ_ASIF environment variable + if envVar := os.Getenv("LIMA_VZ_ASIF"); envVar != "" { + if b, err := strconv.ParseBool(envVar); err != nil { + logrus.WithError(err).Warnf("invalid LIMA_VZ_ASIF value %q", envVar) + } else { + useASIF = b + uses := "ASIF" + if !useASIF { + uses = "raw" + } + logrus.Infof("LIMA_VZ_ASIF=%s; using %s format to diff disk", envVar, uses) + } + } else if useASIF { + logrus.Info("using ASIF format for the disk image") + } else { + logrus.Info("using raw format for the disk image") + } + } + return useASIF +} diff --git a/pkg/driver/vz/vm_darwin.go b/pkg/driver/vz/vm_darwin.go index 15cff0444e3..cbce17b6a5a 100644 --- a/pkg/driver/vz/vm_darwin.go +++ b/pkg/driver/vz/vm_darwin.go @@ -14,6 +14,7 @@ import ( "os" "path/filepath" "runtime" + "slices" "strconv" "sync" "syscall" @@ -22,6 +23,8 @@ import ( "github.com/coreos/go-semver/semver" "github.com/docker/go-units" "github.com/lima-vm/go-qcow2reader" + "github.com/lima-vm/go-qcow2reader/image" + "github.com/lima-vm/go-qcow2reader/image/asif" "github.com/lima-vm/go-qcow2reader/image/raw" "github.com/sirupsen/logrus" @@ -451,8 +454,9 @@ func validateDiskFormat(diskPath string) error { if err != nil { return fmt.Errorf("failed to detect the format of %q: %w", diskPath, err) } - if t := img.Type(); t != raw.Type { - return fmt.Errorf("expected the format of %q to be %q, got %q", diskPath, raw.Type, t) + supportedDiskTypes := []image.Type{raw.Type, asif.Type} + if t := img.Type(); !slices.Contains(supportedDiskTypes, t) { + return fmt.Errorf("expected the format of %q to be one of %v, got %q", diskPath, supportedDiskTypes, t) } // TODO: ensure that the disk is formatted with GPT or ISO9660 return nil diff --git a/pkg/imgutil/manager.go b/pkg/imgutil/manager.go index ffad032d003..047b9b6751a 100644 --- a/pkg/imgutil/manager.go +++ b/pkg/imgutil/manager.go @@ -21,4 +21,7 @@ type ImageDiskManager interface { // MakeSparse makes a file sparse, starting from the specified offset. MakeSparse(ctx context.Context, f *os.File, offset int64) error + + // ConvertToASIF converts a disk image to ASIF format. + ConvertToASIF(ctx context.Context, source, dest string, size *int64, allowSourceWithBackingFile bool) error } diff --git a/pkg/imgutil/nativeimgutil/asifutil/asif_darwin.go b/pkg/imgutil/nativeimgutil/asifutil/asif_darwin.go new file mode 100644 index 00000000000..ad9ab3f7415 --- /dev/null +++ b/pkg/imgutil/nativeimgutil/asifutil/asif_darwin.go @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package asifutil + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" +) + +// NewAttachedASIF creates a new ASIF image file at the specified path with the given size +// and attaches it, returning the attached device path and an open file handle. +// The caller is responsible for detaching the ASIF image device when done. +func NewAttachedASIF(path string, size int64) (string, *os.File, error) { + createArgs := []string{"image", "create", "blank", "--fs", "none", "--format", "ASIF", "--size", fmt.Sprintf("%d", size), path} + if err := exec.CommandContext(context.Background(), "diskutil", createArgs...).Run(); err != nil { + return "", nil, fmt.Errorf("failed to create ASIF image %q: %w", path, err) + } + attachArgs := []string{"image", "attach", "--noMount", path} + out, err := exec.CommandContext(context.Background(), "diskutil", attachArgs...).Output() + if err != nil { + return "", nil, fmt.Errorf("failed to attach ASIF image %q: %w", path, err) + } + devicePath := strings.TrimSpace(string(out)) + f, err := os.OpenFile(devicePath, os.O_RDWR, 0o644) + if err != nil { + _ = DetachASIF(devicePath) + return "", nil, fmt.Errorf("failed to open ASIF device %q: %w", devicePath, err) + } + return devicePath, f, err +} + +// DetachASIF detaches the ASIF image device at the specified path. +func DetachASIF(devicePath string) error { + if output, err := exec.CommandContext(context.Background(), "hdiutil", "detach", devicePath).CombinedOutput(); err != nil { + return fmt.Errorf("failed to detach ASIF image %q: %w: %s", devicePath, err, output) + } + return nil +} + +// ResizeASIF resizes the ASIF image at the specified path to the given size. +func ResizeASIF(path string, size int64) error { + resizeArgs := []string{"image", "resize", "--size", fmt.Sprintf("%d", size), path} + if output, err := exec.CommandContext(context.Background(), "diskutil", resizeArgs...).CombinedOutput(); err != nil { + return fmt.Errorf("failed to resize ASIF image %q: %w: %s", path, err, output) + } + return nil +} diff --git a/pkg/imgutil/nativeimgutil/asifutil/asif_others.go b/pkg/imgutil/nativeimgutil/asifutil/asif_others.go new file mode 100644 index 00000000000..7298a5e7164 --- /dev/null +++ b/pkg/imgutil/nativeimgutil/asifutil/asif_others.go @@ -0,0 +1,25 @@ +//go:build !darwin + +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package asifutil + +import ( + "errors" + "os" +) + +var ErrASIFNotSupported = errors.New("ASIF is only supported on macOS") + +func NewAttachedASIF(_ string, _ int64) (string, *os.File, error) { + return "", nil, ErrASIFNotSupported +} + +func DetachASIF(_ string) error { + return ErrASIFNotSupported +} + +func ResizeASIF(_ string, _ int64) error { + return ErrASIFNotSupported +} diff --git a/pkg/imgutil/nativeimgutil/fuzz_test.go b/pkg/imgutil/nativeimgutil/fuzz_test.go index 204d5583fdc..421438bf91d 100644 --- a/pkg/imgutil/nativeimgutil/fuzz_test.go +++ b/pkg/imgutil/nativeimgutil/fuzz_test.go @@ -17,6 +17,6 @@ func FuzzConvertToRaw(f *testing.F) { destPath := filepath.Join(t.TempDir(), "dest.img") err := os.WriteFile(srcPath, imgData, 0o600) assert.NilError(t, err) - _ = convertToRaw(srcPath, destPath, &size, withBacking) + _ = convertTo(imageRaw, srcPath, destPath, &size, withBacking) }) } diff --git a/pkg/imgutil/nativeimgutil/nativeimgutil.go b/pkg/imgutil/nativeimgutil/nativeimgutil.go index 2eb1e2cae0b..8ab605d26d5 100644 --- a/pkg/imgutil/nativeimgutil/nativeimgutil.go +++ b/pkg/imgutil/nativeimgutil/nativeimgutil.go @@ -10,6 +10,8 @@ import ( "fmt" "io" "io/fs" + "math" + "math/rand/v2" "os" "path/filepath" @@ -17,10 +19,12 @@ import ( "github.com/docker/go-units" "github.com/lima-vm/go-qcow2reader" "github.com/lima-vm/go-qcow2reader/convert" + "github.com/lima-vm/go-qcow2reader/image/asif" "github.com/lima-vm/go-qcow2reader/image/qcow2" "github.com/lima-vm/go-qcow2reader/image/raw" "github.com/sirupsen/logrus" + "github.com/lima-vm/lima/v2/pkg/imgutil/nativeimgutil/asifutil" "github.com/lima-vm/lima/v2/pkg/progressbar" ) @@ -38,10 +42,17 @@ func roundUp(size int64) int64 { return sectors * sectorSize } -// convertToRaw converts a source disk into a raw disk. +type targetImageType string + +const ( + imageRaw targetImageType = "raw" + imageASIF targetImageType = "ASIF" +) + +// convertTo converts a source disk into a raw or ASIF disk. // source and dest may be same. -// convertToRaw is a NOP if source == dest, and no resizing is needed. -func convertToRaw(source, dest string, size *int64, allowSourceWithBackingFile bool) error { +// convertTo is a NOP if source == dest, and no resizing is needed. +func convertTo(destType targetImageType, source, dest string, size *int64, allowSourceWithBackingFile bool) error { srcF, err := os.Open(source) if err != nil { return err @@ -54,13 +65,15 @@ func convertToRaw(source, dest string, size *int64, allowSourceWithBackingFile b if size != nil && *size < srcImg.Size() { return fmt.Errorf("specified size %d is smaller than the original image size (%d) of %q", *size, srcImg.Size(), source) } - logrus.Infof("Converting %q (%s) to a raw disk %q", source, srcImg.Type(), dest) + logrus.Infof("Converting %q (%s) to a %s disk %q", source, srcImg.Type(), destType, dest) switch t := srcImg.Type(); t { case raw.Type: if err = srcF.Close(); err != nil { return err } - return convertRawToRaw(source, dest, size) + if destType == imageRaw { + return convertRawToRaw(source, dest, size) + } case qcow2.Type: if !allowSourceWithBackingFile { q, ok := srcImg.(*qcow2.Qcow2) @@ -71,6 +84,11 @@ func convertToRaw(source, dest string, size *int64, allowSourceWithBackingFile b return fmt.Errorf("qcow2 image %q has an unexpected backing file: %q", source, q.BackingFile) } } + case asif.Type: + if destType == imageASIF { + return convertASIFToASIF(source, dest, size) + } + return fmt.Errorf("conversion from ASIF to %q is not supported", destType) default: logrus.Warnf("image %q has an unexpected format: %q", source, t) } @@ -79,11 +97,26 @@ func convertToRaw(source, dest string, size *int64, allowSourceWithBackingFile b } // Create a tmp file because source and dest can be same. - destTmpF, err := os.CreateTemp(filepath.Dir(dest), filepath.Base(dest)+".lima-*.tmp") + var ( + destTmpF *os.File + destTmp string + attachedDevice string + ) + switch destType { + case imageRaw: + destTmpF, err = os.CreateTemp(filepath.Dir(dest), filepath.Base(dest)+".lima-*.tmp") + destTmp = destTmpF.Name() + case imageASIF: + // destTmp != destTmpF.Name() because destTmpF is mounted ASIF device file. + randomBase := fmt.Sprintf("%s.lima-%d.tmp.asif", filepath.Base(dest), rand.UintN(math.MaxUint)) + destTmp = filepath.Join(filepath.Dir(dest), randomBase) + attachedDevice, destTmpF, err = asifutil.NewAttachedASIF(destTmp, srcImg.Size()) + default: + return fmt.Errorf("unsupported target image type: %q", destType) + } if err != nil { return err } - destTmp := destTmpF.Name() defer os.RemoveAll(destTmp) defer destTmpF.Close() @@ -116,6 +149,13 @@ func convertToRaw(source, dest string, size *int64, allowSourceWithBackingFile b if err = destTmpF.Close(); err != nil { return err } + // Detach ASIF device + if destType == imageASIF { + err := asifutil.DetachASIF(attachedDevice) + if err != nil { + return fmt.Errorf("failed to detach ASIF image %q: %w", attachedDevice, err) + } + } // Rename destTmp into dest if err = os.RemoveAll(dest); err != nil { @@ -149,6 +189,24 @@ func convertRawToRaw(source, dest string, size *int64) error { return nil } +func convertASIFToASIF(source, dest string, size *int64) error { + if source != dest { + if err := containerdfs.CopyFile(dest, source); err != nil { + return fmt.Errorf("failed to copy %q into %q: %w", source, dest, err) + } + if err := os.Chmod(dest, 0o644); err != nil { + return fmt.Errorf("failed to set permissions on %q: %w", dest, err) + } + } + if size != nil { + logrus.Infof("Resizing to %s", units.BytesSize(float64(*size))) + if err := asifutil.ResizeASIF(dest, *size); err != nil { + return fmt.Errorf("failed to resize ASIF image %q: %w", dest, err) + } + } + return nil +} + func makeSparse(f *os.File, offset int64) error { if _, err := f.Seek(offset, io.SeekStart); err != nil { return err @@ -172,7 +230,7 @@ func (n *NativeImageUtil) CreateDisk(_ context.Context, disk string, size int64) // ConvertToRaw converts a disk image to raw format. func (n *NativeImageUtil) ConvertToRaw(_ context.Context, source, dest string, size *int64, allowSourceWithBackingFile bool) error { - return convertToRaw(source, dest, size, allowSourceWithBackingFile) + return convertTo(imageRaw, source, dest, size, allowSourceWithBackingFile) } // ResizeDisk resizes an existing disk image to the specified size. @@ -185,3 +243,8 @@ func (n *NativeImageUtil) ResizeDisk(_ context.Context, disk string, size int64) func (n *NativeImageUtil) MakeSparse(_ context.Context, f *os.File, offset int64) error { return makeSparse(f, offset) } + +// ConvertToASIF converts a disk image to ASIF format. +func (n *NativeImageUtil) ConvertToASIF(_ context.Context, source, dest string, size *int64, allowSourceWithBackingFile bool) error { + return convertTo(imageASIF, source, dest, size, allowSourceWithBackingFile) +} diff --git a/pkg/imgutil/nativeimgutil/nativeimgutil_test.go b/pkg/imgutil/nativeimgutil/nativeimgutil_test.go index 4cf9c515c68..60f54399a45 100644 --- a/pkg/imgutil/nativeimgutil/nativeimgutil_test.go +++ b/pkg/imgutil/nativeimgutil/nativeimgutil_test.go @@ -65,7 +65,7 @@ func TestConvertToRaw(t *testing.T) { t.Run("qcow without backing file", func(t *testing.T) { resultImage := filepath.Join(tmpDir, strings.ReplaceAll(strings.ReplaceAll(t.Name(), string(os.PathSeparator), "_"), "/", "_")) - err = convertToRaw(qcowImage.Name(), resultImage, nil, false) + err = convertTo(imageRaw, qcowImage.Name(), resultImage, nil, false) assert.NilError(t, err) assertFileEquals(t, rawImage.Name(), resultImage) }) @@ -73,7 +73,7 @@ func TestConvertToRaw(t *testing.T) { t.Run("qcow with backing file", func(t *testing.T) { resultImage := filepath.Join(tmpDir, strings.ReplaceAll(strings.ReplaceAll(t.Name(), string(os.PathSeparator), "_"), "/", "_")) - err = convertToRaw(qcowImage.Name(), resultImage, nil, true) + err = convertTo(imageRaw, qcowImage.Name(), resultImage, nil, true) assert.NilError(t, err) assertFileEquals(t, rawImage.Name(), resultImage) }) @@ -82,7 +82,7 @@ func TestConvertToRaw(t *testing.T) { resultImage := filepath.Join(tmpDir, strings.ReplaceAll(strings.ReplaceAll(t.Name(), string(os.PathSeparator), "_"), "/", "_")) size := int64(2_097_152) // 2mb - err = convertToRaw(qcowImage.Name(), resultImage, &size, false) + err = convertTo(imageRaw, qcowImage.Name(), resultImage, &size, false) assert.NilError(t, err) assertFileEquals(t, rawImageExtended.Name(), resultImage) }) @@ -90,7 +90,7 @@ func TestConvertToRaw(t *testing.T) { t.Run("raw", func(t *testing.T) { resultImage := filepath.Join(tmpDir, strings.ReplaceAll(strings.ReplaceAll(t.Name(), string(os.PathSeparator), "_"), "/", "_")) - err = convertToRaw(rawImage.Name(), resultImage, nil, false) + err = convertTo(imageRaw, rawImage.Name(), resultImage, nil, false) assert.NilError(t, err) assertFileEquals(t, rawImage.Name(), resultImage) }) diff --git a/pkg/imgutil/proxyimgutil/proxyimgutil.go b/pkg/imgutil/proxyimgutil/proxyimgutil.go index e08a5d2e2d1..ef8545c7e52 100644 --- a/pkg/imgutil/proxyimgutil/proxyimgutil.go +++ b/pkg/imgutil/proxyimgutil/proxyimgutil.go @@ -74,3 +74,8 @@ func (p *ImageDiskManager) MakeSparse(ctx context.Context, f *os.File, offset in } return err } + +func (p *ImageDiskManager) ConvertToASIF(ctx context.Context, source, dest string, size *int64, allowSourceWithBackingFile bool) error { + // ASIF conversion is only supported by the native image utility. + return p.native.ConvertToASIF(ctx, source, dest, size, allowSourceWithBackingFile) +} diff --git a/pkg/qemuimgutil/qemuimgutil.go b/pkg/qemuimgutil/qemuimgutil.go index 7e364cb0ac2..272f1510adc 100644 --- a/pkg/qemuimgutil/qemuimgutil.go +++ b/pkg/qemuimgutil/qemuimgutil.go @@ -269,3 +269,8 @@ func AcceptableAsBaseDisk(info *Info) error { } return nil } + +func (q *QemuImageUtil) ConvertToASIF(_ context.Context, _, _ string, _ *int64, _ bool) error { + // Should never be called because ASIF is not supported by qemu-img. + return nil +} diff --git a/website/content/en/docs/config/environment-variables.md b/website/content/en/docs/config/environment-variables.md index 7881d5eddf4..6a078056f62 100644 --- a/website/content/en/docs/config/environment-variables.md +++ b/website/content/en/docs/config/environment-variables.md @@ -139,6 +139,14 @@ This page documents the environment variables used in Lima. ```sh export LIMA_USERNET_RESOLVE_IP_ADDRESS_TIMEOUT=5 ``` +### `LIMA_VZ_ASIF` + +- **Description**: Specifies whether to use ASIF disk image format for VZ driver on macOS 26.0 or later. +- **Default**: `false` +- **Usage**: + ```sh + export LIMA_VZ_ASIF=true + ``` ### `_LIMA_QEMU_UEFI_IN_BIOS`