fix service templates and paths

This commit is contained in:
pavel 2026-05-14 20:21:01 +02:00
commit 3ac11468a1
7 changed files with 70 additions and 30 deletions

View file

@ -9,8 +9,7 @@ AUTH_HEADER_EMAIL=X-authentik-email
FORGEJO_BASE_URL= FORGEJO_BASE_URL=
FORGEJO_TOKEN= FORGEJO_TOKEN=
FORGEJO_ORG= FORGEJO_ORG=
PROJECTS_ROOT=$HOME/projects PROJECTS_ROOT=$HOME/projects/$app
DEFAULT_RUN_COMMAND=go run .
CADDY_ROUTE_ENABLED=false CADDY_ROUTE_ENABLED=false
CADDY_ADMIN_URL=http://localhost:2019 CADDY_ADMIN_URL=http://localhost:2019
CADDY_SERVER_ID=srv0 CADDY_SERVER_ID=srv0

View file

@ -38,6 +38,10 @@ Important:
- `X-authentik-email` - `X-authentik-email`
- `FORGEJO_BASE_URL` + `FORGEJO_TOKEN` enable repository creation. - `FORGEJO_BASE_URL` + `FORGEJO_TOKEN` enable repository creation.
- `FORGEJO_ORG` switches creation from personal repos to org repos. - `FORGEJO_ORG` switches creation from personal repos to org repos.
- `PROJECTS_ROOT` supports templates:
- `$HOME` = workspace unix user home
- `$app` = project slug
- Example: `$HOME/projects/$app`
- `UNIX_SOCKET_PATH` enables unix socket listener in addition to `LISTEN_ADDR`. - `UNIX_SOCKET_PATH` enables unix socket listener in addition to `LISTEN_ADDR`.
- `CADDY_ROUTE_ENABLED=true` enables route provisioning. - `CADDY_ROUTE_ENABLED=true` enables route provisioning.
- `CADDY_ADMIN_URL` points to the Caddy Admin API (default `http://localhost:2019`). - `CADDY_ADMIN_URL` points to the Caddy Admin API (default `http://localhost:2019`).
@ -79,9 +83,10 @@ Route shape:
- If auto-provisioning cannot apply (for example external non-Forgejo repo), configure a webhook manually to call `webhook_url`. - If auto-provisioning cannot apply (for example external non-Forgejo repo), configure a webhook manually to call `webhook_url`.
- Deploy runs only when payload `ref` equals `refs/heads/main`. - Deploy runs only when payload `ref` equals `refs/heads/main`.
- Deploy behavior: - Deploy behavior:
1. Clone repo to `<PROJECTS_ROOT>/<app-user>/<workspace>/<project>/repo` if missing. 1. Clone repo to `<PROJECTS_ROOT>/repo` if missing.
2. Otherwise `fetch origin main` and `reset --hard origin/main`. 2. Otherwise `fetch origin main` and `reset --hard origin/main`.
3. Restart the project user service via `systemctl --user restart`. 3. Build binary to `<PROJECTS_ROOT>/bin/app`.
4. Restart the project user service via `systemctl --user restart`.
Notes: Notes:
- `WEBHOOK_BASE_URL` should be set to an externally reachable base URL so Forgejo can call webhook endpoints. - `WEBHOOK_BASE_URL` should be set to an externally reachable base URL so Forgejo can call webhook endpoints.
@ -100,7 +105,8 @@ Notes:
## Systemd ## Systemd
For each project in workspace `<ws>` owned by unix user `<unix_user>`, the app writes: For each project in workspace `<ws>` owned by unix user `<unix_user>`, the app writes:
- env file: `<PROJECTS_ROOT>/<app-user>/<workspace>/<project>/.env` - env file: `<PROJECTS_ROOT>/.env`
- binary path used by systemd: `<PROJECTS_ROOT>/bin/app`
- unit file: `<home-of-unix-user>/.config/systemd/user/projectmgr-<app-user>-<project>.service` - unit file: `<home-of-unix-user>/.config/systemd/user/projectmgr-<app-user>-<project>.service`
Then it attempts as that unix user: Then it attempts as that unix user:
@ -109,6 +115,6 @@ Then it attempts as that unix user:
The generated `.env` is synced from the project env-var configuration and always includes: The generated `.env` is synced from the project env-var configuration and always includes:
- `LISTEN_NETWORK=unix` - `LISTEN_NETWORK=unix`
- `LISTEN_ADDRESS=<PROJECTS_ROOT>/<app-user>/<workspace>/<project>/app.sock` - `LISTEN_ADDRESS=<PROJECTS_ROOT>/app.sock`
If user systemd is not available in the runtime environment, project creation still succeeds and unit files are still generated. If user systemd is not available in the runtime environment, project creation still succeeds and unit files are still generated.

View file

@ -3,13 +3,12 @@ package app
import ( import (
"errors" "errors"
"os" "os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
) )
func loadConfig() (Config, error) { func loadConfig() (Config, error) {
projectRoot := expandPath(getenv("PROJECTS_ROOT", "$HOME/projects")) projectRoot := strings.TrimSpace(getenv("PROJECTS_ROOT", "$HOME/projects/$app"))
cfg := Config{ cfg := Config{
DatabaseURL: getenv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/box?sslmode=disable"), DatabaseURL: getenv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/box?sslmode=disable"),
ListenAddr: getenv("LISTEN_ADDR", ":8080"), ListenAddr: getenv("LISTEN_ADDR", ":8080"),
@ -23,7 +22,6 @@ func loadConfig() (Config, error) {
ForgejoToken: getenv("FORGEJO_TOKEN", ""), ForgejoToken: getenv("FORGEJO_TOKEN", ""),
ForgejoOrg: getenv("FORGEJO_ORG", ""), ForgejoOrg: getenv("FORGEJO_ORG", ""),
ProjectRoot: projectRoot, ProjectRoot: projectRoot,
DefaultRunCommand: getenv("DEFAULT_RUN_COMMAND", "go run ."),
CaddyAdminURL: strings.TrimRight(getenv("CADDY_ADMIN_URL", "http://localhost:2019"), "/"), CaddyAdminURL: strings.TrimRight(getenv("CADDY_ADMIN_URL", "http://localhost:2019"), "/"),
CaddyServerID: getenv("CADDY_SERVER_ID", "srv0"), CaddyServerID: getenv("CADDY_SERVER_ID", "srv0"),
CaddyDomainSuffix: getenv("CADDY_DOMAIN_SUFFIX", ""), CaddyDomainSuffix: getenv("CADDY_DOMAIN_SUFFIX", ""),
@ -36,23 +34,6 @@ func loadConfig() (Config, error) {
return cfg, nil return cfg, nil
} }
func expandPath(p string) string {
p = strings.TrimSpace(p)
if p == "" {
return filepath.Join(userHomeOrDot(), "projects")
}
if strings.HasPrefix(p, "~/") {
p = filepath.Join(userHomeOrDot(), strings.TrimPrefix(p, "~/"))
}
p = os.ExpandEnv(p)
if !filepath.IsAbs(p) {
if abs, err := filepath.Abs(p); err == nil {
p = abs
}
}
return filepath.Clean(p)
}
func userHomeOrDot() string { func userHomeOrDot() string {
h, err := os.UserHomeDir() h, err := os.UserHomeDir()
if err != nil { if err != nil {

View file

@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"log" "log"
"net/http" "net/http"
"os" "os"
@ -72,8 +73,15 @@ func (a *App) deployProjectFromMain(_ context.Context, p Project) error {
if p.RepoURL == "" { if p.RepoURL == "" {
return errors.New("project has no repo_url to deploy from") return errors.New("project has no repo_url to deploy from")
} }
projectDir := filepath.Join(a.cfg.ProjectRoot, slugify(p.UserID), p.Workspace, p.Slug) projectDir, err := a.projectDirFor(p)
if err != nil {
return err
}
repoDir := filepath.Join(projectDir, "repo") repoDir := filepath.Join(projectDir, "repo")
binDir := filepath.Join(projectDir, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
return err
}
if _, err := os.Stat(repoDir); os.IsNotExist(err) { if _, err := os.Stat(repoDir); os.IsNotExist(err) {
if err := runCmd("git", "clone", "--branch", "main", "--single-branch", p.RepoURL, repoDir); err != nil { if err := runCmd("git", "clone", "--branch", "main", "--single-branch", p.RepoURL, repoDir); err != nil {
return err return err
@ -89,5 +97,12 @@ func (a *App) deployProjectFromMain(_ context.Context, p Project) error {
return err return err
} }
} }
binaryPath := filepath.Join(binDir, "app")
if err := runCmd("go", "-C", repoDir, "build", "-o", binaryPath, "."); err != nil {
return fmt.Errorf("build failed: %w", err)
}
if err := ensureOwnedByUnixUser(binaryPath, p.UnixUser, 0o755); err != nil {
return err
}
return runSystemctlUser(p.UnixUser, "restart", p.ServiceName) return runSystemctlUser(p.UnixUser, "restart", p.ServiceName)
} }

29
internal/app/paths.go Normal file
View file

@ -0,0 +1,29 @@
package app
import (
"path/filepath"
"strings"
)
func (a *App) projectDirFor(p Project) (string, error) {
homeDir, err := homeForUnixUser(p.UnixUser)
if err != nil {
return "", err
}
tpl := strings.TrimSpace(a.cfg.ProjectRoot)
if tpl == "" {
tpl = "$HOME/projects/$app"
}
path := tpl
path = strings.ReplaceAll(path, "${HOME}", homeDir)
path = strings.ReplaceAll(path, "$HOME", homeDir)
path = strings.ReplaceAll(path, "${app}", p.Slug)
path = strings.ReplaceAll(path, "$app", p.Slug)
if strings.HasPrefix(path, "~/") {
path = filepath.Join(homeDir, strings.TrimPrefix(path, "~/"))
}
if !filepath.IsAbs(path) {
path = filepath.Join(homeDir, path)
}
return filepath.Clean(path), nil
}

View file

@ -19,7 +19,10 @@ func (a *App) renderSystemdForProject(ctx context.Context, p Project) error {
} }
envVars = []EnvVar{} envVars = []EnvVar{}
} }
projectDir := filepath.Join(a.cfg.ProjectRoot, slugify(p.UserID), p.Workspace, p.Slug) projectDir, err := a.projectDirFor(p)
if err != nil {
return err
}
if err := os.MkdirAll(projectDir, 0o755); err != nil { if err := os.MkdirAll(projectDir, 0o755); err != nil {
return err return err
} }
@ -27,6 +30,11 @@ func (a *App) renderSystemdForProject(ctx context.Context, p Project) error {
if err := os.MkdirAll(dataDir, 0o755); err != nil { if err := os.MkdirAll(dataDir, 0o755); err != nil {
return err 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") socketPath := filepath.Join(projectDir, "app.sock")
envPath := filepath.Join(projectDir, ".env") envPath := filepath.Join(projectDir, ".env")
@ -58,6 +66,9 @@ func (a *App) renderSystemdForProject(ctx context.Context, p Project) error {
if err := ensureOwnedByUnixUser(dataDir, p.UnixUser, 0o755); err != nil { if err := ensureOwnedByUnixUser(dataDir, p.UnixUser, 0o755); err != nil {
return err return err
} }
if err := ensureOwnedByUnixUser(binDir, p.UnixUser, 0o755); err != nil {
return err
}
if err := ensureOwnedByUnixUser(envPath, p.UnixUser, 0o600); err != nil { if err := ensureOwnedByUnixUser(envPath, p.UnixUser, 0o600); err != nil {
return err return err
} }
@ -76,7 +87,7 @@ RestartSec=5
[Install] [Install]
WantedBy=default.target WantedBy=default.target
`, p.Name, dataDir, envPath, a.cfg.DefaultRunCommand) `, p.Name, dataDir, envPath, binaryPath)
homeDir, err := homeForUnixUser(p.UnixUser) homeDir, err := homeForUnixUser(p.UnixUser)
if err != nil { if err != nil {

View file

@ -18,7 +18,6 @@ type Config struct {
ForgejoToken string ForgejoToken string
ForgejoOrg string ForgejoOrg string
ProjectRoot string ProjectRoot string
DefaultRunCommand string
CaddyAdminURL string CaddyAdminURL string
CaddyServerID string CaddyServerID string
CaddyDomainSuffix string CaddyDomainSuffix string