Metadata-Version: 2.4
Name: lr-fleet
Version: 0.5.0
Summary: CLI client for the LumenRadio fleet management REST API.
Author-email: Jonas Estberger <jonas.estberger@lumenradio.com>
License: MIT License
Project-URL: Repository, https://gitlab.com/lumenradio/production/lr-fleet
Keywords: lumenradio,fleet
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.32
Requires-Dist: typer>=0.12
Requires-Dist: rich>=13.7
Requires-Dist: cryptography>=42
Requires-Dist: websockets>=15.0
Provides-Extra: yubikey
Requires-Dist: yubikey-manager>=5.5; extra == "yubikey"
Provides-Extra: tpm
Requires-Dist: tpm2-pytss>=2.2; extra == "tpm"
Provides-Extra: dev
Requires-Dist: build>=1.2.1; extra == "dev"
Requires-Dist: twine>=5.1.1; extra == "dev"
Requires-Dist: wheel; extra == "dev"
Requires-Dist: pytest>=8.4.2; extra == "dev"
Requires-Dist: black>=25.9.0; extra == "dev"
Requires-Dist: pytest-cov; extra == "dev"
Requires-Dist: tomli>=2.0; python_version < "3.11" and extra == "dev"
Dynamic: license-file

# 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.

```bash
pip install lr-fleet
fleet login
fleet list
```

## Install

```bash
pip install lr-fleet
```

Python 3.9 or later.

## Sign in

`fleet login` runs an Entra browser sign-in: it opens the Entra sign-in page in your browser
and captures the result automatically once you finish, with nothing to copy or paste. 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.

```bash
fleet login
# Opened the sign-in page in your browser. Complete sign-in there.
# Login successful. Token stored at /home/you/.config/lr-fleet/token.json
```

To work against the test instance, sign in to it; every later command then goes there until
you sign in again:

```bash
fleet login --base-url https://develop.fleet.cloud.lumenradio.com
fleet status     # base url: https://develop.fleet.cloud.lumenradio.com (from fleet login)
fleet pending    # develop
fleet login      # back to production
```

`fleet --base-url URL login` and `FLEET_URL=URL fleet login` store the URL the same way. A
plain `fleet login` always stores production, never the previous login's fleet. One Entra
sign-in works on every fleet, so `--base-url` on a single command reaches another fleet
without signing in again.

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` checks that the fleet answers and accepts your sign-in, and shows who you are
signed in as:

```bash
fleet status
# base url: https://fleet.cloud.lumenradio.com
# backend: reachable (fleet 0.1.0)
# user: tech@lumenradio.com
# roles: user.admin.api
# scope: read.api write.api admin.api
# expires: 2026-09-25T18:00:00+00:00
# authenticated: yes
```

`backend` is the fleet's unauthenticated health check. `authenticated` is the fleet's own answer
to a one-entry read with your token: `no` when it refuses the token or your account holds no
Fleet access, `unknown` when the fleet cannot be reached. `status` exits 0 only when both lines
pass, so a script can run it as a preflight. The `user`, `roles`, `scope` and `expires` lines
are decoded from the token without checking its signature; they are a label, and
`authenticated` is what says the token works.

## Commands

| Command | Does |
|---|---|
| `fleet list` | List every rostered device, with a `Tunnel` column that says `connected` when the device holds a relay tunnel. `--product-id`, `--online`/`--offline` filter it. |
| `fleet show <uid>` | One device's detail and its last snapshot as a tree: liveness, pairing code, thumbprint, then each reported composite (OS and hostname, test-station version, run state and test bundles, and so on) and a `remote access` line: `connected since <time>` or `not connected`. `--output json` prints the same device fields as JSON, with the last snapshot nested under `snapshot`. |
| `fleet pending` | The pending-enrolment queue, ordinary entries and key-replacement candidates. |
| `fleet approve <uid>` | Approve a pending enrolment, after confirming its pairing code. |
| `fleet reject <uid>` | Reject a pending enrolment. Asks to confirm unless `--yes`. |
| `fleet revoke <uid> --reason TEXT` | Revoke a device for good; it rejoins only with a new identity and a fresh approval. Asks for the reason when `--reason` is omitted, and to confirm unless `--yes`, which needs `--reason`. |
| `fleet revoked` | Every revoked device, most recent first, with who revoked it and why. |
| `fleet audit list` | The fleet's signed decision record, newest first: who did what to which station, through which client, and why. `--limit`, `--before` page back. |
| `fleet audit verify` | Check every entry's hash, link to the one before, signature and fields on this machine. Exits 1 on any problem. `--public-key FILE` trusts only that key; `--server` prints the fleet's own verdict instead. |
| `fleet audit keys` | The public keys that sign the record, as PEM. |
| `fleet replace-key <uid>` | Approve a device's pending key-replacement candidate, after confirming its pairing code. |
| `fleet provision --yubikey` | 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. `status` also checks the fleet answers and accepts it. |

A station is named by the hostname it reports, falling back to its uid until it reports one;
Fleet keeps no name of its own, so neither `approve` nor `provision` takes one. The `Name`
column of `list` and `articles` shows that name.

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.

`fleet show <uid> --output json` (or `-o json`) prints what the tree shows: the device's uid,
name, product, liveness, last seen, pairing code, thumbprint and replacement candidate, and
its last snapshot under `snapshot`, `{"composites": [...]}` as the web UI's *Last snapshot*
block shows it, or `null` when it has not reported one. `--json` still prints the whole
device detail verbatim:

```bash
fleet show cfb59664da164f3c8b1b4d5d7e0dd609 -o json \
  | jq -r '.snapshot.composites[] | select(.type == "test_station") | .data.version'
```

## Verifying the audit trail

`fleet audit verify` does not ask the fleet whether its record is intact; it
fetches every entry and checks it here. Each entry's payload is canonical JSON
whose SHA-256 is its hash, which names the previous entry's hash and is signed
with ECDSA P-256; every other field must agree with the payload. By default the
keys it trusts are the ones the fleet serves, which shows the record was not
changed after it was signed. To show it was signed by the fleet's real key, pin
that key with `--public-key`, from wherever the key is held rather than from the
fleet itself. The rules are the fleet's own, in luminance2's
`doc/fleet-operations.md` §3.

## 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:

```bash
fleet approve a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4
# Pairing code: KHJ9B-A3U82
# Hostname: rc2038-bench3
# Version: 0.6.0
# Source IP: 10.20.4.11
# Type the pairing code shown on the station: khj9b a3u82
# Approved a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4.
```

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:

```bash
fleet provision --yubikey --shipped-to "Example EMS"
# Registered yubikey_piv 31234567.
# 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):

```bash
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
```

`--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).

```bash
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>                          # 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.

```python
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:

```python
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](https://gitlab.com/lumenradio/aws/luminance2) repository.

## Device tunnel

A device behind NAT cannot be dialled. `TunnelClient` has it open one WebSocket to Fleet
instead; Fleet relays a technician's HTTP requests over that socket to the device's local
API, bound to its loopback, and carries the responses back. The tunnel is product-neutral:
it moves HTTP and knows nothing about what the device serves. It reuses the device's key and
signs its handshake like any other device request.

```python
from lr_fleet.device import LoopbackTarget, TunnelClient

target = LoopbackTarget("127.0.0.1", 17080, allowed_prefixes=("/api/",))
tunnel = TunnelClient(
    "https://fleet.cloud.lumenradio.com",
    identity.station_uid,
    identity.sign,
    target,
    is_enrolled=lambda: identity.enrolled,
    on_connect=reporter.wake,   # optional
)
tunnel.start()   # a daemon thread; tunnel.stop() on shutdown
print(tunnel.phase, tunnel.connected_since, tunnel.last_error)
```

`is_enrolled` lets the tunnel wait while the device is unenrolled, and connect once it is
admitted. `phase` is `idle`, `connecting`, `connected`, `backoff`, `waiting_enrolment`,
`rejected`, `contended` or `revoked`; `connected_since` is a UTC datetime while connected; `last_error` is the last
failure's description, cleared by the next successful connection.

`on_connect`, a callable taking no arguments, runs each time the tunnel reaches `connected`,
so a product can act on the link coming back, for instance by reporting at once. It runs on
the tunnel's thread: keep it short. `on_close(code)` runs with each close code Fleet sends.
An exception in either callback is logged at `WARNING` and never ends the tunnel. A connect
that follows failed attempts logs `tunnel <uid>: connected again after <duration>, <n> failed
attempts` at `INFO`.

The product decides:

- **Whether to start it.** Nothing starts a tunnel by itself. A product that offers remote
  access gates `start()` on its own setting.
- **What may be relayed.** The allowlist of path prefixes is declared twice: in the
  product's profile at Fleet, and in the `LoopbackTarget`. Both must allow a path for a
  request to reach the device. The target refuses dot-segments and encoded slashes, and
  resets the stream with `path_not_allowed` for anything else.

The tunnel is fail-open: no failure, whether an unreachable Fleet, a refused handshake or a
bug in the relay, raises out of its thread or blocks the program. It reconnects with
backoff by the failure table of the contract, and ends only when Fleet revokes the device.

The frames, the handshake, the allowlist and the reconnect rules are normative in the fleet
wire contract, §12 *The tunnel*, in `doc/fleet-wire-contract.md` of the
[luminance2](https://gitlab.com/lumenradio/aws/luminance2) repository.

## Using the technician's token from another tool

`lr_fleet.auth.access_token()` returns the signed-in technician's Entra token from the same
cache `fleet login` writes, refreshing it when it is close to expiry:

```python
from lr_fleet.auth import NotSignedIn, access_token

try:
    token = access_token(interactive=False)
except NotSignedIn as exc:
    print(exc)   # run `fleet login`
```

With the default `interactive=True`, a missing or unrefreshable login opens the browser
sign-in and stores the result. With `interactive=False` it raises `NotSignedIn` instead,
which is the mode for a tool that must not open a browser.

## 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:

```bash
export FLEET_TOKEN="$(some-secret-store read fleet-ci-token)"
fleet list --json | jq -r '.[] | select(.liveness != "online") | .uid'
```

A command targets `--base-url`, else `FLEET_URL`, else the fleet the last `fleet login`
stored, else production (`https://fleet.cloud.lumenradio.com`). With `FLEET_TOKEN` set the
stored fleet is not read, so a CI job targets only what `--base-url` or `FLEET_URL` says.

## 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`](https://gitlab.com/lumenradio/devops/examples/pypi-example)
and [`devops/examples/gladiator-example`](https://gitlab.com/lumenradio/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.

```bash
# 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.
