Skip to content

Commit 5622089

Browse files
authored
Merge pull request #3673 from AkihiroSuda/limactl-clone
Implement `limactl clone`
2 parents c62e4c3 + 2f7c401 commit 5622089

File tree

10 files changed

+247
-11
lines changed

10 files changed

+247
-11
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, "[limactl edit] ")
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/edit.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ func newEditCommand() *cobra.Command {
3333
ValidArgsFunction: editBashComplete,
3434
GroupID: basicCommand,
3535
}
36-
editflags.RegisterEdit(editCommand)
36+
editflags.RegisterEdit(editCommand, "")
3737
return editCommand
3838
}
3939

cmd/limactl/editflags/editflags.go

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,7 @@ import (
1818
)
1919

2020
// RegisterEdit registers flags related to in-place YAML modification, for `limactl edit`.
21-
func RegisterEdit(cmd *cobra.Command) {
22-
registerEdit(cmd, "")
23-
}
24-
25-
func registerEdit(cmd *cobra.Command, commentPrefix string) {
21+
func RegisterEdit(cmd *cobra.Command, commentPrefix string) {
2622
flags := cmd.Flags()
2723

2824
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) {
7773

7874
// RegisterCreate registers flags related to in-place YAML modification, for `limactl create`.
7975
func RegisterCreate(cmd *cobra.Command, commentPrefix string) {
80-
registerEdit(cmd, commentPrefix)
76+
RegisterEdit(cmd, commentPrefix)
8177
flags := cmd.Flags()
8278

8379
flags.String("arch", "", commentPrefix+"Machine architecture (x86_64, aarch64, riscv64, armv7l, s390x, ppc64le)") // colima-compatible

cmd/limactl/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ func newApp() *cobra.Command {
190190
newSudoersCommand(),
191191
newStartAtLoginCommand(),
192192
newNetworkCommand(),
193+
newCloneCommand(),
193194
)
194195

195196
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/driver/vz/vm_darwin.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"context"
1010
"errors"
1111
"fmt"
12+
"io/fs"
1213
"net"
1314
"os"
1415
"path/filepath"
@@ -691,7 +692,11 @@ func attachOtherDevices(_ *store.Instance, vmConfig *vz.VirtualMachineConfigurat
691692

692693
func getMachineIdentifier(inst *store.Instance) (*vz.GenericMachineIdentifier, error) {
693694
identifier := filepath.Join(inst.Dir, filenames.VzIdentifier)
694-
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); err != nil || (st != nil && st.Size() == 0) {
697+
if err != nil && !errors.Is(err, fs.ErrNotExist) {
698+
return nil, err
699+
}
695700
machineIdentifier, err := vz.NewGenericMachineIdentifier()
696701
if err != nil {
697702
return nil, err

pkg/instance/clone.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
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+
if _, err = os.Stat(newInstDir); !errors.Is(err, fs.ErrNotExist) {
40+
return nil, fmt.Errorf("instance %q already exists", newInstName)
41+
}
42+
43+
// the full path of the socket name must be less than UNIX_PATH_MAX chars.
44+
maxSockName := filepath.Join(newInstDir, filenames.LongestSock)
45+
if len(maxSockName) >= osutil.UnixPathMax {
46+
return nil, fmt.Errorf("instance name %q too long: %q must be less than UNIX_PATH_MAX=%d characters, but is %d",
47+
newInstName, maxSockName, osutil.UnixPathMax, len(maxSockName))
48+
}
49+
50+
if err = os.Mkdir(newInstDir, 0o700); err != nil {
51+
return nil, err
52+
}
53+
54+
walkDirFn := func(path string, d fs.DirEntry, err error) error {
55+
base := filepath.Base(path)
56+
if slices.Contains(filenames.SkipOnClone, base) {
57+
return nil
58+
}
59+
for _, ext := range filenames.TmpFileSuffixes {
60+
if strings.HasSuffix(path, ext) {
61+
return nil
62+
}
63+
}
64+
if err != nil {
65+
return err
66+
}
67+
pathRel, err := filepath.Rel(oldInst.Dir, path)
68+
if err != nil {
69+
return err
70+
}
71+
dst := filepath.Join(newInstDir, pathRel)
72+
if d.IsDir() {
73+
return os.MkdirAll(dst, d.Type().Perm())
74+
}
75+
// NullifyOnClone contains VzIdentifier.
76+
// VzIdentifier file must not be just removed here, as pkg/limayaml depends on
77+
// the existence of VzIdentifier for resolving the VM type.
78+
if slices.Contains(filenames.NullifyOnClone, base) {
79+
return os.WriteFile(dst, nil, 0o666)
80+
}
81+
// CopyFile attempts copy-on-write when supported by the filesystem
82+
return continuityfs.CopyFile(dst, path)
83+
}
84+
85+
if err = filepath.WalkDir(oldInst.Dir, walkDirFn); err != nil {
86+
return nil, err
87+
}
88+
89+
return store.Inspect(newInstName)
90+
}

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"}

0 commit comments

Comments
 (0)