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,863 @@
|
|
|
1
|
+
"""Deploy and destroy LabLink infrastructure with OpenTofu."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.markup import escape
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
|
|
15
|
+
|
|
16
|
+
from lablink_allocator_service.conf.structured_config import Config
|
|
17
|
+
|
|
18
|
+
from lablink_cli.commands.setup import check_credentials, _get_session
|
|
19
|
+
from lablink_cli.commands.status import check_health_endpoint
|
|
20
|
+
from lablink_cli.commands.utils import (
|
|
21
|
+
format_duration,
|
|
22
|
+
get_allocator_url,
|
|
23
|
+
get_deploy_dir,
|
|
24
|
+
resolve_admin_credentials,
|
|
25
|
+
summarize_tofu,
|
|
26
|
+
)
|
|
27
|
+
from lablink_cli.api import (
|
|
28
|
+
AllocatorAPI,
|
|
29
|
+
AllocatorAuthError,
|
|
30
|
+
AllocatorError,
|
|
31
|
+
AllocatorNotFoundError,
|
|
32
|
+
AllocatorUnavailableError,
|
|
33
|
+
)
|
|
34
|
+
from lablink_cli.commands.export_metrics import run_export_metrics
|
|
35
|
+
from lablink_cli.config.schema import config_to_dict, save_config
|
|
36
|
+
from lablink_cli.deployment_metrics import (
|
|
37
|
+
DeploymentMetrics,
|
|
38
|
+
cache_path_for,
|
|
39
|
+
phase_timer,
|
|
40
|
+
write_metrics,
|
|
41
|
+
)
|
|
42
|
+
from lablink_cli.tofu_source import get_tofu_files
|
|
43
|
+
|
|
44
|
+
console = Console()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _prepare_working_dir(
|
|
48
|
+
cfg: Config,
|
|
49
|
+
*,
|
|
50
|
+
template_version: str | None = None,
|
|
51
|
+
terraform_bundle: str | None = None,
|
|
52
|
+
) -> Path:
|
|
53
|
+
"""Set up the OpenTofu working directory.
|
|
54
|
+
|
|
55
|
+
Downloads (or loads from cache/bundle) the template's .tf files,
|
|
56
|
+
copies them into the deploy directory, and writes config/config.yaml.
|
|
57
|
+
"""
|
|
58
|
+
from lablink_cli import TEMPLATE_VERSION
|
|
59
|
+
|
|
60
|
+
version = template_version or TEMPLATE_VERSION
|
|
61
|
+
skip_checksum = template_version is not None
|
|
62
|
+
|
|
63
|
+
if template_version:
|
|
64
|
+
console.print(
|
|
65
|
+
f" [yellow]Warning: using override version "
|
|
66
|
+
f"{template_version}, skipping checksum "
|
|
67
|
+
f"verification[/yellow]"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
tf_source = get_tofu_files(
|
|
71
|
+
version,
|
|
72
|
+
bundle_path=terraform_bundle,
|
|
73
|
+
skip_checksum=skip_checksum,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
deploy_dir = get_deploy_dir(cfg)
|
|
77
|
+
deploy_dir.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
|
|
79
|
+
# Copy .tf and .hcl files
|
|
80
|
+
for src_file in tf_source.glob("*.tf"):
|
|
81
|
+
shutil.copy2(src_file, deploy_dir / src_file.name)
|
|
82
|
+
for src_file in tf_source.glob("*.hcl"):
|
|
83
|
+
shutil.copy2(src_file, deploy_dir / src_file.name)
|
|
84
|
+
|
|
85
|
+
# Copy user_data.sh
|
|
86
|
+
user_data_src = tf_source / "user_data.sh"
|
|
87
|
+
if user_data_src.exists():
|
|
88
|
+
shutil.copy2(user_data_src, deploy_dir / "user_data.sh")
|
|
89
|
+
|
|
90
|
+
# Copy .terraform.lock.hcl if present (pins provider versions). The template
|
|
91
|
+
# ships no lock file today, and a OpenTofu-generated one must never be added:
|
|
92
|
+
# its entries are keyed registry.terraform.io/... while OpenTofu resolves
|
|
93
|
+
# registry.opentofu.org/... and would reject the lock as unsatisfiable.
|
|
94
|
+
lock_file = tf_source / ".terraform.lock.hcl"
|
|
95
|
+
if lock_file.exists():
|
|
96
|
+
shutil.copy2(
|
|
97
|
+
lock_file, deploy_dir / ".terraform.lock.hcl"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Write config/config.yaml from the Config object
|
|
101
|
+
config_dir = deploy_dir / "config"
|
|
102
|
+
config_dir.mkdir(exist_ok=True)
|
|
103
|
+
save_config(cfg, config_dir / "config.yaml")
|
|
104
|
+
|
|
105
|
+
# Copy custom startup script if configured
|
|
106
|
+
if cfg.startup_script.enabled and cfg.startup_script.path:
|
|
107
|
+
user_script = (
|
|
108
|
+
Path.home() / ".lablink" / "custom-startup.sh"
|
|
109
|
+
)
|
|
110
|
+
if user_script.exists():
|
|
111
|
+
src_startup = user_script
|
|
112
|
+
else:
|
|
113
|
+
src_startup = tf_source / cfg.startup_script.path
|
|
114
|
+
|
|
115
|
+
if src_startup.exists():
|
|
116
|
+
dest_startup = (
|
|
117
|
+
deploy_dir / "config" / "custom-startup.sh"
|
|
118
|
+
)
|
|
119
|
+
dest_startup.parent.mkdir(
|
|
120
|
+
parents=True, exist_ok=True
|
|
121
|
+
)
|
|
122
|
+
shutil.copy2(src_startup, dest_startup)
|
|
123
|
+
|
|
124
|
+
return deploy_dir
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _run_tofu(
|
|
128
|
+
args: list[str],
|
|
129
|
+
cwd: Path,
|
|
130
|
+
check: bool = True,
|
|
131
|
+
*,
|
|
132
|
+
verbose: bool = True,
|
|
133
|
+
) -> int:
|
|
134
|
+
"""Run an OpenTofu command.
|
|
135
|
+
|
|
136
|
+
verbose=True (default): live-stream output line-by-line. Existing
|
|
137
|
+
callers (deploy, init) rely on this for progress visibility.
|
|
138
|
+
verbose=False: hide output behind a spinner; print only a one-line
|
|
139
|
+
summary on success. On failure, dump the buffered output so the
|
|
140
|
+
operator can diagnose.
|
|
141
|
+
"""
|
|
142
|
+
cmd = ["tofu"] + args
|
|
143
|
+
|
|
144
|
+
if verbose:
|
|
145
|
+
console.print(
|
|
146
|
+
f" [dim]$ {' '.join(cmd)}[/dim]"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
proc = subprocess.Popen(
|
|
150
|
+
cmd,
|
|
151
|
+
cwd=cwd,
|
|
152
|
+
stdout=subprocess.PIPE,
|
|
153
|
+
stderr=subprocess.STDOUT,
|
|
154
|
+
text=True,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if proc.stdout:
|
|
158
|
+
for line in proc.stdout:
|
|
159
|
+
console.print(
|
|
160
|
+
f" {line}", end="", highlight=False, markup=False
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
proc.wait()
|
|
164
|
+
else:
|
|
165
|
+
# Quiet mode: spinner + buffered output, summary on success.
|
|
166
|
+
action = args[0]
|
|
167
|
+
started = time.monotonic()
|
|
168
|
+
captured: list[str] = []
|
|
169
|
+
with console.status(
|
|
170
|
+
f" [bold]tofu {action}[/bold]...", spinner="dots"
|
|
171
|
+
):
|
|
172
|
+
proc = subprocess.Popen(
|
|
173
|
+
cmd,
|
|
174
|
+
cwd=cwd,
|
|
175
|
+
stdout=subprocess.PIPE,
|
|
176
|
+
stderr=subprocess.STDOUT,
|
|
177
|
+
text=True,
|
|
178
|
+
)
|
|
179
|
+
if proc.stdout:
|
|
180
|
+
for line in proc.stdout:
|
|
181
|
+
captured.append(line)
|
|
182
|
+
proc.wait()
|
|
183
|
+
elapsed = time.monotonic() - started
|
|
184
|
+
output = "".join(captured)
|
|
185
|
+
|
|
186
|
+
if proc.returncode != 0:
|
|
187
|
+
# On failure, dump everything so the operator can diagnose.
|
|
188
|
+
console.print(output, markup=False)
|
|
189
|
+
else:
|
|
190
|
+
console.print(
|
|
191
|
+
f" [green]✓ tofu {action}[/green] "
|
|
192
|
+
f"[dim]({format_duration(elapsed)})[/dim]"
|
|
193
|
+
)
|
|
194
|
+
summary = summarize_tofu(output)
|
|
195
|
+
if summary:
|
|
196
|
+
console.print(f" {summary}")
|
|
197
|
+
|
|
198
|
+
if check and proc.returncode != 0:
|
|
199
|
+
console.print(
|
|
200
|
+
f"\n [red]tofu {args[0]} failed "
|
|
201
|
+
f"(exit code {proc.returncode})[/red]"
|
|
202
|
+
)
|
|
203
|
+
raise SystemExit(proc.returncode)
|
|
204
|
+
|
|
205
|
+
return proc.returncode
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _tofu_init(
|
|
209
|
+
deploy_dir: Path,
|
|
210
|
+
cfg: Config,
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Run OpenTofu init with S3 remote backend."""
|
|
213
|
+
console.print("[bold]Step 1/3:[/bold] OpenTofu init")
|
|
214
|
+
|
|
215
|
+
# Use -reconfigure if .terraform already exists (avoids
|
|
216
|
+
# "backend configuration changed" errors).
|
|
217
|
+
reconfigure = (deploy_dir / ".terraform").exists()
|
|
218
|
+
|
|
219
|
+
# Resolve bucket name from AWS account
|
|
220
|
+
import boto3
|
|
221
|
+
|
|
222
|
+
account_id = (
|
|
223
|
+
boto3.client(
|
|
224
|
+
"sts", region_name=cfg.app.region
|
|
225
|
+
)
|
|
226
|
+
.get_caller_identity()["Account"]
|
|
227
|
+
)
|
|
228
|
+
bucket_name = f"lablink-tf-state-{account_id}"
|
|
229
|
+
|
|
230
|
+
# State key scoped by deployment_name and environment
|
|
231
|
+
state_key = (
|
|
232
|
+
f"{cfg.deployment_name}/{cfg.environment}"
|
|
233
|
+
f"/terraform.tfstate"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
args = [
|
|
237
|
+
"init",
|
|
238
|
+
f"-backend-config=key={state_key}",
|
|
239
|
+
f"-backend-config=bucket={bucket_name}",
|
|
240
|
+
f"-backend-config=region={cfg.app.region}",
|
|
241
|
+
"-backend-config=dynamodb_table=lock-table",
|
|
242
|
+
"-backend-config=encrypt=true",
|
|
243
|
+
]
|
|
244
|
+
if reconfigure:
|
|
245
|
+
args.append("-reconfigure")
|
|
246
|
+
_run_tofu(args, cwd=deploy_dir)
|
|
247
|
+
|
|
248
|
+
console.print()
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _prompt_passwords() -> dict[str, str]:
|
|
252
|
+
"""Prompt for the admin credentials at deploy time."""
|
|
253
|
+
import getpass
|
|
254
|
+
|
|
255
|
+
console.print(
|
|
256
|
+
"[bold]Credentials[/bold] "
|
|
257
|
+
"(not stored in config, passed to OpenTofu only)"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
admin_user = input(" Admin username [admin]: ").strip()
|
|
261
|
+
if not admin_user:
|
|
262
|
+
admin_user = "admin"
|
|
263
|
+
|
|
264
|
+
admin_pw = getpass.getpass(" Admin password: ")
|
|
265
|
+
if not admin_pw:
|
|
266
|
+
console.print(" [red]Admin password is required[/red]")
|
|
267
|
+
raise SystemExit(1)
|
|
268
|
+
|
|
269
|
+
console.print()
|
|
270
|
+
return {
|
|
271
|
+
"admin_user": admin_user,
|
|
272
|
+
"admin_password": admin_pw,
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _build_health_poll_target(cfg: Config, ec2_ip: str) -> dict:
|
|
277
|
+
"""Pick the post-deploy poll URL + timeout. Caddy is Host-bound under
|
|
278
|
+
letsencrypt/cloudflare, so those must poll the domain, not the IP."""
|
|
279
|
+
provider = cfg.ssl.provider
|
|
280
|
+
domain = cfg.dns.domain if cfg.dns.enabled else ""
|
|
281
|
+
|
|
282
|
+
if provider == "none":
|
|
283
|
+
return {"url": f"http://{ec2_ip}", "max_wait": 300}
|
|
284
|
+
if provider == "acm":
|
|
285
|
+
# ALB owns 80/443; Flask is bound 0.0.0.0:5000 (SG allows 5000).
|
|
286
|
+
return {"url": f"http://{ec2_ip}:5000", "max_wait": 300}
|
|
287
|
+
if not domain:
|
|
288
|
+
console.print(
|
|
289
|
+
f" [yellow]Warning:[/yellow] ssl.provider='{provider}' "
|
|
290
|
+
f"without dns.domain — falling back to IP poll. "
|
|
291
|
+
f"The deploy will likely fail to serve at the expected URL."
|
|
292
|
+
)
|
|
293
|
+
return {"url": f"http://{ec2_ip}", "max_wait": 300}
|
|
294
|
+
scheme = "https" if provider == "letsencrypt" else "http"
|
|
295
|
+
return {"url": f"{scheme}://{domain}", "max_wait": 600}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _poll_allocator_health(
|
|
299
|
+
poll_url: str,
|
|
300
|
+
*,
|
|
301
|
+
max_wait: int = 120,
|
|
302
|
+
) -> dict:
|
|
303
|
+
"""Poll the allocator health endpoint with adaptive intervals.
|
|
304
|
+
|
|
305
|
+
Intervals: 3s for first 30s, 5s for 30-90s, 10s after 90s.
|
|
306
|
+
|
|
307
|
+
Returns dict with:
|
|
308
|
+
- healthy: bool
|
|
309
|
+
- elapsed: float (seconds from start to healthy or timeout)
|
|
310
|
+
- timed_out: bool
|
|
311
|
+
- uptime_seconds: float | None (from allocator's self-reported uptime)
|
|
312
|
+
"""
|
|
313
|
+
start = time.monotonic()
|
|
314
|
+
elapsed = 0.0
|
|
315
|
+
|
|
316
|
+
with console.status(
|
|
317
|
+
f"connecting... (0s / {max_wait}s)", spinner="dots"
|
|
318
|
+
) as status:
|
|
319
|
+
while elapsed < max_wait:
|
|
320
|
+
result = check_health_endpoint(poll_url)
|
|
321
|
+
elapsed = time.monotonic() - start
|
|
322
|
+
|
|
323
|
+
if result["healthy"]:
|
|
324
|
+
return {
|
|
325
|
+
"healthy": True,
|
|
326
|
+
"elapsed": elapsed,
|
|
327
|
+
"timed_out": False,
|
|
328
|
+
"uptime_seconds": result.get("uptime_seconds"),
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
# Adaptive interval based on elapsed time
|
|
332
|
+
if elapsed < 30:
|
|
333
|
+
interval = 3
|
|
334
|
+
elif elapsed < 90:
|
|
335
|
+
interval = 5
|
|
336
|
+
else:
|
|
337
|
+
interval = 10
|
|
338
|
+
|
|
339
|
+
status.update(
|
|
340
|
+
f"{result['status']}... ({elapsed:.0f}s / {max_wait}s)"
|
|
341
|
+
)
|
|
342
|
+
time.sleep(interval)
|
|
343
|
+
elapsed = time.monotonic() - start
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
"healthy": False,
|
|
347
|
+
"elapsed": elapsed,
|
|
348
|
+
"timed_out": True,
|
|
349
|
+
"uptime_seconds": None,
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def run_deploy(
|
|
354
|
+
cfg: Config,
|
|
355
|
+
*,
|
|
356
|
+
template_version: str | None = None,
|
|
357
|
+
terraform_bundle: str | None = None,
|
|
358
|
+
yes: bool = False,
|
|
359
|
+
) -> None:
|
|
360
|
+
"""Deploy LabLink infrastructure. ``yes=True`` skips confirmation prompts."""
|
|
361
|
+
from lablink_cli import TEMPLATE_VERSION
|
|
362
|
+
|
|
363
|
+
console.print()
|
|
364
|
+
console.print(
|
|
365
|
+
Panel(
|
|
366
|
+
"[bold]LabLink Deploy[/bold]\n"
|
|
367
|
+
f"Deployment: {cfg.deployment_name} | "
|
|
368
|
+
f"Environment: {cfg.environment}\n"
|
|
369
|
+
f"Region: {cfg.app.region} | State: S3 (remote)",
|
|
370
|
+
border_style="cyan",
|
|
371
|
+
)
|
|
372
|
+
)
|
|
373
|
+
console.print()
|
|
374
|
+
|
|
375
|
+
# Validate AWS credentials
|
|
376
|
+
check_credentials(_get_session(cfg.app.region))
|
|
377
|
+
|
|
378
|
+
# Prepare working directory
|
|
379
|
+
deploy_dir = _prepare_working_dir(
|
|
380
|
+
cfg,
|
|
381
|
+
template_version=template_version,
|
|
382
|
+
terraform_bundle=terraform_bundle,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
# Prompt for credentials
|
|
386
|
+
passwords = _prompt_passwords()
|
|
387
|
+
|
|
388
|
+
# Write credentials into deploy dir config for OpenTofu
|
|
389
|
+
# (deploy dir only, never persisted to ~/.lablink/)
|
|
390
|
+
import yaml
|
|
391
|
+
|
|
392
|
+
config_path = deploy_dir / "config" / "config.yaml"
|
|
393
|
+
with open(config_path) as f:
|
|
394
|
+
cfg_dict = yaml.safe_load(f)
|
|
395
|
+
|
|
396
|
+
cfg_dict["app"]["admin_user"] = passwords["admin_user"]
|
|
397
|
+
cfg_dict["app"]["admin_password"] = passwords[
|
|
398
|
+
"admin_password"
|
|
399
|
+
]
|
|
400
|
+
|
|
401
|
+
with open(config_path, "w") as f:
|
|
402
|
+
yaml.dump(
|
|
403
|
+
cfg_dict,
|
|
404
|
+
f,
|
|
405
|
+
default_flow_style=False,
|
|
406
|
+
sort_keys=False,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
# Initialize deployment metrics — written incrementally so failed
|
|
410
|
+
# or interrupted deploys still leave a useful partial record on disk.
|
|
411
|
+
has_ssl = cfg.ssl.provider != "none"
|
|
412
|
+
deploy_start_dt = datetime.now(timezone.utc)
|
|
413
|
+
metrics = DeploymentMetrics(
|
|
414
|
+
deployment_name=cfg.deployment_name,
|
|
415
|
+
provider=getattr(cfg, "provider", "aws"),
|
|
416
|
+
region=cfg.app.region,
|
|
417
|
+
template_version=template_version or TEMPLATE_VERSION,
|
|
418
|
+
ssl_enabled=has_ssl,
|
|
419
|
+
allocator_deploy_start_time=deploy_start_dt.isoformat(),
|
|
420
|
+
)
|
|
421
|
+
metrics_path = cache_path_for(cfg.deployment_name, deploy_start_dt)
|
|
422
|
+
write_metrics(metrics_path, metrics)
|
|
423
|
+
|
|
424
|
+
try:
|
|
425
|
+
# OpenTofu init
|
|
426
|
+
with phase_timer(
|
|
427
|
+
metrics, "allocator_tofu_init_duration_seconds", metrics_path
|
|
428
|
+
):
|
|
429
|
+
_tofu_init(deploy_dir, cfg)
|
|
430
|
+
|
|
431
|
+
# OpenTofu plan — pass deployment_name and environment
|
|
432
|
+
console.print("[bold]Step 2/3:[/bold] OpenTofu plan")
|
|
433
|
+
with phase_timer(
|
|
434
|
+
metrics, "allocator_tofu_plan_duration_seconds", metrics_path
|
|
435
|
+
):
|
|
436
|
+
_run_tofu(
|
|
437
|
+
[
|
|
438
|
+
"plan",
|
|
439
|
+
f"-var=deployment_name={cfg.deployment_name}",
|
|
440
|
+
f"-var=environment={cfg.environment}",
|
|
441
|
+
f"-var=region={cfg.app.region}",
|
|
442
|
+
"-out=tfplan",
|
|
443
|
+
],
|
|
444
|
+
cwd=deploy_dir,
|
|
445
|
+
)
|
|
446
|
+
console.print()
|
|
447
|
+
|
|
448
|
+
# Confirm before apply (user think-time intentionally excluded from phases).
|
|
449
|
+
# ``yes=True`` skips this gate for scripted invocations.
|
|
450
|
+
if not yes:
|
|
451
|
+
console.print(
|
|
452
|
+
"[bold yellow]Review the plan above.[/bold yellow] "
|
|
453
|
+
"Type 'yes' to apply: ",
|
|
454
|
+
end="",
|
|
455
|
+
)
|
|
456
|
+
answer = input()
|
|
457
|
+
if answer.strip().lower() != "yes":
|
|
458
|
+
console.print(
|
|
459
|
+
"[dim]Cancelled. No resources were created.[/dim]"
|
|
460
|
+
)
|
|
461
|
+
raise SystemExit(0)
|
|
462
|
+
console.print()
|
|
463
|
+
|
|
464
|
+
# OpenTofu apply
|
|
465
|
+
console.print("[bold]Step 3/3:[/bold] OpenTofu apply")
|
|
466
|
+
with phase_timer(
|
|
467
|
+
metrics, "allocator_tofu_apply_duration_seconds", metrics_path
|
|
468
|
+
):
|
|
469
|
+
_run_tofu(
|
|
470
|
+
["apply", "-auto-approve", "tfplan"], cwd=deploy_dir
|
|
471
|
+
)
|
|
472
|
+
console.print()
|
|
473
|
+
|
|
474
|
+
# Show outputs
|
|
475
|
+
console.print("[bold]Deployment complete![/bold]")
|
|
476
|
+
_run_tofu(
|
|
477
|
+
["output"], cwd=deploy_dir, check=False
|
|
478
|
+
)
|
|
479
|
+
console.print()
|
|
480
|
+
|
|
481
|
+
# --- Deployment timing ---
|
|
482
|
+
from lablink_cli.commands.status import run_status
|
|
483
|
+
from lablink_cli.commands.utils import TofuError, get_tofu_outputs
|
|
484
|
+
|
|
485
|
+
try:
|
|
486
|
+
outputs = get_tofu_outputs(deploy_dir)
|
|
487
|
+
except TofuError as e:
|
|
488
|
+
# apply succeeded, so this is a read fault, not a failed deploy.
|
|
489
|
+
console.print(
|
|
490
|
+
f" [yellow]Could not read outputs:[/yellow] {escape(str(e))}"
|
|
491
|
+
)
|
|
492
|
+
outputs = {}
|
|
493
|
+
ec2_ip = outputs.get("ec2_public_ip", "")
|
|
494
|
+
|
|
495
|
+
# Phase 1: Poll the allocator for readiness. URL and timeout depend
|
|
496
|
+
# on the SSL provider — see _build_health_poll_target for the
|
|
497
|
+
# rationale per provider.
|
|
498
|
+
if ec2_ip:
|
|
499
|
+
target = _build_health_poll_target(cfg, ec2_ip)
|
|
500
|
+
direct_url = target["url"]
|
|
501
|
+
max_wait = target["max_wait"]
|
|
502
|
+
console.print(
|
|
503
|
+
f"[bold]Waiting for allocator to become healthy"
|
|
504
|
+
f" at {direct_url} (up to {max_wait // 60} min)...[/bold]"
|
|
505
|
+
)
|
|
506
|
+
with phase_timer(
|
|
507
|
+
metrics,
|
|
508
|
+
"allocator_health_check_duration_seconds",
|
|
509
|
+
metrics_path,
|
|
510
|
+
):
|
|
511
|
+
poll_result = _poll_allocator_health(
|
|
512
|
+
direct_url, max_wait=max_wait
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
if poll_result["healthy"]:
|
|
516
|
+
console.print(
|
|
517
|
+
f"[green]Allocator healthy after"
|
|
518
|
+
f" {poll_result['elapsed']:.0f}s[/green]"
|
|
519
|
+
)
|
|
520
|
+
else:
|
|
521
|
+
console.print(
|
|
522
|
+
"[yellow]Timed out waiting for healthy status."
|
|
523
|
+
" Running status check anyway...[/yellow]"
|
|
524
|
+
)
|
|
525
|
+
else:
|
|
526
|
+
console.print(
|
|
527
|
+
"[yellow]No EC2 IP found in OpenTofu outputs."
|
|
528
|
+
" Skipping health check.[/yellow]"
|
|
529
|
+
)
|
|
530
|
+
poll_result = {"healthy": False, "elapsed": 0, "timed_out": True}
|
|
531
|
+
|
|
532
|
+
# Mark success and record total time (sum of timed phases — excludes
|
|
533
|
+
# user prompt time, which is the reproducible "machine work" measure).
|
|
534
|
+
deploy_end_dt = datetime.now(timezone.utc)
|
|
535
|
+
metrics.allocator_deploy_end_time = deploy_end_dt.isoformat()
|
|
536
|
+
metrics.allocator_total_deployment_duration_seconds = round(
|
|
537
|
+
sum(
|
|
538
|
+
v
|
|
539
|
+
for v in (
|
|
540
|
+
metrics.allocator_tofu_init_duration_seconds,
|
|
541
|
+
metrics.allocator_tofu_plan_duration_seconds,
|
|
542
|
+
metrics.allocator_tofu_apply_duration_seconds,
|
|
543
|
+
metrics.allocator_health_check_duration_seconds,
|
|
544
|
+
)
|
|
545
|
+
if v is not None
|
|
546
|
+
),
|
|
547
|
+
3,
|
|
548
|
+
)
|
|
549
|
+
metrics.status = "success"
|
|
550
|
+
write_metrics(metrics_path, metrics)
|
|
551
|
+
|
|
552
|
+
except Exception as e:
|
|
553
|
+
# Persist the failure so we have a record of what timed out / blew up.
|
|
554
|
+
# SystemExit (user cancellation, tofu exit code) is a BaseException
|
|
555
|
+
# subclass and intentionally NOT caught here — cancellation leaves the
|
|
556
|
+
# file in 'in_progress' state, which is correct semantics.
|
|
557
|
+
metrics.status = "failed"
|
|
558
|
+
metrics.error = str(e)
|
|
559
|
+
write_metrics(metrics_path, metrics)
|
|
560
|
+
raise
|
|
561
|
+
|
|
562
|
+
# Phase 2: If DNS/SSL configured, verify endpoint reachability
|
|
563
|
+
if cfg.dns.enabled and cfg.dns.domain and poll_result["healthy"]:
|
|
564
|
+
from lablink_cli.commands.status import check_http
|
|
565
|
+
|
|
566
|
+
scheme = "https" if has_ssl else "http"
|
|
567
|
+
endpoint_url = f"{scheme}://{cfg.dns.domain}"
|
|
568
|
+
console.print(
|
|
569
|
+
f"[bold]Checking endpoint reachability at"
|
|
570
|
+
f" {endpoint_url}...[/bold]"
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
dns_start = time.monotonic()
|
|
574
|
+
dns_max = 180 if has_ssl else 60
|
|
575
|
+
dns_elapsed = 0.0
|
|
576
|
+
|
|
577
|
+
while dns_elapsed < dns_max:
|
|
578
|
+
http_result = check_http(endpoint_url)
|
|
579
|
+
dns_elapsed = time.monotonic() - dns_start
|
|
580
|
+
if http_result["status"] == "pass":
|
|
581
|
+
console.print(
|
|
582
|
+
f"[green]Endpoint reachable after"
|
|
583
|
+
f" {dns_elapsed:.0f}s[/green]"
|
|
584
|
+
)
|
|
585
|
+
break
|
|
586
|
+
time.sleep(10)
|
|
587
|
+
dns_elapsed = time.monotonic() - dns_start
|
|
588
|
+
else:
|
|
589
|
+
console.print(
|
|
590
|
+
f"[yellow]Endpoint {endpoint_url} not yet"
|
|
591
|
+
f" reachable after {dns_elapsed:.0f}s."
|
|
592
|
+
f" DNS/SSL may still be propagating.[/yellow]"
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
console.print()
|
|
596
|
+
run_status(cfg)
|
|
597
|
+
console.print()
|
|
598
|
+
|
|
599
|
+
console.print(
|
|
600
|
+
f"[dim]Working directory:[/dim] {deploy_dir}"
|
|
601
|
+
)
|
|
602
|
+
console.print(
|
|
603
|
+
"[dim]To tear down:[/dim] [bold]lablink destroy[/bold]"
|
|
604
|
+
)
|
|
605
|
+
console.print(
|
|
606
|
+
"[dim]To export deployment metrics:[/dim] "
|
|
607
|
+
"[bold]lablink export-metrics --allocator[/bold]"
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _destroy_client_vms(
|
|
612
|
+
cfg: Config,
|
|
613
|
+
admin_user: str,
|
|
614
|
+
admin_pw: str,
|
|
615
|
+
*,
|
|
616
|
+
verbose: bool = False,
|
|
617
|
+
) -> None:
|
|
618
|
+
"""Destroy client VMs via the allocator API."""
|
|
619
|
+
allocator_url = get_allocator_url(cfg)
|
|
620
|
+
if not allocator_url:
|
|
621
|
+
console.print(
|
|
622
|
+
"[yellow]Could not determine allocator "
|
|
623
|
+
"URL — skipping client VM destroy.[/yellow]\n"
|
|
624
|
+
"Client VMs will be terminated when the "
|
|
625
|
+
"allocator is destroyed."
|
|
626
|
+
)
|
|
627
|
+
return
|
|
628
|
+
|
|
629
|
+
console.print(
|
|
630
|
+
"[bold]Destroying client VMs via "
|
|
631
|
+
"allocator...[/bold]"
|
|
632
|
+
)
|
|
633
|
+
console.print(
|
|
634
|
+
f" [dim]POST {allocator_url}/destroy[/dim]"
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
api = AllocatorAPI(
|
|
638
|
+
allocator_url, admin_user, admin_pw, cfg.ssl.provider
|
|
639
|
+
)
|
|
640
|
+
started = time.monotonic()
|
|
641
|
+
try:
|
|
642
|
+
with Progress(
|
|
643
|
+
SpinnerColumn(),
|
|
644
|
+
TextColumn("[progress.description]{task.description}"),
|
|
645
|
+
BarColumn(),
|
|
646
|
+
console=console,
|
|
647
|
+
transient=True,
|
|
648
|
+
) as progress:
|
|
649
|
+
task = progress.add_task(
|
|
650
|
+
" [bold]waiting for allocator...[/bold]", total=None,
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
def _on_progress(done, total):
|
|
654
|
+
if done is not None and total is not None:
|
|
655
|
+
progress.update(
|
|
656
|
+
task,
|
|
657
|
+
completed=done,
|
|
658
|
+
total=total,
|
|
659
|
+
description=(
|
|
660
|
+
f" [bold]waiting for allocator...[/bold] "
|
|
661
|
+
f"({done}/{total} resources)"
|
|
662
|
+
),
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
result = api.destroy_vms(on_progress=_on_progress)
|
|
666
|
+
elapsed = time.monotonic() - started
|
|
667
|
+
console.print(
|
|
668
|
+
f" [green]✓ client VMs destroyed[/green] "
|
|
669
|
+
f"[dim]({format_duration(elapsed)})[/dim]"
|
|
670
|
+
)
|
|
671
|
+
output = (result or {}).get("output", "")
|
|
672
|
+
summary = summarize_tofu(output)
|
|
673
|
+
if summary:
|
|
674
|
+
console.print(f" {summary}")
|
|
675
|
+
if verbose and output:
|
|
676
|
+
console.print()
|
|
677
|
+
console.print("[bold]Allocator's OpenTofu output:[/bold]")
|
|
678
|
+
console.print(output, markup=False)
|
|
679
|
+
elif output:
|
|
680
|
+
console.print(
|
|
681
|
+
" [dim]Pass --verbose to see full OpenTofu output.[/dim]"
|
|
682
|
+
)
|
|
683
|
+
except AllocatorAuthError:
|
|
684
|
+
console.print(
|
|
685
|
+
" [red]Authentication failed.[/red] "
|
|
686
|
+
"Check your admin credentials."
|
|
687
|
+
)
|
|
688
|
+
raise SystemExit(1)
|
|
689
|
+
except AllocatorNotFoundError:
|
|
690
|
+
console.print(
|
|
691
|
+
" [yellow]No client VMs were "
|
|
692
|
+
"launched.[/yellow] Skipping "
|
|
693
|
+
"client destroy."
|
|
694
|
+
)
|
|
695
|
+
console.print(
|
|
696
|
+
" Continuing with allocator "
|
|
697
|
+
"tofu destroy..."
|
|
698
|
+
)
|
|
699
|
+
except AllocatorUnavailableError as e:
|
|
700
|
+
console.print(
|
|
701
|
+
f" [yellow]Could not connect to "
|
|
702
|
+
f"allocator:[/yellow] {e}"
|
|
703
|
+
)
|
|
704
|
+
console.print(
|
|
705
|
+
" Continuing with allocator "
|
|
706
|
+
"tofu destroy..."
|
|
707
|
+
)
|
|
708
|
+
except AllocatorError as e:
|
|
709
|
+
console.print(
|
|
710
|
+
f" [red]Client destroy failed:[/red] {e}"
|
|
711
|
+
)
|
|
712
|
+
raise SystemExit(1)
|
|
713
|
+
|
|
714
|
+
console.print()
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _tofu_destroy(
|
|
718
|
+
deploy_dir: Path,
|
|
719
|
+
cfg: Config,
|
|
720
|
+
admin_user: str,
|
|
721
|
+
admin_pw: str,
|
|
722
|
+
*,
|
|
723
|
+
verbose: bool = False,
|
|
724
|
+
) -> None:
|
|
725
|
+
"""Refresh config, re-init OpenTofu, destroy, and clean up."""
|
|
726
|
+
import yaml
|
|
727
|
+
|
|
728
|
+
config_path = deploy_dir / "config" / "config.yaml"
|
|
729
|
+
cfg_dict = config_to_dict(cfg)
|
|
730
|
+
cfg_dict["app"]["admin_user"] = admin_user
|
|
731
|
+
cfg_dict["app"]["admin_password"] = admin_pw
|
|
732
|
+
cfg_dict["db"]["password"] = "DESTROY_PLACEHOLDER"
|
|
733
|
+
|
|
734
|
+
with open(config_path, "w") as f:
|
|
735
|
+
yaml.dump(
|
|
736
|
+
cfg_dict,
|
|
737
|
+
f,
|
|
738
|
+
default_flow_style=False,
|
|
739
|
+
sort_keys=False,
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
if (deploy_dir / "backend.tf").exists():
|
|
743
|
+
_tofu_init(deploy_dir, cfg)
|
|
744
|
+
|
|
745
|
+
console.print(
|
|
746
|
+
"[bold]Destroying allocator "
|
|
747
|
+
"infrastructure...[/bold]"
|
|
748
|
+
)
|
|
749
|
+
_run_tofu(
|
|
750
|
+
[
|
|
751
|
+
"destroy",
|
|
752
|
+
"-auto-approve",
|
|
753
|
+
f"-var=deployment_name={cfg.deployment_name}",
|
|
754
|
+
f"-var=environment={cfg.environment}",
|
|
755
|
+
f"-var=region={cfg.app.region}",
|
|
756
|
+
],
|
|
757
|
+
cwd=deploy_dir,
|
|
758
|
+
verbose=verbose,
|
|
759
|
+
)
|
|
760
|
+
console.print()
|
|
761
|
+
|
|
762
|
+
shutil.rmtree(deploy_dir)
|
|
763
|
+
console.print(
|
|
764
|
+
f" [green]cleaned[/green] {deploy_dir}"
|
|
765
|
+
)
|
|
766
|
+
console.print()
|
|
767
|
+
console.print("[bold]Infrastructure destroyed.[/bold]")
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def run_destroy(
|
|
771
|
+
cfg: Config, *, yes: bool = False, verbose: bool = False
|
|
772
|
+
) -> None:
|
|
773
|
+
"""Destroy LabLink infrastructure. ``yes=True`` skips confirmation prompts."""
|
|
774
|
+
check_credentials(_get_session(cfg.app.region))
|
|
775
|
+
|
|
776
|
+
deploy_dir = get_deploy_dir(cfg)
|
|
777
|
+
|
|
778
|
+
if not deploy_dir.exists():
|
|
779
|
+
console.print(
|
|
780
|
+
"[red]No deployment found.[/red] "
|
|
781
|
+
f"Expected working directory: {deploy_dir}"
|
|
782
|
+
)
|
|
783
|
+
raise SystemExit(1)
|
|
784
|
+
|
|
785
|
+
has_state = (
|
|
786
|
+
(deploy_dir / "terraform.tfstate").exists()
|
|
787
|
+
or (deploy_dir / ".terraform").exists()
|
|
788
|
+
)
|
|
789
|
+
if not has_state:
|
|
790
|
+
console.print(
|
|
791
|
+
"[red]No OpenTofu state found.[/red] "
|
|
792
|
+
"Nothing to destroy."
|
|
793
|
+
)
|
|
794
|
+
raise SystemExit(1)
|
|
795
|
+
|
|
796
|
+
console.print()
|
|
797
|
+
console.print(
|
|
798
|
+
Panel(
|
|
799
|
+
"[bold red]LabLink Destroy[/bold red]\n"
|
|
800
|
+
"This will tear down ALL LabLink infrastructure\n"
|
|
801
|
+
"(client VMs via allocator, then the allocator "
|
|
802
|
+
"itself via OpenTofu).\n"
|
|
803
|
+
f"Deployment: {cfg.deployment_name} | "
|
|
804
|
+
f"Environment: {cfg.environment}\n"
|
|
805
|
+
f"Region: {cfg.app.region} | State: S3 (remote)",
|
|
806
|
+
border_style="red",
|
|
807
|
+
)
|
|
808
|
+
)
|
|
809
|
+
console.print()
|
|
810
|
+
|
|
811
|
+
admin_user, admin_pw = resolve_admin_credentials(cfg)
|
|
812
|
+
|
|
813
|
+
if not yes:
|
|
814
|
+
console.print(
|
|
815
|
+
"[bold yellow]Are you sure?[/bold yellow] "
|
|
816
|
+
"Type 'yes' to confirm: ",
|
|
817
|
+
end="",
|
|
818
|
+
)
|
|
819
|
+
answer = input()
|
|
820
|
+
if answer.strip().lower() != "yes":
|
|
821
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
822
|
+
raise SystemExit(0)
|
|
823
|
+
console.print()
|
|
824
|
+
|
|
825
|
+
# Offer one last chance to export metrics — once destroy runs, the
|
|
826
|
+
# allocator's per-VM metrics are gone forever. Default = yes.
|
|
827
|
+
# Under ``yes=True`` we take that default without prompting.
|
|
828
|
+
if yes:
|
|
829
|
+
export_answer = ""
|
|
830
|
+
else:
|
|
831
|
+
console.print(
|
|
832
|
+
"[bold]Export metrics before destroying?[/bold] [Y/n]: ",
|
|
833
|
+
end="",
|
|
834
|
+
)
|
|
835
|
+
export_answer = input().strip().lower()
|
|
836
|
+
if export_answer in ("", "y", "yes"):
|
|
837
|
+
# Timestamped filename prevents overwriting prior exports when the
|
|
838
|
+
# same cwd is reused for multiple deployments. The absolute cwd is
|
|
839
|
+
# announced so files don't land "somewhere" invisibly.
|
|
840
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ")
|
|
841
|
+
output_base = f"metrics-{cfg.deployment_name}-{timestamp}.csv"
|
|
842
|
+
console.print(
|
|
843
|
+
f"[dim]Writing metrics to {Path.cwd().resolve()}/[/dim]"
|
|
844
|
+
)
|
|
845
|
+
# Catch both Exception and SystemExit — run_export_metrics raises
|
|
846
|
+
# SystemExit(1) on network/HTTP failures (it doubles as a CLI entry
|
|
847
|
+
# point), and we must not let that abort the destroy itself.
|
|
848
|
+
# KeyboardInterrupt is intentionally left uncaught so Ctrl-C aborts.
|
|
849
|
+
try:
|
|
850
|
+
run_export_metrics(
|
|
851
|
+
cfg, output=output_base, client=True, allocator=True
|
|
852
|
+
)
|
|
853
|
+
except (Exception, SystemExit) as e:
|
|
854
|
+
console.print(
|
|
855
|
+
f"[yellow]Export failed: {e}. "
|
|
856
|
+
f"Continuing with destroy...[/yellow]"
|
|
857
|
+
)
|
|
858
|
+
console.print()
|
|
859
|
+
|
|
860
|
+
_destroy_client_vms(cfg, admin_user, admin_pw, verbose=verbose)
|
|
861
|
+
_tofu_destroy(
|
|
862
|
+
deploy_dir, cfg, admin_user, admin_pw, verbose=verbose
|
|
863
|
+
)
|