Skip to content

Commit 166cb8e

Browse files
Added a new public CLI command: limactl (create|edit|start|delete) --start-at-login=<BOOL>.
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 c339ef2 commit 166cb8e

File tree

7 files changed

+345
-1
lines changed

7 files changed

+345
-1
lines changed

cmd/limactl/delete.go

Lines changed: 9 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,13 @@ 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+
if err := autostart.DeleteStartAtLoginEntry(runtime.GOOS, instName); err != nil {
51+
logrus.Errorf("Cant delete autostart file error: %s", err)
52+
} else {
53+
logrus.Infof("The autostart file (%q) has been deleted", autostart.GetStartAtLoginEntryPath(runtime.GOOS, instName))
54+
}
55+
}
4756
logrus.Infof("Deleted %q (%q)", instName, inst.Dir)
4857
}
4958
return networks.Reconcile(cmd.Context(), "")

cmd/limactl/edit.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import (
66
"fmt"
77
"os"
88
"path/filepath"
9+
"runtime"
910

1011
"github.com/lima-vm/lima/cmd/limactl/editflags"
12+
"github.com/lima-vm/lima/pkg/autostart"
1113
"github.com/lima-vm/lima/pkg/editutil"
1214
"github.com/lima-vm/lima/pkg/limayaml"
1315
networks "github.com/lima-vm/lima/pkg/networks/reconcile"
@@ -30,6 +32,11 @@ func newEditCommand() *cobra.Command {
3032
GroupID: basicCommand,
3133
}
3234
editflags.RegisterEdit(editCommand)
35+
36+
if runtime.GOOS != "windows" {
37+
editCommand.Flags().Bool("start-at-login", false, "register(--start-at-login=true)/unregister(--start-at-login=false) the systemd/launchd unit file for instance")
38+
}
39+
3340
return editCommand
3441
}
3542

@@ -51,12 +58,29 @@ func editAction(cmd *cobra.Command, args []string) error {
5158
return errors.New("Cannot edit a running instance")
5259
}
5360

61+
flags := cmd.Flags()
62+
63+
if flags.Lookup("start-at-login").Changed {
64+
if startAtLogin, _ := flags.GetBool("start-at-login"); startAtLogin {
65+
if err := autostart.CreateStartAtLoginEntry(runtime.GOOS, inst.Name, inst.Dir); err != nil {
66+
logrus.Errorf("Cant create autostart file error: %s", err)
67+
} else {
68+
logrus.Infof("The autostart file (%q) has been created", autostart.GetStartAtLoginEntryPath(runtime.GOOS, inst.Name))
69+
}
70+
} else {
71+
if err := autostart.DeleteStartAtLoginEntry(runtime.GOOS, instName); err != nil {
72+
logrus.Errorf("Cant delete autostart file error: %s", err)
73+
} else {
74+
logrus.Infof("The autostart file (%q) has been deleted", autostart.GetStartAtLoginEntryPath(runtime.GOOS, instName))
75+
}
76+
}
77+
}
78+
5479
filePath := filepath.Join(inst.Dir, filenames.LimaYAML)
5580
yContent, err := os.ReadFile(filePath)
5681
if err != nil {
5782
return err
5883
}
59-
flags := cmd.Flags()
6084
tty, err := flags.GetBool("tty")
6185
if err != nil {
6286
return err

cmd/limactl/start.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/containerd/containerd/identifiers"
1515
"github.com/lima-vm/lima/cmd/limactl/editflags"
1616
"github.com/lima-vm/lima/cmd/limactl/guessarg"
17+
"github.com/lima-vm/lima/pkg/autostart"
1718
"github.com/lima-vm/lima/pkg/editutil"
1819
"github.com/lima-vm/lima/pkg/ioutilx"
1920
"github.com/lima-vm/lima/pkg/limayaml"
@@ -72,6 +73,9 @@ $ cat template.yaml | limactl create --name=local -
7273
GroupID: basicCommand,
7374
}
7475
registerCreateFlags(createCommand, "")
76+
if runtime.GOOS != "windows" {
77+
createCommand.Flags().Bool("start-at-login", false, "create the systemd/launchd unit file but will leave the instance stopped")
78+
}
7579
return createCommand
7680
}
7781

@@ -97,6 +101,7 @@ See the examples in 'limactl create --help'.
97101
registerCreateFlags(startCommand, "[limactl create] ")
98102
if runtime.GOOS != "windows" {
99103
startCommand.Flags().Bool("foreground", false, "run the hostagent in the foreground")
104+
startCommand.Flags().Bool("start-at-login", false, "create the systemd/launchd unit file and start instance")
100105
}
101106
startCommand.Flags().Duration("timeout", start.DefaultWatchHostAgentEventsTimeout, "duration to wait for the instance to be running before timing out")
102107
return startCommand
@@ -486,6 +491,13 @@ func createAction(cmd *cobra.Command, args []string) error {
486491
if _, err = start.Prepare(cmd.Context(), inst); err != nil {
487492
return err
488493
}
494+
if ok, _ := cmd.Flags().GetBool("start-at-login"); ok {
495+
if err := autostart.CreateStartAtLoginEntry(runtime.GOOS, inst.Name, inst.Dir); err != nil {
496+
logrus.Errorf("Cant create autostart file error: %s", err)
497+
} else {
498+
logrus.Infof("The autostart file (%q) has been created", autostart.GetStartAtLoginEntryPath(runtime.GOOS, inst.Name))
499+
}
500+
}
489501
logrus.Infof("Run `limactl start %s` to start the instance.", inst.Name)
490502
return nil
491503
}
@@ -536,6 +548,14 @@ func startAction(cmd *cobra.Command, args []string) error {
536548
ctx = start.WithWatchHostAgentTimeout(ctx, timeout)
537549
}
538550

551+
if ok, _ := cmd.Flags().GetBool("start-at-login"); ok {
552+
if err := autostart.CreateStartAtLoginEntry(runtime.GOOS, inst.Name, inst.Dir); err != nil {
553+
logrus.Errorf("Cant create autostart file error: %s", err)
554+
} else {
555+
logrus.Infof("The autostart file (%q) has been created", autostart.GetStartAtLoginEntryPath(runtime.GOOS, inst.Name))
556+
}
557+
}
558+
539559
return start.Start(ctx, inst, launchHostAgentForeground)
540560
}
541561

pkg/autostart/autostart.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Package 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.WriteFile(unitPath, tmpl, 0o644); err != nil {
35+
// Early return if error is unexpected
36+
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrNotExist) {
37+
return err
38+
}
39+
// Unit file exists, return nil
40+
if errors.Is(err, os.ErrExist) {
41+
return nil
42+
}
43+
// config/.systemd folder might be unavailable
44+
if errors.Is(err, os.ErrNotExist) && hostOS == "linux" {
45+
if err := os.MkdirAll(filepath.Dir(unitPath), os.ModePerm); err != nil {
46+
return err
47+
}
48+
// directory created try to create unit file
49+
if err := os.WriteFile(unitPath, tmpl, 0o644); err != nil {
50+
return err
51+
}
52+
}
53+
}
54+
return enableDisableService("enable", hostOS, GetStartAtLoginEntryPath(hostOS, instName))
55+
}
56+
57+
// DeleteStartAtLoginEntry respect host OS arch and delete unit file
58+
func DeleteStartAtLoginEntry(hostOS, instName string) error {
59+
unitPath := GetStartAtLoginEntryPath(hostOS, instName)
60+
if _, err := os.Stat(unitPath); err != nil {
61+
// Skip error log if file not present
62+
if errors.Is(err, os.ErrNotExist) {
63+
return nil
64+
}
65+
return err
66+
}
67+
if err := enableDisableService("disable", hostOS, GetStartAtLoginEntryPath(hostOS, instName)); err != nil {
68+
return err
69+
}
70+
return os.Remove(unitPath)
71+
}
72+
73+
// GetFilePath returns the path to autostart file with respect of host
74+
func GetStartAtLoginEntryPath(hostOS, instName string) string {
75+
var fileTmp string
76+
if hostOS == "darwin" { // launchd plist
77+
fileTmp = fmt.Sprintf("Library/LaunchAgents/io.lima-vm.autostart.%s.plist", instName)
78+
}
79+
if hostOS == "linux" { // systemd service
80+
// Use instance name as argument to systemd service
81+
// Instance name available in unit file as %i
82+
fileTmp = fmt.Sprintf(".config/systemd/user/lima-vm@%s.service", instName)
83+
}
84+
return filepath.Join(os.Getenv("HOME"), fileTmp)
85+
}
86+
87+
func enableDisableService(action, hostOS, serviceWithPath string) error {
88+
// Get filename without extension
89+
filename := strings.TrimSuffix(path.Base(serviceWithPath), filepath.Ext(path.Base(serviceWithPath)))
90+
91+
var args []string
92+
if hostOS == "darwin" {
93+
// man launchctl
94+
args = append(args, []string{
95+
"launchctl",
96+
action,
97+
fmt.Sprintf("gui/%s/%s", strconv.Itoa(os.Getuid()), filename),
98+
}...)
99+
} else {
100+
args = append(args, []string{
101+
"systemctl",
102+
"--user",
103+
action,
104+
filename,
105+
}...)
106+
}
107+
cmd := exec.Command(args[0], args[1:]...)
108+
cmd.Stdout = os.Stdout
109+
cmd.Stderr = os.Stderr
110+
return cmd.Run()
111+
}
112+
113+
func renderTemplate(hostOS, instName, workDir string, getExecutable func() (string, error)) ([]byte, error) {
114+
selfExeAbs, err := getExecutable()
115+
if err != nil {
116+
return nil, err
117+
}
118+
tmpToExecute := systemdTemplate
119+
if hostOS == "darwin" {
120+
tmpToExecute = launchdTemplate
121+
}
122+
return textutil.ExecuteTemplate(
123+
tmpToExecute,
124+
map[string]string{
125+
"Binary": selfExeAbs,
126+
"Instance": instName,
127+
"WorkDir": workDir,
128+
})
129+
}

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=/some/path
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)