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:
parent
c8637b0fe4
commit
d59425adb9
8 changed files with 260 additions and 13 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue