β΅Bosun
bosun is an experiment, and no host enables it. It serves no production CI and never has β the benchmarks in
apps/bosun/README.mdare a race against GitHub's hosted runners, not a migration. The daemon, its module and both hulls stay in the tree, waiting for hardware worth running them on. Everything below describes whatapps/bosunis, and what a host that enables it gets.GitHub Actions jobs that need a real machine boundary run in skiffs β ephemeral cloud-hypervisor microVMs, one job each, destroyed afterwards.
apps/bosunkeeps a pool of them warm. It is a peer of Architecture/Spindrift, not part of it.The four nouns
Noun What it is skiff one microVM serving exactly one job, then halting bosun the daemon that keeps skiffs alive, and the project hull the immutable kernel + initrd + manifest a skiff boots from class what a runs-on:label resolves to β which hull, how many vCPUs, how much memory and scratch diskGitHub owns
runner,job,workflowandlabel; those words appear in YAML this project does not control, so they are never reused for anything here.
Warm pool, not dispatch
A JIT-registered runner is ephemeral by construction: it runs one job, deregisters itself, and exits. So bosun keeps N skiffs booted and registered, and GitHub hands one a matching job unprompted.
The consequence is what makes this small: bosun never learns that a job was queued. There is no webhook endpoint, no queue listener, no inbound connectivity, and nothing to replay. It notices a skiff halted β the VMM exits 0, which is a
wait(2)on a child β and boots a replacement.That replacement is the fast path, not the guarantee. Every poll tick also reconciles each class against its warm count and boots what is missing, because a spawn can fail β a mint that 5xx'd, a workspace that would not fit β and a class one slot short otherwise stays that way for the life of the process, serving at half its declared depth and looking exactly like a class that works. A class that keeps failing to boot backs off rather than minting a registration every tick forever.
Named cost: idle skiffs hold RAM, and there is no scale-to-zero.
The Spindrift build source
services.bosun.spindriftturns a host into a second, independent work source alongside its GitHub warm pool: bosun long-polls Architecture/Spindrift's outbox instead of waiting for GitHub to hand a registered skiff a job.apps/bosun/spindrift.goclaims, heartbeats, and posts results againstapps/spindrift/src/web/bosun-route.ts's three bearer-authed/internal/bosun/endpoints β the same shared secret authenticates all three, because a bosun host cannot hold a browser session the way every other caller of that process does.A claim boots a build-hull skiff (
nix/images/hull-build-ubuntu.nix) with the opaque request document written into its share where a JIT config would otherwise go, instead of registering it against GitHub. A class serving builds setswarm = 0: a build skiff boots on claim rather than ahead of time, so keeping one warm buys nothing.This is one of Spindrift's own build routes β see Architecture/Spindrift for the other three and how rank among them is decided.
The hull contract is the seam
A hull is a directory holding a
hull.jsonbeside its artifacts, declaringkernel,initrd,cmdline, and an optionaldevices[]list. bosun reads the manifest and translates it to cloud-hypervisor arguments; it carries no per-hull-family branching and never learns what any device means.bosun promises every skiff the same four things regardless of hull β a credential at a contract-fixed location, a writable workspace, outbound network, and a writable diagnostic share on the host β plus its own identity appended to the cmdline as
bosun.skiffandbosun.hull. Those are correlation facts, never claims: a guest reporting its own hull digest proves nothing to anyone.A hull promises to boot from what it declared, find its credential, run one job, and halt. The last clause is enforced by GitHub rather than by anyone here.
Hull identity is a content digest over the manifest and the files it names β not a Nix store path, so a hull built without Nix can have one too.
The NixOS hull
nix/images/hull-nixos.nix, built withnix build .#hull-nixos. It is a fleet registry entry like any other image, so it needs no special flake plumbing.It carries no rootfs:
/is tmpfs, and the host's/nix/storearrives read-only over virtiofs with a tmpfs overlay on top, sonix buildstill works in the guest. Everything the skiff runs is already on the host.That store arrives with no Nix database β the host's is unreadable by an unprivileged virtiofsd β so the closure's registration rides the kernel cmdline and is loaded before
sysinit.target. The warm-store benefit is bounded by what the hull declares in its closure; paths outside it are visible but unregistered, so Nix substitutes them as it would anywhere.A skiff runs its job as root. The VM boundary is the isolation, not the user boundary inside it.
Authenticating to GitHub
bosun mints its own JIT configs and skiff registrations as an installation of the shared Spindrift+bosun GitHub App β see Architecture/Spindrift.
services.bosun.github.appIdand.privateKeyFile(apps/bosun/module.nix) name the App id and bosun's own private key;apps/bosun/github.gosigns the JWT and narrows every mint toadministration:write, resolving the installation per repository.bosun and Spindrift hold the same private key on that App today β the operator's choice, not a code constraint: GitHub Apps support a distinct key per consumer, but this fleet rotates one PEM for both. That key lives in
nix/secrets/bosun.sops.yaml, shared by every bosun host rather than duplicated per host; see Architecture/Secrets and PKI.
Credential handling
bosun mints a JIT config immediately before boot and never stockpiles: an unused one expires about an hour after it is minted.
It arrives on a per-skiff virtiofs share rather than the kernel cmdline, which is world-readable inside the guest. bosun deletes it host-side the moment GitHub reports the runner online β virtiofs passes through to the host filesystem, so it vanishes in-guest with no cooperation from the guest, and untrusted job code never sees a live credential.
bosun polls one runner id at a time and never the runner list. A registration that no skiff ever consumes leaves a ghost behind, so the list is never the source of truth for pool size; local bookkeeping is. That one call also distinguishes a booted-*idle* skiff from a booted-*busy* one, which is invisible from the host, and catches a wedged guest β which
ch-remote pingcannot, because a hung guest with a live VMM answers ping.The wedge rule applies to idle skiffs only. Offline-with-a-live-VMM does not distinguish a hung guest from a running job whose runner went quiet, so on a busy skiff it would destroy the job and the evidence of why. A busy skiff is bounded by its class's
maxLifetimeinstead, which is why that budget may not be zero.
Egress
The policy is inverted from the obvious one: deny the LAN, allow the internet. Every job in this repo already fetches many public hosts, and none needs a LAN destination.
It is a single
IPAddressDenyon bosun's own systemd unit. systemd's IP filtering inherits down the whole cgroup subtree, so one directive covers every skiff with no per-skiff rule anywhere.Denying RFC1918 breaks DNS, so a public resolver is pinned and the deny stays absolute.
State
One directory per skiff under
/run, holding the runner id and the hull digest./runis tmpfs, so a reboot clears it, which is correct β there is no database.A skiff whose registration bosun could not delete leaves its directory behind holding the runner id alone, credential included in what is stripped. That id is the only handle left on a live registration, and the sweep below is the only thing that can still spend it.
A stop drains instead of failing every in-flight job. Idle skiffs are scuttled registration-first: GitHub refuses to delete a busy runner's registration, so a successful delete proves no job can land on that skiff and its VMM is safe to kill. Busy skiffs get the module's
drainTimeout(default 15 min) to finish β which is also how long anixos-rebuild switchmay block on that host.Orphaned VMMs still cannot exist: the unit runs
KillMode=mixed, so the stop signal reaches the daemon alone but systemd SIGKILLs the whole cgroup at the stop timeout.Because a cgroup kill leaves no chance to run teardown, bosun sweeps on start and deregisters what it finds β retrying, rather than discarding, any id whose delete fails again.
Where it runs
Nowhere. No
nix/hosts/*.niximportsapps/bosun/module.nix, so no host serves a skiff class and everyruns-on:in this repo names a hosted runner or an ARC pool.What it wants is a dedicated workstation.
warmis a constant a host declares, so a class costs its RAM and itswarm Γ workspacedisk whether or not a job ever arrives, and the only boxes in the fleet big enough to spare that are cluster nodes sharing themselves with kubelet. A machine whose job is serving skiffs pays that cost happily; a worker node pays it out of the cluster's pocket.Nothing about the module is host-specific; a host imports it with only its class definitions differing, and the workflow side is one dispatch input away from naming a class again.
ARC serves
folly,offsiteandself-hosted. A skiff never claims those labels β its class name is its only label.
Trying it
All of this needs a host enabling
services.bosunfirst..github/workflows/skiff-smoke.ymlis aworkflow_dispatchjob onruns-on: skiff-nixos. It asserts what a skiff does differently: the warm shared store, the writable overlay,nix-ldresolving the FHS interpreter downloaded release binaries ask for, and socket-activated Docker.Inspect a host with
systemctl status bosunandjournalctl -u bosun. Under the module'slogDir, each skiff leaves its serial console as<id>.log, and an Ubuntu-hull skiff also leaves the runner's own trace in<id>.diag/β a writable virtiofs share, so it lands on the host while the job runs and survives a skiff killed mid-job. bosun offers that share to every skiff; the NixOS hull does not mount it yet, so askiff-nixos<id>.diag/is empty. Both outlive the skiff and are aged out bylogRetention.A class's
memoryis also its disk budget unless the class sizes aworkspace: the whole guest root is a tmpfs overlay, so a checkout plus build that outgrows it is an OOM rather than anENOSPC.A
workspaceis a virtio-blk scratch disk, reserved under the module'sworkspaceDirat boot and deleted with the skiff. bosun appends it after every hull-declared device and names it on the cmdline asbosun.workspace=, because a device index shifts with what the hull declared and a name does not. It is handed over raw β the Ubuntu hull formats it and puts the runner's workspace and docker's data root there, which is what lets a class hold more warm skiffs than its memory alone would allow. What the disk carries is the hull's business; bosun never learns what was written to it.The space is reserved up front, not as a build uses it, so
warm Γ workspaceper class is what the host must keep free β an overcommitted pool fails a spawn, which is visible, rather than a build mid-run, which is not.
Linked references 5
Spindrift and Architecture/Bosun both speak to GitHub as installations of one shared GitHub App (spindrift-bot, App ID 4576122, owned by the jonpulsifer user account). Atlantis and the Flux deploy-key App stay separate β a webhook is one-per-App, so folding either in would collide.
bosun authenticates as the same App with the same private key β the operator's choice, not a code constraint: GitHub Apps support a distinct key per consumer, but this fleet rotates one PEM for both, so rotating means updating every home together (1Password, nix/secrets/bosun.sops.yaml, the installation Secret) and then deleting the old key on GitHub. See Architecture/Bosun.
bosun is the route ranked last on purpose: every other adapter is dialed directly, so a broken one is visible the moment a Build runs, while bosun is polled in over Architecture/Bosun's warm pool and keeps building while the Actions plane itself is down.
Architecture/Bosun β ephemeral microVM runners for CI, and the warm pool that keeps them ready
nix/secrets/bosun.sops.yaml breaks the one-file-per-host pattern above on purpose: it holds bosun's own key on the shared Spindrift+bosun GitHub App (see Architecture/Bosun), encrypted to every bosun host's recipient β riptide and oldschool β rather than a copy per host. A new bosun host needs its recipient added to this file through the same two-stage flow before its bosun unit can start, and what gets pasted in is this App key, never a personal access token.