Reference¶
Reference material for bty’s surfaces. Filled in as features land.
Pre-built release artifacts¶
Each tagged release publishes a fixed set of assets to GitHub. The
releases/latest/download/<filename> URLs always 302 to the newest tag’s
copy; substitute latest for a specific tag (e.g. v0.11.1) to pin.
Asset |
What it is |
URL (latest) |
|---|---|---|
|
Bootable USB live ISO with a built-in writable |
https://github.com/safl/bty/releases/latest/download/bty-usbboot-pc-x86_64.iso |
|
arm64 Raspberry-Pi flasher: a Pi-bootable raw disk image (FAT32 firmware + ext4 live squashfs + auto-growing exFAT |
https://github.com/safl/bty/releases/latest/download/bty-usbboot-rpi-arm64.img.gz |
|
bty’s custom iPXE UEFI binary with the embedded chain to |
|
|
Netboot trio for PXE-flash clients. Drop into the server’s |
https://github.com/safl/bty/releases/latest/download/bty-netboot-pc-x86_64.vmlinuz |
|
The default image catalog ( |
https://github.com/safl/nosi/releases/latest/download/catalog.toml |
|
Release manifest: the version plus the asset filenames for the tag. Stable URL for “what’s the latest”. |
https://github.com/safl/bty/releases/latest/download/release.toml |
|
Offline copy of the docs (this site, rendered by Sphinx + LaTeX). |
https://github.com/safl/bty/releases/latest/download/bty.pdf |
|
Source release (sdist). Archival; install via |
The browser path is https://github.com/safl/bty/releases; the JSON
API for build automation is GET /repos/safl/bty/releases/latest.
CLI¶
bty is a Rich-based wizard that picks an image + a target disk and
flashes. Three invocation shapes:
bty # interactive wizard, local image-root only
bty --catalog <URL> # interactive wizard, catalog pre-loaded
bty --server <X> --mac <Y> # server-driven mode (flash / interactive
# / inventory / exit) chosen by GET <X>/pxe/<Y>/plan
bty --version prints the installed version (sourced from package
metadata) and exits. bty --help documents every flag inline.
--server URL (default bty-server)¶
bty-server base URL or hostname. Bare hostnames are accepted; missing
scheme defaults to http://. Pair with a LAN DNS entry (or /etc/hosts
line) pointing at the bty-web host and bty --mac X just works. The
PXE-booted live env sets this from the kernel cmdline (bty.server=...).
--mac MAC¶
Self-MAC of this client (e.g. aa:bb:cc:dd:ee:ff). When supplied, bty
switches to server-driven mode: it POSTs the local disk inventory to
<server>/pxe/<mac>/inventory, then GETs <server>/pxe/<mac>/plan and
dispatches on the JSON response:
|
What happens |
|---|---|
|
Flash without prompts (the plan carries the image URL + target serial picked on the server side), then POST |
|
Drop into the wizard with the plan’s catalog pre-loaded. Operator picks image + disk. |
|
Post the disk inventory, then reboot (no flash, no wizard). The next PXE contact boots the disk. Used by |
|
Print a notice and exit. Firmware / local-disk boot handles it. |
Network / parse failures fall through to interactive with the server’s
/catalog.toml as the catalog source, so the operator still has something
to act on.
--catalog URL¶
Catalog URL or path to pre-load (http(s):// for HTTP, oras:// for OCI, or a
local file path). When given, the SELECT_CATALOG screen is skipped and the
wizard jumps straight to SELECT_IMAGE with the catalog overlaying the local
image-root. Equivalent to picking [c] custom on the source screen and
typing the URL.
Ignored in server-driven mode (--mac set): the server supplies the
catalog as part of /pxe/<mac>/plan.
Catalog sources¶
--catalog accepts the same shapes the wizard’s [c] custom prompt does:
Local TOML file (
/path/to/catalog.toml).HTTP URL (
https://example.com/catalog.toml).oras://reference (oras://ghcr.io/owner/bty-catalog:latest).bty-web instance (
http://server:8080/catalog.toml).
The catalog TOML schema is bty.catalog.Catalog (version 1):
version = 1
[[images]]
name = "demo.qcow2"
src = "https://example.com/images/demo.qcow2"
sha256 = "abc123..." # optional; required for sha-pinned bty-web entries
format = "qcow2"
size_bytes = 1024
src accepts http(s)://, oras://, or file://. sha256 is
optional in the schema; rolling tags (oras://...:latest) leave
it null because the digest is resolved at flash time.
Recognised image formats¶
.qcow2– decompressed viaqemu-img convert..img– raw image;dddirectly..img.zst–zstd -d --stdout | dd..img.xz–xz -d --stdout | dd..img.gz–gzip -d --stdout | dd..img.bz2–bzip2 -d --stdout | dd.
Tarballs (.tar.gz, .tgz, etc.) are not supported: the gzip/xz/bzip2
layer applied to a tarball yields a TAR stream, not an image, and writing
TAR headers into the MBR is a wrong-answer. Extract first.
gzip is the safe default for distributed images: Etcher / Rufus / Imager / dd all decompress it natively, without the version-cliff issues that bit us with xz (Etcher’s bundled xz handler) and zstd (older Etcher pre-1.18). The flash path inside the wizard accepts every format above for operator-supplied target images.
Image root (bty CLI only)¶
The bty wizard scans a local directory for flashable image files
on the host it runs on – typically the USB live env’s BTY_IMAGES
exFAT partition. Resolved in this order:
BTY_IMAGE_ROOTenvironment variable./var/lib/bty/images(the USB live env auto-mounts theBTY_IMAGESpartition here).
bty-web (v0.40+) does NOT use this directory; it has no image-store. See walkthrough-image-store for the server-side bytes model (withcache + URL-only catalog entries).
Configuration¶
bty resolves a small set of paths and runtime knobs from the environment and sensible defaults.
Environment variables¶
Variable |
Purpose |
Default |
|---|---|---|
|
Image root the |
|
|
Opt in ( |
(unset = off) |
Default paths¶
/var/lib/bty/– bty-web state directory. Holdsstate.db+boot/(netboot artifacts) +catalog.toml(the active manifest) +session-secret. v0.40+: no image-store subdirectory./var/lib/bty/images– USB live env’s auto-mount point for theBTY_IMAGESpartition. Used only by thebtyCLI, not bty-web. See walkthrough-image-store for the bty-web server-side model (withcache + URL-only catalog entries).
Python API¶
bty’s modules are usable as a library. Stable entry points:
Module |
Purpose |
|---|---|
|
|
|
|
|
|
|
|
|
|
A full sphinx-autodoc surface is on the roadmap. Until then treat any module not listed above as internal.
HTTP API¶
bty-web exposes a FastAPI server, backed by a single SQLite file at
$BTY_PATHS_STATE_DIR/state.db (default /var/lib/bty/state.db).
Auth¶
Single-admin-password authentication. The operator UI is gated by
$BTY_ADMIN_PASSWORD; when it is unset the UI is open (bty-web logs a
startup warning). Rotate by changing the env var and restarting bty-web.
POST /ui/login (form-encoded password=...) constant-time-compares
the password against $BTY_ADMIN_PASSWORD and flips
request.session["bty_authed"] = True; the session is a server-signed
cookie managed by Starlette’s :class:SessionMiddleware (cookie name
bty-token, sliding 7-day TTL). No DB-backed session table: the cookie
value is the session, signed against the per-instance key at
/var/lib/bty/session-secret (generated by bty-web-init on first
start). POST /ui/logout clears the session.
Open routes, reachable by PXE clients and other live-env tooling that can’t carry a session cookie:
GET /healthz-{"status": "ok"}GET /version-{"version": "..."}GET /pxe/{mac}- per-MAC iPXE script (text/plain). The response depends on the machine’sboot_mode:ipxe-exit(default): boot the local disk, firmware-aware via iPXE’s${platform}. On UEFI the script isiseq ${platform} efi && exit- hand back to the firmware boot order, which boots the disk’s EFI loader (UEFI has no BIOS INT13 drive map, sosanboot --drivecan’t work there). On legacy BIOS it’ssanboot --no-describe --drive <sanboot_drive>(default0x80) with|| exitfalling back to the firmware order. A machine with no usable assignment (or a stale policy) falls through to the same. Auto-discovery still applies to unknown MACs.bty-flash-always/bty-flash-once+ image assigned + target serial picked: chain into the live env over HTTP with kernel cmdlinebty.server=+bty.mac=. The live env’sbtythen GETs/pxe/<mac>/planto retrieve the image URL + target_disk_serial and runs the flash.
Auto-discovery: the first contact for an unknown MAC inserts a
placeholder row (image=null, boot_mode=bty-inventory) so the box
self-reports its disks and just boots; the operator sees it in
GET /machines with a populated disk dropdown and can claim it with
PUT /machines/{mac}. Repeat contacts update last_seen_at /
last_seen_ip. Trust model: bty-web is for a homelab / CI network, not
the open internet - anyone reachable can write discovery rows.
POST /pxe/{mac}/done- completion signal from the live env after a successful flash. Updateslast_flashed_atand never mutatesboot_mode. The post-flash “boot the disk” behaviour comes from thesaw_flasher_bootbit, not a mode rewrite:bty-flash-oncekeeps the bit set (boots the disk thereafter, still readingbty-flash-once),bty-flash-alwaysclears it (re-arms the flash chain - the per-job CI cadence). bty-web runs no post-flash provisioning; the target reboots into whatever the pre-built image brings up via cloud-init.GET /pxe-bootstrap.ipxe- static iPXE script that dnsmasq points iPXE clients at on their second-stage DHCP. Returnschain http://<host>/pxe/${net0/mac:hexhyp}where<host>is the request’sHostheader, so the client always loops back to whichever IP / hostname / .local name it used to reach the server.GET /boot/{name}- serve a live-env artifact fromBTY_PATHS_BOOT_DIR(default/var/lib/bty/boot/). Same trust model as/pxe/*. Operators populate the dir via the browser UI’s “Fetch netboot artifacts” button on the Netboot page, or with the auth-gatedPUT /boot/{name}upload route.GET /images- list the catalog (array ofImageEntry). Open so the PXE-bootedbtyflow can enumerate from inside the live env without bootstrapping a session. The companionGET /images/{key}[/{name}]stream-proxy was removed in v0.60.0: oras catalog entries now reach the live env either through withcache (when configured) or as the raworas://URL the live env’s bty TUI handles itself viawithcache.oras(resolve + bearer + curl).GET /catalog.toml- same row set asGET /images, serialised as abty.catalog.CatalogTOML manifest (version = 1,[[images]]tables). Open for the same reason; consumed bybty --catalogso the same client code path that handles static files (e.g. on GitHub releases) works against a live bty-web.
Protected routes (session cookie required):
Method |
Path |
Body |
Returns |
|---|---|---|---|
GET |
|
- |
array of |
GET |
|
- |
|
GET |
|
- |
raw |
GET |
|
- |
lsblk-derived disk inventory JSON (404 if none posted) |
PUT |
|
|
|
DELETE |
|
- |
204 (404 if missing) |
POST |
|
|
new entry (201) |
GET |
|
- |
array of catalog rows |
DELETE |
|
- |
204 (404 if missing) |
POST |
|
- |
|
POST |
|
(multipart |
303 -> |
POST |
|
- |
303 -> |
GET / POST / DELETE |
|
(BackupManager) |
trigger / list / cancel backups |
GET / POST / DELETE |
|
(ReleaseFetchManager) |
trigger / list / cancel netboot-artifact pulls |
Schema mismatch on upgrade (v0.33.0+)¶
When bty-web starts and finds a state.db whose bty_version
disagrees with the running release (or no marker at all – a
pre-versioning DB), bty.web._db.init_db rotates the old DB to
state.db.<from>.<UTC-iso>.bak and creates a fresh one. A
system.schema.reset event with details = {from_version, to_version, archived_at} is recorded in the fresh DB.
The rotation surfaces as an unacknowledged event on the dashboard
tripwire; acknowledge from /ui/events. The .bak file is a
normal sqlite DB an operator can open with sqlite3 to recover
specific rows. See operations.md for the full upgrade flow.
POST /catalog/import parses the TOML at source (path,
http(s)://, or oras://) via bty.catalog.load_source and adds
each entry to the catalog as metadata. No bytes are fetched at import
time – v0.40+ bty-web has no image-store; the live env streams
each entry’s URL directly (via withcache when warm) at flash time.
Idempotent: re-importing the same source skips duplicates by src.
MAC addresses are accepted in any case + :-or-- separated, and
normalised to lower-case aa:bb:cc:dd:ee:ff.
Wire types¶
Machine = {
"mac": "aa:bb:cc:dd:ee:ff",
"bty_image_ref": "<64-hex>" | null, # null = discovered but unassigned
# references catalog_entries.bty_image_ref
# (sha256 of canonicalised src URL)
"labels": ["rack-3", "noisy", ...], # free-form display tags; each
# alnum-leading + alnum/space/-/_/.,
# max 64 chars per tag, 16 per machine,
# alphabetical on read
"discovered_at": "<ISO 8601>" | null, # first /pxe contact; null if PUT-only
"last_seen_at": "<ISO 8601>" | null, # most recent /pxe contact
"last_seen_ip": "203.0.113.42" | null,
"boot_mode": "ipxe-exit" # one of ipxe-exit /
| "bty-flash-always" # bty-flash-always /
| "bty-flash-once" # bty-flash-once /
| "bty-tui" # bty-tui / bty-inventory;
| "bty-inventory", # what /pxe/{mac} returns
"sanboot_drive": "0x80" | null, # iPXE BIOS drive for sanboot
# (null = default 0x80)
"last_flashed_at": "<ISO 8601>" | null, # set by POST /pxe/{mac}/done
"known_disks": [{ ... InventoryDisk ... }] | null,
# most recent POST /pxe/{mac}/inventory;
# populates the /ui/machines/{mac}
# target-disk dropdown
"known_disks_at": "<ISO 8601>" | null, # when the inventory above was posted
"target_disk_serial": "<vendor serial>" | null,
# operator pick from known_disks;
# required for plan.mode=flash
"created_at": "<ISO 8601>",
"updated_at": "<ISO 8601>"
}
MachineUpsert = {
"bty_image_ref": "<64-hex>" | null,
"labels": [str, ...], # free-form display tags;
# set-semantic (the list replaces
# all prior labels for this MAC).
# Default [] when omitted.
"boot_mode": "ipxe-exit" # default "ipxe-exit" on PUT;
| "bty-flash-always" # auto-discovery sets
| "bty-flash-once" # "bty-inventory"; the
| "bty-tui" # flash policies require a
| "bty-inventory", # target_disk_serial
"sanboot_drive": str | null, # iPXE BIOS drive for sanboot
# (e.g. "0x80"; null = default)
"target_disk_serial": str | null # required when boot_mode is
# bty-flash-always / -once --
# /ui/machines/{mac} POST
# refuses without it
}
CatalogEntry (as returned by `GET /catalog/entries`) = {
"bty_image_ref": "<64-hex>", # PK; sha256(canonicalise_src(src))
"src": "file://..." | "https://..." | "oras://...",
"disk_image_sha": "<64-hex>" | null, # declared content sha;
# populated only when the
# publisher pinned it (TOML
# sha256, sha_url, or oras
# layer digest)
"name": "<filename>",
"format": "img.gz" | "img.zst" | ...,
"size_bytes": int | null,
"sha_url": "https://.../<name>.sha256" | null,
"description": str | null,
"added_at": "<ISO 8601>"
}
ImageEntry = {
"name": "debian.qcow2",
"format": "qcow2",
"size_bytes": 268435456,
"url": "http://server:8080/images/<disk_image_sha>/<name>"
| "https://..." | "oras://...",
"ref": "<64-hex>", # bty_image_ref (=
# sha256(canonicalise_src(src)));
# the value to PUT as
# MachineUpsert.bty_image_ref
# without recomputing the
# canonicalisation client-
# side
"sha_short": "<12-hex>" | null, # display-only prefix
# of disk_image_sha
"cached": true | false # true iff bty-web has
# the bytes on disk
}
InventoryDisk = {
"path": "/dev/sda", # /dev path at inventory time
# (not the durable id)
"size": "500G" | null, # lsblk human-readable string
"vendor": "ATA" | null,
"model": "Samsung 980" | null,
"serial": "<vendor serial>" | null, # the durable id; used at
# flash time
"tran": "sata" | "nvme" | "usb" | null,
"removable": false,
"readonly": false
}
The POST /pxe/{mac}/inventory body is {"disks": [InventoryDisk, ...]}
plus an optional "lshw" field carrying the full lshw -json hardware
tree (CPU / RAM / NICs + MACs / peripherals / firmware). bty collects
it on every live-env boot. It is supplementary: the flasher only
consumes disks (from lsblk); lshw is stored as a blob, surfaced on
the Machine view, and downloadable raw at
GET /machines/{mac}/lshw.json (size-capped server-side; an oversize
or absent blob leaves any prior one intact).
Configuration¶
The canonical operator config is a bty.toml file (located via
BTY_CONFIG_FILE / BTY_CONFIG_DIR, or the default search list
/etc/bty/conf.d/ -> /etc/bty/bty.toml ->
<state_dir>/bty.toml), with per-key env overrides following the
BTY_<SECTION>_<KEY> convention.
Variable |
Purpose |
Default |
|---|---|---|
|
Where |
|
|
Live-env artifacts ( |
|
|
GitHub repo ( |
|
|
uvicorn bind address |
|
|
uvicorn port |
|
Browser UI (/ui)¶
bty-web ships a server-rendered browser UI under /ui (Jinja templates,
Bootstrap CSS, HTMX form posts).
GET /ui-> 303 redirect to/ui/dashboardGET /ui/login-> login formPOST /ui/login-> constant-time-compares the password against[admin] passwordfrombty.toml(env overrideBTY_ADMIN_PASSWORD; defaultbty-lab) and flipsrequest.session["bty_authed"] = True; SessionMiddleware emits the signedbty-tokencookie on the redirect response (SameSite=Strict).POST /ui/logout->request.session.clear(); SessionMiddleware emits a deletion cookie.GET /ui/dashboard-> overview (machine count, discovered count, image count) + sanity-checklist card (one row per readiness condition: netboot artifacts present / catalog non-empty / TFTP daemon running, with deep-links into the relevant page when a condition fails) + recent-activity sliceGET /ui/machines-> table of all machines with a “discovered” badge for unassigned rows; auto-refreshes via SSEGET /ui/machines/{mac}-> detail + edit formPOST /ui/machines/{mac}-> upsert from a form submitPOST /ui/machines/{mac}/delete-> delete recordGET /ui/images-> image catalog page (the unified dir-scan + catalog-entry listing, with Fetch-latest-catalog / Upload-catalog controls in its header). The “Add image” card below the list carries the per-image “Add by URL” + local-upload widgets.POST /ui/catalog/entries(form) andPOST /catalog/entries(JSON) -> add an operator-curated catalog entry.image_urlaccepts http(s):// URLs andoras://references; fororas://the server resolves the OCI manifest at add time, uses the layer’s content-addressed digest as the entry’s sha256 (= machine-bindable), and skips the optional sha_url branch (manifest is authoritative).GET /ui/netboot(Netboot) -> the netboot artifacts inventory (present/missing per artifact, sizes, last-fetched timestamps) + the Fetch artifacts trigger and active-fetch table (release trio + sha256 manifest) + an observation-only TFTP daemon panel: the livesystemctl is-active dnsmasq.servicestate badge plus a short triage hint. Lifecycle (start/stop/restart) is left to systemd / Podman; the UI no longer drives it. An in-page sub-nav jumps between Artifacts / TFTP Daemon / Activity.GET /ui/backups(Backups) -> Back-up-now trigger + active backups list + schedule summary (links to the Settings backup- schedule card) + recentbackup.created/backup.failed/backup.prunedevents. Each worker page lights only its own navbar indicator.The router-config DHCP / Network boot cheatsheet (host-IP / interfaces table + option 60 / 66 / 67 values to paste into the LAN’s DHCP server, for both PXE-via-TFTP and UEFI HTTP Boot) lives on the Settings page (
/ui/settings#dhcp-pxe). bty does NOT run any DHCP role; the operator’s existing DHCP server points clients at this host for TFTP + HTTP-Boot fetches.POST /ui/netboot/fetch-release-> downloadsvmlinuz/initrd/squashfs/sha256fromhttps://github.com/<BTY_BOOT_RELEASE_REPO>/releases/<tag>/download/(defaultsafl/bty, default taglatest); verifies the manifest and atomically installs intoBTY_PATHS_BOOT_DIR.GET /ui/settings-> the config page: the editable Catalog card (singlecatalog_urlfield) full-width on top, Netboot release (release repo + tag) and Backup schedule (enabled / cadence / retention) cards side-by-side, then read-only Identity / Storage / Network config groups (each row’s source: env var / TOML / default, with an inline edit form when sourced from TOML), plus the DHCP / Network boot router cheatsheet. Operator authentication is on the separate Account page (/ui/account, reached via the user pill): the credential is[admin] passwordinbty.toml(env overrideBTY_ADMIN_PASSWORD), rotated by changing the value and restarting bty-web; to invalidate every session at once, rotate the cookie-signing secret withrm /var/lib/bty/session-secret && systemctl restart bty-web.POST /ui/settings/upstream-> persists the netboot repo / tag and the catalog URL into thesettingstable; fetch routes resolve from this at request time so the changes take effect without a restart.POST /ui/settings/backup-> persists the scheduled-backup knobs (enabled / cadence / retention); the scheduler picks them up on the next 60s tick.POST /ui/settings/config/edit-> per-row inline edit form for the read-only config groups (rows whose source istomlcarry an Edit affordance); the handler validates the field, round-trips the value through tomlkit to preserve operator formatting, and reloads the active config inline so the next render reflects the change.
The auth dependency checks request.session.get("bty_authed"); the
session is a Starlette SessionMiddleware-signed payload carried in the
bty-token cookie, so no per-request DB hop is needed. Logging out clears
the session dict; SessionMiddleware emits a deletion cookie.
Static assets (offline-friendly)¶
Bootstrap CSS, HTMX, and the HTMX SSE extension are vendored into the
wheel under bty.web._static/ and served at /static/. bty-web
contacts no CDN at runtime; all browser code is served from the same
origin. See src/bty/web/_static/README.md for asset versions and the
refresh procedure.
Live updates (GET /events/machines)¶
The machines table subscribes to a Server-Sent Events stream so the operator need not refresh after PXE auto-discovery or another admin’s edit. The endpoint:
Authenticates with the same session-cookie dep as the rest of the API. Browsers carry the cookie automatically; the SSE
EventSourceAPI does not let you set custom headers.Sends
Content-Type: text/event-streamand an initialmachines-updateevent containing the current<tbody>snapshot on connect.Emits a fresh
machines-updateevent after every mutation (PUT /machines/{mac},DELETE /machines/{mac}, the corresponding/uiform posts, and PXE auto-discovery on/pxe/{mac}).
The fan-out bus is in-process; slow consumers are silently dropped (every event carries the full snapshot, so they catch up on the next mutation). Single uvicorn worker is required: a multi-worker deployment would need a real broker (Redis pub/sub, NATS, …), overkill for a single bty-web serving a homelab fleet.
Configuration schemas¶
Schemas for the on-disk configuration files used by bty and
bty-web. Populated alongside the relevant features.
State export / import format¶
v0.33.2+ (bty_export_version = 3): a directory containing a single
inventory.json. No image bytes; v1 (pre-v0.31.0) and v2
(v0.31.0..v0.33.1, with image bytes) bundles are refused on import.
inventory.json shape (the ... placeholders below stand in for
elided keys / nested children; the live file is strict JSON):
{
"bty_export_version": 3,
"exported_at": "2026-05-25T14:30:00+00:00",
"exported_by_bty_version": "0.33.2",
"machines": [
{
"mac": "aa:bb:cc:dd:ee:ff",
"known_disks": [{"path": "/dev/sda", "serial": "..."}],
"known_disks_at": "2026-05-25T10:00:00+00:00",
"hw_lshw": {"id": "system", "product": "...", "children": [...]},
"hw_lshw_at": "2026-05-25T10:00:00+00:00"
}
]
}
known_disks and hw_lshw are native objects/arrays (not
re-encoded JSON strings), so jq '.machines[].hw_lshw.product'
works directly. Import inserts each machine as
boot_mode=bty-inventory with bindings cleared; the operator
re-binds image + boot mode after bty-web import.