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>
This commit is contained in:
parent
9b5cbed32d
commit
aaf49fc1b1
5 changed files with 394 additions and 13 deletions
15
README.md
15
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** (`-- <cmd>`), 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 <name>`.
|
||||
|
||||
### Other VM verbs
|
||||
|
||||
|
|
|
|||
|
|
@ -1324,6 +1324,8 @@ func TestRunVMRunWorkspacePreparesAndAttaches(t *testing.T) {
|
|||
&repo,
|
||||
nil,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("d.runVMRun: %v", err)
|
||||
|
|
@ -1400,6 +1402,8 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
|
|||
&repo,
|
||||
nil,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("d.runVMRun: %v", err)
|
||||
|
|
@ -1475,6 +1479,8 @@ func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
|
|||
&repo,
|
||||
nil,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("d.runVMRun: %v", err)
|
||||
|
|
@ -1526,6 +1532,8 @@ func TestRunVMRunBareModeSkipsWorkspaceAndTooling(t *testing.T) {
|
|||
nil,
|
||||
nil,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("d.runVMRun: %v", err)
|
||||
|
|
@ -1569,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)
|
||||
|
|
@ -1619,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")
|
||||
|
|
@ -1662,6 +1674,8 @@ func TestRunVMRunSSHTimeoutReturnsActionableError(t *testing.T) {
|
|||
nil,
|
||||
nil,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("want timeout error")
|
||||
|
|
@ -1711,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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
278
internal/cli/vm_run_test.go
Normal file
278
internal/cli/vm_run_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue