Compare commits

..

6 commits
v0.1.9 ... main

Author SHA1 Message Date
ae3466b944
release: prep v0.1.10 changelog
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:08:48 -03:00
3dceacd40a
readme: add demo gif
Recording script committed at assets/demo.tape — renders with
charmbracelet/vhs against a real Linux+Firecracker host.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:08:42 -03:00
f1b17f6f8e
install: surface ssh-config --install in next-steps blurb
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:57:26 -03:00
05439d2325
daemon: cut vm stop latency
Three changes to stopVMLocked, biggest win first:

- Skip waitForExit on the SSH-success path. sync inside the guest
  already flushed root.ext4, so cleanupRuntime's SIGKILL is safe
  immediately. Saves up to gracefulShutdownWait (10s) per stop.
- Drop the SendCtrlAltDel + 10s wait fallback when SSH is
  unreachable. On Debian, ctrl+alt+del routes to reboot.target so
  FC never exits on it — the wait was pure latency.
- Shrink the SSH dial timeout 5s → 2s. A reachable guest dials in
  single-digit milliseconds; if it doesn't, fail fast and SIGKILL.

Worst-case (broken SSH) goes ~15s → ~2s + cleanup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:51:22 -03:00
c352aba50a
daemon: parallelize tap-pool warmup
Pool warmup ran createTap calls sequentially (one per loop iteration),
so warming N taps cold took N times the per-tap cost. Each releaseTap
also fired its own ensureTapPool goroutine, racing on n.tapPool.next.

Reserve a batch of names under the lock, then run up to
maxConcurrentTapWarmup createTap RPCs in parallel — root helper already
handles each connection in its own goroutine, so multiple in-flight
priv.create_tap requests don't contend at the wire level. Add a
warming flag to dedupe concurrent ensureTapPool invocations triggered
by parallel releases.

Bail-on-first-error semantics preserved: if every goroutine in a
batch fails (e.g. host out of taps, kernel limit), the loop exits
rather than burning monotonic indices forever.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:54:07 -03:00
71e073ac49
fix: land .hushlogin on work disk so vm run is quiet
The work disk mounts at /root, so the .hushlogin written to the
rootfs overlay was shadowed and never reached the guest — pam_motd
kept printing the Debian banner on `banger vm run`. Move the write
to the work disk root inode (= /root in the guest) and run it from
PrepareHost so existing VMs pick it up on next start.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:39:46 -03:00
11 changed files with 232 additions and 70 deletions

View file

@ -10,6 +10,16 @@ changed between versions.
## [Unreleased] ## [Unreleased]
## [v0.1.10] - 2026-05-03
### Added
- README now includes an animated demo GIF showing the typical
sandbox lifecycle (`vm run`, host-side `ssh demo.vm`, stop/start
with file persistence, `vm exec`, `curl http://demo.vm`). The
recording script lives at `assets/demo.tape` and is rendered with
[VHS](https://github.com/charmbracelet/vhs).
## [v0.1.9] - 2026-05-01 ## [v0.1.9] - 2026-05-01
### Fixed ### Fixed
@ -302,7 +312,8 @@ root filesystem and network, and exits on demand.
the swap rather than starting up against an incompatible store. the swap rather than starting up against an incompatible store.
- Linux only. amd64 only. KVM required. - Linux only. amd64 only. KVM required.
[Unreleased]: https://git.thaloco.com/thaloco/banger/compare/v0.1.9...HEAD [Unreleased]: https://git.thaloco.com/thaloco/banger/compare/v0.1.10...HEAD
[v0.1.10]: https://git.thaloco.com/thaloco/banger/releases/tag/v0.1.10
[v0.1.9]: https://git.thaloco.com/thaloco/banger/releases/tag/v0.1.9 [v0.1.9]: https://git.thaloco.com/thaloco/banger/releases/tag/v0.1.9
[v0.1.8]: https://git.thaloco.com/thaloco/banger/releases/tag/v0.1.8 [v0.1.8]: https://git.thaloco.com/thaloco/banger/releases/tag/v0.1.8
[v0.1.7]: https://git.thaloco.com/thaloco/banger/releases/tag/v0.1.7 [v0.1.7]: https://git.thaloco.com/thaloco/banger/releases/tag/v0.1.7

View file

@ -2,6 +2,8 @@
One-command development sandboxes on Firecracker microVMs. One-command development sandboxes on Firecracker microVMs.
![banger demo](assets/banger.gif)
Spin up a clean Linux VM with your repo and tooling preloaded, drop Spin up a clean Linux VM with your repo and tooling preloaded, drop
into ssh, and tear it down — all from one command. banger is built into ssh, and tear it down — all from one command. banger is built
for the dev loop, not the server use case: guests are short-lived, for the dev loop, not the server use case: guests are short-lived,

BIN
assets/banger.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

112
assets/demo.tape Normal file
View file

@ -0,0 +1,112 @@
# banger hero demo — VHS tape
# Render with: vhs assets/demo.tape
Output assets/banger.gif
Require banger
Require ssh
Require curl
Set Shell "bash"
Set FontSize 14
Set LineHeight 1.4
Set Width 1200
Set Height 720
Set Padding 20
Set Theme "Catppuccin Frappe"
Set TypingSpeed 66ms
# Off-camera reset: enable bash syntax highlighting via ble.sh, prompt
# styling, drop any prior demo VM, and clear the screen.
Hide
Type "source ~/.local/share/blesh/ble.sh --noattach"
Enter
Sleep 200ms
Type "bleopt complete_auto_complete= complete_auto_history="
Enter
Sleep 100ms
Type `export PS1="\n$PS1"`
Enter
Sleep 200ms
Type "[[ ${BLE_VERSION-} ]] && ble-attach"
Enter
Sleep 400ms
Type "ble-face -s syntax_error fg=red"
Enter
Sleep 100ms
Type "banger vm kill demo 2>/dev/null; banger vm delete demo 2>/dev/null; clear"
Enter
Sleep 500ms
Show
Type "banger vm run --nat --name demo"
Enter
Wait+Line /demo:~#/
Sleep 1.4s
Type "uname -a"
Enter
Sleep 1.4s
Type "exit"
Enter
Wait
Sleep 700ms
Type "banger vm list"
Enter
Wait
Sleep 1.8s
Type "ssh demo.vm"
Enter
Wait+Line /demo:~#/
Sleep 500ms
Type "touch foo bar baz"
Enter
Sleep 700ms
Type "ls"
Enter
Sleep 1.4s
Type "exit"
Enter
Sleep 700ms
Type "banger vm stop demo"
Enter
Wait
Sleep 1s
Type "banger vm start demo"
Enter
Wait
Sleep 1s
Type "banger vm exec demo -- ls"
Enter
Wait
Sleep 1.4s
Type "banger vm exec demo -- docker run -d -p 80:80 nginx"
Enter
Wait
Sleep 1.6s
Type "banger vm ports demo"
Enter
Wait
Sleep 2s
Type "curl http://demo.vm"
Sleep 1.2s
Enter
Wait
Sleep 4s
Type "banger vm kill demo && banger vm delete demo"
Enter
Wait
Sleep 3s

View file

@ -247,6 +247,9 @@ func (c workDiskCapability) PrepareHost(ctx context.Context, vm *model.VMRecord,
if err := c.ws.ensureAuthorizedKeyOnWorkDisk(ctx, vm, image, prep); err != nil { if err := c.ws.ensureAuthorizedKeyOnWorkDisk(ctx, vm, image, prep); err != nil {
return err return err
} }
if err := c.ws.ensureHushLoginOnWorkDisk(ctx, vm); err != nil {
return err
}
if err := c.ws.ensureGitIdentityOnWorkDisk(ctx, vm); err != nil { if err := c.ws.ensureGitIdentityOnWorkDisk(ctx, vm); err != nil {
return err return err
} }

View file

@ -6,6 +6,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
) )
const tapPoolPrefix = "tap-pool-" const tapPoolPrefix = "tap-pool-"
@ -16,8 +17,16 @@ type tapPool struct {
mu sync.Mutex mu sync.Mutex
entries []string entries []string
next int next int
warming bool
} }
// maxConcurrentTapWarmup caps the number of `priv.create_tap` RPCs the
// warmup loop runs in parallel. Each tap creation is ~4 root-helper
// shell-outs serialized within one RPC handler; running too many at
// once just contends on netlink. 8 is the production sweet spot for
// SMOKE_JOBS=8.
const maxConcurrentTapWarmup = 8
// initializeTapPool seeds the monotonic pool index from the set of // initializeTapPool seeds the monotonic pool index from the set of
// tap names already in use by running/stopped VMs, so newly warmed // tap names already in use by running/stopped VMs, so newly warmed
// pool entries don't collide with existing ones. Callers (Daemon.Open) // pool entries don't collide with existing ones. Callers (Daemon.Open)
@ -41,6 +50,23 @@ func (n *HostNetwork) ensureTapPool(ctx context.Context) {
if n.config.TapPoolSize <= 0 { if n.config.TapPoolSize <= 0 {
return return
} }
// Dedupe concurrent warmup invocations. Releases trigger a fresh
// ensureTapPool in a goroutine; without this, N parallel releases
// would each spin up their own warmup loop racing on n.tapPool.next.
n.tapPool.mu.Lock()
if n.tapPool.warming {
n.tapPool.mu.Unlock()
return
}
n.tapPool.warming = true
n.tapPool.mu.Unlock()
defer func() {
n.tapPool.mu.Lock()
n.tapPool.warming = false
n.tapPool.mu.Unlock()
}()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@ -51,27 +77,53 @@ func (n *HostNetwork) ensureTapPool(ctx context.Context) {
} }
n.tapPool.mu.Lock() n.tapPool.mu.Lock()
if len(n.tapPool.entries) >= n.config.TapPoolSize { deficit := n.config.TapPoolSize - len(n.tapPool.entries)
if deficit <= 0 {
n.tapPool.mu.Unlock() n.tapPool.mu.Unlock()
return return
} }
tapName := fmt.Sprintf("%s%d", tapPoolPrefix, n.tapPool.next) batch := deficit
n.tapPool.next++ if batch > maxConcurrentTapWarmup {
n.tapPool.mu.Unlock() batch = maxConcurrentTapWarmup
}
if err := n.createTap(ctx, tapName); err != nil { // Reserve names up front so concurrent goroutines can't collide
if n.logger != nil { // on n.tapPool.next.
n.logger.Warn("tap pool warmup failed", "tap_device", tapName, "error", err.Error()) names := make([]string, batch)
} for i := range names {
return names[i] = fmt.Sprintf("%s%d", tapPoolPrefix, n.tapPool.next)
n.tapPool.next++
} }
n.tapPool.mu.Lock()
n.tapPool.entries = append(n.tapPool.entries, tapName)
n.tapPool.mu.Unlock() n.tapPool.mu.Unlock()
if n.logger != nil { var (
n.logger.Debug("tap added to idle pool", "tap_device", tapName) wg sync.WaitGroup
progress atomic.Int32
)
for _, tapName := range names {
wg.Add(1)
go func(tapName string) {
defer wg.Done()
if err := n.createTap(ctx, tapName); err != nil {
if n.logger != nil {
n.logger.Warn("tap pool warmup failed", "tap_device", tapName, "error", err.Error())
}
return
}
n.tapPool.mu.Lock()
n.tapPool.entries = append(n.tapPool.entries, tapName)
n.tapPool.mu.Unlock()
progress.Add(1)
if n.logger != nil {
n.logger.Debug("tap added to idle pool", "tap_device", tapName)
}
}(tapName)
}
wg.Wait()
// Whole batch failed → bail rather than burn names indefinitely
// (the original sequential loop bailed on first error too).
if progress.Load() == 0 {
return
} }
} }
} }

View file

@ -86,6 +86,15 @@ func provisionAuthorizedKey(ctx context.Context, runner system.CommandRunner, im
return system.WriteExt4FileOwned(ctx, runner, imagePath, "/.ssh/authorized_keys", 0o600, 0, 0, merged) return system.WriteExt4FileOwned(ctx, runner, imagePath, "/.ssh/authorized_keys", 0o600, 0, 0, merged)
} }
// ensureHushLoginOnWorkDisk lands /root/.hushlogin in the guest by
// writing /.hushlogin at the root of the work disk (which mounts at
// /root inside the guest). pam_motd checks $HOME/.hushlogin and stays
// silent when it exists — combined with sshd's PrintMotd no / PrintLastLog no
// that suppresses the Debian-style banner on `banger vm run`.
func (s *WorkspaceService) ensureHushLoginOnWorkDisk(ctx context.Context, vm *model.VMRecord) error {
return system.WriteExt4FileOwned(ctx, s.runner, vm.Runtime.WorkDiskPath, "/.hushlogin", 0o644, 0, 0, nil)
}
func (s *WorkspaceService) ensureGitIdentityOnWorkDisk(ctx context.Context, vm *model.VMRecord) error { func (s *WorkspaceService) ensureGitIdentityOnWorkDisk(ctx context.Context, vm *model.VMRecord) error {
runner := s.runner runner := s.runner
if runner == nil { if runner == nil {

View file

@ -50,11 +50,6 @@ func (s *VMService) patchRootOverlay(ctx context.Context, vm model.VMRecord, ima
builder.WriteFile(guestnet.ConfigPath, guestnet.ConfigFile(vm.Runtime.GuestIP, s.config.BridgeIP, s.config.DefaultDNS)) builder.WriteFile(guestnet.ConfigPath, guestnet.ConfigFile(vm.Runtime.GuestIP, s.config.BridgeIP, s.config.DefaultDNS))
builder.WriteFile(guestnet.GuestScriptPath, []byte(guestnet.BootstrapScript())) builder.WriteFile(guestnet.GuestScriptPath, []byte(guestnet.BootstrapScript()))
builder.WriteFile("/etc/ssh/sshd_config.d/99-banger.conf", sshdConfig) builder.WriteFile("/etc/ssh/sshd_config.d/99-banger.conf", sshdConfig)
// pam_motd reads /etc/motd + /etc/update-motd.d on Debian-family
// guests independent of sshd's PrintMotd. .hushlogin in $HOME tells
// pam_motd to stay quiet for that user — root is the only login on
// banger VMs, so a single file suffices.
builder.WriteFile("/root/.hushlogin", []byte{})
builder.DropMountTarget("/home") builder.DropMountTarget("/home")
builder.DropMountTarget("/var") builder.DropMountTarget("/var")
builder.AddMount(guestconfig.MountSpec{ builder.AddMount(guestconfig.MountSpec{
@ -169,9 +164,11 @@ func (s *VMService) ensureWorkDisk(ctx context.Context, vm *model.VMRecord, imag
// Banger VMs are short-lived sandboxes. The Debian-style MOTD // Banger VMs are short-lived sandboxes. The Debian-style MOTD
// ("Linux ... GNU/Linux comes with ABSOLUTELY NO WARRANTY …") and // ("Linux ... GNU/Linux comes with ABSOLUTELY NO WARRANTY …") and
// the "Last login" line are pure noise for `vm run -- echo hi` // the "Last login" line are pure noise for `vm run -- echo hi`
// style invocations. Pair this with the .hushlogin written below // style invocations. Pair this with the .hushlogin landed on the
// so pam_motd also stays silent on distros that read /etc/motd // work disk (see ensureHushLoginOnWorkDisk) so pam_motd also stays
// through PAM rather than sshd. // silent on distros that read /etc/motd through PAM rather than
// sshd. The work disk mounts at /root, so the file has to live on
// that disk — a write to the rootfs overlay would be shadowed.
func sshdGuestConfig() string { func sshdGuestConfig() string {
return strings.Join([]string{ return strings.Join([]string{
"PermitRootLogin prohibit-password", "PermitRootLogin prohibit-password",

View file

@ -131,44 +131,27 @@ func (s *VMService) stopVMLocked(ctx context.Context, current model.VMRecord) (v
} }
return vm, nil return vm, nil
} }
pid := s.vmHandles(vm.ID).PID
op.stage("graceful_shutdown") op.stage("graceful_shutdown")
// Reach into the guest over SSH to force a sync + queue a poweroff // Reach into the guest over SSH to force a sync + queue a poweroff.
// before falling back on FC's SendCtrlAltDel. The sync is what // The sync is what keeps stop() from losing data: every dirty page
// keeps stop() from losing data: every dirty page the guest hasn't // the guest hasn't flushed through virtio-blk to the work disk is
// flushed through virtio-blk to the work disk is written out // written out before this RPC returns. Once sync completes,
// before this RPC returns. Without it, files freshly created via // root.ext4 on the host is consistent and cleanupRuntime's SIGKILL
// `vm workspace prepare` can disappear across stop+start, because // is safe — there is no benefit to waiting for the guest's
// the 10-second wait_for_exit window expires (FC doesn't exit on // poweroff.target to finish, so we skip waitForExit entirely.
// SendCtrlAltDel — Debian routes ctrl-alt-del.target → reboot.target,
// not poweroff) and the fallback SIGKILL drops everything still
// in FC's userspace I/O path.
// //
// `systemctl --no-block poweroff` is queued for the same reason // When SSH is unreachable (broken sshd, network down, drifted host
// SendCtrlAltDel was here originally — it's how stop() asks the // key) we drop straight to SIGKILL via cleanupRuntime. The
// guest to halt. That request is best-effort; FC may or may not // previous fallback was SendCtrlAltDel + a 10-second wait for FC
// exit before the SIGKILL fallback fires. Either way, sync // to exit, but on Debian ctrl+alt+del routes to reboot.target, so
// already ran, so the on-host root.ext4 is consistent regardless. // FC never exits on it — the wait was always a wasted 10s. We pay
// // the data-loss cost we already paid before (after the timeout
// SendCtrlAltDel survives as a fallback for guests where SSH // expired the old code SIGKILLed too), but without the latency.
// itself is unreachable (broken sshd, network down, drifted host
// key); it doesn't fix the data-loss path, but it's the existing
// last-resort signal and is at least no worse than today.
if err := s.requestGuestPoweroff(ctx, vm); err != nil { if err := s.requestGuestPoweroff(ctx, vm); err != nil {
if s.logger != nil { if s.logger != nil {
s.logger.Warn("guest ssh poweroff failed; falling back to ctrl+alt+del", s.logger.Warn("guest ssh poweroff failed; SIGKILL without sync",
append(vmLogAttrs(vm), "error", err.Error())...) append(vmLogAttrs(vm), "error", err.Error())...)
} }
if fallbackErr := s.net.sendCtrlAltDel(ctx, vm.Runtime.APISockPath); fallbackErr != nil {
return model.VMRecord{}, fallbackErr
}
}
op.stage("wait_for_exit", "pid", pid)
if err := s.net.waitForExit(ctx, pid, vm.Runtime.APISockPath, gracefulShutdownWait); err != nil {
if !errors.Is(err, errWaitForExitTimeout) {
return model.VMRecord{}, err
}
op.stage("graceful_shutdown_timeout", "pid", pid)
} }
op.stage("cleanup_runtime") op.stage("cleanup_runtime")
if err := s.cleanupRuntime(ctx, vm, true); err != nil { if err := s.cleanupRuntime(ctx, vm, true); err != nil {
@ -190,16 +173,16 @@ func (s *VMService) stopVMLocked(ctx context.Context, current model.VMRecord) (v
// comment in stopVMLocked. Returns the dial / SSH error if the guest // comment in stopVMLocked. Returns the dial / SSH error if the guest
// is unreachable; the caller treats that as a fallback signal. // is unreachable; the caller treats that as a fallback signal.
// //
// Bounded by a hard 5-second SSH-dial timeout so a half-broken guest // Bounded by a hard 2-second SSH-dial timeout. A reachable guest on
// doesn't extend the overall stop window past the existing // the host bridge dials in single-digit milliseconds; if we haven't
// gracefulShutdownWait. If the dial doesn't succeed in that window we // connected in 2s the guest is effectively gone, so we fail fast and
// surface an error and let the caller take the SendCtrlAltDel path. // let the caller SIGKILL rather than burning latency on a doomed dial.
func (s *VMService) requestGuestPoweroff(ctx context.Context, vm model.VMRecord) error { func (s *VMService) requestGuestPoweroff(ctx context.Context, vm model.VMRecord) error {
guestIP := strings.TrimSpace(vm.Runtime.GuestIP) guestIP := strings.TrimSpace(vm.Runtime.GuestIP)
if guestIP == "" { if guestIP == "" {
return errors.New("guest IP unknown") return errors.New("guest IP unknown")
} }
dialCtx, cancel := context.WithTimeout(ctx, 5*time.Second) dialCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel() defer cancel()
address := net.JoinHostPort(guestIP, "22") address := net.JoinHostPort(guestIP, "22")
client, err := guest.Dial(dialCtx, address, s.config.SSHKeyPath, s.layout.KnownHostsPath) client, err := guest.Dial(dialCtx, address, s.config.SSHKeyPath, s.layout.KnownHostsPath)

View file

@ -1592,7 +1592,7 @@ func TestDeleteStoppedNATVMDoesNotFailWithoutTapDevice(t *testing.T) {
} }
} }
func TestStopVMFallsBackToForcedCleanupAfterGracefulTimeout(t *testing.T) { func TestStopVMSIGKILLsWhenSSHUnreachable(t *testing.T) {
ctx := context.Background() ctx := context.Background()
db := openDaemonStore(t) db := openDaemonStore(t)
apiSock := filepath.Join(t.TempDir(), "fc.sock") apiSock := filepath.Join(t.TempDir(), "fc.sock")
@ -1606,12 +1606,6 @@ func TestStopVMFallsBackToForcedCleanupAfterGracefulTimeout(t *testing.T) {
} }
}) })
oldGracefulWait := gracefulShutdownWait
gracefulShutdownWait = 50 * time.Millisecond
t.Cleanup(func() {
gracefulShutdownWait = oldGracefulWait
})
vm := testVM("stubborn", "image-stubborn", "172.16.0.23") vm := testVM("stubborn", "image-stubborn", "172.16.0.23")
vm.State = model.VMStateRunning vm.State = model.VMStateRunning
vm.Runtime.State = model.VMStateRunning vm.Runtime.State = model.VMStateRunning
@ -1622,8 +1616,6 @@ func TestStopVMFallsBackToForcedCleanupAfterGracefulTimeout(t *testing.T) {
scriptedRunner: &scriptedRunner{ scriptedRunner: &scriptedRunner{
t: t, t: t,
steps: []runnerStep{ steps: []runnerStep{
sudoStep("", nil, "chmod", "600", apiSock),
sudoStep("", nil, "chown", "-h", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), apiSock),
{call: runnerCall{name: "pgrep", args: []string{"-n", "-f", apiSock}}, out: []byte(strconv.Itoa(fake.Process.Pid) + "\n")}, {call: runnerCall{name: "pgrep", args: []string{"-n", "-f", apiSock}}, out: []byte(strconv.Itoa(fake.Process.Pid) + "\n")},
sudoStep("", nil, "kill", "-KILL", strconv.Itoa(fake.Process.Pid)), sudoStep("", nil, "kill", "-KILL", strconv.Itoa(fake.Process.Pid)),
}, },

View file

@ -228,6 +228,7 @@ banger $TARGET_VERSION installed.
Next steps: Next steps:
banger doctor # confirm host readiness banger doctor # confirm host readiness
banger vm run # boot a sandbox banger vm run # boot a sandbox
banger ssh-config --install # optional: enable 'ssh <name>.vm'
Updates land via: Updates land via:
banger update --check banger update --check