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,244 @@
|
|
|
1
|
+
"""Export deployment metrics to CSV or JSON.
|
|
2
|
+
|
|
3
|
+
Two metric sources, selectable via flags:
|
|
4
|
+
|
|
5
|
+
* ``--client`` : per-VM client metrics fetched from the allocator's
|
|
6
|
+
``/api/export-metrics`` endpoint.
|
|
7
|
+
* ``--allocator`` : per-deploy allocator metrics from the local CLI cache
|
|
8
|
+
at ``~/.lablink/deployments/`` (issue #317), scoped to
|
|
9
|
+
the config's ``deployment_name``.
|
|
10
|
+
|
|
11
|
+
Default (no flag) exports both. ``--allocator`` alone never touches the
|
|
12
|
+
network — and loads no config, so it exports the cache unscoped — which is
|
|
13
|
+
what makes it work even after ``lablink destroy``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import csv
|
|
19
|
+
import json
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from urllib.error import HTTPError, URLError
|
|
22
|
+
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
|
|
25
|
+
from lablink_cli.api import authenticated_json_request
|
|
26
|
+
from lablink_cli.commands.utils import (
|
|
27
|
+
get_allocator_url,
|
|
28
|
+
print_admin_credentials_hint,
|
|
29
|
+
resolve_admin_credentials,
|
|
30
|
+
)
|
|
31
|
+
from lablink_cli.deployment_metrics import load_all_metrics
|
|
32
|
+
|
|
33
|
+
console = Console()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
VALID_FORMATS = ("csv", "json")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _suffixed_path(output_path: Path, suffix: str, fmt: str) -> Path:
|
|
40
|
+
"""Return ``output_path`` with ``suffix`` inserted before the extension.
|
|
41
|
+
|
|
42
|
+
Used when both ``--client`` and ``--allocator`` are set and the user's
|
|
43
|
+
``-o`` value acts as a base name — we write to ``{stem}_client.{fmt}``
|
|
44
|
+
and ``{stem}_allocator.{fmt}`` so both outputs are symmetrically named.
|
|
45
|
+
"""
|
|
46
|
+
return output_path.with_name(f"{output_path.stem}{suffix}.{fmt}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _export_client_metrics(
|
|
50
|
+
cfg,
|
|
51
|
+
output_path: Path,
|
|
52
|
+
fmt: str,
|
|
53
|
+
include_logs: bool,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Fetch per-VM metrics from the allocator and write to ``output_path``."""
|
|
56
|
+
allocator_url = get_allocator_url(cfg)
|
|
57
|
+
if not allocator_url:
|
|
58
|
+
console.print("[red]Could not determine allocator URL.[/red]")
|
|
59
|
+
raise SystemExit(1)
|
|
60
|
+
|
|
61
|
+
admin_user, admin_pw = resolve_admin_credentials(cfg)
|
|
62
|
+
|
|
63
|
+
logs_param = "true" if include_logs else "false"
|
|
64
|
+
url = f"{allocator_url}/api/export-metrics?include_logs={logs_param}"
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
body = authenticated_json_request(
|
|
68
|
+
url, admin_user, admin_pw, ssl_provider=cfg.ssl.provider
|
|
69
|
+
)
|
|
70
|
+
except HTTPError as e:
|
|
71
|
+
if e.code == 401:
|
|
72
|
+
console.print(
|
|
73
|
+
f"[red]The allocator rejected admin user "
|
|
74
|
+
f"'{admin_user}' (HTTP 401).[/red]"
|
|
75
|
+
)
|
|
76
|
+
print_admin_credentials_hint(cfg)
|
|
77
|
+
else:
|
|
78
|
+
console.print(f"[red]HTTP {e.code}: {e.reason}[/red]")
|
|
79
|
+
raise SystemExit(1) from e
|
|
80
|
+
except URLError as e:
|
|
81
|
+
console.print(f"[red]Connection error: {e.reason}[/red]")
|
|
82
|
+
raise SystemExit(1) from e
|
|
83
|
+
except json.JSONDecodeError as e:
|
|
84
|
+
console.print(
|
|
85
|
+
f"[red]Invalid JSON response from allocator: {e}[/red]"
|
|
86
|
+
)
|
|
87
|
+
raise SystemExit(1) from e
|
|
88
|
+
|
|
89
|
+
vms = body.get("vms", [])
|
|
90
|
+
if not vms:
|
|
91
|
+
console.print("[yellow]No VMs found to export.[/yellow]")
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
if fmt == "json":
|
|
95
|
+
with open(output_path, "w") as f:
|
|
96
|
+
json.dump(vms, f, indent=2)
|
|
97
|
+
else:
|
|
98
|
+
fieldnames = list(vms[0].keys())
|
|
99
|
+
with open(output_path, "w", newline="") as f:
|
|
100
|
+
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
|
101
|
+
writer.writeheader()
|
|
102
|
+
writer.writerows(vms)
|
|
103
|
+
|
|
104
|
+
console.print(
|
|
105
|
+
f"[green]Exported {len(vms)} VMs to {output_path}[/green]"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _export_allocator_metrics(
|
|
110
|
+
output_path: Path,
|
|
111
|
+
fmt: str,
|
|
112
|
+
deployment_name: str | None = None,
|
|
113
|
+
provider: str | None = None,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Read CLI-local allocator deployment cache and write to ``output_path``.
|
|
116
|
+
|
|
117
|
+
The cache at ``~/.lablink/deployments/`` is global — one record per deploy
|
|
118
|
+
attempt for every deployment the operator has ever run — so an unfiltered
|
|
119
|
+
export puts other deployments' rows in a file named for this one.
|
|
120
|
+
``provider`` is part of the scope because a name can be reused across
|
|
121
|
+
providers, and an AWS record's OpenTofu phase columns say nothing about a
|
|
122
|
+
compose stack. Records predating that field are AWS ones, hence the
|
|
123
|
+
``"aws"`` default.
|
|
124
|
+
|
|
125
|
+
Either filter as None means no config was loaded (``--allocator`` alone on
|
|
126
|
+
a machine that has none, which is what keeps it working after ``lablink
|
|
127
|
+
destroy``) and exports the whole cache.
|
|
128
|
+
|
|
129
|
+
Empty result → print a yellow notice and skip writing the file (don't
|
|
130
|
+
create a confusing zero-row CSV / empty-list JSON).
|
|
131
|
+
"""
|
|
132
|
+
records = [
|
|
133
|
+
r
|
|
134
|
+
for r in load_all_metrics()
|
|
135
|
+
if (not deployment_name or r.get("deployment_name") == deployment_name)
|
|
136
|
+
and (not provider or (r.get("provider") or "aws") == provider)
|
|
137
|
+
]
|
|
138
|
+
if not records:
|
|
139
|
+
scope = f" for '{deployment_name}'" if deployment_name else ""
|
|
140
|
+
console.print(
|
|
141
|
+
f"[yellow]No allocator deployment metrics{scope} in "
|
|
142
|
+
f"~/.lablink/deployments/. Run `lablink deploy` first.[/yellow]"
|
|
143
|
+
)
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
if fmt == "json":
|
|
147
|
+
with open(output_path, "w") as f:
|
|
148
|
+
json.dump(
|
|
149
|
+
{"allocator_metrics": records, "count": len(records)},
|
|
150
|
+
f,
|
|
151
|
+
indent=2,
|
|
152
|
+
)
|
|
153
|
+
else: # csv
|
|
154
|
+
# Union of keys across all records → stable header even when records
|
|
155
|
+
# have different optional fields populated (failed vs successful deploys).
|
|
156
|
+
fieldnames: list[str] = []
|
|
157
|
+
seen: set[str] = set()
|
|
158
|
+
for rec in records:
|
|
159
|
+
for k in rec:
|
|
160
|
+
if k not in seen:
|
|
161
|
+
seen.add(k)
|
|
162
|
+
fieldnames.append(k)
|
|
163
|
+
with open(output_path, "w", newline="") as f:
|
|
164
|
+
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
|
165
|
+
writer.writeheader()
|
|
166
|
+
writer.writerows(records)
|
|
167
|
+
|
|
168
|
+
console.print(
|
|
169
|
+
f"[green]Exported {len(records)} allocator deployment "
|
|
170
|
+
f"records to {output_path}[/green]"
|
|
171
|
+
)
|
|
172
|
+
names = {r.get("deployment_name") for r in records}
|
|
173
|
+
if deployment_name is None and len(names) > 1:
|
|
174
|
+
console.print(
|
|
175
|
+
f" [dim]Spanning {len(names)} deployments — no config to scope by."
|
|
176
|
+
f"[/dim]"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def run_export_metrics(
|
|
181
|
+
cfg,
|
|
182
|
+
output: str | None = None,
|
|
183
|
+
include_logs: bool = False,
|
|
184
|
+
format: str = "csv",
|
|
185
|
+
client: bool = False,
|
|
186
|
+
allocator: bool = False,
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Export client and/or allocator metrics.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
cfg: LabLink config (only required for ``client=True``; pass ``None``
|
|
192
|
+
when only ``allocator=True``).
|
|
193
|
+
output: Path for the output file(s). With a single flag, this is the
|
|
194
|
+
literal output path. With both flags, it's a **base** name: the
|
|
195
|
+
client file gets a ``_client`` suffix and the allocator file gets
|
|
196
|
+
an ``_allocator`` suffix inserted before the extension. If unset,
|
|
197
|
+
defaults to ``metrics_client.<fmt>`` and/or
|
|
198
|
+
``metrics_allocator.<fmt>`` in the current directory.
|
|
199
|
+
include_logs: For client metrics, include cloud_init / docker logs.
|
|
200
|
+
format: ``csv`` or ``json``.
|
|
201
|
+
client: Export per-VM metrics fetched from the allocator.
|
|
202
|
+
allocator: Export per-deploy metrics from the CLI-local cache,
|
|
203
|
+
scoped to ``cfg.deployment_name`` (whole cache when ``cfg`` is
|
|
204
|
+
None).
|
|
205
|
+
|
|
206
|
+
No flags → both (the common "give me everything" case).
|
|
207
|
+
"""
|
|
208
|
+
if format not in VALID_FORMATS:
|
|
209
|
+
console.print(
|
|
210
|
+
f"[red]Invalid format '{format}'. Must be one of: "
|
|
211
|
+
f"{', '.join(VALID_FORMATS)}[/red]"
|
|
212
|
+
)
|
|
213
|
+
raise SystemExit(1)
|
|
214
|
+
|
|
215
|
+
# Default: if neither flag is set, export both.
|
|
216
|
+
if not client and not allocator:
|
|
217
|
+
client = True
|
|
218
|
+
allocator = True
|
|
219
|
+
|
|
220
|
+
# Path resolution:
|
|
221
|
+
# - Single flag + no -o → metrics_{role}.{fmt} in cwd
|
|
222
|
+
# - Single flag + -o foo.x → foo.x (literal)
|
|
223
|
+
# - Both flags + no -o → metrics_client.{fmt} + metrics_allocator.{fmt}
|
|
224
|
+
# - Both flags + -o foo.x → foo_client.x + foo_allocator.x (base name)
|
|
225
|
+
both = client and allocator
|
|
226
|
+
|
|
227
|
+
def _path_for(role: str) -> Path:
|
|
228
|
+
if output is None:
|
|
229
|
+
return Path(f"metrics_{role}.{format}")
|
|
230
|
+
p = Path(output)
|
|
231
|
+
return _suffixed_path(p, f"_{role}", format) if both else p
|
|
232
|
+
|
|
233
|
+
if client:
|
|
234
|
+
_export_client_metrics(
|
|
235
|
+
cfg, _path_for("client"), format, include_logs
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
if allocator:
|
|
239
|
+
_export_allocator_metrics(
|
|
240
|
+
_path_for("allocator"),
|
|
241
|
+
format,
|
|
242
|
+
deployment_name=getattr(cfg, "deployment_name", None),
|
|
243
|
+
provider=getattr(cfg, "provider", None) if cfg else None,
|
|
244
|
+
)
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Launch and destroy client VMs via the allocator service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Callable, NoReturn
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
|
|
11
|
+
|
|
12
|
+
from lablink_allocator_service.conf.structured_config import Config
|
|
13
|
+
|
|
14
|
+
from lablink_cli.api import (
|
|
15
|
+
AllocatorAPI,
|
|
16
|
+
AllocatorAuthError,
|
|
17
|
+
AllocatorError,
|
|
18
|
+
AllocatorNotFoundError,
|
|
19
|
+
AllocatorUnavailableError,
|
|
20
|
+
)
|
|
21
|
+
from lablink_cli.commands.utils import (
|
|
22
|
+
format_duration,
|
|
23
|
+
get_allocator_url,
|
|
24
|
+
resolve_admin_credentials,
|
|
25
|
+
summarize_tofu,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
console = Console()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _resolve_api(cfg: Config) -> tuple[AllocatorAPI, str]:
|
|
32
|
+
"""Build an allocator client from config, prompting for credentials if
|
|
33
|
+
they were never saved.
|
|
34
|
+
|
|
35
|
+
Returns ``(api, allocator_url)``; exits 1 if the deployment's URL
|
|
36
|
+
cannot be determined.
|
|
37
|
+
"""
|
|
38
|
+
allocator_url = get_allocator_url(cfg)
|
|
39
|
+
if not allocator_url:
|
|
40
|
+
console.print(
|
|
41
|
+
"[red]Could not determine allocator URL.[/red]\n"
|
|
42
|
+
"Run 'lablink deploy' first or check 'lablink status'."
|
|
43
|
+
)
|
|
44
|
+
raise SystemExit(1)
|
|
45
|
+
|
|
46
|
+
admin_user, admin_pw = resolve_admin_credentials(cfg)
|
|
47
|
+
api = AllocatorAPI(allocator_url, admin_user, admin_pw, cfg.ssl.provider)
|
|
48
|
+
return api, allocator_url
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _run_fleet_op(
|
|
52
|
+
api_call: Callable[..., dict | None],
|
|
53
|
+
*,
|
|
54
|
+
description: str,
|
|
55
|
+
) -> tuple[dict | None, float]:
|
|
56
|
+
"""Run a client-fleet operation under a progress bar.
|
|
57
|
+
|
|
58
|
+
``api_call`` is invoked as ``api_call(on_progress=cb)``; the callback
|
|
59
|
+
updates the bar with the allocator's resource counts. The allocator
|
|
60
|
+
reports (None, None) if it predates progress reporting, in which case
|
|
61
|
+
the bar stays indeterminate rather than rendering a literal "None".
|
|
62
|
+
|
|
63
|
+
Returns ``(result, elapsed_seconds)``. Exceptions from ``api_call``
|
|
64
|
+
propagate untouched — callers map them via ``_exit_fleet_error``.
|
|
65
|
+
"""
|
|
66
|
+
started = time.monotonic()
|
|
67
|
+
with Progress(
|
|
68
|
+
SpinnerColumn(),
|
|
69
|
+
TextColumn("[progress.description]{task.description}"),
|
|
70
|
+
BarColumn(),
|
|
71
|
+
console=console,
|
|
72
|
+
transient=True,
|
|
73
|
+
) as progress:
|
|
74
|
+
task = progress.add_task(description, total=None)
|
|
75
|
+
|
|
76
|
+
def _on_progress(done, total):
|
|
77
|
+
if done is not None and total is not None:
|
|
78
|
+
progress.update(
|
|
79
|
+
task,
|
|
80
|
+
completed=done,
|
|
81
|
+
total=total,
|
|
82
|
+
description=f"{description} ({done}/{total} resources)",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
result = api_call(on_progress=_on_progress)
|
|
86
|
+
return result, time.monotonic() - started
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _report(
|
|
90
|
+
result: dict | None,
|
|
91
|
+
elapsed: float,
|
|
92
|
+
*,
|
|
93
|
+
label: str,
|
|
94
|
+
verbose: bool,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Print the success line and OpenTofu's resource summary, plus the
|
|
97
|
+
raw OpenTofu output under ``verbose``."""
|
|
98
|
+
output = (result or {}).get("output", "")
|
|
99
|
+
console.print(
|
|
100
|
+
f"[green]✓ {label}[/green] [dim]({format_duration(elapsed)})[/dim]"
|
|
101
|
+
)
|
|
102
|
+
summary = summarize_tofu(output)
|
|
103
|
+
if summary:
|
|
104
|
+
console.print(f" {summary}")
|
|
105
|
+
if verbose and output:
|
|
106
|
+
console.print()
|
|
107
|
+
console.print("[bold]OpenTofu output:[/bold]")
|
|
108
|
+
console.print(output, markup=False)
|
|
109
|
+
elif output:
|
|
110
|
+
console.print(
|
|
111
|
+
" [dim]Pass --verbose to see full OpenTofu output.[/dim]"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _exit_fleet_error(e: AllocatorError, *, label: str) -> NoReturn:
|
|
116
|
+
"""Report a failed fleet operation and exit 1.
|
|
117
|
+
|
|
118
|
+
Auth and connectivity failures get their own advice because the fix
|
|
119
|
+
differs. Everything else (409 already-in-progress, 405 unsupported,
|
|
120
|
+
poll timeout, HTTP 5xx) is reported verbatim under ``label`` — the
|
|
121
|
+
allocator's own message is more specific than anything we'd invent.
|
|
122
|
+
"""
|
|
123
|
+
if isinstance(e, AllocatorAuthError):
|
|
124
|
+
console.print(
|
|
125
|
+
"[red]Authentication failed.[/red] Check your admin credentials."
|
|
126
|
+
)
|
|
127
|
+
elif isinstance(e, AllocatorUnavailableError):
|
|
128
|
+
console.print(f"[red]Could not connect to allocator:[/red] {e}")
|
|
129
|
+
console.print(
|
|
130
|
+
" Check that the allocator is running with 'lablink status'."
|
|
131
|
+
)
|
|
132
|
+
else:
|
|
133
|
+
console.print(f"[red]{label}:[/red] {e}")
|
|
134
|
+
raise SystemExit(1)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def run_launch(cfg: Config, num_vms: int, *, verbose: bool = False) -> None:
|
|
138
|
+
"""Launch client VMs by calling the allocator /api/launch endpoint."""
|
|
139
|
+
if cfg.provider == "manual":
|
|
140
|
+
console.print(
|
|
141
|
+
"Manual provider has no VMs to launch — each BYO box "
|
|
142
|
+
"runs `lablink client register` to join the pool. See "
|
|
143
|
+
"`lablink status` for currently registered clients."
|
|
144
|
+
)
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
console.print()
|
|
148
|
+
api, allocator_url = _resolve_api(cfg)
|
|
149
|
+
|
|
150
|
+
console.print(f" [dim]POST {allocator_url}/api/launch[/dim]")
|
|
151
|
+
console.print()
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
result, elapsed = _run_fleet_op(
|
|
155
|
+
lambda on_progress: api.launch_vms(
|
|
156
|
+
num_vms, on_progress=on_progress
|
|
157
|
+
),
|
|
158
|
+
description=f"[bold]Launching {num_vms} client VM(s)...[/bold]",
|
|
159
|
+
)
|
|
160
|
+
_report(result, elapsed, label="Launch successful", verbose=verbose)
|
|
161
|
+
except AllocatorError as e:
|
|
162
|
+
_exit_fleet_error(e, label="Launch failed")
|
|
163
|
+
|
|
164
|
+
console.print()
|
|
165
|
+
console.print(
|
|
166
|
+
"[dim]Run 'lablink status' to see client VMs.[/dim]"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def run_client_destroy(
|
|
171
|
+
cfg: Config,
|
|
172
|
+
*,
|
|
173
|
+
yes: bool = False,
|
|
174
|
+
verbose: bool = False,
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Destroy every client VM by calling the allocator's /destroy endpoint.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
cfg: Loaded LabLink config.
|
|
180
|
+
yes: Skip the confirmation prompt.
|
|
181
|
+
verbose: Print the allocator's full OpenTofu output.
|
|
182
|
+
"""
|
|
183
|
+
if cfg.provider == "manual":
|
|
184
|
+
console.print(
|
|
185
|
+
"Manual provider has no VMs to destroy — each BYO box "
|
|
186
|
+
"leaves the pool by running `lablink client unregister` "
|
|
187
|
+
"on that box. See `lablink status` for currently "
|
|
188
|
+
"registered clients."
|
|
189
|
+
)
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
console.print()
|
|
193
|
+
api, allocator_url = _resolve_api(cfg)
|
|
194
|
+
|
|
195
|
+
if not yes:
|
|
196
|
+
console.print(
|
|
197
|
+
"[bold yellow]This destroys ALL client VMs[/bold yellow] and "
|
|
198
|
+
"clears the allocator's VM table — inventory, per-VM logs, and "
|
|
199
|
+
"session history go with them. Any user connected right now "
|
|
200
|
+
"loses their session."
|
|
201
|
+
)
|
|
202
|
+
console.print(
|
|
203
|
+
"[dim]Export first if you need the numbers: "
|
|
204
|
+
"lablink export-metrics --allocator[/dim]"
|
|
205
|
+
)
|
|
206
|
+
if not typer.confirm("Destroy all client VMs?", default=False):
|
|
207
|
+
console.print("Aborted.")
|
|
208
|
+
return
|
|
209
|
+
console.print()
|
|
210
|
+
|
|
211
|
+
console.print(f" [dim]POST {allocator_url}/destroy[/dim]")
|
|
212
|
+
console.print()
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
result, elapsed = _run_fleet_op(
|
|
216
|
+
lambda on_progress: api.destroy_vms(on_progress=on_progress),
|
|
217
|
+
description="[bold]Destroying client VMs...[/bold]",
|
|
218
|
+
)
|
|
219
|
+
_report(result, elapsed, label="client VMs destroyed", verbose=verbose)
|
|
220
|
+
except AllocatorNotFoundError:
|
|
221
|
+
# Nothing was ever launched, so there is nothing to tear down.
|
|
222
|
+
# Not a failure: the command is idempotent. Must precede the
|
|
223
|
+
# AllocatorError clause below — it is a subclass.
|
|
224
|
+
console.print(
|
|
225
|
+
"[yellow]No client VMs were launched — "
|
|
226
|
+
"nothing to destroy.[/yellow]"
|
|
227
|
+
)
|
|
228
|
+
return
|
|
229
|
+
except AllocatorError as e:
|
|
230
|
+
_exit_fleet_error(e, label="Client destroy failed")
|
|
231
|
+
|
|
232
|
+
console.print()
|
|
233
|
+
console.print(
|
|
234
|
+
"[dim]Run 'lablink client launch --num-vms N' to refill the "
|
|
235
|
+
"pool.[/dim]"
|
|
236
|
+
)
|