Adopt golib/pg for migrations and pool construction
encapi's schema was a cumulative IF NOT EXISTS blob re-executed inline on every start: it grows forever, records nothing, and cannot express a change that is not a fresh CREATE. golib owns that mechanism now, so encapi keeps the SQL and drops the runner. - Move the DDL verbatim into migrations/0001_init.sql, embedded via migrations.FS. It stays IF NOT EXISTS-guarded, so the first start against the live database re-runs it as a no-op and only lands the schema_migrations row. - Build the pool with pg.NewMigrated under the advisory lock named encapi-migrations, and delete the inline migrate(). database.New now takes a context and a logger; main.go hands it the signal context so a start blocked on the migration lock still dies on SIGTERM. - Render the DSN with pg.DSN. The env var contract is untouched — the fields are still resolved by internal/config, because encapi defaults DBUSER and DBNAME to "encapi" where pg.DSNFromEnv treats both as required. - Guard the set: embedded files must match migrations/, every CREATE must be idempotent, the derived lock key is pinned, and a container test proves the adoption path over a database that predates schema_migrations. - Plumb GOPRIVATE=git.unkin.net for the first cross-repo Go dependency: exported by the Makefile, set in the Dockerfile and the woodpecker Go steps, documented in the README.
This commit is contained in:
@@ -10,7 +10,11 @@ import (
|
||||
"git.unkin.net/unkin/encapi/pkg/models"
|
||||
)
|
||||
|
||||
var testDB *DB
|
||||
// testDB is the shared throwaway database; testDSN reaches the same server.
|
||||
var (
|
||||
testDB *DB
|
||||
testDSN string
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
ctx := context.Background()
|
||||
@@ -19,12 +23,13 @@ func TestMain(m *testing.M) {
|
||||
// Docker unavailable: run so tests self-skip via requireDB.
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
db, err := New(dsn)
|
||||
db, err := New(ctx, dsn, nil)
|
||||
if err != nil {
|
||||
terminate()
|
||||
panic(err)
|
||||
}
|
||||
testDB = db
|
||||
testDSN = dsn
|
||||
|
||||
code := m.Run()
|
||||
db.Close()
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// encapi's schema predates schema_migrations, so the first start after this
|
||||
// change re-applies 0001_init against a database that already has the tables.
|
||||
// The file is IF NOT EXISTS-guarded for exactly that: the run must succeed and
|
||||
// leave only the tracking row behind.
|
||||
func TestMigrateAdoptsAnExistingSchema(t *testing.T) {
|
||||
requireDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := testDB.Pool.Exec(ctx, `DROP TABLE schema_migrations`); err != nil {
|
||||
t.Fatalf("drop schema_migrations: %v", err)
|
||||
}
|
||||
|
||||
db, err := New(ctx, testDSN, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("migrate over an existing schema: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
var n int
|
||||
if err := db.Pool.QueryRow(ctx, `SELECT count(*) FROM schema_migrations`).Scan(&n); err != nil {
|
||||
t.Fatalf("count schema_migrations: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("schema_migrations rows = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,13 @@ package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.unkin.net/unkin/golib/pg"
|
||||
|
||||
"git.unkin.net/unkin/encapi/migrations"
|
||||
)
|
||||
|
||||
// DB wraps a pgx connection pool.
|
||||
@@ -16,56 +20,19 @@ type DB struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// New connects to Postgres, verifies the connection, and runs migrations.
|
||||
func New(dsn string) (*DB, error) {
|
||||
pool, err := pgxpool.New(context.Background(), dsn)
|
||||
// New connects to Postgres, verifies the connection, and brings the schema up
|
||||
// to date from the embedded migrations before returning, so the server never
|
||||
// serves against a half-migrated database. log may be nil.
|
||||
func New(ctx context.Context, dsn string, log *slog.Logger) (*DB, error) {
|
||||
pool, err := pg.NewMigrated(ctx, dsn, migrations.FS, pg.MigrateOptions{
|
||||
LockName: migrations.LockName,
|
||||
Logger: log,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to postgres: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
if err := pool.Ping(context.Background()); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping postgres: %w", err)
|
||||
}
|
||||
|
||||
db := &DB{Pool: pool}
|
||||
if err := db.migrate(); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("run migrations: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
return &DB{Pool: pool}, nil
|
||||
}
|
||||
|
||||
// Close releases the pool.
|
||||
func (db *DB) Close() { db.Pool.Close() }
|
||||
|
||||
func (db *DB) migrate() error {
|
||||
_, err := db.Pool.Exec(context.Background(), `
|
||||
CREATE TABLE IF NOT EXISTS statuses (
|
||||
name TEXT PRIMARY KEY,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
name TEXT PRIMARY KEY,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
default_params JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
certname TEXT PRIMARY KEY,
|
||||
role TEXT NOT NULL REFERENCES roles(name) ON UPDATE CASCADE,
|
||||
environment TEXT NOT NULL REFERENCES statuses(name) ON UPDATE CASCADE,
|
||||
params JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user