From 09a3ef812f0e5bcc65963e306e533ee564b795f4 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Fri, 1 May 2026 14:18:04 -0300 Subject: [PATCH 1/4] style: gofmt internal/firecracker/client.go Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/firecracker/client.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/firecracker/client.go b/internal/firecracker/client.go index f15e83c..3a96acf 100644 --- a/internal/firecracker/client.go +++ b/internal/firecracker/client.go @@ -276,7 +276,6 @@ func defaultDriveID(drive DriveConfig, fallback string) string { // the configured UID:GID) — see fcproc.PrepareJailerChroot. The SDK's own // JailerCfg path is intentionally bypassed: it cannot mknod block devices and // does not expose --new-pid-ns. -// func buildProcessRunner(cfg MachineConfig, logFile *os.File) *exec.Cmd { var bin string var args []string From 9b5cbed32de4b4985ab7b2c8a00f596e514ab924 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Fri, 1 May 2026 14:18:09 -0300 Subject: [PATCH 2/4] doctor: collapse healthy output to one line, add --verbose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A healthy host triggered ~20 PASS rows with details — too noisy for the common case. Default now prints only fail/warn rows plus a summary footer; an all-pass run collapses to a single line. Pass --verbose / -v for the full per-check output. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/cli/banger.go | 13 ++++-- internal/cli/cli_test.go | 7 ++- internal/cli/printers.go | 41 +++++++++++++++- internal/cli/printers_test.go | 88 +++++++++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 internal/cli/printers_test.go diff --git a/internal/cli/banger.go b/internal/cli/banger.go index 281325a..a9d4e80 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -71,7 +71,8 @@ to diagnose host readiness problems. } func (d *deps) newDoctorCommand() *cobra.Command { - return &cobra.Command{ + var verbose bool + cmd := &cobra.Command{ Use: "doctor", Short: "Check host and runtime readiness", Long: strings.TrimSpace(` @@ -85,8 +86,10 @@ Run 'banger doctor': - after upgrading the host kernel or banger itself - when 'banger vm run' fails with an unclear error -Exit code is non-zero if any check fails. Warnings are reported but -do not fail the run. +By default, prints failing and warning checks only and a summary +footer; a healthy host collapses to a single line. Pass --verbose to +print every check with its details. Exit code is non-zero if any +check fails. Warnings are reported but do not fail the run. `), Args: noArgsUsage("usage: banger doctor"), RunE: func(cmd *cobra.Command, args []string) error { @@ -94,7 +97,7 @@ do not fail the run. if err != nil { return err } - if err := printDoctorReport(cmd.OutOrStdout(), report); err != nil { + if err := printDoctorReport(cmd.OutOrStdout(), report, verbose); err != nil { return err } if report.HasFailures() { @@ -103,6 +106,8 @@ do not fail the run. return nil }, } + cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "show every check (default: only failures and warnings)") + return cmd } func newVersionCommand() *cobra.Command { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index ed2ab59..d67ddef 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -133,12 +133,15 @@ func TestDoctorCommandPrintsReportAndFailsOnHardFailures(t *testing.T) { t.Fatalf("Execute() error = %v, want doctor failure", err) } output := stdout.String() - if !strings.Contains(output, "PASS\truntime bundle") { - t.Fatalf("output = %q, want runtime bundle pass", output) + if strings.Contains(output, "PASS\truntime bundle") { + t.Fatalf("output = %q, brief default should hide PASS rows", output) } if !strings.Contains(output, "FAIL\tfeature nat") { t.Fatalf("output = %q, want feature nat fail", output) } + if !strings.Contains(output, "1 passed, 0 warnings, 1 failure") { + t.Fatalf("output = %q, want summary footer", output) + } } func TestDoctorCommandReturnsUnderlyingError(t *testing.T) { diff --git a/internal/cli/printers.go b/internal/cli/printers.go index d4ea646..afedbc8 100644 --- a/internal/cli/printers.go +++ b/internal/cli/printers.go @@ -272,9 +272,34 @@ func printKernelCatalogTable(out anyWriter, entries []api.KernelCatalogEntry) er // -- doctor printer ------------------------------------------------- -func printDoctorReport(out anyWriter, report system.Report) error { +func printDoctorReport(out anyWriter, report system.Report, verbose bool) error { colorWriter, _ := out.(io.Writer) + + var passes, warns, fails int + for _, c := range report.Checks { + switch c.Status { + case system.CheckStatusPass: + passes++ + case system.CheckStatusWarn: + warns++ + case system.CheckStatusFail: + fails++ + } + } + + if !verbose && warns == 0 && fails == 0 { + msg := fmt.Sprintf("all %d checks passed", passes) + if colorWriter != nil { + msg = style.Pass(colorWriter, msg) + } + _, err := fmt.Fprintln(out, msg) + return err + } + for _, check := range report.Checks { + if !verbose && check.Status == system.CheckStatusPass { + continue + } status := strings.ToUpper(string(check.Status)) if colorWriter != nil { switch check.Status { @@ -295,5 +320,19 @@ func printDoctorReport(out anyWriter, report system.Report) error { } } } + + if !verbose { + if _, err := fmt.Fprintf(out, "\n%d passed, %s, %s\n", passes, pluralCount(warns, "warning"), pluralCount(fails, "failure")); err != nil { + return err + } + } + return nil } + +func pluralCount(n int, word string) string { + if n == 1 { + return fmt.Sprintf("%d %s", n, word) + } + return fmt.Sprintf("%d %ss", n, word) +} diff --git a/internal/cli/printers_test.go b/internal/cli/printers_test.go new file mode 100644 index 0000000..3018ca8 --- /dev/null +++ b/internal/cli/printers_test.go @@ -0,0 +1,88 @@ +package cli + +import ( + "bytes" + "strings" + "testing" + + "banger/internal/system" +) + +func TestPrintDoctorReport_BriefAllPass(t *testing.T) { + report := system.Report{} + report.AddPass("first", "detail one") + report.AddPass("second", "detail two") + report.AddPass("third") + + var buf bytes.Buffer + if err := printDoctorReport(&buf, report, false); err != nil { + t.Fatalf("printDoctorReport: %v", err) + } + + got := buf.String() + want := "all 3 checks passed\n" + if got != want { + t.Fatalf("brief all-pass output\n got: %q\nwant: %q", got, want) + } +} + +func TestPrintDoctorReport_BriefHidesPassDetails(t *testing.T) { + report := system.Report{} + report.AddPass("first", "detail one") + report.AddWarn("second", "warn detail") + report.AddPass("third", "detail three") + report.AddFail("fourth", "fail detail") + + var buf bytes.Buffer + if err := printDoctorReport(&buf, report, false); err != nil { + t.Fatalf("printDoctorReport: %v", err) + } + + got := buf.String() + if strings.Contains(got, "PASS") || strings.Contains(got, "first") || strings.Contains(got, "third") { + t.Fatalf("brief mode leaked PASS rows: %q", got) + } + for _, want := range []string{"WARN\tsecond", "warn detail", "FAIL\tfourth", "fail detail"} { + if !strings.Contains(got, want) { + t.Fatalf("missing %q in brief output: %q", want, got) + } + } + if !strings.Contains(got, "2 passed, 1 warning, 1 failure") { + t.Fatalf("missing summary footer in: %q", got) + } +} + +func TestPrintDoctorReport_BriefSummaryPlurals(t *testing.T) { + report := system.Report{} + report.AddPass("a") + report.AddWarn("b") + report.AddWarn("c") + + var buf bytes.Buffer + if err := printDoctorReport(&buf, report, false); err != nil { + t.Fatalf("printDoctorReport: %v", err) + } + if !strings.Contains(buf.String(), "1 passed, 2 warnings, 0 failures") { + t.Fatalf("plural counts wrong: %q", buf.String()) + } +} + +func TestPrintDoctorReport_VerboseShowsEverything(t *testing.T) { + report := system.Report{} + report.AddPass("first", "detail one") + report.AddWarn("second", "warn detail") + + var buf bytes.Buffer + if err := printDoctorReport(&buf, report, true); err != nil { + t.Fatalf("printDoctorReport: %v", err) + } + got := buf.String() + for _, want := range []string{"PASS\tfirst", "detail one", "WARN\tsecond", "warn detail"} { + if !strings.Contains(got, want) { + t.Fatalf("verbose mode missing %q: %q", want, got) + } + } + if strings.Contains(got, "passed,") { + t.Fatalf("verbose mode should not print summary footer: %q", got) + } +} From aaf49fc1b1a825626fe7324e81e2271336b21aa4 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Fri, 1 May 2026 14:51:16 -0300 Subject: [PATCH 3/4] vm run: add -d/--detach + transparent tooling bootstrap The mise tooling bootstrap was failing silently when --nat wasn't set: the VM came up, the user landed in ssh, and tools were missing with no obvious cause. Two coupled fixes: * `-d`/`--detach`: create + prep + bootstrap, exit without attaching to ssh. Reconnect later with `banger vm ssh `. Rejects the ambiguous combos `-d --rm` and `-d -- `. * NAT precondition: when the workspace has a .mise.toml or .tool-versions, vm run now refuses before VM creation if --nat isn't set. Error message points at --nat or --no-bootstrap. * `--no-bootstrap`: explicit opt-out for users who want a vanilla VM with their workspace and no tooling install. Detached bootstrap runs synchronously (foreground tee'd to the log file) so the CLI only returns once installs finish. Interactive mode keeps today's nohup'd background behaviour so the ssh session starts promptly. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 15 +- internal/cli/cli_test.go | 20 ++- internal/cli/commands_vm.go | 22 ++- internal/cli/vm_run.go | 72 +++++++++- internal/cli/vm_run_test.go | 278 ++++++++++++++++++++++++++++++++++++ 5 files changed, 394 insertions(+), 13 deletions(-) create mode 100644 internal/cli/vm_run_test.go 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) + } +} From b9b3505e340ae422b089d8b8a27c92eeb4e71db3 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Fri, 1 May 2026 15:05:27 -0300 Subject: [PATCH 4/4] smoke: cover -d/--detach and bootstrap NAT precondition Two new pure scenarios: * detach_run: -d --rm and -d -- combos rejected before VM creation; bare -d leaves the VM running and ssh-able afterward. * bootstrap_precondition: workspace with a .mise.toml is refused without --nat; --no-bootstrap bypasses the precondition and the run completes normally. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/smoke.sh | 72 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 4b2a7cc..152f3c8 100644 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -66,6 +66,8 @@ SMOKE_SCENARIOS=( include_untracked workspace_export concurrent_run + detach_run + bootstrap_precondition vm_lifecycle vm_set vm_restart @@ -97,6 +99,8 @@ declare -A SMOKE_DESCS=( [include_untracked]="--include-untracked ships files outside the git index" [workspace_export]="workspace export round-trip: guest edit -> patch marker" [concurrent_run]="two parallel --rm invocations both succeed" + [detach_run]="vm run -d: --rm/--cmd combos rejected; -d leaves VM running and ssh-able" + [bootstrap_precondition]="workspace with .mise.toml refused without --nat; --no-bootstrap bypasses" [vm_lifecycle]="explicit create / stop / start / ssh / delete" [vm_set]="reconfigure vcpu while stopped; guest sees new count" [vm_restart]="restart verb: boot_id changes" @@ -128,6 +132,8 @@ declare -A SMOKE_CLASS=( [include_untracked]=repodir [workspace_export]=repodir [concurrent_run]=pure + [detach_run]=pure + [bootstrap_precondition]=pure [vm_lifecycle]=pure [vm_set]=pure [vm_restart]=pure @@ -516,6 +522,72 @@ scenario_concurrent_run() { grep -q 'smoke-concurrent-b' "$tmpB" || die "concurrent VM B missing marker: $(cat "$tmpB")" } +scenario_detach_run() { + log "${SMOKE_DESCS[detach_run]}" + local rc + + set +e + "$BANGER" vm run -d --rm 2>/dev/null + rc=$? + set -e + [[ "$rc" -ne 0 ]] || die "detach: -d --rm should be rejected before VM creation" + + set +e + "$BANGER" vm run -d -- echo hi 2>/dev/null + rc=$? + set -e + [[ "$rc" -ne 0 ]] || die "detach: -d -- should be rejected before VM creation" + + local detach_name=smoke-detach + "$BANGER" vm run -d --name "$detach_name" >/dev/null \ + || die "detach: vm run -d --name $detach_name failed" + + local show_out + show_out="$("$BANGER" vm show "$detach_name")" \ + || die "detach: vm show after -d failed" + grep -q '"state": "running"' <<<"$show_out" \ + || die "detach: VM not running after -d: $show_out" + + local ssh_out + ssh_out="$("$BANGER" vm ssh "$detach_name" -- echo detach-marker)" \ + || die "detach: post-detach ssh failed" + grep -q 'detach-marker' <<<"$ssh_out" \ + || die "detach: ssh missing marker: $ssh_out" + + "$BANGER" vm delete "$detach_name" >/dev/null \ + || die "detach: cleanup vm delete failed" +} + +scenario_bootstrap_precondition() { + log "${SMOKE_DESCS[bootstrap_precondition]}" + local mise_repo="$runtime_dir/smoke-mise-repo" + rm -rf "$mise_repo" + mkdir -p "$mise_repo" + ( + cd "$mise_repo" + git init -q + git -c user.email=smoke@banger -c user.name=smoke commit --allow-empty -q -m init + printf '[tools]\n' > .mise.toml + git add .mise.toml + git -c user.email=smoke@banger -c user.name=smoke commit -q -m 'add mise' + ) + + local rc + set +e + "$BANGER" vm run --rm "$mise_repo" -- echo nope 2>/dev/null + rc=$? + set -e + [[ "$rc" -ne 0 ]] || die "bootstrap: workspace with .mise.toml should refuse without --nat / --no-bootstrap" + + local nb_out + nb_out="$("$BANGER" vm run --rm --no-bootstrap "$mise_repo" -- echo no-bootstrap-ok)" \ + || die "bootstrap: --no-bootstrap should bypass NAT precondition" + grep -q 'no-bootstrap-ok' <<<"$nb_out" \ + || die "bootstrap: --no-bootstrap output missing marker: $nb_out" + + rm -rf "$mise_repo" +} + scenario_vm_lifecycle() { log "${SMOKE_DESCS[vm_lifecycle]}" local lifecycle_name=smoke-lifecycle