From ec6fc9d18593b1415a0ab09c4e135f4e502df322 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Tue, 28 Apr 2026 18:41:31 -0300 Subject: [PATCH] store,bangerd: add --check-migrations flag for pre-swap schema check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/cli/bangerd.go | 75 ++++++++++++++++ internal/store/migrations.go | 137 ++++++++++++++++++++++++++++++ internal/store/migrations_test.go | 115 +++++++++++++++++++++++++ 3 files changed, 327 insertions(+) diff --git a/internal/cli/bangerd.go b/internal/cli/bangerd.go index 23c98d4..1754978 100644 --- a/internal/cli/bangerd.go +++ b/internal/cli/bangerd.go @@ -2,18 +2,27 @@ package cli import ( "errors" + "fmt" + "os" "strings" "banger/internal/buildinfo" "banger/internal/daemon" + "banger/internal/paths" "banger/internal/roothelper" + "banger/internal/store" "github.com/spf13/cobra" ) +// bangerdExit is var-injected so tests can capture the exit code +// without terminating the test process. Production points at os.Exit. +var bangerdExit = os.Exit + func NewBangerdCommand() *cobra.Command { var systemMode bool var rootHelperMode bool + var checkMigrations bool cmd := &cobra.Command{ Use: "bangerd", Version: strings.Replace(formatVersionLine(buildinfo.Current()), "banger ", "bangerd ", 1), @@ -25,6 +34,9 @@ func NewBangerdCommand() *cobra.Command { if systemMode && rootHelperMode { return errors.New("choose only one of --system or --root-helper") } + if checkMigrations { + return runCheckMigrations(cmd, systemMode) + } if rootHelperMode { server, err := roothelper.Open() if err != nil { @@ -47,7 +59,70 @@ func NewBangerdCommand() *cobra.Command { } cmd.Flags().BoolVar(&systemMode, "system", false, "run as the owner-user system service") cmd.Flags().BoolVar(&rootHelperMode, "root-helper", false, "run as the privileged root helper service") + cmd.Flags().BoolVar(&checkMigrations, "check-migrations", false, "inspect the state DB and report whether this binary's schema matches; exit 0=compatible, 1=migrations needed, 2=incompatible") cmd.SetVersionTemplate("{{.Version}}\n") cmd.CompletionOptions.DisableDefaultCmd = true return cmd } + +// runCheckMigrations is the entry point for `bangerd --check-migrations`. +// Used by `banger update` to gate a binary swap on a staged binary +// before service restart: if the staged binary doesn't recognise the +// running install's schema, the swap is aborted before any host state +// changes. +// +// Exit codes are part of the contract: +// +// 0 — compatible (no migrations to apply on Open) +// 1 — migrations needed (binary newer than DB; safe to swap) +// 2 — incompatible (DB has migrations this binary doesn't know; +// swapping would leave the daemon unable to open the store) +func runCheckMigrations(cmd *cobra.Command, systemMode bool) error { + layout := paths.ResolveSystem() + if !systemMode { + userLayout, err := paths.Resolve() + if err != nil { + return err + } + layout = userLayout + } + state, err := store.InspectSchemaState(layout.DBPath) + if err != nil { + return fmt.Errorf("inspect %s: %w", layout.DBPath, err) + } + out := cmd.OutOrStdout() + switch state.Compatibility { + case store.SchemaCompatible: + fmt.Fprintf(out, "compatible: db at v%d, binary knows up to v%d\n", lastID(state.AppliedIDs), state.KnownMaxID) + return nil + case store.SchemaMigrationsNeeded: + fmt.Fprintf(out, "migrations needed: pending %v (binary will apply on first Open)\n", state.Pending) + // Distinct exit code so callers can tell "safe to swap, will + // auto-migrate" apart from "compatible, no work pending". + // Returning a cobra error would also exit non-zero, but we + // want a specific code (1) — and we don't want SilenceErrors + // to print our message twice. + bangerdExit(1) + return nil + case store.SchemaIncompatible: + fmt.Fprintf(out, "incompatible: db has unknown migrations %v (binary knows up to v%d)\n", state.Unknown, state.KnownMaxID) + bangerdExit(2) + return nil + default: + return fmt.Errorf("unexpected schema-state classification %d", state.Compatibility) + } +} + +// lastID returns the largest int in xs, or 0 when empty. The schema- +// migrations table doesn't guarantee insert order, so we scan rather +// than trusting xs[len-1]. +func lastID(xs []int) int { + max := 0 + for _, x := range xs { + if x > max { + max = x + } + } + return max +} + diff --git a/internal/store/migrations.go b/internal/store/migrations.go index 1b23efb..1734c03 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -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 { diff --git a/internal/store/migrations_test.go b/internal/store/migrations_test.go index 32bcb1a..580fc6c 100644 --- a/internal/store/migrations_test.go +++ b/internal/store/migrations_test.go @@ -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