Skip to content

Commit f0218cc

Browse files
Added a new public CLI command: `limactl start-at-login INSTANCE
--enabled`. This command facilitates the generation of unit files for `launchd/systemd`, providing users with a straightforward way to control `limactl` autostart behavior. Signed-off-by: roman-kiselenko <roman.kiselenko.dev@gmail.com>
1 parent 752afc0 commit f0218cc

File tree

8 files changed

+376
-1
lines changed

8 files changed

+376
-1
lines changed

cmd/limactl/delete.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import (
55
"errors"
66
"fmt"
77
"os"
8+
"runtime"
89

10+
"github.com/lima-vm/lima/pkg/autostart"
911
networks "github.com/lima-vm/lima/pkg/networks/reconcile"
1012
"github.com/lima-vm/lima/pkg/stop"
1113
"github.com/lima-vm/lima/pkg/store"
@@ -44,6 +46,14 @@ func deleteAction(cmd *cobra.Command, args []string) error {
4446
if err := deleteInstance(cmd.Context(), inst, force); err != nil {
4547
return fmt.Errorf("failed to delete instance %q: %w", instName, err)
4648
}
49+
if runtime.GOOS != "windows" {
50+
deleted, err := autostart.DeleteStartAtLoginEntry(runtime.GOOS, instName)
51+
if err != nil {
52+
logrus.WithError(err)
53+
} else if deleted {
54+
logrus.Infof("The autostart file (%q) has been deleted", autostart.GetStartAtLoginEntryPath(runtime.GOOS, instName))
55+
}
56+
}
4757
logrus.Infof("Deleted %q (%q)", instName, inst.Dir)
4858
}
4959
return networks.Reconcile(cmd.Context(), "")

cmd/limactl/edit.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ func newEditCommand() *cobra.Command {
3030
GroupID: basicCommand,
3131
}
3232
editflags.RegisterEdit(editCommand)
33+
3334
return editCommand
3435
}
3536

@@ -51,12 +52,13 @@ func editAction(cmd *cobra.Command, args []string) error {
5152
return errors.New("Cannot edit a running instance")
5253
}
5354

55+
flags := cmd.Flags()
56+
5457
filePath := filepath.Join(inst.Dir, filenames.LimaYAML)
5558
yContent, err := os.ReadFile(filePath)
5659
if err != nil {
5760
return err
5861
}
59-
flags := cmd.Flags()
6062
tty, err := flags.GetBool("tty")
6163
if err != nil {
6264
return err

cmd/limactl/main.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,10 @@ func newApp() *cobra.Command {
123123
newProtectCommand(),
124124
newUnprotectCommand(),
125125
)
126+
if runtime.GOOS != "windows" {
127+
rootCmd.AddCommand(startAtLoginCommand())
128+
}
129+
126130
return rootCmd
127131
}
128132

cmd/limactl/start-at-login.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"os"
6+
"runtime"
7+
8+
"github.com/lima-vm/lima/pkg/autostart"
9+
"github.com/lima-vm/lima/pkg/store"
10+
"github.com/sirupsen/logrus"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
func startAtLoginCommand() *cobra.Command {
15+
resetCommand := &cobra.Command{
16+
Use: "start-at-login INSTANCE",
17+
Short: "Register/Unregister the unit file for instance",
18+
Args: WrapArgsError(cobra.MaximumNArgs(1)),
19+
RunE: startAtLoginAction,
20+
ValidArgsFunction: startAtLoginComplete,
21+
GroupID: advancedCommand,
22+
}
23+
24+
resetCommand.Flags().Bool(
25+
"enabled", true,
26+
"--enabled=true/false the "+map[string]string{"darwin": "launchd", "linux": "systemd"}[runtime.GOOS]+" unit file for instance",
27+
)
28+
29+
return resetCommand
30+
}
31+
32+
func startAtLoginAction(cmd *cobra.Command, args []string) error {
33+
instName := DefaultInstanceName
34+
if len(args) > 0 {
35+
instName = args[0]
36+
}
37+
38+
inst, err := store.Inspect(instName)
39+
if err != nil {
40+
if errors.Is(err, os.ErrNotExist) {
41+
logrus.Infof("Instance %q not found", instName)
42+
return nil
43+
}
44+
return err
45+
}
46+
47+
flags := cmd.Flags()
48+
startAtLogin, err := flags.GetBool("enabled")
49+
if err != nil {
50+
return err
51+
}
52+
if startAtLogin {
53+
if err := autostart.CreateStartAtLoginEntry(runtime.GOOS, inst.Name, inst.Dir); err != nil {
54+
logrus.WithError(err)
55+
} else {
56+
logrus.Infof("The autostart file (%q) has been created", autostart.GetStartAtLoginEntryPath(runtime.GOOS, inst.Name))
57+
}
58+
} else {
59+
deleted, err := autostart.DeleteStartAtLoginEntry(runtime.GOOS, instName)
60+
if err != nil {
61+
logrus.WithError(err)
62+
} else if deleted {
63+
logrus.Infof("The autostart file (%q) has been deleted", autostart.GetStartAtLoginEntryPath(runtime.GOOS, instName))
64+
}
65+
}
66+
67+
return nil
68+
}
69+
70+
func startAtLoginComplete(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
71+
return bashCompleteInstanceNames(cmd)
72+
}

pkg/autostart/autostart.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// Package autostart manage start at login unit files for darwin/linux
2+
package autostart
3+
4+
import (
5+
_ "embed"
6+
"errors"
7+
"fmt"
8+
"os"
9+
"os/exec"
10+
"path"
11+
"path/filepath"
12+
"strconv"
13+
"strings"
14+
15+
"github.com/lima-vm/lima/pkg/textutil"
16+
)
17+
18+
//go:embed lima-vm@INSTANCE.service
19+
var systemdTemplate string
20+
21+
//go:embed io.lima-vm.autostart.INSTANCE.plist
22+
var launchdTemplate string
23+
24+
// CreateStartAtLoginEntry respect host OS arch and create unit file
25+
func CreateStartAtLoginEntry(hostOS, instName, workDir string) error {
26+
unitPath := GetStartAtLoginEntryPath(hostOS, instName)
27+
if _, err := os.Stat(unitPath); !errors.Is(err, os.ErrNotExist) {
28+
return err
29+
}
30+
tmpl, err := renderTemplate(hostOS, workDir, instName, os.Executable)
31+
if err != nil {
32+
return err
33+
}
34+
if err := os.MkdirAll(filepath.Dir(unitPath), os.ModePerm); err != nil {
35+
return err
36+
}
37+
if err := os.WriteFile(unitPath, tmpl, 0o644); err != nil {
38+
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrNotExist) {
39+
return err
40+
}
41+
}
42+
return enableDisableService("enable", hostOS, GetStartAtLoginEntryPath(hostOS, instName))
43+
}
44+
45+
// DeleteStartAtLoginEntry respect host OS arch and delete unit file
46+
// return true, nil if unit file has been deleted
47+
func DeleteStartAtLoginEntry(hostOS, instName string) (bool, error) {
48+
unitPath := GetStartAtLoginEntryPath(hostOS, instName)
49+
if _, err := os.Stat(unitPath); err != nil {
50+
// Skip error log if file not present
51+
if errors.Is(err, os.ErrNotExist) {
52+
return false, nil
53+
}
54+
return false, err
55+
}
56+
if err := enableDisableService("disable", hostOS, GetStartAtLoginEntryPath(hostOS, instName)); err != nil {
57+
return false, err
58+
}
59+
if err := os.Remove(unitPath); err != nil {
60+
return false, err
61+
}
62+
return true, nil
63+
}
64+
65+
// GetFilePath returns the path to autostart file with respect of host
66+
func GetStartAtLoginEntryPath(hostOS, instName string) string {
67+
var fileTmp string
68+
if hostOS == "darwin" { // launchd plist
69+
fileTmp = fmt.Sprintf("%s/Library/LaunchAgents/io.lima-vm.autostart.%s.plist", os.Getenv("HOME"), instName)
70+
}
71+
if hostOS == "linux" { // systemd service
72+
// Use instance name as argument to systemd service
73+
// Instance name available in unit file as %i
74+
xdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
75+
if xdgConfigHome == "" {
76+
xdgConfigHome = filepath.Join(os.Getenv("HOME"), ".config")
77+
}
78+
fileTmp = fmt.Sprintf("%s/systemd/user/lima-vm@%s.service", xdgConfigHome, instName)
79+
}
80+
return fileTmp
81+
}
82+
83+
func enableDisableService(action, hostOS, serviceWithPath string) error {
84+
// Get filename without extension
85+
filename := strings.TrimSuffix(path.Base(serviceWithPath), filepath.Ext(path.Base(serviceWithPath)))
86+
87+
var args []string
88+
if hostOS == "darwin" {
89+
// man launchctl
90+
args = append(args, []string{
91+
"launchctl",
92+
action,
93+
fmt.Sprintf("gui/%s/%s", strconv.Itoa(os.Getuid()), filename),
94+
}...)
95+
} else {
96+
args = append(args, []string{
97+
"systemctl",
98+
"--user",
99+
action,
100+
filename,
101+
}...)
102+
}
103+
cmd := exec.Command(args[0], args[1:]...)
104+
cmd.Stdout = os.Stdout
105+
cmd.Stderr = os.Stderr
106+
return cmd.Run()
107+
}
108+
109+
func renderTemplate(hostOS, instName, workDir string, getExecutable func() (string, error)) ([]byte, error) {
110+
selfExeAbs, err := getExecutable()
111+
if err != nil {
112+
return nil, err
113+
}
114+
tmpToExecute := systemdTemplate
115+
if hostOS == "darwin" {
116+
tmpToExecute = launchdTemplate
117+
}
118+
return textutil.ExecuteTemplate(
119+
tmpToExecute,
120+
map[string]string{
121+
"Binary": selfExeAbs,
122+
"Instance": instName,
123+
"WorkDir": workDir,
124+
})
125+
}

pkg/autostart/autostart_test.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package autostart
2+
3+
import (
4+
"runtime"
5+
"strings"
6+
"testing"
7+
8+
"gotest.tools/v3/assert"
9+
)
10+
11+
func TestRenderTemplate(t *testing.T) {
12+
if runtime.GOOS == "windows" {
13+
t.Skip("skipping testing on windows host")
14+
}
15+
tests := []struct {
16+
Name string
17+
InstanceName string
18+
HostOS string
19+
Expected string
20+
WorkDir string
21+
GetExecutable func() (string, error)
22+
}{
23+
{
24+
Name: "render darwin launchd plist",
25+
InstanceName: "default",
26+
HostOS: "darwin",
27+
Expected: `<?xml version="1.0" encoding="UTF-8"?>
28+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
29+
<plist version="1.0">
30+
<dict>
31+
<key>Label</key>
32+
<string>io.lima-vm.autostart.default</string>
33+
<key>ProgramArguments</key>
34+
<array>
35+
<string>/limactl</string>
36+
<string>start</string>
37+
<string>default</string>
38+
<string>--foreground</string>
39+
</array>
40+
<key>RunAtLoad</key>
41+
<true/>
42+
<key>StandardErrorPath</key>
43+
<string>launchd.stderr.log</string>
44+
<key>StandardOutPath</key>
45+
<string>launchd.stdout.log</string>
46+
<key>WorkingDirectory</key>
47+
<string>/some/path</string>
48+
<key>ProcessType</key>
49+
<string>Background</string>
50+
</dict>
51+
</plist>`,
52+
GetExecutable: func() (string, error) {
53+
return "/limactl", nil
54+
},
55+
WorkDir: "/some/path",
56+
},
57+
{
58+
Name: "render linux systemd service",
59+
InstanceName: "default",
60+
HostOS: "linux",
61+
Expected: `[Unit]
62+
Description=Lima - Linux virtual machines, with a focus on running containers.
63+
Documentation=man:lima(1)
64+
65+
[Service]
66+
ExecStart=/limactl start %i --foreground
67+
WorkingDirectory=%h
68+
Type=simple
69+
TimeoutSec=10
70+
Restart=on-failure
71+
72+
[Install]
73+
WantedBy=multi-user.target`,
74+
GetExecutable: func() (string, error) {
75+
return "/limactl", nil
76+
},
77+
WorkDir: "/some/path",
78+
},
79+
}
80+
for _, tt := range tests {
81+
t.Run(tt.Name, func(t *testing.T) {
82+
tmpl, err := renderTemplate(tt.HostOS, tt.InstanceName, tt.WorkDir, tt.GetExecutable)
83+
assert.NilError(t, err)
84+
assert.Equal(t, string(tmpl), tt.Expected)
85+
})
86+
}
87+
}
88+
89+
func TestGetFilePath(t *testing.T) {
90+
if runtime.GOOS == "windows" {
91+
t.Skip("skipping testing on windows host")
92+
}
93+
tests := []struct {
94+
Name string
95+
HostOS string
96+
InstanceName string
97+
HomeEnv string
98+
Expected string
99+
}{
100+
{
101+
Name: "darwin with docker instance name",
102+
HostOS: "darwin",
103+
InstanceName: "docker",
104+
Expected: "Library/LaunchAgents/io.lima-vm.autostart.docker.plist",
105+
},
106+
{
107+
Name: "linux with docker instance name",
108+
HostOS: "linux",
109+
InstanceName: "docker",
110+
Expected: ".config/systemd/user/lima-vm@docker.service",
111+
},
112+
{
113+
Name: "empty with empty instance name",
114+
HostOS: "",
115+
InstanceName: "",
116+
Expected: "",
117+
},
118+
}
119+
for _, tt := range tests {
120+
t.Run(tt.Name, func(t *testing.T) {
121+
assert.Check(t, strings.HasSuffix(GetStartAtLoginEntryPath(tt.HostOS, tt.InstanceName), tt.Expected))
122+
})
123+
}
124+
}

0 commit comments

Comments
 (0)