From 732b84d24e5923b485cce2bd3884241c203585ce Mon Sep 17 00:00:00 2001 From: George Dumitrescu Date: Mon, 20 Jul 2026 12:41:40 +0300 Subject: [PATCH] feat(install): unattended setup for package-manager installs Debian and Ubuntu users install lerd from a Launchpad PPA, where a maintainer script runs as root and cannot answer the interactive sudo prompts a normal lerd install relies on. This adds the pieces that let a package finish setup without prompting, while leaving the interactive install path unchanged. lerd bootstrap --system performs the root-level, machine-global steps: the unprivileged-port sysctl, systemd linger, and the DNS sudoers rule. lerd install --unattended runs the rest non-interactively, defaults to managed .test, and generates the mkcert CA with TRUST_STORES=nss so it needs no sudo. lerd bootstrap --trust-ca then installs that CA into the system trust store as root. The postinst runs bootstrap --system, the per-user install, then bootstrap --trust-ca, so managed DNS and HTTPS come up with no password. Every unattended-only step is gated behind the flag, so an ordinary lerd install behaves exactly as before. The systemd unit templates hardcoded ~/.local/bin/lerd, which cannot resolve for a package that installs the binary to /usr/bin. The unit ExecStart is now filled in with the running binary's real path, so the daemon starts from wherever lerd actually lives. lerd update self-replaces a ~/.local/bin binary, which would fight apt on a packaged install, so it now recognises a binary under /usr and points the user at their package manager instead. --- cmd/lerd/main.go | 1 + docs/getting-started/installation.md | 22 ++++++ internal/certs/system_trust_linux.go | 41 ++++++++++ internal/certs/system_trust_linux_test.go | 41 ++++++++++ internal/cli/bootstrap.go | 95 +++++++++++++++++++++++ internal/cli/bootstrap_linux.go | 67 ++++++++++++++++ internal/cli/bootstrap_other.go | 13 ++++ internal/cli/bootstrap_test.go | 77 ++++++++++++++++++ internal/cli/install.go | 44 +++++++++-- internal/cli/update.go | 15 ++++ internal/cli/update_test.go | 20 +++++ internal/dns/setup.go | 21 +++++ internal/systemd/embed.go | 40 +++++++++- internal/systemd/embed_test.go | 47 +++++++++++ 14 files changed, 535 insertions(+), 9 deletions(-) create mode 100644 internal/certs/system_trust_linux.go create mode 100644 internal/certs/system_trust_linux_test.go create mode 100644 internal/cli/bootstrap.go create mode 100644 internal/cli/bootstrap_linux.go create mode 100644 internal/cli/bootstrap_other.go create mode 100644 internal/cli/bootstrap_test.go create mode 100644 internal/systemd/embed_test.go diff --git a/cmd/lerd/main.go b/cmd/lerd/main.go index e42ff3df8..ea7b004f9 100644 --- a/cmd/lerd/main.go +++ b/cmd/lerd/main.go @@ -96,6 +96,7 @@ func main() { // Register all subcommands root.AddCommand(cli.NewInstallCmd()) + root.AddCommand(cli.NewBootstrapCmd()) root.AddCommand(cli.NewStartCmd()) root.AddCommand(cli.NewStopCmd()) root.AddCommand(cli.NewQuitCmd()) diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 497c1d890..feae647a4 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -72,6 +72,28 @@ bash install.sh --local ./build/lerd --- +### Install via apt (Ubuntu/Debian) + +Lerd is published to a Launchpad PPA, so you can install and update it with your package manager: + +```bash +sudo add-apt-repository ppa:lerd/lerd +sudo apt update +sudo apt install lerd +``` + +The package installs the binary to `/usr/bin/lerd` and finishes setup automatically: its maintainer script enables the unprivileged-port sysctl and systemd linger, then runs `lerd install` for your user, so the stack comes up straight away and again at every boot. `.test` DNS and HTTPS are configured with no prompt because the package sets up the sudoers rule and trusts the mkcert CA as root. + +Updates come through apt like any other package: + +```bash +sudo apt upgrade +``` + +An apt-installed lerd lives under `/usr`, so `lerd update` (which self-replaces a `~/.local/bin` install) detects it and defers to `sudo apt upgrade` instead of fighting the package manager. + +--- + ### Update ```bash diff --git a/internal/certs/system_trust_linux.go b/internal/certs/system_trust_linux.go new file mode 100644 index 000000000..1cb8bccda --- /dev/null +++ b/internal/certs/system_trust_linux.go @@ -0,0 +1,41 @@ +//go:build linux + +package certs + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" +) + +// systemStoreCAFile is where the mkcert root CA is dropped for the system trust +// store; update-ca-certificates picks up .crt files under this directory. A var +// so tests can point it at a temp path. +var systemStoreCAFile = "/usr/local/share/ca-certificates/lerd-mkcert-rootCA.crt" + +// updateSystemTrust refreshes the aggregated trust bundle. A var so tests can +// stub out the real command. +var updateSystemTrust = func() error { + return exec.Command("update-ca-certificates").Run() +} + +// TrustCAInSystemStore installs the given mkcert root CA PEM into the system +// trust store. The caller must be root. Idempotent: a byte-identical CA already +// in place is a no-op, so re-running on every package upgrade is cheap. +// +// mkcert normally does this itself via an interactive sudo, which a package +// maintainer script cannot answer. `lerd bootstrap --trust-ca` runs as root and +// installs the user-generated CA directly instead. +func TrustCAInSystemStore(caPEM []byte) error { + if existing, err := os.ReadFile(systemStoreCAFile); err == nil && bytes.Equal(existing, caPEM) { + return nil + } + if err := os.MkdirAll(filepath.Dir(systemStoreCAFile), 0755); err != nil { + return err + } + if err := os.WriteFile(systemStoreCAFile, caPEM, 0644); err != nil { + return err + } + return updateSystemTrust() +} diff --git a/internal/certs/system_trust_linux_test.go b/internal/certs/system_trust_linux_test.go new file mode 100644 index 000000000..4d4c3fa90 --- /dev/null +++ b/internal/certs/system_trust_linux_test.go @@ -0,0 +1,41 @@ +//go:build linux + +package certs + +import ( + "os" + "path/filepath" + "testing" +) + +func TestTrustCAInSystemStore(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "lerd-mkcert-rootCA.crt") + + origPath, origUpdate := systemStoreCAFile, updateSystemTrust + t.Cleanup(func() { systemStoreCAFile, updateSystemTrust = origPath, origUpdate }) + systemStoreCAFile = path + + var updates int + updateSystemTrust = func() error { updates++; return nil } + + ca := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n") + if err := TrustCAInSystemStore(ca); err != nil { + t.Fatalf("first install: %v", err) + } + got, err := os.ReadFile(path) + if err != nil || string(got) != string(ca) { + t.Fatalf("CA not written: got %q err %v", got, err) + } + if updates != 1 { + t.Errorf("update-ca-certificates called %d times, want 1", updates) + } + + // Idempotent: same CA already in place must not rewrite or re-run update. + if err := TrustCAInSystemStore(ca); err != nil { + t.Fatalf("second install: %v", err) + } + if updates != 1 { + t.Errorf("idempotent install re-ran update (%d times)", updates) + } +} diff --git a/internal/cli/bootstrap.go b/internal/cli/bootstrap.go new file mode 100644 index 000000000..3ff324a59 --- /dev/null +++ b/internal/cli/bootstrap.go @@ -0,0 +1,95 @@ +package cli + +import ( + "fmt" + "os" + "os/exec" + + "github.com/spf13/cobra" +) + +// The machine-global, user-independent steps that `lerd install` would +// otherwise perform through interactive sudo. A package maintainer script runs +// as root but cannot prompt, so it calls `lerd bootstrap --system` for the +// prerequisites and `lerd bootstrap --trust-ca` after the per-user install, so +// `lerd install --unattended` in between needs no sudo. See lerd-env/lerd#979. +const ( + unprivPortDropIn = "/etc/sysctl.d/99-lerd-ports.conf" + unprivPortSetting = "net.ipv4.ip_unprivileged_port_start=80" +) + +// cmdRunner runs an external command. A seam so the bootstrap steps can be +// tested without touching the real system. +type cmdRunner func(name string, args ...string) error + +func execRunner(name string, args ...string) error { + c := exec.Command(name, args...) + c.Stdout = os.Stdout + c.Stderr = os.Stderr + return c.Run() +} + +// writePortDropIn lets rootless Podman bind 80/443 by lowering +// ip_unprivileged_port_start, both live (sysctl -w) and persistently (a +// sysctl.d drop-in). Idempotent. +func writePortDropIn(path string, run cmdRunner) error { + if err := os.WriteFile(path, []byte(unprivPortSetting+"\n"), 0644); err != nil { + return err + } + return run("sysctl", "-w", unprivPortSetting) +} + +// enableLinger keeps the user's rootless containers alive across screen blank, +// lock, and logout. A blank user is a no-op. +func enableLinger(user string, run cmdRunner) error { + if user == "" { + return nil + } + return run("loginctl", "enable-linger", user) +} + +// bootstrapTargetUser resolves whose session the per-user half of setup belongs +// to. An explicit --user wins; otherwise SUDO_USER (the human who invoked the +// package manager) is preferred over the ambient USER, and a root SUDO_USER is +// ignored since it carries no per-user meaning. +func bootstrapTargetUser(flagUser string) string { + if flagUser != "" { + return flagUser + } + if u := os.Getenv("SUDO_USER"); u != "" && u != "root" { + return u + } + if u := os.Getenv("USER"); u != "" { + return u + } + return os.Getenv("LOGNAME") +} + +// NewBootstrapCmd returns the bootstrap command. It performs the root-level, +// non-interactive halves of setup so a deb/rpm postinst can finish the install +// without prompting, pairing with `lerd install --unattended`. +func NewBootstrapCmd() *cobra.Command { + var system, trustCA bool + var user string + cmd := &cobra.Command{ + Use: "bootstrap", + Short: "Apply the root-level system setup (for package maintainer scripts)", + RunE: func(_ *cobra.Command, _ []string) error { + if !system && !trustCA { + return fmt.Errorf("nothing to do: pass --system or --trust-ca") + } + if os.Geteuid() != 0 { + return fmt.Errorf("lerd bootstrap configures system-level settings and must run as root") + } + target := bootstrapTargetUser(user) + if trustCA { + return runBootstrapTrustCA(target) + } + return runBootstrapSystem(target) + }, + } + cmd.Flags().BoolVar(&system, "system", false, "Apply root-level setup: unprivileged ports, linger, DNS sudoers") + cmd.Flags().BoolVar(&trustCA, "trust-ca", false, "Trust the user's mkcert CA in the system store (run after install)") + cmd.Flags().StringVar(&user, "user", "", "Target user for per-user settings (defaults to SUDO_USER)") + return cmd +} diff --git a/internal/cli/bootstrap_linux.go b/internal/cli/bootstrap_linux.go new file mode 100644 index 000000000..ef5700882 --- /dev/null +++ b/internal/cli/bootstrap_linux.go @@ -0,0 +1,67 @@ +//go:build linux + +package cli + +import ( + "fmt" + "os" + "os/user" + "path/filepath" + + "github.com/geodro/lerd/internal/certs" + "github.com/geodro/lerd/internal/dns" + "github.com/geodro/lerd/internal/feedback" +) + +// runBootstrapSystem performs the root prerequisites a later unattended install +// relies on: the unprivileged-port sysctl, systemd linger, and the DNS sudoers +// rule that lets the install configure the resolver without prompting. +func runBootstrapSystem(target string) error { + feedback.Header("Bootstrapping system for lerd") + + if err := writePortDropIn(unprivPortDropIn, execRunner); err != nil { + feedback.Warn("enabling unprivileged ports: %v", err) + } else { + feedback.Done("unprivileged ports enabled for 80/443") + } + + if target == "" { + return nil + } + if err := enableLinger(target, execRunner); err != nil { + feedback.Warn("enabling linger for %s: %v", target, err) + } else { + feedback.Done("systemd linger enabled for " + target) + } + if err := dns.WriteSudoersForUser(target); err != nil { + feedback.Warn("installing DNS sudoers rule: %v", err) + } else { + feedback.Done("DNS sudoers rule installed for " + target) + } + return nil +} + +// runBootstrapTrustCA installs the user-generated mkcert root CA into the system +// trust store, the one managed-DNS step the per-user install cannot do without +// an interactive sudo. Run after `lerd install --unattended` has generated the +// CA. A missing CA is not an error (localhost-mode installs have none). +func runBootstrapTrustCA(target string) error { + if target == "" { + return fmt.Errorf("--trust-ca needs a target user (pass --user)") + } + u, err := user.Lookup(target) + if err != nil { + return fmt.Errorf("looking up user %s: %w", target, err) + } + caPath := filepath.Join(u.HomeDir, ".local", "share", "mkcert", "rootCA.pem") + pem, err := os.ReadFile(caPath) + if err != nil { + feedback.Note("no mkcert CA at " + caPath + " yet, skipping system trust") + return nil + } + if err := certs.TrustCAInSystemStore(pem); err != nil { + return fmt.Errorf("trusting CA in system store: %w", err) + } + feedback.Done("mkcert CA trusted in the system store") + return nil +} diff --git a/internal/cli/bootstrap_other.go b/internal/cli/bootstrap_other.go new file mode 100644 index 000000000..e3e02a23a --- /dev/null +++ b/internal/cli/bootstrap_other.go @@ -0,0 +1,13 @@ +//go:build !linux + +package cli + +import "fmt" + +func runBootstrapSystem(string) error { + return fmt.Errorf("lerd bootstrap is only supported on Linux") +} + +func runBootstrapTrustCA(string) error { + return fmt.Errorf("lerd bootstrap is only supported on Linux") +} diff --git a/internal/cli/bootstrap_test.go b/internal/cli/bootstrap_test.go new file mode 100644 index 000000000..7405b6b57 --- /dev/null +++ b/internal/cli/bootstrap_test.go @@ -0,0 +1,77 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" +) + +func TestWritePortDropIn(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "99-lerd-ports.conf") + + var gotName string + var gotArgs []string + run := func(name string, args ...string) error { + gotName = name + gotArgs = args + return nil + } + + if err := writePortDropIn(path, run); err != nil { + t.Fatalf("writePortDropIn: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("drop-in not written: %v", err) + } + if string(data) != unprivPortSetting+"\n" { + t.Errorf("drop-in content = %q, want %q", string(data), unprivPortSetting+"\n") + } + if gotName != "sysctl" || len(gotArgs) != 2 || gotArgs[0] != "-w" || gotArgs[1] != unprivPortSetting { + t.Errorf("runner called with %q %v, want sysctl -w %s", gotName, gotArgs, unprivPortSetting) + } +} + +func TestEnableLinger(t *testing.T) { + var gotArgs []string + run := func(_ string, args ...string) error { + gotArgs = args + return nil + } + + // A blank user is a no-op: nothing to enable, no runner call. + if err := enableLinger("", run); err != nil { + t.Fatalf("enableLinger blank: %v", err) + } + if gotArgs != nil { + t.Errorf("blank user still called runner: %v", gotArgs) + } + + if err := enableLinger("george", run); err != nil { + t.Fatalf("enableLinger: %v", err) + } + if len(gotArgs) != 2 || gotArgs[0] != "enable-linger" || gotArgs[1] != "george" { + t.Errorf("runner args = %v, want enable-linger george", gotArgs) + } +} + +func TestBootstrapTargetUser(t *testing.T) { + t.Setenv("SUDO_USER", "sudoer") + t.Setenv("USER", "current") + + if got := bootstrapTargetUser("explicit"); got != "explicit" { + t.Errorf("flag user ignored: got %q", got) + } + if got := bootstrapTargetUser(""); got != "sudoer" { + t.Errorf("SUDO_USER not preferred: got %q", got) + } + + // root as SUDO_USER is meaningless (it means apt was run by root directly), + // so fall back to USER. + t.Setenv("SUDO_USER", "root") + if got := bootstrapTargetUser(""); got != "current" { + t.Errorf("root SUDO_USER not skipped: got %q", got) + } +} diff --git a/internal/cli/install.go b/internal/cli/install.go index 2f5b9f354..6627c8890 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -66,6 +66,8 @@ func NewInstallCmd() *cobra.Command { "Preselect the DNS mode and skip the prompt: 'managed' (.test + HTTPS) or 'localhost' (.localhost, plain HTTP)") cmd.Flags().Bool("from-update", false, "") _ = cmd.Flags().MarkHidden("from-update") + cmd.Flags().Bool("unattended", false, + "Run non-interactively for package installs: no prompts, and skip the sudo-gated system steps that `lerd bootstrap` handles") return cmd } @@ -140,6 +142,7 @@ func runInstall(cmd *cobra.Command, _ []string) error { noIPv6 = true } fromUpdate, _ := cmd.Flags().GetBool("from-update") + unattended, _ := cmd.Flags().GetBool("unattended") dnsFlag, _ := cmd.Flags().GetString("dns") // Captured before any step writes config: a missing file means this is a // first install, the only time the DNS question is asked. Every later run @@ -147,6 +150,14 @@ func runInstall(cmd *cobra.Command, _ []string) error { // dns:disable rather than by re-prompting. _, cfgStatErr := os.Stat(config.GlobalConfigFile()) configExisted := cfgStatErr == nil + // Unattended runs are driven by a package maintainer script: reuse the + // non-interactive update path for prompts. The sudo-gated system steps are + // skipped here because `lerd bootstrap --system` performs them as root + // beforehand, and the mkcert CA's system-trust is done afterward by + // `lerd bootstrap --trust-ca`, so managed .test DNS works with no prompts. + if unattended { + fromUpdate = true + } if noIPv6 { podman.MarkIPv6Disabled("lerd") feedback.Line("IPv6 disabled by user, lerd network will be v4-only") @@ -163,8 +174,13 @@ func runInstall(cmd *cobra.Command, _ []string) error { ensurePortsAvailable() - if err := ensureUnprivilegedPorts(); err != nil { - return err + // Skipped under --unattended: these prompt for sudo, which a package + // maintainer script cannot answer. `lerd bootstrap --system` set the + // unprivileged-port sysctl and enabled linger as root beforehand. + if !unattended { + if err := ensureUnprivilegedPorts(); err != nil { + return err + } } if err := ensurePortForwarding(); err != nil { return err @@ -194,8 +210,10 @@ func runInstall(cmd *cobra.Command, _ []string) error { // the session goes inactive and lerd appears to "stop working" until // the next manual `lerd install`. This is the single biggest source of // "DNS just stopped" issues reported in the wild — see #153. - if err := ensureSystemdLinger(); err != nil { - fmt.Printf(" WARN: %v\n", err) + if !unattended { + if err := ensureSystemdLinger(); err != nil { + fmt.Printf(" WARN: %v\n", err) + } } // 2. Podman network @@ -403,10 +421,18 @@ func runInstall(cmd *cobra.Command, _ []string) error { // gold sudo header and swallow its "already installed" banner. Only a // genuine first install announces the sudo step and runs interactively. mkcertCmd := exec.Command(certs.MkcertPath(), "-install") - if certs.CATrusted() { + switch { + case unattended: + // No sudo available: generate the CA and trust it only in the user's + // NSS store (TRUST_STORES=nss skips the system store). The system + // store is handled afterward by `lerd bootstrap --trust-ca` as root. + mkcertCmd.Env = append(os.Environ(), "TRUST_STORES=nss") mkcertCmd.Stdout = io.Discard mkcertCmd.Stderr = io.Discard - } else { + case certs.CATrusted(): + mkcertCmd.Stdout = io.Discard + mkcertCmd.Stderr = io.Discard + default: feedback.Sudo("Installing mkcert CA") mkcertCmd.Stdin = os.Stdin mkcertCmd.Stdout = os.Stdout @@ -436,7 +462,11 @@ func runInstall(cmd *cobra.Command, _ []string) error { // InstallSudoers prints its own gold "🔒 Installing DNS sudoers rule" // line when it actually writes the drop-in, so no header is printed here. - dns.InstallSudoers() //nolint:errcheck + // Skipped under --unattended: `lerd bootstrap --system` already wrote the + // rule as root, and InstallSudoers would prompt for a password here. + if !unattended { + dns.InstallSudoers() //nolint:errcheck + } } else { feedback.Line("DNS disabled, skipping mkcert CA, dnsmasq and sudoers") } diff --git a/internal/cli/update.go b/internal/cli/update.go index c939273ce..ee8e92ff0 100644 --- a/internal/cli/update.go +++ b/internal/cli/update.go @@ -98,6 +98,13 @@ func runUpdate(currentVersion string, beta bool) error { } } + // A deb/rpm install lives under /usr and is owned by the package manager; + // self-replacing it would fight apt/dnf, so defer to them. + if self, err := selfPath(); err == nil && isSystemPackageManaged(self) { + fmt.Printf("\nThis lerd is managed by your system package manager (%s).\nUpdate it with:\n\n sudo apt upgrade\n\n", self) + return nil + } + // Ask for confirmation. if !feedback.Confirm("Update to v"+lat+"?", true) { feedback.Line("update cancelled") @@ -487,6 +494,14 @@ func isHomebrewManaged(path string) bool { return strings.Contains(path, "/Cellar/") } +// isSystemPackageManaged reports whether the binary lives under a system prefix +// owned by a package manager. lerd's own installers use ~/.local/bin, so a +// binary under /usr came from the deb/rpm and must be updated with apt/dnf, not +// by self-replacing files the package manager owns. +func isSystemPackageManaged(path string) bool { + return strings.HasPrefix(path, "/usr/") +} + func selfPath() (string, error) { self, err := os.Executable() if err != nil { diff --git a/internal/cli/update_test.go b/internal/cli/update_test.go index 75ed4179d..2c6124b07 100644 --- a/internal/cli/update_test.go +++ b/internal/cli/update_test.go @@ -13,6 +13,26 @@ import ( lerdUpdate "github.com/geodro/lerd/internal/update" ) +// ── package-managed detection ───────────────────────────────────────────────── + +func TestIsSystemPackageManaged(t *testing.T) { + cases := []struct { + path string + want bool + }{ + {"/usr/bin/lerd", true}, + {"/usr/local/bin/lerd", true}, + {"/home/george/.local/bin/lerd", false}, + {"/opt/lerd/lerd", false}, + {"/tmp/lerd", false}, + } + for _, c := range cases { + if got := isSystemPackageManaged(c.path); got != c.want { + t.Errorf("isSystemPackageManaged(%q) = %v, want %v", c.path, got, c.want) + } + } +} + // ── stripV ─────────────────────────────────────────────────────────────────── func TestStripV(t *testing.T) { diff --git a/internal/dns/setup.go b/internal/dns/setup.go index b83ddbf22..b5f5105c0 100644 --- a/internal/dns/setup.go +++ b/internal/dns/setup.go @@ -902,6 +902,27 @@ func Teardown() { // InstallSudoers writes a sudoers drop-in granting the current user passwordless // access to resolvectl commands. This is required for the autostart service which // runs non-interactively and cannot prompt for a sudo password. +// WriteSudoersForUser writes the DNS sudoers drop-in directly, without the +// interactive `sudo tee` that InstallSudoers uses. The caller must already be +// root. `lerd bootstrap --system` calls this so a package maintainer script can +// grant the passwordless DNS rules up front, letting a later unattended install +// configure the resolver without a prompt. Idempotent. +func WriteSudoersForUser(user string) error { + if user == "" { + return fmt.Errorf("cannot determine target user") + } + content := renderLinuxSudoers(user) + if sudoersInstalled([]byte(content)) { + return nil + } + const sudoersPath = "/etc/sudoers.d/lerd" + if err := os.WriteFile(sudoersPath, []byte(content), 0440); err != nil { + return fmt.Errorf("writing sudoers drop-in: %w", err) + } + recordSudoersInstalled([]byte(content)) + return nil +} + func InstallSudoers() error { user := os.Getenv("USER") if user == "" { diff --git a/internal/systemd/embed.go b/internal/systemd/embed.go index 4f573b318..6b6a74d95 100644 --- a/internal/systemd/embed.go +++ b/internal/systemd/embed.go @@ -3,13 +3,35 @@ package systemd import ( "embed" "fmt" + "os" + "path/filepath" "strings" ) //go:embed units var unitsFS embed.FS -// GetUnit returns the content of an embedded systemd unit file. +// lerdBinaryPath resolves the absolute path to the running lerd binary so unit +// ExecStart lines point at wherever lerd is actually installed: ~/.local/bin +// for curl/brew, /usr/bin for the deb. A var so tests can override it; returns +// "" when the path can't be resolved, in which case GetUnit leaves the template +// default in place. +var lerdBinaryPath = func() string { + exe, err := os.Executable() + if err != nil { + return "" + } + if resolved, rErr := filepath.EvalSymlinks(exe); rErr == nil { + exe = resolved + } + return exe +} + +// GetUnit returns the content of an embedded systemd unit file with the lerd +// binary path resolved. The templates ship with ExecStart=%h/.local/bin/lerd, +// which only works for a ~/.local/bin install; substituting the real path lets +// the daemon units run from any install location (notably /usr/bin under the +// Debian package). func GetUnit(name string) (string, error) { // name may or may not have .service suffix filename := name @@ -20,5 +42,19 @@ func GetUnit(name string) (string, error) { if err != nil { return "", fmt.Errorf("systemd unit %q not found: %w", name, err) } - return string(data), nil + return resolveUnitBinaryPath(string(data)), nil +} + +// resolveUnitBinaryPath swaps the hardcoded ~/.local/bin templates for the +// running binary's real location. lerd-tray is replaced first because its name +// has lerd as a prefix. +func resolveUnitBinaryPath(content string) string { + bin := lerdBinaryPath() + if bin == "" { + return content + } + tray := filepath.Join(filepath.Dir(bin), "lerd-tray") + content = strings.ReplaceAll(content, "%h/.local/bin/lerd-tray", tray) + content = strings.ReplaceAll(content, "%h/.local/bin/lerd", bin) + return content } diff --git a/internal/systemd/embed_test.go b/internal/systemd/embed_test.go new file mode 100644 index 000000000..4b6361fa8 --- /dev/null +++ b/internal/systemd/embed_test.go @@ -0,0 +1,47 @@ +package systemd + +import ( + "strings" + "testing" +) + +func TestGetUnitResolvesBinaryPath(t *testing.T) { + orig := lerdBinaryPath + t.Cleanup(func() { lerdBinaryPath = orig }) + lerdBinaryPath = func() string { return "/usr/bin/lerd" } + + ui, err := GetUnit("lerd-ui") + if err != nil { + t.Fatalf("GetUnit: %v", err) + } + if !strings.Contains(ui, "ExecStart=/usr/bin/lerd serve-ui") { + t.Errorf("lerd-ui ExecStart not resolved:\n%s", ui) + } + if strings.Contains(ui, "%h/.local/bin/lerd") { + t.Errorf("lerd-ui still has the template path:\n%s", ui) + } + + tray, err := GetUnit("lerd-tray") + if err != nil { + t.Fatalf("GetUnit tray: %v", err) + } + if !strings.Contains(tray, "ExecStart=/usr/bin/lerd-tray") { + t.Errorf("lerd-tray ExecStart not resolved:\n%s", tray) + } +} + +// When the binary path can't be resolved, the template default is left intact +// rather than producing a broken ExecStart. +func TestGetUnitKeepsTemplateWhenUnresolved(t *testing.T) { + orig := lerdBinaryPath + t.Cleanup(func() { lerdBinaryPath = orig }) + lerdBinaryPath = func() string { return "" } + + ui, err := GetUnit("lerd-ui") + if err != nil { + t.Fatalf("GetUnit: %v", err) + } + if !strings.Contains(ui, "%h/.local/bin/lerd serve-ui") { + t.Errorf("expected template default, got:\n%s", ui) + } +}