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 +12 -0
- lr_fleet/cli.py +70 -0
- lr_fleet/client.py +141 -0
- lr_fleet/commands/__init__.py +66 -0
- lr_fleet/commands/articles.py +62 -0
- lr_fleet/commands/device.py +208 -0
- lr_fleet/commands/devices.py +167 -0
- lr_fleet/commands/login.py +79 -0
- lr_fleet/commands/pending.py +145 -0
- lr_fleet/commands/provision.py +178 -0
- lr_fleet/config.py +60 -0
- lr_fleet/device/__init__.py +39 -0
- lr_fleet/device/client.py +140 -0
- lr_fleet/device/identity.py +145 -0
- lr_fleet/device/keys.py +108 -0
- lr_fleet/device/reporter.py +227 -0
- lr_fleet/device/tpm.py +198 -0
- lr_fleet/device/wire.py +83 -0
- lr_fleet/device/yubikey.py +187 -0
- lr_fleet/entra.py +155 -0
- lr_fleet/exceptions.py +45 -0
- lr_fleet/jwt_decode.py +30 -0
- lr_fleet/pairing.py +23 -0
- lr_fleet/session.py +54 -0
- lr_fleet-0.1.0.dist-info/METADATA +302 -0
- lr_fleet-0.1.0.dist-info/RECORD +30 -0
- lr_fleet-0.1.0.dist-info/WHEEL +5 -0
- lr_fleet-0.1.0.dist-info/entry_points.txt +2 -0
- lr_fleet-0.1.0.dist-info/licenses/LICENSE +21 -0
- lr_fleet-0.1.0.dist-info/top_level.txt +1 -0
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"]
|