257 lines
6.5 KiB
Go
257 lines
6.5 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, 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
|
|
}
|
|
if err := runSystemctlUser(p.UnixUser, "daemon-reload"); err != nil {
|
|
return err
|
|
}
|
|
if err := runSystemctlUser(p.UnixUser, "enable", "--now", p.ServiceName); err != nil {
|
|
return err
|
|
}
|
|
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")
|
|
}
|