Metadata-Version: 2.4
Name: lr-fleet
Version: 0.1.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
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 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.

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

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

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

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

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

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

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

## 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'
```

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

```bash
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`](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.
