feat(db): Phase 1 — DB-Schema, goose-Migrations, GORM-Models
Initialer Schema-Set (8 Migrationen, 13 Tabellen) für EdgeGuard v1: users + audit_log + system_settings, ha_nodes, backends/domains/ routing_rules/tls_certs, forward_proxy_acls, wireguard_peers, firewall_rules, dns_zones/dns_records, licenses. Migrations liegen in internal/database/migrations/ (analog mail-gateway) und werden per //go:embed ins Binary gepackt — keine separate SQL-Dateien im .deb. ValidateMigrations + Test schützen vor Duplicate-Versionen (mail-gateway 2026-05-08-Vorfall). GORM-Models für alle Tabellen, sensible Felder (password_hash, private_key_enc) sind json:"-". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
188
internal/database/db.go
Normal file
188
internal/database/db.go
Normal file
@@ -0,0 +1,188 @@
|
||||
// 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 <version>_<name>.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
|
||||
}
|
||||
Reference in New Issue
Block a user