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