Files
oikos/internal/adapters/postgres/pool.go
dtoro 64f7d54011
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
feat: Phase 2 — ports package, secrets port move, postgres adapter move
Problem: the hexagon's Phase 2 (plans/2026-08-15-hexagonal-architecture.md)
must give the use-cases-to-be their contract surface: driven-port
interfaces, test fakes, the secrets interface moved into core, and the
postgres package inside the adapters tree — before the first vertical
slice (Phase 3) can wire a composition root.

Change:
- internal/core/ports: full driven-port catalog per plan §3.3 —
  repositories as transaction-scoped aggregates whose inputs carry
  derived checks, audit, and events (§3.6), plus CommandExecutor,
  TargetResolver, Checker, Secrets, EventPublisher, Provisioner.
  Port-local payload types (Event, AuditEntry, CheckDef, KnowledgeEntry,
  ExecResult) keep signatures off infrastructure; TypeTree aliases
  internal/ontology (pure over domain) until checkdefaults is absorbed.
  ReadModels intentionally not declared yet — it materializes with the
  Phase 3 slice and grows as report handlers rewire.
- secrets.Backend is now an alias of ports.Secrets; implementations
  (Infisical, SOPS, Manager) unchanged. mcp's local secretBackend
  subset is deleted; tool constructors take ports.Secrets.
- internal/db → internal/adapters/postgres (mechanical import rewrite;
  package identifier stays db until the Phase 3 repository split).
  sqlc.yaml, Makefile, golangci exclusions, and docs follow the move;
  make generate-check verified.
- internal/adapters/ssh: Executor implements ports.CommandExecutor over
  the actuator dial pool + RunStreaming (10-min default timeout carried
  over from the httpapi path).
- internal/adapters/remote: Resolver implements ports.TargetResolver
  delegating to internal/remote (still pool-based; drops onto
  ports.EntityRepository when repositories land in Phase 3 — documented
  transitional import).
- internal/core/ports/portstest: importable fakes — in-memory
  EntityRepo (with check-then-act SetState, side-effect recording),
  RecordingExecutor, FakeChecker, SpyPublisher; port-satisfaction
  guards; tests.

Risk: ports are declared ahead of implementations — signatures firm up
per phase as slices land (documented in the package doc); the
remote→postgres transitional import is explicit and dissolves in
Phase 3.

Verification: go vet, make test (race, 19 packages), generate-check,
golangci on core+adapters — 0 issues; full-repo baseline down
365→344.
2026-08-15 22:56:56 +02:00

289 lines
7.5 KiB
Go

package db
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io/fs"
"log/slog"
"sort"
"strings"
"github.com/dtoro/oikos/migrations"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gopkg.in/yaml.v3"
)
// Pool wraps a pgx connection pool.
type Pool struct {
*pgxpool.Pool
}
// New creates a new connection pool.
func New(ctx context.Context, databaseURL string) (*Pool, error) {
cfg, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("parse database url: %w", err)
}
cfg.MaxConns = 15
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("create pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
return nil, fmt.Errorf("ping db: %w", err)
}
return &Pool{pool}, nil
}
// migrationLockKey is the advisory-lock key serializing migration runs —
// two concurrent `oikos migrate` invocations must not interleave DDL.
const migrationLockKey = 0x01c05e5
// Migrate runs all embedded forward migrations in order.
// Uses a schema_migrations table to track applied versions. The whole run
// happens on one connection holding a session advisory lock.
func (p *Pool) Migrate(ctx context.Context) error {
conn, err := p.Acquire(ctx)
if err != nil {
return fmt.Errorf("acquire migration conn: %w", err)
}
defer conn.Release()
if _, err := conn.Exec(ctx, "SELECT pg_advisory_lock($1)", migrationLockKey); err != nil {
return fmt.Errorf("acquire migration lock: %w", err)
}
defer func() {
if _, err := conn.Exec(context.WithoutCancel(ctx), "SELECT pg_advisory_unlock($1)", migrationLockKey); err != nil {
slog.Warn("postgres: release migration lock failed", "error", err)
}
}()
// Create tracking table if not exists
_, err = conn.Exec(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version INT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`)
if err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
// List migration files
entries, err := fs.ReadDir(migrations.FS, ".")
if err != nil {
return fmt.Errorf("read migration fs: %w", err)
}
var files []string
for _, e := range entries {
if !e.IsDir() && hasSuffix(e.Name(), ".up.sql") {
files = append(files, e.Name())
}
}
sort.Strings(files)
for _, fname := range files {
// Extract version number (001, 002, etc.)
var version int
if _, err := fmt.Sscanf(fname, "%03d", &version); err != nil {
continue
}
// Check if already applied
var applied int
err := conn.QueryRow(ctx,
"SELECT COUNT(*) FROM schema_migrations WHERE version = $1", version).Scan(&applied)
if err != nil {
return fmt.Errorf("check migration %d: %w", version, err)
}
if applied > 0 {
continue
}
// Read and execute migration — split into individual statements
// because TimescaleDB CAGGs and some DDL can't run inside a transaction,
// and pgx's multi-statement Exec wraps them implicitly.
content, err := migrations.FS.ReadFile(fname)
if err != nil {
return fmt.Errorf("read %s: %w", fname, err)
}
stmts := splitSQL(string(content))
for i, stmt := range stmts {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
_, err := conn.Exec(ctx, stmt)
if err != nil {
return fmt.Errorf("exec migration %s stmt %d: %w", fname, i+1, err)
}
}
_, err = conn.Exec(ctx, "INSERT INTO schema_migrations (version) VALUES ($1)", version)
if err != nil {
return fmt.Errorf("record migration %d: %w", version, err)
}
slog.Info("migration applied", "file", fname, "version", version, "statements", len(stmts))
}
return nil
}
// SeedIngest ingests a YAML seed file into the database.
// Idempotent: if the file's content hash matches seed_versions, it's a no-op (A4).
func (p *Pool) SeedIngest(ctx context.Context, filename string, content []byte,
ingestFn func(ctx context.Context, tx pgx.Tx, data map[string]any) error) error {
hash := contentHash(content)
// Check if already applied with same hash
var existing string
err := p.QueryRow(ctx,
"SELECT content_hash FROM seed_versions WHERE file = $1", filename).Scan(&existing)
if err == nil && existing == hash {
return nil // no-op, same content
}
// Parse YAML
var data map[string]any
if err := yaml.Unmarshal(content, &data); err != nil {
return fmt.Errorf("parse %s: %w", filename, err)
}
// Apply in a single transaction
tx, err := p.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() {
if err := tx.Rollback(ctx); err != nil {
slog.Debug("postgres: rollback after failed ingest", "error", err)
}
}()
if err := ingestFn(ctx, tx, data); err != nil {
return fmt.Errorf("ingest %s: %w", filename, err)
}
// Record the seed version
_, err = tx.Exec(ctx,
`INSERT INTO seed_versions (file, content_hash) VALUES ($1, $2)
ON CONFLICT (file) DO UPDATE SET content_hash = $2, applied_at = now()`,
filename, hash)
if err != nil {
return fmt.Errorf("record seed version: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit seed: %w", err)
}
return nil
}
// contentHash returns a SHA-256 hex digest of the content.
func contentHash(content []byte) string {
h := sha256.Sum256(content)
return hex.EncodeToString(h[:])
}
// hasSuffix reports whether the string ends with the given suffix.
func hasSuffix(s, suffix string) bool {
return strings.HasSuffix(s, suffix)
}
// splitSQL splits a SQL string into individual statements.
// Handles $$ ... $$ dollar-quoted blocks, $tag$ ... $tag$ tagged quotes,
// -- line comments, /* ... */ block comments, and '...' string literals
// so that semicolons inside any of these constructs are not treated as
// statement boundaries.
func splitSQL(sql string) []string {
var statements []string
var current strings.Builder
inDollarQuote := false
dollarTag := ""
i := 0
for i < len(sql) {
// Handle line comments (-- to end of line)
if !inDollarQuote && i+1 < len(sql) && sql[i] == '-' && sql[i+1] == '-' {
for i < len(sql) && sql[i] != '\n' {
current.WriteByte(sql[i])
i++
}
continue
}
// Handle block comments (/* ... */)
if !inDollarQuote && i+1 < len(sql) && sql[i] == '/' && sql[i+1] == '*' {
end := strings.Index(sql[i+2:], "*/")
if end >= 0 {
current.WriteString(sql[i : i+end+4])
i += end + 4
continue
}
}
// Handle single-quoted string literals ('...')
if !inDollarQuote && sql[i] == '\'' {
j := i + 1
for j < len(sql) {
if sql[j] == '\'' {
if j+1 < len(sql) && sql[j+1] == '\'' {
j += 2 // skip doubled quote ''
continue
}
break
}
j++
}
current.WriteString(sql[i : j+1])
i = j + 1
continue
}
// Check for dollar-quote start/end
if !inDollarQuote && sql[i] == '$' {
j := i + 1
for j < len(sql) && (sql[j] == '_' || (sql[j] >= 'a' && sql[j] <= 'z') || (sql[j] >= 'A' && sql[j] <= 'Z') || (sql[j] >= '0' && sql[j] <= '9')) {
j++
}
if j < len(sql) && sql[j] == '$' {
dollarTag = sql[i : j+1]
current.WriteString(dollarTag)
inDollarQuote = true
i = j + 1
continue
}
}
if inDollarQuote && strings.HasPrefix(sql[i:], dollarTag) {
current.WriteString(dollarTag)
i += len(dollarTag)
inDollarQuote = false
dollarTag = ""
continue
}
if !inDollarQuote && sql[i] == ';' {
statements = append(statements, current.String())
current.Reset()
i++
continue
}
current.WriteByte(sql[i])
i++
}
if strings.TrimSpace(current.String()) != "" {
statements = append(statements, current.String())
}
return statements
}