diff --git a/README.md b/README.md index a8b0c5d..3f1273f 100644 --- a/README.md +++ b/README.md @@ -46,16 +46,21 @@ Disconnecting an interactive session leaves the VM running, banger vm run ./my-repo # copy /my-repo into /root/repo — drops into ssh banger vm run ./repo -- make test # workspace + run command, exits with its status banger vm run --rm -- script.sh # ephemeral: VM is deleted on exit +banger vm run -d ./repo --nat # detached: prep + bootstrap, exit (no ssh attach) ``` If a repository is passed, banger copies your repo's git-tracked files -into `/root/repo` and runs a best-effort `mise` bootstrap from -`.mise.toml` / `.tool-versions`. Untracked files are skipped by -default — pass `--include-untracked` to ship them too, or -`--dry-run` to preview the file list. +into `/root/repo` and runs a `mise` bootstrap from `.mise.toml` / +`.tool-versions` if either is present. The bootstrap reaches the +public internet, so workspaces with mise manifests require `--nat`; +pass `--no-bootstrap` to skip the install entirely. Untracked files +are skipped by default — pass `--include-untracked` to ship them +too, or `--dry-run` to preview the file list. In **command mode** (`-- `), the exit code propagates through -`banger`. +`banger`. In **detached mode** (`-d`), banger creates the VM, runs +workspace prep + bootstrap synchronously, then exits — no ssh +attach. Reconnect later with `banger vm ssh `. ### Other VM verbs diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index d67ddef..a5fedfa 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -1324,6 +1324,8 @@ func TestRunVMRunWorkspacePreparesAndAttaches(t *testing.T) { &repo, nil, false, + false, + false, ) if err != nil { t.Fatalf("d.runVMRun: %v", err) @@ -1400,6 +1402,8 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) { &repo, nil, false, + false, + false, ) if err != nil { t.Fatalf("d.runVMRun: %v", err) @@ -1475,6 +1479,8 @@ func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) { &repo, nil, false, + false, + false, ) if err != nil { t.Fatalf("d.runVMRun: %v", err) @@ -1526,6 +1532,8 @@ func TestRunVMRunBareModeSkipsWorkspaceAndTooling(t *testing.T) { nil, nil, false, + false, + false, ) if err != nil { t.Fatalf("d.runVMRun: %v", err) @@ -1569,7 +1577,9 @@ func TestRunVMRunRMDeletesAfterSessionExits(t *testing.T) { api.VMCreateParams{Name: "tmpbox"}, nil, nil, - true, // --rm + true, // --rm, + false, + false, ) if err != nil { t.Fatalf("d.runVMRun: %v", err) @@ -1619,7 +1629,9 @@ func TestRunVMRunRMSkipsDeleteOnSSHWaitTimeout(t *testing.T) { api.VMCreateParams{Name: "slowvm"}, nil, nil, - true, // --rm + true, // --rm, + false, + false, ) if err == nil { t.Fatal("want timeout error") @@ -1662,6 +1674,8 @@ func TestRunVMRunSSHTimeoutReturnsActionableError(t *testing.T) { nil, nil, false, + false, + false, ) if err == nil { t.Fatal("want timeout error") @@ -1711,6 +1725,8 @@ func TestRunVMRunCommandModePropagatesExitCode(t *testing.T) { nil, []string{"false"}, false, + false, + false, ) var exitErr ExitCodeError if !errors.As(err, &exitErr) || exitErr.Code != 7 { diff --git a/internal/cli/commands_vm.go b/internal/cli/commands_vm.go index 8228a5b..e5c38c0 100644 --- a/internal/cli/commands_vm.go +++ b/internal/cli/commands_vm.go @@ -91,6 +91,8 @@ func (d *deps) newVMRunCommand() *cobra.Command { removeOnExit bool includeUntracked bool dryRun bool + detach bool + skipBootstrap bool ) cmd := &cobra.Command{ Use: "run [path] [-- command args...]", @@ -98,16 +100,24 @@ func (d *deps) newVMRunCommand() *cobra.Command { Long: strings.TrimSpace(` Create a sandbox VM and either drop into an interactive shell or run a command. -Three modes: +Modes: banger vm run bare sandbox, drops into ssh banger vm run ./repo workspace sandbox, drops into ssh at /root/repo banger vm run ./repo -- make test workspace, runs command, exits with its status + banger vm run -d ./repo workspace + bootstrap, exit (no ssh attach) + +Tooling bootstrap (workspace mode): + When the workspace contains a .mise.toml or .tool-versions, vm run + installs the listed tools via mise on first boot. The bootstrap + needs internet, so --nat must be set. Pass --no-bootstrap to skip + it entirely (no NAT requirement). `), Args: cobra.ArbitraryArgs, Example: strings.TrimSpace(` banger vm run banger vm run ../repo --name agent-box --branch feature/demo banger vm run ../repo -- make test + banger vm run -d ../repo --nat banger vm run -- uname -a `), RunE: func(cmd *cobra.Command, args []string) error { @@ -129,6 +139,12 @@ Three modes: if sourcePath == "" && strings.TrimSpace(branchName) != "" { return errors.New("--branch requires a path argument") } + if detach && removeOnExit { + return errors.New("cannot combine --detach with --rm") + } + if detach && len(commandArgs) > 0 { + return errors.New("cannot combine --detach with a guest command") + } var repoPtr *vmRunRepo if sourcePath != "" { @@ -174,7 +190,7 @@ Three modes: if err != nil { return err } - return d.runVMRun(cmd.Context(), layout.SocketPath, cfg, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), params, repoPtr, commandArgs, removeOnExit) + return d.runVMRun(cmd.Context(), layout.SocketPath, cfg, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), params, repoPtr, commandArgs, removeOnExit, detach, skipBootstrap) }, } cmd.Flags().StringVar(&name, "name", "", "vm name") @@ -189,6 +205,8 @@ Three modes: cmd.Flags().BoolVar(&removeOnExit, "rm", false, "delete the VM after the ssh session / command exits") cmd.Flags().BoolVar(&includeUntracked, "include-untracked", false, "also copy untracked non-ignored files into the guest workspace (default: tracked files only)") cmd.Flags().BoolVar(&dryRun, "dry-run", false, "list the files that would be copied into the guest workspace and exit without creating a VM") + cmd.Flags().BoolVarP(&detach, "detach", "d", false, "create the VM, prep workspace + bootstrap, exit without attaching to ssh") + cmd.Flags().BoolVar(&skipBootstrap, "no-bootstrap", false, "skip the mise tooling bootstrap (no --nat requirement)") _ = cmd.RegisterFlagCompletionFunc("image", d.completeImageNames) return cmd } diff --git a/internal/cli/vm_run.go b/internal/cli/vm_run.go index 1b8b182..3bd9285 100644 --- a/internal/cli/vm_run.go +++ b/internal/cli/vm_run.go @@ -114,6 +114,23 @@ func (d *deps) vmRunPreflightRepo(ctx context.Context, rawPath string) (string, return sourcePath, nil } +// repoHasMiseFiles reports whether the repo at sourcePath contains a +// mise tooling manifest. Used as a host-side preflight: when --nat is +// off and a manifest is present, vm run refuses early instead of +// committing to a VM that will silently fail to install tools. +func repoHasMiseFiles(sourcePath string) (bool, error) { + for _, name := range []string{".mise.toml", ".tool-versions"} { + info, err := os.Stat(filepath.Join(sourcePath, name)) + if err == nil && !info.IsDir() { + return true, nil + } + if err != nil && !errors.Is(err, os.ErrNotExist) { + return false, fmt.Errorf("inspect %s: %w", name, err) + } + } + return false, nil +} + // splitVMRunArgs partitions cobra positional args into the optional path // argument and the trailing command (everything after a `--` separator). // The path slice may contain 0..1 entries; the command slice may be empty. @@ -132,7 +149,16 @@ func splitVMRunArgs(cmd *cobra.Command, args []string) (pathArgs, commandArgs [] // for guest ssh, optionally materialise a workspace and kick off the // tooling bootstrap, then either attach interactively or run the // user's command and propagate its exit status. -func (d *deps) runVMRun(ctx context.Context, socketPath string, cfg model.DaemonConfig, stdin io.Reader, stdout, stderr io.Writer, params api.VMCreateParams, repo *vmRunRepo, command []string, removeOnExit bool) error { +func (d *deps) runVMRun(ctx context.Context, socketPath string, cfg model.DaemonConfig, stdin io.Reader, stdout, stderr io.Writer, params api.VMCreateParams, repo *vmRunRepo, command []string, removeOnExit, detach, skipBootstrap bool) error { + if repo != nil && !skipBootstrap && !params.NATEnabled { + hasMise, err := repoHasMiseFiles(repo.sourcePath) + if err != nil { + return err + } + if hasMise { + return errors.New("tooling bootstrap requires --nat (or pass --no-bootstrap to skip)") + } + } progress := newVMRunProgressRenderer(stderr) vm, err := d.runVMCreate(ctx, socketPath, stderr, params) if err != nil { @@ -214,17 +240,21 @@ func (d *deps) runVMRun(ctx context.Context, socketPath string, cfg model.Daemon // The prepare RPC already did the full git inspection on the // daemon side; grab what the tooling harness needs from its // result instead of re-inspecting here. - if len(command) == 0 { + if len(command) == 0 && !skipBootstrap { client, err := d.guestDial(ctx, sshAddress, cfg.SSHKeyPath) if err != nil { return fmt.Errorf("vm %q is running but guest ssh is unavailable: %w", vmRef, err) } - if err := d.startVMRunToolingHarness(ctx, client, prepared.Workspace.RepoRoot, prepared.Workspace.RepoName, progress); err != nil { + if err := d.startVMRunToolingHarness(ctx, client, prepared.Workspace.RepoRoot, prepared.Workspace.RepoName, progress, detach, stderr); err != nil { printVMRunWarning(stderr, fmt.Sprintf("guest tooling bootstrap start failed: %v", err)) } _ = client.Close() } } + if detach { + progress.render(fmt.Sprintf("vm %s running; reconnect with: banger vm ssh %s", vmRef, vmRef)) + return nil + } sshArgs, err := sshCommandArgs(cfg, vm.Runtime.GuestIP, command) if err != nil { return fmt.Errorf("vm %q is running but ssh args could not be built: %w", vmRef, err) @@ -260,7 +290,13 @@ func vmRunToolingHarnessLogPath(repoName string) string { // script inside the guest. repoRoot / repoName both come from the // daemon's workspace.prepare RPC response so the CLI doesn't have // to re-inspect the git tree. -func (d *deps) startVMRunToolingHarness(ctx context.Context, client vmRunGuestClient, repoRoot, repoName string, progress *vmRunProgressRenderer) error { +// +// When wait is true (used by --detach), the harness runs in the +// foreground so the CLI can return only after bootstrap finishes; +// the harness's stdout is streamed to syncOut for live visibility. +// When wait is false (interactive mode), the harness is nohup'd so +// the user's ssh session can start while bootstrap continues. +func (d *deps) startVMRunToolingHarness(ctx context.Context, client vmRunGuestClient, repoRoot, repoName string, progress *vmRunProgressRenderer, wait bool, syncOut io.Writer) error { if progress != nil { progress.render("starting guest tooling bootstrap") } @@ -269,6 +305,20 @@ func (d *deps) startVMRunToolingHarness(ctx context.Context, client vmRunGuestCl if err := client.UploadFile(ctx, vmRunToolingHarnessPath(repoName), 0o755, []byte(vmRunToolingHarnessScript(plan)), &uploadLog); err != nil { return formatVMRunStepError("upload guest tooling bootstrap", err, uploadLog.String()) } + if wait { + var launchLog bytes.Buffer + out := io.Writer(&launchLog) + if syncOut != nil { + out = io.MultiWriter(syncOut, &launchLog) + } + if err := client.RunScript(ctx, vmRunToolingHarnessSyncScript(repoName), out); err != nil { + return formatVMRunStepError("run guest tooling bootstrap", err, launchLog.String()) + } + if progress != nil { + progress.render("guest tooling bootstrap done (log: " + vmRunToolingHarnessLogPath(repoName) + ")") + } + return nil + } var launchLog bytes.Buffer if err := client.RunScript(ctx, vmRunToolingHarnessLaunchScript(repoName), &launchLog); err != nil { return formatVMRunStepError("launch guest tooling bootstrap", err, launchLog.String()) @@ -367,6 +417,20 @@ func vmRunToolingHarnessLaunchScript(repoName string) string { return script.String() } +// vmRunToolingHarnessSyncScript is the foreground variant used by +// --detach: it tees the harness output to both the log file and the +// caller's stdout so the host-side CLI can stream live progress while +// still preserving the log for later inspection. +func vmRunToolingHarnessSyncScript(repoName string) string { + var script strings.Builder + script.WriteString("set -uo pipefail\n") + fmt.Fprintf(&script, "HELPER=%s\n", shellQuote(vmRunToolingHarnessPath(repoName))) + fmt.Fprintf(&script, "LOG=%s\n", shellQuote(vmRunToolingHarnessLogPath(repoName))) + script.WriteString("mkdir -p \"$(dirname \"$LOG\")\"\n") + script.WriteString("bash \"$HELPER\" 2>&1 | tee \"$LOG\"\n") + return script.String() +} + func formatVMRunStepError(action string, err error, log string) error { log = strings.TrimSpace(log) if log == "" { diff --git a/internal/cli/vm_run_test.go b/internal/cli/vm_run_test.go new file mode 100644 index 0000000..978b111 --- /dev/null +++ b/internal/cli/vm_run_test.go @@ -0,0 +1,278 @@ +package cli + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "banger/internal/api" + "banger/internal/model" + "banger/internal/toolingplan" +) + +func TestVMRunRejectsDetachWithRm(t *testing.T) { + cmd := NewBangerCommand() + cmd.SetArgs([]string{"vm", "run", "-d", "--rm"}) + + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "cannot combine --detach with --rm") { + t.Fatalf("Execute() error = %v, want --detach + --rm rejection", err) + } +} + +func TestVMRunRejectsDetachWithCommand(t *testing.T) { + cmd := NewBangerCommand() + cmd.SetArgs([]string{"vm", "run", "-d", "--", "whoami"}) + + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "cannot combine --detach with a guest command") { + t.Fatalf("Execute() error = %v, want --detach + command rejection", err) + } +} + +func TestRepoHasMiseFiles(t *testing.T) { + dir := t.TempDir() + got, err := repoHasMiseFiles(dir) + if err != nil { + t.Fatalf("repoHasMiseFiles(empty): %v", err) + } + if got { + t.Fatalf("repoHasMiseFiles(empty) = true, want false") + } + + if err := os.WriteFile(filepath.Join(dir, ".mise.toml"), []byte(""), 0o600); err != nil { + t.Fatalf("write .mise.toml: %v", err) + } + got, err = repoHasMiseFiles(dir) + if err != nil { + t.Fatalf("repoHasMiseFiles(.mise.toml): %v", err) + } + if !got { + t.Fatalf("repoHasMiseFiles(.mise.toml) = false, want true") + } + + dir2 := t.TempDir() + if err := os.WriteFile(filepath.Join(dir2, ".tool-versions"), []byte(""), 0o600); err != nil { + t.Fatalf("write .tool-versions: %v", err) + } + got, err = repoHasMiseFiles(dir2) + if err != nil { + t.Fatalf("repoHasMiseFiles(.tool-versions): %v", err) + } + if !got { + t.Fatalf("repoHasMiseFiles(.tool-versions) = false, want true") + } +} + +// runVMRunDepsRunningVM returns a deps wired so runVMRun reaches a +// point where it would create a VM and proceed — used by precondition +// tests that should refuse before any of these fakes get called. +func runVMRunDepsRunningVM(t *testing.T) (*deps, *model.VMRecord) { + t.Helper() + d := defaultDeps() + vm := &model.VMRecord{ + ID: "vm-id", + Name: "devbox", + Runtime: model.VMRuntime{ + State: model.VMStateRunning, + GuestIP: "172.16.0.2", + DNSName: "devbox.vm", + }, + } + d.vmCreateBegin = func(context.Context, string, api.VMCreateParams) (api.VMCreateBeginResult, error) { + return api.VMCreateBeginResult{Operation: api.VMCreateOperation{ID: "op-1", Stage: "ready", Done: true, Success: true, VM: vm}}, nil + } + d.guestWaitForSSH = func(context.Context, string, string, time.Duration) error { return nil } + d.vmWorkspacePrepare = func(context.Context, string, api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) { + return api.VMWorkspacePrepareResult{Workspace: model.WorkspacePrepareResult{VMID: vm.ID, GuestPath: "/root/repo", RepoName: "repo", RepoRoot: "/tmp/repo"}}, nil + } + d.buildVMRunToolingPlan = func(context.Context, string) toolingplan.Plan { + return toolingplan.Plan{} + } + d.vmHealth = func(context.Context, string, string) (api.VMHealthResult, error) { + return api.VMHealthResult{Healthy: true}, nil + } + d.sshExec = func(context.Context, io.Reader, io.Writer, io.Writer, []string) error { return nil } + return d, vm +} + +func TestRunVMRunRefusesBootstrapWithoutNAT(t *testing.T) { + repoRoot := t.TempDir() + if err := os.WriteFile(filepath.Join(repoRoot, ".mise.toml"), []byte(""), 0o600); err != nil { + t.Fatalf("write .mise.toml: %v", err) + } + + d := defaultDeps() + d.vmCreateBegin = func(context.Context, string, api.VMCreateParams) (api.VMCreateBeginResult, error) { + t.Fatal("vmCreateBegin should not be called when NAT precondition refuses") + return api.VMCreateBeginResult{}, nil + } + + repo := vmRunRepo{sourcePath: repoRoot} + var stdout, stderr bytes.Buffer + err := d.runVMRun( + context.Background(), + "/tmp/bangerd.sock", + model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"}, + strings.NewReader(""), + &stdout, &stderr, + api.VMCreateParams{Name: "devbox", NATEnabled: false}, + &repo, + nil, + false, false, false, + ) + if err == nil || !strings.Contains(err.Error(), "tooling bootstrap requires --nat") { + t.Fatalf("runVMRun = %v, want NAT precondition refusal", err) + } +} + +func TestRunVMRunBootstrapPreconditionRespectsNoBootstrap(t *testing.T) { + repoRoot := t.TempDir() + if err := os.WriteFile(filepath.Join(repoRoot, ".mise.toml"), []byte(""), 0o600); err != nil { + t.Fatalf("write .mise.toml: %v", err) + } + + d, _ := runVMRunDepsRunningVM(t) + dialed := false + d.guestDial = func(context.Context, string, string) (vmRunGuestClient, error) { + dialed = true + return &testVMRunGuestClient{}, nil + } + + repo := vmRunRepo{sourcePath: repoRoot} + var stdout, stderr bytes.Buffer + err := d.runVMRun( + context.Background(), + "/tmp/bangerd.sock", + model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"}, + strings.NewReader(""), + &stdout, &stderr, + api.VMCreateParams{Name: "devbox", NATEnabled: false}, + &repo, + nil, + false, false, true, // skipBootstrap = true + ) + if err != nil { + t.Fatalf("runVMRun: %v", err) + } + if dialed { + t.Fatal("guestDial should not be called when --no-bootstrap is set") + } +} + +func TestRunVMRunBootstrapPreconditionPassesWithoutMiseFiles(t *testing.T) { + repoRoot := t.TempDir() // empty repo, no mise files + + d, _ := runVMRunDepsRunningVM(t) + dialed := false + d.guestDial = func(context.Context, string, string) (vmRunGuestClient, error) { + dialed = true + return &testVMRunGuestClient{}, nil + } + + repo := vmRunRepo{sourcePath: repoRoot} + var stdout, stderr bytes.Buffer + err := d.runVMRun( + context.Background(), + "/tmp/bangerd.sock", + model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"}, + strings.NewReader(""), + &stdout, &stderr, + api.VMCreateParams{Name: "devbox", NATEnabled: false}, + &repo, + nil, + false, false, false, + ) + if err != nil { + t.Fatalf("runVMRun: %v", err) + } + // Bootstrap dispatch happens (no mise file gating) but dial still + // gets called because the harness pipeline runs. + if !dialed { + t.Fatal("guestDial should be called for bootstrap dispatch") + } +} + +func TestRunVMRunDetachSkipsSshAttach(t *testing.T) { + d, _ := runVMRunDepsRunningVM(t) + d.guestDial = func(context.Context, string, string) (vmRunGuestClient, error) { + return &testVMRunGuestClient{}, nil + } + sshExecCalls := 0 + d.sshExec = func(context.Context, io.Reader, io.Writer, io.Writer, []string) error { + sshExecCalls++ + return nil + } + + var stdout, stderr bytes.Buffer + err := d.runVMRun( + context.Background(), + "/tmp/bangerd.sock", + model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"}, + strings.NewReader(""), + &stdout, &stderr, + api.VMCreateParams{Name: "devbox"}, + nil, // bare mode + nil, // no command + false, true, false, // detach = true + ) + if err != nil { + t.Fatalf("runVMRun: %v", err) + } + if sshExecCalls != 0 { + t.Fatalf("sshExec called %d times, want 0 in detach mode", sshExecCalls) + } + if !strings.Contains(stderr.String(), "reconnect with: banger vm ssh devbox") { + t.Fatalf("stderr = %q, want reconnect hint", stderr.String()) + } +} + +func TestRunVMRunDetachUsesSyncBootstrapPath(t *testing.T) { + repoRoot := t.TempDir() + + d, _ := runVMRunDepsRunningVM(t) + fakeClient := &testVMRunGuestClient{} + d.guestDial = func(context.Context, string, string) (vmRunGuestClient, error) { + return fakeClient, nil + } + sshExecCalls := 0 + d.sshExec = func(context.Context, io.Reader, io.Writer, io.Writer, []string) error { + sshExecCalls++ + return nil + } + + repo := vmRunRepo{sourcePath: repoRoot} + var stdout, stderr bytes.Buffer + err := d.runVMRun( + context.Background(), + "/tmp/bangerd.sock", + model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"}, + strings.NewReader(""), + &stdout, &stderr, + api.VMCreateParams{Name: "devbox", NATEnabled: true}, + &repo, + nil, + false, true, false, // detach = true + ) + if err != nil { + t.Fatalf("runVMRun: %v", err) + } + if sshExecCalls != 0 { + t.Fatalf("sshExec called %d times, want 0 in detach mode", sshExecCalls) + } + if len(fakeClient.uploads) != 1 { + t.Fatalf("uploads = %d, want 1 (harness upload)", len(fakeClient.uploads)) + } + // Sync mode should invoke the tee'd wrapper, not the nohup launcher. + if strings.Contains(fakeClient.launchScript, "nohup") { + t.Fatalf("detach mode should not use nohup launcher; got: %q", fakeClient.launchScript) + } + if !strings.Contains(fakeClient.launchScript, "tee") { + t.Fatalf("detach mode should tee output to log; got: %q", fakeClient.launchScript) + } +}