db provisioning

This commit is contained in:
pavel 2026-05-14 22:20:48 +02:00
commit 1293ef678e
8 changed files with 151 additions and 38 deletions

View file

@ -1,4 +1,6 @@
DATABASE_URL=postgres://postgres:postgres@localhost:5432/box?sslmode=disable DATABASE_URL=postgres://postgres:postgres@localhost:5432/box?sslmode=disable
DB_PROVISION_ENABLED=true
DB_PROVISION_ADMIN_URL=
LISTEN_ADDR=:8080 LISTEN_ADDR=:8080
UNIX_SOCKET_PATH= UNIX_SOCKET_PATH=
DEV_MODE=true DEV_MODE=true

View file

@ -36,6 +36,8 @@ Important:
- `DEV_MODE=false` expects auth headers from your reverse proxy: - `DEV_MODE=false` expects auth headers from your reverse proxy:
- `X-authentik-username` - `X-authentik-username`
- `X-authentik-email` - `X-authentik-email`
- `DB_PROVISION_ENABLED=true` provisions a dedicated PostgreSQL role/database per app.
- `DB_PROVISION_ADMIN_URL` is the admin DSN used for provisioning (defaults to `DATABASE_URL`).
- `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.
- `UNIX_SOCKET_PATH` enables unix socket listener in addition to `LISTEN_ADDR`. - `UNIX_SOCKET_PATH` enables unix socket listener in addition to `LISTEN_ADDR`.
@ -112,5 +114,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=<unix-user-home>/projects/<app>/app.sock` - `LISTEN_ADDRESS=<unix-user-home>/projects/<app>/app.sock`
- `DATABASE_URL=postgres://<app_db_user>:<generated_password>@.../<app_db_name>`
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

@ -9,26 +9,31 @@ import (
func loadConfig() (Config, error) { func loadConfig() (Config, error) {
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"),
UnixSocketPath: getenv("UNIX_SOCKET_PATH", ""), UnixSocketPath: getenv("UNIX_SOCKET_PATH", ""),
DevMode: getenvBool("DEV_MODE", true), DevMode: getenvBool("DEV_MODE", true),
DemoUser: getenv("DEV_DEMO_USER", "demo"), DemoUser: getenv("DEV_DEMO_USER", "demo"),
DemoEmail: getenv("DEV_DEMO_EMAIL", "demo@example.local"), DemoEmail: getenv("DEV_DEMO_EMAIL", "demo@example.local"),
AuthHeaderUser: getenv("AUTH_HEADER_USER", "X-authentik-username"), AuthHeaderUser: getenv("AUTH_HEADER_USER", "X-authentik-username"),
AuthHeaderEmail: getenv("AUTH_HEADER_EMAIL", "X-authentik-email"), AuthHeaderEmail: getenv("AUTH_HEADER_EMAIL", "X-authentik-email"),
ForgejoBaseURL: strings.TrimRight(getenv("FORGEJO_BASE_URL", ""), "/"), ForgejoBaseURL: strings.TrimRight(getenv("FORGEJO_BASE_URL", ""), "/"),
ForgejoToken: getenv("FORGEJO_TOKEN", ""), ForgejoToken: getenv("FORGEJO_TOKEN", ""),
ForgejoOrg: getenv("FORGEJO_ORG", ""), ForgejoOrg: getenv("FORGEJO_ORG", ""),
CaddyAdminURL: strings.TrimRight(getenv("CADDY_ADMIN_URL", "http://localhost:2019"), "/"), DBProvisionAdminURL: getenv("DB_PROVISION_ADMIN_URL", ""),
CaddyServerID: getenv("CADDY_SERVER_ID", "srv0"), DBProvisionEnabled: getenvBool("DB_PROVISION_ENABLED", true),
CaddyDomainSuffix: getenv("CADDY_DOMAIN_SUFFIX", ""), CaddyAdminURL: strings.TrimRight(getenv("CADDY_ADMIN_URL", "http://localhost:2019"), "/"),
CaddyRouteEnabled: getenvBool("CADDY_ROUTE_ENABLED", false), CaddyServerID: getenv("CADDY_SERVER_ID", "srv0"),
WebhookBaseURL: strings.TrimRight(getenv("WEBHOOK_BASE_URL", ""), "/"), CaddyDomainSuffix: getenv("CADDY_DOMAIN_SUFFIX", ""),
CaddyRouteEnabled: getenvBool("CADDY_ROUTE_ENABLED", false),
WebhookBaseURL: strings.TrimRight(getenv("WEBHOOK_BASE_URL", ""), "/"),
} }
if cfg.DatabaseURL == "" { if cfg.DatabaseURL == "" {
return cfg, errors.New("DATABASE_URL is required") return cfg, errors.New("DATABASE_URL is required")
} }
if cfg.DBProvisionAdminURL == "" {
cfg.DBProvisionAdminURL = cfg.DatabaseURL
}
return cfg, nil return cfg, nil
} }

View file

@ -27,6 +27,9 @@ func (a *App) migrate(ctx context.Context) error {
deploy_token TEXT NOT NULL DEFAULT '', deploy_token TEXT NOT NULL DEFAULT '',
provision_state TEXT NOT NULL DEFAULT 'provisioned', provision_state TEXT NOT NULL DEFAULT 'provisioned',
provision_error TEXT NOT NULL DEFAULT '', provision_error TEXT NOT NULL DEFAULT '',
db_name TEXT NOT NULL DEFAULT '',
db_user TEXT NOT NULL DEFAULT '',
db_password TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, slug) UNIQUE (user_id, slug)
);`, );`,
@ -51,6 +54,9 @@ func (a *App) migrate(ctx context.Context) error {
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS repo_private BOOLEAN NOT NULL DEFAULT true;`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS repo_private BOOLEAN NOT NULL DEFAULT true;`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS provision_state TEXT NOT NULL DEFAULT 'provisioned';`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS provision_state TEXT NOT NULL DEFAULT 'provisioned';`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS provision_error TEXT NOT NULL DEFAULT '';`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS provision_error TEXT NOT NULL DEFAULT '';`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS db_name TEXT NOT NULL DEFAULT '';`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS db_user TEXT NOT NULL DEFAULT '';`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS db_password TEXT NOT NULL DEFAULT '';`,
} }
for _, q := range alter { for _, q := range alter {
if _, err := a.db.ExecContext(ctx, q); err != nil { if _, err := a.db.ExecContext(ctx, q); err != nil {

View file

@ -0,0 +1,83 @@
package app
import (
"context"
"database/sql"
"fmt"
"net/url"
"strings"
)
func (a *App) ensureProjectDatabase(ctx context.Context, p *Project) error {
if !a.cfg.DBProvisionEnabled {
return nil
}
adminDB, err := sql.Open("pgx", a.cfg.DBProvisionAdminURL)
if err != nil {
return fmt.Errorf("db provisioning connect failed: %w", err)
}
defer adminDB.Close()
if err := adminDB.PingContext(ctx); err != nil {
return fmt.Errorf("db provisioning ping failed: %w", err)
}
if p.DBName == "" {
p.DBName = fmt.Sprintf("app_%d", p.ID)
}
if p.DBUser == "" {
p.DBUser = fmt.Sprintf("appu_%d", p.ID)
}
if p.DBPassword == "" {
pw, err := newDeployToken()
if err != nil {
return err
}
p.DBPassword = pw
}
if _, err := adminDB.ExecContext(ctx, fmt.Sprintf(`DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = %s) THEN
CREATE ROLE %s LOGIN PASSWORD %s;
ELSE
ALTER ROLE %s LOGIN PASSWORD %s;
END IF;
END $$;`, sqlQuoteLiteral(p.DBUser), sqlQuoteIdent(p.DBUser), sqlQuoteLiteral(p.DBPassword), sqlQuoteIdent(p.DBUser), sqlQuoteLiteral(p.DBPassword))); err != nil {
return fmt.Errorf("create/alter role failed: %w", err)
}
if _, err := adminDB.ExecContext(ctx, fmt.Sprintf(`DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = %s) THEN
CREATE DATABASE %s OWNER %s;
END IF;
END $$;`, sqlQuoteLiteral(p.DBName), sqlQuoteIdent(p.DBName), sqlQuoteIdent(p.DBUser))); err != nil {
return fmt.Errorf("create database failed: %w", err)
}
if _, err := a.db.ExecContext(ctx, `UPDATE projects SET db_name=$1, db_user=$2, db_password=$3 WHERE id=$4`, p.DBName, p.DBUser, p.DBPassword, p.ID); err != nil {
return fmt.Errorf("persist db credentials failed: %w", err)
}
return nil
}
func (a *App) projectDatabaseURL(p Project) (string, error) {
if p.DBName == "" || p.DBUser == "" || p.DBPassword == "" {
return "", nil
}
base, err := url.Parse(a.cfg.DBProvisionAdminURL)
if err != nil {
return "", err
}
base.User = url.UserPassword(p.DBUser, p.DBPassword)
base.Path = "/" + p.DBName
return base.String(), nil
}
func sqlQuoteIdent(s string) string {
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
}
func sqlQuoteLiteral(s string) string {
return `'` + strings.ReplaceAll(s, `'`, `''`) + `'`
}

View file

@ -9,7 +9,7 @@ import (
) )
func (a *App) listProjects(ctx context.Context, userID string) ([]Project, error) { func (a *App) listProjects(ctx context.Context, userID string) ([]Project, error) {
rows, err := a.db.QueryContext(ctx, `SELECT p.id, p.user_id, p.name, p.slug, p.description, p.repo_url, p.repo_private, p.service_name, p.route_host, p.target_port, p.workspace_id, w.name, w.unix_user, p.deploy_token, p.provision_state, p.provision_error, p.created_at rows, err := a.db.QueryContext(ctx, `SELECT p.id, p.user_id, p.name, p.slug, p.description, p.repo_url, p.repo_private, p.service_name, p.route_host, p.target_port, p.workspace_id, w.name, w.unix_user, p.deploy_token, p.provision_state, p.provision_error, p.db_name, p.db_user, p.db_password, p.created_at
FROM projects p FROM projects p
JOIN workspaces w ON w.id = p.workspace_id JOIN workspaces w ON w.id = p.workspace_id
WHERE p.user_id=$1 ORDER BY p.created_at DESC`, userID) WHERE p.user_id=$1 ORDER BY p.created_at DESC`, userID)
@ -20,7 +20,7 @@ func (a *App) listProjects(ctx context.Context, userID string) ([]Project, error
var out []Project var out []Project
for rows.Next() { for rows.Next() {
var p Project var p Project
if err := rows.Scan(&p.ID, &p.UserID, &p.Name, &p.Slug, &p.Description, &p.RepoURL, &p.RepoPrivate, &p.ServiceName, &p.RouteHost, &p.TargetPort, &p.WorkspaceID, &p.Workspace, &p.UnixUser, &p.DeployToken, &p.ProvisionState, &p.ProvisionError, &p.CreatedAt); err != nil { if err := rows.Scan(&p.ID, &p.UserID, &p.Name, &p.Slug, &p.Description, &p.RepoURL, &p.RepoPrivate, &p.ServiceName, &p.RouteHost, &p.TargetPort, &p.WorkspaceID, &p.Workspace, &p.UnixUser, &p.DeployToken, &p.ProvisionState, &p.ProvisionError, &p.DBName, &p.DBUser, &p.DBPassword, &p.CreatedAt); err != nil {
return nil, err return nil, err
} }
p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken) p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken)
@ -54,9 +54,9 @@ func (a *App) createProject(ctx context.Context, user User, workspaceID int64, n
} }
defer tx.Rollback() defer tx.Rollback()
err = tx.QueryRowContext(ctx, `INSERT INTO projects (user_id, workspace_id, name, slug, description, repo_url, repo_private, service_name, route_host, target_port, deploy_token, provision_state, provision_error) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'pending','') RETURNING id, user_id, name, slug, description, repo_url, repo_private, service_name, route_host, target_port, workspace_id, deploy_token, provision_state, provision_error, created_at`, err = tx.QueryRowContext(ctx, `INSERT INTO projects (user_id, workspace_id, name, slug, description, repo_url, repo_private, service_name, route_host, target_port, deploy_token, provision_state, provision_error) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'pending','') RETURNING id, user_id, name, slug, description, repo_url, repo_private, service_name, route_host, target_port, workspace_id, deploy_token, provision_state, provision_error, db_name, db_user, db_password, created_at`,
user.Username, workspaceID, name, slug, description, repoURL, private, serviceName, routeHost, targetPort, deployToken, user.Username, workspaceID, name, slug, description, repoURL, private, serviceName, routeHost, targetPort, deployToken,
).Scan(&p.ID, &p.UserID, &p.Name, &p.Slug, &p.Description, &p.RepoURL, &p.RepoPrivate, &p.ServiceName, &p.RouteHost, &p.TargetPort, &p.WorkspaceID, &p.DeployToken, &p.ProvisionState, &p.ProvisionError, &p.CreatedAt) ).Scan(&p.ID, &p.UserID, &p.Name, &p.Slug, &p.Description, &p.RepoURL, &p.RepoPrivate, &p.ServiceName, &p.RouteHost, &p.TargetPort, &p.WorkspaceID, &p.DeployToken, &p.ProvisionState, &p.ProvisionError, &p.DBName, &p.DBUser, &p.DBPassword, &p.CreatedAt)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "duplicate key") { if strings.Contains(err.Error(), "duplicate key") {
return Project{}, errors.New("project with this name already exists") return Project{}, errors.New("project with this name already exists")
@ -74,6 +74,12 @@ func (a *App) createProject(ctx context.Context, user User, workspaceID int64, n
} }
func (a *App) provisionProject(ctx context.Context, p *Project) error { func (a *App) provisionProject(ctx context.Context, p *Project) error {
if err := a.ensureProjectDatabase(ctx, p); err != nil {
_ = a.updateProvisioningState(ctx, p.ID, "failed", err.Error())
p.ProvisionState = "failed"
p.ProvisionError = err.Error()
return err
}
if p.RepoURL == "" { if p.RepoURL == "" {
if a.cfg.ForgejoBaseURL == "" || a.cfg.ForgejoToken == "" { if a.cfg.ForgejoBaseURL == "" || a.cfg.ForgejoToken == "" {
err := errors.New("repo_url is empty and Forgejo is not configured; set FORGEJO_BASE_URL/FORGEJO_TOKEN or provide repo_url") err := errors.New("repo_url is empty and Forgejo is not configured; set FORGEJO_BASE_URL/FORGEJO_TOKEN or provide repo_url")
@ -136,9 +142,9 @@ func (a *App) provisionProject(ctx context.Context, p *Project) error {
func (a *App) getProject(ctx context.Context, userID string, projectID int64) (Project, error) { func (a *App) getProject(ctx context.Context, userID string, projectID int64) (Project, error) {
var p Project var p Project
err := a.db.QueryRowContext(ctx, `SELECT p.id, p.user_id, p.name, p.slug, p.description, p.repo_url, p.repo_private, p.service_name, p.route_host, p.target_port, p.workspace_id, w.name, w.unix_user, p.deploy_token, p.provision_state, p.provision_error, p.created_at err := a.db.QueryRowContext(ctx, `SELECT p.id, p.user_id, p.name, p.slug, p.description, p.repo_url, p.repo_private, p.service_name, p.route_host, p.target_port, p.workspace_id, w.name, w.unix_user, p.deploy_token, p.provision_state, p.provision_error, p.db_name, p.db_user, p.db_password, p.created_at
FROM projects p JOIN workspaces w ON w.id=p.workspace_id WHERE p.user_id=$1 AND p.id=$2`, userID, projectID). FROM projects p JOIN workspaces w ON w.id=p.workspace_id WHERE p.user_id=$1 AND p.id=$2`, userID, projectID).
Scan(&p.ID, &p.UserID, &p.Name, &p.Slug, &p.Description, &p.RepoURL, &p.RepoPrivate, &p.ServiceName, &p.RouteHost, &p.TargetPort, &p.WorkspaceID, &p.Workspace, &p.UnixUser, &p.DeployToken, &p.ProvisionState, &p.ProvisionError, &p.CreatedAt) Scan(&p.ID, &p.UserID, &p.Name, &p.Slug, &p.Description, &p.RepoURL, &p.RepoPrivate, &p.ServiceName, &p.RouteHost, &p.TargetPort, &p.WorkspaceID, &p.Workspace, &p.UnixUser, &p.DeployToken, &p.ProvisionState, &p.ProvisionError, &p.DBName, &p.DBUser, &p.DBPassword, &p.CreatedAt)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return Project{}, errors.New("project not found") return Project{}, errors.New("project not found")

View file

@ -42,6 +42,9 @@ func (a *App) renderSystemdForProject(ctx context.Context, p Project) error {
for _, v := range envVars { for _, v := range envVars {
values[v.Key] = v.Value values[v.Key] = v.Value
} }
if dbURL, err := a.projectDatabaseURL(p); err == nil && dbURL != "" {
values["DATABASE_URL"] = dbURL
}
values["LISTEN_NETWORK"] = "unix" values["LISTEN_NETWORK"] = "unix"
values["LISTEN_ADDRESS"] = socketPath values["LISTEN_ADDRESS"] = socketPath

View file

@ -6,22 +6,24 @@ import (
) )
type Config struct { type Config struct {
DatabaseURL string DatabaseURL string
ListenAddr string ListenAddr string
UnixSocketPath string UnixSocketPath string
DevMode bool DevMode bool
DemoUser string DemoUser string
DemoEmail string DemoEmail string
AuthHeaderUser string AuthHeaderUser string
AuthHeaderEmail string AuthHeaderEmail string
ForgejoBaseURL string ForgejoBaseURL string
ForgejoToken string ForgejoToken string
ForgejoOrg string ForgejoOrg string
CaddyAdminURL string DBProvisionAdminURL string
CaddyServerID string DBProvisionEnabled bool
CaddyDomainSuffix string CaddyAdminURL string
CaddyRouteEnabled bool CaddyServerID string
WebhookBaseURL string CaddyDomainSuffix string
CaddyRouteEnabled bool
WebhookBaseURL string
} }
type App struct { type App struct {
@ -52,6 +54,9 @@ type Project struct {
DeployToken string `json:"-"` DeployToken string `json:"-"`
ProvisionState string `json:"provision_state"` ProvisionState string `json:"provision_state"`
ProvisionError string `json:"provision_error,omitempty"` ProvisionError string `json:"provision_error,omitempty"`
DBName string `json:"db_name,omitempty"`
DBUser string `json:"db_user,omitempty"`
DBPassword string `json:"-"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }