From 1ed3dc211a0f8203375d6624d86965684d4c8e89 Mon Sep 17 00:00:00 2001 From: roman-kiselenko Date: Thu, 18 Jan 2024 17:05:09 +0300 Subject: [PATCH] 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 --- cmd/limactl/delete.go | 10 ++ cmd/limactl/main.go | 4 + cmd/limactl/start-at-login.go | 72 ++++++++++ pkg/autostart/autostart.go | 119 +++++++++++++++++ pkg/autostart/autostart_test.go | 124 ++++++++++++++++++ .../io.lima-vm.autostart.INSTANCE.plist | 25 ++++ pkg/autostart/lima-vm@INSTANCE.service | 13 ++ 7 files changed, 367 insertions(+) create mode 100644 cmd/limactl/start-at-login.go create mode 100644 pkg/autostart/autostart.go create mode 100644 pkg/autostart/autostart_test.go create mode 100644 pkg/autostart/io.lima-vm.autostart.INSTANCE.plist create mode 100644 pkg/autostart/lima-vm@INSTANCE.service diff --git a/cmd/limactl/delete.go b/cmd/limactl/delete.go index 94bfc44b642..fdec52f9485 100644 --- a/cmd/limactl/delete.go +++ b/cmd/limactl/delete.go @@ -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" @@ -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 %q has been deleted", autostart.GetFilePath(runtime.GOOS, instName)) + } + } logrus.Infof("Deleted %q (%q)", instName, inst.Dir) } return networks.Reconcile(cmd.Context(), "") diff --git a/cmd/limactl/main.go b/cmd/limactl/main.go index 113964a9e1b..81d62eed4e5 100644 --- a/cmd/limactl/main.go +++ b/cmd/limactl/main.go @@ -123,6 +123,10 @@ func newApp() *cobra.Command { newProtectCommand(), newUnprotectCommand(), ) + if runtime.GOOS == "darwin" || runtime.GOOS == "linux" { + rootCmd.AddCommand(startAtLoginCommand()) + } + return rootCmd } diff --git a/cmd/limactl/start-at-login.go b/cmd/limactl/start-at-login.go new file mode 100644 index 00000000000..312d1a4698d --- /dev/null +++ b/cmd/limactl/start-at-login.go @@ -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 %q could not be deleted", 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) +} diff --git a/pkg/autostart/autostart.go b/pkg/autostart/autostart.go new file mode 100644 index 00000000000..d214aa7a3c4 --- /dev/null +++ b/pkg/autostart/autostart.go @@ -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 lima-vm@INSTANCE.service +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, + }) +} diff --git a/pkg/autostart/autostart_test.go b/pkg/autostart/autostart_test.go new file mode 100644 index 00000000000..3bc905bdb3c --- /dev/null +++ b/pkg/autostart/autostart_test.go @@ -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: ` + + + + Label + io.lima-vm.autostart.default + ProgramArguments + + /limactl + start + default + --foreground + + RunAtLoad + + StandardErrorPath + launchd.stderr.log + StandardOutPath + launchd.stdout.log + WorkingDirectory + /some/path + ProcessType + Background + +`, + 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/lima-vm@docker.service", + }, + { + 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)) + }) + } +} diff --git a/pkg/autostart/io.lima-vm.autostart.INSTANCE.plist b/pkg/autostart/io.lima-vm.autostart.INSTANCE.plist new file mode 100644 index 00000000000..7e0ffd9494c --- /dev/null +++ b/pkg/autostart/io.lima-vm.autostart.INSTANCE.plist @@ -0,0 +1,25 @@ + + + + + Label + io.lima-vm.autostart.{{ .Instance }} + ProgramArguments + + {{ .Binary }} + start + {{ .Instance }} + --foreground + + RunAtLoad + + StandardErrorPath + launchd.stderr.log + StandardOutPath + launchd.stdout.log + WorkingDirectory + {{ .WorkDir }} + ProcessType + Background + + \ No newline at end of file diff --git a/pkg/autostart/lima-vm@INSTANCE.service b/pkg/autostart/lima-vm@INSTANCE.service new file mode 100644 index 00000000000..af01854cb23 --- /dev/null +++ b/pkg/autostart/lima-vm@INSTANCE.service @@ -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 \ No newline at end of file