Skip to content
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

vz: implement auto save/restore #2900

Draft
wants to merge 7 commits into
base: master
Choose a base branch
from
Draft
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
1 change: 1 addition & 0 deletions cmd/limactl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ func newApp() *cobra.Command {
newProtectCommand(),
newUnprotectCommand(),
newTunnelCommand(),
newSaveCommand(),
)
if runtime.GOOS == "darwin" || runtime.GOOS == "linux" {
rootCmd.AddCommand(startAtLoginCommand())
Expand Down
48 changes: 48 additions & 0 deletions cmd/limactl/save.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package main

import (
"github.com/lima-vm/lima/pkg/instance"
networks "github.com/lima-vm/lima/pkg/networks/reconcile"
"github.com/lima-vm/lima/pkg/store"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)

func newSaveCommand() *cobra.Command {
saveCmd := &cobra.Command{
Use: "save INSTANCE",
Short: "Save an instance",
PersistentPreRun: func(*cobra.Command, []string) {
logrus.Warn("`limactl save` is experimental")
},
Args: WrapArgsError(cobra.MaximumNArgs(1)),
RunE: saveAction,
ValidArgsFunction: saveBashComplete,
GroupID: basicCommand,
}

return saveCmd
}

func saveAction(cmd *cobra.Command, args []string) error {
instName := DefaultInstanceName
if len(args) > 0 {
instName = args[0]
}

inst, err := store.Inspect(instName)
if err != nil {
return err
}

err = instance.StopGracefully(inst, true)
// TODO: should we also reconcile networks if graceful save returned an error?
if err == nil {
err = networks.Reconcile(cmd.Context(), "")
}
return err
}

func saveBashComplete(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return bashCompleteInstanceNames(cmd)
}
2 changes: 1 addition & 1 deletion cmd/limactl/stop.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func stopAction(cmd *cobra.Command, args []string) error {
if force {
instance.StopForcibly(inst)
} else {
err = instance.StopGracefully(inst)
err = instance.StopGracefully(inst, false)
}
// TODO: should we also reconcile networks if graceful stop returned an error?
if err == nil {
Expand Down
7 changes: 7 additions & 0 deletions pkg/driver/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ type Driver interface {

// GuestAgentConn returns the guest agent connection, or nil (if forwarded by ssh).
GuestAgentConn(_ context.Context) (net.Conn, error)

// RuntimeConfig accepts config containing changes to the runtime configuration, and returns the updated runtime configuration.
RuntimeConfig(_ context.Context, config interface{}) (interface{}, error)
}

type BaseDriver struct {
Expand Down Expand Up @@ -149,3 +152,7 @@ func (d *BaseDriver) GuestAgentConn(_ context.Context) (net.Conn, error) {
// use the unix socket forwarded by host agent
return nil, nil
}

func (d *BaseDriver) RuntimeConfig(_ context.Context, _ interface{}) (interface{}, error) {
return nil, fmt.Errorf("unimplemented")
}
35 changes: 35 additions & 0 deletions pkg/hostagent/api/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ package client
// Apache License 2.0

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"

"github.com/lima-vm/lima/pkg/hostagent/api"
Expand All @@ -16,6 +18,7 @@ import (
type HostAgentClient interface {
HTTPClient() *http.Client
Info(context.Context) (*api.Info, error)
DriverConfig(context.Context, interface{}) (interface{}, error)
Copy link
Member

Choose a reason for hiding this comment

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

Would more clear as:

// Get current driver config
DriveConfig(context.Context) (interface{}, error)

// Patch runtime config and return updated config
PatchDriveConfig(context.Context, interface{}) (interface{}, error)

}

// NewHostAgentClient creates a client.
Expand Down Expand Up @@ -62,3 +65,35 @@ func (c *client) Info(ctx context.Context) (*api.Info, error) {
}
return &info, nil
}

func (c *client) DriverConfig(ctx context.Context, config interface{}) (interface{}, error) {
u := fmt.Sprintf("http://%s/%s/driver/config", c.dummyHost, c.version)
method := "GET"
var body io.Reader
if config != nil {
method = "PATCH"
b, err := json.Marshal(config)
if err != nil {
return nil, err
}
body = bytes.NewBuffer(b)
}
Copy link
Member

Choose a reason for hiding this comment

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

Doing both GET and PATCH in the same function is confusing, both for understanding this code, and both for code using this function. If we want to avoid duplicate code for sending a request and getting a response, move the common code to a helper.

req, err := http.NewRequestWithContext(ctx, method, u, body)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if err := httpclientutil.Successful(resp); err != nil {
return nil, err
}
dec := json.NewDecoder(resp.Body)
if err := dec.Decode(&config); err != nil {
return nil, err
}
return &config, nil
}
30 changes: 30 additions & 0 deletions pkg/hostagent/api/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,36 @@ func (b *Backend) GetInfo(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(m)
}

// DriverConfig is the handler for GET /v1/driver/config and PATCH /v1/driver/config.
func (b *Backend) DriverConfig(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var config interface{}
if r.Method == http.MethodPatch {
defer r.Body.Close()
if err := json.NewDecoder(r.Body).Decode(&config); err != nil {
b.onError(w, err, http.StatusBadRequest)
return
}
}
config, err := b.Agent.DriverRuntimeConfig(ctx, config)
if err != nil {
b.onError(w, err, http.StatusInternalServerError)
return
}
m, err := json.Marshal(config)
if err != nil {
b.onError(w, err, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
Copy link
Member

Choose a reason for hiding this comment

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

Isn't OK the default?

_, _ = w.Write(m)
}

func AddRoutes(r *http.ServeMux, b *Backend) {
r.Handle("/v1/info", http.HandlerFunc(b.GetInfo))
r.Handle("GET /v1/driver/config", http.HandlerFunc(b.DriverConfig))
r.Handle("PATCH /v1/driver/config", http.HandlerFunc(b.DriverConfig))
Copy link
Member

Choose a reason for hiding this comment

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

The name of the new handler is not consistent with existing GetInfo. GetConfig and PatchConfig would be consistent.

}
4 changes: 4 additions & 0 deletions pkg/hostagent/hostagent.go
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,10 @@ func (a *HostAgent) Info(_ context.Context) (*hostagentapi.Info, error) {
return info, nil
}

func (a *HostAgent) DriverRuntimeConfig(ctx context.Context, config interface{}) (interface{}, error) {
return a.driver.RuntimeConfig(ctx, config)
}

func (a *HostAgent) startHostAgentRoutines(ctx context.Context) error {
if *a.instConfig.Plain {
logrus.Info("Running in plain mode. Mounts, port forwarding, containerd, etc. will be ignored. Guest agent will not be running.")
Expand Down
26 changes: 25 additions & 1 deletion pkg/instance/stop.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,42 @@ import (
"strings"
"time"

hostagentclient "github.com/lima-vm/lima/pkg/hostagent/api/client"
hostagentevents "github.com/lima-vm/lima/pkg/hostagent/events"
"github.com/lima-vm/lima/pkg/limayaml"
"github.com/lima-vm/lima/pkg/osutil"
"github.com/lima-vm/lima/pkg/store"
"github.com/lima-vm/lima/pkg/store/filenames"
"github.com/sirupsen/logrus"
)

func StopGracefully(inst *store.Instance) error {
func StopGracefully(inst *store.Instance, saveOnStop bool) error {
Copy link
Member

Choose a reason for hiding this comment

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

Maybe save bool or saveMachineState bool?

if inst.Status != store.StatusRunning {
return fmt.Errorf("expected status %q, got %q (maybe use `limactl stop -f`?)", store.StatusRunning, inst.Status)
}

if saveOnStop && inst.Saved {
logrus.Warn("saved VZ machine state is found. It will be overwritten by the new one.")
Copy link
Member

Choose a reason for hiding this comment

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

Why warning? this is expected condition, nothing to warn about. Use Debug?

}

if inst.VMType == limayaml.VZ {
haSock := filepath.Join(inst.Dir, filenames.HostAgentSock)
haClient, err := hostagentclient.NewHostAgentClient(haSock)
if err != nil {
logrus.WithError(err).Error("Failed to create a host agent client")
}
ctx, cancel := context.WithTimeout(context.TODO(), 3*time.Second)
defer cancel()
disableSaveOnStopConfig := struct {
Copy link
Member

Choose a reason for hiding this comment

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

Why is this "disableSaveOnStopConfig"? why not patch?

SaveOnStop bool `json:"saveOnStop"`
}{SaveOnStop: saveOnStop}
_, err = haClient.DriverConfig(ctx, disableSaveOnStopConfig)
if err != nil {
return fmt.Errorf("failed to disable saveOnStop: %w", err)
Copy link
Member

Choose a reason for hiding this comment

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

Why are we treating this as disable when the value can be true?

}
} else if saveOnStop {
return fmt.Errorf("save is not supported for %q", inst.VMType)
Copy link
Member

Choose a reason for hiding this comment

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

Doing input validation first is better and more clear, something like:

if saveOnStop && inst.VMType != limayaml.VZ {
    return fmt.Errorf(...)
}

This keeps the happy path very clear, and it is easy to very that we did not forget to handle all abnormal cases.

}
begin := time.Now() // used for logrus propagation
logrus.Infof("Sending SIGINT to hostagent process %d", inst.HostAgentPID)
if err := osutil.SysKill(inst.HostAgentPID, osutil.SigInt); err != nil {
Expand Down
27 changes: 27 additions & 0 deletions pkg/limayaml/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -855,6 +855,32 @@ func FillDefault(y, d, o *LimaYAML, filePath string, warn bool) {
y.NestedVirtualization = ptr.Of(false)
}

if y.SaveOnStop == nil {
y.SaveOnStop = d.SaveOnStop
}
if o.SaveOnStop != nil {
y.SaveOnStop = o.SaveOnStop
}
if y.SaveOnStop == nil {
y.SaveOnStop = ptr.Of(false)
}
if *y.SaveOnStop {
y.SaveOnStop = ptr.Of(false)
if *y.VMType != VZ {
logrus.Warn("saveOnStop is only supported for VM type vz")
} else if runtime.GOARCH != "arm64" {
logrus.Warn("saveOnStop is only supported for arm64 VM type vz")
} else if runtime.GOOS != "darwin" {
logrus.Warn("saveOnStop is only supported on macOS")
} else if macOSProductVersion, err := osutil.ProductVersion(); err != nil {
logrus.WithError(err).Warn("Failed to get macOS product version")
} else if macOSProductVersion.LessThan(*semver.New("14.0.0")) {
logrus.Warn("saveOnStop is not supported on macOS prior to 14.0")
} else {
Copy link
Member

Choose a reason for hiding this comment

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

This will more clear if we extract all the checks to a helper like:

if *y.SaveOnStop {
    y.SaveOnStop = validateSaveOnStop(y)
}

In the helper can do something like:

func validateSaveOnStop() *bool {
    notSupported := ptr.Of(false)
    if condition {
        log ...
        return notSupported
    }
    if other condition {
        log ...
        return notSupported
    }
    ...
    return ptr.Of(true)
}

y.SaveOnStop = ptr.Of(true)
}
}

if y.Plain == nil {
y.Plain = d.Plain
}
Expand All @@ -878,6 +904,7 @@ func fixUpForPlainMode(y *LimaYAML) {
y.Containerd.User = ptr.Of(false)
y.Rosetta.BinFmt = ptr.Of(false)
y.Rosetta.Enabled = ptr.Of(false)
y.SaveOnStop = ptr.Of(false)
y.TimeZone = ptr.Of("")
}

Expand Down
6 changes: 6 additions & 0 deletions pkg/limayaml/defaults_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ func TestFillDefault(t *testing.T) {
RemoveDefaults: ptr.Of(false),
},
NestedVirtualization: ptr.Of(false),
SaveOnStop: ptr.Of(false),
Plain: ptr.Of(false),
User: User{
Name: ptr.Of(user.Username),
Expand Down Expand Up @@ -437,6 +438,7 @@ func TestFillDefault(t *testing.T) {
BinFmt: ptr.Of(true),
},
NestedVirtualization: ptr.Of(true),
SaveOnStop: ptr.Of(true),
User: User{
Name: ptr.Of("xxx"),
Comment: ptr.Of("Foo Bar"),
Expand Down Expand Up @@ -474,11 +476,13 @@ func TestFillDefault(t *testing.T) {
Enabled: ptr.Of(true),
BinFmt: ptr.Of(true),
}
expect.SaveOnStop = ptr.Of(true)
} else {
expect.Rosetta = Rosetta{
Enabled: ptr.Of(false),
BinFmt: ptr.Of(true),
}
expect.SaveOnStop = ptr.Of(false)
}
expect.Plain = ptr.Of(false)

Expand Down Expand Up @@ -660,6 +664,7 @@ func TestFillDefault(t *testing.T) {
BinFmt: ptr.Of(false),
},
NestedVirtualization: ptr.Of(false),
SaveOnStop: ptr.Of(false),
User: User{
Name: ptr.Of("foo"),
Comment: ptr.Of("foo bar baz"),
Expand Down Expand Up @@ -723,6 +728,7 @@ func TestFillDefault(t *testing.T) {
expect.Plain = ptr.Of(false)

expect.NestedVirtualization = ptr.Of(false)
expect.SaveOnStop = ptr.Of(false)

FillDefault(&y, &d, &o, filePath, false)
assert.DeepEqual(t, &y, &expect, opts...)
Expand Down
1 change: 1 addition & 0 deletions pkg/limayaml/limayaml.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type LimaYAML struct {
TimeZone *string `yaml:"timezone,omitempty" json:"timezone,omitempty" jsonschema:"nullable"`
NestedVirtualization *bool `yaml:"nestedVirtualization,omitempty" json:"nestedVirtualization,omitempty" jsonschema:"nullable"`
User User `yaml:"user,omitempty" json:"user,omitempty"`
SaveOnStop *bool `yaml:"saveOnStop,omitempty" json:"saveOnStop,omitempty" jsonschema:"nullable"`
}

type (
Expand Down
3 changes: 3 additions & 0 deletions pkg/limayaml/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -523,4 +523,7 @@ func warnExperimental(y *LimaYAML) {
if y.MountInotify != nil && *y.MountInotify {
logrus.Warn("`mountInotify` is experimental")
}
if y.SaveOnStop != nil && *y.SaveOnStop {
logrus.Warn("`saveOnStop` is experimental")
}
Copy link
Member

Choose a reason for hiding this comment

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

This makes sense but we should add it to the commit adding the option. Do yo plan to squash all the commits to one commit later?

}
1 change: 1 addition & 0 deletions pkg/store/filenames/filenames.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const (
HostAgentStderrLog = "ha.stderr.log"
VzIdentifier = "vz-identifier"
VzEfi = "vz-efi" // efi variable store
VzMachineState = "vz-machine-state" // machine state file
QemuEfiCodeFD = "qemu-efi-code.fd" // efi code; not always created
AnsibleInventoryYAML = "ansible-inventory.yaml"

Expand Down
9 changes: 9 additions & 0 deletions pkg/store/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ type Instance struct {
// Hostname, not HostName (corresponds to SSH's naming convention)
Hostname string `json:"hostname"`
Status Status `json:"status"`
Saved bool `json:"saved"`
Copy link
Member

Choose a reason for hiding this comment

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

Can document here what saved mean?

Dir string `json:"dir"`
VMType limayaml.VMType `json:"vmType"`
Arch limayaml.Arch `json:"arch"`
Expand Down Expand Up @@ -147,6 +148,14 @@ func Inspect(instName string) (*Instance, error) {
}

inspectStatus(instDir, inst, y)
_, err = os.Stat(filepath.Join(instDir, filenames.VzMachineState))
if err == nil {
inst.Saved = true
} else if errors.Is(err, os.ErrNotExist) {
inst.Saved = false
} else {
inst.Errors = append(inst.Errors, fmt.Errorf("cannot determine whether the instance is saved: %w", err))
}

tmpl, err := template.New("format").Parse(y.Message)
if err != nil {
Expand Down
18 changes: 18 additions & 0 deletions pkg/vz/save_restore.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//go:build darwin && !arm64 && !no_vz

package vz

import (
"fmt"
"runtime"

"github.com/Code-Hex/vz/v3"
)

func saveVM(vm *vz.VirtualMachine, machineStatePath string) error {
return fmt.Errorf("save is not supported on the vz driver for the architecture %s", runtime.GOARCH)
}

func restoreVM(vm *vz.VirtualMachine, machineStatePath string) error {
return fmt.Errorf("restore is not supported on the vz driver for the architecture %s", runtime.GOARCH)
}
Loading
Loading