Skip to main content

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:

  1. Protected tag pattern — Settings → Repository → Protected tags — lr-fleet-*, allowed to create: Maintainers.
  2. Credentials — PYPI_TOKEN and GLADIATOR_USERNAME/GLADIATOR_PASSWORD are inherited from the root lumenradio group, 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)

Source distribution for lr-fleet 0.1.0
File Size Uploaded
lr_fleet-0.1.0.tar.gz 60.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for lr-fleet 0.1.0
File Interpreter ABI Platform
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

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page