From 2f7c4013baa3fb6bb72b0c5de25c704931f7fbaf Mon Sep 17 00:00:00 2001 From: Akihiro Suda Date: Tue, 1 Jul 2025 14:12:43 +0900 Subject: [PATCH] Implement `limactl clone` `limactl clone OLDINST NEWINST` clones an instance. Not to be confused with `limactl copy SRC DST` (copy files). Fix issue 3658 Signed-off-by: Akihiro Suda --- cmd/limactl/clone.go | 117 +++++++++++++++++++++++++++++ cmd/limactl/copy.go | 2 + cmd/limactl/edit.go | 2 +- cmd/limactl/editflags/editflags.go | 8 +- cmd/limactl/main.go | 1 + hack/test-templates.sh | 12 +++ pkg/driver/vz/vm_darwin.go | 7 +- pkg/instance/clone.go | 90 ++++++++++++++++++++++ pkg/instance/stop.go | 5 +- pkg/store/filenames/filenames.go | 14 ++++ 10 files changed, 247 insertions(+), 11 deletions(-) create mode 100644 cmd/limactl/clone.go create mode 100644 pkg/instance/clone.go diff --git a/cmd/limactl/clone.go b/cmd/limactl/clone.go new file mode 100644 index 00000000000..694c3488a6a --- /dev/null +++ b/cmd/limactl/clone.go @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/lima-vm/lima/cmd/limactl/editflags" + "github.com/lima-vm/lima/pkg/instance" + "github.com/lima-vm/lima/pkg/limayaml" + networks "github.com/lima-vm/lima/pkg/networks/reconcile" + "github.com/lima-vm/lima/pkg/store" + "github.com/lima-vm/lima/pkg/store/filenames" + "github.com/lima-vm/lima/pkg/yqutil" +) + +func newCloneCommand() *cobra.Command { + cloneCommand := &cobra.Command{ + Use: "clone OLDINST NEWINST", + Short: "Clone an instance of Lima", + Long: `Clone an instance of Lima. + +Not to be confused with 'limactl copy' ('limactl cp'). +`, + Args: WrapArgsError(cobra.ExactArgs(2)), + RunE: cloneAction, + ValidArgsFunction: cloneBashComplete, + GroupID: advancedCommand, + } + editflags.RegisterEdit(cloneCommand, "[limactl edit] ") + return cloneCommand +} + +func cloneAction(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + flags := cmd.Flags() + tty, err := flags.GetBool("tty") + if err != nil { + return err + } + + oldInstName, newInstName := args[0], args[1] + oldInst, err := store.Inspect(oldInstName) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("instance %q not found", oldInstName) + } + return err + } + + newInst, err := instance.Clone(ctx, oldInst, newInstName) + if err != nil { + return err + } + + yqExprs, err := editflags.YQExpressions(flags, false) + if err != nil { + return err + } + if len(yqExprs) > 0 { + // TODO: reduce duplicated codes across cloneAction and editAction + yq := yqutil.Join(yqExprs) + filePath := filepath.Join(newInst.Dir, filenames.LimaYAML) + yContent, err := os.ReadFile(filePath) + if err != nil { + return err + } + yBytes, err := yqutil.EvaluateExpression(yq, yContent) + if err != nil { + return err + } + y, err := limayaml.LoadWithWarnings(yBytes, filePath) + if err != nil { + return err + } + if err := limayaml.Validate(y, true); err != nil { + return saveRejectedYAML(yBytes, err) + } + if err := limayaml.ValidateAgainstLatestConfig(yBytes, yContent); err != nil { + return saveRejectedYAML(yBytes, err) + } + if err := os.WriteFile(filePath, yBytes, 0o644); err != nil { + return err + } + newInst, err = store.Inspect(newInst.Name) + if err != nil { + return err + } + } + + if !tty { + // use "start" to start it + return nil + } + startNow, err := askWhetherToStart() + if err != nil { + return err + } + if !startNow { + return nil + } + err = networks.Reconcile(ctx, newInst.Name) + if err != nil { + return err + } + return instance.Start(ctx, newInst, "", false) +} + +func cloneBashComplete(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return bashCompleteInstanceNames(cmd) +} diff --git a/cmd/limactl/copy.go b/cmd/limactl/copy.go index 1f422d210a6..832c3fdb0a6 100644 --- a/cmd/limactl/copy.go +++ b/cmd/limactl/copy.go @@ -26,6 +26,8 @@ const copyHelp = `Copy files between host and guest Prefix guest filenames with the instance name and a colon. Example: limactl copy default:/etc/os-release . + +Not to be confused with 'limactl clone'. ` func newCopyCommand() *cobra.Command { diff --git a/cmd/limactl/edit.go b/cmd/limactl/edit.go index 6474d5e8bef..0265e0ad5e3 100644 --- a/cmd/limactl/edit.go +++ b/cmd/limactl/edit.go @@ -33,7 +33,7 @@ func newEditCommand() *cobra.Command { ValidArgsFunction: editBashComplete, GroupID: basicCommand, } - editflags.RegisterEdit(editCommand) + editflags.RegisterEdit(editCommand, "") return editCommand } diff --git a/cmd/limactl/editflags/editflags.go b/cmd/limactl/editflags/editflags.go index ae9fa593e4f..9f4e7c601af 100644 --- a/cmd/limactl/editflags/editflags.go +++ b/cmd/limactl/editflags/editflags.go @@ -18,11 +18,7 @@ import ( ) // RegisterEdit registers flags related to in-place YAML modification, for `limactl edit`. -func RegisterEdit(cmd *cobra.Command) { - registerEdit(cmd, "") -} - -func registerEdit(cmd *cobra.Command, commentPrefix string) { +func RegisterEdit(cmd *cobra.Command, commentPrefix string) { flags := cmd.Flags() flags.Int("cpus", 0, commentPrefix+"Number of CPUs") // Similar to colima's --cpu, but the flag name is slightly different (cpu vs cpus) @@ -77,7 +73,7 @@ func registerEdit(cmd *cobra.Command, commentPrefix string) { // RegisterCreate registers flags related to in-place YAML modification, for `limactl create`. func RegisterCreate(cmd *cobra.Command, commentPrefix string) { - registerEdit(cmd, commentPrefix) + RegisterEdit(cmd, commentPrefix) flags := cmd.Flags() flags.String("arch", "", commentPrefix+"Machine architecture (x86_64, aarch64, riscv64, armv7l, s390x, ppc64le)") // colima-compatible diff --git a/cmd/limactl/main.go b/cmd/limactl/main.go index acfa41074e2..5b256900be6 100644 --- a/cmd/limactl/main.go +++ b/cmd/limactl/main.go @@ -190,6 +190,7 @@ func newApp() *cobra.Command { newSudoersCommand(), newStartAtLoginCommand(), newNetworkCommand(), + newCloneCommand(), ) return rootCmd diff --git a/hack/test-templates.sh b/hack/test-templates.sh index 2935907c99e..0dd6f75c335 100755 --- a/hack/test-templates.sh +++ b/hack/test-templates.sh @@ -49,6 +49,7 @@ declare -A CHECKS=( # snapshot tests are too flaky (especially with archlinux) ["snapshot-online"]="" ["snapshot-offline"]="" + ["clone"]="" ["port-forwards"]="1" ["vmnet"]="" ["disk"]="" @@ -85,6 +86,7 @@ case "$NAME" in CHECKS["disk"]=1 CHECKS["snapshot-online"]="1" CHECKS["snapshot-offline"]="1" + CHECKS["clone"]="1" CHECKS["mount-path-with-spaces"]="1" CHECKS["provision-data"]="1" CHECKS["param-env-variables"]="1" @@ -527,6 +529,16 @@ if [[ -n ${CHECKS["snapshot-offline"]} ]]; then limactl snapshot delete "$NAME" --tag snap2 limactl start "$NAME" fi +if [[ -n ${CHECKS["clone"]} ]]; then + INFO "Testing cloning" + limactl stop "$NAME" + sleep 3 + # [hostagent] could not attach disk \"data\", in use by instance \"test-misc-clone\" + limactl clone --set '.additionalDisks = null' "$NAME" "${NAME}-clone" + limactl start "${NAME}-clone" + [ "$(limactl shell "${NAME}-clone" hostname)" = "lima-${NAME}-clone" ] + limactl start "$NAME" +fi if [[ $NAME == "fedora" && "$(limactl ls --json "$NAME" | jq -r .vmType)" == "vz" ]]; then "${scriptdir}"/test-selinux.sh "$NAME" diff --git a/pkg/driver/vz/vm_darwin.go b/pkg/driver/vz/vm_darwin.go index 147af63fddb..2f1351dfb62 100644 --- a/pkg/driver/vz/vm_darwin.go +++ b/pkg/driver/vz/vm_darwin.go @@ -9,6 +9,7 @@ import ( "context" "errors" "fmt" + "io/fs" "net" "os" "path/filepath" @@ -691,7 +692,11 @@ func attachOtherDevices(_ *store.Instance, vmConfig *vz.VirtualMachineConfigurat func getMachineIdentifier(inst *store.Instance) (*vz.GenericMachineIdentifier, error) { identifier := filepath.Join(inst.Dir, filenames.VzIdentifier) - if _, err := os.Stat(identifier); os.IsNotExist(err) { + // Empty VzIdentifier can be created on cloning an instance. + if st, err := os.Stat(identifier); err != nil || (st != nil && st.Size() == 0) { + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return nil, err + } machineIdentifier, err := vz.NewGenericMachineIdentifier() if err != nil { return nil, err diff --git a/pkg/instance/clone.go b/pkg/instance/clone.go new file mode 100644 index 00000000000..b41a7ed614c --- /dev/null +++ b/pkg/instance/clone.go @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package instance + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + + continuityfs "github.com/containerd/continuity/fs" + + "github.com/lima-vm/lima/pkg/osutil" + "github.com/lima-vm/lima/pkg/store" + "github.com/lima-vm/lima/pkg/store/filenames" +) + +func Clone(_ context.Context, oldInst *store.Instance, newInstName string) (*store.Instance, error) { + if newInstName == "" { + return nil, errors.New("got empty instName") + } + if oldInst.Name == newInstName { + return nil, fmt.Errorf("new instance name %q must be different from %q", newInstName, oldInst.Name) + } + if oldInst.Status == store.StatusRunning { + return nil, errors.New("cannot clone a running instance") + } + + newInstDir, err := store.InstanceDir(newInstName) + if err != nil { + return nil, err + } + + if _, err = os.Stat(newInstDir); !errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("instance %q already exists", newInstName) + } + + // the full path of the socket name must be less than UNIX_PATH_MAX chars. + maxSockName := filepath.Join(newInstDir, filenames.LongestSock) + if len(maxSockName) >= osutil.UnixPathMax { + return nil, fmt.Errorf("instance name %q too long: %q must be less than UNIX_PATH_MAX=%d characters, but is %d", + newInstName, maxSockName, osutil.UnixPathMax, len(maxSockName)) + } + + if err = os.Mkdir(newInstDir, 0o700); err != nil { + return nil, err + } + + walkDirFn := func(path string, d fs.DirEntry, err error) error { + base := filepath.Base(path) + if slices.Contains(filenames.SkipOnClone, base) { + return nil + } + for _, ext := range filenames.TmpFileSuffixes { + if strings.HasSuffix(path, ext) { + return nil + } + } + if err != nil { + return err + } + pathRel, err := filepath.Rel(oldInst.Dir, path) + if err != nil { + return err + } + dst := filepath.Join(newInstDir, pathRel) + if d.IsDir() { + return os.MkdirAll(dst, d.Type().Perm()) + } + // NullifyOnClone contains VzIdentifier. + // VzIdentifier file must not be just removed here, as pkg/limayaml depends on + // the existence of VzIdentifier for resolving the VM type. + if slices.Contains(filenames.NullifyOnClone, base) { + return os.WriteFile(dst, nil, 0o666) + } + // CopyFile attempts copy-on-write when supported by the filesystem + return continuityfs.CopyFile(dst, path) + } + + if err = filepath.WalkDir(oldInst.Dir, walkDirFn); err != nil { + return nil, err + } + + return store.Inspect(newInstName) +} diff --git a/pkg/instance/stop.go b/pkg/instance/stop.go index 60b8d9b3fe9..a2704a0f944 100644 --- a/pkg/instance/stop.go +++ b/pkg/instance/stop.go @@ -131,8 +131,7 @@ func StopForcibly(inst *store.Instance) { logrus.Info("The host agent process seems already stopped") } - suffixesToBeRemoved := []string{".pid", ".sock", ".tmp"} - globPatterns := strings.ReplaceAll(strings.Join(suffixesToBeRemoved, " "), ".", "*.") + globPatterns := strings.ReplaceAll(strings.Join(filenames.TmpFileSuffixes, " "), ".", "*.") logrus.Infof("Removing %s under %q", globPatterns, inst.Dir) fi, err := os.ReadDir(inst.Dir) @@ -142,7 +141,7 @@ func StopForcibly(inst *store.Instance) { } for _, f := range fi { path := filepath.Join(inst.Dir, f.Name()) - for _, suffix := range suffixesToBeRemoved { + for _, suffix := range filenames.TmpFileSuffixes { if strings.HasSuffix(path, suffix) { logrus.Infof("Removing %q", path) if err := os.Remove(path); err != nil { diff --git a/pkg/store/filenames/filenames.go b/pkg/store/filenames/filenames.go index cfb825fc0ee..cc9a0fc9f33 100644 --- a/pkg/store/filenames/filenames.go +++ b/pkg/store/filenames/filenames.go @@ -91,3 +91,17 @@ const LongestSock = SSHSock + ".1234567890123456" func PIDFile(name string) string { return name + ".pid" } + +// SkipOnClone files should be skipped on cloning an instance. +var SkipOnClone = []string{ + Protected, +} + +// NullifyOnClone files should be nullified on cloning an instance. +// FIXME: this list should be provided by the VM driver. +var NullifyOnClone = []string{ + VzIdentifier, +} + +// TmpFileSuffixes is the list of the tmp file suffixes. +var TmpFileSuffixes = []string{".pid", ".sock", ".tmp"}