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,934 @@
1
+ """Health checks and cost estimation for LabLink deployments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import os
8
+ import socket
9
+ import ssl
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from urllib.error import HTTPError, URLError
13
+ from urllib.request import Request, urlopen
14
+
15
+ import boto3
16
+ from botocore.exceptions import ClientError
17
+ from rich.console import Console
18
+ from rich.markup import escape
19
+ from rich.panel import Panel
20
+ from rich.table import Table
21
+
22
+ from lablink_allocator_service.conf.structured_config import Config
23
+
24
+ from lablink_cli.api import USER_AGENT
25
+ from lablink_cli.commands.utils import (
26
+ AwsQueryError,
27
+ aws_credentials_error,
28
+ get_client_vms,
29
+ get_deploy_dir as _get_deploy_dir,
30
+ get_tofu_outputs,
31
+ TofuError,
32
+ print_aws_error,
33
+ resolve_from_saved_config,
34
+ )
35
+ from lablink_cli.docker import Docker, default_docker
36
+
37
+ console = Console()
38
+
39
+ # Fallback daily costs (Feb 2025 on-demand, us-east-1)
40
+ FALLBACK_COSTS: dict[str, dict[str, float]] = {
41
+ "ec2": {
42
+ "t3.large": 0.0832 * 24,
43
+ "t3.xlarge": 0.1664 * 24,
44
+ "g4dn.xlarge": 0.526 * 24,
45
+ "g4dn.2xlarge": 0.752 * 24,
46
+ "g5.xlarge": 1.006 * 24,
47
+ "g5.2xlarge": 1.212 * 24,
48
+ "p3.2xlarge": 3.06 * 24,
49
+ },
50
+ "ebs_per_gb": 0.08,
51
+ "eip": 0.005 * 24,
52
+ "route53_zone": 0.50 / 30,
53
+ "alb": 0.0225 * 24,
54
+ }
55
+
56
+
57
+
58
+ # ------------------------------------------------------------------
59
+ # Health checks
60
+ # ------------------------------------------------------------------
61
+ def check_dns(domain: str, expected_ip: str) -> dict:
62
+ """Check DNS resolution."""
63
+ result = {"check": "DNS Resolution", "status": "skip"}
64
+ if not domain:
65
+ result["detail"] = "No domain configured"
66
+ return result
67
+
68
+ try:
69
+ resolved_ip = socket.gethostbyname(domain)
70
+ if resolved_ip == expected_ip:
71
+ result["status"] = "pass"
72
+ result["detail"] = f"{domain} → {resolved_ip}"
73
+ else:
74
+ result["status"] = "warn"
75
+ result["detail"] = (
76
+ f"{domain} → {resolved_ip} "
77
+ f"(expected {expected_ip})"
78
+ )
79
+ except socket.gaierror:
80
+ result["status"] = "fail"
81
+ result["detail"] = f"{domain} does not resolve"
82
+ return result
83
+
84
+
85
+ def check_http(url: str) -> dict:
86
+ """Check HTTP connectivity to the allocator."""
87
+ result = {"check": "HTTP Health", "status": "fail"}
88
+ try:
89
+ req = Request(url, method="GET")
90
+ req.add_header("User-Agent", USER_AGENT)
91
+ resp = urlopen(req, timeout=10) # noqa: S310
92
+ code = resp.getcode()
93
+ if code and code < 400:
94
+ result["status"] = "pass"
95
+ result["detail"] = f"{url} → HTTP {code}"
96
+ else:
97
+ result["status"] = "warn"
98
+ result["detail"] = f"{url} → HTTP {code}"
99
+ except URLError as e:
100
+ result["detail"] = f"{url} → {e.reason}"
101
+ except Exception as e:
102
+ result["detail"] = f"{url} → {e}"
103
+ return result
104
+
105
+
106
+ def check_health_endpoint(base_url: str) -> dict:
107
+ """Check the allocator /api/health endpoint for structured readiness.
108
+
109
+ Returns a dict with:
110
+ - status: "pass" | "starting" | "unreachable"
111
+ - healthy: bool
112
+ - uptime_seconds: float | None
113
+ - checks: dict | None (from the health endpoint response)
114
+ - detail: str
115
+ """
116
+ url = f"{base_url.rstrip('/')}/api/health"
117
+ result: dict = {
118
+ "status": "unreachable",
119
+ "healthy": False,
120
+ "uptime_seconds": None,
121
+ "checks": None,
122
+ "detail": "",
123
+ }
124
+ try:
125
+ req = Request(url, method="GET")
126
+ req.add_header("User-Agent", USER_AGENT)
127
+ resp = urlopen(req, timeout=10) # noqa: S310
128
+ body = json.loads(resp.read().decode())
129
+ if body.get("status") == "healthy":
130
+ result["status"] = "pass"
131
+ result["healthy"] = True
132
+ else:
133
+ result["status"] = "starting"
134
+ result["uptime_seconds"] = body.get("uptime_seconds")
135
+ result["checks"] = body.get("checks")
136
+ result["detail"] = f"{url} → {body.get('status')}"
137
+ except HTTPError as e:
138
+ try:
139
+ body = json.loads(e.read().decode())
140
+ result["status"] = "starting"
141
+ result["uptime_seconds"] = body.get("uptime_seconds")
142
+ result["checks"] = body.get("checks")
143
+ result["detail"] = f"{url} → {body.get('status')}"
144
+ except Exception:
145
+ result["detail"] = f"{url} → HTTP {e.code}"
146
+ except URLError as e:
147
+ result["detail"] = f"{url} → {e.reason}"
148
+ except Exception as e:
149
+ result["detail"] = f"{url} → {e}"
150
+ return result
151
+
152
+
153
+ def check_ssl_cert(domain: str) -> dict:
154
+ """Check SSL certificate validity."""
155
+ result = {"check": "SSL Certificate", "status": "skip"}
156
+ if not domain:
157
+ result["detail"] = "No domain configured"
158
+ return result
159
+
160
+ try:
161
+ ctx = ssl.create_default_context()
162
+ with ctx.wrap_socket(
163
+ socket.socket(), server_hostname=domain
164
+ ) as sock:
165
+ sock.settimeout(10)
166
+ sock.connect((domain, 443))
167
+ cert = sock.getpeercert()
168
+
169
+ if not cert:
170
+ result["status"] = "fail"
171
+ result["detail"] = "No certificate returned"
172
+ return result
173
+
174
+ # Parse expiry
175
+ not_after = cert.get("notAfter", "")
176
+ if not_after:
177
+ expiry = datetime.strptime(
178
+ not_after, "%b %d %H:%M:%S %Y %Z"
179
+ ).replace(tzinfo=timezone.utc)
180
+ days_left = (
181
+ expiry - datetime.now(timezone.utc)
182
+ ).days
183
+
184
+ issuer_parts = dict(
185
+ x[0] for x in cert.get("issuer", ())
186
+ )
187
+ issuer = issuer_parts.get(
188
+ "organizationName", "Unknown"
189
+ )
190
+
191
+ if days_left > 14:
192
+ result["status"] = "pass"
193
+ elif days_left > 0:
194
+ result["status"] = "warn"
195
+ else:
196
+ result["status"] = "fail"
197
+
198
+ result["detail"] = (
199
+ f"Issuer: {issuer}, "
200
+ f"Expires: {expiry.date()} "
201
+ f"({days_left} days)"
202
+ )
203
+ else:
204
+ result["status"] = "warn"
205
+ result["detail"] = "Could not parse expiry"
206
+
207
+ except ssl.SSLError as e:
208
+ result["status"] = "fail"
209
+ result["detail"] = f"SSL error: {e}"
210
+ except (ConnectionRefusedError, OSError) as e:
211
+ result["status"] = "fail"
212
+ result["detail"] = f"Connection failed: {e}"
213
+ return result
214
+
215
+
216
+ # ------------------------------------------------------------------
217
+ # Cost estimation
218
+ # ------------------------------------------------------------------
219
+ REGION_NAME_MAP = {
220
+ "us-east-1": "US East (N. Virginia)",
221
+ "us-east-2": "US East (Ohio)",
222
+ "us-west-1": "US West (N. California)",
223
+ "us-west-2": "US West (Oregon)",
224
+ "eu-west-1": "EU (Ireland)",
225
+ "eu-central-1": "EU (Frankfurt)",
226
+ "ap-northeast-1": "Asia Pacific (Tokyo)",
227
+ "ap-southeast-1": "Asia Pacific (Singapore)",
228
+ }
229
+
230
+
231
+ def _get_ec2_price(
232
+ pricing_client, instance_type: str, location: str
233
+ ) -> float | None:
234
+ """Query AWS Pricing API for EC2 on-demand hourly price."""
235
+ try:
236
+ resp = pricing_client.get_products(
237
+ ServiceCode="AmazonEC2",
238
+ Filters=[
239
+ {
240
+ "Type": "TERM_MATCH",
241
+ "Field": "instanceType",
242
+ "Value": instance_type,
243
+ },
244
+ {
245
+ "Type": "TERM_MATCH",
246
+ "Field": "location",
247
+ "Value": location,
248
+ },
249
+ {
250
+ "Type": "TERM_MATCH",
251
+ "Field": "operatingSystem",
252
+ "Value": "Linux",
253
+ },
254
+ {
255
+ "Type": "TERM_MATCH",
256
+ "Field": "tenancy",
257
+ "Value": "Shared",
258
+ },
259
+ {
260
+ "Type": "TERM_MATCH",
261
+ "Field": "preInstalledSw",
262
+ "Value": "NA",
263
+ },
264
+ {
265
+ "Type": "TERM_MATCH",
266
+ "Field": "capacitystatus",
267
+ "Value": "Used",
268
+ },
269
+ ],
270
+ MaxResults=1,
271
+ )
272
+ if resp["PriceList"]:
273
+ product = json.loads(resp["PriceList"][0])
274
+ terms = product["terms"]["OnDemand"]
275
+ for term in terms.values():
276
+ for dim in term["priceDimensions"].values():
277
+ price = float(
278
+ dim["pricePerUnit"]["USD"]
279
+ )
280
+ if price > 0:
281
+ return price
282
+ except (ClientError, KeyError, ValueError):
283
+ pass
284
+ return None
285
+
286
+
287
+ def estimate_costs(
288
+ cfg: Config, use_pricing_api: bool = True
289
+ ) -> list[dict]:
290
+ """Estimate daily costs for the deployment.
291
+
292
+ Pass ``use_pricing_api=False`` when AWS credentials are known to be
293
+ unusable, to skip Pricing API calls that can only fail and fall
294
+ straight through to FALLBACK_COSTS.
295
+ """
296
+ region = cfg.app.region
297
+ location = REGION_NAME_MAP.get(region, region)
298
+ costs: list[dict] = []
299
+
300
+ # Try AWS Pricing API (only available in us-east-1)
301
+ pricing = None
302
+ use_api = False
303
+ if use_pricing_api:
304
+ try:
305
+ pricing = boto3.client(
306
+ "pricing", region_name="us-east-1"
307
+ )
308
+ use_api = True
309
+ except Exception:
310
+ use_api = False
311
+ pricing = None
312
+
313
+ # Allocator EC2 (always t3.large)
314
+ alloc_type = "t3.large"
315
+ if use_api:
316
+ price = _get_ec2_price(
317
+ pricing, alloc_type, location
318
+ )
319
+ else:
320
+ price = None
321
+ daily = (
322
+ price * 24
323
+ if price
324
+ else FALLBACK_COSTS["ec2"].get(alloc_type, 2.0)
325
+ )
326
+ costs.append(
327
+ {
328
+ "resource": f"Allocator EC2 ({alloc_type})",
329
+ "daily": daily,
330
+ "note": "always on",
331
+ }
332
+ )
333
+
334
+ # EBS (30 GB gp3 assumed for allocator)
335
+ ebs_daily = FALLBACK_COSTS["ebs_per_gb"] * 30 / 30
336
+ costs.append(
337
+ {
338
+ "resource": "Allocator EBS (30 GB gp3)",
339
+ "daily": ebs_daily,
340
+ "note": "always on",
341
+ }
342
+ )
343
+
344
+ # Elastic IP
345
+ costs.append(
346
+ {
347
+ "resource": "Elastic IP",
348
+ "daily": FALLBACK_COSTS["eip"],
349
+ "note": "free while attached",
350
+ }
351
+ )
352
+
353
+ # Route53
354
+ if cfg.dns.enabled:
355
+ costs.append(
356
+ {
357
+ "resource": "Route53 Hosted Zone",
358
+ "daily": FALLBACK_COSTS["route53_zone"],
359
+ "note": "$0.50/month",
360
+ }
361
+ )
362
+
363
+ # ALB (ACM only)
364
+ if cfg.ssl.provider == "acm":
365
+ costs.append(
366
+ {
367
+ "resource": "Application Load Balancer",
368
+ "daily": FALLBACK_COSTS["alb"],
369
+ "note": "~$20/month",
370
+ }
371
+ )
372
+
373
+ # Client VMs (per-VM cost, not always on)
374
+ client_type = cfg.machine.machine_type
375
+ if use_api:
376
+ client_price = _get_ec2_price(
377
+ pricing, client_type, location
378
+ )
379
+ else:
380
+ client_price = None
381
+ client_daily = (
382
+ client_price * 24
383
+ if client_price
384
+ else FALLBACK_COSTS["ec2"].get(client_type)
385
+ )
386
+ if client_daily:
387
+ costs.append(
388
+ {
389
+ "resource": f"Client VM ({client_type})",
390
+ "daily": client_daily,
391
+ "note": "per VM, on-demand",
392
+ }
393
+ )
394
+
395
+ return costs
396
+
397
+
398
+ def _render_tofu_state(
399
+ deploy_dir: Path, aws_unavailable: bool = False
400
+ ) -> dict:
401
+ """Read and display OpenTofu outputs. Returns outputs dict.
402
+
403
+ ``aws_unavailable`` only picks the wording for a failed read: when
404
+ credentials are already known to be dead, the block printed above
405
+ carries the remedy, so repeating tofu's own STS complaint here adds
406
+ noise instead of information.
407
+ """
408
+ if not deploy_dir.exists():
409
+ return {}
410
+
411
+ console.print("[bold]OpenTofu State[/bold]")
412
+ try:
413
+ outputs = get_tofu_outputs(deploy_dir)
414
+ except TofuError as e:
415
+ if aws_unavailable:
416
+ console.print(
417
+ " [yellow]State unreadable — see AWS credentials "
418
+ "above[/yellow]"
419
+ )
420
+ else:
421
+ console.print(
422
+ f" [yellow]State unreadable:[/yellow] {escape(str(e))}"
423
+ )
424
+ console.print()
425
+ return {}
426
+
427
+ if outputs:
428
+ state_table = Table(show_header=False)
429
+ state_table.add_column("Key", style="bold")
430
+ state_table.add_column("Value")
431
+ for k, v in outputs.items():
432
+ if k == "private_key_pem":
433
+ v = "(sensitive)"
434
+ state_table.add_row(k, str(v))
435
+ console.print(state_table)
436
+ else:
437
+ console.print(
438
+ " [yellow]No OpenTofu state found[/yellow]"
439
+ )
440
+ console.print()
441
+ return outputs
442
+
443
+
444
+ def _build_health_url(cfg: Config, outputs: dict) -> str:
445
+ """Build the URL to use for HTTP health checks."""
446
+ domain = cfg.dns.domain if cfg.dns.enabled else ""
447
+ ip = outputs.get("ec2_public_ip", "")
448
+ use_https = cfg.ssl.provider != "none"
449
+
450
+ if domain and use_https:
451
+ return f"https://{domain}"
452
+ if domain:
453
+ return f"http://{domain}"
454
+ if ip:
455
+ return f"http://{ip}"
456
+ return ""
457
+
458
+
459
+ def _print_admin_url(base_url: str) -> None:
460
+ """Print the admin page URL, if we could build one."""
461
+ if base_url:
462
+ console.print(f"[bold]Admin URL:[/bold] {base_url.rstrip('/')}/admin")
463
+
464
+
465
+ def _render_health_checks(cfg: Config, outputs: dict) -> None:
466
+ """Run and display health checks."""
467
+ domain = cfg.dns.domain if cfg.dns.enabled else ""
468
+ use_https = cfg.ssl.provider != "none"
469
+ url = _build_health_url(cfg, outputs)
470
+
471
+ console.print("[bold]Health Checks[/bold]")
472
+ checks = []
473
+
474
+ if domain:
475
+ checks.append(check_dns(domain, outputs.get("ec2_public_ip", "")))
476
+ if url:
477
+ health = check_health_endpoint(url)
478
+ detail = health.get("detail", "")
479
+ if health["healthy"] and health.get("uptime_seconds") is not None:
480
+ detail += f" (uptime: {health['uptime_seconds']}s)"
481
+ checks.append({
482
+ "check": "Allocator Health",
483
+ "status": "pass" if health["healthy"] else (
484
+ "warn" if health["status"] == "starting" else "fail"
485
+ ),
486
+ "detail": detail,
487
+ })
488
+ if domain and use_https:
489
+ checks.append(check_ssl_cert(domain))
490
+
491
+ if checks:
492
+ health_table = Table(show_header=True)
493
+ health_table.add_column("Check")
494
+ health_table.add_column("Status")
495
+ health_table.add_column("Detail")
496
+
497
+ status_styles = {
498
+ "pass": "[green]PASS[/green]",
499
+ "fail": "[red]FAIL[/red]",
500
+ "warn": "[yellow]WARN[/yellow]",
501
+ "skip": "[dim]SKIP[/dim]",
502
+ }
503
+
504
+ for c in checks:
505
+ health_table.add_row(
506
+ c["check"],
507
+ status_styles.get(
508
+ c["status"], c["status"]
509
+ ),
510
+ c.get("detail", ""),
511
+ )
512
+ console.print(health_table)
513
+ else:
514
+ console.print(
515
+ " [dim]No deployment found — "
516
+ "skipping health checks[/dim]"
517
+ )
518
+ console.print()
519
+
520
+
521
+ def _render_client_vms(cfg: Config, aws_unavailable: bool = False) -> None:
522
+ """Query and display client VM status.
523
+
524
+ "No client VMs found" is reserved for a query that succeeded and
525
+ matched nothing. A failed query says so instead.
526
+ """
527
+ console.print("[bold]Client VMs[/bold]")
528
+ if aws_unavailable:
529
+ console.print(
530
+ " [dim]Inventory unavailable — see AWS credentials "
531
+ "above[/dim]"
532
+ )
533
+ console.print()
534
+ return
535
+
536
+ try:
537
+ vms = get_client_vms(cfg)
538
+ except AwsQueryError as e:
539
+ print_aws_error(e, prefix="Could not query EC2")
540
+ console.print()
541
+ return
542
+
543
+ if not vms:
544
+ console.print(
545
+ " [dim]No client VMs found[/dim]"
546
+ )
547
+ console.print()
548
+ return
549
+
550
+ vm_table = Table(show_header=True)
551
+ vm_table.add_column("Name")
552
+ vm_table.add_column("Instance ID")
553
+ vm_table.add_column("Type")
554
+ vm_table.add_column("State")
555
+ vm_table.add_column("Public IP")
556
+
557
+ running_count = 0
558
+ stopped_count = 0
559
+ for vm in vms:
560
+ state = vm["state"]
561
+ if state == "running":
562
+ running_count += 1
563
+ state_str = "[green]running[/green]"
564
+ elif state == "stopped":
565
+ stopped_count += 1
566
+ state_str = "[red]stopped[/red]"
567
+ else:
568
+ state_str = f"[yellow]{state}[/yellow]"
569
+ vm_table.add_row(
570
+ vm["name"],
571
+ vm["instance_id"],
572
+ vm["type"],
573
+ state_str,
574
+ vm["public_ip"] or "—",
575
+ )
576
+
577
+ console.print(vm_table)
578
+
579
+ parts = []
580
+ if running_count:
581
+ parts.append(
582
+ f"[green]{running_count} running[/green]"
583
+ )
584
+ if stopped_count:
585
+ parts.append(
586
+ f"[red]{stopped_count} stopped[/red]"
587
+ )
588
+ console.print(f" {', '.join(parts)}")
589
+
590
+ if running_count:
591
+ vm_type = vms[0]["type"]
592
+ hourly = FALLBACK_COSTS["ec2"].get(vm_type)
593
+ if hourly:
594
+ daily = hourly
595
+ hourly_rate = daily / 24
596
+ total_hourly = hourly_rate * running_count
597
+ console.print(
598
+ f" [dim]Estimated burn rate: "
599
+ f"${total_hourly:.2f}/hr "
600
+ f"(${total_hourly * 24:.2f}/day) "
601
+ f"for {running_count} "
602
+ f"x {vm_type}[/dim]"
603
+ )
604
+ console.print()
605
+
606
+
607
+ def _render_cost_estimate(cfg: Config, live_pricing: bool = True) -> None:
608
+ """Calculate and display cost estimate."""
609
+ console.print("[bold]Cost Estimate (daily)[/bold]")
610
+ costs = estimate_costs(cfg, use_pricing_api=live_pricing)
611
+
612
+ cost_table = Table(show_header=True)
613
+ cost_table.add_column("Resource")
614
+ cost_table.add_column("Daily", justify="right")
615
+ cost_table.add_column("Monthly", justify="right")
616
+ cost_table.add_column("Note", style="dim")
617
+
618
+ base_total = 0.0
619
+ for c in costs:
620
+ daily = c["daily"]
621
+ monthly = daily * 30
622
+ if "per VM" not in c.get("note", ""):
623
+ base_total += daily
624
+ cost_table.add_row(
625
+ c["resource"],
626
+ f"${daily:.2f}",
627
+ f"${monthly:.2f}",
628
+ c.get("note", ""),
629
+ )
630
+
631
+ cost_table.add_row(
632
+ "[bold]Base Total[/bold]",
633
+ f"[bold]${base_total:.2f}[/bold]",
634
+ f"[bold]${base_total * 30:.2f}[/bold]",
635
+ "excl. client VMs",
636
+ )
637
+ console.print(cost_table)
638
+ if live_pricing:
639
+ console.print(
640
+ " [dim]Prices are on-demand estimates. "
641
+ "Actual costs may vary.[/dim]"
642
+ )
643
+ else:
644
+ console.print(
645
+ " [dim]Fallback prices (Feb 2025 on-demand) — live "
646
+ "pricing needs working AWS credentials.[/dim]"
647
+ )
648
+
649
+
650
+ # ------------------------------------------------------------------
651
+ # Main entry point
652
+ # ------------------------------------------------------------------
653
+ def _resolve_manual_admin_credentials(
654
+ cfg: Config, workdir: Path
655
+ ) -> tuple[str, str] | None:
656
+ """Find admin user/password for the manual compose stack.
657
+
658
+ Tries cfg first, then the workdir's rendered config.yaml (which
659
+ deploy_compose.render_compose_dir always writes with the resolved
660
+ credentials) — the same two sources ``resolve_admin_credentials``
661
+ consults for a manual config, minus its interactive prompt: callers
662
+ here print their own guidance instead, so this returns None.
663
+ """
664
+ user = getattr(cfg.app, "admin_user", "") or ""
665
+ pw = getattr(cfg.app, "admin_password", "") or ""
666
+ if user and pw and user != "MISSING" and pw != "MISSING":
667
+ return user, pw
668
+
669
+ return resolve_from_saved_config(workdir / "config.yaml")
670
+
671
+
672
+ def _fetch_registered_clients(
673
+ base_url: str, admin_user: str, admin_password: str
674
+ ) -> tuple[list[dict] | None, str]:
675
+ """GET /api/v1/clients with admin Basic auth.
676
+
677
+ Returns (clients, error_message). On success, error_message is "".
678
+ On failure, clients is None.
679
+ """
680
+ url = f"{base_url.rstrip('/')}/api/v1/clients"
681
+ creds = f"{admin_user}:{admin_password}".encode()
682
+ header = "Basic " + base64.b64encode(creds).decode()
683
+ req = Request(url, method="GET", headers={"Authorization": header})
684
+ try:
685
+ resp = urlopen(req, timeout=10) # noqa: S310
686
+ body = json.loads(resp.read().decode())
687
+ return body.get("clients", []) or [], ""
688
+ except HTTPError as e:
689
+ if e.code == 401:
690
+ # A bare "HTTP 401" reads like an allocator fault. It's the
691
+ # admin credentials, and they live in one of two files.
692
+ return None, (
693
+ f"the allocator rejected admin user '{admin_user}' "
694
+ "(HTTP 401). Check app.admin_user / app.admin_password "
695
+ "in ~/.lablink/config.yaml or in the rendered "
696
+ "~/.lablink/compose/<deployment>/config.yaml — a "
697
+ "redeploy can change them."
698
+ )
699
+ return None, f"HTTP {e.code} from {url}"
700
+ except URLError as e:
701
+ return None, f"{url} → {e.reason}"
702
+ except Exception as e:
703
+ return None, f"{url} → {e}"
704
+
705
+
706
+ def _render_manual_clients_table(clients: list[dict]) -> None:
707
+ """Print a Rich table of registered BYO clients."""
708
+ table = Table(show_header=True)
709
+ table.add_column("Hostname")
710
+ table.add_column("Provider")
711
+ table.add_column("Status")
712
+ table.add_column("Healthy")
713
+ table.add_column("In use")
714
+ table.add_column("GPU")
715
+ table.add_column("Endpoint")
716
+
717
+ for c in clients:
718
+ status_val = c.get("status") or "-"
719
+ if status_val == "running":
720
+ status_str = "[green]running[/green]"
721
+ elif status_val in ("stopped", "failed"):
722
+ status_str = f"[red]{status_val}[/red]"
723
+ else:
724
+ status_str = f"[yellow]{status_val}[/yellow]"
725
+
726
+ healthy_val = c.get("healthy")
727
+ if healthy_val in (None, ""):
728
+ healthy_str = "-"
729
+ elif str(healthy_val).lower() in ("true", "yes", "ok", "healthy"):
730
+ healthy_str = "[green]yes[/green]"
731
+ else:
732
+ healthy_str = f"[yellow]{healthy_val}[/yellow]"
733
+
734
+ gpu_present = c.get("gpu_present")
735
+ gpu_model = c.get("gpu_model") or ""
736
+ if gpu_present is True:
737
+ gpu_str = gpu_model or "yes"
738
+ elif gpu_present is False:
739
+ gpu_str = "no"
740
+ else:
741
+ gpu_str = "-"
742
+
743
+ table.add_row(
744
+ c.get("hostname") or "-",
745
+ c.get("provider") or "-",
746
+ status_str,
747
+ healthy_str,
748
+ "yes" if c.get("inuse") else "no",
749
+ gpu_str,
750
+ c.get("endpoint_url") or "-",
751
+ )
752
+
753
+ console.print(table)
754
+
755
+
756
+ def _public_url(workdir: Path) -> str | None:
757
+ """The participant-facing URL `lablink deploy` published for this stack.
758
+
759
+ Read from the canonical-URL file staged in the deployment dir (the same
760
+ file bind-mounted into the allocator, written from `tailscale funnel
761
+ status`). Empty on every deployment that isn't Funnel-exposed, in which
762
+ case there is no public URL to show and this returns None.
763
+ """
764
+ # Imported inside the function: deploy_compose imports
765
+ # check_health_endpoint from this module, so a module-level import here
766
+ # would close an import cycle.
767
+ from lablink_cli.commands.deploy_compose import CANONICAL_URL_FILENAME
768
+
769
+ try:
770
+ candidate = (workdir / CANONICAL_URL_FILENAME).read_text().strip()
771
+ except OSError:
772
+ return None
773
+ return candidate if candidate.startswith(("http://", "https://")) else None
774
+
775
+
776
+ def _run_status_manual(cfg: Config, *, docker: Docker | None = None) -> None:
777
+ """Report compose stack health, allocator HTTP health, and BYO clients."""
778
+ docker = docker or default_docker()
779
+ workdir = Path.home() / ".lablink" / "compose" / (
780
+ cfg.deployment_name or "lablink"
781
+ )
782
+
783
+ console.print(
784
+ f"[bold]Manual deployment:[/bold] {cfg.deployment_name}"
785
+ )
786
+
787
+ if not workdir.exists():
788
+ console.print(
789
+ f"[yellow]No compose stack at {workdir} — run "
790
+ "`lablink deploy` first.[/yellow]"
791
+ )
792
+ return
793
+
794
+ ps = docker.compose(workdir, "ps")
795
+ if ps.ok:
796
+ console.print(ps.stdout)
797
+
798
+ scheme = "https" if cfg.ssl.provider == "self_signed" else "http"
799
+ base_url = f"{scheme}://localhost"
800
+ health = check_health_endpoint(base_url)
801
+ if health.get("healthy"):
802
+ console.print(
803
+ f"[green]Allocator healthy at {base_url}/api/health[/green]"
804
+ )
805
+ else:
806
+ console.print(
807
+ f"[yellow]Allocator not healthy at {base_url}/api/health[/yellow]"
808
+ )
809
+
810
+ # localhost above is the local liveness probe; it is not the address
811
+ # participants (or BYO clients) use. When the stack is Funnel-exposed,
812
+ # surface the public URL too — and check it, since Funnel being off or
813
+ # the tailnet being down is invisible from a localhost probe.
814
+ public_url = _public_url(workdir)
815
+ if public_url:
816
+ label = (
817
+ "Tailscale Funnel"
818
+ if cfg.manual.participant_exposure == "tailscale_funnel"
819
+ else "public"
820
+ )
821
+ console.print(f"[bold]Public URL ({label}):[/bold] {public_url}")
822
+ public_health = check_health_endpoint(public_url)
823
+ if public_health.get("healthy"):
824
+ console.print(f"[green]Reachable at {public_url}/api/health[/green]")
825
+ else:
826
+ detail = public_health.get("detail") or public_health.get("status", "")
827
+ console.print(
828
+ f"[yellow]Not reachable at {public_url}/api/health"
829
+ f"{f' — {detail}' if detail else ''}[/yellow]"
830
+ )
831
+
832
+ # No LAN detection on this path — degrades to localhost.
833
+ _print_admin_url(public_url or base_url)
834
+
835
+ console.print()
836
+ console.print("[bold]Registered Clients[/bold]")
837
+ creds = _resolve_manual_admin_credentials(cfg, workdir)
838
+ if creds is None:
839
+ console.print(
840
+ "[yellow]Admin credentials not found in config — "
841
+ "cannot list clients. Open the admin dashboard at "
842
+ f"{public_url or f'{scheme}://localhost'} instead.[/yellow]"
843
+ )
844
+ return
845
+
846
+ admin_user, admin_pw = creds
847
+ clients, err = _fetch_registered_clients(base_url, admin_user, admin_pw)
848
+ if clients is None:
849
+ console.print(f"[red]Failed to list clients: {err}[/red]")
850
+ return
851
+ if not clients:
852
+ console.print(
853
+ " [dim]No clients registered yet. On each BYO box, run "
854
+ "`lablink client register …` (token shown by `lablink deploy`).[/dim]"
855
+ )
856
+ return
857
+
858
+ _render_manual_clients_table(clients)
859
+ running = sum(1 for c in clients if c.get("status") == "running")
860
+ console.print(
861
+ f" [dim]{len(clients)} registered, {running} running.[/dim]"
862
+ )
863
+
864
+
865
+ def _render_aws_credentials_error(
866
+ err: AwsQueryError, region: str
867
+ ) -> None:
868
+ """Report unusable AWS credentials and what it costs this report."""
869
+ console.print("[bold]AWS credentials[/bold]")
870
+ print_aws_error(err)
871
+ profile = os.environ.get("AWS_PROFILE")
872
+ if profile is None:
873
+ profile_desc = "default"
874
+ elif profile == "":
875
+ # An exported-but-empty AWS_PROFILE fails every AWS call; saying
876
+ # "default" here would contradict the error printed above.
877
+ profile_desc = "(AWS_PROFILE is set but empty)"
878
+ else:
879
+ profile_desc = profile
880
+ console.print(
881
+ f" [dim]Region: {region}, profile: {profile_desc}[/dim]"
882
+ )
883
+ if err.is_auth:
884
+ console.print(
885
+ " [dim]OpenTofu state, VM inventory and live pricing are "
886
+ "unavailable until this is fixed.[/dim]"
887
+ )
888
+ else:
889
+ # Not a credential problem, so the AWS-backed sections below may
890
+ # still work. Claiming they're unavailable would be a guess.
891
+ console.print(
892
+ " [dim]Sections below may be incomplete.[/dim]"
893
+ )
894
+ console.print()
895
+
896
+
897
+ def run_status(cfg: Config) -> None:
898
+ """Run health checks and show cost estimate."""
899
+ if getattr(cfg, "provider", "aws") == "manual":
900
+ _run_status_manual(cfg)
901
+ return
902
+
903
+ deploy_dir = _get_deploy_dir(cfg)
904
+
905
+ console.print()
906
+ console.print(
907
+ Panel(
908
+ "[bold]LabLink Status[/bold]\n"
909
+ f"Deployment: {cfg.deployment_name} | "
910
+ f"Environment: {cfg.environment}",
911
+ border_style="cyan",
912
+ )
913
+ )
914
+ console.print()
915
+
916
+ # Probed up front: every AWS-backed section below degrades to an
917
+ # empty result on a credential failure, which reads as "nothing is
918
+ # deployed". Reported once here instead of three times below.
919
+ aws_error = aws_credentials_error(cfg.app.region)
920
+ if aws_error is not None:
921
+ _render_aws_credentials_error(aws_error, cfg.app.region)
922
+
923
+ # Only a *credential* failure dooms every AWS-backed section below. A
924
+ # non-auth probe failure (transient blip, or STS blocked by a proxy or
925
+ # VPC endpoint policy while EC2 answers fine) proves nothing about
926
+ # EC2, so let the real queries run and report for themselves —
927
+ # _render_client_vms already handles AwsQueryError.
928
+ aws_down = aws_error is not None and aws_error.is_auth
929
+ outputs = _render_tofu_state(deploy_dir, aws_unavailable=aws_down)
930
+ # DNS/HTTP/SSL checks need no AWS credentials, so they still run.
931
+ _render_health_checks(cfg, outputs)
932
+ _print_admin_url(_build_health_url(cfg, outputs))
933
+ _render_client_vms(cfg, aws_unavailable=aws_down)
934
+ _render_cost_estimate(cfg, live_pricing=not aws_down)