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
This commit is contained in:
Thales Maciel 2026-04-26 23:53:45 -03:00
parent c8637b0fe4
commit d59425adb9
No known key found for this signature in database
GPG key ID: 33112E6833C34679
8 changed files with 260 additions and 13 deletions

View file

@ -25,6 +25,7 @@ type migration struct {
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
@ -152,3 +153,14 @@ 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
}

View file

@ -236,7 +236,7 @@ func (s *Store) UpsertVM(ctx context.Context, vm model.VMRecord) error {
func (s *Store) GetVM(ctx context.Context, idOrName string) (model.VMRecord, error) {
const query = `
SELECT id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at,
spec_json, runtime_json, stats_json
spec_json, runtime_json, stats_json, workspace_json
FROM vms
WHERE id = ? OR name = ?
`
@ -247,7 +247,7 @@ func (s *Store) GetVM(ctx context.Context, idOrName string) (model.VMRecord, err
func (s *Store) GetVMByID(ctx context.Context, id string) (model.VMRecord, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at,
spec_json, runtime_json, stats_json
spec_json, runtime_json, stats_json, workspace_json
FROM vms WHERE id = ?`, id)
return scanVMRow(row)
}
@ -261,7 +261,7 @@ func (s *Store) GetVMByID(ctx context.Context, id string) (model.VMRecord, error
func (s *Store) GetVMByName(ctx context.Context, name string) (model.VMRecord, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at,
spec_json, runtime_json, stats_json
spec_json, runtime_json, stats_json, workspace_json
FROM vms WHERE name = ?`, name)
return scanVMRow(row)
}
@ -269,7 +269,7 @@ func (s *Store) GetVMByName(ctx context.Context, name string) (model.VMRecord, e
func (s *Store) ListVMs(ctx context.Context) ([]model.VMRecord, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at,
spec_json, runtime_json, stats_json
spec_json, runtime_json, stats_json, workspace_json
FROM vms ORDER BY created_at ASC`)
if err != nil {
return nil, err
@ -293,10 +293,27 @@ func (s *Store) DeleteVM(ctx context.Context, id string) error {
return err
}
// SetVMWorkspace persists the workspace state from a workspace.prepare
// result onto the VM row. Called after a successful prepare so the
// guest path, host source path, and HEAD commit survive daemon
// restarts and are available to `vm exec` without re-stating them.
// Best-effort from the caller's perspective — a failure here does not
// roll back the prepare itself.
func (s *Store) SetVMWorkspace(ctx context.Context, vmID string, workspace model.VMWorkspace) error {
s.writeMu.Lock()
defer s.writeMu.Unlock()
data, err := json.Marshal(workspace)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx, "UPDATE vms SET workspace_json = ? WHERE id = ?", string(data), vmID)
return err
}
func (s *Store) FindVMsUsingImage(ctx context.Context, imageID string) ([]model.VMRecord, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at,
spec_json, runtime_json, stats_json
spec_json, runtime_json, stats_json, workspace_json
FROM vms WHERE image_id = ?`, imageID)
if err != nil {
return nil, err
@ -400,7 +417,7 @@ func scanVMRows(rows scanner) (model.VMRecord, error) {
func scanVMInto(row scanner) (model.VMRecord, error) {
var vm model.VMRecord
var state, createdAt, updatedAt, touchedAt, specJSON, runtimeJSON, statsJSON string
var state, createdAt, updatedAt, touchedAt, specJSON, runtimeJSON, statsJSON, workspaceJSON string
err := row.Scan(
&vm.ID,
&vm.Name,
@ -413,6 +430,7 @@ func scanVMInto(row scanner) (model.VMRecord, error) {
&specJSON,
&runtimeJSON,
&statsJSON,
&workspaceJSON,
)
if err != nil {
return vm, err
@ -429,6 +447,11 @@ func scanVMInto(row scanner) (model.VMRecord, error) {
return vm, err
}
}
if workspaceJSON != "" && workspaceJSON != "{}" {
if err := json.Unmarshal([]byte(workspaceJSON), &vm.Workspace); err != nil {
return vm, err
}
}
var parseErr error
vm.CreatedAt, parseErr = time.Parse(time.RFC3339, createdAt)
if parseErr != nil {