Skip to content

Commit f2ca93d

Browse files
committed
feat(autostart): add LaunchDaemon support for headless macOS servers
Introduces `limactl daemon install/uninstall` to register Lima instances as system LaunchDaemons, enabling VM startup at boot without requiring a user session or auto-login. LaunchAgents (the existing `start-at-login` mechanism) require a GUI login session and do not start on headless macOS servers. A LaunchDaemon with a `UserName` key runs as a specified user at system boot, before any login, solving this gap. Changes: - pkg/autostart/launchd: add DaemonTemplate, GetDaemonPlistPath, DaemonServiceNameFrom; restore RequestStop to its original form - pkg/autostart/managers: add extraTemplateVars to TemplateFileBasedManager to support templates requiring additional variables (e.g. UserName) - pkg/autostart/managers_darwin: add DaemonManager(userName string) for rendering daemon plists and tracking registration state - pkg/autostart/managers_{linux,others}: add DaemonManager stubs returning notSupportedManager - cmd/limactl: add `daemon` subcommand with `install` and `uninstall`; runs as normal user; privileged operations (writing to /Library/LaunchDaemons/ and launchctl system domain) are performed via sudo internally so limactl itself never runs as root - tests: add TestGetDaemonPlistPath, TestDaemonServiceNameFrom, and a daemon plist render test verifying UserName substitution Developed with AI assistance. Signed-off-by: Robert Esker <resker@gmail.com>
1 parent e855be6 commit f2ca93d

12 files changed

Lines changed: 348 additions & 7 deletions

cmd/limactl/daemon.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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 newDaemonCommand() *cobra.Command {
11+
daemonCommand := &cobra.Command{
12+
Use: "daemon",
13+
Short: "Manage Lima instances as system LaunchDaemons (macOS only, requires root)",
14+
GroupID: advancedCommand,
15+
}
16+
daemonCommand.AddCommand(newDaemonInstallCommand(), newDaemonUninstallCommand())
17+
return daemonCommand
18+
}
19+
20+
func newDaemonInstallCommand() *cobra.Command {
21+
installCommand := &cobra.Command{
22+
Use: "install INSTANCE",
23+
Short: "Install a system LaunchDaemon for the instance (run with sudo)",
24+
Args: WrapArgsError(cobra.ExactArgs(1)),
25+
RunE: daemonInstallAction,
26+
ValidArgsFunction: daemonComplete,
27+
}
28+
installCommand.Flags().String(
29+
"user", "",
30+
"macOS username to run the daemon as (default: $USER)",
31+
)
32+
return installCommand
33+
}
34+
35+
func newDaemonUninstallCommand() *cobra.Command {
36+
return &cobra.Command{
37+
Use: "uninstall INSTANCE",
38+
Short: "Uninstall the system LaunchDaemon for the instance (run with sudo)",
39+
Args: WrapArgsError(cobra.ExactArgs(1)),
40+
RunE: daemonUninstallAction,
41+
ValidArgsFunction: daemonComplete,
42+
}
43+
}
44+
45+
func daemonComplete(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
46+
return bashCompleteInstanceNames(cmd)
47+
}

cmd/limactl/daemon_darwin.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
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/launchd"
17+
"github.com/lima-vm/lima/v2/pkg/store"
18+
"github.com/lima-vm/lima/v2/pkg/textutil"
19+
)
20+
21+
func daemonInstallAction(cmd *cobra.Command, args []string) error {
22+
userName, err := cmd.Flags().GetString("user")
23+
if err != nil {
24+
return err
25+
}
26+
if userName == "" {
27+
userName = os.Getenv("USER")
28+
}
29+
if userName == "" {
30+
return errors.New("could not determine user; pass --user")
31+
}
32+
33+
ctx := cmd.Context()
34+
inst, err := store.Inspect(ctx, args[0])
35+
if err != nil {
36+
if errors.Is(err, os.ErrNotExist) {
37+
return fmt.Errorf("instance %q not found", args[0])
38+
}
39+
return err
40+
}
41+
42+
selfExe, err := os.Executable()
43+
if err != nil {
44+
return fmt.Errorf("could not determine limactl path: %w", err)
45+
}
46+
47+
content, err := textutil.ExecuteTemplate(launchd.DaemonTemplate, map[string]string{
48+
"Binary": selfExe,
49+
"Instance": inst.Name,
50+
"WorkDir": inst.Dir,
51+
"UserName": userName,
52+
})
53+
if err != nil {
54+
return fmt.Errorf("failed to render daemon plist: %w", err)
55+
}
56+
57+
tmp, err := os.CreateTemp("", "io.lima-vm.daemon.*.plist")
58+
if err != nil {
59+
return fmt.Errorf("failed to create temp file: %w", err)
60+
}
61+
defer os.Remove(tmp.Name())
62+
if _, err := tmp.Write(content); err != nil {
63+
return fmt.Errorf("failed to write temp plist: %w", err)
64+
}
65+
tmp.Close()
66+
67+
destPath := launchd.GetDaemonPlistPath(inst.Name)
68+
svcTarget := "system/" + launchd.DaemonServiceNameFrom(inst.Name)
69+
70+
// Bootout first in case a previous install is still loaded (ignore error).
71+
_ = runSudo(ctx, "launchctl", "bootout", svcTarget)
72+
73+
if err := runSudo(ctx, "install", "-m", "644", tmp.Name(), destPath); err != nil {
74+
return fmt.Errorf("failed to install plist to %s: %w", destPath, err)
75+
}
76+
if err := runSudo(ctx, "launchctl", "enable", svcTarget); err != nil {
77+
return fmt.Errorf("failed to enable LaunchDaemon: %w", err)
78+
}
79+
if err := runSudo(ctx, "launchctl", "bootstrap", "system", destPath); err != nil {
80+
return fmt.Errorf("failed to bootstrap LaunchDaemon: %w", err)
81+
}
82+
83+
logrus.Infof("LaunchDaemon installed for instance %q (runs as %q at boot)", inst.Name, userName)
84+
logrus.Infof("Plist: %s", destPath)
85+
return nil
86+
}
87+
88+
func daemonUninstallAction(cmd *cobra.Command, args []string) error {
89+
ctx := cmd.Context()
90+
inst, err := store.Inspect(ctx, args[0])
91+
if err != nil {
92+
if errors.Is(err, os.ErrNotExist) {
93+
return fmt.Errorf("instance %q not found", args[0])
94+
}
95+
return err
96+
}
97+
98+
svcTarget := "system/" + launchd.DaemonServiceNameFrom(inst.Name)
99+
destPath := launchd.GetDaemonPlistPath(inst.Name)
100+
101+
_ = runSudo(ctx, "launchctl", "bootout", svcTarget)
102+
_ = runSudo(ctx, "launchctl", "disable", svcTarget)
103+
if err := runSudo(ctx, "rm", "-f", destPath); err != nil {
104+
return fmt.Errorf("failed to remove %s: %w", destPath, err)
105+
}
106+
logrus.Infof("LaunchDaemon uninstalled for instance %q", inst.Name)
107+
return nil
108+
}
109+
110+
// runSudo executes a command under sudo, inheriting the terminal so password prompts work.
111+
func runSudo(ctx context.Context, args ...string) error {
112+
cmd := exec.CommandContext(ctx, "sudo", args...) //nolint:gosec // args are constructed internally, not from user input
113+
cmd.Stdout = os.Stdout
114+
cmd.Stderr = os.Stderr
115+
cmd.Stdin = os.Stdin
116+
logrus.Debugf("running: sudo %v", args)
117+
return cmd.Run()
118+
}

cmd/limactl/daemon_others.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
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+
11+
"github.com/spf13/cobra"
12+
)
13+
14+
func daemonInstallAction(_ *cobra.Command, _ []string) error {
15+
return errors.New("daemon install is only supported on macOS")
16+
}
17+
18+
func daemonUninstallAction(_ *cobra.Command, _ []string) error {
19+
return errors.New("daemon uninstall is only supported on macOS")
20+
}

cmd/limactl/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ func newApp() *cobra.Command {
206206
newRestartCommand(),
207207
newSudoersCommand(),
208208
newStartAtLoginCommand(),
209+
newDaemonCommand(),
209210
newNetworkCommand(),
210211
newCloneCommand(),
211212
newRenameCommand(),

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>

pkg/autostart/launchd/launchd.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ import (
1919
//go:embed io.lima-vm.autostart.INSTANCE.plist
2020
var Template string
2121

22+
//go:embed io.lima-vm.daemon.INSTANCE.plist
23+
var DaemonTemplate string
24+
2225
// GetPlistPath returns the path to the launchd plist file for the given instance name.
2326
func GetPlistPath(instName string) string {
2427
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) {
9598
}
9699
return false, nil
97100
}
101+
102+
// GetDaemonPlistPath returns the path to the system LaunchDaemon plist for the given instance.
103+
func GetDaemonPlistPath(instName string) string {
104+
return fmt.Sprintf("/Library/LaunchDaemons/%s.plist", DaemonServiceNameFrom(instName))
105+
}
106+
107+
// DaemonServiceNameFrom returns the launchd daemon service name for the given instance name.
108+
func DaemonServiceNameFrom(instName string) string {
109+
return fmt.Sprintf("io.lima-vm.daemon.%s", instName)
110+
}

pkg/autostart/launchd/launchd_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,51 @@ func TestGetPlistPath(t *testing.T) {
3030
})
3131
}
3232
}
33+
34+
func TestGetDaemonPlistPath(t *testing.T) {
35+
tests := []struct {
36+
Name string
37+
InstanceName string
38+
Expected string
39+
}{
40+
{
41+
Name: "k3s instance",
42+
InstanceName: "k3s",
43+
Expected: "/Library/LaunchDaemons/io.lima-vm.daemon.k3s.plist",
44+
},
45+
{
46+
Name: "default instance",
47+
InstanceName: "default",
48+
Expected: "/Library/LaunchDaemons/io.lima-vm.daemon.default.plist",
49+
},
50+
}
51+
for _, tt := range tests {
52+
t.Run(tt.Name, func(t *testing.T) {
53+
assert.Equal(t, GetDaemonPlistPath(tt.InstanceName), tt.Expected)
54+
})
55+
}
56+
}
57+
58+
func TestDaemonServiceNameFrom(t *testing.T) {
59+
tests := []struct {
60+
Name string
61+
InstanceName string
62+
Expected string
63+
}{
64+
{
65+
Name: "k3s instance",
66+
InstanceName: "k3s",
67+
Expected: "io.lima-vm.daemon.k3s",
68+
},
69+
{
70+
Name: "default instance",
71+
InstanceName: "default",
72+
Expected: "io.lima-vm.daemon.default",
73+
},
74+
}
75+
for _, tt := range tests {
76+
t.Run(tt.Name, func(t *testing.T) {
77+
assert.Equal(t, DaemonServiceNameFrom(tt.InstanceName), tt.Expected)
78+
})
79+
}
80+
}

pkg/autostart/managers.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"context"
99
"errors"
1010
"fmt"
11+
"maps"
1112
"os"
1213
"path/filepath"
1314
"runtime"
@@ -51,6 +52,7 @@ type TemplateFileBasedManager struct {
5152
enabler func(ctx context.Context, enable bool, instName string) error
5253
filePath func(instName string) string
5354
template string
55+
extraTemplateVars map[string]string
5456
autoStartedIdentifier func() string
5557
requestStart func(ctx context.Context, inst *limatype.Instance) error
5658
requestStop func(ctx context.Context, inst *limatype.Instance) (bool, error)
@@ -118,13 +120,13 @@ func (t *TemplateFileBasedManager) renderTemplate(instName, workDir string, getE
118120
if err != nil {
119121
return nil, err
120122
}
121-
return textutil.ExecuteTemplate(
122-
t.template,
123-
map[string]string{
124-
"Binary": selfExeAbs,
125-
"Instance": instName,
126-
"WorkDir": workDir,
127-
})
123+
data := map[string]string{
124+
"Binary": selfExeAbs,
125+
"Instance": instName,
126+
"WorkDir": workDir,
127+
}
128+
maps.Copy(data, t.extraTemplateVars)
129+
return textutil.ExecuteTemplate(t.template, data)
128130
}
129131

130132
func (t *TemplateFileBasedManager) AutoStartedIdentifier() string {

0 commit comments

Comments
 (0)