Metadata-Version: 2.4
Name: lr-fleet
Version: 0.7.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, and its tags. `--product-id`, `--online`/`--offline` and `--tag` filter it; repeat `--tag` to keep only devices carrying every tag named. |
| `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 tag list` | Every tag, with its id and how many devices carry it. |
| `fleet tag create NAME` / `rename NAME NEW` / `delete NAME` | Manage the fleet's tags. `delete` takes the tag off every device, and asks to confirm unless `--yes`. |
| `fleet tag add <uid> NAME...` / `remove <uid> NAME...` | Tag a device or untag it. `add --create` makes any tag that does not exist yet. |
| `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'
```

## Tags

A tag is a name to group devices by: a site, a product line, a bench. Tags may hold
spaces and emoji, are at most 32 characters, and are the same tag whatever their case, so
`fleet tag add <uid> ronneby` finds `Ronneby`. A device carries at most 16. Every name is
looked up before anything changes, so a mistyped one changes nothing. Seeing tags needs the
fleet's read tier and changing them needs write. A tag grants no access to anything.

```bash
fleet tag add cfb59664da164f3c8b1b4d5d7e0dd609 Ronneby CRMX --create
fleet list --tag Ronneby --tag CRMX
fleet list --tag Ronneby --json | jq -r '.[].uid'
```

The web dashboard's address carries the same filter, `/?tag=Ronneby&tag=CRMX`, so a link
to a filtered view can be shared.

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

`os_composite()` reports the operating system, its release, the hostname and, on Linux and
Windows, `uptime_s`: whole seconds since the host booted. `uptime_s` is left out wherever the
uptime cannot be read. `os_composite(hostname="sns-prod")` reports a name of the caller's
choosing, for a host whose own name means nothing to a technician, such as an ECS Fargate task
(`ip-10-…`). An override that is not an RFC 1123 host name of at most 255 bytes raises
`ValueError`; check it once at startup with `check_hostname()` so a mistake stops the program
there rather than failing every beat of a reporter.

### One identity, several processes

The fleet refuses any request whose sequence number is not above the last it accepted, so two
processes signing with one identity — an old and a new container sharing a volume during a
rolling deploy — are refused `409 replayed`. A program whose identity directory another process
can reach loads it exclusively:

```python
identity = DeviceIdentity.load_or_create(data_dir, exclusive=True)              # wait for the holder
identity = DeviceIdentity.load_or_create(data_dir, exclusive=True, timeout=30)   # or IdentityLocked
```

This takes an advisory lock on `fleet-identity.lock` (mode 0600) beside the identity file, then
loads the identity. It waits while another process holds the lock, logging at `WARNING` once a
minute whom it waits for, and holds the lock until the process exits; the operating system
releases it however the process ends. The lock is `flock` on Linux and macOS, which holds across
hosts on NFSv4 (EFS), and `msvcrt.locking` on Windows. Keep the identity referenced for as long as
the process signs: the lock lives on `identity.exclusive_lock`. `IdentityLock(directory)` is the
lock alone, with `acquire(timeout=None)`, `release()` and `with`, for a program that loads the
identity some other way; it must load the identity only after acquiring. One process takes one
lock per directory, and a second attempt raises `FleetError`. Without `exclusive`, nothing is
locked.

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, check_hostname

hostname = check_hostname("sns-prod")   # once, at startup; or os_composite() for the machine's own
reporter = DeviceReporter(
    client,
    product_id="504-1007",
    composites=lambda: [os_composite(hostname=hostname), 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.
