Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CLI flag to generate autostart files #2151

Merged
merged 1 commit into from
Mar 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 %q has been 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 %q could not be deleted", instName)
} else if deleted {
logrus.Infof("The autostart file %q has been deleted", autostart.GetFilePath(runtime.GOOS, instName))
}
roman-kiselenko marked this conversation as resolved.
Show resolved Hide resolved
}

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
roman-kiselenko marked this conversation as resolved.
Show resolved Hide resolved
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
balajiv113 marked this conversation as resolved.
Show resolved Hide resolved
// 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),
}...)
roman-kiselenko marked this conversation as resolved.
Show resolved Hide resolved
} 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
roman-kiselenko marked this conversation as resolved.
Show resolved Hide resolved
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
Loading