diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 919dc155e..89b773aa8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -102,6 +102,8 @@ jobs: if pgrep '^socket_vmnet'; then sudo pkill '^socket_vmnet' fi + - name: Install Rosetta 2 + run: echo "A" | softwareupdate --install-rosetta || true - run: brew install go lz4 automake autoconf libtool - name: Build project run: | diff --git a/Makefile b/Makefile index cc1557ca5..4a858209d 100644 --- a/Makefile +++ b/Makefile @@ -35,13 +35,13 @@ ifneq (,$(findstring arm64,$(ARCH))) LIMA_ARCH = aarch64 # From https://dl.fedoraproject.org/pub/fedora/linux/releases/37/Cloud/aarch64/images/ FINCH_OS_BASENAME ?= Fedora-Cloud-Base-37-1.7.aarch64-20230321224649.qcow2 - LIMA_URL ?= https://deps.runfinch.com/aarch64/lima-and-qemu.macos-aarch64.1678826933.tar.gz + LIMA_URL ?= https://deps.runfinch.com/aarch64/lima-and-qemu.macos-aarch64.1679936560.tar.gz else ifneq (,$(findstring x86_64,$(ARCH))) SUPPORTED_ARCH = true LIMA_ARCH = x86_64 # From https://dl.fedoraproject.org/pub/fedora/linux/releases/37/Cloud/x86_64/images/ FINCH_OS_BASENAME ?= Fedora-Cloud-Base-37-1.7.x86_64-20230321224635.qcow2 - LIMA_URL ?= https://deps.runfinch.com/x86-64/lima-and-qemu.macos-x86_64.1678817277.tar.gz + LIMA_URL ?= https://deps.runfinch.com/x86-64/lima-and-qemu.macos-x86_64.1679936560.tar.gz endif FINCH_OS_HASH := `shasum -a 256 $(OUTDIR)/os/$(FINCH_OS_BASENAME) | cut -d ' ' -f 1` diff --git a/README.md b/README.md index 7c42586fe..a28050e7d 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,17 @@ memory: 4GiB additional_directories: # the path of each additional directory. - path: /Volumes +# vmType (Experimental): sets which Hypervisor to use to launch the VM. (optional) +# Only takes effect when a new VM is launched (only on vm init). +# One of: "qemu", "vz". +# - "qemu" (default): Uses QEMU as the Hypervisor. +# - "vz": Uses Virtualization.framework as the Hypervisor. +vmType: "qemu" +# rosetta (Experimental): sets whether to enable Rosetta as the binfmt_misc handler inside the VM. (optional) +# Only takes effect when a new VM is launched (only on vm init). +# Only available when using vmType "vz" on Apple Silicon running macOS 13+. +# If true, also sets vmType to "vz". +rosetta: false ``` ### FAQ diff --git a/cmd/finch/main.go b/cmd/finch/main.go index a93cf811b..2685181f9 100644 --- a/cmd/finch/main.go +++ b/cmd/finch/main.go @@ -116,11 +116,11 @@ func virtualMachineCommands( lcc, logger, optionalDepGroups, - config.NewLimaApplier(fc, fs, fp.LimaOverrideConfigPath()), + config.NewLimaApplier(fc, ecc, fs, fp.LimaOverrideConfigPath(), system.NewStdLib()), config.NewNerdctlApplier(fssh.NewDialer(), fs, fp.LimaSSHPrivateKeyPath()), fp, fs, - disk.NewUserDataDiskManager(lcc, &afero.OsFs{}, fp, system.NewStdLib().Env("HOME")), + disk.NewUserDataDiskManager(lcc, ecc, &afero.OsFs{}, fp, system.NewStdLib().Env("HOME"), fc), ) } diff --git a/cmd/finch/virtual_machine_init.go b/cmd/finch/virtual_machine_init.go index 0c691b864..ab7161562 100644 --- a/cmd/finch/virtual_machine_init.go +++ b/cmd/finch/virtual_machine_init.go @@ -81,7 +81,7 @@ func (iva *initVMAction) run() error { iva.logger.Errorf("Dependency error: %v", err) } - err = iva.limaConfigApplier.Apply() + err = iva.limaConfigApplier.Apply(true) if err != nil { return err } diff --git a/cmd/finch/virtual_machine_init_test.go b/cmd/finch/virtual_machine_init_test.go index cf73bf1f5..38a060ab3 100644 --- a/cmd/finch/virtual_machine_init_test.go +++ b/cmd/finch/virtual_machine_init_test.go @@ -71,7 +71,7 @@ func TestInitVMAction_runAdapter(t *testing.T) { logger.EXPECT().Debugf("Status of virtual machine: %s", "") command := mocks.NewCommand(ctrl) - lca.EXPECT().Apply().Return(nil) + lca.EXPECT().Apply(true).Return(nil) dm.EXPECT().EnsureUserDataDisk().Return(nil) lcc.EXPECT().CreateWithoutStdio("start", fmt.Sprintf("--name=%s", limaInstanceName), mockBaseYamlFilePath, "--tty=false").Return(command) @@ -135,7 +135,7 @@ func TestInitVMAction_run(t *testing.T) { getVMStatusC.EXPECT().Output().Return([]byte(""), nil) logger.EXPECT().Debugf("Status of virtual machine: %s", "") - lca.EXPECT().Apply().Return(nil) + lca.EXPECT().Apply(true).Return(nil) dm.EXPECT().EnsureUserDataDisk().Return(nil) command := mocks.NewCommand(ctrl) @@ -253,7 +253,7 @@ func TestInitVMAction_run(t *testing.T) { getVMStatusC.EXPECT().Output().Return([]byte(""), nil) logger.EXPECT().Debugf("Status of virtual machine: %s", "") - lca.EXPECT().Apply().Return(errors.New("load config fails")) + lca.EXPECT().Apply(true).Return(errors.New("load config fails")) logger.EXPECT().Errorf("Dependency error: %v", fmt.Errorf("failed to install dependencies: %w", errors.Join(fmt.Errorf("%s: %w", "mock_error_msg", errors.Join(errors.New("dependency error occurs")))), @@ -279,7 +279,7 @@ func TestInitVMAction_run(t *testing.T) { getVMStatusC.EXPECT().Output().Return([]byte(""), nil) logger.EXPECT().Debugf("Status of virtual machine: %s", "") - lca.EXPECT().Apply().Return(nil) + lca.EXPECT().Apply(true).Return(nil) dm.EXPECT().EnsureUserDataDisk().Return(nil) logs := []byte("stdout + stderr") diff --git a/cmd/finch/virtual_machine_start.go b/cmd/finch/virtual_machine_start.go index 0edb893f6..2ede0b464 100644 --- a/cmd/finch/virtual_machine_start.go +++ b/cmd/finch/virtual_machine_start.go @@ -74,7 +74,7 @@ func (sva *startVMAction) run() error { sva.logger.Errorf("Dependency error: %v", err) } - err = sva.limaConfigApplier.Apply() + err = sva.limaConfigApplier.Apply(false) if err != nil { return err } diff --git a/cmd/finch/virtual_machine_start_test.go b/cmd/finch/virtual_machine_start_test.go index 1b65543c2..7bdb39691 100644 --- a/cmd/finch/virtual_machine_start_test.go +++ b/cmd/finch/virtual_machine_start_test.go @@ -70,7 +70,7 @@ func TestStartVMAction_runAdapter(t *testing.T) { getVMStatusC.EXPECT().Output().Return([]byte("Stopped"), nil) logger.EXPECT().Debugf("Status of virtual machine: %s", "Stopped") - lca.EXPECT().Apply().Return(nil) + lca.EXPECT().Apply(false).Return(nil) dm.EXPECT().EnsureUserDataDisk().Return(nil) @@ -145,7 +145,7 @@ func TestStartVMAction_run(t *testing.T) { getVMStatusC.EXPECT().Output().Return([]byte("Stopped"), nil) logger.EXPECT().Debugf("Status of virtual machine: %s", "Stopped") - lca.EXPECT().Apply().Return(nil) + lca.EXPECT().Apply(false).Return(nil) dm.EXPECT().EnsureUserDataDisk().Return(nil) @@ -262,7 +262,7 @@ func TestStartVMAction_run(t *testing.T) { getVMStatusC.EXPECT().Output().Return([]byte("Stopped"), nil) logger.EXPECT().Debugf("Status of virtual machine: %s", "Stopped") - lca.EXPECT().Apply().Return(errors.New("load config fails")) + lca.EXPECT().Apply(false).Return(errors.New("load config fails")) logger.EXPECT().Errorf("Dependency error: %v", fmt.Errorf("failed to install dependencies: %w", @@ -295,7 +295,7 @@ func TestStartVMAction_run(t *testing.T) { getVMStatusC.EXPECT().Output().Return([]byte("Stopped"), nil) logger.EXPECT().Debugf("Status of virtual machine: %s", "Stopped") - lca.EXPECT().Apply().Return(nil) + lca.EXPECT().Apply(false).Return(nil) dm.EXPECT().EnsureUserDataDisk().Return(nil) diff --git a/e2e/vm/config_test.go b/e2e/vm/config_test.go index 8f8a8bf6e..cfc1e4746 100644 --- a/e2e/vm/config_test.go +++ b/e2e/vm/config_test.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "github.com/lima-vm/lima/pkg/limayaml" "github.com/onsi/ginkgo/v2" @@ -20,6 +21,8 @@ import ( "gopkg.in/yaml.v3" "github.com/runfinch/finch/e2e" + finch_cmd "github.com/runfinch/finch/pkg/command" + "github.com/runfinch/finch/pkg/config" ) var finchConfigFilePath = os.Getenv("HOME") + "/.finch/finch.yaml" @@ -80,7 +83,7 @@ var testConfig = func(o *option.Option, installed bool) { writeFile(limaConfigFilePath, origLimaCfg) command.New(o, virtualMachineRootCmd, "stop").WithoutCheckingExitCode().WithTimeoutInSeconds(90).Run() - command.New(o, virtualMachineRootCmd, "start").WithTimeoutInSeconds(120).Run() + command.New(o, virtualMachineRootCmd, "start").WithTimeoutInSeconds(600).Run() }) }) @@ -166,5 +169,51 @@ additional_directories: gomega.Expect(limaCfg.Mounts[1].Location).Should(gomega.Equal("/tmp/workspace")) gomega.Expect(limaCfg.Mounts[1].Writable).Should(gomega.Equal(pointer.Bool(true))) }) + + ginkgo.It("does not update init-only config values when values are changed between start/stop", func() { + startCmdSession := updateAndApplyConfig(o, []byte("memory: 4GiB\ncpus: 6\nvmType: vz\nrosetta: true")) + gomega.Expect(startCmdSession).Should(gexec.Exit(0)) + + gomega.Expect(limaConfigFilePath).Should(gomega.BeARegularFile()) + cfgBuf, err := os.ReadFile(filepath.Clean(limaConfigFilePath)) + gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) + + var limaCfg limayaml.LimaYAML + err = yaml.Unmarshal(cfgBuf, &limaCfg) + gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) + gomega.Expect(*limaCfg.CPUs).Should(gomega.Equal(6)) + gomega.Expect(*limaCfg.Memory).Should(gomega.Equal("4GiB")) + gomega.Expect(*limaCfg.VMType).Should(gomega.Equal("qemu")) + gomega.Expect(limaCfg.Rosetta.Enabled).Should(gomega.Equal(false)) + gomega.Expect(limaCfg.Rosetta.BinFmt).Should(gomega.Equal(false)) + }) + }) + + ginkgo.Describe("Config (after init)", ginkgo.Serial, func() { + ginkgo.It("updates init-only config values when values are changed after init", func() { + supportsVz, supportsVzErr := config.SupportsVirtualizationFramework(finch_cmd.NewExecCmdCreator()) + gomega.Expect(supportsVzErr).ShouldNot(gomega.HaveOccurred()) + if !supportsVz || runtime.GOOS != "darwin" { + ginkgo.Skip("Skipping because existing init only configuration options require Virtualization.framework support to test") + } + + limaConfigFilePath := resetVM(o, installed) + writeFile(finchConfigFilePath, []byte("memory: 4GiB\ncpus: 6\nvmType: vz\nrosetta: false")) + initCmdSession := command.New(o, virtualMachineRootCmd, "init").WithTimeoutInSeconds(120).Run() + gomega.Expect(initCmdSession).Should(gexec.Exit(0)) + + gomega.Expect(limaConfigFilePath).Should(gomega.BeARegularFile()) + cfgBuf, err := os.ReadFile(filepath.Clean(limaConfigFilePath)) + gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) + + var limaCfg limayaml.LimaYAML + err = yaml.Unmarshal(cfgBuf, &limaCfg) + gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) + gomega.Expect(*limaCfg.CPUs).Should(gomega.Equal(6)) + gomega.Expect(*limaCfg.Memory).Should(gomega.Equal("4GiB")) + gomega.Expect(*limaCfg.VMType).Should(gomega.Equal("vz")) + gomega.Expect(limaCfg.Rosetta.Enabled).Should(gomega.Equal(false)) + gomega.Expect(limaCfg.Rosetta.BinFmt).Should(gomega.Equal(false)) + }) }) } diff --git a/e2e/vm/virtualization_framework_rosetta_test.go b/e2e/vm/virtualization_framework_rosetta_test.go new file mode 100644 index 000000000..4d2361a28 --- /dev/null +++ b/e2e/vm/virtualization_framework_rosetta_test.go @@ -0,0 +1,61 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package vm + +import ( + "runtime" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + "github.com/onsi/gomega/gexec" + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/option" + "github.com/runfinch/common-tests/tests" + + finch_cmd "github.com/runfinch/finch/pkg/command" + "github.com/runfinch/finch/pkg/config" +) + +var testVirtualizationFrameworkAndRosetta = func(o *option.Option, installed bool) { + ginkgo.Describe("Virtualization framework", ginkgo.Ordered, func() { + supportsVz, supportsVzErr := config.SupportsVirtualizationFramework(finch_cmd.NewExecCmdCreator()) + gomega.Expect(supportsVzErr).ShouldNot(gomega.HaveOccurred()) + + ginkgo.Describe("Virtualization framework", ginkgo.Ordered, func() { + ginkgo.BeforeAll(func() { + if !supportsVz { + ginkgo.Skip("Skipping because system does not support Virtualization.framework") + } + + resetVM(o, installed) + writeFile(finchConfigFilePath, []byte("memory: 4GiB\ncpus: 6\nvmType: vz\nrosetta: false")) + initCmdSession := command.New(o, virtualMachineRootCmd, "init").WithTimeoutInSeconds(180).Run() + gomega.Expect(initCmdSession).Should(gexec.Exit(0)) + }) + + // Run sanity check tests + tests.Build(o) + tests.Run(&tests.RunOption{BaseOpt: o, CGMode: tests.Unified, DefaultHostGatewayIP: "192.168.5.2"}) + tests.Port(o) + }) + + ginkgo.Describe("Virtualization framework and Rosetta", ginkgo.Ordered, func() { + ginkgo.BeforeAll(func() { + if !supportsVz || runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" { + ginkgo.Skip("Skipping because system does not support Rosetta") + } + + resetVM(o, installed) + writeFile(finchConfigFilePath, []byte("memory: 4GiB\ncpus: 6\nvmType: vz\nrosetta: true")) + initCmdSession := command.New(o, virtualMachineRootCmd, "init").WithTimeoutInSeconds(180).Run() + gomega.Expect(initCmdSession).Should(gexec.Exit(0)) + }) + + // Run sanity check tests + tests.Build(o) + tests.Run(&tests.RunOption{BaseOpt: o, CGMode: tests.Unified, DefaultHostGatewayIP: "192.168.5.2"}) + tests.Port(o) + }) + }) +} diff --git a/e2e/vm/vm_test.go b/e2e/vm/vm_test.go index b938df6e3..b4843c46d 100644 --- a/e2e/vm/vm_test.go +++ b/e2e/vm/vm_test.go @@ -5,11 +5,15 @@ package vm import ( + "os/exec" + "path/filepath" "testing" "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/option" "github.com/runfinch/finch/e2e" ) @@ -33,8 +37,8 @@ func TestVM(t *testing.T) { }, func(bytes []byte) {}) ginkgo.SynchronizedAfterSuite(func() { - command.New(o, "vm", "stop").WithTimeoutInSeconds(90).Run() - command.New(o, "vm", "remove").WithTimeoutInSeconds(60).Run() + command.New(o, "vm", "stop", "-f").WithTimeoutInSeconds(90).Run() + command.New(o, "vm", "remove", "-f").WithTimeoutInSeconds(60).Run() }, func() {}) ginkgo.Describe("", func() { @@ -42,8 +46,37 @@ func TestVM(t *testing.T) { testAdditionalDisk(o) testConfig(o, *e2e.Installed) testVersion(o) + testVirtualizationFrameworkAndRosetta(o, *e2e.Installed) }) gomega.RegisterFailHandler(ginkgo.Fail) ginkgo.RunSpecs(t, description) } + +var resetVM = func(o *option.Option, installed bool) string { + var limaConfigFilePath string + + origFinchCfg := readFile(finchConfigFilePath) + limaConfigFilePath = defaultLimaConfigFilePath + if installed { + path, err := exec.LookPath(e2e.InstalledTestSubject) + gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) + realFinchPath, err := filepath.EvalSymlinks(path) + gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) + limaConfigFilePath = filepath.Join(realFinchPath, "../../lima/data/_config/override.yaml") + } + origLimaCfg := readFile(limaConfigFilePath) + + command.New(o, virtualMachineRootCmd, "stop", "-f").WithoutCheckingExitCode().WithTimeoutInSeconds(90).Run() + command.New(o, virtualMachineRootCmd, "remove", "-f").WithoutCheckingExitCode().WithTimeoutInSeconds(90).Run() + + ginkgo.DeferCleanup(func() { + writeFile(finchConfigFilePath, origFinchCfg) + writeFile(limaConfigFilePath, origLimaCfg) + command.New(o, virtualMachineRootCmd, "stop", "-f").WithoutCheckingExitCode().WithTimeoutInSeconds(90).Run() + command.New(o, virtualMachineRootCmd, "remove", "-f").WithoutCheckingExitCode().WithTimeoutInSeconds(90).Run() + command.New(o, virtualMachineRootCmd, "init").WithoutCheckingExitCode().WithTimeoutInSeconds(600).Run() + }) + + return limaConfigFilePath +} diff --git a/finch.yaml b/finch.yaml index 46276ca4b..f1dcc0f43 100644 --- a/finch.yaml +++ b/finch.yaml @@ -77,10 +77,6 @@ mounts: # 🔵 This file: true (only for "/tmp/lima") writable: true -# Mount type for above mounts, such as "reverse-sshfs" (from sshocker) or "9p" (EXPERIMENTAL, from QEMU’s virtio-9p-pci, aka virtfs) -# 🟢 Builtin default: "reverse-sshfs" -mountType: reverse-sshfs - # Lima disks to attach to the instance. The disks will be accessible from inside the # instance, labeled by name. (e.g. if the disk is named "data", it will be labeled # "lima-data" inside the instance). The disk will be mounted inside the instance at @@ -134,16 +130,14 @@ containerd: # multiple times, e.g. when the host VM is being restarted. # 🟢 Builtin default: null provision: -# Install packages needed for QEMU user-mode emulation -- mode: system - script: | - #!/bin/bash - dnf install -y --setopt=install_weak_deps=False qemu-user-static-aarch64 qemu-user-static-arm qemu-user-static-x86 - mode: boot script: | systemctl stop NetworkManager-wait-online.service systemctl reset-failed NetworkManager-wait-online.service systemctl mask NetworkManager-wait-online.service +- mode: boot + script: | + modprobe virtiofs # # `user` is executed without the root privilege - mode: user script: | @@ -236,4 +230,4 @@ env: # Containerd namespace is used by the lima cidata script # 40-install-containerd.sh. Specifically this variable is defining the # Buildkit Workers Containerd namespace. - CONTAINERD_NAMESPACE: finch \ No newline at end of file + CONTAINERD_NAMESPACE: finch diff --git a/pkg/config/config.go b/pkg/config/config.go index 2eaaac9b8..93d670b4f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -13,10 +13,14 @@ import ( "errors" "fmt" "path" + "strconv" + "strings" + "github.com/lima-vm/lima/pkg/limayaml" "github.com/spf13/afero" "gopkg.in/yaml.v3" + "github.com/runfinch/finch/pkg/command" "github.com/runfinch/finch/pkg/flog" "github.com/runfinch/finch/pkg/fmemory" "github.com/runfinch/finch/pkg/system" @@ -35,6 +39,17 @@ type Finch struct { // For example, if you want to mount a directory into a container, and that directory is not under your home directory, // then you'll need to specify this field to add that directory or any ascendant of it as a work directory. AdditionalDirectories []AdditionalDirectory `yaml:"additional_directories,omitempty"` + // VMType sets which technology to use for Finch's VM. + // Currently supports `qemu` and `vz` (Virtualization.framework). + // Also sets mountType to "virtiofs", instead of the default "reverse-sshfs" + // Requires macOS 13.0 or later. + // This setting will only be applied on vm init. + VMType *limayaml.VMType `yaml:"vmType,omitempty"` + // Use Rosetta 2 when available. Forces vmType to "vz" (Virtualization.framework) if set to `true`. + // Requires macOS 13.0 or later and an Apple Silicon (ARM64) mac. + // Has no effect on systems where Rosetta 2 is not available (Intel/AMD64 macs, or macOS < 13.0). + // This setting will only be applied on vm init. + Rosetta *bool `yaml:"rosetta,omitempty"` } // Nerdctl is a copy from github.com/containerd/nerdctl/cmd/nerdctl/main.go @@ -58,7 +73,7 @@ type Nerdctl struct { // //go:generate mockgen -copyright_file=../../copyright_header -destination=../mocks/pkg_config_lima_config_applier.go -package=mocks -mock_names LimaConfigApplier=LimaConfigApplier . LimaConfigApplier type LimaConfigApplier interface { - Apply() error + Apply(isInit bool) error } // NerdctlConfigApplier applies nerdctl configuration changes. @@ -137,3 +152,28 @@ func Load(fs afero.Fs, cfgPath string, log flog.Logger, systemDeps LoadSystemDep return defCfg, nil } + +// SupportsVirtualizationFramework checks if the user's system supports Virtualization.framework. +func SupportsVirtualizationFramework(cmdCreator command.Creator) (bool, error) { + cmd := cmdCreator.Create("sw_vers", "-productVersion") + out, err := cmd.Output() + if err != nil { + return false, fmt.Errorf("failed to run sw_vers command: %w", err) + } + + splitVer := strings.Split(string(out), ".") + if len(splitVer) == 0 { + return false, fmt.Errorf("unexpected result from string split: %v", splitVer) + } + + majorVersionInt, err := strconv.ParseInt(splitVer[0], 10, 64) + if err != nil { + return false, fmt.Errorf("failed to parse split sw_vers output (%s) into int: %w", splitVer[0], err) + } + + if majorVersionInt >= 13 { + return true, nil + } + + return false, nil +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 7a5be8f42..e6a9439a8 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -41,7 +41,12 @@ cpus: 8 // 12_884_901_888 == 12GiB mem.EXPECT().TotalMemory().Return(uint64(12_884_901_888)) }, - want: &Finch{Memory: pointer.String("4GiB"), CPUs: pointer.Int(8)}, + want: &Finch{ + Memory: pointer.String("4GiB"), + CPUs: pointer.Int(8), + VMType: pointer.String("qemu"), + Rosetta: pointer.Bool(false), + }, wantErr: nil, }, { @@ -52,7 +57,12 @@ cpus: 8 deps.EXPECT().NumCPU().Return(4).Times(2) mem.EXPECT().TotalMemory().Return(uint64(12_884_901_888)).Times(2) }, - want: &Finch{Memory: pointer.String("3GiB"), CPUs: pointer.Int(2)}, + want: &Finch{ + Memory: pointer.String("3GiB"), + CPUs: pointer.Int(2), + VMType: pointer.String("qemu"), + Rosetta: pointer.Bool(false), + }, wantErr: nil, }, { @@ -63,7 +73,12 @@ cpus: 8 deps.EXPECT().NumCPU().Return(4).Times(2) mem.EXPECT().TotalMemory().Return(uint64(12_884_901_888)).Times(1) }, - want: &Finch{Memory: pointer.String("2GiB"), CPUs: pointer.Int(2)}, + want: &Finch{ + Memory: pointer.String("2GiB"), + CPUs: pointer.Int(2), + VMType: pointer.String("qemu"), + Rosetta: pointer.Bool(false), + }, wantErr: nil, }, { @@ -74,7 +89,12 @@ cpus: 8 deps.EXPECT().NumCPU().Return(4).Times(2) mem.EXPECT().TotalMemory().Return(uint64(12_884_901_888)).Times(2) }, - want: &Finch{Memory: pointer.String("3GiB"), CPUs: pointer.Int(2)}, + want: &Finch{ + Memory: pointer.String("3GiB"), + CPUs: pointer.Int(2), + VMType: pointer.String("qemu"), + Rosetta: pointer.Bool(false), + }, wantErr: nil, }, { @@ -85,7 +105,12 @@ cpus: 8 deps.EXPECT().NumCPU().Return(4).Times(1) mem.EXPECT().TotalMemory().Return(uint64(12_884_901_888)).Times(1) }, - want: &Finch{Memory: pointer.String("3GiB"), CPUs: pointer.Int(2)}, + want: &Finch{ + Memory: pointer.String("3GiB"), + CPUs: pointer.Int(2), + VMType: pointer.String("qemu"), + Rosetta: pointer.Bool(false), + }, wantErr: nil, }, { diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index c9ac9bfdb..66c0a9c92 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -38,5 +38,13 @@ func applyDefaults(cfg *Finch, deps LoadSystemDeps, mem fmemory.Memory) *Finch { } } + if cfg.VMType == nil { + cfg.VMType = pointer.String("qemu") + } + + if cfg.Rosetta == nil { + cfg.Rosetta = pointer.Bool(false) + } + return cfg } diff --git a/pkg/config/defaults_test.go b/pkg/config/defaults_test.go index 2500399d5..23235f96a 100644 --- a/pkg/config/defaults_test.go +++ b/pkg/config/defaults_test.go @@ -31,8 +31,10 @@ func Test_applyDefaults(t *testing.T) { mem.EXPECT().TotalMemory().Return(uint64(12_884_901_888)) }, want: &Finch{ - CPUs: pointer.Int(2), - Memory: pointer.String("3GiB"), + CPUs: pointer.Int(2), + Memory: pointer.String("3GiB"), + VMType: pointer.String("qemu"), + Rosetta: pointer.Bool(false), }, }, { @@ -44,8 +46,10 @@ func Test_applyDefaults(t *testing.T) { deps.EXPECT().NumCPU().Return(8) }, want: &Finch{ - CPUs: pointer.Int(2), - Memory: pointer.String("4GiB"), + CPUs: pointer.Int(2), + Memory: pointer.String("4GiB"), + VMType: pointer.String("qemu"), + Rosetta: pointer.Bool(false), }, }, { @@ -58,8 +62,10 @@ func Test_applyDefaults(t *testing.T) { mem.EXPECT().TotalMemory().Return(uint64(12_884_901_888)) }, want: &Finch{ - CPUs: pointer.Int(6), - Memory: pointer.String("3GiB"), + CPUs: pointer.Int(6), + Memory: pointer.String("3GiB"), + VMType: pointer.String("qemu"), + Rosetta: pointer.Bool(false), }, }, { @@ -71,8 +77,10 @@ func Test_applyDefaults(t *testing.T) { mem.EXPECT().TotalMemory().Return(uint64(1_073_741_824)) }, want: &Finch{ - CPUs: pointer.Int(2), - Memory: pointer.String("2GiB"), + CPUs: pointer.Int(2), + Memory: pointer.String("2GiB"), + VMType: pointer.String("qemu"), + Rosetta: pointer.Bool(false), }, }, } diff --git a/pkg/config/lima_config_applier.go b/pkg/config/lima_config_applier.go index 7180ea0c7..4cfdc21cf 100644 --- a/pkg/config/lima_config_applier.go +++ b/pkg/config/lima_config_applier.go @@ -5,33 +5,66 @@ package config import ( "fmt" + "strings" "github.com/lima-vm/lima/pkg/limayaml" "github.com/spf13/afero" "github.com/xorcare/pointer" "gopkg.in/yaml.v3" + + "github.com/runfinch/finch/pkg/command" + "github.com/runfinch/finch/pkg/system" ) +const userModeEmulationProvisioningScriptHeader = "# cross-arch tools" + +// LimaConfigApplierSystemDeps contains the system dependencies for LimaConfigApplier. +// +//go:generate mockgen -copyright_file=../../copyright_header -destination=../mocks/pkg_config_lima_config_applier_system_deps.go -package=mocks -mock_names LimaConfigApplierSystemDeps=LimaConfigApplierSystemDeps . LimaConfigApplierSystemDeps +type LimaConfigApplierSystemDeps interface { + system.RuntimeArchGetter + system.RuntimeOSGetter +} + type limaConfigApplier struct { cfg *Finch + cmdCreator command.Creator fs afero.Fs limaConfigPath string + systemDeps LimaConfigApplierSystemDeps } var _ LimaConfigApplier = (*limaConfigApplier)(nil) // NewLimaApplier creates a new LimaConfigApplier that // applies lima configuration changes by writing to the lima config file on the disk. -func NewLimaApplier(cfg *Finch, fs afero.Fs, limaConfigPath string) LimaConfigApplier { +func NewLimaApplier( + cfg *Finch, + cmdCreator command.Creator, + fs afero.Fs, + limaConfigPath string, + systemDeps LimaConfigApplierSystemDeps, +) LimaConfigApplier { return &limaConfigApplier{ cfg: cfg, + cmdCreator: cmdCreator, fs: fs, limaConfigPath: limaConfigPath, + systemDeps: systemDeps, } } -// Apply reads the Finch config from disk and writes the Lima-related portion to the lima config file. -func (lca *limaConfigApplier) Apply() error { +// Apply writes Lima-specific config values from Finch's config to the supplied lima config file path. +// Apply will create a lima config file at the path if it does not exist. +func (lca *limaConfigApplier) Apply(isInit bool) error { + if cfgExists, err := afero.Exists(lca.fs, lca.limaConfigPath); err != nil { + return fmt.Errorf("error checking if file at path %s exists, error: %w", lca.limaConfigPath, err) + } else if !cfgExists { + if err := afero.WriteFile(lca.fs, lca.limaConfigPath, []byte(""), 0o644); err != nil { + return fmt.Errorf("failed to create the an empty lima config file: %w", err) + } + } + b, err := afero.ReadFile(lca.fs, lca.limaConfigPath) if err != nil { return fmt.Errorf("failed to load the lima config file: %w", err) @@ -51,6 +84,14 @@ func (lca *limaConfigApplier) Apply() error { }) } + if isInit { + cfgAfterInit, err := lca.applyInit(&limaCfg) + if err != nil { + return fmt.Errorf("failed to apply init-only config values: %w", err) + } + limaCfg = *cfgAfterInit + } + limaCfgBytes, err := yaml.Marshal(limaCfg) if err != nil { return fmt.Errorf("failed to marshal the lima config file: %w", err) @@ -62,3 +103,72 @@ func (lca *limaConfigApplier) Apply() error { return nil } + +// applyInit changes settings that will only apply to the VM after a new init. +func (lca *limaConfigApplier) applyInit(limaCfg *limayaml.LimaYAML) (*limayaml.LimaYAML, error) { + hasSupport, hasSupportErr := SupportsVirtualizationFramework(lca.cmdCreator) + if *lca.cfg.Rosetta && + lca.systemDeps.OS() == "darwin" && + lca.systemDeps.Arch() == "arm64" { + if hasSupportErr != nil { + return nil, fmt.Errorf("failed to check for virtualization framework support: %w", hasSupportErr) + } + if !hasSupport { + return nil, fmt.Errorf(`system does not have virtualization framework support, change vmType to "qemu"`) + } + + limaCfg.Rosetta.Enabled = true + limaCfg.Rosetta.BinFmt = true + limaCfg.VMType = pointer.String("vz") + limaCfg.MountType = pointer.String("virtiofs") + toggleUserModeEmulationInstallationScript(limaCfg, false) + } else { + if *lca.cfg.VMType == "vz" { + if hasSupportErr != nil { + return nil, fmt.Errorf("failed to check for virtualization framework support: %w", hasSupportErr) + } + if !hasSupport { + return nil, fmt.Errorf(`system does not have virtualization framework support, change vmType to "qemu"`) + } + limaCfg.MountType = pointer.String("virtiofs") + } else if *lca.cfg.VMType == "qemu" { + limaCfg.MountType = pointer.String("reverse-sshfs") + } + limaCfg.Rosetta = limayaml.Rosetta{} + limaCfg.VMType = lca.cfg.VMType + toggleUserModeEmulationInstallationScript(limaCfg, true) + } + + return limaCfg, nil +} + +func toggleUserModeEmulationInstallationScript(limaCfg *limayaml.LimaYAML, enabled bool) { + idx, hasScript := hasUserModeEmulationInstallationScript(limaCfg) + if !hasScript && enabled { + limaCfg.Provision = append(limaCfg.Provision, limayaml.Provision{ + Mode: "system", + Script: fmt.Sprintf(`%s +#!/bin/bash +dnf install -y --setopt=install_weak_deps=False qemu-user-static-aarch64 qemu-user-static-arm qemu-user-static-x86 +`, userModeEmulationProvisioningScriptHeader), + }) + } else if hasScript && !enabled { + if len(limaCfg.Provision) > 0 { + limaCfg.Provision = append(limaCfg.Provision[:idx], limaCfg.Provision[idx+1:]...) + } + } +} + +func hasUserModeEmulationInstallationScript(limaCfg *limayaml.LimaYAML) (int, bool) { + hasCrossArchToolInstallationScript := false + var scriptIdx int + for idx, prov := range limaCfg.Provision { + trimmed := strings.Trim(prov.Script, " ") + if !hasCrossArchToolInstallationScript && strings.HasPrefix(trimmed, userModeEmulationProvisioningScriptHeader) { + hasCrossArchToolInstallationScript = true + scriptIdx = idx + } + } + + return scriptIdx, hasCrossArchToolInstallationScript +} diff --git a/pkg/config/lima_config_applier_test.go b/pkg/config/lima_config_applier_test.go index ad8940f57..20d26e063 100644 --- a/pkg/config/lima_config_applier_test.go +++ b/pkg/config/lima_config_applier_test.go @@ -4,9 +4,7 @@ package config import ( - "errors" "fmt" - "io/fs" "testing" "github.com/golang/mock/gomock" @@ -23,23 +21,41 @@ func TestDiskLimaConfigApplier_Apply(t *testing.T) { t.Parallel() testCases := []struct { - name string - config *Finch - path string - mockSvc func(fs afero.Fs, l *mocks.Logger) + name string + config *Finch + path string + isInit bool + mockSvc func( + fs afero.Fs, + l *mocks.Logger, + cmd *mocks.Command, + creator *mocks.CommandCreator, + deps *mocks.LimaConfigApplierSystemDeps, + ) postRunCheck func(t *testing.T, fs afero.Fs) want error }{ { name: "happy path", config: &Finch{ - Memory: pointer.String("2GiB"), - CPUs: pointer.Int(4), + Memory: pointer.String("2GiB"), + CPUs: pointer.Int(4), + VMType: pointer.String("qemu"), + Rosetta: pointer.Bool(false), }, - path: "/lima.yaml", - mockSvc: func(fs afero.Fs, l *mocks.Logger) { + path: "/lima.yaml", + isInit: true, + mockSvc: func( + fs afero.Fs, + l *mocks.Logger, + cmd *mocks.Command, + creator *mocks.CommandCreator, + deps *mocks.LimaConfigApplierSystemDeps, + ) { err := afero.WriteFile(fs, "/lima.yaml", []byte("memory: 4GiB\ncpus: 8"), 0o600) require.NoError(t, err) + cmd.EXPECT().Output().Return([]byte("13.0.0"), nil) + creator.EXPECT().Create("sw_vers", "-productVersion").Return(cmd) }, postRunCheck: func(t *testing.T, fs afero.Fs) { buf, err := afero.ReadFile(fs, "/lima.yaml") @@ -50,27 +66,215 @@ func TestDiskLimaConfigApplier_Apply(t *testing.T) { require.NoError(t, err) require.Equal(t, 4, *limaCfg.CPUs) require.Equal(t, "2GiB", *limaCfg.Memory) + require.Equal(t, "reverse-sshfs", *limaCfg.MountType) + require.Equal(t, "system", limaCfg.Provision[0].Mode) + require.Equal(t, `# cross-arch tools +#!/bin/bash +dnf install -y --setopt=install_weak_deps=False qemu-user-static-aarch64 qemu-user-static-arm qemu-user-static-x86 +`, limaCfg.Provision[0].Script) }, want: nil, }, { - name: "lima config file does not exist", - config: nil, - path: "/lima.yaml", - mockSvc: func(afs afero.Fs, l *mocks.Logger) {}, - postRunCheck: func(t *testing.T, mFs afero.Fs) { - _, err := afero.ReadFile(mFs, "/lima.yaml") - require.Equal(t, err, &fs.PathError{Op: "open", Path: "/lima.yaml", Err: errors.New("file does not exist")}) - }, - want: fmt.Errorf("failed to load the lima config file: %w", - &fs.PathError{Op: "open", Path: "/lima.yaml", Err: errors.New("file does not exist")}, - ), + name: "updates vmType and removes cross-arch provisioning script and network config", + config: &Finch{ + Memory: pointer.String("2GiB"), + CPUs: pointer.Int(4), + VMType: pointer.String("vz"), + Rosetta: pointer.Bool(true), + }, + path: "/lima.yaml", + isInit: true, + mockSvc: func( + fs afero.Fs, + l *mocks.Logger, + cmd *mocks.Command, + creator *mocks.CommandCreator, + deps *mocks.LimaConfigApplierSystemDeps, + ) { + err := afero.WriteFile(fs, "/lima.yaml", []byte(`memory: 4GiB +cpus: 8 +vmType: "qemu" +provision: +- mode: system + script: | + # cross-arch tools + #!/bin/bash + dnf install -y --setopt=install_weak_deps=False qemu-user-static-aarch64 qemu-user-static-arm qemu-user-static-x86 +`), 0o600) + require.NoError(t, err) + cmd.EXPECT().Output().Return([]byte("13.0.0"), nil) + creator.EXPECT().Create("sw_vers", "-productVersion").Return(cmd) + deps.EXPECT().OS().Return("darwin") + deps.EXPECT().Arch().Return("arm64") + }, + postRunCheck: func(t *testing.T, fs afero.Fs) { + buf, err := afero.ReadFile(fs, "/lima.yaml") + require.NoError(t, err) + + var limaCfg limayaml.LimaYAML + err = yaml.Unmarshal(buf, &limaCfg) + require.NoError(t, err) + require.Equal(t, 4, *limaCfg.CPUs) + require.Equal(t, "2GiB", *limaCfg.Memory) + require.Equal(t, "vz", *limaCfg.VMType) + require.Equal(t, "virtiofs", *limaCfg.MountType) + require.Equal(t, true, limaCfg.Rosetta.Enabled) + require.Equal(t, true, limaCfg.Rosetta.BinFmt) + require.Len(t, limaCfg.Provision, 0) + }, + want: nil, + }, + { + name: "updates vmType from vz to qemu and adds cross-arch provisioning script", + config: &Finch{ + Memory: pointer.String("2GiB"), + CPUs: pointer.Int(4), + VMType: pointer.String("qemu"), + Rosetta: pointer.Bool(false), + }, + path: "/lima.yaml", + isInit: true, + mockSvc: func( + fs afero.Fs, + l *mocks.Logger, + cmd *mocks.Command, + creator *mocks.CommandCreator, + deps *mocks.LimaConfigApplierSystemDeps, + ) { + err := afero.WriteFile(fs, "/lima.yaml", []byte(`memory: 4GiB +cpus: 8 +vmType: "vz" +rosetta: + enabled: true + binfmt: true +`), 0o600) + require.NoError(t, err) + cmd.EXPECT().Output().Return([]byte("13.0.0"), nil) + creator.EXPECT().Create("sw_vers", "-productVersion").Return(cmd) + }, + postRunCheck: func(t *testing.T, fs afero.Fs) { + buf, err := afero.ReadFile(fs, "/lima.yaml") + require.NoError(t, err) + + var limaCfg limayaml.LimaYAML + err = yaml.Unmarshal(buf, &limaCfg) + require.NoError(t, err) + require.Equal(t, 4, *limaCfg.CPUs) + require.Equal(t, "2GiB", *limaCfg.Memory) + require.Equal(t, "qemu", *limaCfg.VMType) + require.Equal(t, false, limaCfg.Rosetta.Enabled) + require.Equal(t, false, limaCfg.Rosetta.BinFmt) + require.Equal(t, "reverse-sshfs", *limaCfg.MountType) + require.Equal(t, "system", limaCfg.Provision[0].Mode) + require.Equal(t, `# cross-arch tools +#!/bin/bash +dnf install -y --setopt=install_weak_deps=False qemu-user-static-aarch64 qemu-user-static-arm qemu-user-static-x86 +`, limaCfg.Provision[0].Script) + }, + want: nil, + }, + { + name: "does not update lima config because isInit == false", + config: &Finch{ + Memory: pointer.String("2GiB"), + CPUs: pointer.Int(4), + VMType: pointer.String("vz"), + Rosetta: pointer.Bool(false), + }, + path: "/lima.yaml", + isInit: false, + mockSvc: func( + fs afero.Fs, + l *mocks.Logger, + cmd *mocks.Command, + creator *mocks.CommandCreator, + deps *mocks.LimaConfigApplierSystemDeps, + ) { + err := afero.WriteFile(fs, "/lima.yaml", []byte(`memory: 4GiB +cpus: 8 +vmType: "qemu" +mountType: "reverse-sshfs" +provision: +- mode: system + script: | + # cross-arch tools + #!/bin/bash + dnf install -y --setopt=install_weak_deps=False qemu-user-static-aarch64 qemu-user-static-arm qemu-user-static-x86 +`), 0o600) + require.NoError(t, err) + }, + postRunCheck: func(t *testing.T, fs afero.Fs) { + buf, err := afero.ReadFile(fs, "/lima.yaml") + require.NoError(t, err) + + var limaCfg limayaml.LimaYAML + err = yaml.Unmarshal(buf, &limaCfg) + require.NoError(t, err) + require.Equal(t, 4, *limaCfg.CPUs) + require.Equal(t, "2GiB", *limaCfg.Memory) + require.Equal(t, "qemu", *limaCfg.VMType) + require.Equal(t, "reverse-sshfs", *limaCfg.MountType) + require.Equal(t, "system", limaCfg.Provision[0].Mode) + require.Equal(t, `# cross-arch tools +#!/bin/bash +dnf install -y --setopt=install_weak_deps=False qemu-user-static-aarch64 qemu-user-static-arm qemu-user-static-x86 +`, limaCfg.Provision[0].Script) + }, + want: nil, + }, + { + name: "lima config file does not exist", + config: &Finch{ + Memory: pointer.String("2GiB"), + CPUs: pointer.Int(4), + VMType: pointer.String("qemu"), + Rosetta: pointer.Bool(false), + }, + path: "/lima.yaml", + isInit: true, + mockSvc: func( + fs afero.Fs, + l *mocks.Logger, + cmd *mocks.Command, + creator *mocks.CommandCreator, + deps *mocks.LimaConfigApplierSystemDeps, + ) { + err := afero.WriteFile(fs, "/lima.yaml", []byte("memory: 4GiB\ncpus: 8"), 0o600) + require.NoError(t, err) + cmd.EXPECT().Output().Return([]byte("13.0.0"), nil) + creator.EXPECT().Create("sw_vers", "-productVersion").Return(cmd) + }, + postRunCheck: func(t *testing.T, fs afero.Fs) { + buf, err := afero.ReadFile(fs, "/lima.yaml") + require.NoError(t, err) + + var limaCfg limayaml.LimaYAML + err = yaml.Unmarshal(buf, &limaCfg) + require.NoError(t, err) + require.Equal(t, 4, *limaCfg.CPUs) + require.Equal(t, "2GiB", *limaCfg.Memory) + require.Equal(t, "reverse-sshfs", *limaCfg.MountType) + require.Equal(t, "system", limaCfg.Provision[0].Mode) + require.Equal(t, `# cross-arch tools +#!/bin/bash +dnf install -y --setopt=install_weak_deps=False qemu-user-static-aarch64 qemu-user-static-arm qemu-user-static-x86 +`, limaCfg.Provision[0].Script) + }, + want: nil, }, { name: "lima config file does not contain valid YAML", config: nil, path: "/lima.yaml", - mockSvc: func(fs afero.Fs, l *mocks.Logger) { + isInit: true, + mockSvc: func( + fs afero.Fs, + l *mocks.Logger, + cmd *mocks.Command, + creator *mocks.CommandCreator, + deps *mocks.LimaConfigApplierSystemDeps, + ) { err := afero.WriteFile(fs, "/lima.yaml", []byte("this isn't YAML"), 0o600) require.NoError(t, err) }, @@ -91,33 +295,41 @@ func TestDiskLimaConfigApplier_Apply(t *testing.T) { Memory: pointer.String("2GiB"), CPUs: pointer.Int(4), AdditionalDirectories: []AdditionalDirectory{{pointer.String("/Volumes")}}, + VMType: pointer.String("qemu"), + Rosetta: pointer.Bool(false), }, - path: "/lima.yaml", - mockSvc: func(fs afero.Fs, l *mocks.Logger) { + path: "/lima.yaml", + isInit: true, + mockSvc: func( + fs afero.Fs, + l *mocks.Logger, + cmd *mocks.Command, + creator *mocks.CommandCreator, + deps *mocks.LimaConfigApplierSystemDeps, + ) { err := afero.WriteFile(fs, "/lima.yaml", []byte("memory: 4GiB\ncpus: 8"), 0o600) require.NoError(t, err) + cmd.EXPECT().Output().Return([]byte("13.0.0"), nil) + creator.EXPECT().Create("sw_vers", "-productVersion").Return(cmd) }, postRunCheck: func(t *testing.T, fs afero.Fs) { buf, err := afero.ReadFile(fs, "/lima.yaml") require.NoError(t, err) - // limayaml.LimaYAML has a required "images" field which will also get marshaled - wantYaml := `images: [] -cpus: 4 -memory: 2GiB -mounts: - - location: /Volumes - writable: true -` - require.Equal(t, wantYaml, string(buf)) var limaCfg limayaml.LimaYAML err = yaml.Unmarshal(buf, &limaCfg) require.NoError(t, err) require.Equal(t, 4, *limaCfg.CPUs) require.Equal(t, "2GiB", *limaCfg.Memory) + require.Equal(t, "reverse-sshfs", *limaCfg.MountType) require.Equal(t, 1, len(limaCfg.Mounts)) require.Equal(t, "/Volumes", limaCfg.Mounts[0].Location) require.Equal(t, true, *limaCfg.Mounts[0].Writable) + require.Equal(t, "system", limaCfg.Provision[0].Mode) + require.Equal(t, `# cross-arch tools +#!/bin/bash +dnf install -y --setopt=install_weak_deps=False qemu-user-static-aarch64 qemu-user-static-arm qemu-user-static-x86 +`, limaCfg.Provision[0].Script) }, want: nil, }, @@ -129,11 +341,14 @@ mounts: t.Parallel() ctrl := gomock.NewController(t) + cmd := mocks.NewCommand(ctrl) + cmdCreator := mocks.NewCommandCreator(ctrl) + deps := mocks.NewLimaConfigApplierSystemDeps(ctrl) l := mocks.NewLogger(ctrl) fs := afero.NewMemMapFs() - tc.mockSvc(fs, l) - got := NewLimaApplier(tc.config, fs, tc.path).Apply() + tc.mockSvc(fs, l, cmd, cmdCreator, deps) + got := NewLimaApplier(tc.config, cmdCreator, fs, tc.path, deps).Apply(tc.isInit) require.Equal(t, tc.want, got) tc.postRunCheck(t, fs) diff --git a/pkg/dependency/vmnet/vmnet_test.go b/pkg/dependency/vmnet/vmnet_test.go index c30e0844a..5f176ab04 100644 --- a/pkg/dependency/vmnet/vmnet_test.go +++ b/pkg/dependency/vmnet/vmnet_test.go @@ -27,5 +27,4 @@ func Test_newDeps(t *testing.T) { require.Equal(t, 3, len(got)) assert.IsType(t, (*binaries)(nil), got[0]) assert.IsType(t, (*sudoersFile)(nil), got[1]) - assert.IsType(t, (*overrideLimaConfig)(nil), got[2]) } diff --git a/pkg/disk/disk.go b/pkg/disk/disk.go index d7a2d6f31..553c260a0 100644 --- a/pkg/disk/disk.go +++ b/pkg/disk/disk.go @@ -9,13 +9,13 @@ import ( "errors" "fmt" "io/fs" - "os" "path" limaStore "github.com/lima-vm/lima/pkg/store" "github.com/spf13/afero" "github.com/runfinch/finch/pkg/command" + "github.com/runfinch/finch/pkg/config" fpath "github.com/runfinch/finch/pkg/path" ) @@ -30,6 +30,14 @@ type UserDataDiskManager interface { EnsureUserDataDisk() error } +type qemuDiskInfo struct { + VirtualSize int `json:"virtual-size"` + Filename string `json:"filename"` + Format string `json:"format"` + ActualSize int `json:"actual-size"` + DirtyFlag bool `json:"dirty-flag"` +} + // fs functions required for setting up the user data disk. type diskFS interface { afero.Fs @@ -39,39 +47,68 @@ type diskFS interface { type userDataDiskManager struct { lcc command.LimaCmdCreator + ecc command.Creator fs diskFS finch fpath.Finch homeDir string + config *config.Finch } // NewUserDataDiskManager is a constructor for UserDataDiskManager. func NewUserDataDiskManager( lcc command.LimaCmdCreator, + ecc command.Creator, fs diskFS, finch fpath.Finch, homeDir string, + config *config.Finch, ) UserDataDiskManager { return &userDataDiskManager{ lcc: lcc, + ecc: ecc, fs: fs, finch: finch, homeDir: homeDir, + config: config, } } // EnsureUserDataDisk checks the current disk configuration and fixes it if needed. func (m *userDataDiskManager) EnsureUserDataDisk() error { if m.limaDiskExists() { + diskPath := m.finch.UserDataDiskPath(m.homeDir) + + if *m.config.VMType == "vz" { + info, err := m.getDiskInfo(diskPath) + if err != nil { + return err + } + + // Convert the persistent disk file to RAW before Lima starts. + // Lima also does this, but since Finch uses a symlink to this file, lima won't create the new RAW file + // in the persistent location, but in its own _disks directory. + if info.Format != "raw" { + if err := m.convertToRaw(diskPath); err != nil { + return err + } + + // since convertToRaw moves the disk, the symlink needs to be recreated + if err := m.attachPersistentDiskToLimaDisk(); err != nil { + return err + } + } + } + + // if the file is not a symlink, loc will be an empty string + // both os.Readlink() and UserDataDiskPath return absolute paths, so they will be equal if equivalent limaPath := fmt.Sprintf("%s/_disks/%s/datadisk", m.finch.LimaHomePath(), diskName) loc, err := m.fs.ReadlinkIfPossible(limaPath) if err != nil { return err } - // if the file is not a symlink, loc will be an empty string - // both os.Readlink() and UserDataDiskPath return absolute paths, so they will be equal if equivalent - if loc != m.finch.UserDataDiskPath(m.homeDir) { - err := m.attachPersistentDiskToLimaDisk() - if err != nil { + + if loc != diskPath { + if err := m.attachPersistentDiskToLimaDisk(); err != nil { return err } } @@ -79,8 +116,7 @@ func (m *userDataDiskManager) EnsureUserDataDisk() error { if err := m.createLimaDisk(); err != nil { return err } - err := m.attachPersistentDiskToLimaDisk() - if err != nil { + if err := m.attachPersistentDiskToLimaDisk(); err != nil { return err } } @@ -114,8 +150,48 @@ func (m *userDataDiskManager) limaDiskExists() bool { return diskListOutput.Name == diskName } +func (m *userDataDiskManager) getDiskInfo(diskPath string) (*qemuDiskInfo, error) { + out, err := m.ecc.Create( + path.Join(m.finch.QEMUBinDir(), "qemu-img"), + "info", + "--output=json", + diskPath, + ).CombinedOutput() + if err != nil { + return nil, fmt.Errorf("failed to get disk info for disk at %q: %w", diskPath, err) + } + + var diskInfoJSON qemuDiskInfo + if err = json.Unmarshal(out, &diskInfoJSON); err != nil { + return nil, fmt.Errorf("failed to unmarshal disk info JSON for disk at %q: %w", diskPath, err) + } + + return &diskInfoJSON, nil +} + +func (m *userDataDiskManager) convertToRaw(diskPath string) error { + qcowPath := fmt.Sprintf("%s.qcow2", diskPath) + if err := m.fs.Rename(diskPath, qcowPath); err != nil { + return fmt.Errorf("faied to rename disk: %w", err) + } + if _, err := m.ecc.Create( + path.Join(m.finch.QEMUBinDir(), "qemu-img"), + "convert", + "-f", + "qcow2", + "-O", + "raw", + qcowPath, + diskPath, + ).CombinedOutput(); err != nil { + return fmt.Errorf("failed to convert disk %q from qcow2 to raw: %w", diskPath, err) + } + + return nil +} + func (m *userDataDiskManager) createLimaDisk() error { - cmd := m.lcc.CreateWithoutStdio("disk", "create", diskName, "--size", diskSize) + cmd := m.lcc.CreateWithoutStdio("disk", "create", diskName, "--size", diskSize, "--format", "raw") if logs, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to create disk, debug logs:\n%s", logs) } @@ -127,15 +203,13 @@ func (m *userDataDiskManager) attachPersistentDiskToLimaDisk() error { if !m.persistentDiskExists() { disksDir := path.Dir(m.finch.UserDataDiskPath(m.homeDir)) _, err := m.fs.Stat(disksDir) - if os.IsNotExist(err) { - err := m.fs.MkdirAll(disksDir, 0o755) - if err != nil { + if errors.Is(err, fs.ErrNotExist) { + if err := m.fs.MkdirAll(disksDir, 0o755); err != nil { return fmt.Errorf("could not create persistent disk directory: %w", err) } } - err = m.fs.Rename(limaPath, m.finch.UserDataDiskPath(m.homeDir)) - if err != nil { - return err + if err = m.fs.Rename(limaPath, m.finch.UserDataDiskPath(m.homeDir)); err != nil { + return fmt.Errorf("could not move data disk to persistent path: %w", err) } } diff --git a/pkg/disk/disk_test.go b/pkg/disk/disk_test.go index fbf4cdf68..43dfefcf6 100644 --- a/pkg/disk/disk_test.go +++ b/pkg/disk/disk_test.go @@ -4,13 +4,16 @@ package disk import ( + "fmt" "io/fs" "path" "testing" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" + "github.com/xorcare/pointer" + "github.com/runfinch/finch/pkg/config" "github.com/runfinch/finch/pkg/mocks" fpath "github.com/runfinch/finch/pkg/path" ) @@ -20,11 +23,12 @@ func TestDisk_NewUserDataDiskManager(t *testing.T) { ctrl := gomock.NewController(t) lcc := mocks.NewLimaCmdCreator(ctrl) + ecc := mocks.NewCommandCreator(ctrl) dfs := mocks.NewMockdiskFS(ctrl) finch := fpath.Finch("mock_finch") homeDir := "mock_home" - NewUserDataDiskManager(lcc, dfs, finch, homeDir) + NewUserDataDiskManager(lcc, ecc, dfs, finch, homeDir, &config.Finch{}) } func TestUserDataDiskManager_InitializeUserDataDisk(t *testing.T) { @@ -36,21 +40,51 @@ func TestUserDataDiskManager_InitializeUserDataDisk(t *testing.T) { limaPath := path.Join(finch.LimaHomePath(), "_disks", diskName, "datadisk") lockPath := path.Join(finch.LimaHomePath(), "_disks", diskName, "in_use_by") mockListArgs := []string{"disk", "ls", diskName, "--json"} - mockCreateArgs := []string{"disk", "create", diskName, "--size", diskSize} + mockCreateArgs := []string{"disk", "create", diskName, "--size", diskSize, "--format", "raw"} mockUnlockArgs := []string{"disk", "unlock", diskName} + mockQemuImgExePath := "mock_finch/lima/bin/qemu-img" + mockDiskInfoArgs := []string{ + "info", + "--output=json", + finch.UserDataDiskPath(homeDir), + } + mockQemuBackupDiskPath := fmt.Sprintf("%s.qcow2", finch.UserDataDiskPath(homeDir)) + mockDiskConvertArgs := []string{ + "convert", + "-f", + "qcow2", + "-O", + "raw", + mockQemuBackupDiskPath, + finch.UserDataDiskPath(homeDir), + } //nolint:lll // line cannot be shortened without losing functionality listSuccessOutput := []byte("{\"name\":\"finch\",\"size\":5,\"dir\":\"mock_dir\",\"instance\":\"\",\"instanceDir\":\"\",\"mountPoint\":\"/mnt/lima-finch\"}") + diskInfoQCOW2SuccessOutput := []byte(` +{ + "virtual-size": 53687091200, + "filename": "mock_home/.finch/.disks/datadisk", + "format": "qcow2", + "actual-size": 6107136, + "dirty-flag": false +} +`) + testCases := []struct { name string + cfg *config.Finch wantErr error - mockSvc func(*mocks.LimaCmdCreator, *mocks.MockdiskFS, *mocks.Command) + mockSvc func(lcc *mocks.LimaCmdCreator, dfs *mocks.MockdiskFS, cmd *mocks.Command, ecc *mocks.CommandCreator) }{ { - name: "create and save disk", + name: "create and save disk", + cfg: &config.Finch{ + VMType: pointer.String("qemu"), + }, wantErr: nil, - mockSvc: func(lcc *mocks.LimaCmdCreator, dfs *mocks.MockdiskFS, cmd *mocks.Command) { + mockSvc: func(lcc *mocks.LimaCmdCreator, dfs *mocks.MockdiskFS, cmd *mocks.Command, ecc *mocks.CommandCreator) { lcc.EXPECT().CreateWithoutStdio(mockListArgs).Return(cmd) cmd.EXPECT().Output().Return([]byte(""), nil) @@ -68,9 +102,12 @@ func TestUserDataDiskManager_InitializeUserDataDisk(t *testing.T) { }, }, { - name: "disk already exists", + name: "disk already exists", + cfg: &config.Finch{ + VMType: pointer.String("qemu"), + }, wantErr: nil, - mockSvc: func(lcc *mocks.LimaCmdCreator, dfs *mocks.MockdiskFS, cmd *mocks.Command) { + mockSvc: func(lcc *mocks.LimaCmdCreator, dfs *mocks.MockdiskFS, cmd *mocks.Command, ecc *mocks.CommandCreator) { lcc.EXPECT().CreateWithoutStdio(mockListArgs).Return(cmd) cmd.EXPECT().Output().Return(listSuccessOutput, nil) @@ -80,9 +117,12 @@ func TestUserDataDiskManager_InitializeUserDataDisk(t *testing.T) { }, }, { - name: "disk exists but has not been saved", + name: "disk exists but has not been saved", + cfg: &config.Finch{ + VMType: pointer.String("qemu"), + }, wantErr: nil, - mockSvc: func(lcc *mocks.LimaCmdCreator, dfs *mocks.MockdiskFS, cmd *mocks.Command) { + mockSvc: func(lcc *mocks.LimaCmdCreator, dfs *mocks.MockdiskFS, cmd *mocks.Command, ecc *mocks.CommandCreator) { lcc.EXPECT().CreateWithoutStdio(mockListArgs).Return(cmd) cmd.EXPECT().Output().Return(listSuccessOutput, nil) @@ -100,9 +140,12 @@ func TestUserDataDiskManager_InitializeUserDataDisk(t *testing.T) { }, }, { - name: "disk does not exist but a persistent disk does", + name: "disk does not exist but a persistent disk does", + cfg: &config.Finch{ + VMType: pointer.String("qemu"), + }, wantErr: nil, - mockSvc: func(lcc *mocks.LimaCmdCreator, dfs *mocks.MockdiskFS, cmd *mocks.Command) { + mockSvc: func(lcc *mocks.LimaCmdCreator, dfs *mocks.MockdiskFS, cmd *mocks.Command, ecc *mocks.CommandCreator) { lcc.EXPECT().CreateWithoutStdio(mockListArgs).Return(cmd) cmd.EXPECT().Output().Return([]byte(""), nil) @@ -120,9 +163,12 @@ func TestUserDataDiskManager_InitializeUserDataDisk(t *testing.T) { }, }, { - name: "disk already exists but is locked", + name: "disk already exists but is locked", + cfg: &config.Finch{ + VMType: pointer.String("qemu"), + }, wantErr: nil, - mockSvc: func(lcc *mocks.LimaCmdCreator, dfs *mocks.MockdiskFS, cmd *mocks.Command) { + mockSvc: func(lcc *mocks.LimaCmdCreator, dfs *mocks.MockdiskFS, cmd *mocks.Command, ecc *mocks.CommandCreator) { lcc.EXPECT().CreateWithoutStdio(mockListArgs).Return(cmd) cmd.EXPECT().Output().Return(listSuccessOutput, nil) @@ -133,6 +179,36 @@ func TestUserDataDiskManager_InitializeUserDataDisk(t *testing.T) { cmd.EXPECT().CombinedOutput().Return(nil, nil) }, }, + { + name: "disk exists and using vz mode, but disk is the wrong format", + cfg: &config.Finch{ + VMType: pointer.String("vz"), + }, + wantErr: nil, + mockSvc: func(lcc *mocks.LimaCmdCreator, dfs *mocks.MockdiskFS, cmd *mocks.Command, ecc *mocks.CommandCreator) { + lcc.EXPECT().CreateWithoutStdio(mockListArgs).Return(cmd) + cmd.EXPECT().Output().Return(listSuccessOutput, nil) + + ecc.EXPECT().Create(mockQemuImgExePath, mockDiskInfoArgs).Return(cmd) + cmd.EXPECT().CombinedOutput().Return(diskInfoQCOW2SuccessOutput, nil) + + dfs.EXPECT().Rename(finch.UserDataDiskPath(homeDir), mockQemuBackupDiskPath).Return(nil) + + ecc.EXPECT().Create(mockQemuImgExePath, mockDiskConvertArgs).Return(cmd) + cmd.EXPECT().CombinedOutput().Return([]byte(""), nil) + + dfs.EXPECT().Stat(finch.UserDataDiskPath(homeDir)).Return(nil, fs.ErrNotExist) + dfs.EXPECT().Stat(path.Dir(finch.UserDataDiskPath(homeDir))).Return(nil, nil) + dfs.EXPECT().Rename(limaPath, finch.UserDataDiskPath(homeDir)).Return(nil) + + dfs.EXPECT().ReadlinkIfPossible(limaPath).Return(finch.UserDataDiskPath(homeDir), nil) + + dfs.EXPECT().Stat(limaPath).Return(nil, fs.ErrNotExist) + dfs.EXPECT().SymlinkIfPossible(finch.UserDataDiskPath(homeDir), limaPath).Return(nil) + + dfs.EXPECT().Stat(lockPath).Return(nil, fs.ErrNotExist) + }, + }, } for _, tc := range testCases { @@ -142,10 +218,11 @@ func TestUserDataDiskManager_InitializeUserDataDisk(t *testing.T) { ctrl := gomock.NewController(t) lcc := mocks.NewLimaCmdCreator(ctrl) + ecc := mocks.NewCommandCreator(ctrl) dfs := mocks.NewMockdiskFS(ctrl) cmd := mocks.NewCommand(ctrl) - tc.mockSvc(lcc, dfs, cmd) - dm := NewUserDataDiskManager(lcc, dfs, finch, homeDir) + tc.mockSvc(lcc, dfs, cmd, ecc) + dm := NewUserDataDiskManager(lcc, ecc, dfs, finch, homeDir, tc.cfg) err := dm.EnsureUserDataDisk() assert.Equal(t, tc.wantErr, err) }) diff --git a/pkg/mocks/pkg_config_lima_config_applier.go b/pkg/mocks/pkg_config_lima_config_applier.go index 045bf0f98..968e25947 100644 --- a/pkg/mocks/pkg_config_lima_config_applier.go +++ b/pkg/mocks/pkg_config_lima_config_applier.go @@ -37,15 +37,15 @@ func (m *LimaConfigApplier) EXPECT() *LimaConfigApplierMockRecorder { } // Apply mocks base method. -func (m *LimaConfigApplier) Apply() error { +func (m *LimaConfigApplier) Apply(arg0 bool) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Apply") + ret := m.ctrl.Call(m, "Apply", arg0) ret0, _ := ret[0].(error) return ret0 } // Apply indicates an expected call of Apply. -func (mr *LimaConfigApplierMockRecorder) Apply() *gomock.Call { +func (mr *LimaConfigApplierMockRecorder) Apply(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Apply", reflect.TypeOf((*LimaConfigApplier)(nil).Apply)) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Apply", reflect.TypeOf((*LimaConfigApplier)(nil).Apply), arg0) } diff --git a/pkg/mocks/pkg_config_lima_config_applier_system_deps.go b/pkg/mocks/pkg_config_lima_config_applier_system_deps.go new file mode 100644 index 000000000..676523039 --- /dev/null +++ b/pkg/mocks/pkg_config_lima_config_applier_system_deps.go @@ -0,0 +1,65 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/runfinch/finch/pkg/config (interfaces: LimaConfigApplierSystemDeps) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// LimaConfigApplierSystemDeps is a mock of LimaConfigApplierSystemDeps interface. +type LimaConfigApplierSystemDeps struct { + ctrl *gomock.Controller + recorder *LimaConfigApplierSystemDepsMockRecorder +} + +// LimaConfigApplierSystemDepsMockRecorder is the mock recorder for LimaConfigApplierSystemDeps. +type LimaConfigApplierSystemDepsMockRecorder struct { + mock *LimaConfigApplierSystemDeps +} + +// NewLimaConfigApplierSystemDeps creates a new mock instance. +func NewLimaConfigApplierSystemDeps(ctrl *gomock.Controller) *LimaConfigApplierSystemDeps { + mock := &LimaConfigApplierSystemDeps{ctrl: ctrl} + mock.recorder = &LimaConfigApplierSystemDepsMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *LimaConfigApplierSystemDeps) EXPECT() *LimaConfigApplierSystemDepsMockRecorder { + return m.recorder +} + +// Arch mocks base method. +func (m *LimaConfigApplierSystemDeps) Arch() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Arch") + ret0, _ := ret[0].(string) + return ret0 +} + +// Arch indicates an expected call of Arch. +func (mr *LimaConfigApplierSystemDepsMockRecorder) Arch() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Arch", reflect.TypeOf((*LimaConfigApplierSystemDeps)(nil).Arch)) +} + +// OS mocks base method. +func (m *LimaConfigApplierSystemDeps) OS() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "OS") + ret0, _ := ret[0].(string) + return ret0 +} + +// OS indicates an expected call of OS. +func (mr *LimaConfigApplierSystemDepsMockRecorder) OS() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OS", reflect.TypeOf((*LimaConfigApplierSystemDeps)(nil).OS)) +} diff --git a/pkg/system/stdlib.go b/pkg/system/stdlib.go index 0f9f1d1c7..dc2102971 100644 --- a/pkg/system/stdlib.go +++ b/pkg/system/stdlib.go @@ -61,3 +61,11 @@ func (s *StdLib) NumCPU() int { func (s *StdLib) ReadMemStats(st *runtime.MemStats) { runtime.ReadMemStats(st) } + +func (s *StdLib) Arch() string { + return runtime.GOARCH +} + +func (s *StdLib) OS() string { + return runtime.GOOS +} diff --git a/pkg/system/system.go b/pkg/system/system.go index 92c6323e9..9c589b4da 100644 --- a/pkg/system/system.go +++ b/pkg/system/system.go @@ -71,3 +71,13 @@ type StderrGetter interface { type RuntimeCPUGetter interface { NumCPU() int } + +// RuntimeArchGetter mocks out runtime.GOARCH. +type RuntimeArchGetter interface { + Arch() string +} + +// RuntimeOSGetter mocks out runtime.GOOS. +type RuntimeOSGetter interface { + OS() string +}