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:
Thales Maciel 2026-04-28 18:41:31 -03:00
parent 3c0af3a2de
commit ec6fc9d185
No known key found for this signature in database
GPG key ID: 33112E6833C34679
3 changed files with 327 additions and 0 deletions

View file

@ -66,6 +66,143 @@ func runMigrations(db *sql.DB) error {
return nil
}
// SchemaCompatibility classifies the relationship between this
// binary's known migrations and a (possibly stale) DB's applied set.
type SchemaCompatibility int
const (
// SchemaCompatible: every applied id is known to this binary AND
// every known id has been applied. Binary and DB are in lockstep.
SchemaCompatible SchemaCompatibility = iota
// SchemaMigrationsNeeded: binary knows ids the DB hasn't applied
// yet. Open() would auto-migrate; safe.
SchemaMigrationsNeeded
// SchemaIncompatible: DB has applied ids this binary doesn't
// know about. Binary is older than the running install. Refuse
// the swap.
SchemaIncompatible
)
// SchemaState describes the migration status of a DB relative to
// this binary's compiled-in `migrations` slice. Used by
// `bangerd --check-migrations` to gate `banger update`'s binary swap
// before service restart — a staged binary must not be allowed to
// take over a DB whose schema it doesn't know how to read.
type SchemaState struct {
Compatibility SchemaCompatibility
AppliedIDs []int
KnownMaxID int
Pending []int // known IDs not yet applied
Unknown []int // applied IDs the binary doesn't recognise
}
// InspectSchemaState opens path read-only and reports how the DB's
// applied-migration set compares to the binary's known set. Returns
// an error only on real I/O failures (file missing, permission
// denied, corrupt SQLite); a "DB ahead of binary" state is reported
// via Compatibility, not as an error.
func InspectSchemaState(path string) (SchemaState, error) {
dsn, err := sqliteReadOnlyDSN(path)
if err != nil {
return SchemaState{}, err
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
return SchemaState{}, err
}
defer db.Close()
if err := db.Ping(); err != nil {
return SchemaState{}, err
}
// schema_migrations may not exist on a fresh install. Treat that
// as "applied = ∅" rather than an error — the equivalent of
// "the new binary will create the table on first Open".
rows, err := db.Query("SELECT id FROM schema_migrations")
if err != nil {
// modernc.org/sqlite doesn't expose a typed "no such table"
// error; sniff the message. Anything else bubbles.
if errMissingTable(err) {
return classifySchemaState(nil), nil
}
return SchemaState{}, err
}
defer rows.Close()
var applied []int
for rows.Next() {
var id int
if err := rows.Scan(&id); err != nil {
return SchemaState{}, err
}
applied = append(applied, id)
}
if err := rows.Err(); err != nil {
return SchemaState{}, err
}
return classifySchemaState(applied), nil
}
func classifySchemaState(applied []int) SchemaState {
known := map[int]struct{}{}
knownMax := 0
for _, m := range migrations {
known[m.id] = struct{}{}
if m.id > knownMax {
knownMax = m.id
}
}
appliedSet := map[int]struct{}{}
var unknown []int
for _, id := range applied {
appliedSet[id] = struct{}{}
if _, ok := known[id]; !ok {
unknown = append(unknown, id)
}
}
var pending []int
for _, m := range migrations {
if _, ok := appliedSet[m.id]; !ok {
pending = append(pending, m.id)
}
}
state := SchemaState{
AppliedIDs: append([]int(nil), applied...),
KnownMaxID: knownMax,
Pending: pending,
Unknown: unknown,
}
switch {
case len(unknown) > 0:
state.Compatibility = SchemaIncompatible
case len(pending) > 0:
state.Compatibility = SchemaMigrationsNeeded
default:
state.Compatibility = SchemaCompatible
}
return state
}
func errMissingTable(err error) bool {
if err == nil {
return false
}
msg := err.Error()
// modernc.org/sqlite wraps the underlying SQLITE_ERROR with this
// canonical sub-string for missing-table errors.
return contains(msg, "no such table: schema_migrations")
}
func contains(s, sub string) bool {
if len(sub) > len(s) {
return false
}
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
func loadAppliedMigrations(db *sql.DB) (map[int]struct{}, error) {
rows, err := db.Query("SELECT id FROM schema_migrations")
if err != nil {

View file

@ -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