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
|
|
@ -39,6 +39,7 @@ Quick reference:
|
||||||
banger vm run ./repo -- make test ship a repo, run a command, exit
|
banger vm run ./repo -- make test ship a repo, run a command, exit
|
||||||
banger vm create --name dev persistent VM; pair with 'vm ssh'
|
banger vm create --name dev persistent VM; pair with 'vm ssh'
|
||||||
banger vm ssh <name> open a shell in a running VM
|
banger vm ssh <name> open a shell in a running VM
|
||||||
|
banger vm exec <name> -- make test run a command in the workspace with mise toolchain
|
||||||
banger vm stop <name> | vm restart graceful lifecycle
|
banger vm stop <name> | vm restart graceful lifecycle
|
||||||
banger vm kill <name> force-kill if stop hangs
|
banger vm kill <name> force-kill if stop hangs
|
||||||
banger vm delete <name> stop + remove disks
|
banger vm delete <name> stop + remove disks
|
||||||
|
|
@ -49,7 +50,7 @@ Quick reference:
|
||||||
Example: strings.TrimSpace(`
|
Example: strings.TrimSpace(`
|
||||||
banger vm run -- uname -a
|
banger vm run -- uname -a
|
||||||
banger vm run ./project -- npm test
|
banger vm run ./project -- npm test
|
||||||
banger vm create --name agent && banger vm ssh agent
|
banger vm create --name dev && banger vm workspace prepare dev . && banger vm exec dev -- make test
|
||||||
`),
|
`),
|
||||||
RunE: helpNoArgs,
|
RunE: helpNoArgs,
|
||||||
}
|
}
|
||||||
|
|
@ -66,6 +67,7 @@ Quick reference:
|
||||||
d.newVMPruneCommand(),
|
d.newVMPruneCommand(),
|
||||||
d.newVMSetCommand(),
|
d.newVMSetCommand(),
|
||||||
d.newVMSSHCommand(),
|
d.newVMSSHCommand(),
|
||||||
|
d.newVMExecCommand(),
|
||||||
d.newVMWorkspaceCommand(),
|
d.newVMWorkspaceCommand(),
|
||||||
d.newVMLogsCommand(),
|
d.newVMLogsCommand(),
|
||||||
d.newVMStatsCommand(),
|
d.newVMStatsCommand(),
|
||||||
|
|
|
||||||
|
|
@ -98,13 +98,13 @@ func printVMIDList(out anyWriter, vms []model.VMRecord) error {
|
||||||
|
|
||||||
func printVMListTable(out anyWriter, vms []model.VMRecord, imageNames map[string]string) error {
|
func printVMListTable(out anyWriter, vms []model.VMRecord, imageNames map[string]string) error {
|
||||||
w := tabwriter.NewWriter(out, 0, 8, 2, ' ', 0)
|
w := tabwriter.NewWriter(out, 0, 8, 2, ' ', 0)
|
||||||
if _, err := fmt.Fprintln(w, "ID\tNAME\tSTATE\tIMAGE\tIP\tVCPU\tMEM\tDISK\tCREATED"); err != nil {
|
if _, err := fmt.Fprintln(w, "ID\tNAME\tSTATE\tIMAGE\tIP\tVCPU\tMEM\tDISK\tWORKSPACE\tCREATED"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, vm := range vms {
|
for _, vm := range vms {
|
||||||
if _, err := fmt.Fprintf(
|
if _, err := fmt.Fprintf(
|
||||||
w,
|
w,
|
||||||
"%s\t%s\t%s\t%s\t%s\t%d\t%d MiB\t%s\t%s\n",
|
"%s\t%s\t%s\t%s\t%s\t%d\t%d MiB\t%s\t%s\t%s\n",
|
||||||
shortID(vm.ID),
|
shortID(vm.ID),
|
||||||
vm.Name,
|
vm.Name,
|
||||||
vm.State,
|
vm.State,
|
||||||
|
|
@ -113,6 +113,7 @@ func printVMListTable(out anyWriter, vms []model.VMRecord, imageNames map[string
|
||||||
vm.Spec.VCPUCount,
|
vm.Spec.VCPUCount,
|
||||||
vm.Spec.MemoryMiB,
|
vm.Spec.MemoryMiB,
|
||||||
model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes),
|
model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes),
|
||||||
|
dashIfEmpty(vm.Workspace.GuestPath),
|
||||||
relativeTime(vm.CreatedAt),
|
relativeTime(vm.CreatedAt),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
184
internal/cli/vm_exec.go
Normal file
184
internal/cli/vm_exec.go
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"banger/internal/api"
|
||||||
|
"banger/internal/model"
|
||||||
|
"banger/internal/rpc"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (d *deps) newVMExecCommand() *cobra.Command {
|
||||||
|
var guestPath string
|
||||||
|
var autoPrepare bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "exec <id-or-name> -- <command> [args...]",
|
||||||
|
Short: "Run a command in the VM workspace with the repo toolchain",
|
||||||
|
Long: strings.TrimSpace(`
|
||||||
|
Run a command inside a persistent VM, automatically cd-ing into the
|
||||||
|
prepared workspace and wrapping the command with 'mise exec' so all
|
||||||
|
mise-managed tools (Go, Node, Python, etc.) are on PATH.
|
||||||
|
|
||||||
|
The workspace path comes from the last 'vm workspace prepare' or
|
||||||
|
'vm run ./repo' on this VM. If the host repo has advanced since then,
|
||||||
|
banger warns; pass --auto-prepare to re-sync the workspace first.
|
||||||
|
|
||||||
|
Exit code of the guest command is propagated verbatim.
|
||||||
|
`),
|
||||||
|
Example: strings.TrimSpace(`
|
||||||
|
banger vm exec dev -- make test
|
||||||
|
banger vm exec dev -- go build ./...
|
||||||
|
banger vm exec dev --auto-prepare -- npm ci && npm test
|
||||||
|
banger vm exec dev --guest-path /root/other -- make lint
|
||||||
|
`),
|
||||||
|
Args: cobra.ArbitraryArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
// Split on -- : everything before is [vm-name], everything after is the command.
|
||||||
|
dash := cmd.ArgsLenAtDash()
|
||||||
|
var vmRef string
|
||||||
|
var command []string
|
||||||
|
switch {
|
||||||
|
case dash < 0:
|
||||||
|
// No -- separator: first arg is VM, rest is command.
|
||||||
|
if len(args) < 2 {
|
||||||
|
return errors.New("usage: banger vm exec <id-or-name> -- <command> [args...]")
|
||||||
|
}
|
||||||
|
vmRef = args[0]
|
||||||
|
command = args[1:]
|
||||||
|
case dash == 0 || len(args[dash:]) == 0:
|
||||||
|
return errors.New("usage: banger vm exec <id-or-name> -- <command> [args...]")
|
||||||
|
default:
|
||||||
|
vmRef = args[:dash][0]
|
||||||
|
command = args[dash:]
|
||||||
|
}
|
||||||
|
|
||||||
|
layout, cfg, err := d.ensureDaemon(cmd.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateSSHPrereqs(cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the full VM record — we need Workspace and GuestIP.
|
||||||
|
result, err := rpc.Call[api.VMShowResult](cmd.Context(), layout.SocketPath, "vm.show", api.VMRefParams{IDOrName: vmRef})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
vm := result.VM
|
||||||
|
if vm.State != model.VMStateRunning {
|
||||||
|
return fmt.Errorf("vm %q is not running (state: %s)", vm.Name, vm.State)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve effective guest workspace path.
|
||||||
|
execGuestPath := strings.TrimSpace(guestPath)
|
||||||
|
if execGuestPath == "" {
|
||||||
|
execGuestPath = vm.Workspace.GuestPath
|
||||||
|
}
|
||||||
|
if execGuestPath == "" {
|
||||||
|
execGuestPath = "/root/repo"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dirty-workspace check: compare stored HEAD with current host HEAD.
|
||||||
|
isDirty, currentHead, _ := d.vmExecDirtyCheck(cmd.Context(), vm.Workspace)
|
||||||
|
if isDirty {
|
||||||
|
storedShort := shortRef(vm.Workspace.HeadCommit)
|
||||||
|
currentShort := shortRef(currentHead)
|
||||||
|
preparedLabel := relativeTime(vm.Workspace.PreparedAt)
|
||||||
|
|
||||||
|
if autoPrepare && vm.Workspace.SourcePath != "" {
|
||||||
|
_, _ = fmt.Fprintf(cmd.ErrOrStderr(),
|
||||||
|
"[vm exec] workspace stale (prepared %s from %s, host HEAD now %s) — re-preparing\n",
|
||||||
|
preparedLabel, storedShort, currentShort)
|
||||||
|
if err := validateVMRunPrereqs(cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := d.vmWorkspacePrepare(cmd.Context(), layout.SocketPath, api.VMWorkspacePrepareParams{
|
||||||
|
IDOrName: vmRef,
|
||||||
|
SourcePath: vm.Workspace.SourcePath,
|
||||||
|
GuestPath: execGuestPath,
|
||||||
|
Mode: string(model.WorkspacePrepareModeShallowOverlay),
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("auto-prepare workspace: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_, _ = fmt.Fprintf(cmd.ErrOrStderr(),
|
||||||
|
"[vm exec] warning: workspace stale (prepared %s from %s, host HEAD now %s) — use --auto-prepare to re-sync\n",
|
||||||
|
preparedLabel, storedShort, currentShort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build and run the exec script.
|
||||||
|
script := buildVMExecScript(execGuestPath, command)
|
||||||
|
sshArgs, err := sshCommandArgs(cfg, vm.Runtime.GuestIP, []string{"bash", "-lc", script})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("vm %q: build ssh args: %w", vm.Name, err)
|
||||||
|
}
|
||||||
|
if err := d.sshExec(cmd.Context(), cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), sshArgs); err != nil {
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if errors.As(err, &exitErr) {
|
||||||
|
return ExitCodeError{Code: exitErr.ExitCode()}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&guestPath, "guest-path", "", "workspace directory in the guest (default: from last workspace prepare, or /root/repo)")
|
||||||
|
cmd.Flags().BoolVar(&autoPrepare, "auto-prepare", false, "re-sync the workspace from the host repo before running if it's stale")
|
||||||
|
_ = cmd.RegisterFlagCompletionFunc("guest-path", cobra.NoFileCompletions)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildVMExecScript returns the bash -lc argument that cd's into the
|
||||||
|
// workspace and runs the command through mise exec when mise is
|
||||||
|
// available, falling back to a plain exec if it's not. Each command
|
||||||
|
// argument is shell-quoted so spaces and special characters survive
|
||||||
|
// the bash re-parse inside the -lc string.
|
||||||
|
func buildVMExecScript(guestPath string, command []string) string {
|
||||||
|
parts := make([]string, len(command))
|
||||||
|
for i, a := range command {
|
||||||
|
parts[i] = shellQuote(a)
|
||||||
|
}
|
||||||
|
quotedCmd := strings.Join(parts, " ")
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"cd %s && if command -v mise >/dev/null 2>&1; then mise exec -- %s; else %s; fi",
|
||||||
|
shellQuote(guestPath),
|
||||||
|
quotedCmd,
|
||||||
|
quotedCmd,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// vmExecDirtyCheck compares the HEAD commit stored in the VM's
|
||||||
|
// workspace record against the current HEAD of the host repo. Returns
|
||||||
|
// (false, "", nil) when the check can't be performed (no workspace
|
||||||
|
// recorded, path gone, not a repo, git not installed) so callers
|
||||||
|
// treat unknown as "not dirty" rather than blocking the exec.
|
||||||
|
func (d *deps) vmExecDirtyCheck(ctx context.Context, ws model.VMWorkspace) (isDirty bool, currentHead string, err error) {
|
||||||
|
if ws.SourcePath == "" || ws.HeadCommit == "" {
|
||||||
|
return false, "", nil
|
||||||
|
}
|
||||||
|
out, err := d.hostCommandOutput(ctx, "git", "-C", ws.SourcePath, "rev-parse", "HEAD")
|
||||||
|
if err != nil {
|
||||||
|
// Source path gone, not a git repo, or git not installed —
|
||||||
|
// treat as unknown rather than blocking.
|
||||||
|
return false, "", nil
|
||||||
|
}
|
||||||
|
currentHead = strings.TrimSpace(string(out))
|
||||||
|
return currentHead != ws.HeadCommit, currentHead, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// shortRef returns the first 8 characters of a git ref / commit SHA
|
||||||
|
// for display. Returns the full string if it's already short.
|
||||||
|
func shortRef(ref string) string {
|
||||||
|
if len(ref) > 8 {
|
||||||
|
return ref[:8]
|
||||||
|
}
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
|
@ -205,7 +205,7 @@ func (s *WorkspaceService) miseTrustGuestRepo(ctx context.Context, client ws.Gue
|
||||||
// edit doesn't accidentally drop the `command -v` guard.
|
// edit doesn't accidentally drop the `command -v` guard.
|
||||||
func miseTrustScript(guestPath string) string {
|
func miseTrustScript(guestPath string) string {
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"if command -v mise >/dev/null 2>&1; then mise trust --quiet --all %s 2>/dev/null || true; fi\n",
|
"if command -v mise >/dev/null 2>&1; then cd %s && mise trust --quiet --all 2>/dev/null || true; fi\n",
|
||||||
ws.ShellQuote(guestPath),
|
ws.ShellQuote(guestPath),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -247,6 +247,18 @@ func (s *WorkspaceService) prepareVMWorkspaceGuestIO(ctx context.Context, vm mod
|
||||||
// 'banger vm run ./repo -- <cmd>' invocation. Best-effort: a
|
// 'banger vm run ./repo -- <cmd>' invocation. Best-effort: a
|
||||||
// missing mise binary or a 'trust' that does nothing is fine.
|
// missing mise binary or a 'trust' that does nothing is fine.
|
||||||
s.miseTrustGuestRepo(ctx, client, guestPath)
|
s.miseTrustGuestRepo(ctx, client, guestPath)
|
||||||
|
preparedAt := model.Now()
|
||||||
|
// Persist workspace state so `vm exec` and dirty-checking can
|
||||||
|
// resolve guest path + HEAD commit without re-stating them. Best
|
||||||
|
// effort: a store failure here doesn't roll back the prepare.
|
||||||
|
if err := s.store.SetVMWorkspace(ctx, vm.ID, model.VMWorkspace{
|
||||||
|
GuestPath: guestPath,
|
||||||
|
SourcePath: spec.SourcePath,
|
||||||
|
HeadCommit: spec.HeadCommit,
|
||||||
|
PreparedAt: preparedAt,
|
||||||
|
}); err != nil && s.logger != nil {
|
||||||
|
s.logger.Warn("failed to persist workspace state", "vm_id", vm.ID, "error", err)
|
||||||
|
}
|
||||||
return model.WorkspacePrepareResult{
|
return model.WorkspacePrepareResult{
|
||||||
VMID: vm.ID,
|
VMID: vm.ID,
|
||||||
SourcePath: spec.SourcePath,
|
SourcePath: spec.SourcePath,
|
||||||
|
|
@ -258,6 +270,6 @@ func (s *WorkspaceService) prepareVMWorkspaceGuestIO(ctx context.Context, vm mod
|
||||||
CurrentBranch: spec.CurrentBranch,
|
CurrentBranch: spec.CurrentBranch,
|
||||||
BranchName: spec.BranchName,
|
BranchName: spec.BranchName,
|
||||||
BaseCommit: spec.BaseCommit,
|
BaseCommit: spec.BaseCommit,
|
||||||
PreparedAt: model.Now(),
|
PreparedAt: preparedAt,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -619,7 +619,7 @@ func TestMiseTrustScriptShape(t *testing.T) {
|
||||||
got := miseTrustScript("/root/repo")
|
got := miseTrustScript("/root/repo")
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"command -v mise",
|
"command -v mise",
|
||||||
"mise trust --quiet --all '/root/repo'",
|
"cd '/root/repo' && mise trust --quiet --all",
|
||||||
"|| true",
|
"|| true",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(got, want) {
|
if !strings.Contains(got, want) {
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,7 @@ type VMRecord struct {
|
||||||
Spec VMSpec `json:"spec"`
|
Spec VMSpec `json:"spec"`
|
||||||
Runtime VMRuntime `json:"runtime"`
|
Runtime VMRuntime `json:"runtime"`
|
||||||
Stats VMStats `json:"stats"`
|
Stats VMStats `json:"stats"`
|
||||||
|
Workspace VMWorkspace `json:"workspace"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type VMCreateRequest struct {
|
type VMCreateRequest struct {
|
||||||
|
|
@ -166,6 +167,18 @@ type VMSetRequest struct {
|
||||||
NATEnabled *bool
|
NATEnabled *bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VMWorkspace records the last successful workspace.prepare result on
|
||||||
|
// a VM so callers can skip re-stating the source path on every exec
|
||||||
|
// and so banger can detect drift between the guest copy and the host
|
||||||
|
// repo. Stored as workspace_json in the vms table; zero value means
|
||||||
|
// no workspace has been prepared on this VM yet.
|
||||||
|
type VMWorkspace struct {
|
||||||
|
GuestPath string `json:"guest_path,omitempty"`
|
||||||
|
SourcePath string `json:"source_path,omitempty"`
|
||||||
|
HeadCommit string `json:"head_commit,omitempty"`
|
||||||
|
PreparedAt time.Time `json:"prepared_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type WorkspacePrepareMode string
|
type WorkspacePrepareMode string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ type migration struct {
|
||||||
var migrations = []migration{
|
var migrations = []migration{
|
||||||
{id: 1, name: "baseline", up: migrateBaseline},
|
{id: 1, name: "baseline", up: migrateBaseline},
|
||||||
{id: 2, name: "drop_images_docker", up: migrateDropImagesDocker},
|
{id: 2, name: "drop_images_docker", up: migrateDropImagesDocker},
|
||||||
|
{id: 3, name: "add_vm_workspace", up: migrateAddVMWorkspace},
|
||||||
}
|
}
|
||||||
|
|
||||||
// runMigrations ensures schema_migrations exists, then applies every
|
// 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;`)
|
_, err := tx.Exec(`ALTER TABLE images DROP COLUMN docker;`)
|
||||||
return err
|
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) {
|
func (s *Store) GetVM(ctx context.Context, idOrName string) (model.VMRecord, error) {
|
||||||
const query = `
|
const query = `
|
||||||
SELECT id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at,
|
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
|
FROM vms
|
||||||
WHERE id = ? OR name = ?
|
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) {
|
func (s *Store) GetVMByID(ctx context.Context, id string) (model.VMRecord, error) {
|
||||||
row := s.db.QueryRowContext(ctx, `
|
row := s.db.QueryRowContext(ctx, `
|
||||||
SELECT id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at,
|
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)
|
FROM vms WHERE id = ?`, id)
|
||||||
return scanVMRow(row)
|
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) {
|
func (s *Store) GetVMByName(ctx context.Context, name string) (model.VMRecord, error) {
|
||||||
row := s.db.QueryRowContext(ctx, `
|
row := s.db.QueryRowContext(ctx, `
|
||||||
SELECT id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at,
|
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)
|
FROM vms WHERE name = ?`, name)
|
||||||
return scanVMRow(row)
|
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) {
|
func (s *Store) ListVMs(ctx context.Context) ([]model.VMRecord, error) {
|
||||||
rows, err := s.db.QueryContext(ctx, `
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
SELECT id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at,
|
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`)
|
FROM vms ORDER BY created_at ASC`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -293,10 +293,27 @@ func (s *Store) DeleteVM(ctx context.Context, id string) error {
|
||||||
return err
|
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) {
|
func (s *Store) FindVMsUsingImage(ctx context.Context, imageID string) ([]model.VMRecord, error) {
|
||||||
rows, err := s.db.QueryContext(ctx, `
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
SELECT id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at,
|
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)
|
FROM vms WHERE image_id = ?`, imageID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -400,7 +417,7 @@ func scanVMRows(rows scanner) (model.VMRecord, error) {
|
||||||
|
|
||||||
func scanVMInto(row scanner) (model.VMRecord, error) {
|
func scanVMInto(row scanner) (model.VMRecord, error) {
|
||||||
var vm model.VMRecord
|
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(
|
err := row.Scan(
|
||||||
&vm.ID,
|
&vm.ID,
|
||||||
&vm.Name,
|
&vm.Name,
|
||||||
|
|
@ -413,6 +430,7 @@ func scanVMInto(row scanner) (model.VMRecord, error) {
|
||||||
&specJSON,
|
&specJSON,
|
||||||
&runtimeJSON,
|
&runtimeJSON,
|
||||||
&statsJSON,
|
&statsJSON,
|
||||||
|
&workspaceJSON,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return vm, err
|
return vm, err
|
||||||
|
|
@ -429,6 +447,11 @@ func scanVMInto(row scanner) (model.VMRecord, error) {
|
||||||
return vm, err
|
return vm, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if workspaceJSON != "" && workspaceJSON != "{}" {
|
||||||
|
if err := json.Unmarshal([]byte(workspaceJSON), &vm.Workspace); err != nil {
|
||||||
|
return vm, err
|
||||||
|
}
|
||||||
|
}
|
||||||
var parseErr error
|
var parseErr error
|
||||||
vm.CreatedAt, parseErr = time.Parse(time.RFC3339, createdAt)
|
vm.CreatedAt, parseErr = time.Parse(time.RFC3339, createdAt)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue