| 1 | //go:build linux |
| 2 | |
| 3 | package service |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "fmt" |
| 8 | "os" |
| 9 | "os/exec" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | ) |
| 13 | |
| 14 | func Install(ctx context.Context, def Definition) error { |
| 15 | unitPath, userMode, err := linuxUnitPath(def.Name) |
| 16 | if err != nil { |
| 17 | return err |
| 18 | } |
| 19 | if err := os.MkdirAll(filepath.Dir(unitPath), 0o755); err != nil { |
| 20 | return err |
| 21 | } |
| 22 | if err := os.WriteFile(unitPath, []byte(systemdUnit(def)), 0o644); err != nil { |
| 23 | return err |
| 24 | } |
| 25 | if err := runSystemctl(ctx, userMode, "daemon-reload"); err != nil { |
| 26 | return err |
| 27 | } |
| 28 | return runSystemctl(ctx, userMode, "enable", def.Name+".service") |
| 29 | } |
| 30 | |
| 31 | func Start(ctx context.Context, name string) error { |
| 32 | _, userMode, err := linuxUnitPath(name) |
| 33 | if err != nil { |
| 34 | return err |
| 35 | } |
| 36 | return runSystemctl(ctx, userMode, "start", name+".service") |
| 37 | } |
| 38 | |
| 39 | func Stop(ctx context.Context, name string) error { |
| 40 | _, userMode, err := linuxUnitPath(name) |
| 41 | if err != nil { |
| 42 | return err |
| 43 | } |
| 44 | return runSystemctl(ctx, userMode, "stop", name+".service") |
| 45 | } |
| 46 | |
| 47 | func StopDisable(ctx context.Context, name string) error { |
| 48 | _, userMode, err := linuxUnitPath(name) |
| 49 | if err != nil { |
| 50 | return err |
| 51 | } |
| 52 | return runSystemctl(ctx, userMode, "disable", "--now", name+".service") |
| 53 | } |
| 54 | |
| 55 | func Run(ctx context.Context, name string, run func(context.Context) error) error { |
| 56 | return run(ctx) |
| 57 | } |
| 58 | |
| 59 | func linuxUnitPath(name string) (string, bool, error) { |
| 60 | if os.Geteuid() == 0 { |
| 61 | return filepath.Join("/etc/systemd/system", name+".service"), false, nil |
| 62 | } |
| 63 | home, err := os.UserHomeDir() |
| 64 | if err != nil { |
| 65 | return "", false, err |
| 66 | } |
| 67 | return filepath.Join(home, ".config", "systemd", "user", name+".service"), true, nil |
| 68 | } |
| 69 | |
| 70 | func systemctlArgs(userMode bool, args ...string) []string { |
| 71 | if userMode { |
| 72 | return append([]string{"--user"}, args...) |
| 73 | } |
| 74 | return args |
| 75 | } |
| 76 | |
| 77 | func runSystemctl(ctx context.Context, userMode bool, args ...string) error { |
| 78 | commandArgs := systemctlArgs(userMode, args...) |
| 79 | output, err := exec.CommandContext(ctx, "systemctl", commandArgs...).CombinedOutput() |
| 80 | if err == nil { |
| 81 | return nil |
| 82 | } |
| 83 | message := "systemctl " + strings.Join(commandArgs, " ") |
| 84 | if detail := strings.TrimSpace(string(output)); detail != "" { |
| 85 | message += ": " + detail |
| 86 | } |
| 87 | if userMode { |
| 88 | message += "; user systemd must be available for managed agent mode" |
| 89 | } |
| 90 | return fmt.Errorf("%s: %w", message, err) |
| 91 | } |
| 92 | |
| 93 | func systemdUnit(def Definition) string { |
| 94 | parts := append([]string{def.Executable}, def.Args...) |
| 95 | for i := range parts { |
| 96 | parts[i] = shellQuote(parts[i]) |
| 97 | } |
| 98 | return fmt.Sprintf(`[Unit] |
| 99 | Description=%s |
| 100 | After=network-online.target |
| 101 | Wants=network-online.target |
| 102 | |
| 103 | [Service] |
| 104 | Type=simple |
| 105 | WorkingDirectory=%s |
| 106 | ExecStart=%s |
| 107 | Restart=always |
| 108 | RestartSec=5 |
| 109 | |
| 110 | [Install] |
| 111 | WantedBy=default.target |
| 112 | `, def.Description, shellQuote(def.WorkingDir), strings.Join(parts, " ")) |
| 113 | } |
| 114 | |
| 115 | func shellQuote(value string) string { |
| 116 | return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" |
| 117 | } |