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,549 @@
1
+ """Pre-flight checks for LabLink deployment prerequisites."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ import yaml
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.table import Table
14
+
15
+ from lablink_cli.docker import Docker, DockerUnavailable, default_docker
16
+
17
+ console = Console()
18
+
19
+ DEFAULT_CONFIG = Path.home() / ".lablink" / "config.yaml"
20
+
21
+ STATUS_STYLES = {
22
+ "pass": "[green]PASS[/green]",
23
+ "fail": "[red]FAIL[/red]",
24
+ "warn": "[yellow]WARN[/yellow]",
25
+ }
26
+
27
+ # The S3 backend corrupts state below this version: an aws-sdk-go-v2 bug leaves
28
+ # the PutObject body non-seekable, so a retried state upload fails with "failed
29
+ # to rewind transport stream for retry" *after* apply/destroy has already run.
30
+ #
31
+ # The fix was a pure SDK bump (aws/aws-sdk-go-v2#2485), so what matters is the
32
+ # vendored SDK, not the release number. OpenTofu pinned aws-sdk-go-v2 v1.23.2
33
+ # from 1.6.0 all the way through 1.9.x — older than the v1.24.0 the bug was
34
+ # reported against, and well short of the v1.25.3 that carries the fix. 1.10.0
35
+ # is the first release past it (v1.36.0). Do not "translate" OpenTofu's old
36
+ # 1.9.0 floor across by number: OpenTofu 1.9 predates the fix.
37
+ MIN_OPENTOFU_VERSION = (1, 10, 0)
38
+
39
+
40
+ def _parse_version(version: str) -> tuple[int, ...] | None:
41
+ """Parse a dotted version string into a comparable tuple.
42
+
43
+ Args:
44
+ version: Version string such as ``"1.12.5"``. Pre-release suffixes
45
+ (``"1.10.0-beta1"``) are truncated at the first hyphen.
46
+
47
+ Returns:
48
+ Tuple of at least three integers, or None if the string is not
49
+ parseable.
50
+ """
51
+ try:
52
+ parts = tuple(int(part) for part in version.split("-")[0].split("."))
53
+ except (AttributeError, ValueError):
54
+ return None
55
+ # Pad to three components so "1.10" compares equal to "1.10.0" instead of
56
+ # sorting below it — the minimum below sits exactly on a .0 boundary.
57
+ return parts + (0,) * (3 - len(parts))
58
+
59
+
60
+ def _load_config_safe():
61
+ """Load config from default path; return None if missing/invalid.
62
+
63
+ On load failure (malformed YAML, permission error, broken structure)
64
+ surfaces a yellow warning so the operator can tell why doctor fell
65
+ through to the AWS prereq path instead of silently doing so.
66
+ """
67
+ if not DEFAULT_CONFIG.exists():
68
+ return None
69
+ try:
70
+ from lablink_cli.config.schema import load_config
71
+
72
+ return load_config(DEFAULT_CONFIG)
73
+ except (OSError, yaml.YAMLError, AttributeError, TypeError, ValueError) as e:
74
+ console.print(
75
+ f"[yellow]Could not load {DEFAULT_CONFIG}: {e}.[/yellow]\n"
76
+ "[yellow]Falling back to AWS prereq checks. "
77
+ "Fix the config or run `lablink configure` to regenerate it.[/yellow]"
78
+ )
79
+ return None
80
+
81
+
82
+ def _check_opentofu() -> dict:
83
+ """Check that OpenTofu is installed and return version."""
84
+ result = {"check": "OpenTofu installed", "status": "fail"}
85
+
86
+ path = shutil.which("tofu")
87
+ if not path:
88
+ result["detail"] = (
89
+ "tofu not found on PATH. "
90
+ "Install from https://opentofu.org/docs/intro/install/"
91
+ )
92
+ return result
93
+
94
+ try:
95
+ proc = subprocess.run(
96
+ ["tofu", "version", "-json"],
97
+ capture_output=True,
98
+ text=True,
99
+ timeout=10,
100
+ )
101
+ if proc.returncode == 0:
102
+ info = json.loads(proc.stdout)
103
+ # OpenTofu keeps OpenTofu's key name here.
104
+ version = info.get(
105
+ "terraform_version", "unknown"
106
+ )
107
+ parsed = _parse_version(version)
108
+ minimum = ".".join(str(p) for p in MIN_OPENTOFU_VERSION)
109
+ if parsed is not None and parsed < MIN_OPENTOFU_VERSION:
110
+ result["status"] = "fail"
111
+ result["detail"] = (
112
+ f"v{version} ({path}) is too old — need {minimum}+. "
113
+ "Older versions vendor an aws-sdk-go-v2 that corrupts "
114
+ "state on S3 upload retries instead of failing cleanly "
115
+ "(aws/aws-sdk-go-v2#2485)."
116
+ )
117
+ else:
118
+ result["status"] = "pass"
119
+ result["detail"] = f"v{version} ({path})"
120
+ else:
121
+ result["status"] = "warn"
122
+ result["detail"] = (
123
+ f"Found at {path} but could not get version"
124
+ )
125
+ except (subprocess.TimeoutExpired, json.JSONDecodeError):
126
+ result["status"] = "warn"
127
+ result["detail"] = (
128
+ f"Found at {path} but could not get version"
129
+ )
130
+
131
+ return result
132
+
133
+
134
+ def _check_aws_credentials(region: str | None) -> dict:
135
+ """Check AWS credentials are valid."""
136
+ result = {"check": "AWS credentials", "status": "fail"}
137
+
138
+ try:
139
+ from lablink_cli.commands.setup import (
140
+ _get_session,
141
+ check_credentials,
142
+ )
143
+
144
+ session = _get_session(region or "us-east-1")
145
+ identity = check_credentials(session)
146
+ result["status"] = "pass"
147
+ result["detail"] = (
148
+ f"Account: {identity['account']}, "
149
+ f"Identity: {identity['arn']}"
150
+ )
151
+ except SystemExit:
152
+ result["detail"] = (
153
+ "Invalid or missing. Run 'aws configure' "
154
+ "or set AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY"
155
+ )
156
+ except Exception as e:
157
+ result["detail"] = str(e)
158
+
159
+ return result
160
+
161
+
162
+ def _check_config_exists() -> dict:
163
+ """Check that the config file exists."""
164
+ result = {"check": "Config file", "status": "fail"}
165
+
166
+ if DEFAULT_CONFIG.exists():
167
+ result["status"] = "pass"
168
+ result["detail"] = str(DEFAULT_CONFIG)
169
+ else:
170
+ result["detail"] = (
171
+ f"{DEFAULT_CONFIG} not found. "
172
+ "Run 'lablink configure' to create one"
173
+ )
174
+
175
+ return result
176
+
177
+
178
+ def _check_config_valid() -> tuple[dict, object | None]:
179
+ """Validate the config file. Returns (result, cfg_or_None)."""
180
+ result = {"check": "Config validates", "status": "fail"}
181
+
182
+ if not DEFAULT_CONFIG.exists():
183
+ result["status"] = "warn"
184
+ result["detail"] = "Skipped (no config file)"
185
+ return result, None
186
+
187
+ try:
188
+ from lablink_cli.config.schema import (
189
+ load_config,
190
+ validate_config,
191
+ )
192
+
193
+ cfg = load_config(DEFAULT_CONFIG)
194
+ errors = validate_config(cfg)
195
+ if errors:
196
+ result["status"] = "fail"
197
+ result["detail"] = "; ".join(errors)
198
+ else:
199
+ result["status"] = "pass"
200
+ result["detail"] = "No errors"
201
+ return result, cfg
202
+ except Exception as e:
203
+ result["detail"] = f"Failed to load: {e}"
204
+ return result, None
205
+
206
+
207
+ def _check_s3_bucket(cfg) -> dict:
208
+ """Check that the S3 bucket for OpenTofu state exists."""
209
+ result = {"check": "S3 state bucket", "status": "fail"}
210
+
211
+ if cfg is None:
212
+ result["status"] = "warn"
213
+ result["detail"] = "Skipped (no valid config)"
214
+ return result
215
+
216
+ bucket_name = getattr(cfg, "bucket_name", None)
217
+ if not bucket_name:
218
+ result["status"] = "fail"
219
+ result["detail"] = (
220
+ "No bucket_name in config. "
221
+ "Run 'lablink setup' to create one"
222
+ )
223
+ return result
224
+
225
+ try:
226
+ from lablink_cli.commands.setup import _get_session
227
+
228
+ session = _get_session(cfg.app.region)
229
+ s3 = session.client("s3")
230
+ s3.head_bucket(Bucket=bucket_name)
231
+ result["status"] = "pass"
232
+ result["detail"] = bucket_name
233
+ except Exception:
234
+ result["status"] = "fail"
235
+ result["detail"] = (
236
+ f"Bucket '{bucket_name}' not found. "
237
+ "Run 'lablink setup' to recreate it"
238
+ )
239
+
240
+ return result
241
+
242
+
243
+ def _check_ami(cfg) -> dict:
244
+ """Check that an AMI is available for the configured region."""
245
+ result = {"check": "AMI for region", "status": "fail"}
246
+
247
+ if cfg is None:
248
+ result["status"] = "warn"
249
+ result["detail"] = "Skipped (no valid config)"
250
+ return result
251
+
252
+ from lablink_cli.config.schema import AMI_MAP
253
+
254
+ region = cfg.app.region
255
+ if region in AMI_MAP:
256
+ result["status"] = "pass"
257
+ result["detail"] = (
258
+ f"{region} → {AMI_MAP[region]}"
259
+ )
260
+ else:
261
+ result["status"] = "fail"
262
+ result["detail"] = (
263
+ f"No AMI defined for region '{region}'. "
264
+ f"Supported: {', '.join(AMI_MAP.keys())}"
265
+ )
266
+
267
+ return result
268
+
269
+
270
+ def _check_aws_prereqs() -> None:
271
+ """Run the AWS-specific pre-flight checks and print a results table."""
272
+ checks: list[dict] = []
273
+
274
+ # 1. OpenTofu
275
+ checks.append(_check_opentofu())
276
+
277
+ # 2. Config file exists
278
+ checks.append(_check_config_exists())
279
+
280
+ # 3. Config validates (also returns the config object)
281
+ valid_result, cfg = _check_config_valid()
282
+ checks.append(valid_result)
283
+
284
+ # 4. AWS credentials
285
+ region = cfg.app.region if cfg else None
286
+ checks.append(_check_aws_credentials(region))
287
+
288
+ # 5. S3 state bucket
289
+ checks.append(_check_s3_bucket(cfg))
290
+
291
+ # 6. AMI for region
292
+ checks.append(_check_ami(cfg))
293
+
294
+ _render_checks(
295
+ checks,
296
+ pass_message=(
297
+ "[green]All checks passed.[/green] "
298
+ "Ready to deploy with 'lablink deploy'."
299
+ ),
300
+ fail_message=(
301
+ "[yellow]Some checks failed.[/yellow] "
302
+ "Resolve the issues above before deploying."
303
+ ),
304
+ )
305
+
306
+
307
+ def _render_checks(
308
+ checks: list[dict], *, pass_message: str, fail_message: str
309
+ ) -> bool:
310
+ """Print a check-results table. Returns True if every check passed."""
311
+ table = Table(show_header=True)
312
+ table.add_column("Check")
313
+ table.add_column("Status")
314
+ table.add_column("Detail")
315
+
316
+ all_pass = True
317
+ for c in checks:
318
+ status = c["status"]
319
+ if status != "pass":
320
+ all_pass = False
321
+ table.add_row(
322
+ c["check"],
323
+ STATUS_STYLES.get(status, status),
324
+ c.get("detail", ""),
325
+ )
326
+
327
+ console.print(table)
328
+ console.print()
329
+ console.print(pass_message if all_pass else fail_message)
330
+ return all_pass
331
+
332
+
333
+ def _check_manual_prereqs(*, docker: Docker | None = None) -> None:
334
+ """Check that docker + docker compose are available (manual provider)."""
335
+ docker = docker or default_docker()
336
+
337
+ docker_path = docker.path()
338
+ if docker_path:
339
+ console.print(f"[green]✓[/green] docker: {docker_path}")
340
+ else:
341
+ console.print("[red]✗[/red] docker: not found")
342
+
343
+ # docker-compose v2 is a subcommand, not a separate binary
344
+ try:
345
+ result = docker.compose(None, "version")
346
+ except DockerUnavailable:
347
+ console.print(
348
+ "[red]✗[/red] docker compose: missing "
349
+ "(install the Compose plugin)"
350
+ )
351
+ return
352
+ if result.ok:
353
+ console.print("[green]✓[/green] docker compose: available")
354
+ else:
355
+ console.print(
356
+ "[red]✗[/red] docker compose: missing "
357
+ "(install the Compose plugin)"
358
+ )
359
+
360
+
361
+ # --------------------------------------------------------------------
362
+ # Client-side checks (`lablink client doctor`)
363
+ #
364
+ # These run ON a registered BYO box, not on the operator's deploy host.
365
+ # `lablink doctor` answers "can I deploy from here?"; this answers "is the
366
+ # client on this machine actually working?"
367
+ # --------------------------------------------------------------------
368
+
369
+ # A shipper that is alive but hasn't shipped in this long is reporting a
370
+ # problem no liveness check can see — the process is up and the container is
371
+ # healthy, but nothing is reaching the allocator. That combination went
372
+ # unnoticed for a week (lablink#428), which is the reason this command exists.
373
+ SHIPPER_STALE_AFTER_S = 15 * 60
374
+
375
+
376
+ def _format_age(seconds: float) -> str:
377
+ """Coarse human age ("6d", "3h", "20m") for the staleness message.
378
+
379
+ A raw minute count reads as noise once it passes a few hours
380
+ ("8687 min ago"), and this is the one line an operator scans to decide
381
+ whether logs are flowing.
382
+ """
383
+ if seconds >= 86400:
384
+ return f"{int(seconds // 86400)}d"
385
+ if seconds >= 3600:
386
+ return f"{int(seconds // 3600)}h"
387
+ return f"{int(seconds // 60)}m"
388
+
389
+
390
+ def _check_client_registered() -> dict:
391
+ """Check that `lablink client register` has run on this box."""
392
+ from lablink_cli.commands.register import DEFAULT_ENV_FILE
393
+
394
+ result = {"check": "Registered", "status": "fail"}
395
+ if not DEFAULT_ENV_FILE.exists():
396
+ result["detail"] = (
397
+ f"No {DEFAULT_ENV_FILE}. Run `lablink client register` first."
398
+ )
399
+ return result
400
+ result["status"] = "pass"
401
+ result["detail"] = str(DEFAULT_ENV_FILE)
402
+ return result
403
+
404
+
405
+ def _check_client_container(docker: Docker) -> dict:
406
+ """Report the lablink-client container's state.
407
+
408
+ Doubles as the docker-daemon check: `container_status` returns
409
+ "daemon_error" when the daemon is unreachable, so a separate probe would
410
+ only duplicate the same `docker inspect` call.
411
+ """
412
+ from lablink_cli.log_shipper import CONTAINER_NAME
413
+
414
+ result = {"check": "Client container", "status": "fail"}
415
+ status = docker.container_status(CONTAINER_NAME)
416
+
417
+ if status == "daemon_error":
418
+ result["detail"] = (
419
+ "Docker daemon unreachable. Start Docker and re-check."
420
+ )
421
+ elif status == "missing":
422
+ result["detail"] = (
423
+ f"No container named {CONTAINER_NAME}. "
424
+ "Re-run `lablink client register --force` to recreate it."
425
+ )
426
+ elif status == "exited":
427
+ result["detail"] = (
428
+ f"{CONTAINER_NAME} is stopped. "
429
+ "Run `lablink client register` to restart it."
430
+ )
431
+ elif status == "restarting":
432
+ result["status"] = "warn"
433
+ result["detail"] = (
434
+ f"{CONTAINER_NAME} is restarting — it may be crash-looping. "
435
+ f"Check `docker logs {CONTAINER_NAME}`."
436
+ )
437
+ else:
438
+ result["status"] = "pass"
439
+ result["detail"] = f"{CONTAINER_NAME} is running"
440
+ return result
441
+
442
+
443
+ def _check_log_shipper(now: float | None = None) -> dict:
444
+ """Check the log shipper is alive AND actually shipping.
445
+
446
+ Liveness alone is not enough. A shipper can sit blocked on a quiet
447
+ container with a full buffer, process up, nothing delivered — so this
448
+ also reports how long ago a batch last landed.
449
+ """
450
+ import time
451
+ from datetime import datetime, timezone
452
+
453
+ from lablink_cli.commands.register import _shipper_alive
454
+ from lablink_cli.log_shipper import STATE_FILE, read_last_shipped_ts
455
+
456
+ result = {"check": "Log shipper", "status": "fail"}
457
+
458
+ if not _shipper_alive():
459
+ result["detail"] = (
460
+ "Not running — client logs are not reaching the allocator. "
461
+ "Run `lablink client register` to restart it."
462
+ )
463
+ return result
464
+
465
+ last = read_last_shipped_ts(STATE_FILE)
466
+ if last is None:
467
+ result["status"] = "warn"
468
+ result["detail"] = (
469
+ "Running, but has never shipped a batch. Normal for the first "
470
+ "minute after registering; otherwise check the allocator URL "
471
+ "and client secret."
472
+ )
473
+ return result
474
+
475
+ try:
476
+ shipped_at = datetime.strptime(last, "%Y-%m-%dT%H:%M:%SZ").replace(
477
+ tzinfo=timezone.utc
478
+ )
479
+ except ValueError:
480
+ result["status"] = "warn"
481
+ result["detail"] = f"Running; unparseable last-shipped value {last!r}"
482
+ return result
483
+
484
+ current = now if now is not None else time.time()
485
+ age_s = current - shipped_at.timestamp()
486
+ if age_s > SHIPPER_STALE_AFTER_S:
487
+ result["status"] = "warn"
488
+ result["detail"] = (
489
+ f"Running, but last shipped {_format_age(age_s)} ago ({last}). "
490
+ "The process is up but nothing is reaching the allocator."
491
+ )
492
+ return result
493
+
494
+ result["status"] = "pass"
495
+ result["detail"] = f"Running; last shipped {last}"
496
+ return result
497
+
498
+
499
+ def run_client_doctor(*, docker: Docker | None = None) -> None:
500
+ """Run the BYO-client checks and print a results table."""
501
+ docker = docker or default_docker()
502
+ console.print()
503
+ console.print(
504
+ Panel(
505
+ "[bold]LabLink Client Doctor[/bold]\n"
506
+ "Checking this machine's BYO client.",
507
+ border_style="cyan",
508
+ )
509
+ )
510
+ console.print()
511
+
512
+ checks = [
513
+ _check_client_registered(),
514
+ _check_client_container(docker),
515
+ _check_log_shipper(),
516
+ ]
517
+
518
+ _render_checks(
519
+ checks,
520
+ pass_message=(
521
+ "[green]All checks passed.[/green] "
522
+ "This client is registered and shipping logs."
523
+ ),
524
+ fail_message=(
525
+ "[yellow]Some checks need attention.[/yellow] "
526
+ "Most are fixed by re-running `lablink client register`."
527
+ ),
528
+ )
529
+
530
+
531
+ def run_doctor() -> None:
532
+ """Run all pre-flight checks."""
533
+ console.print()
534
+ console.print(
535
+ Panel(
536
+ "[bold]LabLink Doctor[/bold]\n"
537
+ "Checking prerequisites and configuration.",
538
+ border_style="cyan",
539
+ )
540
+ )
541
+ console.print()
542
+
543
+ cfg = _load_config_safe()
544
+ provider = getattr(cfg, "provider", None) if cfg else None
545
+
546
+ if provider == "manual":
547
+ _check_manual_prereqs()
548
+ else:
549
+ _check_aws_prereqs()