Skip to content

Commit 1e6504f

Browse files
committed
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 <[email protected]>
1 parent 53d7186 commit 1e6504f

File tree

8 files changed

+236
-4
lines changed

8 files changed

+236
-4
lines changed

cmd/limactl/clone.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// SPDX-FileCopyrightText: Copyright The Lima Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package main
5+
6+
import (
7+
"errors"
8+
"fmt"
9+
"os"
10+
"path/filepath"
11+
12+
"github.com/spf13/cobra"
13+
14+
"github.com/lima-vm/lima/cmd/limactl/editflags"
15+
"github.com/lima-vm/lima/pkg/instance"
16+
"github.com/lima-vm/lima/pkg/limayaml"
17+
networks "github.com/lima-vm/lima/pkg/networks/reconcile"
18+
"github.com/lima-vm/lima/pkg/store"
19+
"github.com/lima-vm/lima/pkg/store/filenames"
20+
"github.com/lima-vm/lima/pkg/yqutil"
21+
)
22+
23+
func newCloneCommand() *cobra.Command {
24+
cloneCommand := &cobra.Command{
25+
Use: "clone OLDINST NEWINST",
26+
Short: "Clone an instance of Lima",
27+
Long: `Clone an instance of Lima.
28+
29+
Not to be confused with 'limactl copy' ('limactl cp').
30+
`,
31+
Args: WrapArgsError(cobra.ExactArgs(2)),
32+
RunE: cloneAction,
33+
ValidArgsFunction: cloneBashComplete,
34+
GroupID: advancedCommand,
35+
}
36+
editflags.RegisterEdit(cloneCommand)
37+
return cloneCommand
38+
}
39+
40+
func cloneAction(cmd *cobra.Command, args []string) error {
41+
ctx := cmd.Context()
42+
flags := cmd.Flags()
43+
tty, err := flags.GetBool("tty")
44+
if err != nil {
45+
return err
46+
}
47+
48+
oldInstName, newInstName := args[0], args[1]
49+
oldInst, err := store.Inspect(oldInstName)
50+
if err != nil {
51+
if errors.Is(err, os.ErrNotExist) {
52+
return fmt.Errorf("instance %q not found", oldInstName)
53+
}
54+
return err
55+
}
56+
57+
newInst, err := instance.Clone(ctx, oldInst, newInstName)
58+
if err != nil {
59+
return err
60+
}
61+
62+
yqExprs, err := editflags.YQExpressions(flags, false)
63+
if err != nil {
64+
return err
65+
}
66+
if len(yqExprs) > 0 {
67+
// TODO: reduce duplicated codes across cloneAction and editAction
68+
yq := yqutil.Join(yqExprs)
69+
filePath := filepath.Join(newInst.Dir, filenames.LimaYAML)
70+
yContent, err := os.ReadFile(filePath)
71+
if err != nil {
72+
return err
73+
}
74+
yBytes, err := yqutil.EvaluateExpression(yq, yContent)
75+
if err != nil {
76+
return err
77+
}
78+
y, err := limayaml.LoadWithWarnings(yBytes, filePath)
79+
if err != nil {
80+
return err
81+
}
82+
if err := limayaml.Validate(y, true); err != nil {
83+
return saveRejectedYAML(yBytes, err)
84+
}
85+
if err := limayaml.ValidateAgainstLatestConfig(yBytes, yContent); err != nil {
86+
return saveRejectedYAML(yBytes, err)
87+
}
88+
if err := os.WriteFile(filePath, yBytes, 0o644); err != nil {
89+
return err
90+
}
91+
newInst, err = store.Inspect(newInst.Name)
92+
if err != nil {
93+
return err
94+
}
95+
}
96+
97+
if !tty {
98+
// use "start" to start it
99+
return nil
100+
}
101+
startNow, err := askWhetherToStart()
102+
if err != nil {
103+
return err
104+
}
105+
if !startNow {
106+
return nil
107+
}
108+
err = networks.Reconcile(ctx, newInst.Name)
109+
if err != nil {
110+
return err
111+
}
112+
return instance.Start(ctx, newInst, "", false)
113+
}
114+
115+
func cloneBashComplete(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
116+
return bashCompleteInstanceNames(cmd)
117+
}

cmd/limactl/copy.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ const copyHelp = `Copy files between host and guest
2626
Prefix guest filenames with the instance name and a colon.
2727
2828
Example: limactl copy default:/etc/os-release .
29+
30+
Not to be confused with 'limactl clone'.
2931
`
3032

3133
func newCopyCommand() *cobra.Command {

cmd/limactl/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ func newApp() *cobra.Command {
189189
newRestartCommand(),
190190
newSudoersCommand(),
191191
newStartAtLoginCommand(),
192+
newCloneCommand(),
192193
)
193194

194195
return rootCmd

hack/test-templates.sh

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ declare -A CHECKS=(
4949
# snapshot tests are too flaky (especially with archlinux)
5050
["snapshot-online"]=""
5151
["snapshot-offline"]=""
52+
["clone"]=""
5253
["port-forwards"]="1"
5354
["vmnet"]=""
5455
["disk"]=""
@@ -85,6 +86,7 @@ case "$NAME" in
8586
CHECKS["disk"]=1
8687
CHECKS["snapshot-online"]="1"
8788
CHECKS["snapshot-offline"]="1"
89+
CHECKS["clone"]="1"
8890
CHECKS["mount-path-with-spaces"]="1"
8991
CHECKS["provision-data"]="1"
9092
CHECKS["param-env-variables"]="1"
@@ -527,6 +529,16 @@ if [[ -n ${CHECKS["snapshot-offline"]} ]]; then
527529
limactl snapshot delete "$NAME" --tag snap2
528530
limactl start "$NAME"
529531
fi
532+
if [[ -n ${CHECKS["clone"]} ]]; then
533+
INFO "Testing cloning"
534+
limactl stop "$NAME"
535+
sleep 3
536+
# [hostagent] could not attach disk \"data\", in use by instance \"test-misc-clone\"
537+
limactl clone --set '.additionalDisks = null' "$NAME" "${NAME}-clone"
538+
limactl start "${NAME}-clone"
539+
[ "$(limactl shell "${NAME}-clone" hostname)" = "lima-${NAME}-clone" ]
540+
limactl start "$NAME"
541+
fi
530542

531543
if [[ $NAME == "fedora" && "$(limactl ls --json "$NAME" | jq -r .vmType)" == "vz" ]]; then
532544
"${scriptdir}"/test-selinux.sh "$NAME"

pkg/instance/clone.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// SPDX-FileCopyrightText: Copyright The Lima Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package instance
5+
6+
import (
7+
"context"
8+
"errors"
9+
"fmt"
10+
"io/fs"
11+
"os"
12+
"path/filepath"
13+
"slices"
14+
"strings"
15+
16+
continuityfs "github.com/containerd/continuity/fs"
17+
18+
"github.com/lima-vm/lima/pkg/osutil"
19+
"github.com/lima-vm/lima/pkg/store"
20+
"github.com/lima-vm/lima/pkg/store/filenames"
21+
)
22+
23+
func Clone(_ context.Context, oldInst *store.Instance, newInstName string) (*store.Instance, error) {
24+
if newInstName == "" {
25+
return nil, errors.New("got empty instName")
26+
}
27+
if oldInst.Name == newInstName {
28+
return nil, fmt.Errorf("new instance name %q must be different from %q", newInstName, oldInst.Name)
29+
}
30+
if oldInst.Status == store.StatusRunning {
31+
return nil, errors.New("cannot clone a running instance")
32+
}
33+
34+
newInstDir, err := store.InstanceDir(newInstName)
35+
if err != nil {
36+
return nil, err
37+
}
38+
39+
// the full path of the socket name must be less than UNIX_PATH_MAX chars.
40+
maxSockName := filepath.Join(newInstDir, filenames.LongestSock)
41+
if len(maxSockName) >= osutil.UnixPathMax {
42+
return nil, fmt.Errorf("instance name %q too long: %q must be less than UNIX_PATH_MAX=%d characters, but is %d",
43+
newInstName, maxSockName, osutil.UnixPathMax, len(maxSockName))
44+
}
45+
46+
if err = os.Mkdir(newInstDir, 0o700); err != nil {
47+
return nil, err
48+
}
49+
50+
walkDirFn := func(path string, d fs.DirEntry, err error) error {
51+
base := filepath.Base(path)
52+
if slices.Contains(filenames.SkipOnClone, base) {
53+
return nil
54+
}
55+
for _, ext := range filenames.TmpFileSuffixes {
56+
if strings.HasSuffix(path, ext) {
57+
return nil
58+
}
59+
}
60+
if err != nil {
61+
return err
62+
}
63+
pathRel, err := filepath.Rel(oldInst.Dir, path)
64+
if err != nil {
65+
return err
66+
}
67+
dst := filepath.Join(newInstDir, pathRel)
68+
if d.IsDir() {
69+
return os.MkdirAll(dst, d.Type().Perm())
70+
}
71+
// NullifyOnClone contains VzIdentifier.
72+
// VzIdentifier file must not be just removed here, as pkg/limayaml depends on
73+
// the existence of VzIdentifier for resolving the VM type.
74+
if slices.Contains(filenames.NullifyOnClone, base) {
75+
return os.WriteFile(dst, nil, 0o666)
76+
}
77+
// CopyFile attempts copy-on-write when supported by the filesystem
78+
return continuityfs.CopyFile(dst, path)
79+
}
80+
81+
if err = filepath.WalkDir(oldInst.Dir, walkDirFn); err != nil {
82+
return nil, err
83+
}
84+
85+
return store.Inspect(newInstName)
86+
}

pkg/instance/stop.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,7 @@ func StopForcibly(inst *store.Instance) {
131131
logrus.Info("The host agent process seems already stopped")
132132
}
133133

134-
suffixesToBeRemoved := []string{".pid", ".sock", ".tmp"}
135-
globPatterns := strings.ReplaceAll(strings.Join(suffixesToBeRemoved, " "), ".", "*.")
134+
globPatterns := strings.ReplaceAll(strings.Join(filenames.TmpFileSuffixes, " "), ".", "*.")
136135
logrus.Infof("Removing %s under %q", globPatterns, inst.Dir)
137136

138137
fi, err := os.ReadDir(inst.Dir)
@@ -142,7 +141,7 @@ func StopForcibly(inst *store.Instance) {
142141
}
143142
for _, f := range fi {
144143
path := filepath.Join(inst.Dir, f.Name())
145-
for _, suffix := range suffixesToBeRemoved {
144+
for _, suffix := range filenames.TmpFileSuffixes {
146145
if strings.HasSuffix(path, suffix) {
147146
logrus.Infof("Removing %q", path)
148147
if err := os.Remove(path); err != nil {

pkg/store/filenames/filenames.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,17 @@ const LongestSock = SSHSock + ".1234567890123456"
9191
func PIDFile(name string) string {
9292
return name + ".pid"
9393
}
94+
95+
// SkipOnClone files should be skipped on cloning an instance.
96+
var SkipOnClone = []string{
97+
Protected,
98+
}
99+
100+
// NullifyOnClone files should be nullified on cloning an instance.
101+
// FIXME: this list should be provided by the VM driver.
102+
var NullifyOnClone = []string{
103+
VzIdentifier,
104+
}
105+
106+
// TmpFileSuffixes is the list of the tmp file suffixes.
107+
var TmpFileSuffixes = []string{".pid", ".sock", ".tmp"}

pkg/vz/vm_darwin.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -692,7 +692,8 @@ func attachOtherDevices(_ *driver.BaseDriver, vmConfig *vz.VirtualMachineConfigu
692692

693693
func getMachineIdentifier(driver *driver.BaseDriver) (*vz.GenericMachineIdentifier, error) {
694694
identifier := filepath.Join(driver.Instance.Dir, filenames.VzIdentifier)
695-
if _, err := os.Stat(identifier); os.IsNotExist(err) {
695+
// Empty VzIdentifier can be created on cloning an instance.
696+
if st, err := os.Stat(identifier); os.IsNotExist(err) || (st != nil && st.Size() == 0) {
696697
machineIdentifier, err := vz.NewGenericMachineIdentifier()
697698
if err != nil {
698699
return nil, err

0 commit comments

Comments
 (0)