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

@ -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
}

View file

@ -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 {

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) {
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")

View file

@ -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

View file

@ -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"`
}