Skip to content
47 changes: 47 additions & 0 deletions cmd/limactl/daemon.go
Comment thread
resker marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// SPDX-FileCopyrightText: Copyright The Lima Authors
// SPDX-License-Identifier: Apache-2.0

package main

import (
"github.com/spf13/cobra"
)

func newDaemonCommand() *cobra.Command {
daemonCommand := &cobra.Command{
Use: "daemon",
Short: "Manage Lima instances as system LaunchDaemons (macOS only, requires root)",
GroupID: advancedCommand,
}
daemonCommand.AddCommand(newDaemonInstallCommand(), newDaemonUninstallCommand())
return daemonCommand
}

func newDaemonInstallCommand() *cobra.Command {
installCommand := &cobra.Command{
Use: "install INSTANCE",
Short: "Install a system LaunchDaemon for the instance (run with sudo)",
Args: WrapArgsError(cobra.ExactArgs(1)),
RunE: daemonInstallAction,
ValidArgsFunction: daemonComplete,
}
installCommand.Flags().String(
"user", "",
"macOS username to run the daemon as (default: $USER)",
)
return installCommand
}

func newDaemonUninstallCommand() *cobra.Command {
return &cobra.Command{
Use: "uninstall INSTANCE",
Short: "Uninstall the system LaunchDaemon for the instance (run with sudo)",
Args: WrapArgsError(cobra.ExactArgs(1)),
RunE: daemonUninstallAction,
ValidArgsFunction: daemonComplete,
}
}

func daemonComplete(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return bashCompleteInstanceNames(cmd)
}
118 changes: 118 additions & 0 deletions cmd/limactl/daemon_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// 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/launchd"
"github.com/lima-vm/lima/v2/pkg/store"
"github.com/lima-vm/lima/v2/pkg/textutil"
)

func daemonInstallAction(cmd *cobra.Command, args []string) error {
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")
}

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
}

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": inst.Name,
"WorkDir": inst.Dir,
"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(inst.Name)
svcTarget := "system/" + launchd.DaemonServiceNameFrom(inst.Name)

// Bootout first in case a previous install is still loaded (ignore error).
_ = 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)", inst.Name, userName)
logrus.Infof("Plist: %s", destPath)
return nil
}

func daemonUninstallAction(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
}

svcTarget := "system/" + launchd.DaemonServiceNameFrom(inst.Name)
destPath := launchd.GetDaemonPlistPath(inst.Name)

_ = 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", inst.Name)
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()
}
20 changes: 20 additions & 0 deletions cmd/limactl/daemon_others.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//go:build !darwin

// SPDX-FileCopyrightText: Copyright The Lima Authors
// SPDX-License-Identifier: Apache-2.0

package main

import (
"errors"

"github.com/spf13/cobra"
)

func daemonInstallAction(_ *cobra.Command, _ []string) error {
return errors.New("daemon install is only supported on macOS")
}

func daemonUninstallAction(_ *cobra.Command, _ []string) error {
return errors.New("daemon uninstall is only supported on macOS")
}
1 change: 1 addition & 0 deletions cmd/limactl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ func newApp() *cobra.Command {
newRestartCommand(),
newSudoersCommand(),
newStartAtLoginCommand(),
newDaemonCommand(),
newNetworkCommand(),
newCloneCommand(),
newRenameCommand(),
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>
13 changes: 13 additions & 0 deletions pkg/autostart/launchd/launchd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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)
}
48 changes: 48 additions & 0 deletions pkg/autostart/launchd/launchd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
}
16 changes: 9 additions & 7 deletions pkg/autostart/managers.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"context"
"errors"
"fmt"
"maps"
"os"
"path/filepath"
"runtime"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading