Skip to content

Commit f3b3abb

Browse files
committed
feat(autostart): add LaunchDaemon support for headless macOS servers
Adds `limactl autostart enable --condition=boot` to register a Lima instance as a system-level LaunchDaemon. The VM starts at boot without requiring a user login session, enabling headless macOS server deployments. Also unifies the existing start-at-login functionality under the new `limactl autostart` command and deprecates `limactl start-at-login`. - pkg/autostart/launchd: add DaemonManager, plist template, and helpers - pkg/autostart/managers.go: add extraTemplateVars field - pkg/autostart/managers_darwin.go: DaemonManager constructor - pkg/autostart/managers_{linux,others}.go: unsupported stubs - cmd/limactl/autostart.go: cross-platform autostart command group - cmd/limactl/autostart_darwin.go: login → LaunchAgent, boot → LaunchDaemon - cmd/limactl/autostart_others.go: login only, boot returns unsupported error - cmd/limactl/start-at-login.go: marked deprecated - website/content/en/docs/usage/autostart.md: new documentation page Signed-off-by: Robert Esker <resker@gmail.com>
1 parent 09f37ab commit f3b3abb

16 files changed

Lines changed: 504 additions & 8 deletions

cmd/limactl/autostart.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// SPDX-FileCopyrightText: Copyright The Lima Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package main
5+
6+
import (
7+
"github.com/spf13/cobra"
8+
)
9+
10+
func newAutostartCommand() *cobra.Command {
11+
autostartCommand := &cobra.Command{
12+
Use: "autostart",
13+
Short: "Manage automatic startup of Lima instances",
14+
GroupID: advancedCommand,
15+
}
16+
autostartCommand.AddCommand(newAutostartEnableCommand(), newAutostartDisableCommand())
17+
return autostartCommand
18+
}
19+
20+
func newAutostartEnableCommand() *cobra.Command {
21+
cmd := &cobra.Command{
22+
Use: "enable INSTANCE",
23+
Short: "Register an instance to start automatically",
24+
Args: WrapArgsError(cobra.ExactArgs(1)),
25+
RunE: autostartEnableAction,
26+
ValidArgsFunction: autostartComplete,
27+
}
28+
flags := cmd.Flags()
29+
flags.String(
30+
"condition", "login",
31+
"When to start the instance: \"login\" (user session) or \"boot\" (system boot, macOS only)",
32+
)
33+
flags.String(
34+
"user", "",
35+
"macOS username to run the instance as when --condition=boot (default: $USER)",
36+
)
37+
return cmd
38+
}
39+
40+
func newAutostartDisableCommand() *cobra.Command {
41+
return &cobra.Command{
42+
Use: "disable INSTANCE",
43+
Short: "Unregister an instance from automatic startup",
44+
Args: WrapArgsError(cobra.ExactArgs(1)),
45+
RunE: autostartDisableAction,
46+
ValidArgsFunction: autostartComplete,
47+
}
48+
}
49+
50+
func autostartComplete(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
51+
return bashCompleteInstanceNames(cmd)
52+
}

cmd/limactl/autostart_darwin.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// SPDX-FileCopyrightText: Copyright The Lima Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package main
5+
6+
import (
7+
"context"
8+
"errors"
9+
"fmt"
10+
"os"
11+
"os/exec"
12+
13+
"github.com/sirupsen/logrus"
14+
"github.com/spf13/cobra"
15+
16+
"github.com/lima-vm/lima/v2/pkg/autostart"
17+
"github.com/lima-vm/lima/v2/pkg/autostart/launchd"
18+
"github.com/lima-vm/lima/v2/pkg/store"
19+
"github.com/lima-vm/lima/v2/pkg/textutil"
20+
)
21+
22+
func autostartEnableAction(cmd *cobra.Command, args []string) error {
23+
condition, err := cmd.Flags().GetString("condition")
24+
if err != nil {
25+
return err
26+
}
27+
28+
ctx := cmd.Context()
29+
inst, err := store.Inspect(ctx, args[0])
30+
if err != nil {
31+
if errors.Is(err, os.ErrNotExist) {
32+
return fmt.Errorf("instance %q not found", args[0])
33+
}
34+
return err
35+
}
36+
37+
switch condition {
38+
case "login":
39+
if err := autostart.RegisterToStartAtLogin(ctx, inst); err != nil {
40+
return fmt.Errorf("failed to register instance %#q to start at login: %w", inst.Name, err)
41+
}
42+
logrus.Infof("Instance %#q registered to start at login", inst.Name)
43+
case "boot":
44+
userName, err := cmd.Flags().GetString("user")
45+
if err != nil {
46+
return err
47+
}
48+
if userName == "" {
49+
userName = os.Getenv("USER")
50+
}
51+
if userName == "" {
52+
return errors.New("could not determine user; pass --user")
53+
}
54+
return daemonInstall(ctx, inst.Name, inst.Dir, userName)
55+
default:
56+
return fmt.Errorf("unknown condition %q: must be \"login\" or \"boot\"", condition)
57+
}
58+
return nil
59+
}
60+
61+
func autostartDisableAction(cmd *cobra.Command, args []string) error {
62+
ctx := cmd.Context()
63+
inst, err := store.Inspect(ctx, args[0])
64+
if err != nil {
65+
if errors.Is(err, os.ErrNotExist) {
66+
return fmt.Errorf("instance %q not found", args[0])
67+
}
68+
return err
69+
}
70+
71+
// Check for a LaunchAgent (login) registration first.
72+
if registered, err := autostart.IsRegistered(ctx, inst); err != nil {
73+
return err
74+
} else if registered {
75+
if err := autostart.UnregisterFromStartAtLogin(ctx, inst); err != nil {
76+
return fmt.Errorf("failed to unregister instance %#q from start at login: %w", inst.Name, err)
77+
}
78+
logrus.Infof("Instance %#q unregistered from start at login", inst.Name)
79+
return nil
80+
}
81+
82+
// Check for a LaunchDaemon (boot) installation.
83+
daemonPath := launchd.GetDaemonPlistPath(inst.Name)
84+
if _, err := os.Stat(daemonPath); err == nil {
85+
return daemonUninstall(ctx, inst.Name)
86+
}
87+
88+
logrus.Infof("Instance %#q is not registered for automatic startup", inst.Name)
89+
return nil
90+
}
91+
92+
func daemonInstall(ctx context.Context, instName, workDir, userName string) error {
93+
selfExe, err := os.Executable()
94+
if err != nil {
95+
return fmt.Errorf("could not determine limactl path: %w", err)
96+
}
97+
98+
content, err := textutil.ExecuteTemplate(launchd.DaemonTemplate, map[string]string{
99+
"Binary": selfExe,
100+
"Instance": instName,
101+
"WorkDir": workDir,
102+
"UserName": userName,
103+
})
104+
if err != nil {
105+
return fmt.Errorf("failed to render daemon plist: %w", err)
106+
}
107+
108+
tmp, err := os.CreateTemp("", "io.lima-vm.daemon.*.plist")
109+
if err != nil {
110+
return fmt.Errorf("failed to create temp file: %w", err)
111+
}
112+
defer os.Remove(tmp.Name())
113+
if _, err := tmp.Write(content); err != nil {
114+
return fmt.Errorf("failed to write temp plist: %w", err)
115+
}
116+
tmp.Close()
117+
118+
destPath := launchd.GetDaemonPlistPath(instName)
119+
svcTarget := "system/" + launchd.DaemonServiceNameFrom(instName)
120+
121+
_ = runSudo(ctx, "launchctl", "bootout", svcTarget)
122+
if err := runSudo(ctx, "install", "-m", "644", tmp.Name(), destPath); err != nil {
123+
return fmt.Errorf("failed to install plist to %s: %w", destPath, err)
124+
}
125+
if err := runSudo(ctx, "launchctl", "enable", svcTarget); err != nil {
126+
return fmt.Errorf("failed to enable LaunchDaemon: %w", err)
127+
}
128+
if err := runSudo(ctx, "launchctl", "bootstrap", "system", destPath); err != nil {
129+
return fmt.Errorf("failed to bootstrap LaunchDaemon: %w", err)
130+
}
131+
132+
logrus.Infof("LaunchDaemon installed for instance %q (runs as %q at boot)", instName, userName)
133+
logrus.Infof("Plist: %s", destPath)
134+
return nil
135+
}
136+
137+
func daemonUninstall(ctx context.Context, instName string) error {
138+
svcTarget := "system/" + launchd.DaemonServiceNameFrom(instName)
139+
destPath := launchd.GetDaemonPlistPath(instName)
140+
141+
_ = runSudo(ctx, "launchctl", "bootout", svcTarget)
142+
_ = runSudo(ctx, "launchctl", "disable", svcTarget)
143+
if err := runSudo(ctx, "rm", "-f", destPath); err != nil {
144+
return fmt.Errorf("failed to remove %s: %w", destPath, err)
145+
}
146+
logrus.Infof("LaunchDaemon uninstalled for instance %q", instName)
147+
return nil
148+
}
149+
150+
// runSudo executes a command under sudo, inheriting the terminal so password prompts work.
151+
func runSudo(ctx context.Context, args ...string) error {
152+
cmd := exec.CommandContext(ctx, "sudo", args...) //nolint:gosec // args are constructed internally, not from user input
153+
cmd.Stdout = os.Stdout
154+
cmd.Stderr = os.Stderr
155+
cmd.Stdin = os.Stdin
156+
logrus.Debugf("running: sudo %v", args)
157+
return cmd.Run()
158+
}

cmd/limactl/autostart_others.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
//go:build !darwin
2+
3+
// SPDX-FileCopyrightText: Copyright The Lima Authors
4+
// SPDX-License-Identifier: Apache-2.0
5+
6+
package main
7+
8+
import (
9+
"errors"
10+
"fmt"
11+
"os"
12+
13+
"github.com/sirupsen/logrus"
14+
"github.com/spf13/cobra"
15+
16+
"github.com/lima-vm/lima/v2/pkg/autostart"
17+
"github.com/lima-vm/lima/v2/pkg/store"
18+
)
19+
20+
func autostartEnableAction(cmd *cobra.Command, args []string) error {
21+
condition, err := cmd.Flags().GetString("condition")
22+
if err != nil {
23+
return err
24+
}
25+
if condition == "boot" {
26+
return errors.New("--condition=boot is only supported on macOS")
27+
}
28+
29+
ctx := cmd.Context()
30+
inst, err := store.Inspect(ctx, args[0])
31+
if err != nil {
32+
if errors.Is(err, os.ErrNotExist) {
33+
return fmt.Errorf("instance %q not found", args[0])
34+
}
35+
return err
36+
}
37+
38+
if err := autostart.RegisterToStartAtLogin(ctx, inst); err != nil {
39+
return fmt.Errorf("failed to register instance %#q to start at login: %w", inst.Name, err)
40+
}
41+
logrus.Infof("Instance %#q registered to start at login", inst.Name)
42+
return nil
43+
}
44+
45+
func autostartDisableAction(cmd *cobra.Command, args []string) error {
46+
ctx := cmd.Context()
47+
inst, err := store.Inspect(ctx, args[0])
48+
if err != nil {
49+
if errors.Is(err, os.ErrNotExist) {
50+
return fmt.Errorf("instance %q not found", args[0])
51+
}
52+
return err
53+
}
54+
55+
if registered, err := autostart.IsRegistered(ctx, inst); err != nil {
56+
return err
57+
} else if !registered {
58+
logrus.Infof("Instance %#q is not registered for automatic startup", inst.Name)
59+
return nil
60+
}
61+
62+
if err := autostart.UnregisterFromStartAtLogin(ctx, inst); err != nil {
63+
return fmt.Errorf("failed to unregister instance %#q from start at login: %w", inst.Name, err)
64+
}
65+
logrus.Infof("Instance %#q unregistered from start at login", inst.Name)
66+
return nil
67+
}

cmd/limactl/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ func newApp() *cobra.Command {
205205
newTemplateCommand(),
206206
newRestartCommand(),
207207
newSudoersCommand(),
208+
newAutostartCommand(),
208209
newStartAtLoginCommand(),
209210
newNetworkCommand(),
210211
newCloneCommand(),

cmd/limactl/start-at-login.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ func newStartAtLoginCommand() *cobra.Command {
1111
startAtLoginCommand := &cobra.Command{
1212
Use: "start-at-login INSTANCE",
1313
Short: "Register/Unregister an autostart file for the instance",
14+
Deprecated: "use \"limactl autostart\" instead",
1415
Args: WrapArgsError(cobra.MaximumNArgs(1)),
1516
RunE: startAtLoginAction,
1617
ValidArgsFunction: startAtLoginComplete,

pkg/autostart/autostart_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ var (
2222
requestStart: launchd.RequestStart,
2323
requestStop: launchd.RequestStop,
2424
}
25+
LaunchdDaemon = &TemplateFileBasedManager{
26+
filePath: launchd.GetDaemonPlistPath,
27+
template: launchd.DaemonTemplate,
28+
extraTemplateVars: map[string]string{"UserName": "alice"},
29+
}
2530
Systemd = &TemplateFileBasedManager{
2631
filePath: systemd.GetUnitPath,
2732
template: systemd.Template,
@@ -73,6 +78,43 @@ func TestRenderTemplate(t *testing.T) {
7378
<string>Background</string>
7479
</dict>
7580
</plist>
81+
`,
82+
GetExecutable: func() (string, error) {
83+
return "/limactl", nil
84+
},
85+
WorkDir: "/some/path",
86+
},
87+
{
88+
Manager: LaunchdDaemon,
89+
Name: "render darwin launchd daemon plist",
90+
InstanceName: "k3s",
91+
Expected: `<?xml version="1.0" encoding="UTF-8"?>
92+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
93+
<plist version="1.0">
94+
<dict>
95+
<key>Label</key>
96+
<string>io.lima-vm.daemon.k3s</string>
97+
<key>UserName</key>
98+
<string>alice</string>
99+
<key>ProgramArguments</key>
100+
<array>
101+
<string>/limactl</string>
102+
<string>start</string>
103+
<string>k3s</string>
104+
<string>--foreground</string>
105+
</array>
106+
<key>RunAtLoad</key>
107+
<true/>
108+
<key>StandardErrorPath</key>
109+
<string>launchd.stderr.log</string>
110+
<key>StandardOutPath</key>
111+
<string>launchd.stdout.log</string>
112+
<key>WorkingDirectory</key>
113+
<string>/some/path</string>
114+
<key>ProcessType</key>
115+
<string>Background</string>
116+
</dict>
117+
</plist>
76118
`,
77119
GetExecutable: func() (string, error) {
78120
return "/limactl", nil
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>Label</key>
6+
<string>io.lima-vm.daemon.{{ .Instance }}</string>
7+
<key>UserName</key>
8+
<string>{{ .UserName }}</string>
9+
<key>ProgramArguments</key>
10+
<array>
11+
<string>{{ .Binary }}</string>
12+
<string>start</string>
13+
<string>{{ .Instance }}</string>
14+
<string>--foreground</string>
15+
</array>
16+
<key>RunAtLoad</key>
17+
<true/>
18+
<key>StandardErrorPath</key>
19+
<string>launchd.stderr.log</string>
20+
<key>StandardOutPath</key>
21+
<string>launchd.stdout.log</string>
22+
<key>WorkingDirectory</key>
23+
<string>{{ .WorkDir }}</string>
24+
<key>ProcessType</key>
25+
<string>Background</string>
26+
</dict>
27+
</plist>

0 commit comments

Comments
 (0)