lr-fleet
CLI client for the LumenRadio fleet management REST API: approve stations, watch the roster, and pull the article divergence pivot, from a terminal or a script.
pip install lr-fleet
fleet login
fleet list
Install
pip install lr-fleet
Python 3.9 or later.
Sign in
fleet login runs an Entra device-code sign-in: it prints a URL and a short code, you enter
the code in a browser, and the CLI polls until you finish. There is no password or client
secret involved — signing in proves you are a member of the Production Technicians Entra
group, the same group the web UI checks, so a technician the group excludes is refused at the
CLI too.
fleet login
# To sign in, use a web browser to open the page https://microsoft.com/devicelogin
# and enter the code ABCD-1234 to authenticate.
The token is cached at ~/.config/lr-fleet/token.json, mode 0600. Every later command reads
it and refreshes it automatically once it is close to expiry. fleet logout removes the cache.
fleet status shows who you are signed in as, without needing another sign-in:
fleet status
# base url: https://fleet.cloud.lumenradio.com
# user: tech@lumenradio.com
# roles: user.admin.api
# scope: read.api write.api admin.api
# expires: 2026-09-25T18:00:00+00:00
status decodes the cached token's payload to show this. It does not check the token's
signature — that would be pointless work the fleet service always redoes on every request — so
treat this output as a label, not a guarantee that the token still works.
Commands
| Command | Does |
|---|---|
fleet list |
List every rostered device. --product-id, --online/--offline filter it. |
fleet show <uid> |
One device's detail: liveness, pairing code, thumbprint, composites. |
fleet pending |
The pending-enrolment queue, ordinary entries and key-replacement candidates. |
fleet approve <uid> --name NAME |
Approve a pending enrolment, after confirming its pairing code. |
fleet reject <uid> |
Reject a pending enrolment. Asks to confirm unless --yes. |
fleet revoke <uid> |
Revoke a device. Asks to confirm unless --yes. |
fleet replace-key <uid> |
Approve a device's pending key-replacement candidate, after confirming its pairing code. |
fleet provision --yubikey --name NAME |
Generate a station key on the attached YubiKey, register it and pre-approve the station it becomes. --statement FILE registers a TPM's attestation instead. |
fleet device init / enrol / report / show / attest |
Act as a fleet device, with a software key, a YubiKey or the machine's TPM. |
fleet articles <article> |
Every station holding an article, grouped by Build Words; flags disagreement. |
fleet login / fleet logout / fleet status |
Manage the cached sign-in. |
Every read command takes --json and prints the server's raw JSON instead of a table, for
piping into jq or another script. Every write command takes --json too, and prints the
resource the server returned.
The pairing-code rule
fleet approve and fleet replace-key never post on a bare uid. Each command looks up the
pending entry, prints the pairing code the service holds for it along with its hostname,
software version and source IP, and then asks you to type the code shown on the station's own display:
fleet approve a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 --name "Bench 3"
# Pairing code: WOLF-WOLF-YOLK
# Hostname: rc2038-bench3
# Version: 0.6.0
# Source IP: 10.20.4.11
# Type the pairing code shown on the station: wolf wolf yolk
# Approved a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 as 'Bench 3'.
The comparison ignores case, - and whitespace, but a mismatch still exits 1 without posting
anything. The fleet service never accepts a pairing code as input — approving a device by
comparing what the operator sees against what the station displays is entirely the client's
job, so the CLI enforces it even in a script: pass --pairing-code CODE to supply the typed
value non-interactively, but there is no flag that skips the comparison itself.
Provision hardware for an EMS station
A station whose key is in hardware LumenRadio registered beforehand is admitted on its first enrolment, with no one approving it at the EMS. Provisioning needs the admin tier.
A YubiKey (pip install "lr-fleet[yubikey]"), plugged in:
fleet provision --yubikey --name "EMS bench 1" --shipped-to "Example EMS"
# Registered yubikey_piv 31234567 as 'EMS bench 1'.
# Firmware: 5.7.2
# Key: 9e
# Pairing code: ABCDE-FGHJK
# Pre-approved until: 2026-10-25T12:00:00Z
This generates the station key in slot 9e with PIN and touch policy "never", so the station
signs unattended, then posts the slot's and the YubiKey's own attestation certificates. It
refuses to overwrite a key already in the slot unless given --replace, and uses the factory
management key. The service reads the serial, firmware and slot from the certificates and
refuses firmware below 5.7. Certificates exported by ykman can be posted instead with
--slot-attestation FILE --token-attestation FILE, PEM or DER.
A TPM, on a machine prepared at LumenRadio (pip install "lr-fleet[tpm]", Linux):
fleet device init --key tpm # on the machine: the key is created inside its TPM
fleet device attest > bench.json # its TPM2_Certify statement
fleet provision --statement bench.json --name "EMS bench 2"
--product-id defaults to the LumenTest station, 504-1007, and --expires-in-days (1–90,
default 30) bounds how long the pre-approval lasts. After it lapses the station still installs
and enrols, and waits for fleet approve like any other.
Act as a device
fleet device plays a fleet device from a terminal — the way to try the enrolment workflow by
hand. It signs as the device, so it needs no sign-in, and keeps the identity in
~/.config/lr-fleet/device (--dir or FLEET_DEVICE_DIR moves it).
fleet device init # a software key; --key yubikey or --key tpm
fleet device enrol --enrol-token "$PSK" # 202 pending: compare the pairing code, then approve
fleet approve <uid> --name "Bench 3" # as a technician
fleet device enrol # 200 enrolled
fleet device report --composites c.json # one snapshot; default: this machine's os composite
A TPM key sends its attestation on enrolment, so the station waits in the pending queue marked as hardware-held. An attestation that cannot be produced or does not verify leaves it an ordinary software-key enrolment — never a refusal.
Use it as a library
Any Python program can be a fleet device with lr_fleet.device: the key holders, the signed
wire and the enrol/report client, the same code the commands above run.
from pathlib import Path
from lr_fleet.device import DeviceClient, DeviceIdentity, os_composite
identity = DeviceIdentity.load_or_create(Path("/var/lib/my-app/fleet"))
client = DeviceClient(identity, "https://fleet.cloud.lumenradio.com")
response = client.enroll("504-1007", [os_composite()], enrol_token=psk)
if response.enrolled:
client.snapshot([os_composite(), {"type": "my_app", "version": 1, "data": {...}}])
A device describes itself only in composites — {"type", "version", "data"} — and the fleet
renders each by its type. No call raises for an HTTP status: pending, revoked and replayed
are outcomes to branch on. Only an unreachable fleet raises NetworkError, which a program that
must never be disturbed by the fleet catches and ignores. The identity file saves each request's
sequence number before sending it, so a crash never locks the device out.
lr_fleet.device.yubikey.YubiKeyPivKey and lr_fleet.device.tpm.Tpm2Key are the hardware
holders; DeviceIdentity.create(directory, holder) takes one.
A program that should simply keep reporting — a test station, a label printer — hands the
client to a DeviceReporter instead of driving it by hand:
from lr_fleet.device import DeviceReporter
reporter = DeviceReporter(
client,
product_id="504-1007",
composites=lambda: [os_composite(), my_app_composite()],
enrol_token=psk,
)
reporter.start() # a daemon thread; reporter.stop() on shutdown
print(reporter.phase, reporter.pairing_code, reporter.last_error)
It enrols until the fleet admits the device, then posts the composites every interval the fleet
hands back, and acts on every answer: key_mismatch waits for a technician, revoked ends the
thread, Retry-After is honoured, and an unreachable fleet backs off to five minutes. Every
failure — the composites callable's included — is logged and swallowed, so reporting can never
disturb the program. phase, pairing_code, last_report_at and last_error are what to show
locally, where whoever commissions the device can read them.
The wire, the answers and what a device must do with each are normative in the fleet wire
contract, doc/fleet-wire-contract.md in the
luminance2 repository.
Scripting
FLEET_TOKEN, when set, is used as the bearer token verbatim; nothing is read from or written
to the token cache. This is the path for CI and other unattended callers:
export FLEET_TOKEN="$(some-secret-store read fleet-ci-token)"
fleet list --json | jq -r '.[] | select(.liveness != "online") | .uid'
--base-url, the FLEET_URL environment variable, and the default
(https://fleet.cloud.lumenradio.com) are checked in that order. Point at the test instance,
https://develop.fleet.cloud.lumenradio.com, the same way:
export FLEET_URL=https://develop.fleet.cloud.lumenradio.com
fleet login
Testing the hardware holders
make test runs offline and never touches hardware. make hardware-test runs the TPM holder
against a software TPM (swtpm) with tpm2-pytss, in tests/hardware/Dockerfile's image; it needs
Docker, not a TPM. No test drives a physical YubiKey: the YubiKey holder is tested against a
stand-in for yubikit.
Exit codes
0 on success. 1 on a user or server error — not signed in, a rejected pairing code, a
403 from the API, a 404, and so on. 2 on a usage error (a missing required option), from
typer's own argument parsing.
Releasing
This repo's pipeline, .gitlab-ci.yml, is copied from
devops/examples/pypi-example
and devops/examples/gladiator-example:
a protected release tag drives PyPI, then Arena (article 504-1014), then a manual change
order. Read those two repos' READMEs for what each check in the pipeline guards against.
Before tagging a release here, two settings must already exist on this project — copying the CI file cannot bring them with it, and without them the publish jobs are silently skipped:
- Protected tag pattern — Settings → Repository → Protected tags —
lr-fleet-*, allowed to create: Maintainers. - Credentials —
PYPI_TOKENandGLADIATOR_USERNAME/GLADIATOR_PASSWORDare inherited from the rootlumenradiogroup, already protected and masked there. This repo defines none of its own; do not add a project-level duplicate, since a project variable silently overrides the group's and turns one rotation point into two.
# 1. Bump the version in pyproject.toml, update CHANGELOG.md, merge to main.
# 2. Tag the merged commit. Only a Maintainer can create a protected tag.
git tag lr-fleet-0.2.0
git push origin lr-fleet-0.2.0
The tag pipeline runs Verify, Build, publish-pypi, then publish-to-arena.
submit-change-order-to-production stays manual: publishing a candidate is repeatable, but
submitting the change order that releases it is a decision a person makes after checking the
candidate.
| Target | Does |
|---|---|
make dev |
venv + editable install with dev extras |
make test |
pytest with coverage, fails under 90% |
make black |
format (BLACK_ARGS=--check to check only) |
make dist |
build the sdist and wheel |
make check |
dist + twine check --strict |
make check-version |
fail if the tag and pyproject.toml disagree |
make publish |
upload to PyPI (PYPI_TOKEN) |
make publish-test |
upload to TestPyPI (TEST_PYPI_TOKEN) |
A published version is spent. If a release is wrong, the fix is a new version, never a retry of the job — PyPI refuses the same version twice even if the wheel is byte-identical.
Release files for lr-fleet 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| lr_fleet-0.1.0.tar.gz | 60.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| lr_fleet-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 105.0 kB
Release files / lr_fleet-0.1.0.tar.gz
| Download URL | lr_fleet-0.1.0.tar.gz |
|---|---|
| Size | 60.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
01bd0fc91b446967fe4a919a9dc3e3d567949aa7bae9f9364d3cbcc301b5679e
|
|
BLAKE2b-256 checksum How to use checksums |
674365455af54f46258734a00090992b5183c4c45e93a2aebc19d1e1a201a99e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.14
|
Release files / lr_fleet-0.1.0-py3-none-any.whl
| Download URL | lr_fleet-0.1.0-py3-none-any.whl |
|---|---|
| Size | 44.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
df9fe16f782d802d79662a9e9e42d889e4b8c030bfe9d13e8f7361fc7918109a
|
|
BLAKE2b-256 checksum How to use checksums |
dc84ddea7781f87b6b8a8296643e39eefa3a55f3fd6ce0aa4dde4a68619fd6aa
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.14
|