diff --git a/cmd/limactl/autostart.go b/cmd/limactl/autostart.go new file mode 100644 index 00000000000..1e3d5c7cf73 --- /dev/null +++ b/cmd/limactl/autostart.go @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "github.com/spf13/cobra" +) + +func newAutostartCommand() *cobra.Command { + autostartCommand := &cobra.Command{ + Use: "autostart", + Short: "Manage automatic startup of Lima instances", + GroupID: advancedCommand, + } + autostartCommand.AddCommand(newAutostartEnableCommand(), newAutostartDisableCommand()) + return autostartCommand +} + +func newAutostartEnableCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "enable INSTANCE", + Short: "Register an instance to start automatically", + Args: WrapArgsError(cobra.ExactArgs(1)), + RunE: autostartEnableAction, + ValidArgsFunction: autostartComplete, + } + flags := cmd.Flags() + flags.String( + "condition", "login", + "When to start the instance: \"login\" (user session) or \"boot\" (system boot, macOS only)", + ) + flags.String( + "user", "", + "macOS username to run the instance as when --condition=boot (default: $USER)", + ) + return cmd +} + +func newAutostartDisableCommand() *cobra.Command { + return &cobra.Command{ + Use: "disable INSTANCE", + Short: "Unregister an instance from automatic startup", + Args: WrapArgsError(cobra.ExactArgs(1)), + RunE: autostartDisableAction, + ValidArgsFunction: autostartComplete, + } +} + +func autostartComplete(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return bashCompleteInstanceNames(cmd) +} diff --git a/cmd/limactl/autostart_darwin.go b/cmd/limactl/autostart_darwin.go new file mode 100644 index 00000000000..dc5cfdb9680 --- /dev/null +++ b/cmd/limactl/autostart_darwin.go @@ -0,0 +1,158 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "github.com/lima-vm/lima/v2/pkg/autostart" + "github.com/lima-vm/lima/v2/pkg/autostart/launchd" + "github.com/lima-vm/lima/v2/pkg/store" + "github.com/lima-vm/lima/v2/pkg/textutil" +) + +func autostartEnableAction(cmd *cobra.Command, args []string) error { + condition, err := cmd.Flags().GetString("condition") + if err != nil { + return err + } + + ctx := cmd.Context() + inst, err := store.Inspect(ctx, args[0]) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("instance %q not found", args[0]) + } + return err + } + + switch condition { + case "login": + if err := autostart.RegisterToStartAtLogin(ctx, inst); err != nil { + return fmt.Errorf("failed to register instance %#q to start at login: %w", inst.Name, err) + } + logrus.Infof("Instance %#q registered to start at login", inst.Name) + case "boot": + userName, err := cmd.Flags().GetString("user") + if err != nil { + return err + } + if userName == "" { + userName = os.Getenv("USER") + } + if userName == "" { + return errors.New("could not determine user; pass --user") + } + return daemonInstall(ctx, inst.Name, inst.Dir, userName) + default: + return fmt.Errorf("unknown condition %q: must be \"login\" or \"boot\"", condition) + } + return nil +} + +func autostartDisableAction(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + inst, err := store.Inspect(ctx, args[0]) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("instance %q not found", args[0]) + } + return err + } + + // Check for a LaunchAgent (login) registration first. + if registered, err := autostart.IsRegistered(ctx, inst); err != nil { + return err + } else if registered { + if err := autostart.UnregisterFromStartAtLogin(ctx, inst); err != nil { + return fmt.Errorf("failed to unregister instance %#q from start at login: %w", inst.Name, err) + } + logrus.Infof("Instance %#q unregistered from start at login", inst.Name) + return nil + } + + // Check for a LaunchDaemon (boot) installation. + daemonPath := launchd.GetDaemonPlistPath(inst.Name) + if _, err := os.Stat(daemonPath); err == nil { + return daemonUninstall(ctx, inst.Name) + } + + logrus.Infof("Instance %#q is not registered for automatic startup", inst.Name) + return nil +} + +func daemonInstall(ctx context.Context, instName, workDir, userName string) error { + selfExe, err := os.Executable() + if err != nil { + return fmt.Errorf("could not determine limactl path: %w", err) + } + + content, err := textutil.ExecuteTemplate(launchd.DaemonTemplate, map[string]string{ + "Binary": selfExe, + "Instance": instName, + "WorkDir": workDir, + "UserName": userName, + }) + if err != nil { + return fmt.Errorf("failed to render daemon plist: %w", err) + } + + tmp, err := os.CreateTemp("", "io.lima-vm.daemon.*.plist") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + defer os.Remove(tmp.Name()) + if _, err := tmp.Write(content); err != nil { + return fmt.Errorf("failed to write temp plist: %w", err) + } + tmp.Close() + + destPath := launchd.GetDaemonPlistPath(instName) + svcTarget := "system/" + launchd.DaemonServiceNameFrom(instName) + + _ = runSudo(ctx, "launchctl", "bootout", svcTarget) + if err := runSudo(ctx, "install", "-m", "644", tmp.Name(), destPath); err != nil { + return fmt.Errorf("failed to install plist to %s: %w", destPath, err) + } + if err := runSudo(ctx, "launchctl", "enable", svcTarget); err != nil { + return fmt.Errorf("failed to enable LaunchDaemon: %w", err) + } + if err := runSudo(ctx, "launchctl", "bootstrap", "system", destPath); err != nil { + return fmt.Errorf("failed to bootstrap LaunchDaemon: %w", err) + } + + logrus.Infof("LaunchDaemon installed for instance %q (runs as %q at boot)", instName, userName) + logrus.Infof("Plist: %s", destPath) + return nil +} + +func daemonUninstall(ctx context.Context, instName string) error { + svcTarget := "system/" + launchd.DaemonServiceNameFrom(instName) + destPath := launchd.GetDaemonPlistPath(instName) + + _ = runSudo(ctx, "launchctl", "bootout", svcTarget) + _ = runSudo(ctx, "launchctl", "disable", svcTarget) + if err := runSudo(ctx, "rm", "-f", destPath); err != nil { + return fmt.Errorf("failed to remove %s: %w", destPath, err) + } + logrus.Infof("LaunchDaemon uninstalled for instance %q", instName) + return nil +} + +// runSudo executes a command under sudo, inheriting the terminal so password prompts work. +func runSudo(ctx context.Context, args ...string) error { + cmd := exec.CommandContext(ctx, "sudo", args...) //nolint:gosec // args are constructed internally, not from user input + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + logrus.Debugf("running: sudo %v", args) + return cmd.Run() +} diff --git a/cmd/limactl/autostart_others.go b/cmd/limactl/autostart_others.go new file mode 100644 index 00000000000..a888aa8930b --- /dev/null +++ b/cmd/limactl/autostart_others.go @@ -0,0 +1,67 @@ +//go:build !darwin + +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "errors" + "fmt" + "os" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "github.com/lima-vm/lima/v2/pkg/autostart" + "github.com/lima-vm/lima/v2/pkg/store" +) + +func autostartEnableAction(cmd *cobra.Command, args []string) error { + condition, err := cmd.Flags().GetString("condition") + if err != nil { + return err + } + if condition == "boot" { + return errors.New("--condition=boot is only supported on macOS") + } + + ctx := cmd.Context() + inst, err := store.Inspect(ctx, args[0]) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("instance %q not found", args[0]) + } + return err + } + + if err := autostart.RegisterToStartAtLogin(ctx, inst); err != nil { + return fmt.Errorf("failed to register instance %#q to start at login: %w", inst.Name, err) + } + logrus.Infof("Instance %#q registered to start at login", inst.Name) + return nil +} + +func autostartDisableAction(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + inst, err := store.Inspect(ctx, args[0]) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("instance %q not found", args[0]) + } + return err + } + + if registered, err := autostart.IsRegistered(ctx, inst); err != nil { + return err + } else if !registered { + logrus.Infof("Instance %#q is not registered for automatic startup", inst.Name) + return nil + } + + if err := autostart.UnregisterFromStartAtLogin(ctx, inst); err != nil { + return fmt.Errorf("failed to unregister instance %#q from start at login: %w", inst.Name, err) + } + logrus.Infof("Instance %#q unregistered from start at login", inst.Name) + return nil +} diff --git a/cmd/limactl/main.go b/cmd/limactl/main.go index 31f322f6930..9ed78b3eb85 100644 --- a/cmd/limactl/main.go +++ b/cmd/limactl/main.go @@ -205,6 +205,7 @@ func newApp() *cobra.Command { newTemplateCommand(), newRestartCommand(), newSudoersCommand(), + newAutostartCommand(), newStartAtLoginCommand(), newNetworkCommand(), newCloneCommand(), diff --git a/cmd/limactl/start-at-login.go b/cmd/limactl/start-at-login.go index b7ca2aaf0b2..ba9fb013fb8 100644 --- a/cmd/limactl/start-at-login.go +++ b/cmd/limactl/start-at-login.go @@ -11,6 +11,7 @@ func newStartAtLoginCommand() *cobra.Command { startAtLoginCommand := &cobra.Command{ Use: "start-at-login INSTANCE", Short: "Register/Unregister an autostart file for the instance", + Deprecated: "use \"limactl autostart\" instead", Args: WrapArgsError(cobra.MaximumNArgs(1)), RunE: startAtLoginAction, ValidArgsFunction: startAtLoginComplete, diff --git a/pkg/autostart/autostart_test.go b/pkg/autostart/autostart_test.go index bc6c274d9c7..170e72095c9 100644 --- a/pkg/autostart/autostart_test.go +++ b/pkg/autostart/autostart_test.go @@ -22,6 +22,11 @@ var ( requestStart: launchd.RequestStart, requestStop: launchd.RequestStop, } + LaunchdDaemon = &TemplateFileBasedManager{ + filePath: launchd.GetDaemonPlistPath, + template: launchd.DaemonTemplate, + extraTemplateVars: map[string]string{"UserName": "alice"}, + } Systemd = &TemplateFileBasedManager{ filePath: systemd.GetUnitPath, template: systemd.Template, @@ -73,6 +78,43 @@ func TestRenderTemplate(t *testing.T) { Background +`, + GetExecutable: func() (string, error) { + return "/limactl", nil + }, + WorkDir: "/some/path", + }, + { + Manager: LaunchdDaemon, + Name: "render darwin launchd daemon plist", + InstanceName: "k3s", + Expected: ` + + + + Label + io.lima-vm.daemon.k3s + UserName + alice + ProgramArguments + + /limactl + start + k3s + --foreground + + RunAtLoad + + StandardErrorPath + launchd.stderr.log + StandardOutPath + launchd.stdout.log + WorkingDirectory + /some/path + ProcessType + Background + + `, GetExecutable: func() (string, error) { return "/limactl", nil diff --git a/pkg/autostart/launchd/io.lima-vm.daemon.INSTANCE.plist b/pkg/autostart/launchd/io.lima-vm.daemon.INSTANCE.plist new file mode 100644 index 00000000000..a846980869f --- /dev/null +++ b/pkg/autostart/launchd/io.lima-vm.daemon.INSTANCE.plist @@ -0,0 +1,27 @@ + + + + + Label + io.lima-vm.daemon.{{ .Instance }} + UserName + {{ .UserName }} + ProgramArguments + + {{ .Binary }} + start + {{ .Instance }} + --foreground + + RunAtLoad + + StandardErrorPath + launchd.stderr.log + StandardOutPath + launchd.stdout.log + WorkingDirectory + {{ .WorkDir }} + ProcessType + Background + + diff --git a/pkg/autostart/launchd/launchd.go b/pkg/autostart/launchd/launchd.go index 3ec52980ed2..afdce13d163 100644 --- a/pkg/autostart/launchd/launchd.go +++ b/pkg/autostart/launchd/launchd.go @@ -19,6 +19,9 @@ import ( //go:embed io.lima-vm.autostart.INSTANCE.plist var Template string +//go:embed io.lima-vm.daemon.INSTANCE.plist +var DaemonTemplate string + // GetPlistPath returns the path to the launchd plist file for the given instance name. func GetPlistPath(instName string) string { return fmt.Sprintf("%s/Library/LaunchAgents/%s.plist", os.Getenv("HOME"), ServiceNameFrom(instName)) @@ -95,3 +98,13 @@ func RequestStop(ctx context.Context, inst *limatype.Instance) (bool, error) { } return false, nil } + +// GetDaemonPlistPath returns the path to the system LaunchDaemon plist for the given instance. +func GetDaemonPlistPath(instName string) string { + return fmt.Sprintf("/Library/LaunchDaemons/%s.plist", DaemonServiceNameFrom(instName)) +} + +// DaemonServiceNameFrom returns the launchd daemon service name for the given instance name. +func DaemonServiceNameFrom(instName string) string { + return fmt.Sprintf("io.lima-vm.daemon.%s", instName) +} diff --git a/pkg/autostart/launchd/launchd_test.go b/pkg/autostart/launchd/launchd_test.go index b26943cb633..de20ef4a4e0 100644 --- a/pkg/autostart/launchd/launchd_test.go +++ b/pkg/autostart/launchd/launchd_test.go @@ -30,3 +30,51 @@ func TestGetPlistPath(t *testing.T) { }) } } + +func TestGetDaemonPlistPath(t *testing.T) { + tests := []struct { + Name string + InstanceName string + Expected string + }{ + { + Name: "k3s instance", + InstanceName: "k3s", + Expected: "/Library/LaunchDaemons/io.lima-vm.daemon.k3s.plist", + }, + { + Name: "default instance", + InstanceName: "default", + Expected: "/Library/LaunchDaemons/io.lima-vm.daemon.default.plist", + }, + } + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + assert.Equal(t, GetDaemonPlistPath(tt.InstanceName), tt.Expected) + }) + } +} + +func TestDaemonServiceNameFrom(t *testing.T) { + tests := []struct { + Name string + InstanceName string + Expected string + }{ + { + Name: "k3s instance", + InstanceName: "k3s", + Expected: "io.lima-vm.daemon.k3s", + }, + { + Name: "default instance", + InstanceName: "default", + Expected: "io.lima-vm.daemon.default", + }, + } + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + assert.Equal(t, DaemonServiceNameFrom(tt.InstanceName), tt.Expected) + }) + } +} diff --git a/pkg/autostart/managers.go b/pkg/autostart/managers.go index 87480dbb9b0..4d2475ef483 100644 --- a/pkg/autostart/managers.go +++ b/pkg/autostart/managers.go @@ -8,6 +8,7 @@ import ( "context" "errors" "fmt" + "maps" "os" "path/filepath" "runtime" @@ -51,6 +52,7 @@ type TemplateFileBasedManager struct { enabler func(ctx context.Context, enable bool, instName string) error filePath func(instName string) string template string + extraTemplateVars map[string]string autoStartedIdentifier func() string requestStart func(ctx context.Context, inst *limatype.Instance) error requestStop func(ctx context.Context, inst *limatype.Instance) (bool, error) @@ -118,13 +120,13 @@ func (t *TemplateFileBasedManager) renderTemplate(instName, workDir string, getE if err != nil { return nil, err } - return textutil.ExecuteTemplate( - t.template, - map[string]string{ - "Binary": selfExeAbs, - "Instance": instName, - "WorkDir": workDir, - }) + data := map[string]string{ + "Binary": selfExeAbs, + "Instance": instName, + "WorkDir": workDir, + } + maps.Copy(data, t.extraTemplateVars) + return textutil.ExecuteTemplate(t.template, data) } func (t *TemplateFileBasedManager) AutoStartedIdentifier() string { diff --git a/pkg/autostart/managers_darwin.go b/pkg/autostart/managers_darwin.go index 8451c4ce8dc..ff998e69319 100644 --- a/pkg/autostart/managers_darwin.go +++ b/pkg/autostart/managers_darwin.go @@ -16,3 +16,16 @@ func Manager() autoStartManager { requestStop: launchd.RequestStop, } } + +// DaemonManager returns an autostart manager for rendering and tracking system LaunchDaemon plists. +// The userName is the macOS user the daemon will run as. +// Note: install/uninstall require privileged operations (writing to /Library/LaunchDaemons/ and +// interacting with the system launchctl domain) that are handled by the `limactl daemon` CLI +// command via sudo rather than by this manager directly. +func DaemonManager(userName string) autoStartManager { + return &TemplateFileBasedManager{ + filePath: launchd.GetDaemonPlistPath, + template: launchd.DaemonTemplate, + extraTemplateVars: map[string]string{"UserName": userName}, + } +} diff --git a/pkg/autostart/managers_linux.go b/pkg/autostart/managers_linux.go index bbe334331b2..0996d2c5c02 100644 --- a/pkg/autostart/managers_linux.go +++ b/pkg/autostart/managers_linux.go @@ -5,6 +5,11 @@ package autostart import "github.com/lima-vm/lima/v2/pkg/autostart/systemd" +// DaemonManager is not supported on Linux; use systemd user services instead. +func DaemonManager(_ string) autoStartManager { + return ¬SupportedManager{} +} + // Manager returns the autostart manager for Linux. func Manager() autoStartManager { if systemd.IsRunningSystemd() { diff --git a/pkg/autostart/managers_others.go b/pkg/autostart/managers_others.go index f8c864ab397..b35ecfb7619 100644 --- a/pkg/autostart/managers_others.go +++ b/pkg/autostart/managers_others.go @@ -9,3 +9,8 @@ package autostart func Manager() autoStartManager { return ¬SupportedManager{} } + +// DaemonManager is not supported on this OS. +func DaemonManager(_ string) autoStartManager { + return ¬SupportedManager{} +} diff --git a/website/content/en/docs/releases/deprecated.md b/website/content/en/docs/releases/deprecated.md index 75719d42fa9..b1d6cc462eb 100644 --- a/website/content/en/docs/releases/deprecated.md +++ b/website/content/en/docs/releases/deprecated.md @@ -11,6 +11,7 @@ The following features are deprecated: - Environment variable `LIMA_SSH_OVER_VSOCK`: deprecated in Lima v2.0.2 (Use the YAML property `.ssh.overVsock`) - YAML property `cpuType`: deprecated in Lima v2.0.0 (Use `vmOpts.qemu.cpuType` instead) - YAML property `rosetta`: deprecated in Lima v2.0.0 (Use `vmOpts.vz.rosetta` instead) +- `limactl start-at-login` command: deprecated in Lima v2.2.0 (Use `limactl autostart` instead, which also adds support for starting instances at system boot via `--condition=boot`) ## Removed features - YAML property `network`: deprecated in [Lima v0.7.0](https://github.com/lima-vm/lima/commit/07e68230e70b21108d2db3ca5e0efd0e43842fbd) diff --git a/website/content/en/docs/security/_index.md b/website/content/en/docs/security/_index.md index eb5429bb7c7..74b8c7023bf 100644 --- a/website/content/en/docs/security/_index.md +++ b/website/content/en/docs/security/_index.md @@ -28,7 +28,7 @@ sudo softwareupdate --install "Name of the Update" Alternatively , you can set the [`upgradePackages`](https://github.com/lima-vm/lima/blob/a28905cb1bd332cc7178c30f4e42d4c6bf1b2a34/templates/default.yaml#L181) in your template to `true` for most Linux distributions (except `alpine-iso`, for example). -> ⚠️ Rapidly updating can reduce exposure to known CVEs, but it can also increase exposure to upstream supply chain compromises (for example, [the XZ backdoor](https://en.wikipedia.org/wiki/XZ_Utils_backdoor)). +> ⚠️ Rapidly updating can reduce exposure to known CVEs, but it can also increase exposure to upstream supply chain compromises (for example, [the XZ backdoor](https://en.wikipedia.org/wiki/XZ_Utils_backdoor)). ## Security model diff --git a/website/content/en/docs/usage/autostart.md b/website/content/en/docs/usage/autostart.md new file mode 100644 index 00000000000..c29d5da06ac --- /dev/null +++ b/website/content/en/docs/usage/autostart.md @@ -0,0 +1,61 @@ +--- +title: Automatic Startup +weight: 4 +--- + +| ⚡ Requirement | Lima >= 2.2 | +|----------------|-------------| + +Lima instances can be registered to start automatically using `limactl autostart`. +Two conditions are supported: `login` (start when the user logs in) and `boot` +(start at system boot, before any user session). This replaces the older +`limactl start-at-login` command, which is deprecated as of Lima v2.2. + +## Starting instances automatically + +Use `limactl autostart enable` to register a Lima instance to start automatically. +Use `limactl autostart disable` to remove the registration. + +### At user login (macOS and Linux) + +```bash +# Register +limactl autostart enable default + +# Unregister +limactl autostart disable default +``` + +On macOS this installs a LaunchAgent in `~/Library/LaunchAgents/`. On Linux it +installs a systemd user service. The instance starts in the background on the +next login and on subsequent logins. + +### At system boot, without a user session (macOS only) + +For headless macOS servers where no user session is expected, use +`--condition=boot`. This installs a system LaunchDaemon that starts the instance +at boot, before any user logs in. + +```bash +# Register (prompts for sudo once) +limactl autostart enable --condition=boot k3s + +# Unregister +limactl autostart disable k3s +``` + +The `--user` flag specifies which macOS user the instance runs as (default: +`$USER`). The plist is installed to +`/Library/LaunchDaemons/io.lima-vm.daemon..plist`. + +## Lima < 2.2 + +Use `limactl start-at-login` (equivalent to `limactl autostart enable --condition=login`): + +```bash +# Register +limactl start-at-login default + +# Unregister +limactl start-at-login --enabled=false default +```