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/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..a5fedfa 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) { @@ -1321,6 +1324,8 @@ func TestRunVMRunWorkspacePreparesAndAttaches(t *testing.T) { &repo, nil, false, + false, + false, ) if err != nil { t.Fatalf("d.runVMRun: %v", err) @@ -1397,6 +1402,8 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) { &repo, nil, false, + false, + false, ) if err != nil { t.Fatalf("d.runVMRun: %v", err) @@ -1472,6 +1479,8 @@ func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) { &repo, nil, false, + false, + false, ) if err != nil { t.Fatalf("d.runVMRun: %v", err) @@ -1523,6 +1532,8 @@ func TestRunVMRunBareModeSkipsWorkspaceAndTooling(t *testing.T) { nil, nil, false, + false, + false, ) if err != nil { t.Fatalf("d.runVMRun: %v", err) @@ -1566,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) @@ -1616,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") @@ -1659,6 +1674,8 @@ func TestRunVMRunSSHTimeoutReturnsActionableError(t *testing.T) { nil, nil, false, + false, + false, ) if err == nil { t.Fatal("want timeout error") @@ -1708,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/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) + } +} 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) + } +} 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 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