Compare commits
No commits in common. "b9b3505e340ae422b089d8b8a27c92eeb4e71db3" and "59e58878ef89025fde2ac06136543f5c7b0370f5" have entirely different histories.
b9b3505e34
...
59e58878ef
10 changed files with 21 additions and 608 deletions
15
README.md
15
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** (`-- <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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue