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,552 @@
1
+ """Shared helpers for CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ from rich.console import Console
11
+
12
+ from lablink_allocator_service.conf.structured_config import Config
13
+
14
+ console = Console()
15
+
16
+
17
+ # ------------------------------------------------------------------
18
+ # OpenTofu output formatting
19
+ # ------------------------------------------------------------------
20
+ # Matches OpenTofu's `Apply complete!` and `Destroy complete!` summary lines.
21
+ _APPLY_SUMMARY_RE = re.compile(
22
+ r"Apply complete!\s+Resources:\s+"
23
+ r"(\d+)\s+added,\s+(\d+)\s+changed,\s+(\d+)\s+destroyed",
24
+ )
25
+ _DESTROY_SUMMARY_RE = re.compile(
26
+ r"Destroy complete!\s+Resources:\s+(\d+)\s+destroyed",
27
+ )
28
+
29
+
30
+ def summarize_tofu(output: str) -> str | None:
31
+ """Extract OpenTofu's apply/destroy summary line from raw output.
32
+
33
+ Returns None when neither summary matches — a no-op apply, an
34
+ interrupted run, or output captured before the trailing summary.
35
+ """
36
+ m = _APPLY_SUMMARY_RE.search(output)
37
+ if m:
38
+ added, changed, destroyed = m.groups()
39
+ return f"Resources: {added} added, {changed} changed, {destroyed} destroyed"
40
+ m = _DESTROY_SUMMARY_RE.search(output)
41
+ if m:
42
+ (destroyed,) = m.groups()
43
+ return f"Resources: {destroyed} destroyed"
44
+ return None
45
+
46
+
47
+ def format_duration(seconds: float) -> str:
48
+ """Render a duration as `1m 23s` or `45s`."""
49
+ seconds = int(seconds)
50
+ if seconds < 60:
51
+ return f"{seconds}s"
52
+ mins, secs = divmod(seconds, 60)
53
+ return f"{mins}m {secs}s"
54
+
55
+
56
+ # ------------------------------------------------------------------
57
+ # AWS error reporting
58
+ # ------------------------------------------------------------------
59
+ class AwsQueryError(Exception):
60
+ """An AWS query could not be answered.
61
+
62
+ Two flags, at most one of which is set, because they have different
63
+ fixes and so must produce different advice:
64
+
65
+ ``is_auth``
66
+ Authentication — we cannot establish who the caller is (absent,
67
+ expired, or invalid credentials). Fixed by supplying credentials.
68
+ ``is_permission``
69
+ Authorization — the caller is known, but not allowed to make this
70
+ call. Fixed by an IAM policy change; re-authenticating with the
71
+ same identity changes nothing.
72
+
73
+ Neither set means something else went wrong (throttling, endpoint
74
+ trouble) and the error is reported verbatim with no advice, since
75
+ telling someone to run 'aws configure' over a throttling error just
76
+ wastes their time.
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ message: str,
82
+ *,
83
+ is_auth: bool = False,
84
+ is_permission: bool = False,
85
+ ) -> None:
86
+ super().__init__(message)
87
+ self.is_auth = is_auth
88
+ self.is_permission = is_permission
89
+
90
+
91
+ # "We cannot establish who you are" — the fix is new/refreshed credentials.
92
+ _AUTHENTICATION_ERROR_CODES = frozenset({
93
+ "AuthFailure",
94
+ "ExpiredToken",
95
+ "ExpiredTokenException",
96
+ "InvalidClientTokenId",
97
+ "RequestExpired",
98
+ "SignatureDoesNotMatch",
99
+ "UnrecognizedClientException",
100
+ })
101
+
102
+ # "We know who you are, and you may not do this" — the fix is an IAM
103
+ # policy change. Kept separate from the codes above because the credential
104
+ # remedies cannot resolve these, and offering them sends the operator in
105
+ # circles re-authenticating an identity that was never the problem.
106
+ _AUTHORIZATION_ERROR_CODES = frozenset({
107
+ "AccessDenied",
108
+ "AccessDeniedException",
109
+ "UnauthorizedOperation",
110
+ })
111
+
112
+ # Printed one per line: Rich wraps at the console width, and a hint
113
+ # folded mid-command is a hint the operator can't copy-paste.
114
+ AWS_CREDENTIALS_REMEDIES = (
115
+ "aws configure",
116
+ "export AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=...",
117
+ "aws sso login (if this account uses SSO)",
118
+ )
119
+
120
+ # Deliberately does not name the credential commands, even to dismiss
121
+ # them — a skimming operator would try them anyway.
122
+ AWS_PERMISSION_REMEDY_LINES = (
123
+ "These credentials are valid but lack permission for this call.",
124
+ "Grant the calling identity the action named above (for example",
125
+ "ec2:DescribeInstances), or switch to a role or profile that has it.",
126
+ )
127
+
128
+
129
+ def _classify_aws_error(e: Exception) -> AwsQueryError:
130
+ """Translate a boto3/botocore exception into an AwsQueryError."""
131
+ from botocore.exceptions import (
132
+ ClientError,
133
+ NoCredentialsError,
134
+ PartialCredentialsError,
135
+ ProfileNotFound,
136
+ SSOTokenLoadError,
137
+ TokenRetrievalError,
138
+ UnauthorizedSSOTokenError,
139
+ )
140
+
141
+ if isinstance(e, (NoCredentialsError, PartialCredentialsError)):
142
+ return AwsQueryError(
143
+ f"No usable AWS credentials found ({e})", is_auth=True
144
+ )
145
+ if isinstance(
146
+ e, (TokenRetrievalError, UnauthorizedSSOTokenError, SSOTokenLoadError)
147
+ ):
148
+ return AwsQueryError(
149
+ f"AWS SSO session is not usable ({e})", is_auth=True
150
+ )
151
+ if isinstance(e, ProfileNotFound):
152
+ return AwsQueryError(f"AWS profile not found ({e})", is_auth=True)
153
+ if isinstance(e, ClientError):
154
+ err = (getattr(e, "response", None) or {}).get("Error", {}) or {}
155
+ code = err.get("Code", "") or "Unknown"
156
+ msg = err.get("Message", "") or str(e)
157
+ if code in _AUTHORIZATION_ERROR_CODES:
158
+ return AwsQueryError(
159
+ f"AWS denied the request: {code} — {msg}",
160
+ is_permission=True,
161
+ )
162
+ if code in _AUTHENTICATION_ERROR_CODES:
163
+ return AwsQueryError(
164
+ f"AWS rejected the request: {code} — {msg}", is_auth=True
165
+ )
166
+ return AwsQueryError(f"AWS API error: {code} — {msg}")
167
+ return AwsQueryError(f"AWS query failed: {e}")
168
+
169
+
170
+ def aws_credentials_error(region: str) -> AwsQueryError | None:
171
+ """Probe STS for the caller identity. Return None if credentials work.
172
+
173
+ Deliberately silent, unlike ``setup.check_credentials``, which prints
174
+ a remediation block and raises SystemExit (which is why
175
+ ``doctor._check_aws_credentials`` has to catch SystemExit). Callers
176
+ render their own message so the probe can be used mid-report.
177
+ """
178
+ from lablink_cli.commands.setup import _get_session
179
+
180
+ try:
181
+ _get_session(region).client("sts").get_caller_identity()
182
+ except Exception as e: # boto3 raises many types; classified below
183
+ return _classify_aws_error(e)
184
+ return None
185
+
186
+
187
+ def print_aws_error(err: AwsQueryError, *, prefix: str | None = None) -> None:
188
+ """Print an AwsQueryError with advice matching the kind of failure."""
189
+ label = f"[red]{prefix}:[/red] " if prefix else "[red]✗[/red] "
190
+ console.print(f" {label}{err}")
191
+ if err.is_auth:
192
+ console.print(" [dim]Authenticate with one of:[/dim]")
193
+ for remedy in AWS_CREDENTIALS_REMEDIES:
194
+ console.print(f" [dim]{remedy}[/dim]")
195
+ elif err.is_permission:
196
+ for line in AWS_PERMISSION_REMEDY_LINES:
197
+ console.print(f" [dim]{line}[/dim]")
198
+
199
+
200
+ # ------------------------------------------------------------------
201
+ # EC2 instance helpers
202
+ # ------------------------------------------------------------------
203
+ def _parse_instances(resp: dict) -> list[dict]:
204
+ """Extract VM info dicts from an EC2 describe_instances response."""
205
+ vms = []
206
+ for reservation in resp.get("Reservations", []):
207
+ for inst in reservation.get("Instances", []):
208
+ name = ""
209
+ for tag in inst.get("Tags", []):
210
+ if tag["Key"] == "Name":
211
+ name = tag["Value"]
212
+ break
213
+ vms.append(
214
+ {
215
+ "name": name,
216
+ "instance_id": inst["InstanceId"],
217
+ "type": inst["InstanceType"],
218
+ "state": inst["State"]["Name"],
219
+ "launch_time": inst.get("LaunchTime", ""),
220
+ "public_ip": inst.get("PublicIpAddress", "—"),
221
+ }
222
+ )
223
+ return vms
224
+
225
+
226
+ def query_ec2_instances(
227
+ region: str,
228
+ tag_pattern: str,
229
+ states: list[str] | None = None,
230
+ ) -> list[dict]:
231
+ """Query EC2 instances by Name tag pattern and state.
232
+
233
+ Args:
234
+ region: AWS region.
235
+ tag_pattern: Glob pattern for the Name tag (e.g. ``"my-app-*"``).
236
+ states: Instance states to match. Defaults to ``["running"]``.
237
+
238
+ Returns:
239
+ List of VM info dicts. Empty only when the query succeeded and
240
+ matched nothing.
241
+
242
+ Raises:
243
+ AwsQueryError: the query could not be answered. Callers must not
244
+ report this as "no instances" — that conflation is what made
245
+ ``lablink status`` print an empty inventory when the real
246
+ problem was an unauthenticated caller.
247
+ """
248
+ from lablink_cli.commands.setup import _get_session
249
+
250
+ if states is None:
251
+ states = ["running"]
252
+
253
+ try:
254
+ ec2 = _get_session(region).client("ec2")
255
+ resp = ec2.describe_instances(
256
+ Filters=[
257
+ {"Name": "tag:Name", "Values": [tag_pattern]},
258
+ {"Name": "instance-state-name", "Values": states},
259
+ ]
260
+ )
261
+ except Exception as e: # boto3 raises many types; classified below
262
+ raise _classify_aws_error(e) from e
263
+
264
+ return _parse_instances(resp)
265
+
266
+
267
+ def get_allocator_vm(cfg: Config) -> dict | None:
268
+ """Find the allocator EC2 instance for this deployment.
269
+
270
+ Propagates AwsQueryError from the underlying query — None means the
271
+ instance genuinely isn't there.
272
+ """
273
+ tag = f"{cfg.deployment_name}-allocator-{cfg.environment}"
274
+ vms = query_ec2_instances(cfg.app.region, tag)
275
+ if vms:
276
+ vms[0]["vm_type"] = "allocator"
277
+ return vms[0]
278
+ return None
279
+
280
+
281
+ def get_client_vms(cfg: Config) -> list[dict]:
282
+ """Query EC2 for LabLink client VMs.
283
+
284
+ Propagates AwsQueryError — an empty list means no client VMs exist.
285
+ """
286
+ tag = (
287
+ f"{cfg.machine.software}-lablink-client-"
288
+ f"{cfg.environment}-vm-*"
289
+ )
290
+ vms = query_ec2_instances(
291
+ cfg.app.region,
292
+ tag,
293
+ states=["running", "stopped", "pending"],
294
+ )
295
+ for vm in vms:
296
+ vm["vm_type"] = "client"
297
+ return vms
298
+
299
+
300
+ def list_all_vms(cfg: Config) -> list[dict]:
301
+ """Return allocator + client VMs for this deployment."""
302
+ vms: list[dict] = []
303
+ allocator = get_allocator_vm(cfg)
304
+ if allocator:
305
+ vms.append(allocator)
306
+ vms.extend(get_client_vms(cfg))
307
+ return vms
308
+
309
+
310
+ class TofuError(Exception):
311
+ """``tofu`` ran but produced no usable output.
312
+
313
+ Carries the tool's own stderr, because the useful part of the
314
+ diagnosis — expired credentials, an uninitialised backend, a held
315
+ state lock — only ever appears there.
316
+ """
317
+
318
+
319
+ # tofu draws errors in an ANSI-coloured box; neither survives usefully in a
320
+ # one-line CLI message.
321
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
322
+ _BOX_CHARS = "╷│╵╶╴─"
323
+
324
+
325
+ def _clean_tofu_stderr(stderr: str) -> str:
326
+ """Reduce tofu's boxed, coloured error output to its headline.
327
+
328
+ tofu prints ``Error: <headline>`` followed by a blank line and then
329
+ several paragraphs of general explanation that read the same for
330
+ every occurrence of that error. Only the headline identifies the
331
+ actual fault, so the trailing prose is dropped.
332
+ """
333
+ lines = [
334
+ line.strip().lstrip(_BOX_CHARS).strip()
335
+ for line in _ANSI_RE.sub("", stderr or "").splitlines()
336
+ ]
337
+ for i, line in enumerate(lines):
338
+ if line.startswith("Error:"):
339
+ headline = [line]
340
+ for following in lines[i + 1:]:
341
+ if not following:
342
+ break
343
+ headline.append(following)
344
+ return " ".join(headline)
345
+ return " ".join(x for x in lines if x) or "tofu failed with no error output"
346
+
347
+
348
+ def get_tofu_outputs(deploy_dir: Path) -> dict[str, str]:
349
+ """Read OpenTofu outputs as a dict.
350
+
351
+ An empty result means the state genuinely declares no outputs. It
352
+ never means "the read failed": ``tofu output -json`` exits 0 and
353
+ prints ``{}`` even in an uninitialised directory with no state, so a
354
+ non-zero exit is always a real fault and is raised rather than
355
+ flattened into an empty dict. Reporting a failed read as "no
356
+ outputs" sends the operator looking for a missing deployment when
357
+ the actual problem is usually their credentials.
358
+
359
+ Raises:
360
+ TofuError: tofu is missing, exited non-zero, or emitted JSON
361
+ that could not be parsed.
362
+ """
363
+ try:
364
+ result = subprocess.run(
365
+ ["tofu", "output", "-json"],
366
+ cwd=deploy_dir,
367
+ capture_output=True,
368
+ text=True,
369
+ check=True,
370
+ )
371
+ except FileNotFoundError as e:
372
+ raise TofuError(
373
+ "tofu not found on PATH — run `lablink doctor`"
374
+ ) from e
375
+ except subprocess.CalledProcessError as e:
376
+ raise TofuError(_clean_tofu_stderr(e.stderr)) from e
377
+
378
+ try:
379
+ raw = json.loads(result.stdout)
380
+ except json.JSONDecodeError as e:
381
+ raise TofuError(f"could not parse tofu output as JSON: {e}") from e
382
+
383
+ return {
384
+ k: v.get("value", "")
385
+ for k, v in raw.items()
386
+ }
387
+
388
+
389
+ def get_deploy_dir(cfg: Config) -> Path:
390
+ """Return the scoped deploy directory for this deployment."""
391
+ return (
392
+ Path.home()
393
+ / ".lablink"
394
+ / "deploy"
395
+ / cfg.deployment_name
396
+ / cfg.environment
397
+ )
398
+
399
+
400
+ def get_allocator_url(cfg: Config) -> str:
401
+ """Determine the allocator base URL from OpenTofu outputs or config.
402
+
403
+ Manual provider has neither input: no OpenTofu state to read an IP
404
+ from, and ``dns.enabled`` is meaningless for a compose stack. Both
405
+ compose templates publish ``${HTTP_PORT}:5000`` on the host, and the
406
+ CLI's manual paths already assume they run on that host (`status` and
407
+ `logs` shell into the local container), so localhost is the address —
408
+ the same base URL deploy_compose._health_poll polls after `up`.
409
+ Imported lazily: deploy_compose imports this module at load time.
410
+ """
411
+ if getattr(cfg, "provider", "aws") == "manual":
412
+ from lablink_cli.commands.deploy_compose import DEFAULT_HTTP_PORT
413
+
414
+ return f"http://localhost:{DEFAULT_HTTP_PORT}"
415
+
416
+ deploy_dir = get_deploy_dir(cfg)
417
+ outputs = {}
418
+ if deploy_dir.exists():
419
+ try:
420
+ outputs = get_tofu_outputs(deploy_dir)
421
+ except TofuError:
422
+ # Silent by design: the config-derived domain below is a
423
+ # complete answer on its own, and this helper is called from
424
+ # deep inside other commands that report the failure properly.
425
+ pass
426
+
427
+ ip = outputs.get("ec2_public_ip", "")
428
+ domain = cfg.dns.domain if cfg.dns.enabled else ""
429
+ use_https = cfg.ssl.provider != "none"
430
+
431
+ if domain and use_https:
432
+ return f"https://{domain}"
433
+ elif domain:
434
+ return f"http://{domain}"
435
+ elif ip:
436
+ return f"http://{ip}"
437
+ return ""
438
+
439
+
440
+ _MISSING = ("MISSING", "")
441
+
442
+ # The places resolve_admin_credentials draws from, quoted back to the
443
+ # operator when the allocator rejects what it produced — a bare "HTTP 401"
444
+ # doesn't say which file to go edit. Pre-split into short lines: Rich
445
+ # wraps at the console width but does not carry the indent onto
446
+ # continuation lines, which looks ragged under an indented bullet.
447
+ ADMIN_CREDENTIALS_HINT_LINES = (
448
+ "Check app.admin_user / app.admin_password in your config, or in",
449
+ "~/.lablink/deploy/<deployment>/<environment>/config/config.yaml",
450
+ "(saved at deploy time — a redeploy can change them).",
451
+ )
452
+
453
+ # Manual keeps its copy in the rendered compose dir instead, with no
454
+ # environment scoping — only the middle line above differs, so swap that
455
+ # rather than carrying a second near-identical tuple.
456
+ MANUAL_SAVED_CONFIG_HINT = "~/.lablink/compose/<deployment>/config.yaml"
457
+
458
+
459
+ def print_admin_credentials_hint(cfg: Config | None = None) -> None:
460
+ """Print where admin credentials come from, after a rejected login.
461
+
462
+ ``cfg`` selects which deploy-time file to name; omit it (or pass a
463
+ non-manual config) for the AWS deploy dir.
464
+ """
465
+ header, path, footer = ADMIN_CREDENTIALS_HINT_LINES
466
+ if cfg is not None and getattr(cfg, "provider", "aws") == "manual":
467
+ path = MANUAL_SAVED_CONFIG_HINT
468
+ for line in (header, path, footer):
469
+ console.print(f" [dim]{line}[/dim]")
470
+
471
+
472
+ def _resolve_from_config(
473
+ cfg: Config,
474
+ ) -> tuple[str, str] | None:
475
+ """Try to get credentials from the main config."""
476
+ user = cfg.app.admin_user
477
+ pw = cfg.app.admin_password
478
+ if user not in _MISSING and pw not in _MISSING:
479
+ return user, pw
480
+ return None
481
+
482
+
483
+ def resolve_from_saved_config(path: Path) -> tuple[str, str] | None:
484
+ """Try to get credentials from a deploy-time config.yaml at ``path``.
485
+
486
+ Both providers stash the resolved credentials in a rendered config.yaml,
487
+ just in different places (AWS: the deploy dir, manual: the compose
488
+ workdir), so the read is shared and only the path differs.
489
+ """
490
+ import yaml
491
+
492
+ if not path.exists():
493
+ return None
494
+
495
+ with open(path) as f:
496
+ saved_cfg = yaml.safe_load(f) or {}
497
+
498
+ app_cfg = saved_cfg.get("app", {}) or {}
499
+ user = app_cfg.get("admin_user", "")
500
+ pw = app_cfg.get("admin_password", "")
501
+
502
+ if user and user not in _MISSING and pw and pw not in _MISSING:
503
+ return user, pw
504
+ return None
505
+
506
+
507
+ def _resolve_from_prompt() -> tuple[str, str]:
508
+ """Prompt the user for admin credentials."""
509
+ import getpass
510
+
511
+ admin_user = (
512
+ input(" Admin username [admin]: ").strip()
513
+ or "admin"
514
+ )
515
+ admin_pw = getpass.getpass(" Admin password: ")
516
+ if not admin_pw:
517
+ console.print(
518
+ " [red]Admin password is required[/red]"
519
+ )
520
+ raise SystemExit(1)
521
+ console.print()
522
+ return admin_user, admin_pw
523
+
524
+
525
+ def resolve_admin_credentials(
526
+ cfg: Config,
527
+ ) -> tuple[str, str]:
528
+ """Resolve admin credentials from config, deployment dir, or prompt.
529
+
530
+ Resolution order:
531
+ 1. Main config (``cfg.app.admin_user`` / ``cfg.app.admin_password``)
532
+ 2. Deployment-specific config written during deploy — the AWS deploy
533
+ dir, or the rendered compose workdir under the manual provider
534
+ (which has no deploy dir at all, so the AWS lookup always missed
535
+ and every BYO operator got prompted)
536
+ 3. Interactive prompt (last resort)
537
+
538
+ Returns ``(admin_user, admin_password)``.
539
+ """
540
+ resolved = _resolve_from_config(cfg)
541
+ if resolved:
542
+ return resolved
543
+
544
+ if getattr(cfg, "provider", "aws") == "manual":
545
+ # Lazy: deploy_compose imports this module at load time.
546
+ from lablink_cli.commands.deploy_compose import compose_workdir
547
+
548
+ path = compose_workdir(cfg) / "config.yaml"
549
+ else:
550
+ path = get_deploy_dir(cfg) / "config" / "config.yaml"
551
+
552
+ return resolve_from_saved_config(path) or _resolve_from_prompt()
File without changes