One-command development sandboxes on Firecracker microVMs. https://git.thaloco.com/thaloco/banger/
Find a file
Thales Maciel 11a33604c0
daemon: extract startVMLocked into step runner with per-step rollback
startVMLocked was a ~260-line method running 18 sequential phases
with one lumped error path: on any failure, cleanupOnErr called
cleanupRuntime — a catch-all teardown that didn't distinguish
"this phase acquired resources we should undo" from "this phase is
idempotent." The blast radius was the entire VM lifecycle. Every
tweak to boot, NAT, disk, or auth-sync orchestration had to reason
about a closure that could fire at any of 18 points.

This commit extracts the phases into a data-driven pipeline:

  - startContext threads the mutable state (vm, live, apiSock,
    dmName, tapName, etc.) through every step by pointer so step
    bodies mutate in place without returning copies.
  - startStep carries the op.stage name, optional vmCreateStage
    progress ping, optional log attrs, a run closure, and an
    optional undo closure.
  - runStartSteps walks steps in order, appends the failing step
    to the rollback set (so partial-acquire failures like
    machine.Start's post-spawn HTTP config get their undo fired),
    then iterates the rollback set in reverse and joins errors
    via errors.Join.

Each phase that acquires a resource now owns its own undo:
system_overlay removes a file it created, dm_snapshot cleans up
the loop + DM handles it set, prepare_host_features delegates to
capHooks.cleanupState, tap releases via releaseTap, metrics_file
removes the file, firecracker_launch kills the spawned PID and
drops the sockets, post_start_features calls capHooks.cleanupState
again (capability Cleanup hooks are idempotent — safe to call
whether PostStart reached every cap or not). The 11 phases with
no teardown obligation leave `undo` nil and the driver silently
skips them on rollback.

cleanupRuntime is retired from the start-failure path. It stays
intact for reconcile, stopVMLocked, killVMLocked, deleteVMLocked,
stopStaleVMs — the crash-recovery / lifecycle-teardown contract
those paths rely on is unchanged.

startVMLocked shrinks from ~225 lines of sequential-phase code
plus a cleanupOnErr closure to ~45 lines: compute derived paths,
build the step list, drive it, persist ERROR state on failure.
Stage names preserved 1:1 so existing log grep + the async-create
progress stream stay compatible.

Tests:

  - TestRunStartSteps_RollsBackInReverseOnFailure — the contract
    is pinned: succeeded-before-failing run, all their undos in
    reverse, failing step's undo also fires, original err still
    visible via errors.Is.
  - TestRunStartSteps_SkipsNilUndos — optional-undo contract.
  - TestRunStartSteps_JoinsRollbackErrors — undo failures don't
    hide the root cause.
  - TestRunStartSteps_HappyPathNoRollback — success path never
    fires any undo.

Smoke: all 21 scenarios pass, including the start-path ones
(bare vm run, workspace vm run, vm restart, vm lifecycle, vm set
reconfig) that exercise real firecracker boots end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:34:34 -03:00
cmd cli: restrict ExitCodeError unwrap to the CLI's own type 2026-04-17 15:37:47 -03:00
configs Generic kernel + init= boot path for OCI-pulled images 2026-04-16 20:12:56 -03:00
docs cleanup: drop pre-v0.1 migration scaffolding + legacy-behavior refs 2026-04-23 13:56:32 -03:00
images/golden supply chain: verify signatures and pins across image + kernel builds 2026-04-21 19:38:13 -03:00
internal daemon: extract startVMLocked into step runner with per-step rollback 2026-04-23 15:34:34 -03:00
scripts model: validate VM names as DNS labels at CLI + daemon 2026-04-23 14:06:40 -03:00
.gitignore make: coverage-combined — merge unit-test and smoke covdata 2026-04-23 13:17:17 -03:00
AGENTS.md ssh-config: narrow the legacy-dir cleanup so it can't delete a user key 2026-04-22 16:31:07 -03:00
go.mod Phase 1: imagepull package — pull, flatten, ext4 2026-04-16 17:22:13 -03:00
go.sum Phase 1: imagepull package — pull, flatten, ext4 2026-04-16 17:22:13 -03:00
LICENSE Add LICENSE, update .gitignore, add security note to README 2026-04-14 16:54:33 -03:00
Makefile make: coverage-combined — merge unit-test and smoke covdata 2026-04-23 13:17:17 -03:00
README.md file_sync: skip nested symlinks during recursive copy 2026-04-23 14:11:58 -03:00

banger

One-command development sandboxes on Firecracker microVMs.

Quick start

make install
banger vm run --name sandbox

That's it. banger vm run auto-pulls the default golden image (Debian bookworm with systemd, sshd, Docker CE, git, jq, mise, and the usual dev tools) and kernel, creates a VM, starts it, and drops you into an interactive ssh session. First run takes a couple minutes (bundle download); subsequent vm runs are seconds.

Requirements

  • x86_64 / amd64 Linux — arm64 is not supported today. The companion binaries, the published kernel catalog, and the OCI import path all assume linux/amd64. banger doctor surfaces this as a failing check on other architectures.
  • /dev/kvm
  • sudo
  • Firecracker on PATH, or firecracker_bin set in config
  • host tools checked by banger doctor

Build + install

make install

Installs banger (CLI), bangerd (daemon, auto-starts on first CLI call), and banger-vsock-agent (companion, under $PREFIX/lib/banger/).

To remove the binaries (and stop the daemon):

make uninstall

User data stays in place — the target prints the paths so you can rm -rf them if you want a full purge:

  • ~/.config/banger/ — config, managed SSH keys
  • ~/.local/state/banger/ — VM records, rootfs images, kernels, daemon DB/log
  • ~/.cache/banger/ — OCI layer cache

Shell completion

banger ships completion scripts for bash, zsh, fish, and powershell. Tab-completion covers subcommands, flags, and live resource names (VM, image, kernel) looked up from the daemon. With the daemon down, resource completion silently returns nothing — no file-completion fallback.

# bash (system-wide)
banger completion bash | sudo tee /etc/bash_completion.d/banger

# zsh (user-local; ~/.zfunc must be on fpath)
banger completion zsh > ~/.zfunc/_banger

# fish
banger completion fish > ~/.config/fish/completions/banger.fish

banger completion --help shows the shell-specific loading recipes.

vm run

One command, four common shapes:

banger vm run                          # bare sandbox — drops into ssh
banger vm run ./repo                   # workspace at /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
  • Bare mode gives you a clean shell.
  • Workspace mode (path given) copies the repo's git-tracked files into /root/repo and kicks off a best-effort mise tooling bootstrap from the repo's .mise.toml / .tool-versions. Log: /root/.cache/banger/vm-run-tooling-<repo>.log. Untracked files (including local .env, scratch notes, credentials that aren't gitignored) are skipped by default — pass --include-untracked to also ship them. Pass --dry-run to print the exact file list and exit without creating a VM.
  • Command mode (-- <cmd>) runs the command in the guest; exit code propagates through banger.

Disconnecting from an interactive session leaves the VM running. Use vm stop / vm delete to clean up — or pass --rm so the VM auto-deletes once the session / command exits.

--branch, --from, --include-untracked, and --dry-run apply only to workspace mode. --rm skips the delete when the initial ssh wait times out, so a wedged sshd leaves the VM alive for banger vm logs inspection.

Hostnames: reaching <vm>.vm

banger's daemon runs a DNS server for the .vm zone. With host-side DNS routing you can curl http://sandbox.vm:3000 from anywhere on the host — no copy-pasting guest IPs. On systemd-resolved hosts this is auto-wired; everywhere else there's a short recipe. See docs/dns-routing.md.

Optional: ssh <name>.vm shortcut

banger vm ssh <name> works out of the box. If you'd also like plain ssh sandbox.vm from any terminal (using banger's key + known_hosts), opt in:

banger ssh-config --install    # adds `Include ~/.config/banger/ssh_config`
                               # to ~/.ssh/config in a marker-fenced block
banger ssh-config --uninstall  # reverse it
banger ssh-config              # show the include line to paste manually

banger never touches ~/.ssh/config on its own — the daemon keeps its file fresh at ~/.config/banger/ssh_config; whether and how it's pulled into your SSH config is up to you.

Image catalog

banger image pull <name> fetches a pre-built bundle from the embedded catalog. vm run calls this for you on demand.

Today's catalog:

Name What it is
debian-bookworm Debian 12 slim + sshd + docker + dev tools

See docs/image-catalog.md for the bundle format and how to publish a new entry.

Config

Config lives at ~/.config/banger/config.toml. All keys optional.

Most commonly set:

  • default_image_name — image used when --image is omitted (default debian-bookworm, auto-pulled from the catalog if not local).
  • ssh_key_path — host SSH key. If unset, banger creates ~/.local/state/banger/ssh/id_ed25519. Accepts absolute paths or ~/-anchored paths; ~/foo expands against $HOME. Relative paths are rejected at config load.
  • firecracker_bin — override the auto-resolved PATH lookup.

Full key list in internal/config/config.go.

vm_defaults — sizing for new VMs

Every vm run / vm create prints a spec: line up front showing the vCPU, RAM, and disk the VM will get. When the flags aren't set, those values come from:

  1. [vm_defaults] in config (if present, wins).
  2. Host-derived heuristics (roughly: cpus/4 capped at 4, ram/8 capped at 8 GiB, 8 GiB disk).
  3. Built-in constants (floor).

banger doctor prints the effective defaults with provenance.

[vm_defaults]
vcpu = 4
memory_mib = 4096
disk_size = "16G"

All keys optional — omit whichever you want banger to decide.

file_sync — host → guest file copies

[[file_sync]]
host = "~/.aws"          # whole directory, recursive
guest = "~/.aws"

[[file_sync]]
host = "~/.config/gh/hosts.yml"
guest = "~/.config/gh/hosts.yml"

[[file_sync]]
host = "~/bin/my-script"
guest = "~/bin/my-script"
mode = "0755"            # optional; default 0600 for files

Runs at vm create time. Each entry copies hostguest onto the VM's work disk (mounted at /root in the guest). Guest paths must live under ~/ or /root/.... Default is no entries — add the ones you want. Symlinks encountered while recursing into a synced directory are skipped with a warning — they'd otherwise leak files from outside the named tree (e.g. a symlink inside ~/.aws pointing to an unrelated credential dir).

Advanced

The common path is vm run. Power-user flows (vm create, OCI pull for arbitrary images, image register, manual workspace prepare) are documented in docs/advanced.md.

Security

Guest VMs are single-user development sandboxes, not multi-tenant servers. Each guest's sshd is configured with:

PermitRootLogin prohibit-password
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
AuthorizedKeysFile /root/.ssh/authorized_keys

The host SSH key is the only authentication mechanism. StrictModes is on (sshd's default); banger normalises /root, /root/.ssh, and authorized_keys perms at provisioning time so the default passes.

VMs are reachable only through the host bridge network (172.16.0.0/24 by default). Do not expose the bridge interface or guest IPs to an untrusted network.

Further reading