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,169 @@
1
+ """Download and cache OpenTofu files from lablink-template releases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import io
7
+ import shutil
8
+ import tarfile
9
+ import tempfile
10
+ import time
11
+ from pathlib import Path
12
+ from urllib.error import HTTPError, URLError
13
+ from urllib.request import urlopen
14
+
15
+ from rich.console import Console
16
+
17
+ from lablink_cli import TEMPLATE_REPO, TEMPLATE_SHA256
18
+
19
+ console = Console()
20
+
21
+ CACHE_DIR = Path.home() / ".lablink" / "cache" / "terraform"
22
+
23
+ _ALLOWED_EXTENSIONS = {".tf", ".hcl", ".sh", ".yaml"}
24
+
25
+
26
+ def get_tofu_files(
27
+ version: str,
28
+ *,
29
+ bundle_path: str | None = None,
30
+ skip_checksum: bool = False,
31
+ ) -> Path:
32
+ """Return path to cached OpenTofu files, downloading if needed.
33
+
34
+ Args:
35
+ version: Git tag in the template repo (e.g. "v0.1.0").
36
+ bundle_path: Path to a local tarball (offline mode). Skips download.
37
+ skip_checksum: If True, skip SHA-256 verification.
38
+
39
+ Returns:
40
+ Path to directory containing .tf files ready for use.
41
+ """
42
+ cache_path = CACHE_DIR / version
43
+
44
+ # Cache hit — return immediately
45
+ if cache_path.exists() and any(cache_path.glob("*.tf")):
46
+ return cache_path
47
+
48
+ # Get tarball bytes
49
+ if bundle_path:
50
+ console.print(f" Using local bundle: {bundle_path}")
51
+ tarball_data = Path(bundle_path).read_bytes()
52
+ else:
53
+ console.print(
54
+ f" Downloading infrastructure templates {version}... ",
55
+ end="",
56
+ )
57
+ tarball_data = _download_tarball(version, retries=3)
58
+ console.print("done.")
59
+
60
+ # Verify checksum
61
+ if not skip_checksum:
62
+ _verify_checksum(tarball_data, version)
63
+
64
+ # Extract to temp dir, then atomic rename to cache
65
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
66
+ tmp_dir = Path(tempfile.mkdtemp(
67
+ dir=CACHE_DIR.parent, prefix=".terraform-extract-"
68
+ ))
69
+ try:
70
+ _extract_tofu_files(tarball_data, tmp_dir)
71
+
72
+ # Atomic move to final cache location
73
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
74
+ if cache_path.exists():
75
+ # Another process may have created it concurrently
76
+ return cache_path
77
+ tmp_dir.rename(cache_path)
78
+ except Exception:
79
+ # Clean up temp dir on failure
80
+ shutil.rmtree(tmp_dir, ignore_errors=True)
81
+ raise
82
+
83
+ return cache_path
84
+
85
+
86
+ def _download_tarball(version: str, retries: int = 3) -> bytes:
87
+ """Download the release tarball from GitHub with retries."""
88
+ url = (
89
+ f"https://github.com/{TEMPLATE_REPO}"
90
+ f"/archive/refs/tags/{version}.tar.gz"
91
+ )
92
+
93
+ last_error: Exception | None = None
94
+ for attempt in range(retries):
95
+ try:
96
+ with urlopen(url, timeout=60) as resp: # noqa: S310
97
+ return resp.read()
98
+ except (HTTPError, URLError, OSError) as e:
99
+ last_error = e
100
+ if attempt < retries - 1:
101
+ time.sleep(2 ** attempt)
102
+
103
+ console.print(
104
+ f"\n\n [red]Failed to download templates after "
105
+ f"{retries} attempts.[/red]\n"
106
+ f" URL: {url}\n"
107
+ f" Error: {last_error}\n\n"
108
+ f" [bold]Workarounds:[/bold]\n"
109
+ f" 1. Check your internet connection\n"
110
+ f" 2. If behind a proxy, set HTTPS_PROXY\n"
111
+ f" 3. Download manually and use:\n"
112
+ f" lablink deploy --terraform-bundle /path/to/tarball.tar.gz\n"
113
+ )
114
+ raise SystemExit(1)
115
+
116
+
117
+ def _verify_checksum(data: bytes, version: str) -> None:
118
+ """Verify SHA-256 checksum of the downloaded tarball."""
119
+ actual = hashlib.sha256(data).hexdigest()
120
+ if actual != TEMPLATE_SHA256:
121
+ console.print(
122
+ f"\n [red]Checksum mismatch for {version}![/red]\n"
123
+ f" Expected: {TEMPLATE_SHA256}\n"
124
+ f" Got: {actual}\n"
125
+ f" The download may be corrupted or tampered with.\n"
126
+ )
127
+ raise SystemExit(1)
128
+
129
+
130
+ def _extract_tofu_files(data: bytes, dest: Path) -> None:
131
+ """Extract OpenTofu files from tarball with safety checks."""
132
+ dest.mkdir(parents=True, exist_ok=True)
133
+
134
+ with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
135
+ for member in tar.getmembers():
136
+ # Find the lablink-infrastructure/ prefix
137
+ parts = Path(member.name).parts
138
+ try:
139
+ infra_idx = parts.index("lablink-infrastructure")
140
+ except ValueError:
141
+ continue
142
+
143
+ # Get the relative path within lablink-infrastructure/
144
+ rel_parts = parts[infra_idx + 1 :]
145
+ if not rel_parts:
146
+ continue
147
+
148
+ rel_path = Path(*rel_parts)
149
+
150
+ # Security: reject path traversal
151
+ if ".." in rel_parts:
152
+ continue
153
+
154
+ # Security: only allow whitelisted extensions
155
+ suffix = rel_path.suffix
156
+ if suffix and suffix not in _ALLOWED_EXTENSIONS:
157
+ continue
158
+
159
+ # Skip directories (we create them as needed)
160
+ if member.isdir():
161
+ continue
162
+
163
+ # Extract to dest
164
+ out_path = dest / rel_path
165
+ out_path.parent.mkdir(parents=True, exist_ok=True)
166
+
167
+ fileobj = tar.extractfile(member)
168
+ if fileobj:
169
+ out_path.write_bytes(fileobj.read())
File without changes
@@ -0,0 +1,413 @@
1
+ """Textual TUI for viewing LabLink VM logs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+
8
+ from textual import work
9
+ from textual.app import App, ComposeResult
10
+ from textual.binding import Binding
11
+ from textual.containers import Horizontal, Vertical
12
+ from textual.timer import Timer
13
+ from textual.widgets import (
14
+ Footer,
15
+ Header,
16
+ Label,
17
+ ListItem,
18
+ ListView,
19
+ RichLog,
20
+ Static,
21
+ )
22
+
23
+ from lablink_allocator_service.conf.structured_config import Config
24
+
25
+ # Self-clocking; see _schedule_next_fetch.
26
+ _AUTO_REFRESH_SECONDS = 5
27
+
28
+
29
+ class VMListItem(ListItem):
30
+ """A list item representing a VM."""
31
+
32
+ def __init__(self, vm: dict) -> None:
33
+ self.vm = vm
34
+ vm_type = vm["vm_type"]
35
+ name = vm["name"]
36
+ state = vm["state"]
37
+ label = f"[{'cyan' if vm_type == 'allocator' else 'green'}]{vm_type}[/] {name}"
38
+ if state != "running":
39
+ label += f" [dim]({state})[/dim]"
40
+ super().__init__(Label(label, markup=True))
41
+
42
+
43
+ class LogsApp(App):
44
+ """Interactive log viewer for LabLink VMs."""
45
+
46
+ TITLE = "LabLink Log Viewer"
47
+
48
+ BINDINGS = [
49
+ Binding("q", "quit", "Quit"),
50
+ Binding("r", "refresh", "Refresh"),
51
+ Binding("a", "toggle_auto", "Auto-fetch"),
52
+ Binding("1", "show_cloud_init", "Cloud-Init"),
53
+ Binding("2", "show_docker", "Docker"),
54
+ ]
55
+
56
+ CSS = """
57
+ #main-container {
58
+ height: 1fr;
59
+ }
60
+
61
+ #vm-list-panel {
62
+ width: 35;
63
+ min-width: 25;
64
+ border-right: solid $primary-lighten-2;
65
+ }
66
+
67
+ #vm-list-panel Label {
68
+ padding: 0 1;
69
+ color: $text-muted;
70
+ }
71
+
72
+ #vm-list {
73
+ height: 1fr;
74
+ }
75
+
76
+ #log-panel {
77
+ width: 1fr;
78
+ }
79
+
80
+ #vm-info {
81
+ padding: 0 1;
82
+ }
83
+
84
+ #tab-bar {
85
+ height: 1;
86
+ padding: 0 1;
87
+ }
88
+
89
+ .tab {
90
+ width: auto;
91
+ margin-right: 2;
92
+ }
93
+
94
+ .tab-active {
95
+ color: $accent;
96
+ text-style: bold underline;
97
+ }
98
+
99
+ .tab-inactive {
100
+ color: $text;
101
+ }
102
+
103
+ #log-output {
104
+ height: 1fr;
105
+ border-top: solid $primary-lighten-3;
106
+ }
107
+
108
+ /* height must cover the border AND a content row: Textual sizes with
109
+ border-box, so `height: 1` here spent its only row on border-top and
110
+ left the text zero rows to render in. */
111
+ #status-bar {
112
+ height: 2;
113
+ width: 1fr;
114
+ padding: 0 1;
115
+ color: $text-muted;
116
+ border-top: solid $primary-lighten-3;
117
+ }
118
+ """
119
+
120
+ def __init__(
121
+ self,
122
+ cfg: Config,
123
+ vms: list[dict],
124
+ allocator_url: str,
125
+ admin_user: str,
126
+ admin_pw: str,
127
+ deploy_dir: Path,
128
+ manual: bool = False,
129
+ ) -> None:
130
+ super().__init__()
131
+ self._cfg = cfg
132
+ self._vms = vms
133
+ self._allocator_url = allocator_url
134
+ self._admin_user = admin_user
135
+ self._admin_pw = admin_pw
136
+ self._deploy_dir = deploy_dir
137
+ self._manual = manual
138
+ self._selected_vm: dict | None = None
139
+ # Manual provider has no cloud-init concept (BYO hosts boot
140
+ # themselves); default to docker and skip the cloud-init tab UI.
141
+ self._current_tab = "docker" if manual else "cloud_init"
142
+ self._cached_logs: dict | None = None
143
+ self._auto = True
144
+ self._auto_timer: Timer | None = None
145
+ self._last_fetched: str | None = None
146
+
147
+ def compose(self) -> ComposeResult:
148
+ yield Header()
149
+ with Horizontal(id="main-container"):
150
+ with Vertical(id="vm-list-panel"):
151
+ yield Label(
152
+ f"[bold]{self._cfg.deployment_name}[/bold] "
153
+ f"({self._cfg.environment})"
154
+ )
155
+ yield ListView(
156
+ *[VMListItem(vm) for vm in self._vms],
157
+ id="vm-list",
158
+ )
159
+ with Vertical(id="log-panel"):
160
+ yield Label("Select a VM to view logs", id="vm-info")
161
+ with Horizontal(id="tab-bar"):
162
+ if not self._manual:
163
+ yield Static(
164
+ "[bold]Cloud-Init[/bold]",
165
+ id="tab-cloud-init",
166
+ classes="tab tab-active",
167
+ markup=True,
168
+ )
169
+ yield Static(
170
+ "Docker",
171
+ id="tab-docker",
172
+ classes="tab tab-inactive",
173
+ markup=True,
174
+ )
175
+ else:
176
+ # Manual provider: only docker logs exist.
177
+ yield Static(
178
+ "[bold]Docker[/bold]",
179
+ id="tab-docker",
180
+ classes="tab tab-active",
181
+ markup=True,
182
+ )
183
+ yield RichLog(
184
+ id="log-output",
185
+ auto_scroll=True,
186
+ wrap=True,
187
+ markup=True,
188
+ )
189
+ yield Label("", id="status-bar")
190
+ yield Footer()
191
+
192
+ def on_mount(self) -> None:
193
+ """Show initial hint in the log panel."""
194
+ log_output = self.query_one("#log-output", RichLog)
195
+ log_output.write(
196
+ "[dim]Select a VM from the list to view its logs.[/dim]"
197
+ )
198
+ # Show cadence before the first tick.
199
+ self._refresh_status()
200
+
201
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
202
+ """Handle VM selection."""
203
+ item = event.item
204
+ if isinstance(item, VMListItem):
205
+ self._selected_vm = item.vm
206
+ self._cached_logs = None
207
+ # Drop the tick queued for the previous VM; the fetch below
208
+ # re-arms the timer against the new selection.
209
+ self._cancel_auto_timer()
210
+ self._update_vm_info()
211
+ # Clear log panel immediately and show loading state
212
+ log_output = self.query_one("#log-output", RichLog)
213
+ log_output.clear()
214
+ log_output.write("[dim]Fetching logs...[/dim]")
215
+ self._set_status("Fetching logs...")
216
+ self._fetch_logs()
217
+
218
+ def _update_vm_info(self) -> None:
219
+ """Update the VM info label."""
220
+ vm = self._selected_vm
221
+ if not vm:
222
+ return
223
+ info = self.query_one("#vm-info", Label)
224
+ ip_display = vm.get("public_ip", "—")
225
+ info.update(
226
+ f"[bold]{vm['name']}[/bold] "
227
+ f"({vm['type']}) "
228
+ f"IP: {ip_display}"
229
+ )
230
+
231
+ def _update_tab_styles(self) -> None:
232
+ """Update tab visual state."""
233
+ if self._manual:
234
+ # Manual mode renders only the docker tab; nothing to swap.
235
+ return
236
+ cloud_init_tab = self.query_one("#tab-cloud-init", Static)
237
+ docker_tab = self.query_one("#tab-docker", Static)
238
+ if self._current_tab == "cloud_init":
239
+ cloud_init_tab.update("[bold]Cloud-Init[/bold]")
240
+ cloud_init_tab.set_classes("tab tab-active")
241
+ docker_tab.update("Docker")
242
+ docker_tab.set_classes("tab tab-inactive")
243
+ else:
244
+ cloud_init_tab.update("Cloud-Init")
245
+ cloud_init_tab.set_classes("tab tab-inactive")
246
+ docker_tab.update("[bold]Docker[/bold]")
247
+ docker_tab.set_classes("tab tab-active")
248
+
249
+ _MAX_LOG_LINES = 5000
250
+
251
+ def _display_logs(self) -> None:
252
+ """Display logs from cache for the current tab."""
253
+ log_output = self.query_one("#log-output", RichLog)
254
+ log_output.clear()
255
+
256
+ if self._cached_logs is None:
257
+ log_output.write("[dim]No logs loaded. Select a VM.[/dim]")
258
+ return
259
+
260
+ error = self._cached_logs.get("error")
261
+ if error:
262
+ log_output.write(f"[red]{error}[/red]")
263
+ return
264
+
265
+ if self._current_tab == "cloud_init":
266
+ content = self._cached_logs.get("cloud_init_logs")
267
+ else:
268
+ content = self._cached_logs.get("docker_logs")
269
+
270
+ if content:
271
+ lines = content.splitlines()
272
+ if len(lines) > self._MAX_LOG_LINES:
273
+ skipped = len(lines) - self._MAX_LOG_LINES
274
+ log_output.write(
275
+ f"[dim]... {skipped} earlier lines truncated ...[/dim]"
276
+ )
277
+ lines = lines[-self._MAX_LOG_LINES :]
278
+ for line in lines:
279
+ log_output.write(line)
280
+ else:
281
+ tab_name = (
282
+ "Cloud-Init" if self._current_tab == "cloud_init" else "Docker"
283
+ )
284
+ log_output.write(
285
+ f"[dim]No {tab_name} logs available for this VM.[/dim]"
286
+ )
287
+
288
+ @work(thread=True, exclusive=True)
289
+ def _fetch_logs(self) -> None:
290
+ """Fetch logs in a background thread."""
291
+ vm = self._selected_vm
292
+ if not vm:
293
+ return
294
+
295
+ try:
296
+ if vm["vm_type"] == "client":
297
+ if not self._allocator_url:
298
+ logs = {
299
+ "cloud_init_logs": None,
300
+ "docker_logs": None,
301
+ "error": (
302
+ "Allocator URL not available. "
303
+ "Cannot fetch client logs."
304
+ ),
305
+ }
306
+ else:
307
+ from lablink_cli.commands.logs import fetch_client_logs
308
+
309
+ logs = fetch_client_logs(
310
+ allocator_url=self._allocator_url,
311
+ hostname=vm["name"],
312
+ admin_user=self._admin_user,
313
+ admin_pw=self._admin_pw,
314
+ ssl_provider=self._cfg.ssl.provider,
315
+ )
316
+ else:
317
+ # Manual provider's allocator is a local docker container, not
318
+ # an EC2 instance — bypass SSH and read `docker logs` directly.
319
+ if self._manual:
320
+ from lablink_cli.commands.logs import (
321
+ fetch_manual_allocator_logs,
322
+ )
323
+
324
+ logs = fetch_manual_allocator_logs()
325
+ else:
326
+ from lablink_cli.commands.logs import fetch_allocator_logs
327
+
328
+ logs = fetch_allocator_logs(
329
+ instance_id=vm["instance_id"],
330
+ public_ip=vm.get("public_ip", "—"),
331
+ region=self._cfg.app.region,
332
+ deploy_dir=self._deploy_dir,
333
+ )
334
+
335
+ # Guard: discard results if user selected a different VM while
336
+ # fetching
337
+ if self._selected_vm is not vm:
338
+ return
339
+
340
+ # Repaint only on change. _display_logs clears and rewrites the
341
+ # RichLog, and auto_scroll drags the view to the bottom — so an
342
+ # unconditional repaint every tick would yank you off whatever you
343
+ # had scrolled up to read. Most ticks find nothing new.
344
+ unchanged = logs == self._cached_logs
345
+ self._cached_logs = logs
346
+ self._last_fetched = datetime.now().strftime("%H:%M:%S")
347
+ if not unchanged:
348
+ self.call_from_thread(self._display_logs)
349
+ self.call_from_thread(self._refresh_status)
350
+ finally:
351
+ # Re-arm even if the fetch raised, or one transient error would
352
+ # kill auto-fetch for the rest of the session.
353
+ self.call_from_thread(self._schedule_next_fetch)
354
+
355
+ def _cancel_auto_timer(self) -> None:
356
+ """Drop any queued auto-fetch tick."""
357
+ if self._auto_timer is not None:
358
+ self._auto_timer.stop()
359
+ self._auto_timer = None
360
+
361
+ def _schedule_next_fetch(self) -> None:
362
+ """Arm the next tick. Cancels first, so at most one is outstanding.
363
+
364
+ Self-clocking rather than ``set_interval``: armed only once the
365
+ previous fetch settles, so fetches cannot overlap and a slow SSH
366
+ round-trip backs the cadence off instead of stacking up.
367
+ """
368
+ self._cancel_auto_timer()
369
+ if self._auto and self._selected_vm:
370
+ self._auto_timer = self.set_timer(
371
+ _AUTO_REFRESH_SECONDS, self._fetch_logs
372
+ )
373
+
374
+ def _set_status(self, text: str) -> None:
375
+ """Update the status bar."""
376
+ status = self.query_one("#status-bar", Label)
377
+ status.update(f"[dim]{text}[/dim]")
378
+
379
+ def _refresh_status(self) -> None:
380
+ """Redraw the status bar. State leads so truncation eats the clock."""
381
+ auto = f"auto {_AUTO_REFRESH_SECONDS}s" if self._auto else "auto off"
382
+ self._set_status(f"{auto} · last fetched {self._last_fetched or '—'}")
383
+
384
+ def action_refresh(self) -> None:
385
+ """Refresh logs for the selected VM."""
386
+ if self._selected_vm:
387
+ self._fetch_logs()
388
+
389
+ def action_toggle_auto(self) -> None:
390
+ """Turn auto-fetch on or off."""
391
+ self._auto = not self._auto
392
+ if self._auto:
393
+ # Fetch now; completing that fetch arms the timer.
394
+ self._fetch_logs()
395
+ else:
396
+ self._cancel_auto_timer()
397
+ self._refresh_status()
398
+
399
+ def action_show_cloud_init(self) -> None:
400
+ """Switch to cloud-init log tab."""
401
+ if self._manual:
402
+ # No cloud-init for manual provider; binding is a no-op.
403
+ return
404
+ self._current_tab = "cloud_init"
405
+ self._update_tab_styles()
406
+ self._display_logs()
407
+
408
+ def action_show_docker(self) -> None:
409
+ """Switch to docker log tab."""
410
+ self._current_tab = "docker"
411
+ self._update_tab_styles()
412
+ self._display_logs()
413
+