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