diff --git a/README.md b/README.md index 79b4ef5a1eea..2e0e5ee05bf8 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,15 @@ $ limactl ls --format='{{.SSHConfigFile}}' default $ ssh -F /Users/example/.lima/default/ssh.config lima-default ``` +#### `limactl snapshot` +`limactl snapshot `: manage instance snapshots + +Commands: +`limactl snapshot create --tag TAG INSTANCE` : create (save) a snapshot +`limactl snapshot apply --tag TAG INSTANCE` : apply (load) a snapshot +`limactl snapshot delete --tag TAG INSTANCE` : delete (del) a snapshot +`limactl snapshot list INSTANCE` : list existing snapshots in instance + #### `limactl completion` - To enable bash completion, add `source <(limactl completion bash)` to `~/.bash_profile`. diff --git a/cmd/limactl/main.go b/cmd/limactl/main.go index d8f3afec8895..313773df49b2 100644 --- a/cmd/limactl/main.go +++ b/cmd/limactl/main.go @@ -110,6 +110,7 @@ func newApp() *cobra.Command { newFactoryResetCommand(), newDiskCommand(), newUsernetCommand(), + newSnapshotCommand(), ) return rootCmd } diff --git a/cmd/limactl/snapshot.go b/cmd/limactl/snapshot.go new file mode 100644 index 000000000000..96679a0b8f8a --- /dev/null +++ b/cmd/limactl/snapshot.go @@ -0,0 +1,185 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/lima-vm/lima/pkg/snapshot" + "github.com/lima-vm/lima/pkg/store" + + "github.com/spf13/cobra" +) + +func newSnapshotCommand() *cobra.Command { + var snapshotCmd = &cobra.Command{ + Use: "snapshot", + Short: "Manage instance snapshots", + } + snapshotCmd.AddCommand(newSnapshotApplyCommand()) + snapshotCmd.AddCommand(newSnapshotCreateCommand()) + snapshotCmd.AddCommand(newSnapshotDeleteCommand()) + snapshotCmd.AddCommand(newSnapshotListCommand()) + + return snapshotCmd +} + +func newSnapshotCreateCommand() *cobra.Command { + var createCmd = &cobra.Command{ + Use: "create INSTANCE", + Aliases: []string{"save"}, + Short: "Create (save) a snapshot", + Args: cobra.MinimumNArgs(1), + RunE: snapshotCreateAction, + ValidArgsFunction: snapshotBashComplete, + } + createCmd.Flags().String("tag", "", "name of the snapshot") + + return createCmd +} + +func snapshotCreateAction(cmd *cobra.Command, args []string) error { + instName := args[0] + + inst, err := store.Inspect(instName) + if err != nil { + return err + } + + tag, err := cmd.Flags().GetString("tag") + if err != nil { + return err + } + + if tag == "" { + return fmt.Errorf("expected tag") + } + + ctx := cmd.Context() + return snapshot.Save(ctx, inst, tag) +} + +func newSnapshotDeleteCommand() *cobra.Command { + var deleteCmd = &cobra.Command{ + Use: "delete INSTANCE", + Aliases: []string{"del"}, + Short: "Delete (del) a snapshot", + Args: cobra.MinimumNArgs(1), + RunE: snapshotDeleteAction, + ValidArgsFunction: snapshotBashComplete, + } + deleteCmd.Flags().String("tag", "", "name of the snapshot") + + return deleteCmd +} + +func snapshotDeleteAction(cmd *cobra.Command, args []string) error { + instName := args[0] + + inst, err := store.Inspect(instName) + if err != nil { + return err + } + + tag, err := cmd.Flags().GetString("tag") + if err != nil { + return err + } + + if tag == "" { + return fmt.Errorf("expected tag") + } + + ctx := cmd.Context() + return snapshot.Del(ctx, inst, tag) +} + +func newSnapshotApplyCommand() *cobra.Command { + var applyCmd = &cobra.Command{ + Use: "apply INSTANCE", + Aliases: []string{"load"}, + Short: "Apply (load) a snapshot", + Args: cobra.MinimumNArgs(1), + RunE: snapshotApplyAction, + ValidArgsFunction: snapshotBashComplete, + } + applyCmd.Flags().String("tag", "", "name of the snapshot") + + return applyCmd +} + +func snapshotApplyAction(cmd *cobra.Command, args []string) error { + instName := args[0] + + inst, err := store.Inspect(instName) + if err != nil { + return err + } + + tag, err := cmd.Flags().GetString("tag") + if err != nil { + return err + } + + if tag == "" { + return fmt.Errorf("expected tag") + } + + ctx := cmd.Context() + return snapshot.Load(ctx, inst, tag) +} + +func newSnapshotListCommand() *cobra.Command { + var listCmd = &cobra.Command{ + Use: "list INSTANCE", + Aliases: []string{"ls"}, + Short: "List existing snapshots", + Args: cobra.MinimumNArgs(1), + RunE: snapshotListAction, + ValidArgsFunction: snapshotBashComplete, + } + listCmd.Flags().BoolP("quiet", "q", false, "Only show tags") + + return listCmd +} + +func snapshotListAction(cmd *cobra.Command, args []string) error { + instName := args[0] + + inst, err := store.Inspect(instName) + if err != nil { + return err + } + + quiet, err := cmd.Flags().GetBool("quiet") + if err != nil { + return err + } + ctx := cmd.Context() + out, err := snapshot.List(ctx, inst) + if err != nil { + return err + } + if quiet { + for i, line := range strings.Split(out, "\n") { + // "ID", "TAG", "VM SIZE", "DATE", "VM CLOCK", "ICOUNT" + fields := strings.Fields(line) + if i == 0 && len(fields) > 1 && fields[1] != "TAG" { + // make sure that output matches the expected + return fmt.Errorf("unknown header: %s", line) + } + if i == 0 || line == "" { + // skip header and empty line after using split + continue + } + tag := fields[1] + fmt.Printf("%s\n", tag) + } + return nil + } + fmt.Print(out) + return nil +} + +func snapshotBashComplete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return bashCompleteInstanceNames(cmd) +} diff --git a/hack/test-example.sh b/hack/test-example.sh index d3a7ce11158d..060984e1c948 100755 --- a/hack/test-example.sh +++ b/hack/test-example.sh @@ -22,6 +22,8 @@ declare -A CHECKS=( ["mount-home"]="1" ["containerd-user"]="1" ["restart"]="1" + ["snapshot-online"]="1" + ["snapshot-offline"]="1" ["port-forwards"]="1" ["vmnet"]="" ["disk"]="" @@ -44,6 +46,9 @@ case "$NAME" in # ● run-r2b459797f5b04262bfa79984077a65c7.service loaded failed failed /usr/bin/systemctl start man-db-cache-update CHECKS["systemd-strict"]= ;; +"9p") + CHECKS["snapshot-online"]="" + ;; "vmnet") CHECKS["vmnet"]=1 ;; @@ -329,6 +334,45 @@ if [[ -n ${CHECKS["user-v2"]} ]]; then limactl delete "$secondvm" set +x fi +if [[ -n ${CHECKS["snapshot-online"]} ]]; then + INFO "Testing online snapshots" + limactl shell "$NAME" sh -c 'echo foo > /tmp/test' + limactl snapshot create "$NAME" --tag snap1 + got=$(limactl snapshot list "$NAME" --quiet) + expected="snap1" + INFO "snapshot list: expected=${expected} got=${got}" + if [ "$got" != "$expected" ]; then + ERROR "snapshot list did not return expected value" + exit 1 + fi + limactl shell "$NAME" sh -c 'echo bar > /tmp/test' + limactl snapshot apply "$NAME" --tag snap1 + got=$(limactl shell "$NAME" cat /tmp/test) + expected="foo" + INFO "snapshot apply: expected=${expected} got=${got}" + if [ "$got" != "$expected" ]; then + ERROR "snapshot apply did not restore snapshot" + exit 1 + fi + limactl snapshot delete "$NAME" --tag snap1 + limactl shell "$NAME" rm /tmp/test +fi +if [[ -n ${CHECKS["snapshot-offline"]} ]]; then + INFO "Testing offline snapshots" + limactl stop "$NAME" + sleep 3 + limactl snapshot create "$NAME" --tag snap2 + got=$(limactl snapshot list "$NAME" --quiet) + expected="snap2" + INFO "snapshot list: expected=${expected} got=${got}" + if [ "$got" != "$expected" ]; then + ERROR "snapshot list did not return expected value" + exit 1 + fi + limactl snapshot apply "$NAME" --tag snap2 + limactl snapshot delete "$NAME" --tag snap2 + limactl start "$NAME" +fi INFO "Stopping \"$NAME\"" limactl stop "$NAME" diff --git a/pkg/driver/driver.go b/pkg/driver/driver.go index 6415567a11f0..a44b2af0787d 100644 --- a/pkg/driver/driver.go +++ b/pkg/driver/driver.go @@ -2,6 +2,7 @@ package driver import ( "context" + "fmt" "github.com/lima-vm/lima/pkg/limayaml" "github.com/lima-vm/lima/pkg/store" @@ -19,6 +20,14 @@ type Driver interface { ChangeDisplayPassword(_ context.Context, password string) error GetDisplayConnection(_ context.Context) (string, error) + + CreateSnapshot(_ context.Context, tag string) error + + ApplySnapshot(_ context.Context, tag string) error + + DeleteSnapshot(_ context.Context, tag string) error + + ListSnapshots(_ context.Context) (string, error) } type BaseDriver struct { @@ -51,3 +60,19 @@ func (d *BaseDriver) ChangeDisplayPassword(_ context.Context, password string) e func (d *BaseDriver) GetDisplayConnection(_ context.Context) (string, error) { return "", nil } + +func (d *BaseDriver) CreateSnapshot(_ context.Context, _ string) error { + return fmt.Errorf("unimplemented") +} + +func (d *BaseDriver) ApplySnapshot(_ context.Context, _ string) error { + return fmt.Errorf("unimplemented") +} + +func (d *BaseDriver) DeleteSnapshot(_ context.Context, _ string) error { + return fmt.Errorf("unimplemented") +} + +func (d *BaseDriver) ListSnapshots(_ context.Context) (string, error) { + return "", fmt.Errorf("unimplemented") +} diff --git a/pkg/qemu/qemu.go b/pkg/qemu/qemu.go index d2de1e9aee16..264433761328 100644 --- a/pkg/qemu/qemu.go +++ b/pkg/qemu/qemu.go @@ -12,10 +12,13 @@ import ( "runtime" "strconv" "strings" + "time" "github.com/lima-vm/lima/pkg/networks/usernet" "github.com/coreos/go-semver/semver" + "github.com/digitalocean/go-qemu/qmp" + "github.com/digitalocean/go-qemu/qmp/raw" "github.com/docker/go-units" "github.com/lima-vm/lima/pkg/fileutils" "github.com/lima-vm/lima/pkg/iso9660util" @@ -120,6 +123,105 @@ func CreateDataDisk(dir, format string, size int) error { return nil } +func newQmpClient(cfg Config) (*qmp.SocketMonitor, error) { + qmpSock := filepath.Join(cfg.InstanceDir, filenames.QMPSock) + qmpClient, err := qmp.NewSocketMonitor("unix", qmpSock, 5*time.Second) + if err != nil { + return nil, err + } + return qmpClient, nil +} + +func sendHmpCommand(cfg Config, cmd string, tag string) (string, error) { + qmpClient, err := newQmpClient(cfg) + if err != nil { + return "", err + } + if err := qmpClient.Connect(); err != nil { + return "", err + } + defer func() { _ = qmpClient.Disconnect() }() + rawClient := raw.NewMonitor(qmpClient) + logrus.Infof("Sending HMP %s command", cmd) + hmc := fmt.Sprintf("%s %s", cmd, tag) + return rawClient.HumanMonitorCommand(hmc, nil) +} + +func execImgCommand(cfg Config, args ...string) (string, error) { + diffDisk := filepath.Join(cfg.InstanceDir, filenames.DiffDisk) + args = append(args, diffDisk) + logrus.Debugf("Running qemu-img %v command", args) + cmd := exec.Command("qemu-img", args...) + b, err := cmd.Output() + if err != nil { + return "", err + } + return string(b), err +} + +func Del(cfg Config, run bool, tag string) error { + if run { + out, err := sendHmpCommand(cfg, "delvm", tag) + // there can still be output, even if no error! + if out != "" { + logrus.Warnf("output: %s", strings.TrimSpace(out)) + } + return err + } + // -d deletes a snapshot + _, err := execImgCommand(cfg, "snapshot", "-d", tag) + return err +} + +func Save(cfg Config, run bool, tag string) error { + if run { + out, err := sendHmpCommand(cfg, "savevm", tag) + // there can still be output, even if no error! + if out != "" { + logrus.Warnf("output: %s", strings.TrimSpace(out)) + } + return err + } + // -c creates a snapshot + _, err := execImgCommand(cfg, "snapshot", "-c", tag) + return err +} + +func Load(cfg Config, run bool, tag string) error { + if run { + out, err := sendHmpCommand(cfg, "loadvm", tag) + // there can still be output, even if no error! + if out != "" { + logrus.Warnf("output: %s", strings.TrimSpace(out)) + } + return err + } + // -a applies a snapshot + _, err := execImgCommand(cfg, "snapshot", "-a", tag) + return err +} + +// List returns a space-separated list of all snapshots, with header and newlines +func List(cfg Config, run bool) (string, error) { + if run { + out, err := sendHmpCommand(cfg, "info", "snapshots") + if err == nil { + out = strings.ReplaceAll(out, "\r", "") + out = strings.Replace(out, "List of snapshots present on all disks:\n", "", 1) + out = strings.Replace(out, "There is no snapshot available.\n", "", 1) + } + return out, err + } + // -l lists all snapshots + args := []string{"snapshot", "-l"} + out, err := execImgCommand(cfg, args...) + if err == nil { + // remove the redundant heading, result is not machine-parseable + out = strings.Replace(out, "Snapshot list:\n", "", 1) + } + return out, err +} + func argValue(args []string, key string) (string, bool) { if !strings.HasPrefix(key, "-") { panic(fmt.Errorf("got unexpected key %q", key)) diff --git a/pkg/qemu/qemu_driver.go b/pkg/qemu/qemu_driver.go index bede6183608b..e660b8c6c59e 100644 --- a/pkg/qemu/qemu_driver.go +++ b/pkg/qemu/qemu_driver.go @@ -21,6 +21,7 @@ import ( "github.com/lima-vm/lima/pkg/driver" "github.com/lima-vm/lima/pkg/limayaml" "github.com/lima-vm/lima/pkg/networks/usernet" + "github.com/lima-vm/lima/pkg/store" "github.com/lima-vm/lima/pkg/store/filenames" "github.com/sirupsen/logrus" ) @@ -265,6 +266,42 @@ func logPipeRoutine(r io.Reader, header string) { } } +func (l *LimaQemuDriver) DeleteSnapshot(ctx context.Context, tag string) error { + qCfg := Config{ + Name: l.Instance.Name, + InstanceDir: l.Instance.Dir, + LimaYAML: l.Yaml, + } + return Del(qCfg, l.Instance.Status == store.StatusRunning, tag) +} + +func (l *LimaQemuDriver) CreateSnapshot(ctx context.Context, tag string) error { + qCfg := Config{ + Name: l.Instance.Name, + InstanceDir: l.Instance.Dir, + LimaYAML: l.Yaml, + } + return Save(qCfg, l.Instance.Status == store.StatusRunning, tag) +} + +func (l *LimaQemuDriver) ApplySnapshot(ctx context.Context, tag string) error { + qCfg := Config{ + Name: l.Instance.Name, + InstanceDir: l.Instance.Dir, + LimaYAML: l.Yaml, + } + return Load(qCfg, l.Instance.Status == store.StatusRunning, tag) +} + +func (l *LimaQemuDriver) ListSnapshots(ctx context.Context) (string, error) { + qCfg := Config{ + Name: l.Instance.Name, + InstanceDir: l.Instance.Dir, + LimaYAML: l.Yaml, + } + return List(qCfg, l.Instance.Status == store.StatusRunning) +} + type qArgTemplateApplier struct { files []*os.File } diff --git a/pkg/snapshot/snapshot.go b/pkg/snapshot/snapshot.go new file mode 100644 index 000000000000..f11483e6c728 --- /dev/null +++ b/pkg/snapshot/snapshot.go @@ -0,0 +1,57 @@ +package snapshot + +import ( + "context" + + "github.com/lima-vm/lima/pkg/driver" + "github.com/lima-vm/lima/pkg/driverutil" + "github.com/lima-vm/lima/pkg/store" +) + +func Del(ctx context.Context, inst *store.Instance, tag string) error { + y, err := inst.LoadYAML() + if err != nil { + return err + } + limaDriver := driverutil.CreateTargetDriverInstance(&driver.BaseDriver{ + Instance: inst, + Yaml: y, + }) + return limaDriver.DeleteSnapshot(ctx, tag) +} + +func Save(ctx context.Context, inst *store.Instance, tag string) error { + y, err := inst.LoadYAML() + if err != nil { + return err + } + limaDriver := driverutil.CreateTargetDriverInstance(&driver.BaseDriver{ + Instance: inst, + Yaml: y, + }) + return limaDriver.CreateSnapshot(ctx, tag) +} + +func Load(ctx context.Context, inst *store.Instance, tag string) error { + y, err := inst.LoadYAML() + if err != nil { + return err + } + limaDriver := driverutil.CreateTargetDriverInstance(&driver.BaseDriver{ + Instance: inst, + Yaml: y, + }) + return limaDriver.ApplySnapshot(ctx, tag) +} + +func List(ctx context.Context, inst *store.Instance) (string, error) { + y, err := inst.LoadYAML() + if err != nil { + return "", err + } + limaDriver := driverutil.CreateTargetDriverInstance(&driver.BaseDriver{ + Instance: inst, + Yaml: y, + }) + return limaDriver.ListSnapshots(ctx) +}