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,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
|