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
|
|
@ -2,18 +2,27 @@ package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"banger/internal/buildinfo"
|
"banger/internal/buildinfo"
|
||||||
"banger/internal/daemon"
|
"banger/internal/daemon"
|
||||||
|
"banger/internal/paths"
|
||||||
"banger/internal/roothelper"
|
"banger/internal/roothelper"
|
||||||
|
"banger/internal/store"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"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 {
|
func NewBangerdCommand() *cobra.Command {
|
||||||
var systemMode bool
|
var systemMode bool
|
||||||
var rootHelperMode bool
|
var rootHelperMode bool
|
||||||
|
var checkMigrations bool
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "bangerd",
|
Use: "bangerd",
|
||||||
Version: strings.Replace(formatVersionLine(buildinfo.Current()), "banger ", "bangerd ", 1),
|
Version: strings.Replace(formatVersionLine(buildinfo.Current()), "banger ", "bangerd ", 1),
|
||||||
|
|
@ -25,6 +34,9 @@ func NewBangerdCommand() *cobra.Command {
|
||||||
if systemMode && rootHelperMode {
|
if systemMode && rootHelperMode {
|
||||||
return errors.New("choose only one of --system or --root-helper")
|
return errors.New("choose only one of --system or --root-helper")
|
||||||
}
|
}
|
||||||
|
if checkMigrations {
|
||||||
|
return runCheckMigrations(cmd, systemMode)
|
||||||
|
}
|
||||||
if rootHelperMode {
|
if rootHelperMode {
|
||||||
server, err := roothelper.Open()
|
server, err := roothelper.Open()
|
||||||
if err != nil {
|
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(&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(&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.SetVersionTemplate("{{.Version}}\n")
|
||||||
cmd.CompletionOptions.DisableDefaultCmd = true
|
cmd.CompletionOptions.DisableDefaultCmd = true
|
||||||
return cmd
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,143 @@ func runMigrations(db *sql.DB) error {
|
||||||
return nil
|
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) {
|
func loadAppliedMigrations(db *sql.DB) (map[int]struct{}, error) {
|
||||||
rows, err := db.Query("SELECT id FROM schema_migrations")
|
rows, err := db.Query("SELECT id FROM schema_migrations")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestRunMigrationsRejectsDuplicateID(t *testing.T) {
|
||||||
db := openRawDB(t)
|
db := openRawDB(t)
|
||||||
orig := migrations
|
orig := migrations
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue