banger/internal/store/migrations.go
Thales Maciel d59425adb9
feat(vm): add vm exec command with workspace dirty detection
Introduces three interconnected features for persistent VM workflows:

1. `banger vm exec <vm> -- <cmd>`: runs a command in the prepared
   workspace, automatically cd-ing into the guest path and wrapping
   via `mise exec --` so mise-managed tools are on PATH. Falls back
   to a plain exec when mise isn't available. Exit code propagates
   verbatim.

2. Workspace persistence: workspace.prepare now stores the guest path,
   host source path, and HEAD commit into a new `workspace_json` column
   on the vms table (migration 3). This state survives daemon restarts
   and informs both dirty-checking and auto-prepare.

3. Dirty detection: `vm exec` compares the stored HEAD commit against
   the current host repo HEAD. When stale it warns and, with
   --auto-prepare, re-syncs the workspace before running.

Also:
- WORKSPACE column added to `banger ps` / `vm list`
- `banger vm` quick reference updated with `vm exec` entry
2026-04-26 23:53:45 -03:00

166 lines
4.8 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_images_docker", up: migrateDropImagesDocker},
{id: 3, name: "add_vm_workspace", up: migrateAddVMWorkspace},
}
// runMigrations ensures schema_migrations exists, then applies every
// migration whose id hasn't been recorded yet, in id order.
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 creates the full current schema.
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,
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
}
}
return nil
}
// migrateDropImagesDocker removes the legacy images.docker column.
// SQLite supports ALTER TABLE ... DROP COLUMN since 3.35 (2021), and
// banger ships against modern SQLite, so a single statement is enough.
// Existing values are simply discarded — the field never affected
// runtime behaviour.
func migrateDropImagesDocker(tx *sql.Tx) error {
_, err := tx.Exec(`ALTER TABLE images DROP COLUMN docker;`)
return err
}
// migrateAddVMWorkspace adds the workspace_json column that records
// the last workspace.prepare result (guest path, host source path,
// HEAD commit, and timestamp) per VM. Default '{}' means no workspace
// has been prepared yet. The column is managed exclusively via
// Store.SetVMWorkspace; lifecycle UpsertVM calls never touch it so
// workspace state survives VM stop/start cycles.
func migrateAddVMWorkspace(tx *sql.Tx) error {
_, err := tx.Exec(`ALTER TABLE vms ADD COLUMN workspace_json TEXT NOT NULL DEFAULT '{}'`)
return err
}