lablink-cli 0.1.0a1__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,350 @@
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.widgets import (
13
+ Footer,
14
+ Header,
15
+ Label,
16
+ ListItem,
17
+ ListView,
18
+ RichLog,
19
+ Static,
20
+ )
21
+
22
+ from lablink_allocator_service.conf.structured_config import Config
23
+
24
+
25
+ class VMListItem(ListItem):
26
+ """A list item representing a VM."""
27
+
28
+ def __init__(self, vm: dict) -> None:
29
+ self.vm = vm
30
+ vm_type = vm["vm_type"]
31
+ name = vm["name"]
32
+ state = vm["state"]
33
+ label = f"[{'cyan' if vm_type == 'allocator' else 'green'}]{vm_type}[/] {name}"
34
+ if state != "running":
35
+ label += f" [dim]({state})[/dim]"
36
+ super().__init__(Label(label, markup=True))
37
+
38
+
39
+ class LogsApp(App):
40
+ """Interactive log viewer for LabLink VMs."""
41
+
42
+ TITLE = "LabLink Log Viewer"
43
+
44
+ BINDINGS = [
45
+ Binding("q", "quit", "Quit"),
46
+ Binding("r", "refresh", "Refresh"),
47
+ Binding("1", "show_cloud_init", "Cloud-Init"),
48
+ Binding("2", "show_docker", "Docker"),
49
+ ]
50
+
51
+ CSS = """
52
+ #main-container {
53
+ height: 1fr;
54
+ }
55
+
56
+ #vm-list-panel {
57
+ width: 35;
58
+ min-width: 25;
59
+ border-right: solid $primary-lighten-2;
60
+ }
61
+
62
+ #vm-list-panel Label {
63
+ padding: 0 1;
64
+ color: $text-muted;
65
+ }
66
+
67
+ #vm-list {
68
+ height: 1fr;
69
+ }
70
+
71
+ #log-panel {
72
+ width: 1fr;
73
+ }
74
+
75
+ #vm-info {
76
+ padding: 0 1;
77
+ }
78
+
79
+ #tab-bar {
80
+ height: 1;
81
+ padding: 0 1;
82
+ }
83
+
84
+ .tab {
85
+ width: auto;
86
+ margin-right: 2;
87
+ }
88
+
89
+ .tab-active {
90
+ color: $accent;
91
+ text-style: bold underline;
92
+ }
93
+
94
+ .tab-inactive {
95
+ color: $text;
96
+ }
97
+
98
+ #log-output {
99
+ height: 1fr;
100
+ border-top: solid $primary-lighten-3;
101
+ }
102
+
103
+ #status-bar {
104
+ height: 1;
105
+ padding: 0 1;
106
+ color: $text-muted;
107
+ border-top: solid $primary-lighten-3;
108
+ }
109
+ """
110
+
111
+ def __init__(
112
+ self,
113
+ cfg: Config,
114
+ vms: list[dict],
115
+ allocator_url: str,
116
+ admin_user: str,
117
+ admin_pw: str,
118
+ deploy_dir: Path,
119
+ manual: bool = False,
120
+ ) -> None:
121
+ super().__init__()
122
+ self._cfg = cfg
123
+ self._vms = vms
124
+ self._allocator_url = allocator_url
125
+ self._admin_user = admin_user
126
+ self._admin_pw = admin_pw
127
+ self._deploy_dir = deploy_dir
128
+ self._manual = manual
129
+ self._selected_vm: dict | None = None
130
+ # Manual provider has no cloud-init concept (BYO hosts boot
131
+ # themselves); default to docker and skip the cloud-init tab UI.
132
+ self._current_tab = "docker" if manual else "cloud_init"
133
+ self._cached_logs: dict | None = None
134
+
135
+ def compose(self) -> ComposeResult:
136
+ yield Header()
137
+ with Horizontal(id="main-container"):
138
+ with Vertical(id="vm-list-panel"):
139
+ yield Label(
140
+ f"[bold]{self._cfg.deployment_name}[/bold] "
141
+ f"({self._cfg.environment})"
142
+ )
143
+ yield ListView(
144
+ *[VMListItem(vm) for vm in self._vms],
145
+ id="vm-list",
146
+ )
147
+ with Vertical(id="log-panel"):
148
+ yield Label("Select a VM to view logs", id="vm-info")
149
+ with Horizontal(id="tab-bar"):
150
+ if not self._manual:
151
+ yield Static(
152
+ "[bold]Cloud-Init[/bold]",
153
+ id="tab-cloud-init",
154
+ classes="tab tab-active",
155
+ markup=True,
156
+ )
157
+ yield Static(
158
+ "Docker",
159
+ id="tab-docker",
160
+ classes="tab tab-inactive",
161
+ markup=True,
162
+ )
163
+ else:
164
+ # Manual provider: only docker logs exist.
165
+ yield Static(
166
+ "[bold]Docker[/bold]",
167
+ id="tab-docker",
168
+ classes="tab tab-active",
169
+ markup=True,
170
+ )
171
+ yield RichLog(
172
+ id="log-output",
173
+ auto_scroll=True,
174
+ wrap=True,
175
+ markup=True,
176
+ )
177
+ yield Label("", id="status-bar")
178
+ yield Footer()
179
+
180
+ def on_mount(self) -> None:
181
+ """Show initial hint in the log panel."""
182
+ log_output = self.query_one("#log-output", RichLog)
183
+ log_output.write(
184
+ "[dim]Select a VM from the list to view its logs.[/dim]"
185
+ )
186
+
187
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
188
+ """Handle VM selection."""
189
+ item = event.item
190
+ if isinstance(item, VMListItem):
191
+ self._selected_vm = item.vm
192
+ self._cached_logs = None
193
+ self._update_vm_info()
194
+ # Clear log panel immediately and show loading state
195
+ log_output = self.query_one("#log-output", RichLog)
196
+ log_output.clear()
197
+ log_output.write("[dim]Fetching logs...[/dim]")
198
+ self._set_status("Fetching logs...")
199
+ self._fetch_logs()
200
+
201
+ def _update_vm_info(self) -> None:
202
+ """Update the VM info label."""
203
+ vm = self._selected_vm
204
+ if not vm:
205
+ return
206
+ info = self.query_one("#vm-info", Label)
207
+ ip_display = vm.get("public_ip", "—")
208
+ info.update(
209
+ f"[bold]{vm['name']}[/bold] "
210
+ f"({vm['type']}) "
211
+ f"IP: {ip_display}"
212
+ )
213
+
214
+ def _update_tab_styles(self) -> None:
215
+ """Update tab visual state."""
216
+ if self._manual:
217
+ # Manual mode renders only the docker tab; nothing to swap.
218
+ return
219
+ cloud_init_tab = self.query_one("#tab-cloud-init", Static)
220
+ docker_tab = self.query_one("#tab-docker", Static)
221
+ if self._current_tab == "cloud_init":
222
+ cloud_init_tab.update("[bold]Cloud-Init[/bold]")
223
+ cloud_init_tab.set_classes("tab tab-active")
224
+ docker_tab.update("Docker")
225
+ docker_tab.set_classes("tab tab-inactive")
226
+ else:
227
+ cloud_init_tab.update("Cloud-Init")
228
+ cloud_init_tab.set_classes("tab tab-inactive")
229
+ docker_tab.update("[bold]Docker[/bold]")
230
+ docker_tab.set_classes("tab tab-active")
231
+
232
+ _MAX_LOG_LINES = 5000
233
+
234
+ def _display_logs(self) -> None:
235
+ """Display logs from cache for the current tab."""
236
+ log_output = self.query_one("#log-output", RichLog)
237
+ log_output.clear()
238
+
239
+ if self._cached_logs is None:
240
+ log_output.write("[dim]No logs loaded. Select a VM.[/dim]")
241
+ return
242
+
243
+ error = self._cached_logs.get("error")
244
+ if error:
245
+ log_output.write(f"[red]{error}[/red]")
246
+ return
247
+
248
+ if self._current_tab == "cloud_init":
249
+ content = self._cached_logs.get("cloud_init_logs")
250
+ else:
251
+ content = self._cached_logs.get("docker_logs")
252
+
253
+ if content:
254
+ lines = content.splitlines()
255
+ if len(lines) > self._MAX_LOG_LINES:
256
+ skipped = len(lines) - self._MAX_LOG_LINES
257
+ log_output.write(
258
+ f"[dim]... {skipped} earlier lines truncated ...[/dim]"
259
+ )
260
+ lines = lines[-self._MAX_LOG_LINES :]
261
+ for line in lines:
262
+ log_output.write(line)
263
+ else:
264
+ tab_name = (
265
+ "Cloud-Init" if self._current_tab == "cloud_init" else "Docker"
266
+ )
267
+ log_output.write(
268
+ f"[dim]No {tab_name} logs available for this VM.[/dim]"
269
+ )
270
+
271
+ @work(thread=True, exclusive=True)
272
+ def _fetch_logs(self) -> None:
273
+ """Fetch logs in a background thread."""
274
+ vm = self._selected_vm
275
+ if not vm:
276
+ return
277
+
278
+ if vm["vm_type"] == "client":
279
+ if not self._allocator_url:
280
+ logs = {
281
+ "cloud_init_logs": None,
282
+ "docker_logs": None,
283
+ "error": (
284
+ "Allocator URL not available. "
285
+ "Cannot fetch client logs."
286
+ ),
287
+ }
288
+ else:
289
+ from lablink_cli.commands.logs import fetch_client_logs
290
+
291
+ logs = fetch_client_logs(
292
+ allocator_url=self._allocator_url,
293
+ hostname=vm["name"],
294
+ admin_user=self._admin_user,
295
+ admin_pw=self._admin_pw,
296
+ ssl_provider=self._cfg.ssl.provider,
297
+ )
298
+ else:
299
+ # Manual provider's allocator is a local docker container, not
300
+ # an EC2 instance — bypass SSH and read `docker logs` directly.
301
+ if self._manual:
302
+ from lablink_cli.commands.logs import (
303
+ fetch_manual_allocator_logs,
304
+ )
305
+
306
+ logs = fetch_manual_allocator_logs()
307
+ else:
308
+ from lablink_cli.commands.logs import fetch_allocator_logs
309
+
310
+ logs = fetch_allocator_logs(
311
+ instance_id=vm["instance_id"],
312
+ public_ip=vm.get("public_ip", "—"),
313
+ region=self._cfg.app.region,
314
+ deploy_dir=self._deploy_dir,
315
+ )
316
+
317
+ # Guard: discard results if user selected a different VM while fetching
318
+ if self._selected_vm is not vm:
319
+ return
320
+
321
+ self._cached_logs = logs
322
+ now = datetime.now().strftime("%H:%M:%S")
323
+ self.call_from_thread(self._display_logs)
324
+ self.call_from_thread(self._set_status, f"Last fetched: {now}")
325
+
326
+ def _set_status(self, text: str) -> None:
327
+ """Update the status bar."""
328
+ status = self.query_one("#status-bar", Label)
329
+ status.update(f"[dim]{text}[/dim]")
330
+
331
+ def action_refresh(self) -> None:
332
+ """Refresh logs for the selected VM."""
333
+ if self._selected_vm:
334
+ self._fetch_logs()
335
+
336
+ def action_show_cloud_init(self) -> None:
337
+ """Switch to cloud-init log tab."""
338
+ if self._manual:
339
+ # No cloud-init for manual provider; binding is a no-op.
340
+ return
341
+ self._current_tab = "cloud_init"
342
+ self._update_tab_styles()
343
+ self._display_logs()
344
+
345
+ def action_show_docker(self) -> None:
346
+ """Switch to docker log tab."""
347
+ self._current_tab = "docker"
348
+ self._update_tab_styles()
349
+ self._display_logs()
350
+