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, err := a.projectDirFor(p) if err != nil { return err } 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 } binDir := filepath.Join(projectDir, "bin") if err := os.MkdirAll(binDir, 0o755); err != nil { return err } binaryPath := filepath.Join(binDir, "app") socketPath := filepath.Join(projectDir, "app.sock") envPath := filepath.Join(projectDir, ".env") values := map[string]string{} for _, v := range envVars { values[v.Key] = v.Value } if dbURL, err := a.projectDatabaseURL(p); err == nil && dbURL != "" { values["DATABASE_URL"] = dbURL } 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(binDir, 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, binaryPath) 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), "-r", "-o", "short-iso") }