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 } 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 } _ = runCmd("sudo", "-n", "-u", p.UnixUser, "systemctl", "--user", "daemon-reload") _ = runCmd("sudo", "-n", "-u", p.UnixUser, "systemctl", "--user", "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 } 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 := runCmdOut( "sudo", "-n", "-u", p.UnixUser, "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 runCmdOut( "sudo", "-n", "-u", p.UnixUser, "journalctl", "--user", "-u", p.ServiceName, "--no-pager", "-n", strconv.Itoa(lines), "-o", "short-iso", ) }