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:
@@ -0,0 +1,23 @@
|
||||
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()
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
// Package migrations embeds encapi's SQL schema so the server carries it in
|
||||
// the binary and applies it at startup, with no separately deployed copy to
|
||||
// drift out of sync.
|
||||
package migrations
|
||||
|
||||
import "embed"
|
||||
|
||||
// FS holds every numbered migration; lexical filename order is version order.
|
||||
//
|
||||
//go:embed *.sql
|
||||
var FS embed.FS
|
||||
|
||||
// LockName names the cluster-wide advisory lock replicas contend for while
|
||||
// migrating. golib derives the key as FNV-1a/64 of this name, so every replica
|
||||
// migrating this database must agree on the string.
|
||||
const LockName = "encapi-migrations"
|
||||
@@ -0,0 +1,99 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.unkin.net/unkin/golib/pg"
|
||||
)
|
||||
|
||||
// readDir returns the .sql files on disk, keyed by name.
|
||||
func readDir(t *testing.T) map[string]string {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(".")
|
||||
if err != nil {
|
||||
t.Fatalf("read migrations dir: %v", err)
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
b, err := os.ReadFile(e.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", e.Name(), err)
|
||||
}
|
||||
out[e.Name()] = string(b)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatal("no .sql files in migrations/")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The embedded set is the schema the binary ships, so it must match the
|
||||
// directory exactly — a file on disk but outside the embed pattern would never
|
||||
// run in production while still passing a local review.
|
||||
func TestEmbeddedMatchesDirectory(t *testing.T) {
|
||||
onDisk := readDir(t)
|
||||
|
||||
entries, err := fs.ReadDir(FS, ".")
|
||||
if err != nil {
|
||||
t.Fatalf("read embedded migrations: %v", err)
|
||||
}
|
||||
embedded := map[string]string{}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
b, err := FS.ReadFile(e.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("read embedded %s: %v", e.Name(), err)
|
||||
}
|
||||
embedded[e.Name()] = string(b)
|
||||
}
|
||||
|
||||
if len(embedded) != len(onDisk) {
|
||||
t.Fatalf("embedded %d files, migrations/ has %d", len(embedded), len(onDisk))
|
||||
}
|
||||
for name, want := range onDisk {
|
||||
got, ok := embedded[name]
|
||||
if !ok {
|
||||
t.Errorf("migrations/%s is not embedded", name)
|
||||
continue
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("%s: embedded body differs from migrations/%s", name, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrations are re-run against a database whose schema predates
|
||||
// schema_migrations, so every CREATE must tolerate the objects already
|
||||
// existing.
|
||||
func TestMigrationsAreIdempotent(t *testing.T) {
|
||||
for name, body := range readDir(t) {
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
upper := strings.ToUpper(strings.TrimSpace(line))
|
||||
if !strings.HasPrefix(upper, "CREATE ") {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(upper, "IF NOT EXISTS") && !strings.Contains(upper, "CREATE OR REPLACE") {
|
||||
t.Errorf("%s: %q is not guarded with IF NOT EXISTS", name, strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// golib derives the advisory lock key from LockName. Replicas only serialize
|
||||
// against each other while they agree on the key, so renaming the lock during a
|
||||
// rolling deploy would let two versions migrate at once.
|
||||
func TestLockKeyIsStable(t *testing.T) {
|
||||
// FNV-1a/64 of "encapi-migrations", reinterpreted as int64.
|
||||
const deployedKey int64 = -7124093699699113719
|
||||
if got := pg.LockKey(LockName); got != deployedKey {
|
||||
t.Fatalf("LockKey(%q) = %d, want %d", LockName, got, deployedKey)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user