From 1293ef678ed0142075e40cd08ad323649b3c6f6c Mon Sep 17 00:00:00 2001 From: pavel Date: Thu, 14 May 2026 22:20:48 +0200 Subject: [PATCH] db provisioning --- .env.example | 2 + README.md | 3 ++ internal/app/config.go | 37 ++++++++------- internal/app/db.go | 6 +++ internal/app/db_provisioning.go | 83 +++++++++++++++++++++++++++++++++ internal/app/project_service.go | 18 ++++--- internal/app/systemd_service.go | 3 ++ internal/app/types.go | 37 ++++++++------- 8 files changed, 151 insertions(+), 38 deletions(-) create mode 100644 internal/app/db_provisioning.go diff --git a/.env.example b/.env.example index 9913f3f..5d31b49 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,6 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/box?sslmode=disable +DB_PROVISION_ENABLED=true +DB_PROVISION_ADMIN_URL= LISTEN_ADDR=:8080 UNIX_SOCKET_PATH= DEV_MODE=true diff --git a/README.md b/README.md index fd910a0..f0e7e03 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ Important: - `DEV_MODE=false` expects auth headers from your reverse proxy: - `X-authentik-username` - `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_ORG` switches creation from personal repos to org repos. - `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: - `LISTEN_NETWORK=unix` - `LISTEN_ADDRESS=/projects//app.sock` +- `DATABASE_URL=postgres://:@.../` If user systemd is not available in the runtime environment, project creation still succeeds and unit files are still generated. diff --git a/internal/app/config.go b/internal/app/config.go index 7a92ab1..dd83274 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -9,26 +9,31 @@ import ( func loadConfig() (Config, error) { cfg := Config{ - DatabaseURL: getenv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/box?sslmode=disable"), - ListenAddr: getenv("LISTEN_ADDR", ":8080"), - UnixSocketPath: getenv("UNIX_SOCKET_PATH", ""), - DevMode: getenvBool("DEV_MODE", true), - DemoUser: getenv("DEV_DEMO_USER", "demo"), - DemoEmail: getenv("DEV_DEMO_EMAIL", "demo@example.local"), - AuthHeaderUser: getenv("AUTH_HEADER_USER", "X-authentik-username"), - AuthHeaderEmail: getenv("AUTH_HEADER_EMAIL", "X-authentik-email"), - ForgejoBaseURL: strings.TrimRight(getenv("FORGEJO_BASE_URL", ""), "/"), - ForgejoToken: getenv("FORGEJO_TOKEN", ""), - ForgejoOrg: getenv("FORGEJO_ORG", ""), - CaddyAdminURL: strings.TrimRight(getenv("CADDY_ADMIN_URL", "http://localhost:2019"), "/"), - CaddyServerID: getenv("CADDY_SERVER_ID", "srv0"), - CaddyDomainSuffix: getenv("CADDY_DOMAIN_SUFFIX", ""), - CaddyRouteEnabled: getenvBool("CADDY_ROUTE_ENABLED", false), - WebhookBaseURL: strings.TrimRight(getenv("WEBHOOK_BASE_URL", ""), "/"), + DatabaseURL: getenv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/box?sslmode=disable"), + ListenAddr: getenv("LISTEN_ADDR", ":8080"), + UnixSocketPath: getenv("UNIX_SOCKET_PATH", ""), + DevMode: getenvBool("DEV_MODE", true), + DemoUser: getenv("DEV_DEMO_USER", "demo"), + DemoEmail: getenv("DEV_DEMO_EMAIL", "demo@example.local"), + AuthHeaderUser: getenv("AUTH_HEADER_USER", "X-authentik-username"), + AuthHeaderEmail: getenv("AUTH_HEADER_EMAIL", "X-authentik-email"), + ForgejoBaseURL: strings.TrimRight(getenv("FORGEJO_BASE_URL", ""), "/"), + ForgejoToken: getenv("FORGEJO_TOKEN", ""), + ForgejoOrg: getenv("FORGEJO_ORG", ""), + DBProvisionAdminURL: getenv("DB_PROVISION_ADMIN_URL", ""), + DBProvisionEnabled: getenvBool("DB_PROVISION_ENABLED", true), + CaddyAdminURL: strings.TrimRight(getenv("CADDY_ADMIN_URL", "http://localhost:2019"), "/"), + CaddyServerID: getenv("CADDY_SERVER_ID", "srv0"), + CaddyDomainSuffix: getenv("CADDY_DOMAIN_SUFFIX", ""), + CaddyRouteEnabled: getenvBool("CADDY_ROUTE_ENABLED", false), + WebhookBaseURL: strings.TrimRight(getenv("WEBHOOK_BASE_URL", ""), "/"), } if cfg.DatabaseURL == "" { return cfg, errors.New("DATABASE_URL is required") } + if cfg.DBProvisionAdminURL == "" { + cfg.DBProvisionAdminURL = cfg.DatabaseURL + } return cfg, nil } diff --git a/internal/app/db.go b/internal/app/db.go index a68f50d..19641ae 100644 --- a/internal/app/db.go +++ b/internal/app/db.go @@ -27,6 +27,9 @@ func (a *App) migrate(ctx context.Context) error { deploy_token TEXT NOT NULL DEFAULT '', provision_state TEXT NOT NULL DEFAULT 'provisioned', 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(), 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 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 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 { if _, err := a.db.ExecContext(ctx, q); err != nil { diff --git a/internal/app/db_provisioning.go b/internal/app/db_provisioning.go new file mode 100644 index 0000000..31c8d93 --- /dev/null +++ b/internal/app/db_provisioning.go @@ -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, `'`, `''`) + `'` +} diff --git a/internal/app/project_service.go b/internal/app/project_service.go index 2f2297a..f6c24c0 100644 --- a/internal/app/project_service.go +++ b/internal/app/project_service.go @@ -9,7 +9,7 @@ import ( ) 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 JOIN workspaces w ON w.id = p.workspace_id 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 for rows.Next() { 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 } 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() - 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, - ).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 strings.Contains(err.Error(), "duplicate key") { 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 { + 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 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") @@ -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) { 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). - 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 errors.Is(err, sql.ErrNoRows) { return Project{}, errors.New("project not found") diff --git a/internal/app/systemd_service.go b/internal/app/systemd_service.go index a009e88..4c3fd9b 100644 --- a/internal/app/systemd_service.go +++ b/internal/app/systemd_service.go @@ -42,6 +42,9 @@ func (a *App) renderSystemdForProject(ctx context.Context, p Project) error { 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 diff --git a/internal/app/types.go b/internal/app/types.go index eea6dd2..3f83f19 100644 --- a/internal/app/types.go +++ b/internal/app/types.go @@ -6,22 +6,24 @@ import ( ) type Config struct { - DatabaseURL string - ListenAddr string - UnixSocketPath string - DevMode bool - DemoUser string - DemoEmail string - AuthHeaderUser string - AuthHeaderEmail string - ForgejoBaseURL string - ForgejoToken string - ForgejoOrg string - CaddyAdminURL string - CaddyServerID string - CaddyDomainSuffix string - CaddyRouteEnabled bool - WebhookBaseURL string + DatabaseURL string + ListenAddr string + UnixSocketPath string + DevMode bool + DemoUser string + DemoEmail string + AuthHeaderUser string + AuthHeaderEmail string + ForgejoBaseURL string + ForgejoToken string + ForgejoOrg string + DBProvisionAdminURL string + DBProvisionEnabled bool + CaddyAdminURL string + CaddyServerID string + CaddyDomainSuffix string + CaddyRouteEnabled bool + WebhookBaseURL string } type App struct { @@ -52,6 +54,9 @@ type Project struct { DeployToken string `json:"-"` ProvisionState string `json:"provision_state"` 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"` }