doctor: open the state DB read-only so inspection never mutates it

`banger doctor` used to call store.Open, which unconditionally runs
migrations on the way up. Diagnostics mutating persistent state is a
surprise — particularly now that migration 2 drops a column, so a
plain `doctor` invocation against an old DB would silently schema-
evolve it.

Add store.OpenReadOnly: separate DSN builder with mode=ro and a
minimal pragma set (foreign_keys, busy_timeout — no journal_mode=WAL,
no wal_autocheckpoint), skips runMigrations, and pings on open so a
missing DB fails up front rather than at first query. doctor.go now
uses OpenReadOnly; the existing storeErr fallback path surfaces any
failure as a failing check, unchanged.

Tests pin two invariants:
- OpenReadOnly against a DB whose migration 2 marker was removed and
  packages_path re-added must leave both alone (i.e. no drift is
  applied behind the user's back).
- Any write attempted through the read-only handle is rejected at
  the driver layer (belt-and-braces for future refactors).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-22 11:05:23 -03:00
parent 129475be20
commit 2685bc73f8
No known key found for this signature in database
GPG key ID: 33112E6833C34679
3 changed files with 141 additions and 1 deletions

View file

@ -215,6 +215,90 @@ func TestMigrateDropDeadImageColumns_AcrossInstallPaths(t *testing.T) {
})
}
// TestOpenReadOnlyDoesNotRunMigrations pins the doctor contract:
// OpenReadOnly must not mutate the DB. We create a DB without the
// schema_migrations row for migration 2 present (simulating a
// daemon-not-yet-run state), open it read-only, and confirm no row
// was added and no column dropped.
func TestOpenReadOnlyDoesNotRunMigrations(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
// Seed the file by running full Open once, then roll migration 2
// backwards manually so the DB is "behind" current code.
full, err := Open(path)
if err != nil {
t.Fatalf("Open: %v", err)
}
if _, err := full.db.Exec("ALTER TABLE images ADD COLUMN packages_path TEXT"); err != nil {
t.Fatalf("re-add packages_path: %v", err)
}
if _, err := full.db.Exec("DELETE FROM schema_migrations WHERE id = 2"); err != nil {
t.Fatalf("remove migration 2 marker: %v", err)
}
_ = full.Close()
ro, err := OpenReadOnly(path)
if err != nil {
t.Fatalf("OpenReadOnly: %v", err)
}
defer ro.Close()
// Migration 2 marker must still be absent; packages_path must
// still exist.
var migCount int
if err := ro.db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE id = 2").Scan(&migCount); err != nil {
t.Fatalf("query schema_migrations: %v", err)
}
if migCount != 0 {
t.Fatal("OpenReadOnly recorded migration 2 — the open path mutated the DB")
}
rows, err := ro.db.Query("PRAGMA table_info(images)")
if err != nil {
t.Fatalf("PRAGMA table_info: %v", err)
}
defer rows.Close()
var sawColumn bool
for rows.Next() {
var (
cid int
name string
valueType string
notNull int
defaultV sql.NullString
pk int
)
if err := rows.Scan(&cid, &name, &valueType, &notNull, &defaultV, &pk); err != nil {
t.Fatalf("scan: %v", err)
}
if name == "packages_path" {
sawColumn = true
}
}
if !sawColumn {
t.Fatal("packages_path disappeared — OpenReadOnly ran the drop migration")
}
}
// TestOpenReadOnlyRefusesWrites confirms SQLite's mode=ro is in effect
// — no matter what a caller tries, writes are rejected at the driver
// level. Belt-and-braces guard against a future refactor that might
// plumb a write method through.
func TestOpenReadOnlyRefusesWrites(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
if s, err := Open(path); err != nil {
t.Fatalf("seed Open: %v", err)
} else {
_ = s.Close()
}
ro, err := OpenReadOnly(path)
if err != nil {
t.Fatalf("OpenReadOnly: %v", err)
}
defer ro.Close()
if _, err := ro.db.Exec("INSERT INTO schema_migrations (id, name, applied_at) VALUES (999, 'x', 'x')"); err == nil {
t.Fatal("write succeeded against a read-only store")
}
}
func TestRunMigrationsRejectsDuplicateID(t *testing.T) {
db := openRawDB(t)
orig := migrations