Compare commits

..

No commits in common. "b9b3505e340ae422b089d8b8a27c92eeb4e71db3" and "59e58878ef89025fde2ac06136543f5c7b0370f5" have entirely different histories.

10 changed files with 21 additions and 608 deletions

View file

@ -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** (`-- <cmd>`), 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 <name>`.
`banger`.
### Other VM verbs

View file

@ -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 {

View file

@ -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 {

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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 == "" {

View file

@ -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)
}
}

View file

@ -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

View file

@ -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 -- <cmd> 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