box/internal/app/systemd_service.go
2026-05-14 20:13:28 +02:00

239 lines
6.1 KiB
Go

package app
import (
"context"
"fmt"
"os"
"os/user"
"path/filepath"
"sort"
"strconv"
"strings"
)
func (a *App) renderSystemdForProject(ctx context.Context, p Project) error {
envVars, err := a.listEnvVars(ctx, p.UserID, p.ID)
if err != nil {
if err.Error() == "project not found" {
return err
}
envVars = []EnvVar{}
}
projectDir := filepath.Join(a.cfg.ProjectRoot, slugify(p.UserID), p.Workspace, p.Slug)
if err := os.MkdirAll(projectDir, 0o755); err != nil {
return err
}
dataDir := filepath.Join(projectDir, "data")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
return err
}
socketPath := filepath.Join(projectDir, "app.sock")
envPath := filepath.Join(projectDir, ".env")
values := map[string]string{}
for _, v := range envVars {
values[v.Key] = v.Value
}
values["LISTEN_NETWORK"] = "unix"
values["LISTEN_ADDRESS"] = socketPath
var b strings.Builder
keys := make([]string, 0, len(values))
for k := range values {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
b.WriteString(k)
b.WriteString("=")
b.WriteString(escapeEnvValue(values[k]))
b.WriteString("\n")
}
if err := os.WriteFile(envPath, []byte(b.String()), 0o600); err != nil {
return err
}
if err := ensureOwnedByUnixUser(projectDir, p.UnixUser, 0o755); err != nil {
return err
}
if err := ensureOwnedByUnixUser(dataDir, p.UnixUser, 0o755); err != nil {
return err
}
if err := ensureOwnedByUnixUser(envPath, p.UnixUser, 0o600); err != nil {
return err
}
unit := fmt.Sprintf(`[Unit]
Description=Project %s
After=network.target
[Service]
Type=simple
WorkingDirectory=%s
EnvironmentFile=%s
ExecStart=%s
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
`, p.Name, dataDir, envPath, a.cfg.DefaultRunCommand)
homeDir, err := homeForUnixUser(p.UnixUser)
if err != nil {
return err
}
serviceDir := filepath.Join(homeDir, ".config", "systemd", "user")
if err := os.MkdirAll(serviceDir, 0o755); err != nil {
return err
}
servicePath := filepath.Join(serviceDir, p.ServiceName)
if err := os.WriteFile(servicePath, []byte(unit), 0o644); err != nil {
return err
}
if err := ensureOwnedByUnixUser(servicePath, p.UnixUser, 0o644); err != nil {
return err
}
_ = runSystemctlUser(p.UnixUser, "daemon-reload")
_ = runSystemctlUser(p.UnixUser, "enable", "--now", p.ServiceName)
return nil
}
func homeForUnixUser(username string) (string, error) {
u, err := user.Lookup(username)
if err != nil {
return "", fmt.Errorf("lookup unix user %q failed: %w", username, err)
}
if u.HomeDir == "" {
return "", fmt.Errorf("unix user %q has no home directory", username)
}
return u.HomeDir, nil
}
func ensureOwnedByUnixUser(path, username string, mode os.FileMode) error {
u, err := user.Lookup(username)
if err != nil {
return fmt.Errorf("lookup unix user %q failed: %w", username, err)
}
uid, err := strconv.Atoi(u.Uid)
if err != nil {
return fmt.Errorf("invalid uid for %q: %w", username, err)
}
gid, err := strconv.Atoi(u.Gid)
if err != nil {
return fmt.Errorf("invalid gid for %q: %w", username, err)
}
if err := os.Chown(path, uid, gid); err != nil {
return err
}
return os.Chmod(path, mode)
}
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"`
SubState string `json:"sub_state"`
MainPID int `json:"main_pid"`
ExecStatus int `json:"exec_status"`
Result string `json:"result"`
FragmentPath string `json:"fragment_path"`
}
func (a *App) getServiceStatus(p Project) (ServiceStatus, error) {
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
}
status := ServiceStatus{Unit: p.ServiceName}
for _, line := range strings.Split(out, "\n") {
if line == "" || !strings.Contains(line, "=") {
continue
}
parts := strings.SplitN(line, "=", 2)
k, v := parts[0], parts[1]
switch k {
case "ActiveState":
status.ActiveState = v
case "SubState":
status.SubState = v
case "MainPID":
status.MainPID, _ = strconv.Atoi(v)
case "ExecMainStatus":
status.ExecStatus, _ = strconv.Atoi(v)
case "Result":
status.Result = v
case "FragmentPath":
status.FragmentPath = v
}
}
return status, nil
}
func (a *App) getServiceLogs(p Project, lines int) (string, error) {
return runJournalctlUser(p.UnixUser, "-u", p.ServiceName, "--no-pager", "-n", strconv.Itoa(lines), "-o", "short-iso")
}