From 7bff7941cd17aa118ed0a2d41d1a43aae79aff99 Mon Sep 17 00:00:00 2001 From: pavel Date: Thu, 14 May 2026 19:48:42 +0200 Subject: [PATCH 1/2] config --- .env.example | 2 +- internal/app/config.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 058fa70..aa11624 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,7 @@ FORGEJO_BASE_URL= FORGEJO_TOKEN= FORGEJO_ORG= PROJECTS_ROOT=$HOME/projects -DEFAULT_RUN_COMMAND=/usr/bin/env bash -lc 'sleep infinity' +DEFAULT_RUN_COMMAND=go run . CADDY_ROUTE_ENABLED=false CADDY_ADMIN_URL=http://localhost:2019 CADDY_SERVER_ID=srv0 diff --git a/internal/app/config.go b/internal/app/config.go index 62ea816..b447341 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -22,7 +22,7 @@ func loadConfig() (Config, error) { ForgejoToken: getenv("FORGEJO_TOKEN", ""), ForgejoOrg: getenv("FORGEJO_ORG", ""), ProjectRoot: getenv("PROJECTS_ROOT", filepath.Join(userHomeOrDot(), "projects")), - DefaultRunCommand: getenv("DEFAULT_RUN_COMMAND", "/usr/bin/env bash -lc 'sleep infinity'"), + DefaultRunCommand: getenv("DEFAULT_RUN_COMMAND", "go run ."), CaddyAdminURL: strings.TrimRight(getenv("CADDY_ADMIN_URL", "http://localhost:2019"), "/"), CaddyServerID: getenv("CADDY_SERVER_ID", "srv0"), CaddyDomainSuffix: getenv("CADDY_DOMAIN_SUFFIX", ""), From 4e6ad3cd26f996997ff06d7769b12cb15ddf82e1 Mon Sep 17 00:00:00 2001 From: pavel Date: Thu, 14 May 2026 20:04:05 +0200 Subject: [PATCH 2/2] sytemd magics --- README.md | 1 + internal/app/deploy_service.go | 2 +- internal/app/systemd_service.go | 81 ++++++++++++++++++++++++++++----- 3 files changed, 72 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index be41978..a5188b1 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ Notes: - Status is read via `systemctl --user show `. - Logs are read via `journalctl --user -u `. - Both commands are executed as the workspace Unix user via `sudo -n -u ...`. +- The app also auto-runs `loginctl enable-linger ` (via sudo) to keep user systemd available without active login. ## Systemd diff --git a/internal/app/deploy_service.go b/internal/app/deploy_service.go index 1dba0cf..11e9fa0 100644 --- a/internal/app/deploy_service.go +++ b/internal/app/deploy_service.go @@ -89,5 +89,5 @@ func (a *App) deployProjectFromMain(_ context.Context, p Project) error { return err } } - return runCmd("sudo", "-n", "-u", p.UnixUser, "systemctl", "--user", "restart", p.ServiceName) + return runSystemctlUser(p.UnixUser, "restart", p.ServiceName) } diff --git a/internal/app/systemd_service.go b/internal/app/systemd_service.go index f0fab29..4c676ed 100644 --- a/internal/app/systemd_service.go +++ b/internal/app/systemd_service.go @@ -81,8 +81,8 @@ WantedBy=default.target if err := os.WriteFile(servicePath, []byte(unit), 0o644); err != nil { return err } - _ = runCmd("sudo", "-n", "-u", p.UnixUser, "systemctl", "--user", "daemon-reload") - _ = runCmd("sudo", "-n", "-u", p.UnixUser, "systemctl", "--user", "enable", "--now", p.ServiceName) + _ = runSystemctlUser(p.UnixUser, "daemon-reload") + _ = runSystemctlUser(p.UnixUser, "enable", "--now", p.ServiceName) return nil } @@ -97,6 +97,59 @@ func homeForUnixUser(username string) (string, error) { return u.HomeDir, nil } +func userRuntimeEnv(username string) (string, string, error) { + u, err := user.Lookup(username) + if err != nil { + return "", "", fmt.Errorf("lookup unix user %q failed: %w", username, err) + } + runtimeDir := filepath.Join("/run/user", u.Uid) + busAddr := "unix:path=" + filepath.Join(runtimeDir, "bus") + return runtimeDir, busAddr, nil +} + +func runSystemctlUser(username string, args ...string) error { + if err := ensureLingerEnabled(username); err != nil { + return err + } + runtimeDir, busAddr, err := userRuntimeEnv(username) + if err != nil { + return err + } + cmdArgs := []string{ + "-n", "-u", username, + "env", + "XDG_RUNTIME_DIR=" + runtimeDir, + "DBUS_SESSION_BUS_ADDRESS=" + busAddr, + "systemctl", "--user", + } + cmdArgs = append(cmdArgs, args...) + return runCmd("sudo", cmdArgs...) +} + +func runJournalctlUser(username string, args ...string) (string, error) { + if err := ensureLingerEnabled(username); err != nil { + return "", err + } + runtimeDir, busAddr, err := userRuntimeEnv(username) + if err != nil { + return "", err + } + cmdArgs := []string{ + "-n", "-u", username, + "env", + "XDG_RUNTIME_DIR=" + runtimeDir, + "DBUS_SESSION_BUS_ADDRESS=" + busAddr, + "journalctl", "--user", + } + cmdArgs = append(cmdArgs, args...) + return runCmdOut("sudo", cmdArgs...) +} + +func ensureLingerEnabled(username string) error { + // Idempotent; safe to call repeatedly. + return runCmd("sudo", "loginctl", "enable-linger", username) +} + type ServiceStatus struct { Unit string `json:"unit"` ActiveState string `json:"active_state"` @@ -108,11 +161,20 @@ type ServiceStatus struct { } func (a *App) getServiceStatus(p Project) (ServiceStatus, error) { - out, err := runCmdOut( - "sudo", "-n", "-u", p.UnixUser, - "systemctl", "--user", "show", p.ServiceName, "--no-pager", - "--property=ActiveState,SubState,MainPID,ExecMainStatus,Result,FragmentPath", - ) + out, err := func() (string, error) { + runtimeDir, busAddr, err := userRuntimeEnv(p.UnixUser) + if err != nil { + return "", err + } + return runCmdOut( + "sudo", "-n", "-u", p.UnixUser, + "env", + "XDG_RUNTIME_DIR="+runtimeDir, + "DBUS_SESSION_BUS_ADDRESS="+busAddr, + "systemctl", "--user", "show", p.ServiceName, "--no-pager", + "--property=ActiveState,SubState,MainPID,ExecMainStatus,Result,FragmentPath", + ) + }() if err != nil { return ServiceStatus{}, err } @@ -142,8 +204,5 @@ func (a *App) getServiceStatus(p Project) (ServiceStatus, error) { } func (a *App) getServiceLogs(p Project, lines int) (string, error) { - return runCmdOut( - "sudo", "-n", "-u", p.UnixUser, - "journalctl", "--user", "-u", p.ServiceName, "--no-pager", "-n", strconv.Itoa(lines), "-o", "short-iso", - ) + return runJournalctlUser(p.UnixUser, "-u", p.ServiceName, "--no-pager", "-n", strconv.Itoa(lines), "-o", "short-iso") }