Three drift items surfaced in review, each dead on arrival and each worth trusting a little more at v0.1.0. config: drop MetricsPollInterval. The field was parsed from TOML (metrics_poll_interval), stored on DaemonConfig, and ignored by every consumer — only StatsPollInterval drives the background poll loop. Users setting it in config.toml saw zero effect. Removed from the TOML surface, the model constant, and the config test. daemon: delete ensureDefaultImage. No callers, body was `_ = ctx; return nil`. Dead since whatever flow used to call it got removed. store: drop packages_path from the images table. The column was carried by the baseline migration but never referenced by UpsertImage (no INSERT / UPDATE mention) or any Go model field — a ghost from a build pipeline that no longer exists. Added migration id=2 (drop_dead_image_columns) with an idempotent dropColumnIfExists helper: fresh installs run baseline (creates the column) + 2 (drops it); legacy DBs where the column was never added get a no-op. Updated the direct-INSERT SQL in TestGetImageRejectsMalformedTimestamp to drop the column reference, and added a migration test covering both install paths (fresh + legacy). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
247 lines
7.3 KiB
Go
247 lines
7.3 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
// migration is one ordered, atomic schema step. id must be unique and
|
|
// strictly increasing across the slice. name is a human-readable label
|
|
// stored alongside the id for debugging, and up receives a *sql.Tx so
|
|
// DDL + data backfills land atomically — either the migration fully
|
|
// applies and a schema_migrations row is written, or the whole thing
|
|
// rolls back and gets retried on next Open().
|
|
type migration struct {
|
|
id int
|
|
name string
|
|
up func(*sql.Tx) error
|
|
}
|
|
|
|
// migrations is the canonical ordered history. Append new migrations
|
|
// at the bottom with the next id. Never edit or reorder existing
|
|
// entries — installed DBs key off the id column.
|
|
var migrations = []migration{
|
|
{id: 1, name: "baseline", up: migrateBaseline},
|
|
{id: 2, name: "drop_dead_image_columns", up: migrateDropDeadImageColumns},
|
|
}
|
|
|
|
// runMigrations ensures schema_migrations exists, then applies every
|
|
// migration whose id hasn't been recorded yet, in id order. Existing
|
|
// dev databases (schema set up by the pre-versioning inline migrate()
|
|
// helper) see the baseline SQL as a no-op because every statement is
|
|
// `CREATE TABLE IF NOT EXISTS`; the row that records id=1 is what
|
|
// brings them into the new system.
|
|
func runMigrations(db *sql.DB) error {
|
|
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
applied_at TEXT NOT NULL
|
|
)`); err != nil {
|
|
return fmt.Errorf("create schema_migrations: %w", err)
|
|
}
|
|
|
|
applied, err := loadAppliedMigrations(db)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
sorted := make([]migration, len(migrations))
|
|
copy(sorted, migrations)
|
|
sort.Slice(sorted, func(i, j int) bool { return sorted[i].id < sorted[j].id })
|
|
seen := map[int]bool{}
|
|
for _, m := range sorted {
|
|
if seen[m.id] {
|
|
return fmt.Errorf("duplicate migration id %d (%q)", m.id, m.name)
|
|
}
|
|
seen[m.id] = true
|
|
}
|
|
|
|
for _, m := range sorted {
|
|
if _, ok := applied[m.id]; ok {
|
|
continue
|
|
}
|
|
if err := applyMigration(db, m); err != nil {
|
|
return fmt.Errorf("migration %d (%s): %w", m.id, m.name, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func loadAppliedMigrations(db *sql.DB) (map[int]struct{}, error) {
|
|
rows, err := db.Query("SELECT id FROM schema_migrations")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load schema_migrations: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
applied := map[int]struct{}{}
|
|
for rows.Next() {
|
|
var id int
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
applied[id] = struct{}{}
|
|
}
|
|
return applied, rows.Err()
|
|
}
|
|
|
|
func applyMigration(db *sql.DB, m migration) error {
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := m.up(tx); err != nil {
|
|
_ = tx.Rollback()
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(
|
|
"INSERT INTO schema_migrations (id, name, applied_at) VALUES (?, ?, ?)",
|
|
m.id, m.name, time.Now().UTC().Format(time.RFC3339),
|
|
); err != nil {
|
|
_ = tx.Rollback()
|
|
return fmt.Errorf("record migration: %w", err)
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// migrateBaseline captures the schema as it stood when the versioned
|
|
// migration system was introduced. Uses IF NOT EXISTS on every object
|
|
// so existing dev databases — whose tables were set up by the old
|
|
// inline migrate() — pass through cleanly and only the
|
|
// schema_migrations row gets added.
|
|
func migrateBaseline(tx *sql.Tx) error {
|
|
stmts := []string{
|
|
`CREATE TABLE IF NOT EXISTS images (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE,
|
|
managed INTEGER NOT NULL DEFAULT 0,
|
|
artifact_dir TEXT,
|
|
rootfs_path TEXT NOT NULL,
|
|
work_seed_path TEXT,
|
|
kernel_path TEXT NOT NULL,
|
|
initrd_path TEXT,
|
|
modules_dir TEXT,
|
|
packages_path TEXT,
|
|
build_size TEXT,
|
|
seeded_ssh_public_key_fingerprint TEXT,
|
|
docker INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);`,
|
|
`CREATE TABLE IF NOT EXISTS vms (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE,
|
|
image_id TEXT NOT NULL,
|
|
guest_ip TEXT NOT NULL UNIQUE,
|
|
state TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
last_touched_at TEXT NOT NULL,
|
|
spec_json TEXT NOT NULL,
|
|
runtime_json TEXT NOT NULL,
|
|
stats_json TEXT NOT NULL DEFAULT '{}',
|
|
FOREIGN KEY(image_id) REFERENCES images(id) ON DELETE RESTRICT
|
|
);`,
|
|
}
|
|
for _, stmt := range stmts {
|
|
if _, err := tx.Exec(stmt); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// Columns added to the images table across the pre-versioning
|
|
// lifetime of the project. New installs get them from the CREATE
|
|
// TABLE above; upgraders from an ancient snapshot (pre-
|
|
// ensureColumnExists) pick them up here. Idempotent either way.
|
|
for _, col := range []struct{ table, name, typ string }{
|
|
{"images", "work_seed_path", "TEXT"},
|
|
{"images", "seeded_ssh_public_key_fingerprint", "TEXT"},
|
|
} {
|
|
if err := addColumnIfMissing(tx, col.table, col.name, col.typ); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// migrateDropDeadImageColumns removes image-table columns that the
|
|
// store never reads or writes. `packages_path` was introduced for a
|
|
// build pipeline that no longer exists; the baseline migration still
|
|
// creates it for historical fidelity, and this migration drops it on
|
|
// new installs + any upgrader that still carries it. Idempotent via
|
|
// dropColumnIfExists so running the migration twice (or against a
|
|
// DB where the column was already gone) is a no-op.
|
|
func migrateDropDeadImageColumns(tx *sql.Tx) error {
|
|
return dropColumnIfExists(tx, "images", "packages_path")
|
|
}
|
|
|
|
// dropColumnIfExists is SQLite's "ALTER TABLE DROP COLUMN IF EXISTS"
|
|
// (which the dialect lacks) as a library function. modernc.org/sqlite
|
|
// bundles SQLite 3.42+, which supports plain DROP COLUMN — we add the
|
|
// existence guard so the statement is idempotent across repeat runs
|
|
// and legacy DBs that never had the column in the first place.
|
|
func dropColumnIfExists(tx *sql.Tx, table, column string) error {
|
|
rows, err := tx.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
var found 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, ¬Null, &defaultV, &pk); err != nil {
|
|
return err
|
|
}
|
|
if name == column {
|
|
found = true
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return err
|
|
}
|
|
if !found {
|
|
return nil
|
|
}
|
|
_, err = tx.Exec(fmt.Sprintf("ALTER TABLE %s DROP COLUMN %s", table, column))
|
|
return err
|
|
}
|
|
|
|
// addColumnIfMissing is SQLite's "ALTER TABLE ADD COLUMN IF NOT EXISTS"
|
|
// (which the dialect lacks) as a library function. Used inside
|
|
// migrations when a column needs to survive a database that went
|
|
// through some historical path where the column was added later.
|
|
func addColumnIfMissing(tx *sql.Tx, table, column, columnType string) error {
|
|
rows, err := tx.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var (
|
|
cid int
|
|
name string
|
|
valueType string
|
|
notNull int
|
|
defaultV sql.NullString
|
|
pk int
|
|
)
|
|
if err := rows.Scan(&cid, &name, &valueType, ¬Null, &defaultV, &pk); err != nil {
|
|
return err
|
|
}
|
|
if name == column {
|
|
return nil
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return err
|
|
}
|
|
_, err = tx.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, columnType))
|
|
return err
|
|
}
|