Skip to content
52 changes: 52 additions & 0 deletions cmd/limactl/autostart.go
Original file line number Diff line number Diff line change
@@ -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)
}
158 changes: 158 additions & 0 deletions cmd/limactl/autostart_darwin.go
Original file line number Diff line number Diff line change
@@ -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()
}
67 changes: 67 additions & 0 deletions cmd/limactl/autostart_others.go
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions cmd/limactl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ func newApp() *cobra.Command {
newTemplateCommand(),
newRestartCommand(),
newSudoersCommand(),
newAutostartCommand(),
newStartAtLoginCommand(),
newNetworkCommand(),
newCloneCommand(),
Expand Down
1 change: 1 addition & 0 deletions cmd/limactl/start-at-login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
42 changes: 42 additions & 0 deletions pkg/autostart/autostart_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -73,6 +78,43 @@ func TestRenderTemplate(t *testing.T) {
<string>Background</string>
</dict>
</plist>
`,
GetExecutable: func() (string, error) {
return "/limactl", nil
},
WorkDir: "/some/path",
},
{
Manager: LaunchdDaemon,
Name: "render darwin launchd daemon plist",
InstanceName: "k3s",
Expected: `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>io.lima-vm.daemon.k3s</string>
<key>UserName</key>
<string>alice</string>
<key>ProgramArguments</key>
<array>
<string>/limactl</string>
<string>start</string>
<string>k3s</string>
<string>--foreground</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StandardErrorPath</key>
<string>launchd.stderr.log</string>
<key>StandardOutPath</key>
<string>launchd.stdout.log</string>
<key>WorkingDirectory</key>
<string>/some/path</string>
<key>ProcessType</key>
<string>Background</string>
</dict>
</plist>
`,
GetExecutable: func() (string, error) {
return "/limactl", nil
Expand Down
27 changes: 27 additions & 0 deletions pkg/autostart/launchd/io.lima-vm.daemon.INSTANCE.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>io.lima-vm.daemon.{{ .Instance }}</string>
<key>UserName</key>
<string>{{ .UserName }}</string>
<key>ProgramArguments</key>
<array>
<string>{{ .Binary }}</string>
<string>start</string>
<string>{{ .Instance }}</string>
<string>--foreground</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StandardErrorPath</key>
<string>launchd.stderr.log</string>
<key>StandardOutPath</key>
<string>launchd.stdout.log</string>
<key>WorkingDirectory</key>
<string>{{ .WorkDir }}</string>
<key>ProcessType</key>
<string>Background</string>
</dict>
</plist>
Loading
Loading