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 of that file; substitute latest for a specific tag (e.g.
v0.2.7) to pin.
Asset |
What it is |
URL (latest) |
|---|---|---|
|
Bootable USB live image. Write with |
https://github.com/safl/bty/releases/latest/download/bty-usb-x86_64.img.zst |
|
Server appliance image, x86_64 (browser UI + iPXE + dnsmasq). Boot in QEMU or |
https://github.com/safl/bty/releases/latest/download/bty-server-x86_64.img.zst |
|
Server appliance image for Raspberry Pi 4 / 5 (arm64). Write with |
https://github.com/safl/bty/releases/latest/download/bty-server-rpi-arm64.img.zst |
|
Netboot trio for PXE-flash clients. Drop into the server’s |
https://github.com/safl/bty/releases/latest/download/bty-live-x86_64.vmlinuz |
|
Offline copy of the docs (this site, rendered by Sphinx + LaTeX). |
https://github.com/safl/bty/releases/latest/download/bty.pdf |
|
Python wheel + sdist. Mirrored on PyPI as |
The browser path is https://github.com/safl/bty/releases; the JSON
API for build automation is GET /repos/safl/bty/releases/latest.
CLI¶
The bty command groups operations as subcommands. Each leaf command
accepts --json to emit machine-readable output instead of the default
human-readable table.
bty --version prints the installed version (sourced from package
metadata) and exits.
JSON output envelope¶
Every --json output is wrapped:
{
"schema_version": "1",
"command": "<subcommand-name>",
...command-specific fields...
}
Agents key off schema_version; incompatible structural changes bump
the version. See AGENTS.md
for the full per-command schema reference and the exit-code table.
Exit codes¶
Code |
Meaning |
|---|---|
0 |
Success. |
1 |
Operation failed (validation rejected the plan; write subprocess returned non-zero; cloud-init / cijoe step failed). |
2 |
Misuse - argparse error, missing required flag, missing input file. |
3 |
Privilege required - operation needs root, rerun via |
4 |
Required external tool is not installed (e.g. |
5 |
Target raced - block device became mounted or otherwise unsuitable between validation and write. |
bty list disks¶
List interesting block devices on the local system. Shells out to
lsblk -J and projects useful columns: path, size, tran (bus
transport), vendor, model, serial, removable.
PATH SIZE TRAN VENDOR MODEL SERIAL REMOVABLE
------------ ---- ---- ------ ----------------- -------------- ---------
/dev/nvme0n1 1T nvme Samsung 980 PRO NVME0X000001 False
/dev/sda 500G sata ATA Samsung SSD 870 S5SUNG0123456 False
bty list images [--image-root PATH]¶
List supported images directly under the image root (non-recursive).
Recognised formats: .qcow2, .img, .img.zst.
The image root is resolved in this order:
The
--image-rootargument, if given.The
BTY_IMAGE_ROOTenvironment variable./var/lib/bty/images(the path the bty USB live appliance auto-mounts theBTY_IMAGESpartition at).
bty inspect image PATH¶
Print detailed metadata for a single image file. Always reports
path, format, and size_bytes. Adds a format-specific detail
block when the relevant tool succeeds:
.qcow2->qemu-img info --output=json.img.zst->zstd -l.img-> nothing extra (raw images have no header to query)
Exit codes:
0-> success2-> the path does not exist (or argparse rejected the invocation)
bty flash --image PATH --target PATH [--provision MODE] [--user-data PATH] [--meta-data PATH] [--cijoe-workflow PATH] [--cijoe-config PATH] [--progress {text,ndjson,none}] [--dry-run] [--yes]¶
Flash an image onto a target block device.
Either --dry-run or --yes is required:
Flags |
Behaviour |
|---|---|
|
Validate the plan; no writes. Exit |
|
Validate, then write. Requires root. |
(neither) |
Refuse with exit |
|
|
Validation¶
Both modes start by validating the plan:
Image exists and is a recognised format (
.qcow2/.img/.img.zst).Image virtual size (decompressed / qcow2-virtual size, not on-disk size) fits the target. Skipped with a note if the virtual size cannot be determined (e.g.
qemu-img infofailure).Target exists and is a block device.
Target has no mounted partitions (refuses to overwrite live storage).
Provisioning mode is one of
none,cloud-init,cijoe.
Write (--yes only)¶
If validation passes and bty is running as root, the write proceeds
in a format-specific way:
.img->dd if=IMG of=TARGET bs=4M conv=fsync status=progress.img.zst->zstd -d --stdout IMG | dd of=TARGET bs=4M conv=fsync status=progress.qcow2->qemu-img convert -p -O raw IMG TARGET
Immediately before the write, the target is re-probed and re-validated
to catch races (e.g. the target getting mounted between dry-run and
flash). On success, bty runs sync and partprobe TARGET so the
kernel re-reads the new partition table.
Provisioning¶
After the flash, bty runs the configured post-flash step:
none- no post-flash work; the cooked image is the result.cloud-init- mounts the partition on the target whose rootfs carries/etc/cloud/(the unambiguous “cloud-init lives here” marker), writes operator-supplieduser-data(and either supplied or auto-synthesisedmeta-data) under/var/lib/cloud/seed/nocloud-net/so cloud-init’s NoCloud datasource picks them up on first boot. Requires--user-data PATH; rejects with exit2if the flag is missing. Errors loudly if no partition on the target appears to have cloud-init installed, rather than silently writing a seed nothing will read.cijoe- mounts the largest partition on the target (heuristic for the rootfs), exportsBTY_ROOTFSpointing at the mount, then invokescijoe <workflow> --monitor [-c <config>]. The workflow’s tasks read or mutate the rootfs through$BTY_ROOTFS; bty itself does not interpret what they do. Requires--cijoe-workflow PATH; rejects with exit2if missing. RequirescijoeonPATH(pipx install cijoe); errors clearly if absent. Workflow exit non-zero is propagated as a flash failure.
Progress¶
--progress {text,ndjson,none} controls lifecycle reporting (default
text).
Lifecycle events: started, writing, synced, partprobed,
provisioning (cloud-init / cijoe steps only), done, failed.
text(default) - one line per event on stderr ([event] note).ndjson- one JSON object per line on stdout ({"event":"started","total_bytes":12345}etc.). Use this from agents and CI scripts.none- no lifecycle output. Subprocess noise (dd status=progress) still goes to stderr in all modes; redirect if you want a clean channel.
The same callback shape (bty.flash.ProgressCallback /
bty.flash.FlashProgress) is used by bty-tui’s flash modal - UI
updates and CLI output share the same event stream.
Exit codes (specific to bty flash)¶
0-> success (validation passed for--dry-run; write completed for--yes).1-> validation failed, or a write / provisioning subprocess returned non-zero.2-> argparse error, missing image, missing--user-data/--cijoe-workflow, neither--dry-runnor--yesgiven.3->--yeswas passed without root.4-> required external tool missing (e.g.cijoefor--provision cijoe).5-> target raced (became mounted or stopped being a block device between validation and write).
The general exit-code table at the top of this section applies to all subcommands.
bty-tui [--server URL] [--mac MAC]¶
Two-pane terminal UI for picking an image + a target disk and
flashing. Same flash machinery as the CLI; the TUI is a thin wrapper
around bty.flash.execute_plan.
Two image-source modes:
Local (default). Scans an image-root directory (USB live env’s
BTY_IMAGESpartition, or whatever pathBTY_IMAGE_ROOTpoints at).Remote (
--server URL). Fetches the catalog from a runningbty-webviaGET /images. Selecting an image streams it from the server’sGET /images/{name}straight to the target disk - no local download. The TUI’s pane title shows the server URL so the operator can see at a glance where the catalog comes from.
--mac MAC is used together with --server: after a successful
flash the TUI POSTs <server>/pxe/<mac>/done so the server’s
last_flashed_at updates. Best-effort - a failed signal surfaces
in the status bar but doesn’t undo the flash.
The TUI-on-PXE flow uses both flags: the live env reads bty.server
and bty.mac from /proc/cmdline and assembles the matching CLI
invocation in /usr/local/sbin/bty-tui-on-tty1.
Configuration¶
bty resolves a small set of paths and runtime knobs from the environment and sensible defaults.
Environment variables¶
Variable |
Purpose |
Default |
|---|---|---|
|
Image root for |
|
The bty --image-root flag (when given) takes precedence over
BTY_IMAGE_ROOT.
Default paths¶
/var/lib/bty/images- image root. The USB live appliance auto-mounts theBTY_IMAGESpartition here.
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_STATE_DIR/state.db (default /var/lib/bty/state.db).
Auth¶
Single-tenant PAM authentication. bty-web runs as a Linux service
user (typically bty); the only credential is that user’s OS
password. passwd bty rotates it. POST /ui/login (form-
encoded password=...) PAM-checks the password and flips
request.session["bty_authed"] = True; the session is a server-
signed cookie managed by Starlette’s
- class:
SessionMiddleware(cookie namebty-token, sliding 7-day TTL). No DB-backed session table - the cookie value is the session, signed against the per-appliance key at/var/lib/bty/session-secret(generated bybty-web-initon first boot).POST /ui/logoutclears the session.
Open routes - these are reachable by PXE clients and other live-env tooling which 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_policy:local(default) or no image assigned: sanboot fallback (“boot from local disk”). Auto-discovery still applies to unknown MACs.flash+ image assigned: chain into the live env over HTTP with kernel cmdline paramsbty.server,bty.mac,bty.image_url,bty.provisioningso the live env can flash the assigned image.
Auto-discovery: the first contact for an unknown MAC inserts a
placeholder row (image=null, boot_policy=local) so the operator
sees it in GET /machines and can claim it with PUT /machines/{mac}. Repeat contacts update last_seen_at /
last_seen_ip. Trust model: bty-web is meant 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_at. Does not modifyboot_policy- flipping a machine back tolocalis an explicit operator action so the per-job CI cadence (constant reflashing) survives across boots. If the machine hasprovisioning_mode='cijoe-online'and acijoe_workflow_ref, this also kicks off a background workflow run from bty-web against the freshly-booted target (milestone 15). Status surfaces vialast_workflow_statusand the SSE machines-update channel.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_BOOT_DIR(default/var/lib/bty/boot/). Same trust model as/pxe/*. Operators populate the dir via the browser UI’s “fetch latest release” button on the Boot page, or with the auth-gatedPUT /boot/{name}upload route.GET /images/{name}- serve image bytes fromBTY_IMAGE_ROOT. Used by the live env to download the assigned image; reachable by anyone on the network. Companion auth-gated upload route atPUT /images/{name}for operators / scripts.GET /images- list the catalog (array ofImageEntry). Open for the same reason asGET /images/{name}: the bty-tui-on-PXE flow needs to enumerate from inside the live env without first bootstrapping a session, and discovery adds no capability beyond what the already-open byte-serving route provides.
Protected routes (session cookie required):
Method |
Path |
Body |
Returns |
|---|---|---|---|
GET |
|
- |
array of |
GET |
|
- |
|
PUT |
|
|
|
DELETE |
|
- |
204 (404 if missing) |
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",
"image": "debian.qcow2" | null, # null = discovered but unassigned
"provisioning_mode": "none" | "cloud-init" | "cijoe",
"hostname": "..." | null,
"cijoe_workflow_ref": "..." | null,
"last_known_good": object | null,
"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_policy": "local" | "flash", # what /pxe/{mac} returns
"last_flashed_at": "<ISO 8601>" | null, # set by POST /pxe/{mac}/done
"last_workflow_run_at": "<ISO 8601>" | null,
"last_workflow_status": "running" | "success" | "failed" | null,
"last_workflow_output_path": str | null, # /var/lib/bty/workflows/<mac>/<run-id>
"created_at": "<ISO 8601>",
"updated_at": "<ISO 8601>"
}
MachineUpsert = {
"image": str | null,
"provisioning_mode": "none" | "cloud-init" | "cijoe" | "cijoe-online",
"hostname": str | null,
"cijoe_workflow_ref": str | null,
"boot_policy": "local" | "flash" # default "local"
}
ImageEntry = {
"name": "debian.qcow2",
"path": "/var/lib/bty/images/debian.qcow2",
"format": "qcow2",
"size_bytes": 268435456
}
Configuration¶
Variable |
Purpose |
Default |
|---|---|---|
|
Where |
|
|
Image catalog directory |
|
|
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-> validates the password against PAM 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)GET /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-> read-only image catalogGET /ui/boot-> live-env boot artifacts: present/missing per artifact, sizes, last-fetched timestamps, “fetch latest release” formPOST /ui/boot/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_BOOT_DIRGET /ui/settings-> two-card page:Authentication - explanatory text only. The credential is the OS password of the bty service user; the operator rotates it with
sudo passwd bty. To force every session to invalidate in one shot, rotate the cookie-signing secret withrm /var/lib/bty/session-secret && systemctl restart bty-web.PXE proxy-DHCP - interface dropdown (read from
/sys/class/net/) + subnet input (192.168.1.0or192.168.1.0/24). Activate callsbty-web-activate-pxewhich writes/etc/dnsmasq.d/bty-pxe-active.confand restarts dnsmasq.POST /ui/settings/pxe-activate-> drivesbty-web-activate-pxe, a sudoers-permitted helper in/usr/local/sbin/that writes/etc/dnsmasq.d/bty-pxe-active.confand restarts dnsmasq. The NOPASSWD entry in/etc/sudoers.d/bty-webis the only sudo grant the appliance gives bty-web.
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 on the response.
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/. The bty
appliance does not contact any CDN at runtime - all browser code is
served from the same origin. See src/bty/web/_static/README.md in
the source tree 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 does not have to 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, …), which is overkill for an appliance 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¶
Format of the archive produced by bty-web’s state export, and
expected by import. Populated alongside the export/import feature.