lr-fleet 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. lr_fleet-0.1.0/LICENSE +21 -0
  2. lr_fleet-0.1.0/PKG-INFO +302 -0
  3. lr_fleet-0.1.0/README.md +273 -0
  4. lr_fleet-0.1.0/pyproject.toml +74 -0
  5. lr_fleet-0.1.0/setup.cfg +4 -0
  6. lr_fleet-0.1.0/src/lr_fleet/__init__.py +12 -0
  7. lr_fleet-0.1.0/src/lr_fleet/cli.py +70 -0
  8. lr_fleet-0.1.0/src/lr_fleet/client.py +141 -0
  9. lr_fleet-0.1.0/src/lr_fleet/commands/__init__.py +66 -0
  10. lr_fleet-0.1.0/src/lr_fleet/commands/articles.py +62 -0
  11. lr_fleet-0.1.0/src/lr_fleet/commands/device.py +208 -0
  12. lr_fleet-0.1.0/src/lr_fleet/commands/devices.py +167 -0
  13. lr_fleet-0.1.0/src/lr_fleet/commands/login.py +79 -0
  14. lr_fleet-0.1.0/src/lr_fleet/commands/pending.py +145 -0
  15. lr_fleet-0.1.0/src/lr_fleet/commands/provision.py +178 -0
  16. lr_fleet-0.1.0/src/lr_fleet/config.py +60 -0
  17. lr_fleet-0.1.0/src/lr_fleet/device/__init__.py +39 -0
  18. lr_fleet-0.1.0/src/lr_fleet/device/client.py +140 -0
  19. lr_fleet-0.1.0/src/lr_fleet/device/identity.py +145 -0
  20. lr_fleet-0.1.0/src/lr_fleet/device/keys.py +108 -0
  21. lr_fleet-0.1.0/src/lr_fleet/device/reporter.py +227 -0
  22. lr_fleet-0.1.0/src/lr_fleet/device/tpm.py +198 -0
  23. lr_fleet-0.1.0/src/lr_fleet/device/wire.py +83 -0
  24. lr_fleet-0.1.0/src/lr_fleet/device/yubikey.py +187 -0
  25. lr_fleet-0.1.0/src/lr_fleet/entra.py +155 -0
  26. lr_fleet-0.1.0/src/lr_fleet/exceptions.py +45 -0
  27. lr_fleet-0.1.0/src/lr_fleet/jwt_decode.py +30 -0
  28. lr_fleet-0.1.0/src/lr_fleet/pairing.py +23 -0
  29. lr_fleet-0.1.0/src/lr_fleet/session.py +54 -0
  30. lr_fleet-0.1.0/src/lr_fleet.egg-info/PKG-INFO +302 -0
  31. lr_fleet-0.1.0/src/lr_fleet.egg-info/SOURCES.txt +54 -0
  32. lr_fleet-0.1.0/src/lr_fleet.egg-info/dependency_links.txt +1 -0
  33. lr_fleet-0.1.0/src/lr_fleet.egg-info/entry_points.txt +2 -0
  34. lr_fleet-0.1.0/src/lr_fleet.egg-info/requires.txt +21 -0
  35. lr_fleet-0.1.0/src/lr_fleet.egg-info/top_level.txt +1 -0
  36. lr_fleet-0.1.0/tests/test_cli_articles.py +85 -0
  37. lr_fleet-0.1.0/tests/test_cli_base_url.py +76 -0
  38. lr_fleet-0.1.0/tests/test_cli_device.py +215 -0
  39. lr_fleet-0.1.0/tests/test_cli_devices.py +231 -0
  40. lr_fleet-0.1.0/tests/test_cli_errors.py +101 -0
  41. lr_fleet-0.1.0/tests/test_cli_login.py +160 -0
  42. lr_fleet-0.1.0/tests/test_cli_pending.py +191 -0
  43. lr_fleet-0.1.0/tests/test_cli_provision.py +279 -0
  44. lr_fleet-0.1.0/tests/test_client.py +195 -0
  45. lr_fleet-0.1.0/tests/test_config.py +72 -0
  46. lr_fleet-0.1.0/tests/test_device_client.py +153 -0
  47. lr_fleet-0.1.0/tests/test_device_identity.py +71 -0
  48. lr_fleet-0.1.0/tests/test_device_keys.py +67 -0
  49. lr_fleet-0.1.0/tests/test_device_reporter.py +255 -0
  50. lr_fleet-0.1.0/tests/test_device_wire.py +67 -0
  51. lr_fleet-0.1.0/tests/test_device_yubikey.py +217 -0
  52. lr_fleet-0.1.0/tests/test_entra.py +171 -0
  53. lr_fleet-0.1.0/tests/test_jwt_decode.py +33 -0
  54. lr_fleet-0.1.0/tests/test_pairing.py +18 -0
  55. lr_fleet-0.1.0/tests/test_session.py +104 -0
  56. lr_fleet-0.1.0/tests/test_version_matches_tag.py +37 -0
lr_fleet-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LumenRadio AB
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,302 @@
1
+ Metadata-Version: 2.4
2
+ Name: lr-fleet
3
+ Version: 0.1.0
4
+ Summary: CLI client for the LumenRadio fleet management REST API.
5
+ Author-email: Jonas Estberger <jonas.estberger@lumenradio.com>
6
+ License: MIT License
7
+ Project-URL: Repository, https://gitlab.com/lumenradio/production/lr-fleet
8
+ Keywords: lumenradio,fleet
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: requests>=2.32
13
+ Requires-Dist: typer>=0.12
14
+ Requires-Dist: rich>=13.7
15
+ Requires-Dist: cryptography>=42
16
+ Provides-Extra: yubikey
17
+ Requires-Dist: yubikey-manager>=5.5; extra == "yubikey"
18
+ Provides-Extra: tpm
19
+ Requires-Dist: tpm2-pytss>=2.2; extra == "tpm"
20
+ Provides-Extra: dev
21
+ Requires-Dist: build>=1.2.1; extra == "dev"
22
+ Requires-Dist: twine>=5.1.1; extra == "dev"
23
+ Requires-Dist: wheel; extra == "dev"
24
+ Requires-Dist: pytest>=8.4.2; extra == "dev"
25
+ Requires-Dist: black>=25.9.0; extra == "dev"
26
+ Requires-Dist: pytest-cov; extra == "dev"
27
+ Requires-Dist: tomli>=2.0; python_version < "3.11" and extra == "dev"
28
+ Dynamic: license-file
29
+
30
+ # lr-fleet
31
+
32
+ CLI client for the LumenRadio fleet management REST API: approve stations, watch the roster,
33
+ and pull the article divergence pivot, from a terminal or a script.
34
+
35
+ ```bash
36
+ pip install lr-fleet
37
+ fleet login
38
+ fleet list
39
+ ```
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install lr-fleet
45
+ ```
46
+
47
+ Python 3.9 or later.
48
+
49
+ ## Sign in
50
+
51
+ `fleet login` runs an Entra device-code sign-in: it prints a URL and a short code, you enter
52
+ the code in a browser, and the CLI polls until you finish. There is no password or client
53
+ secret involved — signing in proves you are a member of the `Production Technicians` Entra
54
+ group, the same group the web UI checks, so a technician the group excludes is refused at the
55
+ CLI too.
56
+
57
+ ```bash
58
+ fleet login
59
+ # To sign in, use a web browser to open the page https://microsoft.com/devicelogin
60
+ # and enter the code ABCD-1234 to authenticate.
61
+ ```
62
+
63
+ The token is cached at `~/.config/lr-fleet/token.json`, mode `0600`. Every later command reads
64
+ it and refreshes it automatically once it is close to expiry. `fleet logout` removes the cache.
65
+ `fleet status` shows who you are signed in as, without needing another sign-in:
66
+
67
+ ```bash
68
+ fleet status
69
+ # base url: https://fleet.cloud.lumenradio.com
70
+ # user: tech@lumenradio.com
71
+ # roles: user.admin.api
72
+ # scope: read.api write.api admin.api
73
+ # expires: 2026-09-25T18:00:00+00:00
74
+ ```
75
+
76
+ `status` decodes the cached token's payload to show this. It does **not** check the token's
77
+ signature — that would be pointless work the fleet service always redoes on every request — so
78
+ treat this output as a label, not a guarantee that the token still works.
79
+
80
+ ## Commands
81
+
82
+ | Command | Does |
83
+ |---|---|
84
+ | `fleet list` | List every rostered device. `--product-id`, `--online`/`--offline` filter it. |
85
+ | `fleet show <uid>` | One device's detail: liveness, pairing code, thumbprint, composites. |
86
+ | `fleet pending` | The pending-enrolment queue, ordinary entries and key-replacement candidates. |
87
+ | `fleet approve <uid> --name NAME` | Approve a pending enrolment, after confirming its pairing code. |
88
+ | `fleet reject <uid>` | Reject a pending enrolment. Asks to confirm unless `--yes`. |
89
+ | `fleet revoke <uid>` | Revoke a device. Asks to confirm unless `--yes`. |
90
+ | `fleet replace-key <uid>` | Approve a device's pending key-replacement candidate, after confirming its pairing code. |
91
+ | `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. |
92
+ | `fleet device init` / `enrol` / `report` / `show` / `attest` | Act as a fleet device, with a software key, a YubiKey or the machine's TPM. |
93
+ | `fleet articles <article>` | Every station holding an article, grouped by Build Words; flags disagreement. |
94
+ | `fleet login` / `fleet logout` / `fleet status` | Manage the cached sign-in. |
95
+
96
+ Every read command takes `--json` and prints the server's raw JSON instead of a table, for
97
+ piping into `jq` or another script. Every write command takes `--json` too, and prints the
98
+ resource the server returned.
99
+
100
+ ## The pairing-code rule
101
+
102
+ `fleet approve` and `fleet replace-key` never post on a bare uid. Each command looks up the
103
+ pending entry, prints the pairing code the service holds for it along with its hostname,
104
+ software version and source IP, and then asks you to type the code shown on the station's own display:
105
+
106
+ ```bash
107
+ fleet approve a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 --name "Bench 3"
108
+ # Pairing code: WOLF-WOLF-YOLK
109
+ # Hostname: rc2038-bench3
110
+ # Version: 0.6.0
111
+ # Source IP: 10.20.4.11
112
+ # Type the pairing code shown on the station: wolf wolf yolk
113
+ # Approved a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 as 'Bench 3'.
114
+ ```
115
+
116
+ The comparison ignores case, `-` and whitespace, but a mismatch still exits 1 without posting
117
+ anything. The fleet service never accepts a pairing code as input — approving a device by
118
+ comparing what the operator sees against what the station displays is entirely the client's
119
+ job, so the CLI enforces it even in a script: pass `--pairing-code CODE` to supply the typed
120
+ value non-interactively, but there is no flag that skips the comparison itself.
121
+
122
+ ## Provision hardware for an EMS station
123
+
124
+ A station whose key is in hardware LumenRadio registered beforehand is admitted on its first
125
+ enrolment, with no one approving it at the EMS. Provisioning needs the admin tier.
126
+
127
+ **A YubiKey** (`pip install "lr-fleet[yubikey]"`), plugged in:
128
+
129
+ ```bash
130
+ fleet provision --yubikey --name "EMS bench 1" --shipped-to "Example EMS"
131
+ # Registered yubikey_piv 31234567 as 'EMS bench 1'.
132
+ # Firmware: 5.7.2
133
+ # Key: 9e
134
+ # Pairing code: ABCDE-FGHJK
135
+ # Pre-approved until: 2026-10-25T12:00:00Z
136
+ ```
137
+
138
+ This generates the station key in slot `9e` with PIN and touch policy "never", so the station
139
+ signs unattended, then posts the slot's and the YubiKey's own attestation certificates. It
140
+ refuses to overwrite a key already in the slot unless given `--replace`, and uses the factory
141
+ management key. The service reads the serial, firmware and slot from the certificates and
142
+ refuses firmware below 5.7. Certificates exported by `ykman` can be posted instead with
143
+ `--slot-attestation FILE --token-attestation FILE`, PEM or DER.
144
+
145
+ **A TPM**, on a machine prepared at LumenRadio (`pip install "lr-fleet[tpm]"`, Linux):
146
+
147
+ ```bash
148
+ fleet device init --key tpm # on the machine: the key is created inside its TPM
149
+ fleet device attest > bench.json # its TPM2_Certify statement
150
+ fleet provision --statement bench.json --name "EMS bench 2"
151
+ ```
152
+
153
+ `--product-id` defaults to the LumenTest station, `504-1007`, and `--expires-in-days` (1–90,
154
+ default 30) bounds how long the pre-approval lasts. After it lapses the station still installs
155
+ and enrols, and waits for `fleet approve` like any other.
156
+
157
+ ## Act as a device
158
+
159
+ `fleet device` plays a fleet device from a terminal — the way to try the enrolment workflow by
160
+ hand. It signs as the device, so it needs no sign-in, and keeps the identity in
161
+ `~/.config/lr-fleet/device` (`--dir` or `FLEET_DEVICE_DIR` moves it).
162
+
163
+ ```bash
164
+ fleet device init # a software key; --key yubikey or --key tpm
165
+ fleet device enrol --enrol-token "$PSK" # 202 pending: compare the pairing code, then approve
166
+ fleet approve <uid> --name "Bench 3" # as a technician
167
+ fleet device enrol # 200 enrolled
168
+ fleet device report --composites c.json # one snapshot; default: this machine's os composite
169
+ ```
170
+
171
+ A TPM key sends its attestation on enrolment, so the station waits in the pending queue marked
172
+ as hardware-held. An attestation that cannot be produced or does not verify leaves it an
173
+ ordinary software-key enrolment — never a refusal.
174
+
175
+ ## Use it as a library
176
+
177
+ Any Python program can be a fleet device with `lr_fleet.device`: the key holders, the signed
178
+ wire and the enrol/report client, the same code the commands above run.
179
+
180
+ ```python
181
+ from pathlib import Path
182
+ from lr_fleet.device import DeviceClient, DeviceIdentity, os_composite
183
+
184
+ identity = DeviceIdentity.load_or_create(Path("/var/lib/my-app/fleet"))
185
+ client = DeviceClient(identity, "https://fleet.cloud.lumenradio.com")
186
+
187
+ response = client.enroll("504-1007", [os_composite()], enrol_token=psk)
188
+ if response.enrolled:
189
+ client.snapshot([os_composite(), {"type": "my_app", "version": 1, "data": {...}}])
190
+ ```
191
+
192
+ A device describes itself only in composites — `{"type", "version", "data"}` — and the fleet
193
+ renders each by its type. No call raises for an HTTP status: `pending`, `revoked` and `replayed`
194
+ are outcomes to branch on. Only an unreachable fleet raises `NetworkError`, which a program that
195
+ must never be disturbed by the fleet catches and ignores. The identity file saves each request's
196
+ sequence number before sending it, so a crash never locks the device out.
197
+
198
+ `lr_fleet.device.yubikey.YubiKeyPivKey` and `lr_fleet.device.tpm.Tpm2Key` are the hardware
199
+ holders; `DeviceIdentity.create(directory, holder)` takes one.
200
+
201
+ A program that should simply keep reporting — a test station, a label printer — hands the
202
+ client to a `DeviceReporter` instead of driving it by hand:
203
+
204
+ ```python
205
+ from lr_fleet.device import DeviceReporter
206
+
207
+ reporter = DeviceReporter(
208
+ client,
209
+ product_id="504-1007",
210
+ composites=lambda: [os_composite(), my_app_composite()],
211
+ enrol_token=psk,
212
+ )
213
+ reporter.start() # a daemon thread; reporter.stop() on shutdown
214
+ print(reporter.phase, reporter.pairing_code, reporter.last_error)
215
+ ```
216
+
217
+ It enrols until the fleet admits the device, then posts the composites every interval the fleet
218
+ hands back, and acts on every answer: `key_mismatch` waits for a technician, `revoked` ends the
219
+ thread, `Retry-After` is honoured, and an unreachable fleet backs off to five minutes. Every
220
+ failure — the composites callable's included — is logged and swallowed, so reporting can never
221
+ disturb the program. `phase`, `pairing_code`, `last_report_at` and `last_error` are what to show
222
+ locally, where whoever commissions the device can read them.
223
+
224
+ The wire, the answers and what a device must do with each are normative in the fleet wire
225
+ contract, `doc/fleet-wire-contract.md` in the
226
+ [luminance2](https://gitlab.com/lumenradio/aws/luminance2) repository.
227
+
228
+ ## Scripting
229
+
230
+ `FLEET_TOKEN`, when set, is used as the bearer token verbatim; nothing is read from or written
231
+ to the token cache. This is the path for CI and other unattended callers:
232
+
233
+ ```bash
234
+ export FLEET_TOKEN="$(some-secret-store read fleet-ci-token)"
235
+ fleet list --json | jq -r '.[] | select(.liveness != "online") | .uid'
236
+ ```
237
+
238
+ `--base-url`, the `FLEET_URL` environment variable, and the default
239
+ (`https://fleet.cloud.lumenradio.com`) are checked in that order. Point at the test instance,
240
+ `https://develop.fleet.cloud.lumenradio.com`, the same way:
241
+
242
+ ```bash
243
+ export FLEET_URL=https://develop.fleet.cloud.lumenradio.com
244
+ fleet login
245
+ ```
246
+
247
+ ## Testing the hardware holders
248
+
249
+ `make test` runs offline and never touches hardware. `make hardware-test` runs the TPM holder
250
+ against a software TPM (swtpm) with tpm2-pytss, in `tests/hardware/Dockerfile`'s image; it needs
251
+ Docker, not a TPM. No test drives a physical YubiKey: the YubiKey holder is tested against a
252
+ stand-in for yubikit.
253
+
254
+ ## Exit codes
255
+
256
+ `0` on success. `1` on a user or server error — not signed in, a rejected pairing code, a
257
+ 403 from the API, a 404, and so on. `2` on a usage error (a missing required option), from
258
+ typer's own argument parsing.
259
+
260
+ ## Releasing
261
+
262
+ This repo's pipeline, `.gitlab-ci.yml`, is copied from
263
+ [`devops/examples/pypi-example`](https://gitlab.com/lumenradio/devops/examples/pypi-example)
264
+ and [`devops/examples/gladiator-example`](https://gitlab.com/lumenradio/devops/examples/gladiator-example):
265
+ a protected release tag drives PyPI, then Arena (article `504-1014`), then a manual change
266
+ order. Read those two repos' READMEs for what each check in the pipeline guards against.
267
+
268
+ Before tagging a release here, two settings must already exist on this project — copying the
269
+ CI file cannot bring them with it, and without them the publish jobs are silently skipped:
270
+
271
+ 1. **Protected tag pattern** — *Settings → Repository → Protected tags* — `lr-fleet-*`,
272
+ allowed to create: **Maintainers**.
273
+ 2. **Credentials** — `PYPI_TOKEN` and `GLADIATOR_USERNAME`/`GLADIATOR_PASSWORD` are inherited
274
+ from the root `lumenradio` group, already protected and masked there. This repo defines none
275
+ of its own; do not add a project-level duplicate, since a project variable silently overrides
276
+ the group's and turns one rotation point into two.
277
+
278
+ ```bash
279
+ # 1. Bump the version in pyproject.toml, update CHANGELOG.md, merge to main.
280
+ # 2. Tag the merged commit. Only a Maintainer can create a protected tag.
281
+ git tag lr-fleet-0.2.0
282
+ git push origin lr-fleet-0.2.0
283
+ ```
284
+
285
+ The tag pipeline runs Verify, Build, `publish-pypi`, then `publish-to-arena`.
286
+ `submit-change-order-to-production` stays manual: publishing a candidate is repeatable, but
287
+ submitting the change order that releases it is a decision a person makes after checking the
288
+ candidate.
289
+
290
+ | Target | Does |
291
+ |---|---|
292
+ | `make dev` | venv + editable install with dev extras |
293
+ | `make test` | pytest with coverage, fails under 90% |
294
+ | `make black` | format (`BLACK_ARGS=--check` to check only) |
295
+ | `make dist` | build the sdist and wheel |
296
+ | `make check` | `dist` + `twine check --strict` |
297
+ | `make check-version` | fail if the tag and `pyproject.toml` disagree |
298
+ | `make publish` | upload to PyPI (`PYPI_TOKEN`) |
299
+ | `make publish-test` | upload to TestPyPI (`TEST_PYPI_TOKEN`) |
300
+
301
+ **A published version is spent.** If a release is wrong, the fix is a new version, never a
302
+ retry of the job — PyPI refuses the same version twice even if the wheel is byte-identical.
@@ -0,0 +1,273 @@
1
+ # lr-fleet
2
+
3
+ CLI client for the LumenRadio fleet management REST API: approve stations, watch the roster,
4
+ and pull the article divergence pivot, from a terminal or a script.
5
+
6
+ ```bash
7
+ pip install lr-fleet
8
+ fleet login
9
+ fleet list
10
+ ```
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install lr-fleet
16
+ ```
17
+
18
+ Python 3.9 or later.
19
+
20
+ ## Sign in
21
+
22
+ `fleet login` runs an Entra device-code sign-in: it prints a URL and a short code, you enter
23
+ the code in a browser, and the CLI polls until you finish. There is no password or client
24
+ secret involved — signing in proves you are a member of the `Production Technicians` Entra
25
+ group, the same group the web UI checks, so a technician the group excludes is refused at the
26
+ CLI too.
27
+
28
+ ```bash
29
+ fleet login
30
+ # To sign in, use a web browser to open the page https://microsoft.com/devicelogin
31
+ # and enter the code ABCD-1234 to authenticate.
32
+ ```
33
+
34
+ The token is cached at `~/.config/lr-fleet/token.json`, mode `0600`. Every later command reads
35
+ it and refreshes it automatically once it is close to expiry. `fleet logout` removes the cache.
36
+ `fleet status` shows who you are signed in as, without needing another sign-in:
37
+
38
+ ```bash
39
+ fleet status
40
+ # base url: https://fleet.cloud.lumenradio.com
41
+ # user: tech@lumenradio.com
42
+ # roles: user.admin.api
43
+ # scope: read.api write.api admin.api
44
+ # expires: 2026-09-25T18:00:00+00:00
45
+ ```
46
+
47
+ `status` decodes the cached token's payload to show this. It does **not** check the token's
48
+ signature — that would be pointless work the fleet service always redoes on every request — so
49
+ treat this output as a label, not a guarantee that the token still works.
50
+
51
+ ## Commands
52
+
53
+ | Command | Does |
54
+ |---|---|
55
+ | `fleet list` | List every rostered device. `--product-id`, `--online`/`--offline` filter it. |
56
+ | `fleet show <uid>` | One device's detail: liveness, pairing code, thumbprint, composites. |
57
+ | `fleet pending` | The pending-enrolment queue, ordinary entries and key-replacement candidates. |
58
+ | `fleet approve <uid> --name NAME` | Approve a pending enrolment, after confirming its pairing code. |
59
+ | `fleet reject <uid>` | Reject a pending enrolment. Asks to confirm unless `--yes`. |
60
+ | `fleet revoke <uid>` | Revoke a device. Asks to confirm unless `--yes`. |
61
+ | `fleet replace-key <uid>` | Approve a device's pending key-replacement candidate, after confirming its pairing code. |
62
+ | `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. |
63
+ | `fleet device init` / `enrol` / `report` / `show` / `attest` | Act as a fleet device, with a software key, a YubiKey or the machine's TPM. |
64
+ | `fleet articles <article>` | Every station holding an article, grouped by Build Words; flags disagreement. |
65
+ | `fleet login` / `fleet logout` / `fleet status` | Manage the cached sign-in. |
66
+
67
+ Every read command takes `--json` and prints the server's raw JSON instead of a table, for
68
+ piping into `jq` or another script. Every write command takes `--json` too, and prints the
69
+ resource the server returned.
70
+
71
+ ## The pairing-code rule
72
+
73
+ `fleet approve` and `fleet replace-key` never post on a bare uid. Each command looks up the
74
+ pending entry, prints the pairing code the service holds for it along with its hostname,
75
+ software version and source IP, and then asks you to type the code shown on the station's own display:
76
+
77
+ ```bash
78
+ fleet approve a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 --name "Bench 3"
79
+ # Pairing code: WOLF-WOLF-YOLK
80
+ # Hostname: rc2038-bench3
81
+ # Version: 0.6.0
82
+ # Source IP: 10.20.4.11
83
+ # Type the pairing code shown on the station: wolf wolf yolk
84
+ # Approved a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 as 'Bench 3'.
85
+ ```
86
+
87
+ The comparison ignores case, `-` and whitespace, but a mismatch still exits 1 without posting
88
+ anything. The fleet service never accepts a pairing code as input — approving a device by
89
+ comparing what the operator sees against what the station displays is entirely the client's
90
+ job, so the CLI enforces it even in a script: pass `--pairing-code CODE` to supply the typed
91
+ value non-interactively, but there is no flag that skips the comparison itself.
92
+
93
+ ## Provision hardware for an EMS station
94
+
95
+ A station whose key is in hardware LumenRadio registered beforehand is admitted on its first
96
+ enrolment, with no one approving it at the EMS. Provisioning needs the admin tier.
97
+
98
+ **A YubiKey** (`pip install "lr-fleet[yubikey]"`), plugged in:
99
+
100
+ ```bash
101
+ fleet provision --yubikey --name "EMS bench 1" --shipped-to "Example EMS"
102
+ # Registered yubikey_piv 31234567 as 'EMS bench 1'.
103
+ # Firmware: 5.7.2
104
+ # Key: 9e
105
+ # Pairing code: ABCDE-FGHJK
106
+ # Pre-approved until: 2026-10-25T12:00:00Z
107
+ ```
108
+
109
+ This generates the station key in slot `9e` with PIN and touch policy "never", so the station
110
+ signs unattended, then posts the slot's and the YubiKey's own attestation certificates. It
111
+ refuses to overwrite a key already in the slot unless given `--replace`, and uses the factory
112
+ management key. The service reads the serial, firmware and slot from the certificates and
113
+ refuses firmware below 5.7. Certificates exported by `ykman` can be posted instead with
114
+ `--slot-attestation FILE --token-attestation FILE`, PEM or DER.
115
+
116
+ **A TPM**, on a machine prepared at LumenRadio (`pip install "lr-fleet[tpm]"`, Linux):
117
+
118
+ ```bash
119
+ fleet device init --key tpm # on the machine: the key is created inside its TPM
120
+ fleet device attest > bench.json # its TPM2_Certify statement
121
+ fleet provision --statement bench.json --name "EMS bench 2"
122
+ ```
123
+
124
+ `--product-id` defaults to the LumenTest station, `504-1007`, and `--expires-in-days` (1–90,
125
+ default 30) bounds how long the pre-approval lasts. After it lapses the station still installs
126
+ and enrols, and waits for `fleet approve` like any other.
127
+
128
+ ## Act as a device
129
+
130
+ `fleet device` plays a fleet device from a terminal — the way to try the enrolment workflow by
131
+ hand. It signs as the device, so it needs no sign-in, and keeps the identity in
132
+ `~/.config/lr-fleet/device` (`--dir` or `FLEET_DEVICE_DIR` moves it).
133
+
134
+ ```bash
135
+ fleet device init # a software key; --key yubikey or --key tpm
136
+ fleet device enrol --enrol-token "$PSK" # 202 pending: compare the pairing code, then approve
137
+ fleet approve <uid> --name "Bench 3" # as a technician
138
+ fleet device enrol # 200 enrolled
139
+ fleet device report --composites c.json # one snapshot; default: this machine's os composite
140
+ ```
141
+
142
+ A TPM key sends its attestation on enrolment, so the station waits in the pending queue marked
143
+ as hardware-held. An attestation that cannot be produced or does not verify leaves it an
144
+ ordinary software-key enrolment — never a refusal.
145
+
146
+ ## Use it as a library
147
+
148
+ Any Python program can be a fleet device with `lr_fleet.device`: the key holders, the signed
149
+ wire and the enrol/report client, the same code the commands above run.
150
+
151
+ ```python
152
+ from pathlib import Path
153
+ from lr_fleet.device import DeviceClient, DeviceIdentity, os_composite
154
+
155
+ identity = DeviceIdentity.load_or_create(Path("/var/lib/my-app/fleet"))
156
+ client = DeviceClient(identity, "https://fleet.cloud.lumenradio.com")
157
+
158
+ response = client.enroll("504-1007", [os_composite()], enrol_token=psk)
159
+ if response.enrolled:
160
+ client.snapshot([os_composite(), {"type": "my_app", "version": 1, "data": {...}}])
161
+ ```
162
+
163
+ A device describes itself only in composites — `{"type", "version", "data"}` — and the fleet
164
+ renders each by its type. No call raises for an HTTP status: `pending`, `revoked` and `replayed`
165
+ are outcomes to branch on. Only an unreachable fleet raises `NetworkError`, which a program that
166
+ must never be disturbed by the fleet catches and ignores. The identity file saves each request's
167
+ sequence number before sending it, so a crash never locks the device out.
168
+
169
+ `lr_fleet.device.yubikey.YubiKeyPivKey` and `lr_fleet.device.tpm.Tpm2Key` are the hardware
170
+ holders; `DeviceIdentity.create(directory, holder)` takes one.
171
+
172
+ A program that should simply keep reporting — a test station, a label printer — hands the
173
+ client to a `DeviceReporter` instead of driving it by hand:
174
+
175
+ ```python
176
+ from lr_fleet.device import DeviceReporter
177
+
178
+ reporter = DeviceReporter(
179
+ client,
180
+ product_id="504-1007",
181
+ composites=lambda: [os_composite(), my_app_composite()],
182
+ enrol_token=psk,
183
+ )
184
+ reporter.start() # a daemon thread; reporter.stop() on shutdown
185
+ print(reporter.phase, reporter.pairing_code, reporter.last_error)
186
+ ```
187
+
188
+ It enrols until the fleet admits the device, then posts the composites every interval the fleet
189
+ hands back, and acts on every answer: `key_mismatch` waits for a technician, `revoked` ends the
190
+ thread, `Retry-After` is honoured, and an unreachable fleet backs off to five minutes. Every
191
+ failure — the composites callable's included — is logged and swallowed, so reporting can never
192
+ disturb the program. `phase`, `pairing_code`, `last_report_at` and `last_error` are what to show
193
+ locally, where whoever commissions the device can read them.
194
+
195
+ The wire, the answers and what a device must do with each are normative in the fleet wire
196
+ contract, `doc/fleet-wire-contract.md` in the
197
+ [luminance2](https://gitlab.com/lumenradio/aws/luminance2) repository.
198
+
199
+ ## Scripting
200
+
201
+ `FLEET_TOKEN`, when set, is used as the bearer token verbatim; nothing is read from or written
202
+ to the token cache. This is the path for CI and other unattended callers:
203
+
204
+ ```bash
205
+ export FLEET_TOKEN="$(some-secret-store read fleet-ci-token)"
206
+ fleet list --json | jq -r '.[] | select(.liveness != "online") | .uid'
207
+ ```
208
+
209
+ `--base-url`, the `FLEET_URL` environment variable, and the default
210
+ (`https://fleet.cloud.lumenradio.com`) are checked in that order. Point at the test instance,
211
+ `https://develop.fleet.cloud.lumenradio.com`, the same way:
212
+
213
+ ```bash
214
+ export FLEET_URL=https://develop.fleet.cloud.lumenradio.com
215
+ fleet login
216
+ ```
217
+
218
+ ## Testing the hardware holders
219
+
220
+ `make test` runs offline and never touches hardware. `make hardware-test` runs the TPM holder
221
+ against a software TPM (swtpm) with tpm2-pytss, in `tests/hardware/Dockerfile`'s image; it needs
222
+ Docker, not a TPM. No test drives a physical YubiKey: the YubiKey holder is tested against a
223
+ stand-in for yubikit.
224
+
225
+ ## Exit codes
226
+
227
+ `0` on success. `1` on a user or server error — not signed in, a rejected pairing code, a
228
+ 403 from the API, a 404, and so on. `2` on a usage error (a missing required option), from
229
+ typer's own argument parsing.
230
+
231
+ ## Releasing
232
+
233
+ This repo's pipeline, `.gitlab-ci.yml`, is copied from
234
+ [`devops/examples/pypi-example`](https://gitlab.com/lumenradio/devops/examples/pypi-example)
235
+ and [`devops/examples/gladiator-example`](https://gitlab.com/lumenradio/devops/examples/gladiator-example):
236
+ a protected release tag drives PyPI, then Arena (article `504-1014`), then a manual change
237
+ order. Read those two repos' READMEs for what each check in the pipeline guards against.
238
+
239
+ Before tagging a release here, two settings must already exist on this project — copying the
240
+ CI file cannot bring them with it, and without them the publish jobs are silently skipped:
241
+
242
+ 1. **Protected tag pattern** — *Settings → Repository → Protected tags* — `lr-fleet-*`,
243
+ allowed to create: **Maintainers**.
244
+ 2. **Credentials** — `PYPI_TOKEN` and `GLADIATOR_USERNAME`/`GLADIATOR_PASSWORD` are inherited
245
+ from the root `lumenradio` group, already protected and masked there. This repo defines none
246
+ of its own; do not add a project-level duplicate, since a project variable silently overrides
247
+ the group's and turns one rotation point into two.
248
+
249
+ ```bash
250
+ # 1. Bump the version in pyproject.toml, update CHANGELOG.md, merge to main.
251
+ # 2. Tag the merged commit. Only a Maintainer can create a protected tag.
252
+ git tag lr-fleet-0.2.0
253
+ git push origin lr-fleet-0.2.0
254
+ ```
255
+
256
+ The tag pipeline runs Verify, Build, `publish-pypi`, then `publish-to-arena`.
257
+ `submit-change-order-to-production` stays manual: publishing a candidate is repeatable, but
258
+ submitting the change order that releases it is a decision a person makes after checking the
259
+ candidate.
260
+
261
+ | Target | Does |
262
+ |---|---|
263
+ | `make dev` | venv + editable install with dev extras |
264
+ | `make test` | pytest with coverage, fails under 90% |
265
+ | `make black` | format (`BLACK_ARGS=--check` to check only) |
266
+ | `make dist` | build the sdist and wheel |
267
+ | `make check` | `dist` + `twine check --strict` |
268
+ | `make check-version` | fail if the tag and `pyproject.toml` disagree |
269
+ | `make publish` | upload to PyPI (`PYPI_TOKEN`) |
270
+ | `make publish-test` | upload to TestPyPI (`TEST_PYPI_TOKEN`) |
271
+
272
+ **A published version is spent.** If a release is wrong, the fix is a new version, never a
273
+ retry of the job — PyPI refuses the same version twice even if the wheel is byte-identical.
@@ -0,0 +1,74 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "lr-fleet"
7
+ version = "0.1.0"
8
+ description = "CLI client for the LumenRadio fleet management REST API."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT License" }
12
+ authors = [
13
+ { name = "Jonas Estberger", email = "jonas.estberger@lumenradio.com" }
14
+ ]
15
+ keywords = ["lumenradio", "fleet"]
16
+ dependencies = [
17
+ "requests>=2.32",
18
+ "typer>=0.12",
19
+ "rich>=13.7",
20
+ # A device's software key and request signatures.
21
+ "cryptography>=42",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ # Hardware key holders. Optional so the base install needs no smart-card or TPM
26
+ # libraries: yubikey-manager builds against PC/SC, tpm2-pytss against tpm2-tss.
27
+ yubikey = ["yubikey-manager>=5.5"]
28
+ tpm = ["tpm2-pytss>=2.2"]
29
+ dev = [
30
+ "build>=1.2.1",
31
+ "twine>=5.1.1",
32
+ "wheel",
33
+ "pytest>=8.4.2",
34
+ "black>=25.9.0",
35
+ "pytest-cov",
36
+ # tomllib is stdlib from 3.11; make check-version and the version/tag test
37
+ # need a reader on the older interpreters this package still supports.
38
+ 'tomli>=2.0; python_version < "3.11"',
39
+ ]
40
+
41
+ [project.scripts]
42
+ fleet = "lr_fleet.cli:app"
43
+
44
+ [project.urls]
45
+ Repository = "https://gitlab.com/lumenradio/production/lr-fleet"
46
+
47
+ [tool.setuptools]
48
+ include-package-data = true
49
+
50
+ [tool.setuptools.packages.find]
51
+ where = ["src"]
52
+
53
+ [tool.pytest.ini_options]
54
+ addopts = "-ra"
55
+ testpaths = ["tests"]
56
+ filterwarnings = ["error"]
57
+
58
+ [tool.coverage.run]
59
+ branch = true
60
+ source = ["src"]
61
+ omit = []
62
+
63
+ [tool.coverage.report]
64
+ fail_under = 90
65
+ show_missing = true
66
+ skip_covered = true
67
+ exclude_also = [
68
+ 'if __name__ == "__main__":',
69
+ "except PackageNotFoundError:",
70
+ ]
71
+
72
+ [tool.black]
73
+ target-version = ["py39"]
74
+ line-length = 100
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+