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.
@@ -0,0 +1,863 @@
1
+ """Deploy and destroy LabLink infrastructure with OpenTofu."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ import time
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ from rich.console import Console
12
+ from rich.markup import escape
13
+ from rich.panel import Panel
14
+ from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
15
+
16
+ from lablink_allocator_service.conf.structured_config import Config
17
+
18
+ from lablink_cli.commands.setup import check_credentials, _get_session
19
+ from lablink_cli.commands.status import check_health_endpoint
20
+ from lablink_cli.commands.utils import (
21
+ format_duration,
22
+ get_allocator_url,
23
+ get_deploy_dir,
24
+ resolve_admin_credentials,
25
+ summarize_tofu,
26
+ )
27
+ from lablink_cli.api import (
28
+ AllocatorAPI,
29
+ AllocatorAuthError,
30
+ AllocatorError,
31
+ AllocatorNotFoundError,
32
+ AllocatorUnavailableError,
33
+ )
34
+ from lablink_cli.commands.export_metrics import run_export_metrics
35
+ from lablink_cli.config.schema import config_to_dict, save_config
36
+ from lablink_cli.deployment_metrics import (
37
+ DeploymentMetrics,
38
+ cache_path_for,
39
+ phase_timer,
40
+ write_metrics,
41
+ )
42
+ from lablink_cli.tofu_source import get_tofu_files
43
+
44
+ console = Console()
45
+
46
+
47
+ def _prepare_working_dir(
48
+ cfg: Config,
49
+ *,
50
+ template_version: str | None = None,
51
+ terraform_bundle: str | None = None,
52
+ ) -> Path:
53
+ """Set up the OpenTofu working directory.
54
+
55
+ Downloads (or loads from cache/bundle) the template's .tf files,
56
+ copies them into the deploy directory, and writes config/config.yaml.
57
+ """
58
+ from lablink_cli import TEMPLATE_VERSION
59
+
60
+ version = template_version or TEMPLATE_VERSION
61
+ skip_checksum = template_version is not None
62
+
63
+ if template_version:
64
+ console.print(
65
+ f" [yellow]Warning: using override version "
66
+ f"{template_version}, skipping checksum "
67
+ f"verification[/yellow]"
68
+ )
69
+
70
+ tf_source = get_tofu_files(
71
+ version,
72
+ bundle_path=terraform_bundle,
73
+ skip_checksum=skip_checksum,
74
+ )
75
+
76
+ deploy_dir = get_deploy_dir(cfg)
77
+ deploy_dir.mkdir(parents=True, exist_ok=True)
78
+
79
+ # Copy .tf and .hcl files
80
+ for src_file in tf_source.glob("*.tf"):
81
+ shutil.copy2(src_file, deploy_dir / src_file.name)
82
+ for src_file in tf_source.glob("*.hcl"):
83
+ shutil.copy2(src_file, deploy_dir / src_file.name)
84
+
85
+ # Copy user_data.sh
86
+ user_data_src = tf_source / "user_data.sh"
87
+ if user_data_src.exists():
88
+ shutil.copy2(user_data_src, deploy_dir / "user_data.sh")
89
+
90
+ # Copy .terraform.lock.hcl if present (pins provider versions). The template
91
+ # ships no lock file today, and a OpenTofu-generated one must never be added:
92
+ # its entries are keyed registry.terraform.io/... while OpenTofu resolves
93
+ # registry.opentofu.org/... and would reject the lock as unsatisfiable.
94
+ lock_file = tf_source / ".terraform.lock.hcl"
95
+ if lock_file.exists():
96
+ shutil.copy2(
97
+ lock_file, deploy_dir / ".terraform.lock.hcl"
98
+ )
99
+
100
+ # Write config/config.yaml from the Config object
101
+ config_dir = deploy_dir / "config"
102
+ config_dir.mkdir(exist_ok=True)
103
+ save_config(cfg, config_dir / "config.yaml")
104
+
105
+ # Copy custom startup script if configured
106
+ if cfg.startup_script.enabled and cfg.startup_script.path:
107
+ user_script = (
108
+ Path.home() / ".lablink" / "custom-startup.sh"
109
+ )
110
+ if user_script.exists():
111
+ src_startup = user_script
112
+ else:
113
+ src_startup = tf_source / cfg.startup_script.path
114
+
115
+ if src_startup.exists():
116
+ dest_startup = (
117
+ deploy_dir / "config" / "custom-startup.sh"
118
+ )
119
+ dest_startup.parent.mkdir(
120
+ parents=True, exist_ok=True
121
+ )
122
+ shutil.copy2(src_startup, dest_startup)
123
+
124
+ return deploy_dir
125
+
126
+
127
+ def _run_tofu(
128
+ args: list[str],
129
+ cwd: Path,
130
+ check: bool = True,
131
+ *,
132
+ verbose: bool = True,
133
+ ) -> int:
134
+ """Run an OpenTofu command.
135
+
136
+ verbose=True (default): live-stream output line-by-line. Existing
137
+ callers (deploy, init) rely on this for progress visibility.
138
+ verbose=False: hide output behind a spinner; print only a one-line
139
+ summary on success. On failure, dump the buffered output so the
140
+ operator can diagnose.
141
+ """
142
+ cmd = ["tofu"] + args
143
+
144
+ if verbose:
145
+ console.print(
146
+ f" [dim]$ {' '.join(cmd)}[/dim]"
147
+ )
148
+
149
+ proc = subprocess.Popen(
150
+ cmd,
151
+ cwd=cwd,
152
+ stdout=subprocess.PIPE,
153
+ stderr=subprocess.STDOUT,
154
+ text=True,
155
+ )
156
+
157
+ if proc.stdout:
158
+ for line in proc.stdout:
159
+ console.print(
160
+ f" {line}", end="", highlight=False, markup=False
161
+ )
162
+
163
+ proc.wait()
164
+ else:
165
+ # Quiet mode: spinner + buffered output, summary on success.
166
+ action = args[0]
167
+ started = time.monotonic()
168
+ captured: list[str] = []
169
+ with console.status(
170
+ f" [bold]tofu {action}[/bold]...", spinner="dots"
171
+ ):
172
+ proc = subprocess.Popen(
173
+ cmd,
174
+ cwd=cwd,
175
+ stdout=subprocess.PIPE,
176
+ stderr=subprocess.STDOUT,
177
+ text=True,
178
+ )
179
+ if proc.stdout:
180
+ for line in proc.stdout:
181
+ captured.append(line)
182
+ proc.wait()
183
+ elapsed = time.monotonic() - started
184
+ output = "".join(captured)
185
+
186
+ if proc.returncode != 0:
187
+ # On failure, dump everything so the operator can diagnose.
188
+ console.print(output, markup=False)
189
+ else:
190
+ console.print(
191
+ f" [green]✓ tofu {action}[/green] "
192
+ f"[dim]({format_duration(elapsed)})[/dim]"
193
+ )
194
+ summary = summarize_tofu(output)
195
+ if summary:
196
+ console.print(f" {summary}")
197
+
198
+ if check and proc.returncode != 0:
199
+ console.print(
200
+ f"\n [red]tofu {args[0]} failed "
201
+ f"(exit code {proc.returncode})[/red]"
202
+ )
203
+ raise SystemExit(proc.returncode)
204
+
205
+ return proc.returncode
206
+
207
+
208
+ def _tofu_init(
209
+ deploy_dir: Path,
210
+ cfg: Config,
211
+ ) -> None:
212
+ """Run OpenTofu init with S3 remote backend."""
213
+ console.print("[bold]Step 1/3:[/bold] OpenTofu init")
214
+
215
+ # Use -reconfigure if .terraform already exists (avoids
216
+ # "backend configuration changed" errors).
217
+ reconfigure = (deploy_dir / ".terraform").exists()
218
+
219
+ # Resolve bucket name from AWS account
220
+ import boto3
221
+
222
+ account_id = (
223
+ boto3.client(
224
+ "sts", region_name=cfg.app.region
225
+ )
226
+ .get_caller_identity()["Account"]
227
+ )
228
+ bucket_name = f"lablink-tf-state-{account_id}"
229
+
230
+ # State key scoped by deployment_name and environment
231
+ state_key = (
232
+ f"{cfg.deployment_name}/{cfg.environment}"
233
+ f"/terraform.tfstate"
234
+ )
235
+
236
+ args = [
237
+ "init",
238
+ f"-backend-config=key={state_key}",
239
+ f"-backend-config=bucket={bucket_name}",
240
+ f"-backend-config=region={cfg.app.region}",
241
+ "-backend-config=dynamodb_table=lock-table",
242
+ "-backend-config=encrypt=true",
243
+ ]
244
+ if reconfigure:
245
+ args.append("-reconfigure")
246
+ _run_tofu(args, cwd=deploy_dir)
247
+
248
+ console.print()
249
+
250
+
251
+ def _prompt_passwords() -> dict[str, str]:
252
+ """Prompt for the admin credentials at deploy time."""
253
+ import getpass
254
+
255
+ console.print(
256
+ "[bold]Credentials[/bold] "
257
+ "(not stored in config, passed to OpenTofu only)"
258
+ )
259
+
260
+ admin_user = input(" Admin username [admin]: ").strip()
261
+ if not admin_user:
262
+ admin_user = "admin"
263
+
264
+ admin_pw = getpass.getpass(" Admin password: ")
265
+ if not admin_pw:
266
+ console.print(" [red]Admin password is required[/red]")
267
+ raise SystemExit(1)
268
+
269
+ console.print()
270
+ return {
271
+ "admin_user": admin_user,
272
+ "admin_password": admin_pw,
273
+ }
274
+
275
+
276
+ def _build_health_poll_target(cfg: Config, ec2_ip: str) -> dict:
277
+ """Pick the post-deploy poll URL + timeout. Caddy is Host-bound under
278
+ letsencrypt/cloudflare, so those must poll the domain, not the IP."""
279
+ provider = cfg.ssl.provider
280
+ domain = cfg.dns.domain if cfg.dns.enabled else ""
281
+
282
+ if provider == "none":
283
+ return {"url": f"http://{ec2_ip}", "max_wait": 300}
284
+ if provider == "acm":
285
+ # ALB owns 80/443; Flask is bound 0.0.0.0:5000 (SG allows 5000).
286
+ return {"url": f"http://{ec2_ip}:5000", "max_wait": 300}
287
+ if not domain:
288
+ console.print(
289
+ f" [yellow]Warning:[/yellow] ssl.provider='{provider}' "
290
+ f"without dns.domain — falling back to IP poll. "
291
+ f"The deploy will likely fail to serve at the expected URL."
292
+ )
293
+ return {"url": f"http://{ec2_ip}", "max_wait": 300}
294
+ scheme = "https" if provider == "letsencrypt" else "http"
295
+ return {"url": f"{scheme}://{domain}", "max_wait": 600}
296
+
297
+
298
+ def _poll_allocator_health(
299
+ poll_url: str,
300
+ *,
301
+ max_wait: int = 120,
302
+ ) -> dict:
303
+ """Poll the allocator health endpoint with adaptive intervals.
304
+
305
+ Intervals: 3s for first 30s, 5s for 30-90s, 10s after 90s.
306
+
307
+ Returns dict with:
308
+ - healthy: bool
309
+ - elapsed: float (seconds from start to healthy or timeout)
310
+ - timed_out: bool
311
+ - uptime_seconds: float | None (from allocator's self-reported uptime)
312
+ """
313
+ start = time.monotonic()
314
+ elapsed = 0.0
315
+
316
+ with console.status(
317
+ f"connecting... (0s / {max_wait}s)", spinner="dots"
318
+ ) as status:
319
+ while elapsed < max_wait:
320
+ result = check_health_endpoint(poll_url)
321
+ elapsed = time.monotonic() - start
322
+
323
+ if result["healthy"]:
324
+ return {
325
+ "healthy": True,
326
+ "elapsed": elapsed,
327
+ "timed_out": False,
328
+ "uptime_seconds": result.get("uptime_seconds"),
329
+ }
330
+
331
+ # Adaptive interval based on elapsed time
332
+ if elapsed < 30:
333
+ interval = 3
334
+ elif elapsed < 90:
335
+ interval = 5
336
+ else:
337
+ interval = 10
338
+
339
+ status.update(
340
+ f"{result['status']}... ({elapsed:.0f}s / {max_wait}s)"
341
+ )
342
+ time.sleep(interval)
343
+ elapsed = time.monotonic() - start
344
+
345
+ return {
346
+ "healthy": False,
347
+ "elapsed": elapsed,
348
+ "timed_out": True,
349
+ "uptime_seconds": None,
350
+ }
351
+
352
+
353
+ def run_deploy(
354
+ cfg: Config,
355
+ *,
356
+ template_version: str | None = None,
357
+ terraform_bundle: str | None = None,
358
+ yes: bool = False,
359
+ ) -> None:
360
+ """Deploy LabLink infrastructure. ``yes=True`` skips confirmation prompts."""
361
+ from lablink_cli import TEMPLATE_VERSION
362
+
363
+ console.print()
364
+ console.print(
365
+ Panel(
366
+ "[bold]LabLink Deploy[/bold]\n"
367
+ f"Deployment: {cfg.deployment_name} | "
368
+ f"Environment: {cfg.environment}\n"
369
+ f"Region: {cfg.app.region} | State: S3 (remote)",
370
+ border_style="cyan",
371
+ )
372
+ )
373
+ console.print()
374
+
375
+ # Validate AWS credentials
376
+ check_credentials(_get_session(cfg.app.region))
377
+
378
+ # Prepare working directory
379
+ deploy_dir = _prepare_working_dir(
380
+ cfg,
381
+ template_version=template_version,
382
+ terraform_bundle=terraform_bundle,
383
+ )
384
+
385
+ # Prompt for credentials
386
+ passwords = _prompt_passwords()
387
+
388
+ # Write credentials into deploy dir config for OpenTofu
389
+ # (deploy dir only, never persisted to ~/.lablink/)
390
+ import yaml
391
+
392
+ config_path = deploy_dir / "config" / "config.yaml"
393
+ with open(config_path) as f:
394
+ cfg_dict = yaml.safe_load(f)
395
+
396
+ cfg_dict["app"]["admin_user"] = passwords["admin_user"]
397
+ cfg_dict["app"]["admin_password"] = passwords[
398
+ "admin_password"
399
+ ]
400
+
401
+ with open(config_path, "w") as f:
402
+ yaml.dump(
403
+ cfg_dict,
404
+ f,
405
+ default_flow_style=False,
406
+ sort_keys=False,
407
+ )
408
+
409
+ # Initialize deployment metrics — written incrementally so failed
410
+ # or interrupted deploys still leave a useful partial record on disk.
411
+ has_ssl = cfg.ssl.provider != "none"
412
+ deploy_start_dt = datetime.now(timezone.utc)
413
+ metrics = DeploymentMetrics(
414
+ deployment_name=cfg.deployment_name,
415
+ provider=getattr(cfg, "provider", "aws"),
416
+ region=cfg.app.region,
417
+ template_version=template_version or TEMPLATE_VERSION,
418
+ ssl_enabled=has_ssl,
419
+ allocator_deploy_start_time=deploy_start_dt.isoformat(),
420
+ )
421
+ metrics_path = cache_path_for(cfg.deployment_name, deploy_start_dt)
422
+ write_metrics(metrics_path, metrics)
423
+
424
+ try:
425
+ # OpenTofu init
426
+ with phase_timer(
427
+ metrics, "allocator_tofu_init_duration_seconds", metrics_path
428
+ ):
429
+ _tofu_init(deploy_dir, cfg)
430
+
431
+ # OpenTofu plan — pass deployment_name and environment
432
+ console.print("[bold]Step 2/3:[/bold] OpenTofu plan")
433
+ with phase_timer(
434
+ metrics, "allocator_tofu_plan_duration_seconds", metrics_path
435
+ ):
436
+ _run_tofu(
437
+ [
438
+ "plan",
439
+ f"-var=deployment_name={cfg.deployment_name}",
440
+ f"-var=environment={cfg.environment}",
441
+ f"-var=region={cfg.app.region}",
442
+ "-out=tfplan",
443
+ ],
444
+ cwd=deploy_dir,
445
+ )
446
+ console.print()
447
+
448
+ # Confirm before apply (user think-time intentionally excluded from phases).
449
+ # ``yes=True`` skips this gate for scripted invocations.
450
+ if not yes:
451
+ console.print(
452
+ "[bold yellow]Review the plan above.[/bold yellow] "
453
+ "Type 'yes' to apply: ",
454
+ end="",
455
+ )
456
+ answer = input()
457
+ if answer.strip().lower() != "yes":
458
+ console.print(
459
+ "[dim]Cancelled. No resources were created.[/dim]"
460
+ )
461
+ raise SystemExit(0)
462
+ console.print()
463
+
464
+ # OpenTofu apply
465
+ console.print("[bold]Step 3/3:[/bold] OpenTofu apply")
466
+ with phase_timer(
467
+ metrics, "allocator_tofu_apply_duration_seconds", metrics_path
468
+ ):
469
+ _run_tofu(
470
+ ["apply", "-auto-approve", "tfplan"], cwd=deploy_dir
471
+ )
472
+ console.print()
473
+
474
+ # Show outputs
475
+ console.print("[bold]Deployment complete![/bold]")
476
+ _run_tofu(
477
+ ["output"], cwd=deploy_dir, check=False
478
+ )
479
+ console.print()
480
+
481
+ # --- Deployment timing ---
482
+ from lablink_cli.commands.status import run_status
483
+ from lablink_cli.commands.utils import TofuError, get_tofu_outputs
484
+
485
+ try:
486
+ outputs = get_tofu_outputs(deploy_dir)
487
+ except TofuError as e:
488
+ # apply succeeded, so this is a read fault, not a failed deploy.
489
+ console.print(
490
+ f" [yellow]Could not read outputs:[/yellow] {escape(str(e))}"
491
+ )
492
+ outputs = {}
493
+ ec2_ip = outputs.get("ec2_public_ip", "")
494
+
495
+ # Phase 1: Poll the allocator for readiness. URL and timeout depend
496
+ # on the SSL provider — see _build_health_poll_target for the
497
+ # rationale per provider.
498
+ if ec2_ip:
499
+ target = _build_health_poll_target(cfg, ec2_ip)
500
+ direct_url = target["url"]
501
+ max_wait = target["max_wait"]
502
+ console.print(
503
+ f"[bold]Waiting for allocator to become healthy"
504
+ f" at {direct_url} (up to {max_wait // 60} min)...[/bold]"
505
+ )
506
+ with phase_timer(
507
+ metrics,
508
+ "allocator_health_check_duration_seconds",
509
+ metrics_path,
510
+ ):
511
+ poll_result = _poll_allocator_health(
512
+ direct_url, max_wait=max_wait
513
+ )
514
+
515
+ if poll_result["healthy"]:
516
+ console.print(
517
+ f"[green]Allocator healthy after"
518
+ f" {poll_result['elapsed']:.0f}s[/green]"
519
+ )
520
+ else:
521
+ console.print(
522
+ "[yellow]Timed out waiting for healthy status."
523
+ " Running status check anyway...[/yellow]"
524
+ )
525
+ else:
526
+ console.print(
527
+ "[yellow]No EC2 IP found in OpenTofu outputs."
528
+ " Skipping health check.[/yellow]"
529
+ )
530
+ poll_result = {"healthy": False, "elapsed": 0, "timed_out": True}
531
+
532
+ # Mark success and record total time (sum of timed phases — excludes
533
+ # user prompt time, which is the reproducible "machine work" measure).
534
+ deploy_end_dt = datetime.now(timezone.utc)
535
+ metrics.allocator_deploy_end_time = deploy_end_dt.isoformat()
536
+ metrics.allocator_total_deployment_duration_seconds = round(
537
+ sum(
538
+ v
539
+ for v in (
540
+ metrics.allocator_tofu_init_duration_seconds,
541
+ metrics.allocator_tofu_plan_duration_seconds,
542
+ metrics.allocator_tofu_apply_duration_seconds,
543
+ metrics.allocator_health_check_duration_seconds,
544
+ )
545
+ if v is not None
546
+ ),
547
+ 3,
548
+ )
549
+ metrics.status = "success"
550
+ write_metrics(metrics_path, metrics)
551
+
552
+ except Exception as e:
553
+ # Persist the failure so we have a record of what timed out / blew up.
554
+ # SystemExit (user cancellation, tofu exit code) is a BaseException
555
+ # subclass and intentionally NOT caught here — cancellation leaves the
556
+ # file in 'in_progress' state, which is correct semantics.
557
+ metrics.status = "failed"
558
+ metrics.error = str(e)
559
+ write_metrics(metrics_path, metrics)
560
+ raise
561
+
562
+ # Phase 2: If DNS/SSL configured, verify endpoint reachability
563
+ if cfg.dns.enabled and cfg.dns.domain and poll_result["healthy"]:
564
+ from lablink_cli.commands.status import check_http
565
+
566
+ scheme = "https" if has_ssl else "http"
567
+ endpoint_url = f"{scheme}://{cfg.dns.domain}"
568
+ console.print(
569
+ f"[bold]Checking endpoint reachability at"
570
+ f" {endpoint_url}...[/bold]"
571
+ )
572
+
573
+ dns_start = time.monotonic()
574
+ dns_max = 180 if has_ssl else 60
575
+ dns_elapsed = 0.0
576
+
577
+ while dns_elapsed < dns_max:
578
+ http_result = check_http(endpoint_url)
579
+ dns_elapsed = time.monotonic() - dns_start
580
+ if http_result["status"] == "pass":
581
+ console.print(
582
+ f"[green]Endpoint reachable after"
583
+ f" {dns_elapsed:.0f}s[/green]"
584
+ )
585
+ break
586
+ time.sleep(10)
587
+ dns_elapsed = time.monotonic() - dns_start
588
+ else:
589
+ console.print(
590
+ f"[yellow]Endpoint {endpoint_url} not yet"
591
+ f" reachable after {dns_elapsed:.0f}s."
592
+ f" DNS/SSL may still be propagating.[/yellow]"
593
+ )
594
+
595
+ console.print()
596
+ run_status(cfg)
597
+ console.print()
598
+
599
+ console.print(
600
+ f"[dim]Working directory:[/dim] {deploy_dir}"
601
+ )
602
+ console.print(
603
+ "[dim]To tear down:[/dim] [bold]lablink destroy[/bold]"
604
+ )
605
+ console.print(
606
+ "[dim]To export deployment metrics:[/dim] "
607
+ "[bold]lablink export-metrics --allocator[/bold]"
608
+ )
609
+
610
+
611
+ def _destroy_client_vms(
612
+ cfg: Config,
613
+ admin_user: str,
614
+ admin_pw: str,
615
+ *,
616
+ verbose: bool = False,
617
+ ) -> None:
618
+ """Destroy client VMs via the allocator API."""
619
+ allocator_url = get_allocator_url(cfg)
620
+ if not allocator_url:
621
+ console.print(
622
+ "[yellow]Could not determine allocator "
623
+ "URL — skipping client VM destroy.[/yellow]\n"
624
+ "Client VMs will be terminated when the "
625
+ "allocator is destroyed."
626
+ )
627
+ return
628
+
629
+ console.print(
630
+ "[bold]Destroying client VMs via "
631
+ "allocator...[/bold]"
632
+ )
633
+ console.print(
634
+ f" [dim]POST {allocator_url}/destroy[/dim]"
635
+ )
636
+
637
+ api = AllocatorAPI(
638
+ allocator_url, admin_user, admin_pw, cfg.ssl.provider
639
+ )
640
+ started = time.monotonic()
641
+ try:
642
+ with Progress(
643
+ SpinnerColumn(),
644
+ TextColumn("[progress.description]{task.description}"),
645
+ BarColumn(),
646
+ console=console,
647
+ transient=True,
648
+ ) as progress:
649
+ task = progress.add_task(
650
+ " [bold]waiting for allocator...[/bold]", total=None,
651
+ )
652
+
653
+ def _on_progress(done, total):
654
+ if done is not None and total is not None:
655
+ progress.update(
656
+ task,
657
+ completed=done,
658
+ total=total,
659
+ description=(
660
+ f" [bold]waiting for allocator...[/bold] "
661
+ f"({done}/{total} resources)"
662
+ ),
663
+ )
664
+
665
+ result = api.destroy_vms(on_progress=_on_progress)
666
+ elapsed = time.monotonic() - started
667
+ console.print(
668
+ f" [green]✓ client VMs destroyed[/green] "
669
+ f"[dim]({format_duration(elapsed)})[/dim]"
670
+ )
671
+ output = (result or {}).get("output", "")
672
+ summary = summarize_tofu(output)
673
+ if summary:
674
+ console.print(f" {summary}")
675
+ if verbose and output:
676
+ console.print()
677
+ console.print("[bold]Allocator's OpenTofu output:[/bold]")
678
+ console.print(output, markup=False)
679
+ elif output:
680
+ console.print(
681
+ " [dim]Pass --verbose to see full OpenTofu output.[/dim]"
682
+ )
683
+ except AllocatorAuthError:
684
+ console.print(
685
+ " [red]Authentication failed.[/red] "
686
+ "Check your admin credentials."
687
+ )
688
+ raise SystemExit(1)
689
+ except AllocatorNotFoundError:
690
+ console.print(
691
+ " [yellow]No client VMs were "
692
+ "launched.[/yellow] Skipping "
693
+ "client destroy."
694
+ )
695
+ console.print(
696
+ " Continuing with allocator "
697
+ "tofu destroy..."
698
+ )
699
+ except AllocatorUnavailableError as e:
700
+ console.print(
701
+ f" [yellow]Could not connect to "
702
+ f"allocator:[/yellow] {e}"
703
+ )
704
+ console.print(
705
+ " Continuing with allocator "
706
+ "tofu destroy..."
707
+ )
708
+ except AllocatorError as e:
709
+ console.print(
710
+ f" [red]Client destroy failed:[/red] {e}"
711
+ )
712
+ raise SystemExit(1)
713
+
714
+ console.print()
715
+
716
+
717
+ def _tofu_destroy(
718
+ deploy_dir: Path,
719
+ cfg: Config,
720
+ admin_user: str,
721
+ admin_pw: str,
722
+ *,
723
+ verbose: bool = False,
724
+ ) -> None:
725
+ """Refresh config, re-init OpenTofu, destroy, and clean up."""
726
+ import yaml
727
+
728
+ config_path = deploy_dir / "config" / "config.yaml"
729
+ cfg_dict = config_to_dict(cfg)
730
+ cfg_dict["app"]["admin_user"] = admin_user
731
+ cfg_dict["app"]["admin_password"] = admin_pw
732
+ cfg_dict["db"]["password"] = "DESTROY_PLACEHOLDER"
733
+
734
+ with open(config_path, "w") as f:
735
+ yaml.dump(
736
+ cfg_dict,
737
+ f,
738
+ default_flow_style=False,
739
+ sort_keys=False,
740
+ )
741
+
742
+ if (deploy_dir / "backend.tf").exists():
743
+ _tofu_init(deploy_dir, cfg)
744
+
745
+ console.print(
746
+ "[bold]Destroying allocator "
747
+ "infrastructure...[/bold]"
748
+ )
749
+ _run_tofu(
750
+ [
751
+ "destroy",
752
+ "-auto-approve",
753
+ f"-var=deployment_name={cfg.deployment_name}",
754
+ f"-var=environment={cfg.environment}",
755
+ f"-var=region={cfg.app.region}",
756
+ ],
757
+ cwd=deploy_dir,
758
+ verbose=verbose,
759
+ )
760
+ console.print()
761
+
762
+ shutil.rmtree(deploy_dir)
763
+ console.print(
764
+ f" [green]cleaned[/green] {deploy_dir}"
765
+ )
766
+ console.print()
767
+ console.print("[bold]Infrastructure destroyed.[/bold]")
768
+
769
+
770
+ def run_destroy(
771
+ cfg: Config, *, yes: bool = False, verbose: bool = False
772
+ ) -> None:
773
+ """Destroy LabLink infrastructure. ``yes=True`` skips confirmation prompts."""
774
+ check_credentials(_get_session(cfg.app.region))
775
+
776
+ deploy_dir = get_deploy_dir(cfg)
777
+
778
+ if not deploy_dir.exists():
779
+ console.print(
780
+ "[red]No deployment found.[/red] "
781
+ f"Expected working directory: {deploy_dir}"
782
+ )
783
+ raise SystemExit(1)
784
+
785
+ has_state = (
786
+ (deploy_dir / "terraform.tfstate").exists()
787
+ or (deploy_dir / ".terraform").exists()
788
+ )
789
+ if not has_state:
790
+ console.print(
791
+ "[red]No OpenTofu state found.[/red] "
792
+ "Nothing to destroy."
793
+ )
794
+ raise SystemExit(1)
795
+
796
+ console.print()
797
+ console.print(
798
+ Panel(
799
+ "[bold red]LabLink Destroy[/bold red]\n"
800
+ "This will tear down ALL LabLink infrastructure\n"
801
+ "(client VMs via allocator, then the allocator "
802
+ "itself via OpenTofu).\n"
803
+ f"Deployment: {cfg.deployment_name} | "
804
+ f"Environment: {cfg.environment}\n"
805
+ f"Region: {cfg.app.region} | State: S3 (remote)",
806
+ border_style="red",
807
+ )
808
+ )
809
+ console.print()
810
+
811
+ admin_user, admin_pw = resolve_admin_credentials(cfg)
812
+
813
+ if not yes:
814
+ console.print(
815
+ "[bold yellow]Are you sure?[/bold yellow] "
816
+ "Type 'yes' to confirm: ",
817
+ end="",
818
+ )
819
+ answer = input()
820
+ if answer.strip().lower() != "yes":
821
+ console.print("[dim]Cancelled.[/dim]")
822
+ raise SystemExit(0)
823
+ console.print()
824
+
825
+ # Offer one last chance to export metrics — once destroy runs, the
826
+ # allocator's per-VM metrics are gone forever. Default = yes.
827
+ # Under ``yes=True`` we take that default without prompting.
828
+ if yes:
829
+ export_answer = ""
830
+ else:
831
+ console.print(
832
+ "[bold]Export metrics before destroying?[/bold] [Y/n]: ",
833
+ end="",
834
+ )
835
+ export_answer = input().strip().lower()
836
+ if export_answer in ("", "y", "yes"):
837
+ # Timestamped filename prevents overwriting prior exports when the
838
+ # same cwd is reused for multiple deployments. The absolute cwd is
839
+ # announced so files don't land "somewhere" invisibly.
840
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ")
841
+ output_base = f"metrics-{cfg.deployment_name}-{timestamp}.csv"
842
+ console.print(
843
+ f"[dim]Writing metrics to {Path.cwd().resolve()}/[/dim]"
844
+ )
845
+ # Catch both Exception and SystemExit — run_export_metrics raises
846
+ # SystemExit(1) on network/HTTP failures (it doubles as a CLI entry
847
+ # point), and we must not let that abort the destroy itself.
848
+ # KeyboardInterrupt is intentionally left uncaught so Ctrl-C aborts.
849
+ try:
850
+ run_export_metrics(
851
+ cfg, output=output_base, client=True, allocator=True
852
+ )
853
+ except (Exception, SystemExit) as e:
854
+ console.print(
855
+ f"[yellow]Export failed: {e}. "
856
+ f"Continuing with destroy...[/yellow]"
857
+ )
858
+ console.print()
859
+
860
+ _destroy_client_vms(cfg, admin_user, admin_pw, verbose=verbose)
861
+ _tofu_destroy(
862
+ deploy_dir, cfg, admin_user, admin_pw, verbose=verbose
863
+ )