Skip to content

Commit

Permalink
Added a new public CLI command: `limactl (create|edit|start|delete) -…
Browse files Browse the repository at this point in the history
…-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 <[email protected]>
  • Loading branch information
roman-kiselenko committed Jan 31, 2024
1 parent c339ef2 commit 166cb8e
Show file tree
Hide file tree
Showing 7 changed files with 345 additions and 1 deletion.
9 changes: 9 additions & 0 deletions cmd/limactl/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import (
"errors"
"fmt"
"os"
"runtime"

"github.com/lima-vm/lima/pkg/autostart"
networks "github.com/lima-vm/lima/pkg/networks/reconcile"
"github.com/lima-vm/lima/pkg/stop"
"github.com/lima-vm/lima/pkg/store"
Expand Down Expand Up @@ -44,6 +46,13 @@ func deleteAction(cmd *cobra.Command, args []string) error {
if err := deleteInstance(cmd.Context(), inst, force); err != nil {
return fmt.Errorf("failed to delete instance %q: %w", instName, err)
}
if runtime.GOOS != "windows" {
if err := autostart.DeleteStartAtLoginEntry(runtime.GOOS, instName); err != nil {
logrus.Errorf("Cant delete autostart file error: %s", err)
} else {
logrus.Infof("The autostart file (%q) has been deleted", autostart.GetStartAtLoginEntryPath(runtime.GOOS, instName))
}
}
logrus.Infof("Deleted %q (%q)", instName, inst.Dir)
}
return networks.Reconcile(cmd.Context(), "")
Expand Down
26 changes: 25 additions & 1 deletion cmd/limactl/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"

"github.com/lima-vm/lima/cmd/limactl/editflags"
"github.com/lima-vm/lima/pkg/autostart"
"github.com/lima-vm/lima/pkg/editutil"
"github.com/lima-vm/lima/pkg/limayaml"
networks "github.com/lima-vm/lima/pkg/networks/reconcile"
Expand All @@ -30,6 +32,11 @@ func newEditCommand() *cobra.Command {
GroupID: basicCommand,
}
editflags.RegisterEdit(editCommand)

if runtime.GOOS != "windows" {
editCommand.Flags().Bool("start-at-login", false, "register(--start-at-login=true)/unregister(--start-at-login=false) the systemd/launchd unit file for instance")
}

return editCommand
}

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

flags := cmd.Flags()

if flags.Lookup("start-at-login").Changed {
if startAtLogin, _ := flags.GetBool("start-at-login"); startAtLogin {
if err := autostart.CreateStartAtLoginEntry(runtime.GOOS, inst.Name, inst.Dir); err != nil {
logrus.Errorf("Cant create autostart file error: %s", err)
} else {
logrus.Infof("The autostart file (%q) has been created", autostart.GetStartAtLoginEntryPath(runtime.GOOS, inst.Name))
}
} else {
if err := autostart.DeleteStartAtLoginEntry(runtime.GOOS, instName); err != nil {
logrus.Errorf("Cant delete autostart file error: %s", err)
} else {
logrus.Infof("The autostart file (%q) has been deleted", autostart.GetStartAtLoginEntryPath(runtime.GOOS, instName))
}
}
}

filePath := filepath.Join(inst.Dir, filenames.LimaYAML)
yContent, err := os.ReadFile(filePath)
if err != nil {
return err
}
flags := cmd.Flags()
tty, err := flags.GetBool("tty")
if err != nil {
return err
Expand Down
20 changes: 20 additions & 0 deletions cmd/limactl/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/containerd/containerd/identifiers"
"github.com/lima-vm/lima/cmd/limactl/editflags"
"github.com/lima-vm/lima/cmd/limactl/guessarg"
"github.com/lima-vm/lima/pkg/autostart"
"github.com/lima-vm/lima/pkg/editutil"
"github.com/lima-vm/lima/pkg/ioutilx"
"github.com/lima-vm/lima/pkg/limayaml"
Expand Down Expand Up @@ -72,6 +73,9 @@ $ cat template.yaml | limactl create --name=local -
GroupID: basicCommand,
}
registerCreateFlags(createCommand, "")
if runtime.GOOS != "windows" {
createCommand.Flags().Bool("start-at-login", false, "create the systemd/launchd unit file but will leave the instance stopped")
}
return createCommand
}

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

if ok, _ := cmd.Flags().GetBool("start-at-login"); ok {
if err := autostart.CreateStartAtLoginEntry(runtime.GOOS, inst.Name, inst.Dir); err != nil {
logrus.Errorf("Cant create autostart file error: %s", err)
} else {
logrus.Infof("The autostart file (%q) has been created", autostart.GetStartAtLoginEntryPath(runtime.GOOS, inst.Name))
}
}

return start.Start(ctx, inst, launchHostAgentForeground)
}

Expand Down
129 changes: 129 additions & 0 deletions pkg/autostart/autostart.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Package manage start at login unit files for darwin/linux
package autostart

import (
_ "embed"
"errors"
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"strconv"
"strings"

"github.com/lima-vm/lima/pkg/textutil"
)

//go:embed [email protected]
var systemdTemplate string

//go:embed io.lima-vm.autostart.INSTANCE.plist
var launchdTemplate string

// CreateStartAtLoginEntry respect host OS arch and create unit file
func CreateStartAtLoginEntry(hostOS, instName, workDir string) error {
unitPath := GetStartAtLoginEntryPath(hostOS, instName)
if _, err := os.Stat(unitPath); !errors.Is(err, os.ErrNotExist) {
return err
}
tmpl, err := renderTemplate(hostOS, workDir, instName, os.Executable)
if err != nil {
return err
}
if err := os.WriteFile(unitPath, tmpl, 0o644); err != nil {
// Early return if error is unexpected
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrNotExist) {
return err
}
// Unit file exists, return nil
if errors.Is(err, os.ErrExist) {
return nil
}
// config/.systemd folder might be unavailable
if errors.Is(err, os.ErrNotExist) && hostOS == "linux" {
if err := os.MkdirAll(filepath.Dir(unitPath), os.ModePerm); err != nil {
return err
}
// directory created try to create unit file
if err := os.WriteFile(unitPath, tmpl, 0o644); err != nil {
return err
}
}
}
return enableDisableService("enable", hostOS, GetStartAtLoginEntryPath(hostOS, instName))
}

// DeleteStartAtLoginEntry respect host OS arch and delete unit file
func DeleteStartAtLoginEntry(hostOS, instName string) error {
unitPath := GetStartAtLoginEntryPath(hostOS, instName)
if _, err := os.Stat(unitPath); err != nil {
// Skip error log if file not present
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
if err := enableDisableService("disable", hostOS, GetStartAtLoginEntryPath(hostOS, instName)); err != nil {
return err
}
return os.Remove(unitPath)
}

// GetFilePath returns the path to autostart file with respect of host
func GetStartAtLoginEntryPath(hostOS, instName string) string {
var fileTmp string
if hostOS == "darwin" { // launchd plist
fileTmp = fmt.Sprintf("Library/LaunchAgents/io.lima-vm.autostart.%s.plist", instName)
}
if hostOS == "linux" { // systemd service
// Use instance name as argument to systemd service
// Instance name available in unit file as %i
fileTmp = fmt.Sprintf(".config/systemd/user/lima-vm@%s.service", instName)
}
return filepath.Join(os.Getenv("HOME"), fileTmp)
}

func enableDisableService(action, hostOS, serviceWithPath string) error {
// Get filename without extension
filename := strings.TrimSuffix(path.Base(serviceWithPath), filepath.Ext(path.Base(serviceWithPath)))

var args []string
if hostOS == "darwin" {
// man launchctl
args = append(args, []string{
"launchctl",
action,
fmt.Sprintf("gui/%s/%s", strconv.Itoa(os.Getuid()), filename),
}...)
} else {
args = append(args, []string{
"systemctl",
"--user",
action,
filename,
}...)
}
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}

func renderTemplate(hostOS, instName, workDir string, getExecutable func() (string, error)) ([]byte, error) {
selfExeAbs, err := getExecutable()
if err != nil {
return nil, err
}
tmpToExecute := systemdTemplate
if hostOS == "darwin" {
tmpToExecute = launchdTemplate
}
return textutil.ExecuteTemplate(
tmpToExecute,
map[string]string{
"Binary": selfExeAbs,
"Instance": instName,
"WorkDir": workDir,
})
}
124 changes: 124 additions & 0 deletions pkg/autostart/autostart_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package autostart

import (
"runtime"
"strings"
"testing"

"gotest.tools/v3/assert"
)

func TestRenderTemplate(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("skipping testing on windows host")
}
tests := []struct {
Name string
InstanceName string
HostOS string
Expected string
WorkDir string
GetExecutable func() (string, error)
}{
{
Name: "render darwin launchd plist",
InstanceName: "default",
HostOS: "darwin",
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.autostart.default</string>
<key>ProgramArguments</key>
<array>
<string>/limactl</string>
<string>start</string>
<string>default</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
},
WorkDir: "/some/path",
},
{
Name: "render linux systemd service",
InstanceName: "default",
HostOS: "linux",
Expected: `[Unit]
Description=Lima - Linux virtual machines, with a focus on running containers.
Documentation=man:lima(1)
[Service]
ExecStart=/limactl start %i --foreground
WorkingDirectory=/some/path
Type=simple
TimeoutSec=10
Restart=on-failure
[Install]
WantedBy=multi-user.target`,
GetExecutable: func() (string, error) {
return "/limactl", nil
},
WorkDir: "/some/path",
},
}
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
tmpl, err := renderTemplate(tt.HostOS, tt.InstanceName, tt.WorkDir, tt.GetExecutable)
assert.NilError(t, err)
assert.Equal(t, string(tmpl), tt.Expected)
})
}
}

func TestGetFilePath(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("skipping testing on windows host")
}
tests := []struct {
Name string
HostOS string
InstanceName string
HomeEnv string
Expected string
}{
{
Name: "darwin with docker instance name",
HostOS: "darwin",
InstanceName: "docker",
Expected: "Library/LaunchAgents/io.lima-vm.autostart.docker.plist",
},
{
Name: "linux with docker instance name",
HostOS: "linux",
InstanceName: "docker",
Expected: ".config/systemd/user/[email protected]",
},
{
Name: "empty with empty instance name",
HostOS: "",
InstanceName: "",
Expected: "",
},
}
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
assert.Check(t, strings.HasSuffix(GetStartAtLoginEntryPath(tt.HostOS, tt.InstanceName), tt.Expected))
})
}
}
Loading

0 comments on commit 166cb8e

Please sign in to comment.