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.
- lr_fleet-0.1.0/LICENSE +21 -0
- lr_fleet-0.1.0/PKG-INFO +302 -0
- lr_fleet-0.1.0/README.md +273 -0
- lr_fleet-0.1.0/pyproject.toml +74 -0
- lr_fleet-0.1.0/setup.cfg +4 -0
- lr_fleet-0.1.0/src/lr_fleet/__init__.py +12 -0
- lr_fleet-0.1.0/src/lr_fleet/cli.py +70 -0
- lr_fleet-0.1.0/src/lr_fleet/client.py +141 -0
- lr_fleet-0.1.0/src/lr_fleet/commands/__init__.py +66 -0
- lr_fleet-0.1.0/src/lr_fleet/commands/articles.py +62 -0
- lr_fleet-0.1.0/src/lr_fleet/commands/device.py +208 -0
- lr_fleet-0.1.0/src/lr_fleet/commands/devices.py +167 -0
- lr_fleet-0.1.0/src/lr_fleet/commands/login.py +79 -0
- lr_fleet-0.1.0/src/lr_fleet/commands/pending.py +145 -0
- lr_fleet-0.1.0/src/lr_fleet/commands/provision.py +178 -0
- lr_fleet-0.1.0/src/lr_fleet/config.py +60 -0
- lr_fleet-0.1.0/src/lr_fleet/device/__init__.py +39 -0
- lr_fleet-0.1.0/src/lr_fleet/device/client.py +140 -0
- lr_fleet-0.1.0/src/lr_fleet/device/identity.py +145 -0
- lr_fleet-0.1.0/src/lr_fleet/device/keys.py +108 -0
- lr_fleet-0.1.0/src/lr_fleet/device/reporter.py +227 -0
- lr_fleet-0.1.0/src/lr_fleet/device/tpm.py +198 -0
- lr_fleet-0.1.0/src/lr_fleet/device/wire.py +83 -0
- lr_fleet-0.1.0/src/lr_fleet/device/yubikey.py +187 -0
- lr_fleet-0.1.0/src/lr_fleet/entra.py +155 -0
- lr_fleet-0.1.0/src/lr_fleet/exceptions.py +45 -0
- lr_fleet-0.1.0/src/lr_fleet/jwt_decode.py +30 -0
- lr_fleet-0.1.0/src/lr_fleet/pairing.py +23 -0
- lr_fleet-0.1.0/src/lr_fleet/session.py +54 -0
- lr_fleet-0.1.0/src/lr_fleet.egg-info/PKG-INFO +302 -0
- lr_fleet-0.1.0/src/lr_fleet.egg-info/SOURCES.txt +54 -0
- lr_fleet-0.1.0/src/lr_fleet.egg-info/dependency_links.txt +1 -0
- lr_fleet-0.1.0/src/lr_fleet.egg-info/entry_points.txt +2 -0
- lr_fleet-0.1.0/src/lr_fleet.egg-info/requires.txt +21 -0
- lr_fleet-0.1.0/src/lr_fleet.egg-info/top_level.txt +1 -0
- lr_fleet-0.1.0/tests/test_cli_articles.py +85 -0
- lr_fleet-0.1.0/tests/test_cli_base_url.py +76 -0
- lr_fleet-0.1.0/tests/test_cli_device.py +215 -0
- lr_fleet-0.1.0/tests/test_cli_devices.py +231 -0
- lr_fleet-0.1.0/tests/test_cli_errors.py +101 -0
- lr_fleet-0.1.0/tests/test_cli_login.py +160 -0
- lr_fleet-0.1.0/tests/test_cli_pending.py +191 -0
- lr_fleet-0.1.0/tests/test_cli_provision.py +279 -0
- lr_fleet-0.1.0/tests/test_client.py +195 -0
- lr_fleet-0.1.0/tests/test_config.py +72 -0
- lr_fleet-0.1.0/tests/test_device_client.py +153 -0
- lr_fleet-0.1.0/tests/test_device_identity.py +71 -0
- lr_fleet-0.1.0/tests/test_device_keys.py +67 -0
- lr_fleet-0.1.0/tests/test_device_reporter.py +255 -0
- lr_fleet-0.1.0/tests/test_device_wire.py +67 -0
- lr_fleet-0.1.0/tests/test_device_yubikey.py +217 -0
- lr_fleet-0.1.0/tests/test_entra.py +171 -0
- lr_fleet-0.1.0/tests/test_jwt_decode.py +33 -0
- lr_fleet-0.1.0/tests/test_pairing.py +18 -0
- lr_fleet-0.1.0/tests/test_session.py +104 -0
- 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.
|
lr_fleet-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
lr_fleet-0.1.0/README.md
ADDED
|
@@ -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
|
lr_fleet-0.1.0/setup.cfg
ADDED