Skip to content

Commit

Permalink
Added a new public CLI command: `limactl start-at-login INSTANCE
Browse files Browse the repository at this point in the history
--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 <[email protected]>
  • Loading branch information
roman-kiselenko committed Feb 28, 2024
1 parent 752afc0 commit ff8153e
Show file tree
Hide file tree
Showing 7 changed files with 367 additions and 0 deletions.
10 changes: 10 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,14 @@ 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 == "darwin" || runtime.GOOS == "linux" {
deleted, err := autostart.DeleteStartAtLoginEntry(runtime.GOOS, instName)
if err != nil && !errors.Is(err, os.ErrNotExist) {
logrus.WithError(err).Warnf("The autostart file for instance %q does not exist", instName)
} else if deleted {
logrus.Infof("The autostart file for instance %q could not be deleted", autostart.GetFilePath(runtime.GOOS, instName))
}
}
logrus.Infof("Deleted %q (%q)", instName, inst.Dir)
}
return networks.Reconcile(cmd.Context(), "")
Expand Down
4 changes: 4 additions & 0 deletions cmd/limactl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ func newApp() *cobra.Command {
newProtectCommand(),
newUnprotectCommand(),
)
if runtime.GOOS == "darwin" || runtime.GOOS == "linux" {
rootCmd.AddCommand(startAtLoginCommand())
}

return rootCmd
}

Expand Down
72 changes: 72 additions & 0 deletions cmd/limactl/start-at-login.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package main

import (
"errors"
"os"
"runtime"

"github.com/lima-vm/lima/pkg/autostart"
"github.com/lima-vm/lima/pkg/store"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)

func startAtLoginCommand() *cobra.Command {
startAtLoginCommand := &cobra.Command{
Use: "start-at-login INSTANCE",
Short: "Register/Unregister an autostart file for the instance",
Args: WrapArgsError(cobra.MaximumNArgs(1)),
RunE: startAtLoginAction,
ValidArgsFunction: startAtLoginComplete,
GroupID: advancedCommand,
}

startAtLoginCommand.Flags().Bool(
"enabled", true,
"Automatically start the instance when the user logs in",
)

return startAtLoginCommand
}

func startAtLoginAction(cmd *cobra.Command, args []string) error {
instName := DefaultInstanceName
if len(args) > 0 {
instName = args[0]
}

inst, err := store.Inspect(instName)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
logrus.Infof("Instance %q not found", instName)
return nil
}
return err
}

flags := cmd.Flags()
startAtLogin, err := flags.GetBool("enabled")
if err != nil {
return err
}
if startAtLogin {
if err := autostart.CreateStartAtLoginEntry(runtime.GOOS, inst.Name, inst.Dir); err != nil {
logrus.WithError(err).Warnf("Can't create an autostart file for instance %q", inst.Name)
} else {
logrus.Infof("The autostart file (%q) has been created or updated", autostart.GetFilePath(runtime.GOOS, inst.Name))
}
} else {
deleted, err := autostart.DeleteStartAtLoginEntry(runtime.GOOS, instName)
if err != nil {
logrus.WithError(err).Warnf("The autostart file for instance %q does not exist", instName)
} else if deleted {
logrus.Infof("The autostart file (%q) has been deleted", autostart.GetFilePath(runtime.GOOS, instName))
}
}

return nil
}

func startAtLoginComplete(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return bashCompleteInstanceNames(cmd)
}
119 changes: 119 additions & 0 deletions pkg/autostart/autostart.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Package autostart 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 := GetFilePath(hostOS, instName)
if _, err := os.Stat(unitPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
tmpl, err := renderTemplate(hostOS, instName, workDir, os.Executable)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(unitPath), os.ModePerm); err != nil {
return err
}
if err := os.WriteFile(unitPath, tmpl, 0o644); err != nil {
return err
}
return enableDisableService("enable", hostOS, GetFilePath(hostOS, instName))
}

// DeleteStartAtLoginEntry respect host OS arch and delete unit file
// return true, nil if unit file has been deleted
func DeleteStartAtLoginEntry(hostOS, instName string) (bool, error) {
unitPath := GetFilePath(hostOS, instName)
if _, err := os.Stat(unitPath); err != nil {
return false, err
}
if err := enableDisableService("disable", hostOS, GetFilePath(hostOS, instName)); err != nil {
return false, err
}
if err := os.Remove(unitPath); err != nil {
return false, err
}
return true, nil
}

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

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=%h
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(GetFilePath(tt.HostOS, tt.InstanceName), tt.Expected))
})
}
}
25 changes: 25 additions & 0 deletions pkg/autostart/io.lima-vm.autostart.INSTANCE.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?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.{{ .Instance }}</string>
<key>ProgramArguments</key>
<array>
<string>{{ .Binary }}</string>
<string>start</string>
<string>{{ .Instance }}</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>{{ .WorkDir }}</string>
<key>ProcessType</key>
<string>Background</string>
</dict>
</plist>
13 changes: 13 additions & 0 deletions pkg/autostart/[email protected]
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[Unit]
Description=Lima - Linux virtual machines, with a focus on running containers.
Documentation=man:lima(1)

[Service]
ExecStart={{.Binary}} start %i --foreground
WorkingDirectory=%h
Type=simple
TimeoutSec=10
Restart=on-failure

[Install]
WantedBy=multi-user.target

0 comments on commit ff8153e

Please sign in to comment.