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/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()