lablink-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lablink_cli/__init__.py +8 -0
- lablink_cli/api.py +428 -0
- lablink_cli/app.py +938 -0
- lablink_cli/byo_detect.py +112 -0
- lablink_cli/commands/__init__.py +0 -0
- lablink_cli/commands/cleanup.py +647 -0
- lablink_cli/commands/deploy.py +863 -0
- lablink_cli/commands/deploy_compose.py +1203 -0
- lablink_cli/commands/doctor.py +549 -0
- lablink_cli/commands/export_metrics.py +244 -0
- lablink_cli/commands/launch.py +236 -0
- lablink_cli/commands/logs.py +434 -0
- lablink_cli/commands/register.py +839 -0
- lablink_cli/commands/reset_overlay.py +109 -0
- lablink_cli/commands/setup.py +347 -0
- lablink_cli/commands/stats.py +133 -0
- lablink_cli/commands/status.py +934 -0
- lablink_cli/commands/unregister.py +188 -0
- lablink_cli/commands/utils.py +552 -0
- lablink_cli/config/__init__.py +0 -0
- lablink_cli/config/schema.py +212 -0
- lablink_cli/deployment_metrics.py +94 -0
- lablink_cli/docker.py +419 -0
- lablink_cli/log_shipper.py +441 -0
- lablink_cli/templates/docker-compose.tailscale-override.yml +55 -0
- lablink_cli/templates/docker-compose.yml +67 -0
- lablink_cli/tofu_source.py +169 -0
- lablink_cli/tui/__init__.py +0 -0
- lablink_cli/tui/logs_viewer.py +413 -0
- lablink_cli/tui/wizard.py +1814 -0
- lablink_cli-0.1.0.dist-info/METADATA +76 -0
- lablink_cli-0.1.0.dist-info/RECORD +35 -0
- lablink_cli-0.1.0.dist-info/WHEEL +5 -0
- lablink_cli-0.1.0.dist-info/entry_points.txt +2 -0
- lablink_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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()
|