port smoke to go
This commit is contained in:
parent
b0a9d64f4a
commit
9ed44bfd75
20 changed files with 2118 additions and 1573 deletions
|
|
@ -34,10 +34,14 @@ The most common workflow is one command:
|
|||
banger vm run bare sandbox, drops into ssh
|
||||
banger vm run ./repo ships a repo into /root/repo, drops into ssh
|
||||
banger vm run ./repo -- make test ships a repo, runs the command, exits with its status
|
||||
banger vm run --rm -- script.sh --rm: VM auto-deletes when the session/command exits
|
||||
banger vm run --nat ./repo --nat: outbound internet (required when .mise.toml installs tools)
|
||||
banger vm run -d ./repo --nat -d/--detach: prep workspace + bootstrap, exit without ssh
|
||||
|
||||
For a longer-lived VM, use 'banger vm create' to provision and
|
||||
'banger vm ssh <name>' to attach. 'banger ps' lists running VMs;
|
||||
'banger vm list --all' shows stopped ones too.
|
||||
'banger vm list --all' shows stopped ones too. Guests are reachable
|
||||
at <name>.vm from the host once 'banger ssh-config --install' is run.
|
||||
|
||||
First-time setup, in order:
|
||||
sudo banger system install install the systemd services
|
||||
|
|
|
|||
|
|
@ -588,7 +588,7 @@ func TestRunVMCreatePollsUntilDone(t *testing.T) {
|
|||
}
|
||||
|
||||
var stderr bytes.Buffer
|
||||
got, err := d.runVMCreate(context.Background(), "/tmp/bangerd.sock", &stderr, api.VMCreateParams{Name: "devbox"})
|
||||
got, err := d.runVMCreate(context.Background(), "/tmp/bangerd.sock", &stderr, api.VMCreateParams{Name: "devbox"}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("d.runVMCreate: %v", err)
|
||||
}
|
||||
|
|
@ -643,7 +643,7 @@ func TestVMCreateProgressRendererSuppressesDuplicateLines(t *testing.T) {
|
|||
|
||||
func TestVMRunProgressRendererSuppressesDuplicateLines(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
renderer := newVMRunProgressRenderer(&stderr)
|
||||
renderer := newVMRunProgressRenderer(&stderr, true)
|
||||
|
||||
renderer.render("waiting for guest ssh")
|
||||
renderer.render("waiting for guest ssh")
|
||||
|
|
@ -661,6 +661,67 @@ func TestVMRunProgressRendererSuppressesDuplicateLines(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestVMRunProgressRendererInlineRewrites covers the TTY default: each
|
||||
// render call rewrites the same line via \r + clear-to-EOL instead of
|
||||
// emitting a newline, so the user sees one moving status line until
|
||||
// commitLine / clear / the caller's own newline closes it out.
|
||||
func TestVMRunProgressRendererInlineRewrites(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
renderer := &vmRunProgressRenderer{out: &stderr, enabled: true, inline: true}
|
||||
|
||||
renderer.render("waiting for guest ssh")
|
||||
renderer.render("preparing guest workspace")
|
||||
renderer.commitLine("vm devbox running; reconnect with: banger vm ssh devbox")
|
||||
|
||||
got := stderr.String()
|
||||
wantPrefix := "\r\x1b[K[vm run] waiting for guest ssh" +
|
||||
"\r\x1b[K[vm run] preparing guest workspace" +
|
||||
"\r\x1b[K[vm run] vm devbox running; reconnect with: banger vm ssh devbox\n"
|
||||
if got != wantPrefix {
|
||||
t.Fatalf("inline output = %q, want %q", got, wantPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVMRunProgressRendererClearWipesActiveLine guards the path used
|
||||
// before sshExec/runSSHSession: clear() must erase the live inline
|
||||
// line so the next writer (the ssh session, a warning, the user's
|
||||
// command output) starts from column 0 without a trailing status.
|
||||
func TestVMRunProgressRendererClearWipesActiveLine(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
renderer := &vmRunProgressRenderer{out: &stderr, enabled: true, inline: true}
|
||||
|
||||
renderer.render("attaching to guest")
|
||||
renderer.clear()
|
||||
// clear() on an already-cleared renderer is a no-op (active=false).
|
||||
renderer.clear()
|
||||
|
||||
got := stderr.String()
|
||||
want := "\r\x1b[K[vm run] attaching to guest\r\x1b[K"
|
||||
if got != want {
|
||||
t.Fatalf("after clear stderr = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVMCreateProgressRendererInlineRewrites mirrors the vm_run inline
|
||||
// test for the create-side renderer so both progress paths stay in
|
||||
// sync if either is touched in isolation.
|
||||
func TestVMCreateProgressRendererInlineRewrites(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
renderer := &vmCreateProgressRenderer{out: &stderr, enabled: true, inline: true}
|
||||
|
||||
renderer.render(api.VMCreateOperation{Stage: "prepare_work_disk", Detail: "cloning work seed"})
|
||||
renderer.render(api.VMCreateOperation{Stage: "wait_vsock_agent", Detail: "waiting for guest vsock agent"})
|
||||
renderer.clear()
|
||||
|
||||
got := stderr.String()
|
||||
want := "\r\x1b[K[vm create] preparing work disk: cloning work seed" +
|
||||
"\r\x1b[K[vm create] waiting for vsock agent: waiting for guest vsock agent" +
|
||||
"\r\x1b[K"
|
||||
if got != want {
|
||||
t.Fatalf("inline output = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithHeartbeatNoOpForNonTTY(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
called := false
|
||||
|
|
@ -1326,6 +1387,7 @@ func TestRunVMRunWorkspacePreparesAndAttaches(t *testing.T) {
|
|||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("d.runVMRun: %v", err)
|
||||
|
|
@ -1404,6 +1466,7 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
|
|||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("d.runVMRun: %v", err)
|
||||
|
|
@ -1481,6 +1544,7 @@ func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
|
|||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("d.runVMRun: %v", err)
|
||||
|
|
@ -1534,6 +1598,7 @@ func TestRunVMRunBareModeSkipsWorkspaceAndTooling(t *testing.T) {
|
|||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("d.runVMRun: %v", err)
|
||||
|
|
@ -1580,6 +1645,7 @@ func TestRunVMRunRMDeletesAfterSessionExits(t *testing.T) {
|
|||
true, // --rm,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("d.runVMRun: %v", err)
|
||||
|
|
@ -1632,6 +1698,7 @@ func TestRunVMRunRMSkipsDeleteOnSSHWaitTimeout(t *testing.T) {
|
|||
true, // --rm,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("want timeout error")
|
||||
|
|
@ -1676,6 +1743,7 @@ func TestRunVMRunSSHTimeoutReturnsActionableError(t *testing.T) {
|
|||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("want timeout error")
|
||||
|
|
@ -1727,6 +1795,7 @@ func TestRunVMRunCommandModePropagatesExitCode(t *testing.T) {
|
|||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
var exitErr ExitCodeError
|
||||
if !errors.As(err, &exitErr) || exitErr.Code != 7 {
|
||||
|
|
|
|||
|
|
@ -35,8 +35,11 @@ provisions ssh, and drops you into the guest in one command. Use
|
|||
longer-lived VM you'll come back to.
|
||||
|
||||
Quick reference:
|
||||
banger vm run ephemeral sandbox; --rm to delete on exit
|
||||
banger vm run ./repo -- make test ship a repo, run a command, exit
|
||||
banger vm run interactive sandbox (stays alive on disconnect)
|
||||
banger vm run --rm -- script.sh ephemeral: VM auto-deletes on exit
|
||||
banger vm run ./repo -- make test ship a repo, run a command, exit with its status
|
||||
banger vm run --nat ./repo --nat: outbound internet (required for mise bootstrap)
|
||||
banger vm run -d ./repo --nat -d/--detach: prep + bootstrap, exit (no ssh attach)
|
||||
banger vm create --name dev persistent VM; pair with 'vm ssh'
|
||||
banger vm ssh <name> open a shell in a running VM
|
||||
banger vm exec <name> -- make test run a command in the workspace with mise toolchain
|
||||
|
|
@ -45,6 +48,7 @@ Quick reference:
|
|||
banger vm delete <name> stop + remove disks
|
||||
banger ps / banger vm list running / all VMs (use --all)
|
||||
banger vm logs <name> guest console + daemon log
|
||||
banger vm set --nat <name> toggle NAT on an existing VM (--no-nat to remove)
|
||||
banger vm workspace prepare/export ship a repo in, pull diffs back
|
||||
`),
|
||||
Example: strings.TrimSpace(`
|
||||
|
|
@ -93,6 +97,7 @@ func (d *deps) newVMRunCommand() *cobra.Command {
|
|||
dryRun bool
|
||||
detach bool
|
||||
skipBootstrap bool
|
||||
verbose bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "run [path] [-- command args...]",
|
||||
|
|
@ -103,14 +108,33 @@ Create a sandbox VM and either drop into an interactive shell or run a command.
|
|||
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)
|
||||
banger vm run ./repo -- make test workspace + run command, exit with its status
|
||||
banger vm run --rm -- script.sh ephemeral: VM auto-deletes when the session/command exits
|
||||
banger vm run -d ./repo workspace + bootstrap, exit (reconnect with 'vm ssh')
|
||||
|
||||
Workspace mode (path argument):
|
||||
Passing a path copies the repo's git-tracked files into /root/repo
|
||||
inside the guest. Untracked files are skipped by default — pass
|
||||
--include-untracked to ship them too, or --dry-run to preview the
|
||||
file list without creating a VM.
|
||||
|
||||
Outbound internet (--nat):
|
||||
Guests have no internet access by default. Pass --nat to enable
|
||||
host-side MASQUERADE so the VM can reach the public network. NAT is
|
||||
required whenever the workspace declares mise tooling (see below).
|
||||
Toggle on an existing VM with 'banger vm set --nat <name>'.
|
||||
|
||||
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).
|
||||
|
||||
Exit behaviour:
|
||||
In command mode (-- <cmd>), the guest command's exit code propagates
|
||||
through banger. Without --rm, the VM stays alive after the session
|
||||
or command exits — reconnect with 'banger vm ssh <name>'. With --rm,
|
||||
the VM is deleted on exit (stdout/stderr are preserved).
|
||||
`),
|
||||
Args: cobra.ArbitraryArgs,
|
||||
Example: strings.TrimSpace(`
|
||||
|
|
@ -190,7 +214,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, detach, skipBootstrap, verbose)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&name, "name", "", "vm name")
|
||||
|
|
@ -199,14 +223,15 @@ Tooling bootstrap (workspace mode):
|
|||
cmd.Flags().IntVar(&memory, "memory", defaults.MemoryMiB, "memory in MiB")
|
||||
cmd.Flags().StringVar(&systemOverlaySize, "system-overlay-size", model.FormatSizeBytes(defaults.SystemOverlaySizeByte), "system overlay size")
|
||||
cmd.Flags().StringVar(&workDiskSize, "disk-size", model.FormatSizeBytes(defaults.WorkDiskSizeBytes), "work disk size")
|
||||
cmd.Flags().BoolVar(&natEnabled, "nat", false, "enable NAT")
|
||||
cmd.Flags().BoolVar(&natEnabled, "nat", false, "enable outbound internet from the guest (host-side MASQUERADE; required when the workspace declares mise tooling)")
|
||||
cmd.Flags().StringVar(&branchName, "branch", "", "create and switch to a new guest branch")
|
||||
cmd.Flags().StringVar(&fromRef, "from", "HEAD", "git ref to branch from when --branch is set (default: HEAD)")
|
||||
cmd.Flags().BoolVar(&removeOnExit, "rm", false, "delete the VM after the ssh session / command exits")
|
||||
cmd.Flags().BoolVar(&removeOnExit, "rm", false, "ephemeral mode: delete the VM (and its disks) 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().BoolVarP(&detach, "detach", "d", false, "detached mode: create the VM, run workspace prep + bootstrap synchronously, exit without ssh attach (reconnect with 'vm ssh')")
|
||||
cmd.Flags().BoolVar(&skipBootstrap, "no-bootstrap", false, "skip the mise tooling bootstrap (no --nat requirement)")
|
||||
cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "show every progress line instead of a single rewriting status line")
|
||||
_ = cmd.RegisterFlagCompletionFunc("image", d.completeImageNames)
|
||||
return cmd
|
||||
}
|
||||
|
|
@ -370,6 +395,7 @@ func (d *deps) newVMCreateCommand() *cobra.Command {
|
|||
workDiskSize = model.FormatSizeBytes(defaults.WorkDiskSizeBytes)
|
||||
natEnabled bool
|
||||
noStart bool
|
||||
verbose bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "create",
|
||||
|
|
@ -397,7 +423,7 @@ Use 'vm create' for a longer-lived VM you'll come back to. Use
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vm, err := d.runVMCreate(cmd.Context(), layout.SocketPath, cmd.ErrOrStderr(), params)
|
||||
vm, err := d.runVMCreate(cmd.Context(), layout.SocketPath, cmd.ErrOrStderr(), params, verbose)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -410,8 +436,9 @@ Use 'vm create' for a longer-lived VM you'll come back to. Use
|
|||
cmd.Flags().IntVar(&memory, "memory", defaults.MemoryMiB, "memory in MiB")
|
||||
cmd.Flags().StringVar(&systemOverlaySize, "system-overlay-size", model.FormatSizeBytes(defaults.SystemOverlaySizeByte), "system overlay size")
|
||||
cmd.Flags().StringVar(&workDiskSize, "disk-size", model.FormatSizeBytes(defaults.WorkDiskSizeBytes), "work disk size")
|
||||
cmd.Flags().BoolVar(&natEnabled, "nat", false, "enable NAT")
|
||||
cmd.Flags().BoolVar(&natEnabled, "nat", false, "enable outbound internet from the guest (host-side MASQUERADE)")
|
||||
cmd.Flags().BoolVar(&noStart, "no-start", false, "create without starting")
|
||||
cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "show every progress line instead of a single rewriting status line")
|
||||
_ = cmd.RegisterFlagCompletionFunc("image", d.completeImageNames)
|
||||
return cmd
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,14 +61,14 @@ func printVMSpecLine(out io.Writer, params api.VMCreateParams) {
|
|||
// gets the spec line up front and the progress renderer thereafter.
|
||||
// On context cancel we cooperate with the daemon to cancel the
|
||||
// in-flight op so it doesn't leak partially-created VM state.
|
||||
func (d *deps) runVMCreate(ctx context.Context, socketPath string, stderr io.Writer, params api.VMCreateParams) (model.VMRecord, error) {
|
||||
func (d *deps) runVMCreate(ctx context.Context, socketPath string, stderr io.Writer, params api.VMCreateParams, verbose bool) (model.VMRecord, error) {
|
||||
start := time.Now()
|
||||
printVMSpecLine(stderr, params)
|
||||
begin, err := d.vmCreateBegin(ctx, socketPath, params)
|
||||
if err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
renderer := newVMCreateProgressRenderer(stderr)
|
||||
renderer := newVMCreateProgressRenderer(stderr, verbose)
|
||||
renderer.render(begin.Operation)
|
||||
|
||||
op := begin.Operation
|
||||
|
|
@ -76,6 +76,7 @@ func (d *deps) runVMCreate(ctx context.Context, socketPath string, stderr io.Wri
|
|||
if op.Done {
|
||||
renderer.render(op)
|
||||
if op.Success && op.VM != nil {
|
||||
renderer.clear()
|
||||
elapsed := formatVMCreateElapsed(time.Since(start))
|
||||
_, _ = fmt.Fprintf(stderr, "[vm create] ready in %s\n", style.Dim(stderr, elapsed))
|
||||
return *op.VM, nil
|
||||
|
|
@ -113,13 +114,22 @@ func (d *deps) runVMCreate(ctx context.Context, socketPath string, stderr io.Wri
|
|||
type vmCreateProgressRenderer struct {
|
||||
out io.Writer
|
||||
enabled bool
|
||||
inline bool
|
||||
active bool
|
||||
lastLine string
|
||||
}
|
||||
|
||||
func newVMCreateProgressRenderer(out io.Writer) *vmCreateProgressRenderer {
|
||||
// newVMCreateProgressRenderer wires up progress for `vm create`. On
|
||||
// non-TTY writers it stays disabled (CI/test logs already capture the
|
||||
// spec + ready lines); on TTY it rewrites a single line via \r unless
|
||||
// verbose is set or BANGER_NO_PROGRESS is exported, in which case it
|
||||
// falls back to one line per stage.
|
||||
func newVMCreateProgressRenderer(out io.Writer, verbose bool) *vmCreateProgressRenderer {
|
||||
tty := writerSupportsProgress(out)
|
||||
return &vmCreateProgressRenderer{
|
||||
out: out,
|
||||
enabled: writerSupportsProgress(out),
|
||||
enabled: tty,
|
||||
inline: tty && !verbose && !progressDisabledByEnv(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -132,9 +142,32 @@ func (r *vmCreateProgressRenderer) render(op api.VMCreateOperation) {
|
|||
return
|
||||
}
|
||||
r.lastLine = line
|
||||
if r.inline {
|
||||
_, _ = fmt.Fprint(r.out, "\r\x1b[K", line)
|
||||
r.active = true
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprintln(r.out, line)
|
||||
}
|
||||
|
||||
// clear resets the live inline line so the caller can write a clean
|
||||
// terminating message. No-op outside inline mode.
|
||||
func (r *vmCreateProgressRenderer) clear() {
|
||||
if r == nil || !r.enabled || !r.inline || !r.active {
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(r.out, "\r\x1b[K")
|
||||
r.active = false
|
||||
r.lastLine = ""
|
||||
}
|
||||
|
||||
// progressDisabledByEnv is the BANGER_NO_PROGRESS escape hatch — a
|
||||
// non-empty value forces line-per-stage output even on a TTY, so users
|
||||
// can pipe `script(1)` / tmux capture without \r artifacts.
|
||||
func progressDisabledByEnv() bool {
|
||||
return strings.TrimSpace(os.Getenv("BANGER_NO_PROGRESS")) != ""
|
||||
}
|
||||
|
||||
// writerSupportsProgress returns true only when out is a terminal.
|
||||
// Keeps stage lines + heartbeat dots out of piped / logged output
|
||||
// where they'd just be noise.
|
||||
|
|
|
|||
|
|
@ -149,7 +149,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 {
|
||||
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, verbose bool) error {
|
||||
if repo != nil && !skipBootstrap && !params.NATEnabled {
|
||||
hasMise, err := repoHasMiseFiles(repo.sourcePath)
|
||||
if err != nil {
|
||||
|
|
@ -159,8 +159,9 @@ func (d *deps) runVMRun(ctx context.Context, socketPath string, cfg model.Daemon
|
|||
return errors.New("tooling bootstrap requires --nat (or pass --no-bootstrap to skip)")
|
||||
}
|
||||
}
|
||||
progress := newVMRunProgressRenderer(stderr)
|
||||
vm, err := d.runVMCreate(ctx, socketPath, stderr, params)
|
||||
progress := newVMRunProgressRenderer(stderr, verbose)
|
||||
defer progress.clear()
|
||||
vm, err := d.runVMCreate(ctx, socketPath, stderr, params, verbose)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -183,8 +184,10 @@ func (d *deps) runVMRun(ctx context.Context, socketPath string, cfg model.Daemon
|
|||
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := d.vmDelete(cleanupCtx, socketPath, vmRef); err != nil {
|
||||
progress.clear()
|
||||
printVMRunWarning(stderr, fmt.Sprintf("--rm cleanup failed: %v (leaked vm %q; delete manually)", err, vmRef))
|
||||
} else if err := removeUserKnownHosts(vm); err != nil {
|
||||
progress.clear()
|
||||
printVMRunWarning(stderr, fmt.Sprintf("known_hosts cleanup failed: %v", err))
|
||||
}
|
||||
}()
|
||||
|
|
@ -223,6 +226,7 @@ func (d *deps) runVMRun(ctx context.Context, socketPath string, cfg model.Daemon
|
|||
fromRef = repo.fromRef
|
||||
}
|
||||
if !repo.includeUntracked {
|
||||
progress.clear()
|
||||
d.noteUntrackedSkipped(ctx, stderr, repo.sourcePath)
|
||||
}
|
||||
prepared, err := d.vmWorkspacePrepare(ctx, socketPath, api.VMWorkspacePrepareParams{
|
||||
|
|
@ -246,13 +250,14 @@ func (d *deps) runVMRun(ctx context.Context, socketPath string, cfg model.Daemon
|
|||
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 {
|
||||
progress.clear()
|
||||
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))
|
||||
progress.commitLine(fmt.Sprintf("vm %s running; reconnect with: banger vm ssh %s", vmRef, vmRef))
|
||||
return nil
|
||||
}
|
||||
sshArgs, err := sshCommandArgs(cfg, vm.Runtime.GuestIP, command)
|
||||
|
|
@ -261,6 +266,7 @@ func (d *deps) runVMRun(ctx context.Context, socketPath string, cfg model.Daemon
|
|||
}
|
||||
if len(command) > 0 {
|
||||
progress.render("running command in guest")
|
||||
progress.clear()
|
||||
if err := d.sshExec(ctx, stdin, stdout, stderr, sshArgs); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
|
|
@ -271,6 +277,7 @@ func (d *deps) runVMRun(ctx context.Context, socketPath string, cfg model.Daemon
|
|||
return nil
|
||||
}
|
||||
progress.render("attaching to guest")
|
||||
progress.clear()
|
||||
return d.runSSHSession(ctx, socketPath, vmRef, stdin, stdout, stderr, sshArgs, removeOnExit)
|
||||
}
|
||||
|
||||
|
|
@ -442,13 +449,24 @@ func formatVMRunStepError(action string, err error, log string) error {
|
|||
type vmRunProgressRenderer struct {
|
||||
out io.Writer
|
||||
enabled bool
|
||||
inline bool
|
||||
active bool
|
||||
lastLine string
|
||||
}
|
||||
|
||||
func newVMRunProgressRenderer(out io.Writer) *vmRunProgressRenderer {
|
||||
// newVMRunProgressRenderer wires up progress for `vm run`. Unlike the
|
||||
// vm_create renderer, this one emits in line mode even on non-TTY
|
||||
// writers (covers tests and piped output that the existing tooling
|
||||
// already parses); inline mode kicks in only when stderr is a TTY,
|
||||
// verbose is unset, and BANGER_NO_PROGRESS is unset.
|
||||
func newVMRunProgressRenderer(out io.Writer, verbose bool) *vmRunProgressRenderer {
|
||||
if out == nil {
|
||||
return &vmRunProgressRenderer{}
|
||||
}
|
||||
return &vmRunProgressRenderer{
|
||||
out: out,
|
||||
enabled: out != nil,
|
||||
enabled: true,
|
||||
inline: writerSupportsProgress(out) && !verbose && !progressDisabledByEnv(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -461,6 +479,47 @@ func (r *vmRunProgressRenderer) render(detail string) {
|
|||
return
|
||||
}
|
||||
r.lastLine = line
|
||||
if r.inline {
|
||||
_, _ = fmt.Fprint(r.out, "\r\x1b[K", line)
|
||||
r.active = true
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprintln(r.out, line)
|
||||
}
|
||||
|
||||
// clear erases the live inline line so the caller can write a clean
|
||||
// terminating message (warning, ssh attach, command output). No-op
|
||||
// outside inline mode.
|
||||
func (r *vmRunProgressRenderer) clear() {
|
||||
if r == nil || !r.enabled || !r.inline || !r.active {
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(r.out, "\r\x1b[K")
|
||||
r.active = false
|
||||
r.lastLine = ""
|
||||
}
|
||||
|
||||
// commitLine prints detail as a final, persistent line. In inline
|
||||
// mode it overwrites the live status; in line mode it just appends.
|
||||
// Used for terminal messages like the --detach hand-off summary.
|
||||
func (r *vmRunProgressRenderer) commitLine(detail string) {
|
||||
if r == nil || !r.enabled {
|
||||
return
|
||||
}
|
||||
line := formatVMRunProgress(detail)
|
||||
if line == "" {
|
||||
return
|
||||
}
|
||||
if r.inline {
|
||||
_, _ = fmt.Fprint(r.out, "\r\x1b[K", line, "\n")
|
||||
r.active = false
|
||||
r.lastLine = ""
|
||||
return
|
||||
}
|
||||
if line == r.lastLine {
|
||||
return
|
||||
}
|
||||
r.lastLine = line
|
||||
_, _ = fmt.Fprintln(r.out, line)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ func TestRunVMRunRefusesBootstrapWithoutNAT(t *testing.T) {
|
|||
api.VMCreateParams{Name: "devbox", NATEnabled: false},
|
||||
&repo,
|
||||
nil,
|
||||
false, false, false,
|
||||
false, false, false, false,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "tooling bootstrap requires --nat") {
|
||||
t.Fatalf("runVMRun = %v, want NAT precondition refusal", err)
|
||||
|
|
@ -155,7 +155,7 @@ func TestRunVMRunBootstrapPreconditionRespectsNoBootstrap(t *testing.T) {
|
|||
api.VMCreateParams{Name: "devbox", NATEnabled: false},
|
||||
&repo,
|
||||
nil,
|
||||
false, false, true, // skipBootstrap = true
|
||||
false, false, true, false, // skipBootstrap = true
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("runVMRun: %v", err)
|
||||
|
|
@ -186,7 +186,7 @@ func TestRunVMRunBootstrapPreconditionPassesWithoutMiseFiles(t *testing.T) {
|
|||
api.VMCreateParams{Name: "devbox", NATEnabled: false},
|
||||
&repo,
|
||||
nil,
|
||||
false, false, false,
|
||||
false, false, false, false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("runVMRun: %v", err)
|
||||
|
|
@ -219,7 +219,7 @@ func TestRunVMRunDetachSkipsSshAttach(t *testing.T) {
|
|||
api.VMCreateParams{Name: "devbox"},
|
||||
nil, // bare mode
|
||||
nil, // no command
|
||||
false, true, false, // detach = true
|
||||
false, true, false, false, // detach = true
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("runVMRun: %v", err)
|
||||
|
|
@ -257,7 +257,7 @@ func TestRunVMRunDetachUsesSyncBootstrapPath(t *testing.T) {
|
|||
api.VMCreateParams{Name: "devbox", NATEnabled: true},
|
||||
&repo,
|
||||
nil,
|
||||
false, true, false, // detach = true
|
||||
false, true, false, false, // detach = true
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("runVMRun: %v", err)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue