42307f285f
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.
100 lines
2.7 KiB
Go
100 lines
2.7 KiB
Go
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)
|
|
}
|
|
}
|