Skip to content

Commit b209d0d

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 849f329 commit b209d0d

File tree

7 files changed

+230
-3
lines changed

7 files changed

+230
-3
lines changed

cmd/limactl/clone.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
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+
if oldInst.Status == store.StatusRunning {
57+
return errors.New("cannot clone a running instance")
58+
}
59+
60+
if err = instance.Clone(ctx, oldInstName, newInstName); err != nil {
61+
return err
62+
}
63+
64+
newInst, err := store.Inspect(newInstName)
65+
if err != nil {
66+
return err
67+
}
68+
69+
yqExprs, err := editflags.YQExpressions(flags, false)
70+
if err != nil {
71+
return err
72+
}
73+
if len(yqExprs) > 0 {
74+
// TODO: reduce duplicated codes across cloneAction and editAction
75+
yq := yqutil.Join(yqExprs)
76+
filePath := filepath.Join(newInst.Dir, filenames.LimaYAML)
77+
yContent, err := os.ReadFile(filePath)
78+
if err != nil {
79+
return err
80+
}
81+
yBytes, err := yqutil.EvaluateExpression(yq, yContent)
82+
if err != nil {
83+
return err
84+
}
85+
y, err := limayaml.LoadWithWarnings(yBytes, filePath)
86+
if err != nil {
87+
return err
88+
}
89+
if err := limayaml.Validate(y, true); err != nil {
90+
return saveRejectedYAML(yBytes, err)
91+
}
92+
if err := limayaml.ValidateAgainstLatestConfig(yBytes, yContent); err != nil {
93+
return saveRejectedYAML(yBytes, err)
94+
}
95+
if err := os.WriteFile(filePath, yBytes, 0o644); err != nil {
96+
return err
97+
}
98+
newInst, err = store.Inspect(newInst.Name)
99+
if err != nil {
100+
return err
101+
}
102+
}
103+
104+
if !tty {
105+
// use "start" to start it
106+
return nil
107+
}
108+
startNow, err := askWhetherToStart()
109+
if err != nil {
110+
return err
111+
}
112+
if !startNow {
113+
return nil
114+
}
115+
err = networks.Reconcile(ctx, newInst.Name)
116+
if err != nil {
117+
return err
118+
}
119+
return instance.Start(ctx, newInst, "", false)
120+
}
121+
122+
func cloneBashComplete(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
123+
return bashCompleteInstanceNames(cmd)
124+
}

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: 11 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,15 @@ 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+
limactl clone "$NAME" "${NAME}-clone"
537+
limactl start "${NAME}-clone"
538+
[ "$(limactl shell "${NAME}-clone" hostname)" = "lima-${NAME}-clone" ]
539+
limactl start "$NAME"
540+
fi
530541

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

pkg/instance/clone.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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, oldInstName, newInstName string) error {
24+
if oldInstName == "" || newInstName == "" {
25+
return errors.New("got empty instName")
26+
}
27+
if oldInstName == newInstName {
28+
return fmt.Errorf("new instance name %q must be different from %q", newInstName, oldInstName)
29+
}
30+
31+
oldInstDir, err := store.InstanceDir(oldInstName)
32+
if err != nil {
33+
return err
34+
}
35+
36+
newInstDir, err := store.InstanceDir(newInstName)
37+
if err != nil {
38+
return err
39+
}
40+
41+
// the full path of the socket name must be less than UNIX_PATH_MAX chars.
42+
maxSockName := filepath.Join(newInstDir, filenames.LongestSock)
43+
if len(maxSockName) >= osutil.UnixPathMax {
44+
return fmt.Errorf("instance name %q too long: %q must be less than UNIX_PATH_MAX=%d characters, but is %d",
45+
newInstName, maxSockName, osutil.UnixPathMax, len(maxSockName))
46+
}
47+
48+
if err = os.Mkdir(newInstDir, 0o700); err != nil {
49+
return err
50+
}
51+
52+
walkDirFn := func(path string, d fs.DirEntry, err error) error {
53+
// TODO: recreate VzIdentifier with the new UUID.
54+
//
55+
// VzIdentifier file must not be just removed here, as pkg/limayaml depends on VzIdentifier
56+
// for resolving the VM type.
57+
58+
if slices.Contains(filenames.SkipOnClone, filepath.Base(path)) {
59+
return nil
60+
}
61+
for _, ext := range filenames.TmpFileSuffixes {
62+
if strings.HasSuffix(path, ext) {
63+
return nil
64+
}
65+
}
66+
if err != nil {
67+
return err
68+
}
69+
pathRel, err := filepath.Rel(oldInstDir, path)
70+
if err != nil {
71+
return err
72+
}
73+
dst := filepath.Join(newInstDir, pathRel)
74+
if d.IsDir() {
75+
return os.MkdirAll(dst, d.Type().Perm())
76+
}
77+
// CopyFile attempts copy-on-write when supported by the filesystem
78+
return continuityfs.CopyFile(dst, path)
79+
}
80+
81+
return filepath.WalkDir(oldInstDir, walkDirFn)
82+
}

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: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,11 @@ 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+
// TmpFileSuffixes is the list of the tmp file suffixes.
101+
var TmpFileSuffixes = []string{".pid", ".sock", ".tmp"}

0 commit comments

Comments
 (0)