lablink-cli 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.
- lablink_cli/__init__.py +8 -0
- lablink_cli/api.py +428 -0
- lablink_cli/app.py +938 -0
- lablink_cli/byo_detect.py +112 -0
- lablink_cli/commands/__init__.py +0 -0
- lablink_cli/commands/cleanup.py +647 -0
- lablink_cli/commands/deploy.py +863 -0
- lablink_cli/commands/deploy_compose.py +1203 -0
- lablink_cli/commands/doctor.py +549 -0
- lablink_cli/commands/export_metrics.py +244 -0
- lablink_cli/commands/launch.py +236 -0
- lablink_cli/commands/logs.py +434 -0
- lablink_cli/commands/register.py +839 -0
- lablink_cli/commands/reset_overlay.py +109 -0
- lablink_cli/commands/setup.py +347 -0
- lablink_cli/commands/stats.py +133 -0
- lablink_cli/commands/status.py +934 -0
- lablink_cli/commands/unregister.py +188 -0
- lablink_cli/commands/utils.py +552 -0
- lablink_cli/config/__init__.py +0 -0
- lablink_cli/config/schema.py +212 -0
- lablink_cli/deployment_metrics.py +94 -0
- lablink_cli/docker.py +419 -0
- lablink_cli/log_shipper.py +441 -0
- lablink_cli/templates/docker-compose.tailscale-override.yml +55 -0
- lablink_cli/templates/docker-compose.yml +67 -0
- lablink_cli/tofu_source.py +169 -0
- lablink_cli/tui/__init__.py +0 -0
- lablink_cli/tui/logs_viewer.py +413 -0
- lablink_cli/tui/wizard.py +1814 -0
- lablink_cli-0.1.0.dist-info/METADATA +76 -0
- lablink_cli-0.1.0.dist-info/RECORD +35 -0
- lablink_cli-0.1.0.dist-info/WHEEL +5 -0
- lablink_cli-0.1.0.dist-info/entry_points.txt +2 -0
- lablink_cli-0.1.0.dist-info/top_level.txt +1 -0
lablink_cli/app.py
ADDED
|
@@ -0,0 +1,938 @@
|
|
|
1
|
+
"""LabLink CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from lablink_cli.config.schema import load_config
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(
|
|
10
|
+
name="lablink",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
client_app = typer.Typer(
|
|
14
|
+
name="client",
|
|
15
|
+
help="Manage the client fleet (register/launch/destroy/unregister).",
|
|
16
|
+
)
|
|
17
|
+
app.add_typer(client_app, name="client")
|
|
18
|
+
|
|
19
|
+
DEFAULT_CONFIG = Path.home() / ".lablink" / "config.yaml"
|
|
20
|
+
|
|
21
|
+
# Where a lablink-template checkout keeps its committed config.
|
|
22
|
+
TEMPLATE_CONFIG = Path("lablink-infrastructure") / "config" / "config.yaml"
|
|
23
|
+
|
|
24
|
+
# The credential fields template mode pins, matching what the template's own
|
|
25
|
+
# scripts/configure.sh emits: passwords as sentinels for CI to substitute,
|
|
26
|
+
# admin_user as the literal the workflow never touches.
|
|
27
|
+
TEMPLATE_CREDENTIALS = {
|
|
28
|
+
("app", "admin_user"): "admin",
|
|
29
|
+
("app", "admin_password"): "PLACEHOLDER_ADMIN_PASSWORD",
|
|
30
|
+
("db", "password"): "PLACEHOLDER_DB_PASSWORD",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _write_template_credentials(path: Path) -> None:
|
|
35
|
+
"""Rewrite the wizard's credential defaults to the template's convention.
|
|
36
|
+
|
|
37
|
+
lablink-template commits config.yaml and injects the real passwords in
|
|
38
|
+
CI with ``sed s/PLACEHOLDER_<NAME>/.../``. The wizard never collects
|
|
39
|
+
credentials (they're resolved at deploy time on the local path), so it
|
|
40
|
+
writes the ``MISSING`` secret sentinel and the ``db.password`` default —
|
|
41
|
+
neither of which that sed matches. Without this the substitution
|
|
42
|
+
silently does nothing, and the workflow's own ``grep -q PLACEHOLDER_``
|
|
43
|
+
guard still passes, because it only catches placeholders left over, not
|
|
44
|
+
placeholders that were never there. ``admin_user`` gets no sed at all,
|
|
45
|
+
so it has to be a usable value here rather than a sentinel.
|
|
46
|
+
"""
|
|
47
|
+
import yaml
|
|
48
|
+
|
|
49
|
+
data = yaml.safe_load(path.read_text())
|
|
50
|
+
for (section, key), value in TEMPLATE_CREDENTIALS.items():
|
|
51
|
+
data.setdefault(section, {})[key] = value
|
|
52
|
+
path.write_text(
|
|
53
|
+
yaml.dump(data, default_flow_style=False, sort_keys=False)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _version_callback(value: bool) -> None:
|
|
58
|
+
if value:
|
|
59
|
+
from importlib.metadata import version
|
|
60
|
+
|
|
61
|
+
from lablink_cli import TEMPLATE_VERSION
|
|
62
|
+
|
|
63
|
+
typer.echo(f"lablink-cli {version('lablink-cli')}")
|
|
64
|
+
typer.echo(f"lablink-template {TEMPLATE_VERSION.lstrip('v')}")
|
|
65
|
+
raise typer.Exit()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.callback(invoke_without_command=True)
|
|
69
|
+
def _root(
|
|
70
|
+
ctx: typer.Context,
|
|
71
|
+
_version: bool = typer.Option(
|
|
72
|
+
False,
|
|
73
|
+
"--version",
|
|
74
|
+
"-v",
|
|
75
|
+
callback=_version_callback,
|
|
76
|
+
is_eager=True,
|
|
77
|
+
help="Show CLI and template versions and exit.",
|
|
78
|
+
),
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Deploy and manage LabLink teaching lab infrastructure."""
|
|
81
|
+
if ctx.invoked_subcommand is not None:
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
if not DEFAULT_CONFIG.exists():
|
|
85
|
+
from rich.console import Console
|
|
86
|
+
from rich.panel import Panel
|
|
87
|
+
|
|
88
|
+
Console().print(
|
|
89
|
+
Panel(
|
|
90
|
+
"Welcome to LabLink. First-time setup:\n\n"
|
|
91
|
+
" 1. [bold]lablink configure[/bold] "
|
|
92
|
+
"create config (AWS or manual/BYO provider)\n"
|
|
93
|
+
" 2. [bold]lablink doctor[/bold] "
|
|
94
|
+
"verify prerequisites for your provider\n"
|
|
95
|
+
" 3. [bold]lablink deploy[/bold] "
|
|
96
|
+
"deploy the allocator\n\n"
|
|
97
|
+
"For the full command list, run 'lablink --help'.",
|
|
98
|
+
border_style="cyan",
|
|
99
|
+
title="Getting started",
|
|
100
|
+
title_align="left",
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
raise typer.Exit()
|
|
104
|
+
|
|
105
|
+
typer.echo(ctx.get_help())
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _load_cfg(config: str | None):
|
|
109
|
+
"""Load config from path, exit with message if not found."""
|
|
110
|
+
from lablink_cli.config.schema import load_config
|
|
111
|
+
|
|
112
|
+
config_path = Path(config) if config else DEFAULT_CONFIG
|
|
113
|
+
if not config_path.exists():
|
|
114
|
+
typer.echo(
|
|
115
|
+
f"Config not found: {config_path}\n"
|
|
116
|
+
"Run 'lablink configure' first to generate a config."
|
|
117
|
+
)
|
|
118
|
+
raise typer.Exit(1)
|
|
119
|
+
return load_config(config_path)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.command(rich_help_panel="Setup")
|
|
123
|
+
def configure(
|
|
124
|
+
config: str = typer.Option(
|
|
125
|
+
None,
|
|
126
|
+
"--config",
|
|
127
|
+
"-c",
|
|
128
|
+
help="Path to config.yaml (default: ~/.lablink/config.yaml)",
|
|
129
|
+
),
|
|
130
|
+
template: bool = typer.Option(
|
|
131
|
+
False,
|
|
132
|
+
"--template",
|
|
133
|
+
help="Configure a lablink-template checkout instead of the local "
|
|
134
|
+
"deployment: writes lablink-infrastructure/config/config.yaml with "
|
|
135
|
+
"PLACEHOLDER_* passwords for GitHub Actions to substitute, and "
|
|
136
|
+
"skips AWS state setup (the template's setup.sh does that).",
|
|
137
|
+
),
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Create or edit the LabLink configuration.
|
|
140
|
+
|
|
141
|
+
Launches a TUI wizard to generate or modify config.yaml,
|
|
142
|
+
then automatically creates the AWS resources needed for
|
|
143
|
+
OpenTofu remote state (S3 bucket + DynamoDB lock table).
|
|
144
|
+
Manual-provider configs skip the AWS setup step.
|
|
145
|
+
|
|
146
|
+
With --template, generates the config a lablink-template repo commits
|
|
147
|
+
and deploys via GitHub Actions, so that path gets the same wizard.
|
|
148
|
+
"""
|
|
149
|
+
from lablink_cli.tui.wizard import ConfigWizard
|
|
150
|
+
|
|
151
|
+
if config:
|
|
152
|
+
config_path = Path(config)
|
|
153
|
+
elif template:
|
|
154
|
+
config_path = TEMPLATE_CONFIG
|
|
155
|
+
# Same guard as the template's own scripts/configure.sh: without it
|
|
156
|
+
# a wrong cwd silently creates a stray lablink-infrastructure/ tree
|
|
157
|
+
# that no workflow will ever read.
|
|
158
|
+
if not TEMPLATE_CONFIG.parent.parent.is_dir():
|
|
159
|
+
typer.echo(
|
|
160
|
+
"--template must be run from the root of a lablink-template "
|
|
161
|
+
f"checkout ({TEMPLATE_CONFIG.parent.parent}/ not found).\n"
|
|
162
|
+
"Pass --config to write somewhere else."
|
|
163
|
+
)
|
|
164
|
+
raise typer.Exit(1)
|
|
165
|
+
else:
|
|
166
|
+
config_path = DEFAULT_CONFIG
|
|
167
|
+
|
|
168
|
+
existing = None
|
|
169
|
+
if config_path.exists():
|
|
170
|
+
existing = load_config(config_path)
|
|
171
|
+
|
|
172
|
+
wizard = ConfigWizard(existing_config=existing, save_path=config_path)
|
|
173
|
+
wizard.run()
|
|
174
|
+
|
|
175
|
+
# After the wizard saves config, run AWS setup automatically
|
|
176
|
+
if not config_path.exists():
|
|
177
|
+
# User quit the wizard without saving
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
if template:
|
|
181
|
+
_write_template_credentials(config_path)
|
|
182
|
+
from rich.console import Console
|
|
183
|
+
|
|
184
|
+
Console().print(
|
|
185
|
+
f"[dim]Wrote {config_path} with placeholder passwords. "
|
|
186
|
+
"Commit it and push — the Deploy LabLink Infrastructure workflow "
|
|
187
|
+
"substitutes your ADMIN_PASSWORD/DB_PASSWORD secrets.[/dim]"
|
|
188
|
+
)
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
cfg_after = load_config(config_path)
|
|
192
|
+
if cfg_after.provider == "manual":
|
|
193
|
+
from rich.console import Console
|
|
194
|
+
|
|
195
|
+
Console().print(
|
|
196
|
+
"[dim]Manual provider doesn't need AWS state resources — "
|
|
197
|
+
"skipping setup. Run `lablink deploy` next.[/dim]"
|
|
198
|
+
)
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
from lablink_cli.commands.setup import run_setup
|
|
202
|
+
|
|
203
|
+
run_setup(cfg_after, config_path=config_path)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@app.command(rich_help_panel="Setup")
|
|
207
|
+
def setup(
|
|
208
|
+
config: str = typer.Option(
|
|
209
|
+
None,
|
|
210
|
+
"--config",
|
|
211
|
+
"-c",
|
|
212
|
+
help="Path to config.yaml (default: ~/.lablink/config.yaml)",
|
|
213
|
+
),
|
|
214
|
+
) -> None:
|
|
215
|
+
"""Provision provider-specific bootstrap resources.
|
|
216
|
+
|
|
217
|
+
AWS provider: creates the S3 bucket and DynamoDB lock table used
|
|
218
|
+
for OpenTofu remote state. Automatically run during 'lablink
|
|
219
|
+
configure'; use this command to recreate the resources if they
|
|
220
|
+
were deleted.
|
|
221
|
+
|
|
222
|
+
Manual provider: no bootstrap resources are needed; this command
|
|
223
|
+
is a no-op (a friendly message is printed).
|
|
224
|
+
"""
|
|
225
|
+
from lablink_cli.commands.setup import run_setup
|
|
226
|
+
|
|
227
|
+
config_path = Path(config) if config else DEFAULT_CONFIG
|
|
228
|
+
run_setup(_load_cfg(config), config_path=config_path)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@app.command(rich_help_panel="Deployment")
|
|
232
|
+
def deploy(
|
|
233
|
+
config: str = typer.Option(
|
|
234
|
+
None,
|
|
235
|
+
"--config",
|
|
236
|
+
"-c",
|
|
237
|
+
help="Path to config.yaml (default: ~/.lablink/config.yaml)",
|
|
238
|
+
),
|
|
239
|
+
template_version: str = typer.Option(
|
|
240
|
+
None,
|
|
241
|
+
"--template-version",
|
|
242
|
+
help="Override the pinned template version (e.g. v0.2.0). "
|
|
243
|
+
"Skips checksum verification. AWS provider only.",
|
|
244
|
+
),
|
|
245
|
+
terraform_bundle: str = typer.Option(
|
|
246
|
+
None,
|
|
247
|
+
"--terraform-bundle",
|
|
248
|
+
help="Path to a local template tarball for offline deploys. AWS provider only.",
|
|
249
|
+
),
|
|
250
|
+
yes: bool = typer.Option(
|
|
251
|
+
False,
|
|
252
|
+
"--yes",
|
|
253
|
+
"-y",
|
|
254
|
+
help="Skip confirmation prompts. Does not bypass credential prompts "
|
|
255
|
+
"(admin password still required interactively).",
|
|
256
|
+
),
|
|
257
|
+
tailscale_authkey: str = typer.Option(
|
|
258
|
+
None,
|
|
259
|
+
"--tailscale-authkey",
|
|
260
|
+
help="Tailscale auth key for the allocator's own tailnet sidecar. "
|
|
261
|
+
"Required on the first deploy when manual.connectivity is "
|
|
262
|
+
"'mesh_overlay' and/or manual.participant_exposure is "
|
|
263
|
+
"'tailscale_funnel'; optional on redeploys (the previous value is "
|
|
264
|
+
"carried forward). Manual provider only.",
|
|
265
|
+
),
|
|
266
|
+
cloudflare_tunnel_token: str = typer.Option(
|
|
267
|
+
None,
|
|
268
|
+
"--cloudflare-tunnel-token",
|
|
269
|
+
help="Cloudflare Tunnel token for publishing the allocator at "
|
|
270
|
+
"manual.public_hostname. Required on the first deploy when "
|
|
271
|
+
"manual.participant_exposure is 'cloudflare_tunnel'; optional on "
|
|
272
|
+
"redeploys (the previous value is carried forward). Supply it again "
|
|
273
|
+
"to rotate. Manual provider only.",
|
|
274
|
+
),
|
|
275
|
+
) -> None:
|
|
276
|
+
"""Deploy LabLink infrastructure (AWS OpenTofu or docker-compose)."""
|
|
277
|
+
cfg = _load_cfg(config)
|
|
278
|
+
if cfg.provider == "manual":
|
|
279
|
+
from lablink_cli.commands.deploy_compose import run_deploy_compose
|
|
280
|
+
|
|
281
|
+
run_deploy_compose(
|
|
282
|
+
cfg,
|
|
283
|
+
yes=yes,
|
|
284
|
+
tailscale_authkey=tailscale_authkey,
|
|
285
|
+
cloudflare_tunnel_token=cloudflare_tunnel_token,
|
|
286
|
+
)
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
from lablink_cli.commands.deploy import run_deploy
|
|
290
|
+
|
|
291
|
+
run_deploy(
|
|
292
|
+
cfg,
|
|
293
|
+
template_version=template_version,
|
|
294
|
+
terraform_bundle=terraform_bundle,
|
|
295
|
+
yes=yes,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@app.command(rich_help_panel="Deployment")
|
|
300
|
+
def destroy(
|
|
301
|
+
config: str = typer.Option(
|
|
302
|
+
None,
|
|
303
|
+
"--config",
|
|
304
|
+
"-c",
|
|
305
|
+
help="Path to config.yaml (default: ~/.lablink/config.yaml)",
|
|
306
|
+
),
|
|
307
|
+
yes: bool = typer.Option(
|
|
308
|
+
False,
|
|
309
|
+
"--yes",
|
|
310
|
+
"-y",
|
|
311
|
+
help="Skip confirmation prompts. Does not bypass credential prompts "
|
|
312
|
+
"(admin password still required interactively).",
|
|
313
|
+
),
|
|
314
|
+
verbose: bool = typer.Option(
|
|
315
|
+
False,
|
|
316
|
+
"--verbose",
|
|
317
|
+
"-v",
|
|
318
|
+
help="Show the full OpenTofu output instead of a summary.",
|
|
319
|
+
),
|
|
320
|
+
keep_data: bool = typer.Option(
|
|
321
|
+
False,
|
|
322
|
+
"--keep-data",
|
|
323
|
+
help="Manual provider only: preserve the Postgres data volume "
|
|
324
|
+
"instead of the default full wipe (registration history, "
|
|
325
|
+
"sessions, etc. survive a subsequent redeploy). Ignored for AWS.",
|
|
326
|
+
),
|
|
327
|
+
) -> None:
|
|
328
|
+
"""Tear down LabLink infrastructure."""
|
|
329
|
+
cfg = _load_cfg(config)
|
|
330
|
+
if cfg.provider == "manual":
|
|
331
|
+
from lablink_cli.commands.deploy_compose import run_destroy_compose
|
|
332
|
+
|
|
333
|
+
run_destroy_compose(cfg, yes=yes, keep_data=keep_data)
|
|
334
|
+
return
|
|
335
|
+
|
|
336
|
+
from lablink_cli.commands.deploy import run_destroy
|
|
337
|
+
|
|
338
|
+
run_destroy(cfg, yes=yes, verbose=verbose)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@client_app.command("launch")
|
|
342
|
+
def launch_client(
|
|
343
|
+
num_vms: int = typer.Option(
|
|
344
|
+
...,
|
|
345
|
+
"--num-vms",
|
|
346
|
+
"-n",
|
|
347
|
+
help="Number of client VMs to launch",
|
|
348
|
+
),
|
|
349
|
+
config: str = typer.Option(
|
|
350
|
+
None,
|
|
351
|
+
"--config",
|
|
352
|
+
"-c",
|
|
353
|
+
help="Path to config.yaml (default: ~/.lablink/config.yaml)",
|
|
354
|
+
),
|
|
355
|
+
verbose: bool = typer.Option(
|
|
356
|
+
False,
|
|
357
|
+
"--verbose",
|
|
358
|
+
"-v",
|
|
359
|
+
help="Show the full OpenTofu output instead of a summary.",
|
|
360
|
+
),
|
|
361
|
+
) -> None:
|
|
362
|
+
"""Launch client VMs via the allocator service.
|
|
363
|
+
|
|
364
|
+
AWS provider only: provisions client VMs through OpenTofu. For
|
|
365
|
+
the manual provider, BYO operators run 'lablink client register' on each
|
|
366
|
+
box instead; this command no-ops with a friendly message.
|
|
367
|
+
"""
|
|
368
|
+
from lablink_cli.commands.launch import run_launch
|
|
369
|
+
|
|
370
|
+
run_launch(_load_cfg(config), num_vms=num_vms, verbose=verbose)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@client_app.command("destroy")
|
|
374
|
+
def destroy_client(
|
|
375
|
+
config: str = typer.Option(
|
|
376
|
+
None,
|
|
377
|
+
"--config",
|
|
378
|
+
"-c",
|
|
379
|
+
help="Path to config.yaml (default: ~/.lablink/config.yaml)",
|
|
380
|
+
),
|
|
381
|
+
yes: bool = typer.Option(
|
|
382
|
+
False,
|
|
383
|
+
"--yes",
|
|
384
|
+
"-y",
|
|
385
|
+
help="Skip confirmation prompts. Does not bypass credential prompts "
|
|
386
|
+
"(admin password still required interactively).",
|
|
387
|
+
),
|
|
388
|
+
verbose: bool = typer.Option(
|
|
389
|
+
False,
|
|
390
|
+
"--verbose",
|
|
391
|
+
"-v",
|
|
392
|
+
help="Show the full OpenTofu output instead of a summary.",
|
|
393
|
+
),
|
|
394
|
+
) -> None:
|
|
395
|
+
"""Destroy all client VMs via the allocator service.
|
|
396
|
+
|
|
397
|
+
AWS provider only: the allocator runs 'tofu destroy' over its own
|
|
398
|
+
workspace and clears the VM table. Leaves the allocator itself running
|
|
399
|
+
— use 'lablink destroy' to tear down the whole deployment. For the
|
|
400
|
+
manual provider, BYO operators run 'lablink client unregister' on each
|
|
401
|
+
box instead; this command no-ops with a friendly message.
|
|
402
|
+
"""
|
|
403
|
+
from lablink_cli.commands.launch import run_client_destroy
|
|
404
|
+
|
|
405
|
+
run_client_destroy(_load_cfg(config), yes=yes, verbose=verbose)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@app.command(rich_help_panel="Operations")
|
|
409
|
+
def status(
|
|
410
|
+
config: str = typer.Option(
|
|
411
|
+
None,
|
|
412
|
+
"--config",
|
|
413
|
+
"-c",
|
|
414
|
+
help="Path to config.yaml (default: ~/.lablink/config.yaml)",
|
|
415
|
+
),
|
|
416
|
+
) -> None:
|
|
417
|
+
"""Show deployment health and inventory.
|
|
418
|
+
|
|
419
|
+
AWS provider: HTTP/DNS/SSL health checks, OpenTofu state, client
|
|
420
|
+
VM inventory, and a cost estimate. Manual provider: docker-compose
|
|
421
|
+
container status and the allocator's HTTP health endpoint.
|
|
422
|
+
"""
|
|
423
|
+
from lablink_cli.commands.status import run_status
|
|
424
|
+
|
|
425
|
+
run_status(_load_cfg(config))
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@app.command(rich_help_panel="Operations")
|
|
429
|
+
def logs(
|
|
430
|
+
config: str = typer.Option(
|
|
431
|
+
None,
|
|
432
|
+
"--config",
|
|
433
|
+
"-c",
|
|
434
|
+
help="Path to config.yaml (default: ~/.lablink/config.yaml)",
|
|
435
|
+
),
|
|
436
|
+
) -> None:
|
|
437
|
+
"""View allocator and client logs.
|
|
438
|
+
|
|
439
|
+
AWS provider: launches the interactive TUI that streams allocator
|
|
440
|
+
and per-VM client logs. Manual provider: tails the local
|
|
441
|
+
'lablink-allocator' docker container's logs (per-VM client logs
|
|
442
|
+
are not centralized; run 'docker logs lablink-client' on each
|
|
443
|
+
BYO box).
|
|
444
|
+
"""
|
|
445
|
+
from lablink_cli.commands.logs import run_logs
|
|
446
|
+
|
|
447
|
+
run_logs(_load_cfg(config))
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@app.command(rich_help_panel="Maintenance")
|
|
451
|
+
def cleanup(
|
|
452
|
+
config: str = typer.Option(
|
|
453
|
+
None,
|
|
454
|
+
"--config",
|
|
455
|
+
"-c",
|
|
456
|
+
help="Path to config.yaml (default: ~/.lablink/config.yaml)",
|
|
457
|
+
),
|
|
458
|
+
dry_run: bool = typer.Option(
|
|
459
|
+
False,
|
|
460
|
+
"--dry-run",
|
|
461
|
+
help="Show what would be deleted without making changes "
|
|
462
|
+
"(AWS provider only; manual provider's cleanup is non-destructive "
|
|
463
|
+
"until you confirm).",
|
|
464
|
+
),
|
|
465
|
+
) -> None:
|
|
466
|
+
"""Remove deployment resources and local state.
|
|
467
|
+
|
|
468
|
+
AWS provider: deletes orphaned EC2/IAM/EIP/SG resources and the
|
|
469
|
+
environment-specific OpenTofu state files. Manual provider: runs
|
|
470
|
+
'docker compose down --volumes' on the local stack and removes
|
|
471
|
+
the compose working directory.
|
|
472
|
+
"""
|
|
473
|
+
from lablink_cli.commands.cleanup import run_cleanup
|
|
474
|
+
|
|
475
|
+
run_cleanup(
|
|
476
|
+
_load_cfg(config),
|
|
477
|
+
dry_run=dry_run,
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
@app.command(rich_help_panel="Setup")
|
|
482
|
+
def doctor() -> None:
|
|
483
|
+
"""Check prerequisites and configuration."""
|
|
484
|
+
from lablink_cli.commands.doctor import run_doctor
|
|
485
|
+
|
|
486
|
+
run_doctor()
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
@client_app.command("doctor")
|
|
490
|
+
def client_doctor() -> None:
|
|
491
|
+
"""Check this machine's BYO client (container, log shipper)."""
|
|
492
|
+
from lablink_cli.commands.doctor import run_client_doctor
|
|
493
|
+
|
|
494
|
+
run_client_doctor()
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
@client_app.command("register")
|
|
498
|
+
def register(
|
|
499
|
+
allocator_url: str = typer.Option(
|
|
500
|
+
...,
|
|
501
|
+
"--allocator-url",
|
|
502
|
+
help="Base URL of the LabLink allocator (e.g., https://lablink.example.com).",
|
|
503
|
+
),
|
|
504
|
+
register_token: str = typer.Option(
|
|
505
|
+
...,
|
|
506
|
+
"--register-token",
|
|
507
|
+
prompt="Register token",
|
|
508
|
+
hide_input=True,
|
|
509
|
+
envvar="LABLINK_REGISTER_TOKEN",
|
|
510
|
+
help="The bootstrap register_token from the allocator operator "
|
|
511
|
+
"(prompted if omitted; also reads $LABLINK_REGISTER_TOKEN).",
|
|
512
|
+
),
|
|
513
|
+
hostname: str = typer.Option(
|
|
514
|
+
None,
|
|
515
|
+
"--hostname",
|
|
516
|
+
help="Override auto-detected hostname.",
|
|
517
|
+
),
|
|
518
|
+
lan_ip: str = typer.Option(
|
|
519
|
+
None,
|
|
520
|
+
"--lan-ip",
|
|
521
|
+
help="Override auto-detected LAN IP.",
|
|
522
|
+
),
|
|
523
|
+
machine_identity: str = typer.Option(
|
|
524
|
+
None,
|
|
525
|
+
"--machine-identity",
|
|
526
|
+
help="Override auto-detected machine identifier.",
|
|
527
|
+
),
|
|
528
|
+
gpu_present: bool = typer.Option(
|
|
529
|
+
None,
|
|
530
|
+
"--gpu-present/--no-gpu-present",
|
|
531
|
+
help="Override auto-detected GPU presence.",
|
|
532
|
+
),
|
|
533
|
+
gpu_model: str = typer.Option(
|
|
534
|
+
None,
|
|
535
|
+
"--gpu-model",
|
|
536
|
+
help="Override auto-detected GPU model string.",
|
|
537
|
+
),
|
|
538
|
+
overlay_hostname: str = typer.Option(
|
|
539
|
+
None,
|
|
540
|
+
"--overlay-hostname",
|
|
541
|
+
help="Register a mesh-overlay client (e.g. a Run:AI-hosted "
|
|
542
|
+
"workload) under this Tailscale hostname, chosen by you. "
|
|
543
|
+
"Requires --tailscale-authkey. By default (see --run-locally) "
|
|
544
|
+
"docker-runs the client container on this box now; pass "
|
|
545
|
+
"--no-run-locally to instead print secrets for a separate "
|
|
546
|
+
"workload submission, which also requires --hostname and "
|
|
547
|
+
"--machine-identity.",
|
|
548
|
+
),
|
|
549
|
+
tailscale_authkey: str = typer.Option(
|
|
550
|
+
None,
|
|
551
|
+
"--tailscale-authkey",
|
|
552
|
+
help="Tailscale auth key the workload will use to join the "
|
|
553
|
+
"tailnet. Required with --overlay-hostname.",
|
|
554
|
+
),
|
|
555
|
+
run_locally: bool = typer.Option(
|
|
556
|
+
True,
|
|
557
|
+
"--run-locally/--no-run-locally",
|
|
558
|
+
help="With --overlay-hostname: docker-run the client container "
|
|
559
|
+
"on this box now, auto-detecting hostname/machine-identity/GPU "
|
|
560
|
+
"like a real BYO box (default: on). Pass --no-run-locally to "
|
|
561
|
+
"instead just print secrets for pasting into a separate Run:AI "
|
|
562
|
+
"workload submission — for registering ahead of time, from "
|
|
563
|
+
"somewhere other than the workload itself.",
|
|
564
|
+
),
|
|
565
|
+
tunnel: bool = typer.Option(
|
|
566
|
+
False,
|
|
567
|
+
"--tunnel",
|
|
568
|
+
help="Register a tunnel client: instead of the allocator dialling "
|
|
569
|
+
"this box, the box dials OUT to the allocator and holds one "
|
|
570
|
+
"connection open. For networks that won't carry Tailscale and "
|
|
571
|
+
"boxes that can't accept inbound connections. Takes no arguments — "
|
|
572
|
+
"the allocator mints every value needed. Defaults to "
|
|
573
|
+
"docker-running the client here; pass --no-run-locally to print "
|
|
574
|
+
"secrets for a separate workload submission instead.",
|
|
575
|
+
),
|
|
576
|
+
force: bool = typer.Option(
|
|
577
|
+
False,
|
|
578
|
+
"--force",
|
|
579
|
+
help="Overwrite an existing ~/.lablink/client.env. Mints a new "
|
|
580
|
+
"client_secret (orphans any running container).",
|
|
581
|
+
),
|
|
582
|
+
env_file: Path = typer.Option(
|
|
583
|
+
None,
|
|
584
|
+
"--env-file",
|
|
585
|
+
help="Path to write secrets (default ~/.lablink/client.env).",
|
|
586
|
+
),
|
|
587
|
+
insecure: bool = typer.Option(
|
|
588
|
+
False,
|
|
589
|
+
"--insecure",
|
|
590
|
+
help="Skip TLS verification (use when the allocator's "
|
|
591
|
+
"ssl.provider is self_signed).",
|
|
592
|
+
),
|
|
593
|
+
) -> None:
|
|
594
|
+
"""Register this BYO box as a manual client and run the client container.
|
|
595
|
+
|
|
596
|
+
Docker-runs the client container after registering — for a real BYO
|
|
597
|
+
box, or for a mesh-overlay client (--overlay-hostname) with the
|
|
598
|
+
default --run-locally. Pass --overlay-hostname --no-run-locally to
|
|
599
|
+
instead print secrets for a separate Run:AI workload submission. If
|
|
600
|
+
docker is missing, the env file is preserved so the user can install
|
|
601
|
+
docker and re-run with --force.
|
|
602
|
+
"""
|
|
603
|
+
from lablink_cli.commands.register import run_register
|
|
604
|
+
|
|
605
|
+
run_register(
|
|
606
|
+
allocator_url=allocator_url,
|
|
607
|
+
register_token=register_token,
|
|
608
|
+
hostname=hostname,
|
|
609
|
+
lan_ip=lan_ip,
|
|
610
|
+
machine_identity=machine_identity,
|
|
611
|
+
gpu_present=gpu_present,
|
|
612
|
+
gpu_model=gpu_model,
|
|
613
|
+
force=force,
|
|
614
|
+
env_file=env_file,
|
|
615
|
+
insecure=insecure,
|
|
616
|
+
overlay_hostname=overlay_hostname,
|
|
617
|
+
tailscale_authkey=tailscale_authkey,
|
|
618
|
+
run_locally=run_locally,
|
|
619
|
+
reverse_tunnel=tunnel,
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
@client_app.command("unregister")
|
|
624
|
+
def unregister(
|
|
625
|
+
env_file: Path = typer.Option(
|
|
626
|
+
None,
|
|
627
|
+
"--env-file",
|
|
628
|
+
help="Path to client.env (default ~/.lablink/client.env).",
|
|
629
|
+
),
|
|
630
|
+
insecure: bool = typer.Option(
|
|
631
|
+
False,
|
|
632
|
+
"--insecure",
|
|
633
|
+
help="Skip TLS verification for the allocator notify call "
|
|
634
|
+
"(use when the allocator's ssl.provider is self_signed).",
|
|
635
|
+
),
|
|
636
|
+
yes: bool = typer.Option(
|
|
637
|
+
False,
|
|
638
|
+
"--yes",
|
|
639
|
+
"-y",
|
|
640
|
+
help="Skip the confirmation prompt.",
|
|
641
|
+
),
|
|
642
|
+
) -> None:
|
|
643
|
+
"""Tear down a registered BYO box.
|
|
644
|
+
|
|
645
|
+
Best-effort notifies the allocator, then removes the
|
|
646
|
+
`lablink-client` container and deletes the env file. Idempotent
|
|
647
|
+
— does nothing and exits 0 if there is no env file. Safe to run
|
|
648
|
+
after `lablink destroy` (the allocator will be unreachable, which
|
|
649
|
+
is the expected case).
|
|
650
|
+
"""
|
|
651
|
+
from lablink_cli.commands.unregister import run_unregister
|
|
652
|
+
|
|
653
|
+
run_unregister(env_file=env_file, insecure=insecure, yes=yes)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
@client_app.command("reset-overlay")
|
|
657
|
+
def reset_overlay(
|
|
658
|
+
yes: bool = typer.Option(
|
|
659
|
+
False,
|
|
660
|
+
"--yes",
|
|
661
|
+
"-y",
|
|
662
|
+
help="Skip the confirmation prompt.",
|
|
663
|
+
),
|
|
664
|
+
) -> None:
|
|
665
|
+
"""Discard this box's persisted mesh-overlay node identity.
|
|
666
|
+
|
|
667
|
+
Only relevant to a mesh-overlay client. `unregister` deliberately
|
|
668
|
+
keeps the identity so that re-registering lands back on the same
|
|
669
|
+
tailnet node under the same name; run this when you want the next
|
|
670
|
+
`register` to join as a brand-new node instead.
|
|
671
|
+
|
|
672
|
+
Note that this does not remove the old machine from your tailnet — it
|
|
673
|
+
goes offline still holding its name, so the new node is given a
|
|
674
|
+
numeric suffix until you delete the stale machine in the Tailscale
|
|
675
|
+
admin console. Requires the `lablink-client` container to be gone
|
|
676
|
+
already (docker will not remove an attached volume).
|
|
677
|
+
"""
|
|
678
|
+
from lablink_cli.commands.reset_overlay import run_reset_overlay
|
|
679
|
+
|
|
680
|
+
run_reset_overlay(yes=yes)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
@app.command("show-config", rich_help_panel="Maintenance")
|
|
684
|
+
def show_config(
|
|
685
|
+
config: str = typer.Option(
|
|
686
|
+
None,
|
|
687
|
+
"--config",
|
|
688
|
+
"-c",
|
|
689
|
+
help="Path to config.yaml (default: ~/.lablink/config.yaml)",
|
|
690
|
+
),
|
|
691
|
+
) -> None:
|
|
692
|
+
"""View the current LabLink configuration."""
|
|
693
|
+
from rich.console import Console
|
|
694
|
+
from rich.syntax import Syntax
|
|
695
|
+
|
|
696
|
+
config_path = Path(config) if config else DEFAULT_CONFIG
|
|
697
|
+
if not config_path.exists():
|
|
698
|
+
typer.echo(
|
|
699
|
+
f"Config not found: {config_path}\n"
|
|
700
|
+
"Run 'lablink configure' first to generate a config."
|
|
701
|
+
)
|
|
702
|
+
raise typer.Exit(1)
|
|
703
|
+
|
|
704
|
+
from lablink_cli.config.schema import load_config, validate_config
|
|
705
|
+
|
|
706
|
+
raw = config_path.read_text()
|
|
707
|
+
console = Console()
|
|
708
|
+
console.print(f"[dim]Config file:[/dim] {config_path}\n")
|
|
709
|
+
console.print(Syntax(raw, "yaml", theme="monokai"))
|
|
710
|
+
|
|
711
|
+
cfg = load_config(config_path)
|
|
712
|
+
errors = validate_config(cfg)
|
|
713
|
+
if errors:
|
|
714
|
+
console.print("\n[bold red]Validation errors:[/bold red]")
|
|
715
|
+
for e in errors:
|
|
716
|
+
console.print(f" [red]*[/red] {e}")
|
|
717
|
+
else:
|
|
718
|
+
console.print("\n[green]Config is valid.[/green]")
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _clear_template_cache(console) -> None:
|
|
722
|
+
"""Clear the OpenTofu template cache at ``tofu_source.CACHE_DIR``."""
|
|
723
|
+
import shutil
|
|
724
|
+
|
|
725
|
+
from lablink_cli import tofu_source
|
|
726
|
+
|
|
727
|
+
cache_dir = tofu_source.CACHE_DIR
|
|
728
|
+
|
|
729
|
+
if not cache_dir.exists():
|
|
730
|
+
console.print("[dim]No cache to clear.[/dim]")
|
|
731
|
+
return
|
|
732
|
+
|
|
733
|
+
versions = [d.name for d in cache_dir.iterdir() if d.is_dir()]
|
|
734
|
+
if not versions:
|
|
735
|
+
console.print("[dim]Cache is empty.[/dim]")
|
|
736
|
+
return
|
|
737
|
+
|
|
738
|
+
for v in sorted(versions):
|
|
739
|
+
console.print(f" Removing {v}...")
|
|
740
|
+
shutil.rmtree(cache_dir)
|
|
741
|
+
console.print(f"[green]Cleared {len(versions)} cached version(s).[/green]")
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _clear_deployments_cache(console, stale_only: bool = False) -> None:
|
|
745
|
+
"""Clear the CLI-local deployment metrics cache (issue #317).
|
|
746
|
+
|
|
747
|
+
With ``stale_only=True``, delete only records whose ``status`` is
|
|
748
|
+
``in_progress`` — the leftovers from plan-cancel or Ctrl-C that never
|
|
749
|
+
reached ``success`` / ``failed``. Malformed JSON files are treated as
|
|
750
|
+
stale under ``stale_only`` (they are un-promotable by definition).
|
|
751
|
+
"""
|
|
752
|
+
import json
|
|
753
|
+
|
|
754
|
+
from lablink_cli import deployment_metrics
|
|
755
|
+
|
|
756
|
+
cache_dir = deployment_metrics.DEPLOYMENTS_DIR
|
|
757
|
+
|
|
758
|
+
if not cache_dir.exists():
|
|
759
|
+
console.print("[dim]No deployments cache to clear.[/dim]")
|
|
760
|
+
return
|
|
761
|
+
|
|
762
|
+
all_records = list(cache_dir.glob("*.json"))
|
|
763
|
+
if not all_records:
|
|
764
|
+
console.print("[dim]Deployments cache is empty.[/dim]")
|
|
765
|
+
return
|
|
766
|
+
|
|
767
|
+
if stale_only:
|
|
768
|
+
records = []
|
|
769
|
+
for p in all_records:
|
|
770
|
+
try:
|
|
771
|
+
data = json.loads(p.read_text())
|
|
772
|
+
except json.JSONDecodeError:
|
|
773
|
+
records.append(p)
|
|
774
|
+
continue
|
|
775
|
+
if data.get("status") == "in_progress":
|
|
776
|
+
records.append(p)
|
|
777
|
+
if not records:
|
|
778
|
+
console.print(
|
|
779
|
+
"[dim]No stale (in_progress) deployment records to clear.[/dim]"
|
|
780
|
+
)
|
|
781
|
+
return
|
|
782
|
+
else:
|
|
783
|
+
records = all_records
|
|
784
|
+
|
|
785
|
+
for p in records:
|
|
786
|
+
p.unlink()
|
|
787
|
+
label = "stale deployment record" if stale_only else "deployment record"
|
|
788
|
+
suffix = "s" if len(records) != 1 else ""
|
|
789
|
+
console.print(f"[green]Cleared {len(records)} {label}{suffix}.[/green]")
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
@app.command("cache-clear", rich_help_panel="Maintenance")
|
|
793
|
+
def cache_clear(
|
|
794
|
+
deployments: bool = typer.Option(
|
|
795
|
+
False,
|
|
796
|
+
"--deployments",
|
|
797
|
+
help=(
|
|
798
|
+
"Clear the local deployment metrics cache "
|
|
799
|
+
"(~/.lablink/deployments/) instead of the template "
|
|
800
|
+
"cache."
|
|
801
|
+
),
|
|
802
|
+
),
|
|
803
|
+
all_caches: bool = typer.Option(
|
|
804
|
+
False,
|
|
805
|
+
"--all",
|
|
806
|
+
help=("Clear all LabLink caches (OpenTofu templates AND deployment metrics)."),
|
|
807
|
+
),
|
|
808
|
+
stale: bool = typer.Option(
|
|
809
|
+
False,
|
|
810
|
+
"--stale",
|
|
811
|
+
help=(
|
|
812
|
+
"With --deployments, delete only in-progress records "
|
|
813
|
+
"(leftovers from plan-cancel or Ctrl-C) instead of the whole "
|
|
814
|
+
"deployments cache. Ignored without --deployments."
|
|
815
|
+
),
|
|
816
|
+
),
|
|
817
|
+
) -> None:
|
|
818
|
+
"""Clear LabLink caches.
|
|
819
|
+
|
|
820
|
+
By default clears only the template cache (backwards-compatible
|
|
821
|
+
with the original command). Use --deployments to clear the CLI-local
|
|
822
|
+
deployment metrics cache, or --all to clear both. Combine --deployments
|
|
823
|
+
with --stale to prune only in-progress records.
|
|
824
|
+
"""
|
|
825
|
+
from rich.console import Console
|
|
826
|
+
|
|
827
|
+
console = Console()
|
|
828
|
+
|
|
829
|
+
if stale and not deployments:
|
|
830
|
+
console.print("[yellow]--stale has no effect without --deployments.[/yellow]")
|
|
831
|
+
|
|
832
|
+
if all_caches:
|
|
833
|
+
_clear_template_cache(console)
|
|
834
|
+
_clear_deployments_cache(console)
|
|
835
|
+
elif deployments:
|
|
836
|
+
_clear_deployments_cache(console, stale_only=stale)
|
|
837
|
+
else:
|
|
838
|
+
_clear_template_cache(console)
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
@app.command("export-metrics", rich_help_panel="Operations")
|
|
842
|
+
def export_metrics(
|
|
843
|
+
output: str = typer.Option(
|
|
844
|
+
None,
|
|
845
|
+
"--output",
|
|
846
|
+
"-o",
|
|
847
|
+
help=(
|
|
848
|
+
"Output file path. With a single source flag, it's the literal "
|
|
849
|
+
"output path. With both flags (or none), it's a base name: "
|
|
850
|
+
"_client / _allocator suffixes are added before the extension. "
|
|
851
|
+
"Default: metrics_client.<fmt> and/or metrics_allocator.<fmt>."
|
|
852
|
+
),
|
|
853
|
+
),
|
|
854
|
+
format: str = typer.Option(
|
|
855
|
+
"csv",
|
|
856
|
+
"--format",
|
|
857
|
+
"-f",
|
|
858
|
+
help="Output format: csv or json",
|
|
859
|
+
),
|
|
860
|
+
include_logs: bool = typer.Option(
|
|
861
|
+
False,
|
|
862
|
+
"--include-logs",
|
|
863
|
+
help="Include cloud_init_logs and docker_logs columns",
|
|
864
|
+
),
|
|
865
|
+
client: bool = typer.Option(
|
|
866
|
+
False,
|
|
867
|
+
"--client",
|
|
868
|
+
help=(
|
|
869
|
+
"Export per-VM client metrics from the allocator "
|
|
870
|
+
"(default if no flag is given exports both)."
|
|
871
|
+
),
|
|
872
|
+
),
|
|
873
|
+
allocator: bool = typer.Option(
|
|
874
|
+
False,
|
|
875
|
+
"--allocator",
|
|
876
|
+
help=(
|
|
877
|
+
"Export per-deploy allocator metrics from the local cache, "
|
|
878
|
+
"scoped to this config's deployment_name. Works without a "
|
|
879
|
+
"running allocator (e.g. after `lablink destroy`); passed "
|
|
880
|
+
"alone it loads no config and exports every deployment."
|
|
881
|
+
),
|
|
882
|
+
),
|
|
883
|
+
config: str = typer.Option(
|
|
884
|
+
None,
|
|
885
|
+
"--config",
|
|
886
|
+
"-c",
|
|
887
|
+
help="Path to config.yaml (default: ~/.lablink/config.yaml)",
|
|
888
|
+
),
|
|
889
|
+
) -> None:
|
|
890
|
+
"""Export deployment metrics to CSV or JSON.
|
|
891
|
+
|
|
892
|
+
Pass --client for per-VM metrics from the allocator. Pass --allocator
|
|
893
|
+
for per-deploy metrics from the local cache, scoped to this config's
|
|
894
|
+
deployment_name. With no flag, exports both. Passing only --allocator
|
|
895
|
+
skips the network entirely.
|
|
896
|
+
"""
|
|
897
|
+
from lablink_cli.commands.export_metrics import run_export_metrics
|
|
898
|
+
|
|
899
|
+
# --client cannot work without the config (allocator URL + admin creds),
|
|
900
|
+
# so a missing one is fatal there. --allocator does not need it, but load
|
|
901
|
+
# it when it's there anyway: the config's deployment_name is what scopes
|
|
902
|
+
# the cache export to this deployment instead of dumping every deployment
|
|
903
|
+
# the operator has ever run. Only a machine with no config at all falls
|
|
904
|
+
# through to None (unscoped), which keeps the command usable after a wipe.
|
|
905
|
+
config_path = Path(config) if config else DEFAULT_CONFIG
|
|
906
|
+
cfg = (
|
|
907
|
+
_load_cfg(config)
|
|
908
|
+
if client or not allocator or config_path.exists()
|
|
909
|
+
else None
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
run_export_metrics(
|
|
913
|
+
cfg,
|
|
914
|
+
output=output,
|
|
915
|
+
include_logs=include_logs,
|
|
916
|
+
format=format,
|
|
917
|
+
client=client,
|
|
918
|
+
allocator=allocator,
|
|
919
|
+
)
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
@app.command("stats", rich_help_panel="Operations")
|
|
923
|
+
def stats(
|
|
924
|
+
config: str = typer.Option(
|
|
925
|
+
None,
|
|
926
|
+
"--config",
|
|
927
|
+
"-c",
|
|
928
|
+
help="Path to config.yaml (default: ~/.lablink/config.yaml)",
|
|
929
|
+
),
|
|
930
|
+
) -> None:
|
|
931
|
+
"""Show a cohort session-metrics summary in the terminal."""
|
|
932
|
+
from lablink_cli.commands.stats import run_stats
|
|
933
|
+
|
|
934
|
+
run_stats(_load_cfg(config))
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def main() -> None:
|
|
938
|
+
app()
|