banger/internal/cli/vm_run_test.go
Thales Maciel aaf49fc1b1
vm run: add -d/--detach + transparent tooling bootstrap
The mise tooling bootstrap was failing silently when --nat wasn't
set: the VM came up, the user landed in ssh, and tools were missing
with no obvious cause. Two coupled fixes:

* `-d`/`--detach`: create + prep + bootstrap, exit without attaching
  to ssh. Reconnect later with `banger vm ssh <name>`. Rejects the
  ambiguous combos `-d --rm` and `-d -- <cmd>`.

* NAT precondition: when the workspace has a .mise.toml or
  .tool-versions, vm run now refuses before VM creation if --nat
  isn't set. Error message points at --nat or --no-bootstrap.

* `--no-bootstrap`: explicit opt-out for users who want a vanilla
  VM with their workspace and no tooling install.

Detached bootstrap runs synchronously (foreground tee'd to the log
file) so the CLI only returns once installs finish. Interactive
mode keeps today's nohup'd background behaviour so the ssh session
starts promptly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:51:16 -03:00

278 lines
8.2 KiB
Go

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