store,bangerd: add --check-migrations flag for pre-swap schema check
Prerequisite for `banger update`. Before swapping a staged binary
into place, the updater needs to confirm the new bangerd recognises
the running install's DB schema. Without this, an operator could end
up with a service that won't open its store after the binary swap +
restart.
* store.InspectSchemaState(path): opens the DB read-only (reusing
OpenReadOnly's mode=ro DSN), reads the schema_migrations table,
and classifies the relationship between applied and known IDs:
SchemaCompatible (lockstep), SchemaMigrationsNeeded (binary
newer, will auto-migrate on first Open), or SchemaIncompatible
(DB has applied IDs the binary doesn't know about).
Missing schema_migrations table is treated as "all migrations
pending" rather than an error — matches the fresh-install case.
* bangerd --check-migrations: opens the configured DB read-only,
prints a one-line classification, and exits 0/1/2. The exit
code is the contract:
0 — compatible
1 — migrations needed (binary newer; safe to swap)
2 — incompatible (binary older than DB; abort the swap)
Honours --system to pick between system StateDir and user mode.
* bangerdExit indirection so future tests can capture the exit
code without terminating the test process. Production points
at os.Exit.
Tests cover the four classifications: compatible (fully migrated
DB), migrations-needed (only baseline applied), incompatible
(synthetic id=99 inserted), and missing-table (fresh DB). Live
exercise on this dev host returned `migrations needed: pending [3]
(binary will apply on first Open)` and exit 1, matching the
contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3c0af3a2de
commit
ec6fc9d185
3 changed files with 327 additions and 0 deletions
|
|
@ -244,6 +244,121 @@ func TestRunMigrationsIgnoresUnknownAppliedIDs(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestInspectSchemaStateCompatible pins the happy path: a fully-
|
||||
// migrated DB reports SchemaCompatible.
|
||||
func TestInspectSchemaStateCompatible(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
dsn, _ := sqliteDSN(path)
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("sql.Open: %v", err)
|
||||
}
|
||||
if err := runMigrations(db); err != nil {
|
||||
t.Fatalf("runMigrations: %v", err)
|
||||
}
|
||||
_ = db.Close()
|
||||
|
||||
state, err := InspectSchemaState(path)
|
||||
if err != nil {
|
||||
t.Fatalf("InspectSchemaState: %v", err)
|
||||
}
|
||||
if state.Compatibility != SchemaCompatible {
|
||||
t.Fatalf("Compatibility = %d, want SchemaCompatible (state=%+v)", state.Compatibility, state)
|
||||
}
|
||||
if len(state.Pending) != 0 || len(state.Unknown) != 0 {
|
||||
t.Fatalf("expected empty pending/unknown; got %+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInspectSchemaStateMigrationsNeeded covers the "binary newer
|
||||
// than DB" case: the DB has only the baseline, so migrations 2 and 3
|
||||
// show up in Pending and Compatibility = SchemaMigrationsNeeded.
|
||||
func TestInspectSchemaStateMigrationsNeeded(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
dsn, _ := sqliteDSN(path)
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("sql.Open: %v", err)
|
||||
}
|
||||
// Create just schema_migrations + record only id=1.
|
||||
if _, err := db.Exec(`CREATE TABLE schema_migrations (id INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL)`); err != nil {
|
||||
t.Fatalf("create schema_migrations: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO schema_migrations VALUES (1, 'baseline', '2026-01-01T00:00:00Z')`); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
_ = db.Close()
|
||||
|
||||
state, err := InspectSchemaState(path)
|
||||
if err != nil {
|
||||
t.Fatalf("InspectSchemaState: %v", err)
|
||||
}
|
||||
if state.Compatibility != SchemaMigrationsNeeded {
|
||||
t.Fatalf("Compatibility = %d, want SchemaMigrationsNeeded (state=%+v)", state.Compatibility, state)
|
||||
}
|
||||
if len(state.Pending) == 0 {
|
||||
t.Fatal("expected non-empty pending list")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInspectSchemaStateIncompatible covers the "DB ahead of binary"
|
||||
// case: the DB records migration id=99 that this binary doesn't
|
||||
// know about. Compatibility = SchemaIncompatible; Unknown contains 99.
|
||||
func TestInspectSchemaStateIncompatible(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
dsn, _ := sqliteDSN(path)
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("sql.Open: %v", err)
|
||||
}
|
||||
if err := runMigrations(db); err != nil {
|
||||
t.Fatalf("runMigrations: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO schema_migrations VALUES (99, 'from_the_future', '2030-01-01T00:00:00Z')`); err != nil {
|
||||
t.Fatalf("insert future: %v", err)
|
||||
}
|
||||
_ = db.Close()
|
||||
|
||||
state, err := InspectSchemaState(path)
|
||||
if err != nil {
|
||||
t.Fatalf("InspectSchemaState: %v", err)
|
||||
}
|
||||
if state.Compatibility != SchemaIncompatible {
|
||||
t.Fatalf("Compatibility = %d, want SchemaIncompatible (state=%+v)", state.Compatibility, state)
|
||||
}
|
||||
if len(state.Unknown) != 1 || state.Unknown[0] != 99 {
|
||||
t.Fatalf("Unknown = %v, want [99]", state.Unknown)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInspectSchemaStateMissingTable handles the fresh-install case:
|
||||
// a DB file exists but schema_migrations doesn't (the file was created
|
||||
// by something other than banger, or banger was halted before its
|
||||
// first migration). Treat this as "all migrations pending".
|
||||
func TestInspectSchemaStateMissingTable(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
dsn, _ := sqliteDSN(path)
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("sql.Open: %v", err)
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
t.Fatalf("ping: %v", err)
|
||||
}
|
||||
_ = db.Close()
|
||||
|
||||
state, err := InspectSchemaState(path)
|
||||
if err != nil {
|
||||
t.Fatalf("InspectSchemaState: %v", err)
|
||||
}
|
||||
if state.Compatibility != SchemaMigrationsNeeded {
|
||||
t.Fatalf("Compatibility = %d, want SchemaMigrationsNeeded (no schema_migrations table)", state.Compatibility)
|
||||
}
|
||||
if len(state.Pending) != len(migrations) {
|
||||
t.Fatalf("Pending = %v, want all %d migrations", state.Pending, len(migrations))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMigrationsRejectsDuplicateID(t *testing.T) {
|
||||
db := openRawDB(t)
|
||||
orig := migrations
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue