Flows¶
The four end-to-end paths bty supports. Pick by what infrastructure you have:
Direct flash - one-off provisioning, no server, USB live stick with the catalog baked onto its
BTY_IMAGESpartition.USB + network catalog - USB live stick boots a target as in the direct path, but the catalog comes from a remote
bty-webinstance (commonly theghcr.io/safl/bty-webDocker container on a teammate’s workstation). Same flash mechanics, shared catalog. No PXE.Interactive PXE flash - server is up, operator picks an image from the
btywizard on first PXE contact (default for unknown MACs).Server-driven PXE flash - fleet image flashing; machines reflash themselves on schedule / on demand / on failure.
Direct flash (USB live, offline)¶
Ad-hoc provisioning of a single box, no infrastructure on the network.
Operator boots the target from the bty USB live image (built by
bty-media).The live env auto-launches
btyon tty1 viabty-on-tty1.service. Withoutbty.mac=on the kernel cmdline, the wizard runs in local-only mode: scans theBTY_IMAGESpartition for local image files.Operator picks an image (Enter), picks a target disk (Enter), confirms the flash plan (y / Enter).
bty writes the image and reports success.
Operator removes the USB stick and reboots; the target boots into the freshly-flashed image.
The whole flow runs offline. No network, no server, no MAC registration.
USB + network catalog (bty --catalog SOURCE)¶
A middle shape between the offline direct flash and the PXE-driven flows.
The operator boots from the same USB live stick but points the wizard at a
network-shared bty-web for the catalog. Useful for a small team that
wants one place for pre-built images without the full PXE deploy.
Someone (operator’s workstation, a homelab server, a dev box) runs
bty-web. The lowest-friction shape is the published container:docker run -d -p 8080:8080 -v bty-data:/var/lib/bty \ ghcr.io/safl/bty-web:latest
Pre-built images dropped into the volume show up in the
/imagesendpoint. Anybty-webinstance serves/imagesand works identically as a catalog source, whether run from a baredocker runor the fulluvx bty-lab initstack.Operator boots a target from the bty USB live stick.
btyauto-launches in local-only mode (no--macon cmdline). On the first stage (SELECT_CATALOG) the operator picks[c] customand typeshttp://<host>:8080/catalog.toml. Or: relaunch the wizard withbty --catalog http://<host>:8080/catalog.tomlfrom a shell on Alt+F2.The wizard fetches
GET /catalog.toml, merges it with the local image-root, and advances to the image picker. Operator picks an image + a target disk, confirms. Image bytes stream fromGET /images/{name}throughcurl | ddto the target disk - no temp file.On completion the operator removes the stick and reboots; the target boots into the freshly-flashed image. The server has no per-MAC record (this isn’t PXE), so no follow-up state to manage. The operator’s pick is never reported back: the catalog source is a one-way feed in this mode.
No PXE, no DHCP-proxy, no L2 broadcasts. The container can live anywhere reachable - operator’s laptop, an EC2 instance, anywhere with HTTP. The cost: the operator still has to plug in the USB stick and stand at the target.
Sub-case: virtual USB via IP-KVM (PiKVM, JetKVM)¶
The “USB live stick” in step 2 need not be physical. IP-KVM appliances
(PiKVM, JetKVM, BMC IPMI virtual media, vendor-specific OoB consoles) can
mount the bty .iso artifact and expose it to the target as a USB or
CD-ROM device. The target boots into the bty live env exactly as from a
physical stick; bty auto-launches on tty1; the operator types c,
fills in http://<host>:8080/catalog.toml, picks an image, picks a
target disk, flashes. The whole sequence runs through the IP-KVM session,
no one at the rack.
Practical notes:
Use the
.isoartifact directly (uncompressed since v0.25.4).bty’s hybrid ISO works as either USB or CD-ROM; pick whichever your IP-KVM offers and the target’s BIOS/UEFI prefers.
Keystroke latency over IP-KVM is real; the wizard’s
Enter-forward /Esc-back UX keeps per-step input minimal.The bty live env’s tty1 framebuffer renders cleanly through every IP-KVM tested (PSF console fonts, no nerd-font / emoji dependencies). The plain-ASCII /etc/issue banner and the wizard’s Rich panels render identically over IP-KVM and locally.
This is what “bare-metal provisioning over the internet” looks like for a
small fleet without PXE: a PiKVM at each site, a bty-web container
somewhere with the catalog, an operator at home with a browser tab.
Sub-case: Ventoy multi-ISO stick¶
Ventoy replaces the bootloader on a USB stick
with a menu that boots any .iso dropped onto its data partition.
bty-usb.iso works there: boot the stick, pick bty-usbboot-pc-x86_64.iso from the
Ventoy menu, and the target boots into the bty live env exactly as if
dd’d directly.
Two ways to use Ventoy with bty:
bty-usb plus a remote catalog. Same shape as the IP-KVM sub-case above:
btyauto-launches, the operator pressescand types thebty-webURL. Ventoy is just a different boot mechanism; the catalog source is unchanged.bty-usb plus images on the same Ventoy partition. Drop
.img.zst/.qcow2/.img.gzfiles onto the Ventoy data partition next to the bty ISO. After bty boots, the partition is still attached to the host (it’s the physical USB stick the live env booted from). Mount it and pointbtyat the path viaBTY_IMAGE_ROOT:# On the booted bty live env's tty1 (drop to a shell first): mount /dev/sdaN /mnt # Ventoy data partition BTY_IMAGE_ROOT=/mnt bty
No
bty-webserver needed for this variant - same self-contained shape as a stock bty-usb stick, just with Ventoy’s multi-ISO bootloader replacing the bty bootloader.
The BTY_IMAGES auto-mount relies on the partition label; Ventoy’s data
partition is labeled Ventoy by default, so the auto-mount does not
trigger. Either relabel that partition BTY_IMAGES (for auto-mount) or
mount it manually as in option 2.
Interactive PXE flash (boot_mode=bty-tui)¶
The “bty-on-a-USB but over the network” path. Default behaviour for any MAC the server has never seen, so onboarding a new box needs zero per-MAC configuration.
Operator brings up the bty-web container deploy:
uvx bty-lab init /opt/bty(create + chown the dir first), setHOST_ADDR+ passwords in/opt/bty/envvars, thenpodman compose -f /opt/bty/compose.yml --env-file /opt/bty/envvars --profile tftp up -d. The web UI is gated by$BTY_ADMIN_PASSWORD(unset = open, with a startup warning; rotate by changing the env var and restarting bty-web); point your LAN DHCP server (option 60/66/67) at the host using the Netboot page cheatsheet (bty serves TFTP via the sidecar but does not run DHCP).A target PXE-boots on the same segment for the first time.
bty-webauto-discovers the MAC asboot_mode=bty-inventory(self-reports its disks, then boots the disk). To drive it with the interactive wizard instead, setboot_mode=bty-tuion the machine;bty-webthen serves the iPXE-tui template (ipxe_tui.j2).The target chains into the bty live env with
bty.server=URL+bty.mac=MACon the kernel cmdline (the iPXE template carries nothing else; every other knob comes from the plan endpoint).bty-on-tty1.servicetakes over tty1 in place of the agetty and exec’sbty --server URL --mac MAC.bty auto-posts the local disk inventory to
POST /pxe/{mac}/inventoryon startup (no operator action). bty-web stores it on the machine row; the/ui/machines/{mac}page now shows a real path / model / serial dropdown for picking a target disk. Then bty GETs<server>/pxe/<mac>/planand seesmode=interactivefor boot_mode=bty-tui.bty drops into the wizard with the server’s catalog pre-loaded (
GET /catalog.toml). The operator picks an image and a target disk, confirms the flash. Image bytes stream fromGET /images/{name}throughcurl | ddto the target disk - no temp file, no intermediate download.On success bty
POSTs/pxe/{mac}/donesolast_flashed_atupdates server-side. The image pick itself is NOT reported back: the machine’sbty_image_refstays whatever it was (or null). For server-tracked flashes, set boot_mode=bty-flash-always with a bound ref + serial. The next reboot chains the wizard again unless the operator flipsboot_mode.
This flow also suits the operator who wants a one-off remote flash without
preparing a USB stick: any unknown MAC on the segment becomes a bty
wizard session reachable via IPMI / serial console.
Server-driven PXE flash (boot_mode=bty-flash-always)¶
Fleet-managed provisioning, where targets are reflashed on schedule, on demand, or on failure.
The bty-web deploy is already up (same setup as the interactive flow above).
The target’s first PXE contact creates a
Machinerecord withboot_mode=bty-tui. The live env runsbtyon tty1, which automatically posts the box’s disk inventory toPOST /pxe/{mac}/inventoryon startup (no operator action).Operator assigns
MAC -> image + target_disk + boot_modein the web UI:bty_image_ref(image binding) - picked from the catalog.target_disk_serial(which disk to flash) - picked from the inventory dropdown populated in step 2.boot_mode=bty-flash-alwaysarms the auto-flash.
Target machine PXE-boots; bty-web’s
/pxe/{mac}returns the iPXE flash chain. Cmdline carries justbty.server+bty.mac; iPXE chains into the bty live env served over HTTP bybty-web.bty-on-tty1.serviceexec’sbty --server URL --mac MAC.btyGETs/pxe/<mac>/plan, seesmode=flashwith the image URL + target_disk_serial filled in, resolves the serial to a/dev/...path via lsblk, fetches the assigned image from whatever URL the plan carries (withcache when configured + warm; the raw upstreamoras://orhttps://otherwise), runs the flash,POSTs/pxe/{mac}/doneto updatelast_flashed_at, then reboots automatically.The reboot lands back on PXE (PXE-first firmware). Because the box fetched the live-env artifacts during steps 4-5,
boot_mode=bty-flash-alwaysnow serves a one-shot boot of the just-flashed disk (UEFI exit / BIOS sanboot) instead of the flash chain, so the freshly imaged OS boots. The next power cycle (no artifact fetch in between) serves the flash chain again - so a per-job CI cadence reflashes every cycle while still booting the image each time, no mode change.bty-flash-onceworks the same way but doesn’t re-arm, so after the one flash it keeps booting the disk - and it staysboot_mode=bty-flash-oncethroughout (see below).First-boot bring-up (users, network, packages, hostnames) is the pre-built image’s job, baked in via cloud-init / NoCloud user-data at image-build time. bty has no online provisioning step.
Both BIOS and UEFI clients are supported via iPXE.
Machine state model¶
Every machine record on bty-web carries five operator-controlled fields plus three timestamps the server maintains:
Field |
Meaning |
|---|---|
|
sha256 of canonicalised catalog |
|
Free-form display tags (a set per machine; max 64 chars each, 16 per machine). Cosmetic; not consumed by the flash chain. |
|
One of |
|
iPXE BIOS drive for the legacy-BIOS disk boot (e.g. |
|
Operator-picked serial number from the most recent inventory post. |
|
JSON array of disks the live env’s |
|
Updated on every |
|
Updated on every |
|
Updated on every |
The boot_mode is the primary control knob; the rest provide the
parameters the policy needs.
boot_mode values¶
Mode |
What |
Mutates |
|---|---|---|
|
|
No. |
|
|
No. The transient |
|
Same chain + |
No. Stays |
|
|
No. |
|
Alternates the live-env chain (plan |
No. Alternates via the bit. |
boot_mode is the operator’s intent and is never rewritten by the
server. bty-flash-once is the “reimage this box now, then leave it
alone” pattern: it flashes on the next boot, then the saw_flasher_boot
bit (armed when the box booted the flasher) makes every later contact
boot the disk instead - while the record stays bty-flash-once. It
differs from bty-flash-always only in that the bit never re-arms, so it
won’t reflash again until the operator re-saves the machine.
bty-flash-always re-arms each cycle: the per-job CI cadence.
Inventory + safety-gate flow¶
The target_disk_serial gate prevents “wrong disk wiped” incidents on multi-disk hosts. The full picture, in event order:
First contact, no inventory yet. Operator powers on a new box. The firmware PXE-DHCPs, gets
ipxe.efivia TFTP, runs the embedded chain script, fetches/pxe-bootstrap.ipxefrom bty-web, chains to/pxe/{mac}. bty-web records the MAC (machine.discoveredevent), setsboot_mode=bty-inventory, returns the live-env chain (ipxe_tui.j2). Audit log gets anetboot.pxe.offeredrow withoffer_kind=bty-inventory.Live env boots,
btystarts.btyruns on tty1; on startup it shells out tolsblkand POSTs the result to/pxe/{mac}/inventory. bty-web stores the inventory as JSON on the machine row, updatesknown_disks_at, recordsmachine.inventory. Fire-and-forget: failures land in the tty1 status bar but don’t block the operator.Operator opens
/ui/machines/{mac}. The Target disk dropdown is now populated fromknown_disks, showing path / size / model / serial per disk. The operator picks one + binds an image + setsboot_mode=bty-flash-always.Operator power-cycles the target. Next PXE contact:
/pxe/{mac}seesboot_mode=bty-flash-always,bty_image_refbound, andtarget_disk_serialpicked. Returnsipxe_flash.j2withbty.server=bty.mac=on the cmdline (the image URL + target serial come from the plan endpoint, not the cmdline).
Live env flashes.
btyon tty1 GETs/pxe/<mac>/plan, seesmode=flashwith image + target_disk_serial filled in, shells outlsblk -o SERIAL, matches the serial to a path, runs the flash on that path,POSTs/pxe/{mac}/done(audit:machine.flashed), reboots.
The gate fires at multiple points:
/ui/machines/{mac}POST refusesboot_mode=bty-flash-alwayswhentarget_disk_serialis empty. The form bounces to/ui/machines/{mac}?error=...so the operator sees a banner explaining how to fix it./pxe/{mac}refuses the flash chain whentarget_disk_serialis empty. Returnsipxe.j2(local fallback) and records anetboot.pxe.flash.no_target_diskevent so the operator can see on/ui/eventswhy their box isn’t reflashing.btyin auto-flash mode refuses when the plan’s serial doesn’t match any current disk. Prints a red Panel listing the current disks and serials, exits non-zero. The bty-on-tty1 service stays at the failed banner; the operator can re-pick on the server and retry.
The serial-match (vs path-match) at flash time is the durable guarantee:
/dev/sda can flip to /dev/nvme0n1 across kernel versions, but the
disk’s serial number is fixed.
Automated event-driven transitions¶
bty-web triggers a few automated mutations in response to HTTP requests from the live env. None require operator action.
POST /pxe/{mac}/done (live env signals completion)¶
Always:
Updates
last_flashed_at+updated_at.Records
machine.flashedevent with the requesting IP.
boot_mode is not touched - for any mode, including
bty-flash-once. The mode is the operator’s intent and stays put; the
saw_flasher_boot bit (armed when the box booted the flasher) is what
makes the next PXE contact boot the disk instead of reflashing, so a
finished bty-flash-once still reads bty-flash-once.
POST /pxe/{mac}/inventory (bty reports disks)¶
Replaces the entire
known_disksJSON column with the new payload (no merge - the live env is authoritative for the box’s current disks).Updates
known_disks_at.Records
machine.inventorywith the disk count + list of serials.404s if the MAC has no machine record (prevents a renegade
btyfrom creating ghost machines).
GET /pxe/{mac} (firmware fetches the per-MAC chain)¶
Always:
Inserts or updates the machine row (
machine.discoveredfires on first contact; subsequent hits just touchlast_seen_at+last_seen_ip).Records
netboot.pxe.offeredwith the offer kind so an operator can ask “what did the server hand back to MAC X at time T?” without debug logging.
Conditional:
netboot.pxe.flash.no_target_diskfires whenboot_mode=bty-flash-always/bty-flash-onceis set, an image is bound, the ref resolves, buttarget_disk_serialis empty. Distinct kind so the operator can filter for “why isn’t this reflashing?” cases.netboot.pxe.flash.orphan_reffires whenboot_mode=bty-flash-alwaysis set and an image is bound but the ref has no resolvablecatalog_entriesrow. Different failure mode fromno_target_disk; the binding itself is stale.
Audit log: event kinds by trigger¶
Kind |
Fires when… |
|---|---|
|
A MAC not in |
|
Operator |
|
Operator |
|
Operator |
|
Live env |
|
Live env |
|
Every |
|
|
|
First |
|
Operator |
|
sha resolve / oras resolve / duplicate-src on |
|
Operator |
|
|
|
Lifecycle events for the release-artifact fetch worker ( |
|
Operator |
|
Operator |
|
Operator |
|
BackupManager lifecycle; terminal success is bare |
|
Operator |
|
Same path with a mismatched password. |
|
Operator |
|
|
Every row carries subject_kind (machine / image / catalog /
netboot / settings / auth / backup), a subject_id, the
requesting source_ip, the actor (operator / pxe-client /
system), and a JSON details blob with kind-specific extras.
Operator UI actions: a quick map¶
Action |
UI path |
What happens server-side |
|---|---|---|
Log in |
|
Constant-time compare against |
Log out |
|
Clears session cookie. Records |
Bind image + disk + policy on a machine |
|
UPSERT. Refuses |
Delete a machine record |
|
DELETE row. Records |
Add catalog entry by URL |
|
sha-resolve (if |
Delete a catalog entry |
|
Removes the row. v0.40+: no on-disk cached bytes to clean up; withcache evicts on its own schedule. Records |
Upload a |
|
Validates + atomic-renames into |
Fetch |
|
GETs the operator’s |
Fetch boot artifacts (kernel + initrd + squashfs) |
|
Pulls release artifacts into |
Save upstream sources (netboot repo / tag, catalog URL) |
|
Persists overrides into |
Save scheduled-backup knobs (enabled / cadence / retention) |
|
Same persistence; scheduler picks up changes on the next 60s tick. Records |
Edit a |
|
Per-row inline form on |
Safety gates summary¶
Where bty-web refuses what the operator asked, and what the operator sees:
Gate |
Trigger condition |
Where it fires |
Operator surface |
|---|---|---|---|
Refuse flash chain without |
|
|
|
Refuse |
Form posts |
|
303 to |
Refuse flash on serial mismatch at boot time |
Live env can’t find a current disk whose serial matches the plan’s |
|
|
Refuse oversize catalog upload |
|
|
303 with |
Refuse oversize boot artifact upload |
|
|
413 Content Too Large. |
Refuse non-TOML catalog upload |
Filename extension not |
|
303 with |
Refuse non-2xx catalog fetch-release body |
HTTPError 404, URLError, TimeoutError, or non-TOML body. |
|
303 with |
Refuse mismatched login |
Submitted password does not match |
|
Login form re-rendered with |
Refuse unknown |
Pydantic pattern check on |
|
422 (JSON) / 303 with flash (form). |
Refuse path-traversal in upload |
|
|
400 / 404 / 405 depending on the request shape. |