Skip to content

Implement limactl clone #3673

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions cmd/limactl/clone.go
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is one issue that is surprising, but not specific to clone; it applies e.g. to edit too:

If I use l clone --yes foo bar I would expect the command to take the default action (which right now means to start the instance).

So it would work better if the default was to not start the instance, even when you ask, but you would have to explicitly enter y and .

But I guess this is a separate discussion outside the scope of this PR.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But limactl (edit|clone) --tty=false should not do an extra operation?
Probably --yes shouldn't be just an alias of --tty=false?
Should be discussed in a separate issue.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be discussed in a separate issue.

👍

Original file line number Diff line number Diff line change
@@ -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)
}
2 changes: 2 additions & 0 deletions cmd/limactl/copy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion cmd/limactl/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func newEditCommand() *cobra.Command {
ValidArgsFunction: editBashComplete,
GroupID: basicCommand,
}
editflags.RegisterEdit(editCommand)
editflags.RegisterEdit(editCommand, "")
return editCommand
}

Expand Down
8 changes: 2 additions & 6 deletions cmd/limactl/editflags/editflags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions cmd/limactl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ func newApp() *cobra.Command {
newSudoersCommand(),
newStartAtLoginCommand(),
newNetworkCommand(),
newCloneCommand(),
)

return rootCmd
Expand Down
12 changes: 12 additions & 0 deletions hack/test-templates.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"]=""
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
7 changes: 6 additions & 1 deletion pkg/driver/vz/vm_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"context"
"errors"
"fmt"
"io/fs"
"net"
"os"
"path/filepath"
Expand Down Expand Up @@ -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
Expand Down
90 changes: 90 additions & 0 deletions pkg/instance/clone.go
Original file line number Diff line number Diff line change
@@ -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)
}
5 changes: 2 additions & 3 deletions pkg/instance/stop.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
14 changes: 14 additions & 0 deletions pkg/store/filenames/filenames.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Loading