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>
278 lines
8.2 KiB
Go
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)
|
|
}
|
|
}
|