Skip to content

Commit 4bc77d5

Browse files
authored
Merge pull request #2151 from roman-kiselenko/feature/autostart
CLI flag to generate autostart files
2 parents 4d57ff6 + 1ed3dc2 commit 4bc77d5

File tree

7 files changed

+367
-0
lines changed

7 files changed

+367
-0
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 == "darwin" || runtime.GOOS == "linux" {
50+
deleted, err := autostart.DeleteStartAtLoginEntry(runtime.GOOS, instName)
51+
if err != nil && !errors.Is(err, os.ErrNotExist) {
52+
logrus.WithError(err).Warnf("The autostart file for instance %q does not exist", instName)
53+
} else if deleted {
54+
logrus.Infof("The autostart file %q has been deleted", autostart.GetFilePath(runtime.GOOS, instName))
55+
}
56+
}
4757
logrus.Infof("Deleted %q (%q)", instName, inst.Dir)
4858
}
4959
return networks.Reconcile(cmd.Context(), "")

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 == "darwin" || runtime.GOOS == "linux" {
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+
startAtLoginCommand := &cobra.Command{
16+
Use: "start-at-login INSTANCE",
17+
Short: "Register/Unregister an autostart file for the instance",
18+
Args: WrapArgsError(cobra.MaximumNArgs(1)),
19+
RunE: startAtLoginAction,
20+
ValidArgsFunction: startAtLoginComplete,
21+
GroupID: advancedCommand,
22+
}
23+
24+
startAtLoginCommand.Flags().Bool(
25+
"enabled", true,
26+
"Automatically start the instance when the user logs in",
27+
)
28+
29+
return startAtLoginCommand
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).Warnf("Can't create an autostart file for instance %q", inst.Name)
55+
} else {
56+
logrus.Infof("The autostart file %q has been created or updated", autostart.GetFilePath(runtime.GOOS, inst.Name))
57+
}
58+
} else {
59+
deleted, err := autostart.DeleteStartAtLoginEntry(runtime.GOOS, instName)
60+
if err != nil {
61+
logrus.WithError(err).Warnf("The autostart file %q could not be deleted", instName)
62+
} else if deleted {
63+
logrus.Infof("The autostart file %q has been deleted", autostart.GetFilePath(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: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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+
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 := GetFilePath(hostOS, instName)
27+
if _, err := os.Stat(unitPath); err != nil && !errors.Is(err, os.ErrNotExist) {
28+
return err
29+
}
30+
tmpl, err := renderTemplate(hostOS, instName, workDir, 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+
return err
39+
}
40+
return enableDisableService("enable", hostOS, GetFilePath(hostOS, instName))
41+
}
42+
43+
// DeleteStartAtLoginEntry respect host OS arch and delete unit file
44+
// return true, nil if unit file has been deleted
45+
func DeleteStartAtLoginEntry(hostOS, instName string) (bool, error) {
46+
unitPath := GetFilePath(hostOS, instName)
47+
if _, err := os.Stat(unitPath); err != nil {
48+
return false, err
49+
}
50+
if err := enableDisableService("disable", hostOS, GetFilePath(hostOS, instName)); err != nil {
51+
return false, err
52+
}
53+
if err := os.Remove(unitPath); err != nil {
54+
return false, err
55+
}
56+
return true, nil
57+
}
58+
59+
// GetFilePath returns the path to autostart file with respect of host
60+
func GetFilePath(hostOS, instName string) string {
61+
var fileTmpl string
62+
if hostOS == "darwin" { // launchd plist
63+
fileTmpl = fmt.Sprintf("%s/Library/LaunchAgents/io.lima-vm.autostart.%s.plist", os.Getenv("HOME"), instName)
64+
}
65+
if hostOS == "linux" { // systemd service
66+
// Use instance name as argument to systemd service
67+
// Instance name available in unit file as %i
68+
xdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
69+
if xdgConfigHome == "" {
70+
xdgConfigHome = filepath.Join(os.Getenv("HOME"), ".config")
71+
}
72+
fileTmpl = fmt.Sprintf("%s/systemd/user/lima-vm@%s.service", xdgConfigHome, instName)
73+
}
74+
return fileTmpl
75+
}
76+
77+
func enableDisableService(action, hostOS, serviceWithPath string) error {
78+
// Get filename without extension
79+
filename := strings.TrimSuffix(path.Base(serviceWithPath), filepath.Ext(path.Base(serviceWithPath)))
80+
81+
var args []string
82+
if hostOS == "darwin" {
83+
// man launchctl
84+
args = append(args, []string{
85+
"launchctl",
86+
action,
87+
fmt.Sprintf("gui/%s/%s", strconv.Itoa(os.Getuid()), filename),
88+
}...)
89+
} else {
90+
args = append(args, []string{
91+
"systemctl",
92+
"--user",
93+
action,
94+
filename,
95+
}...)
96+
}
97+
cmd := exec.Command(args[0], args[1:]...)
98+
cmd.Stdout = os.Stdout
99+
cmd.Stderr = os.Stderr
100+
return cmd.Run()
101+
}
102+
103+
func renderTemplate(hostOS, instName, workDir string, getExecutable func() (string, error)) ([]byte, error) {
104+
selfExeAbs, err := getExecutable()
105+
if err != nil {
106+
return nil, err
107+
}
108+
tmpToExecute := systemdTemplate
109+
if hostOS == "darwin" {
110+
tmpToExecute = launchdTemplate
111+
}
112+
return textutil.ExecuteTemplate(
113+
tmpToExecute,
114+
map[string]string{
115+
"Binary": selfExeAbs,
116+
"Instance": instName,
117+
"WorkDir": workDir,
118+
})
119+
}

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/[email protected]",
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(GetFilePath(tt.HostOS, tt.InstanceName), tt.Expected))
122+
})
123+
}
124+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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.autostart.{{ .Instance }}</string>
7+
<key>ProgramArguments</key>
8+
<array>
9+
<string>{{ .Binary }}</string>
10+
<string>start</string>
11+
<string>{{ .Instance }}</string>
12+
<string>--foreground</string>
13+
</array>
14+
<key>RunAtLoad</key>
15+
<true/>
16+
<key>StandardErrorPath</key>
17+
<string>launchd.stderr.log</string>
18+
<key>StandardOutPath</key>
19+
<string>launchd.stdout.log</string>
20+
<key>WorkingDirectory</key>
21+
<string>{{ .WorkDir }}</string>
22+
<key>ProcessType</key>
23+
<string>Background</string>
24+
</dict>
25+
</plist>

pkg/autostart/[email protected]

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[Unit]
2+
Description=Lima - Linux virtual machines, with a focus on running containers.
3+
Documentation=man:lima(1)
4+
5+
[Service]
6+
ExecStart={{.Binary}} start %i --foreground
7+
WorkingDirectory=%h
8+
Type=simple
9+
TimeoutSec=10
10+
Restart=on-failure
11+
12+
[Install]
13+
WantedBy=multi-user.target

0 commit comments

Comments
 (0)