// Package database owns the PostgreSQL connection pool and migration // runner for edgeguard-api. // // All routes read and write through a *pgxpool.Pool (jackc/pgx v5). // GORM (in internal/models) is used for struct tags / query convenience // against the same DSN; the schema itself is owned by goose // (// +goose Up/Down files in migrations/). package database import ( "context" "embed" "errors" "fmt" "os" "path/filepath" "regexp" "strconv" "strings" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/pressly/goose/v3" _ "github.com/jackc/pgx/v5/stdlib" // register "pgx" driver for goose ) //go:embed migrations/*.sql var embeddedMigrations embed.FS // ConnStringFromEnv returns the DSN for edgeguard-api. Priority: // // 1. EDGEGUARD_DB_URL as a full libpq/pgx URL or key=value DSN // 2. /etc/edgeguard/api.env line "EDGEGUARD_DB_URL=..." // 3. default: unix-socket peer auth against db "edgeguard" // // The default matches the layout edgeguard-ctl initdb sets up // (see docs/architecture.md ยง6). func ConnStringFromEnv() string { if v := os.Getenv("EDGEGUARD_DB_URL"); v != "" { return v } if data, err := os.ReadFile("/etc/edgeguard/api.env"); err == nil { for _, line := range strings.Split(string(data), "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "EDGEGUARD_DB_URL=") { return strings.TrimSpace(strings.TrimPrefix(line, "EDGEGUARD_DB_URL=")) } } } return "host=/var/run/postgresql dbname=edgeguard sslmode=disable" } // Open constructs a pool and pings the server. Never returns a pool // that failed its initial ping. func Open(ctx context.Context, dsn string) (*pgxpool.Pool, error) { if dsn == "" { return nil, errors.New("empty DSN") } cfg, err := pgxpool.ParseConfig(dsn) if err != nil { return nil, fmt.Errorf("parse DSN: %w", err) } if cfg.MaxConns == 0 { cfg.MaxConns = 10 } cfg.MaxConnLifetime = 30 * time.Minute pool, err := pgxpool.NewWithConfig(ctx, cfg) if err != nil { return nil, fmt.Errorf("open pool: %w", err) } pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if err := pool.Ping(pingCtx); err != nil { pool.Close() return nil, fmt.Errorf("ping: %w", err) } return pool, nil } // Migrate runs goose `up` with the embedded migrations. Pass an empty // dsnOverride to use ConnStringFromEnv(). // // Pre-flight ValidateMigrations runs first so a duplicate version // number fails with a clear message instead of goose's panic deep in // migrate.go (mail-gateway 2026-05-08 incident). func Migrate(ctx context.Context, dsnOverride string) error { if err := ValidateMigrations(); err != nil { return err } dsn := dsnOverride if dsn == "" { dsn = ConnStringFromEnv() } db, err := goose.OpenDBWithDriver("pgx", dsn) if err != nil { return fmt.Errorf("open db for migrate: %w", err) } defer db.Close() goose.SetBaseFS(embeddedMigrations) if err := goose.SetDialect("postgres"); err != nil { return err } return goose.RunContext(ctx, "up", db, "migrations") } // MigrateDown rolls back the most recent migration. Used in tests and // development; production is forward-only. func MigrateDown(ctx context.Context, dsnOverride string) error { dsn := dsnOverride if dsn == "" { dsn = ConnStringFromEnv() } db, err := goose.OpenDBWithDriver("pgx", dsn) if err != nil { return err } defer db.Close() goose.SetBaseFS(embeddedMigrations) if err := goose.SetDialect("postgres"); err != nil { return err } return goose.RunContext(ctx, "down", db, "migrations") } // ValidateMigrations checks the embedded migration files for duplicate // version prefixes. Called from Migrate() (runtime), from // `edgeguard-ctl migrate check` (postinst pre-flight), and from // TestEmbeddedMigrationsUnique (CI gate). func ValidateMigrations() error { entries, err := embeddedMigrations.ReadDir("migrations") if err != nil { return fmt.Errorf("read embedded migrations: %w", err) } versionToFile := map[int64]string{} versionRe := regexp.MustCompile(`^(\d+)_[^/]+\.sql$`) var problems []string for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { continue } m := versionRe.FindStringSubmatch(e.Name()) if m == nil { problems = append(problems, fmt.Sprintf("%s: filename does not match _.sql", e.Name())) continue } v, err := strconv.ParseInt(m[1], 10, 64) if err != nil { problems = append(problems, fmt.Sprintf("%s: version is not an integer", e.Name())) continue } if existing, dup := versionToFile[v]; dup { problems = append(problems, fmt.Sprintf( "duplicate migration version %d: %s and %s", v, existing, e.Name())) continue } versionToFile[v] = e.Name() } if len(problems) > 0 { return fmt.Errorf("migration validation failed:\n %s", strings.Join(problems, "\n ")) } return nil } // CopyEmbeddedMigrationsTo writes the embedded SQL files to dst. Used // by edgeguard-ctl to dump the embedded set for manual inspection. func CopyEmbeddedMigrationsTo(dst string) error { if err := os.MkdirAll(dst, 0o755); err != nil { return err } entries, err := embeddedMigrations.ReadDir("migrations") if err != nil { return err } for _, e := range entries { data, err := embeddedMigrations.ReadFile(filepath.ToSlash(filepath.Join("migrations", e.Name()))) if err != nil { return err } if err := os.WriteFile(filepath.Join(dst, e.Name()), data, 0o644); err != nil { return err } } return nil }