lr-fleet 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """lr-fleet: CLI client for the LumenRadio fleet management REST API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ try:
8
+ __version__ = version("lr-fleet")
9
+ except PackageNotFoundError: # running from a source tree that was never installed
10
+ __version__ = "0.0.0+unknown"
11
+
12
+ __all__ = ["__version__"]
lr_fleet/cli.py ADDED
@@ -0,0 +1,70 @@
1
+ """Typer-powered CLI entry point for lr-fleet."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import typer
9
+
10
+ from . import __version__
11
+ from .client import DEFAULT_BASE_URL
12
+ from .commands import CliContext
13
+ from .commands.articles import articles
14
+ from .commands import device as device_commands
15
+ from .commands.devices import list_devices, replace_key, revoke, show_device
16
+ from .commands.login import login, logout, status
17
+ from .commands.pending import approve, pending, reject
18
+ from .commands.provision import provision
19
+ from .config import DEFAULT_CONFIG_PATH
20
+
21
+ app = typer.Typer(help="Manage the LumenRadio fleet from the command line.")
22
+
23
+ app.command("list")(list_devices)
24
+ app.command("show")(show_device)
25
+ app.command("revoke")(revoke)
26
+ app.command("replace-key")(replace_key)
27
+ app.command("pending")(pending)
28
+ app.command("approve")(approve)
29
+ app.command("reject")(reject)
30
+ app.command("provision")(provision)
31
+ app.command("articles")(articles)
32
+ app.command("login")(login)
33
+ app.command("logout")(logout)
34
+ app.command("status")(status)
35
+ app.add_typer(device_commands.app, name="device")
36
+
37
+
38
+ @app.callback(invoke_without_command=True)
39
+ def main(
40
+ ctx: typer.Context,
41
+ base_url: str = typer.Option(
42
+ DEFAULT_BASE_URL,
43
+ "--base-url",
44
+ envvar="FLEET_URL",
45
+ help="Fleet management API base URL.",
46
+ ),
47
+ config_path: Optional[Path] = typer.Option(
48
+ None,
49
+ "--config-path",
50
+ help="Override the location of ~/.config/lr-fleet/token.json.",
51
+ ),
52
+ version: bool = typer.Option(False, "--version", help="Print the lr-fleet version and exit."),
53
+ ) -> None:
54
+ """Top-level callback: resolves --base-url/--config-path for every
55
+ sub-command, and handles --version."""
56
+ if version:
57
+ typer.echo(__version__)
58
+ raise typer.Exit()
59
+
60
+ ctx.obj = CliContext(
61
+ base_url=base_url.rstrip("/"),
62
+ config_path=config_path.expanduser() if config_path else DEFAULT_CONFIG_PATH,
63
+ )
64
+
65
+ if ctx.invoked_subcommand is None:
66
+ typer.echo(ctx.get_help())
67
+ raise typer.Exit()
68
+
69
+
70
+ __all__ = ["app"]
lr_fleet/client.py ADDED
@@ -0,0 +1,141 @@
1
+ """FleetClient: programmatic access to the fleet management REST API.
2
+
3
+ Every method sends the bearer token given at construction; none of them
4
+ touch a token cache, an environment variable or a terminal. Raises
5
+ FleetError subclasses for all error conditions; never calls sys.exit().
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ import requests as _requests
13
+
14
+ from .exceptions import (
15
+ ApiError,
16
+ AuthenticationError,
17
+ ForbiddenError,
18
+ NetworkError,
19
+ NotFoundError,
20
+ )
21
+
22
+ DEFAULT_BASE_URL = "https://fleet.cloud.lumenradio.com"
23
+
24
+
25
+ def _error_message(resp: _requests.Response) -> str:
26
+ try:
27
+ body = resp.json()
28
+ except ValueError:
29
+ return resp.text
30
+ if not isinstance(body, dict):
31
+ return resp.text
32
+ detail = body.get("detail", resp.text)
33
+ if isinstance(detail, list): # FastAPI validation errors
34
+ detail = "; ".join(str(item) for item in detail)
35
+ status = body.get("status")
36
+ return f"{status}: {detail}" if status else str(detail)
37
+
38
+
39
+ class FleetClient:
40
+ """Client for the fleet management REST API under /api/v1."""
41
+
42
+ def __init__(self, access_token: str, base_url: str = DEFAULT_BASE_URL) -> None:
43
+ self._token = access_token
44
+ self._base_url = base_url.rstrip("/")
45
+
46
+ def _request(self, method: str, path: str, **kwargs: Any) -> Any:
47
+ headers = kwargs.pop("headers", {})
48
+ headers["Authorization"] = f"Bearer {self._token}"
49
+ try:
50
+ resp = _requests.request(
51
+ method,
52
+ f"{self._base_url}/api/v1{path}",
53
+ headers=headers,
54
+ timeout=10,
55
+ **kwargs,
56
+ )
57
+ except _requests.RequestException as exc:
58
+ raise NetworkError(str(exc)) from exc
59
+
60
+ self._raise_for_status(resp)
61
+
62
+ if resp.status_code == 204 or not resp.content:
63
+ return None
64
+ return resp.json()
65
+
66
+ @staticmethod
67
+ def _raise_for_status(resp: _requests.Response) -> None:
68
+ if resp.status_code == 401:
69
+ raise AuthenticationError("not signed in or token expired; run `fleet login`")
70
+ if resp.status_code == 403:
71
+ raise ForbiddenError(
72
+ "your account holds no Fleet access at the required tier; "
73
+ "ask to be added to the Entra group that carries it"
74
+ )
75
+ if not resp.ok:
76
+ message = _error_message(resp)
77
+ if resp.status_code == 404:
78
+ raise NotFoundError(message)
79
+ raise ApiError(resp.status_code, message)
80
+
81
+ # ------------------------------------------------------------------
82
+ # Devices
83
+ # ------------------------------------------------------------------
84
+
85
+ def list_devices(
86
+ self, *, product_id: str | None = None, online: bool | None = None
87
+ ) -> list[dict[str, Any]]:
88
+ """GET /devices."""
89
+ params: dict[str, Any] = {}
90
+ if product_id is not None:
91
+ params["product_id"] = product_id
92
+ if online is not None:
93
+ params["online"] = online
94
+ return self._request("GET", "/devices", params=params) or []
95
+
96
+ def show_device(self, uid: str) -> dict[str, Any]:
97
+ """GET /devices/{uid}."""
98
+ return self._request("GET", f"/devices/{uid}")
99
+
100
+ def revoke(self, uid: str) -> dict[str, Any]:
101
+ """POST /devices/{uid}/revoke."""
102
+ return self._request("POST", f"/devices/{uid}/revoke")
103
+
104
+ def replace_key(self, uid: str) -> dict[str, Any]:
105
+ """POST /devices/{uid}/replace-key."""
106
+ return self._request("POST", f"/devices/{uid}/replace-key")
107
+
108
+ # ------------------------------------------------------------------
109
+ # Pending enrolments
110
+ # ------------------------------------------------------------------
111
+
112
+ def list_pending(self) -> list[dict[str, Any]]:
113
+ """GET /pending."""
114
+ return self._request("GET", "/pending") or []
115
+
116
+ def approve(self, uid: str, display_name: str) -> dict[str, Any]:
117
+ """POST /pending/{uid}/approve."""
118
+ return self._request("POST", f"/pending/{uid}/approve", json={"display_name": display_name})
119
+
120
+ def reject(self, uid: str) -> dict[str, Any]:
121
+ """POST /pending/{uid}/reject."""
122
+ return self._request("POST", f"/pending/{uid}/reject")
123
+
124
+ # ------------------------------------------------------------------
125
+ # Hardware keys
126
+ # ------------------------------------------------------------------
127
+
128
+ def register_hardware_key(self, body: dict[str, Any]) -> dict[str, Any]:
129
+ """POST /hardware-keys."""
130
+ return self._request("POST", "/hardware-keys", json=body)
131
+
132
+ # ------------------------------------------------------------------
133
+ # Articles
134
+ # ------------------------------------------------------------------
135
+
136
+ def get_article(self, article: str) -> dict[str, Any]:
137
+ """GET /articles/{article}."""
138
+ return self._request("GET", f"/articles/{article}")
139
+
140
+
141
+ __all__ = ["FleetClient", "DEFAULT_BASE_URL"]
@@ -0,0 +1,66 @@
1
+ """Shared helpers for fleet CLI sub-commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import typer
11
+ from rich.console import Console
12
+
13
+ from ..client import DEFAULT_BASE_URL, FleetClient
14
+ from ..exceptions import FleetError
15
+ from ..session import NotLoggedIn, resolve_token
16
+
17
+ # A fixed width, not the terminal's own. CliRunner and a piped `fleet list`
18
+ # both report no real terminal size, and rich's 80-column guess truncates
19
+ # our widest column (a 32-char uid) before the row's other fields print.
20
+ TABLE_WIDTH = 200
21
+
22
+
23
+ def console() -> Console:
24
+ """A Console at TABLE_WIDTH, for every table and detail panel."""
25
+ return Console(width=TABLE_WIDTH)
26
+
27
+
28
+ @dataclass
29
+ class CliContext:
30
+ """The resolved --base-url and --config-path, shared by every command."""
31
+
32
+ base_url: str
33
+ config_path: Path
34
+
35
+
36
+ def print_json(data: Any) -> None:
37
+ """Print data as indented JSON, the --json output every read supports."""
38
+ typer.echo(json.dumps(data, indent=2))
39
+
40
+
41
+ def get_client(ctx: typer.Context) -> FleetClient:
42
+ """Build a FleetClient for the current context, or exit with an error."""
43
+ cli_ctx: CliContext = ctx.obj
44
+ try:
45
+ token = resolve_token(cli_ctx.config_path)
46
+ except NotLoggedIn as exc:
47
+ typer.secho(str(exc), fg=typer.colors.RED)
48
+ raise typer.Exit(code=1) from exc
49
+ return FleetClient(token, cli_ctx.base_url)
50
+
51
+
52
+ def handle_fleet_error(exc: FleetError) -> None:
53
+ """Print exc and exit with code 1."""
54
+ typer.secho(str(exc), fg=typer.colors.RED)
55
+ raise typer.Exit(code=1)
56
+
57
+
58
+ __all__ = [
59
+ "DEFAULT_BASE_URL",
60
+ "TABLE_WIDTH",
61
+ "CliContext",
62
+ "console",
63
+ "print_json",
64
+ "get_client",
65
+ "handle_fleet_error",
66
+ ]
@@ -0,0 +1,62 @@
1
+ """`fleet articles`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.table import Table
7
+
8
+ from ..exceptions import FleetError
9
+ from . import console, get_client, handle_fleet_error, print_json
10
+
11
+
12
+ def articles(
13
+ ctx: typer.Context,
14
+ article: str = typer.Argument(..., help="The article number to pivot on."),
15
+ json_output: bool = typer.Option(False, "--json", help="Print the raw JSON response."),
16
+ ) -> None:
17
+ """Show every station holding article, grouped by Build Words.
18
+
19
+ More than one group means the fleet disagrees about what that article
20
+ is -- the divergence this pivot exists to surface.
21
+ """
22
+ client = get_client(ctx)
23
+ try:
24
+ result = client.get_article(article)
25
+ except FleetError as exc:
26
+ handle_fleet_error(exc)
27
+ return
28
+
29
+ if json_output:
30
+ print_json(result)
31
+ return
32
+
33
+ groups = result.get("groups", {})
34
+ if not groups:
35
+ typer.echo(f"No devices report article {article}.")
36
+ return
37
+
38
+ if result.get("divergent"):
39
+ typer.secho(
40
+ f"Divergent: {len(groups)} distinct Build Words values for {article}.",
41
+ fg=typer.colors.YELLOW,
42
+ )
43
+
44
+ out = console()
45
+ for build_words, devices in groups.items():
46
+ out.print(f"[bold]Build Words: {build_words or '(none)'}[/bold]")
47
+ table = Table(show_header=True, header_style="bold")
48
+ table.add_column("UID", style="cyan", no_wrap=True)
49
+ table.add_column("Name")
50
+ table.add_column("Revision")
51
+ table.add_column("Manifest SHA-256")
52
+ for device in devices:
53
+ table.add_row(
54
+ device.get("uid", ""),
55
+ device.get("display_name", ""),
56
+ device.get("revision", ""),
57
+ device.get("manifest_sha256", ""),
58
+ )
59
+ out.print(table)
60
+
61
+
62
+ __all__ = ["articles"]
@@ -0,0 +1,208 @@
1
+ """`fleet device …`: play a fleet device from a terminal.
2
+
3
+ These commands are the device library (:mod:`lr_fleet.device`) with a face on
4
+ it — for trying the enrolment workflow by hand, from a laptop, with a software
5
+ key, a YubiKey or the machine's TPM. They sign as the device, so they need no
6
+ sign-in; the fleet's base URL comes from the top-level ``--base-url``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from pathlib import Path
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ import typer
16
+
17
+ from ..exceptions import FleetError
18
+ from ..device import DeviceClient, DeviceIdentity, DeviceResponse, SoftwareKey, os_composite
19
+ from . import CliContext, print_json
20
+
21
+ DEFAULT_DEVICE_DIR = Path("~/.config/lr-fleet/device").expanduser()
22
+
23
+ app = typer.Typer(help="Act as a fleet device: create an identity, enrol, report.")
24
+
25
+ _DirOption = typer.Option(
26
+ DEFAULT_DEVICE_DIR,
27
+ "--dir",
28
+ envvar="FLEET_DEVICE_DIR",
29
+ help="Where the device identity lives.",
30
+ )
31
+
32
+
33
+ def _fail(message: str) -> None:
34
+ typer.secho(message, fg=typer.colors.RED)
35
+ raise typer.Exit(code=1)
36
+
37
+
38
+ def _load(directory: Path) -> DeviceIdentity:
39
+ try:
40
+ return DeviceIdentity.load(directory)
41
+ except FleetError as exc:
42
+ _fail(str(exc))
43
+ raise
44
+
45
+
46
+ def _holder(key: str, serial: Optional[int], slot: str, tcti: Optional[str]):
47
+ if key == "software":
48
+ return SoftwareKey.generate()
49
+ if key == "yubikey":
50
+ from ..device.yubikey import YubiKeyPivKey, attached_serial
51
+
52
+ holder = YubiKeyPivKey(serial=serial or attached_serial(), slot=slot)
53
+ holder.public_key() # refuses a slot with no key before anything is saved
54
+ return holder
55
+ if key == "tpm":
56
+ from ..device.tpm import Tpm2Key
57
+
58
+ return Tpm2Key.create(tcti=tcti)
59
+ _fail(f"--key must be software, yubikey or tpm, not {key!r}.")
60
+
61
+
62
+ @app.command("init")
63
+ def init(
64
+ directory: Path = _DirOption,
65
+ key: str = typer.Option(
66
+ "software", "--key", help="Where the key lives: software, yubikey or tpm."
67
+ ),
68
+ serial: Optional[int] = typer.Option(
69
+ None, "--serial", help="With --key yubikey: which YubiKey, when several are attached."
70
+ ),
71
+ slot: str = typer.Option(
72
+ "9e", "--slot", help="With --key yubikey: the slot `fleet provision --yubikey` used."
73
+ ),
74
+ tcti: Optional[str] = typer.Option(
75
+ None,
76
+ "--tcti",
77
+ envvar="FLEET_TPM_TCTI",
78
+ help="With --key tpm: the TPM to use, e.g. swtpm:host=127.0.0.1,port=2321.",
79
+ ),
80
+ replace: bool = typer.Option(False, "--replace", help="Overwrite an existing identity."),
81
+ ) -> None:
82
+ """Create this device's identity."""
83
+ try:
84
+ identity = DeviceIdentity.create(
85
+ directory, _holder(key, serial, slot, tcti), replace=replace
86
+ )
87
+ except FleetError as exc:
88
+ _fail(str(exc))
89
+ typer.secho(f"Created device {identity.station_uid}.", fg=typer.colors.GREEN)
90
+ typer.echo(f"Key: {identity.holder.kind or 'software'}")
91
+ typer.echo(f"Pairing code: {identity.pairing_code}")
92
+
93
+
94
+ @app.command("show")
95
+ def show(
96
+ directory: Path = _DirOption,
97
+ json_output: bool = typer.Option(False, "--json", help="Print JSON."),
98
+ ) -> None:
99
+ """Show this device's uid, key and pairing code."""
100
+ identity = _load(directory)
101
+ try:
102
+ summary = {
103
+ "station_uid": identity.station_uid,
104
+ "key": identity.holder.kind or "software",
105
+ "pairing_code": identity.pairing_code,
106
+ "thumbprint": identity.thumbprint,
107
+ "request_seq": identity.request_seq,
108
+ "enrolled": identity.enrolled,
109
+ }
110
+ except FleetError as exc:
111
+ _fail(str(exc))
112
+ if json_output:
113
+ print_json(summary)
114
+ return
115
+ for label, value in summary.items():
116
+ typer.echo(f"{label}: {value}")
117
+
118
+
119
+ @app.command("attest")
120
+ def attest(directory: Path = _DirOption) -> None:
121
+ """Print the key's attestation, for `fleet provision --statement`."""
122
+ identity = _load(directory)
123
+ if identity.holder.kind is None:
124
+ _fail("A software key has no attestation.")
125
+ try:
126
+ attestation = identity.holder.attestation()
127
+ except FleetError as exc:
128
+ _fail(str(exc))
129
+ print_json(attestation)
130
+
131
+
132
+ def _composites(path: Optional[Path]) -> List[Dict[str, Any]]:
133
+ if path is None:
134
+ return [os_composite()]
135
+ try:
136
+ composites = json.loads(path.read_text(encoding="utf-8"))
137
+ except (OSError, ValueError) as exc:
138
+ _fail(f"Cannot read composites from {path}: {exc}")
139
+ if not isinstance(composites, list):
140
+ _fail(f"{path} must hold a JSON list of composites.")
141
+ return composites
142
+
143
+
144
+ def _report(response: DeviceResponse, identity: DeviceIdentity, json_output: bool) -> None:
145
+ if json_output:
146
+ print_json({"status_code": response.status_code, **response.body})
147
+ return
148
+ colour = typer.colors.GREEN if response.status_code < 300 else typer.colors.YELLOW
149
+ typer.secho(f"{response.status_code} {response.status or 'ok'}", fg=colour)
150
+ if response.status in ("pending", "key_mismatch"):
151
+ typer.echo(f"Pairing code: {identity.pairing_code}")
152
+ if response.status_code >= 400:
153
+ raise typer.Exit(code=1)
154
+
155
+
156
+ @app.command("enrol")
157
+ def enrol(
158
+ ctx: typer.Context,
159
+ directory: Path = _DirOption,
160
+ product_id: str = typer.Option("504-1007", "--product-id", help="The product to enrol as."),
161
+ enrol_token: Optional[str] = typer.Option(
162
+ None, "--enrol-token", envvar="FLEET_ENROL_TOKEN", help="The product's enrol token."
163
+ ),
164
+ composites: Optional[Path] = typer.Option(
165
+ None, "--composites", help="A JSON list of composites; default: this machine's os."
166
+ ),
167
+ no_attest: bool = typer.Option(
168
+ False, "--no-attest", help="Enrol a hardware key without its attestation."
169
+ ),
170
+ json_output: bool = typer.Option(False, "--json", help="Print the raw JSON response."),
171
+ ) -> None:
172
+ """Enrol, or confirm this device is enrolled. 202 means it waits for approval."""
173
+ cli_ctx: CliContext = ctx.obj
174
+ identity = _load(directory)
175
+ client = DeviceClient(identity, cli_ctx.base_url)
176
+ try:
177
+ response = client.enroll(
178
+ product_id,
179
+ _composites(composites),
180
+ enrol_token=enrol_token,
181
+ attest=not no_attest,
182
+ )
183
+ except FleetError as exc:
184
+ _fail(str(exc))
185
+ _report(response, identity, json_output)
186
+
187
+
188
+ @app.command("report")
189
+ def report(
190
+ ctx: typer.Context,
191
+ directory: Path = _DirOption,
192
+ composites: Optional[Path] = typer.Option(
193
+ None, "--composites", help="A JSON list of composites; default: this machine's os."
194
+ ),
195
+ json_output: bool = typer.Option(False, "--json", help="Print the raw JSON response."),
196
+ ) -> None:
197
+ """Send one snapshot. The device must be enrolled."""
198
+ cli_ctx: CliContext = ctx.obj
199
+ identity = _load(directory)
200
+ client = DeviceClient(identity, cli_ctx.base_url)
201
+ try:
202
+ response = client.snapshot(_composites(composites))
203
+ except FleetError as exc:
204
+ _fail(str(exc))
205
+ _report(response, identity, json_output)
206
+
207
+
208
+ __all__ = ["app", "DEFAULT_DEVICE_DIR"]