diff --git a/README.md b/README.md index 3f1273f..a8b0c5d 100644 --- a/README.md +++ b/README.md @@ -46,21 +46,16 @@ 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 `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. +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. In **command mode** (`-- `), the exit code propagates through -`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 `. +`banger`. ### Other VM verbs diff --git a/internal/cli/banger.go b/internal/cli/banger.go index a9d4e80..281325a 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -71,8 +71,7 @@ to diagnose host readiness problems. } func (d *deps) newDoctorCommand() *cobra.Command { - var verbose bool - cmd := &cobra.Command{ + return &cobra.Command{ Use: "doctor", Short: "Check host and runtime readiness", Long: strings.TrimSpace(` @@ -86,10 +85,8 @@ Run 'banger doctor': - after upgrading the host kernel or banger itself - when 'banger vm run' fails with an unclear error -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. +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 { @@ -97,7 +94,7 @@ check fails. Warnings are reported but do not fail the run. if err != nil { return err } - if err := printDoctorReport(cmd.OutOrStdout(), report, verbose); err != nil { + if err := printDoctorReport(cmd.OutOrStdout(), report); err != nil { return err } if report.HasFailures() { @@ -106,8 +103,6 @@ check fails. Warnings are reported but 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 a5fedfa..ed2ab59 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -133,15 +133,12 @@ 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, brief default should hide PASS rows", output) + if !strings.Contains(output, "PASS\truntime bundle") { + t.Fatalf("output = %q, want runtime bundle pass", 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) { @@ -1324,8 +1321,6 @@ func TestRunVMRunWorkspacePreparesAndAttaches(t *testing.T) { &repo, nil, false, - false, - false, ) if err != nil { t.Fatalf("d.runVMRun: %v", err) @@ -1402,8 +1397,6 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) { &repo, nil, false, - false, - false, ) if err != nil { t.Fatalf("d.runVMRun: %v", err) @@ -1479,8 +1472,6 @@ func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) { &repo, nil, false, - false, - false, ) if err != nil { t.Fatalf("d.runVMRun: %v", err) @@ -1532,8 +1523,6 @@ func TestRunVMRunBareModeSkipsWorkspaceAndTooling(t *testing.T) { nil, nil, false, - false, - false, ) if err != nil { t.Fatalf("d.runVMRun: %v", err) @@ -1577,9 +1566,7 @@ func TestRunVMRunRMDeletesAfterSessionExits(t *testing.T) { api.VMCreateParams{Name: "tmpbox"}, nil, nil, - true, // --rm, - false, - false, + true, // --rm ) if err != nil { t.Fatalf("d.runVMRun: %v", err) @@ -1629,9 +1616,7 @@ func TestRunVMRunRMSkipsDeleteOnSSHWaitTimeout(t *testing.T) { api.VMCreateParams{Name: "slowvm"}, nil, nil, - true, // --rm, - false, - false, + true, // --rm ) if err == nil { t.Fatal("want timeout error") @@ -1674,8 +1659,6 @@ func TestRunVMRunSSHTimeoutReturnsActionableError(t *testing.T) { nil, nil, false, - false, - false, ) if err == nil { t.Fatal("want timeout error") @@ -1725,8 +1708,6 @@ 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 e5c38c0..8228a5b 100644 --- a/internal/cli/commands_vm.go +++ b/internal/cli/commands_vm.go @@ -91,8 +91,6 @@ func (d *deps) newVMRunCommand() *cobra.Command { removeOnExit bool includeUntracked bool dryRun bool - detach bool - skipBootstrap bool ) cmd := &cobra.Command{ Use: "run [path] [-- command args...]", @@ -100,24 +98,16 @@ func (d *deps) newVMRunCommand() *cobra.Command { Long: strings.TrimSpace(` Create a sandbox VM and either drop into an interactive shell or run a command. -Modes: +Three 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 { @@ -139,12 +129,6 @@ Tooling bootstrap (workspace mode): 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 != "" { @@ -190,7 +174,7 @@ Tooling bootstrap (workspace mode): if err != nil { return err } - return d.runVMRun(cmd.Context(), layout.SocketPath, cfg, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), params, repoPtr, commandArgs, removeOnExit, detach, skipBootstrap) + return d.runVMRun(cmd.Context(), layout.SocketPath, cfg, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), params, repoPtr, commandArgs, removeOnExit) }, } cmd.Flags().StringVar(&name, "name", "", "vm name") @@ -205,8 +189,6 @@ Tooling bootstrap (workspace mode): 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 afedbc8..d4ea646 100644 --- a/internal/cli/printers.go +++ b/internal/cli/printers.go @@ -272,34 +272,9 @@ func printKernelCatalogTable(out anyWriter, entries []api.KernelCatalogEntry) er // -- doctor printer ------------------------------------------------- -func printDoctorReport(out anyWriter, report system.Report, verbose bool) error { +func printDoctorReport(out anyWriter, report system.Report) 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 { @@ -320,19 +295,5 @@ func printDoctorReport(out anyWriter, report system.Report, verbose bool) 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 deleted file mode 100644 index 3018ca8..0000000 --- a/internal/cli/printers_test.go +++ /dev/null @@ -1,88 +0,0 @@ -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 3bd9285..1b8b182 100644 --- a/internal/cli/vm_run.go +++ b/internal/cli/vm_run.go @@ -114,23 +114,6 @@ 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. @@ -149,16 +132,7 @@ 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, 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)") - } - } +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 { progress := newVMRunProgressRenderer(stderr) vm, err := d.runVMCreate(ctx, socketPath, stderr, params) if err != nil { @@ -240,21 +214,17 @@ 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 && !skipBootstrap { + if len(command) == 0 { 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, detach, stderr); err != nil { + if err := d.startVMRunToolingHarness(ctx, client, prepared.Workspace.RepoRoot, prepared.Workspace.RepoName, progress); 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) @@ -290,13 +260,7 @@ 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. -// -// 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 { +func (d *deps) startVMRunToolingHarness(ctx context.Context, client vmRunGuestClient, repoRoot, repoName string, progress *vmRunProgressRenderer) error { if progress != nil { progress.render("starting guest tooling bootstrap") } @@ -305,20 +269,6 @@ 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()) @@ -417,20 +367,6 @@ 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 deleted file mode 100644 index 978b111..0000000 --- a/internal/cli/vm_run_test.go +++ /dev/null @@ -1,278 +0,0 @@ -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 3a96acf..f15e83c 100644 --- a/internal/firecracker/client.go +++ b/internal/firecracker/client.go @@ -276,6 +276,7 @@ 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 152f3c8..4b2a7cc 100644 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -66,8 +66,6 @@ SMOKE_SCENARIOS=( include_untracked workspace_export concurrent_run - detach_run - bootstrap_precondition vm_lifecycle vm_set vm_restart @@ -99,8 +97,6 @@ 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" @@ -132,8 +128,6 @@ 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 @@ -522,72 +516,6 @@ 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