Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ lima.REJECTED.yaml
default-template.yaml
schema-limayaml.json
.config
limactl
56 changes: 56 additions & 0 deletions cmd/limactl/autostart.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// 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)",
)
flags.Bool(
"keep-alive", true,
"Restart the instance automatically if the host agent exits unexpectedly",
)
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)
}
168 changes: 168 additions & 0 deletions cmd/limactl/autostart_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// 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 {
flags := cmd.Flags()
condition, err := flags.GetString("condition")
if err != nil {
return err
}
keepAlive, err := flags.GetBool("keep-alive")
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":
mgr := autostart.ManagerWith(keepAlive)
if err := mgr.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 := 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, keepAlive)
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, keepAlive bool) error {
selfExe, err := os.Executable()
if err != nil {
return fmt.Errorf("could not determine limactl path: %w", err)
}

vars := map[string]string{
"Binary": selfExe,
"Instance": instName,
"WorkDir": workDir,
"UserName": userName,
}
if keepAlive {
vars["KeepAlive"] = "true"
}
content, err := textutil.ExecuteTemplate(launchd.DaemonTemplate, vars)
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()
}
72 changes: 72 additions & 0 deletions cmd/limactl/autostart_others.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
//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
}

keepAlive, err := cmd.Flags().GetBool("keep-alive")
if err != nil {
return err
}
mgr := autostart.ManagerWith(keepAlive)
if err := mgr.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
4 changes: 4 additions & 0 deletions pkg/autostart/launchd/io.lima-vm.autostart.INSTANCE.plist
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
</array>
<key>RunAtLoad</key>
<true/>
{{- if .KeepAlive }}
<key>KeepAlive</key>
<true/>
{{- end }}
<key>StandardErrorPath</key>
<string>launchd.stderr.log</string>
<key>StandardOutPath</key>
Expand Down
Loading
Loading