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,839 @@
|
|
|
1
|
+
"""`lablink client register` — register a BYO box as a manual client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from urllib.parse import urlparse
|
|
13
|
+
|
|
14
|
+
import psutil
|
|
15
|
+
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
|
|
18
|
+
from lablink_cli import byo_detect
|
|
19
|
+
from lablink_cli.docker import (
|
|
20
|
+
Docker,
|
|
21
|
+
DockerDaemonError,
|
|
22
|
+
DockerUnavailable,
|
|
23
|
+
default_docker,
|
|
24
|
+
)
|
|
25
|
+
from lablink_cli.api import (
|
|
26
|
+
AllocatorAuthError,
|
|
27
|
+
AllocatorConflictError,
|
|
28
|
+
AllocatorError,
|
|
29
|
+
AllocatorUnavailableError,
|
|
30
|
+
RegistrationClient,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
DEFAULT_ENV_FILE = Path.home() / ".lablink" / "client.env"
|
|
34
|
+
# Distinct from the operator-side override at ~/.lablink/custom-startup.sh
|
|
35
|
+
# (read by deploy.py:101-103) so that running operator + BYO client on the
|
|
36
|
+
# same box doesn't have the client-received copy clobber the operator's
|
|
37
|
+
# local override.
|
|
38
|
+
DEFAULT_STARTUP_SCRIPT = Path.home() / ".lablink" / "client-custom-startup.sh"
|
|
39
|
+
PID_FILE = Path.home() / ".lablink" / "log_shipper.pid"
|
|
40
|
+
# tailscaled's node identity (its state dir). Persisted in a named volume so
|
|
41
|
+
# recreating the container reuses the SAME tailnet node instead of minting a
|
|
42
|
+
# new one — a new node cannot claim a MagicDNS name that an existing (even
|
|
43
|
+
# offline) node still holds, so Tailscale appends a numeric suffix (-1, -2,
|
|
44
|
+
# ...) and the allocator's recorded overlay hostname ends up pointing at the
|
|
45
|
+
# dead node (lablink#404). The allocator's own sidecar has done this from the
|
|
46
|
+
# start (the `tailscale_state` volume in the compose sidecar override);
|
|
47
|
+
# the client side never got the equivalent. Fixed name, like the fixed
|
|
48
|
+
# `--name lablink-client` below: one box runs one client container.
|
|
49
|
+
TAILSCALE_STATE_VOLUME = "lablink-client-tailscale"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _detect_hostname(hostname: str | None, console: Console) -> str:
|
|
53
|
+
resolved = hostname or byo_detect.detect_hostname()
|
|
54
|
+
if not resolved:
|
|
55
|
+
console.print(
|
|
56
|
+
"[red]Could not detect hostname.[/red] "
|
|
57
|
+
"Pass --hostname explicitly."
|
|
58
|
+
)
|
|
59
|
+
raise SystemExit(1)
|
|
60
|
+
console.print(f"Detected hostname: {resolved}")
|
|
61
|
+
return resolved
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _detect_machine_identity(machine_identity: str | None, console: Console) -> str:
|
|
65
|
+
resolved = machine_identity or byo_detect.resolve_machine_identity()
|
|
66
|
+
console.print(f"Detected machine identity: {resolved}")
|
|
67
|
+
return resolved
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _detect_gpu(
|
|
71
|
+
gpu_present: bool | None, gpu_model: str | None, console: Console
|
|
72
|
+
) -> tuple[bool, str | None]:
|
|
73
|
+
detected_present, detected_model = byo_detect.detect_gpu()
|
|
74
|
+
resolved_present = gpu_present if gpu_present is not None else detected_present
|
|
75
|
+
resolved_model = gpu_model or detected_model
|
|
76
|
+
console.print(
|
|
77
|
+
f"Detected GPU: {resolved_model}"
|
|
78
|
+
if resolved_present
|
|
79
|
+
else "Detected GPU: none"
|
|
80
|
+
)
|
|
81
|
+
return resolved_present, resolved_model
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def run_register(
|
|
85
|
+
*,
|
|
86
|
+
allocator_url: str,
|
|
87
|
+
register_token: str,
|
|
88
|
+
hostname: str | None,
|
|
89
|
+
lan_ip: str | None,
|
|
90
|
+
machine_identity: str | None,
|
|
91
|
+
gpu_present: bool | None,
|
|
92
|
+
gpu_model: str | None,
|
|
93
|
+
force: bool,
|
|
94
|
+
env_file: Path | None,
|
|
95
|
+
insecure: bool,
|
|
96
|
+
overlay_hostname: str | None = None,
|
|
97
|
+
tailscale_authkey: str | None = None,
|
|
98
|
+
run_locally: bool = True,
|
|
99
|
+
reverse_tunnel: bool = False,
|
|
100
|
+
docker: Docker | None = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Orchestrate registration. Exits non-zero on any user-facing error.
|
|
103
|
+
|
|
104
|
+
Four shapes:
|
|
105
|
+
- Real BYO box (default): auto-detects hostname/LAN IP/machine
|
|
106
|
+
identity/GPU, then docker-runs the client container after
|
|
107
|
+
persisting secrets.
|
|
108
|
+
- Mesh-overlay, run locally (``overlay_hostname`` set, ``run_locally``
|
|
109
|
+
true — the default): the box registering *is* the target client
|
|
110
|
+
right now (e.g. a terminal inside an already-running Run:AI
|
|
111
|
+
workload with docker-in-docker), so hostname/machine-identity/GPU
|
|
112
|
+
are auto-detected the same as real BYO, and the client container
|
|
113
|
+
is docker-run immediately, joining the Tailscale tailnet on start.
|
|
114
|
+
- Mesh-overlay, hand-off (``overlay_hostname`` set, ``run_locally``
|
|
115
|
+
false via ``--no-run-locally``): the box doesn't exist yet, so
|
|
116
|
+
auto-detection is skipped entirely (it would report *this*
|
|
117
|
+
machine's own facts); ``--hostname``/``--machine-identity`` are
|
|
118
|
+
required. No docker run — instead prints the secrets for the
|
|
119
|
+
admin to paste into their own workload submission.
|
|
120
|
+
- Reverse-tunnel (``reverse_tunnel=True``, i.e. ``--tunnel``): the
|
|
121
|
+
client dials out to the allocator instead of being dialled, so it
|
|
122
|
+
needs none of overlay's Tailscale plumbing — no ``--tailscale-
|
|
123
|
+
authkey``, no LAN IP, no published ports. Otherwise follows the
|
|
124
|
+
same run-locally/hand-off shape as mesh-overlay.
|
|
125
|
+
"""
|
|
126
|
+
console = Console()
|
|
127
|
+
docker = docker or default_docker()
|
|
128
|
+
env_file = env_file or DEFAULT_ENV_FILE
|
|
129
|
+
|
|
130
|
+
if reverse_tunnel and overlay_hostname is not None:
|
|
131
|
+
console.print(
|
|
132
|
+
"[red]--tunnel and --overlay-hostname are different "
|
|
133
|
+
"connectivity modes; pass only one.[/red]"
|
|
134
|
+
)
|
|
135
|
+
raise SystemExit(1)
|
|
136
|
+
if reverse_tunnel and tailscale_authkey:
|
|
137
|
+
console.print(
|
|
138
|
+
"[red]--tailscale-authkey does not apply with --tunnel[/red] "
|
|
139
|
+
"— a tunnel client joins no tailnet."
|
|
140
|
+
)
|
|
141
|
+
raise SystemExit(1)
|
|
142
|
+
if reverse_tunnel and lan_ip is not None:
|
|
143
|
+
console.print(
|
|
144
|
+
"[red]--lan-ip does not apply with --tunnel[/red] "
|
|
145
|
+
"— a tunnel client dials out; the allocator never dials its LAN address."
|
|
146
|
+
)
|
|
147
|
+
raise SystemExit(1)
|
|
148
|
+
|
|
149
|
+
remote_mode = overlay_hostname is not None or reverse_tunnel
|
|
150
|
+
|
|
151
|
+
if not remote_mode:
|
|
152
|
+
if not run_locally:
|
|
153
|
+
console.print(
|
|
154
|
+
"[red]--no-run-locally only applies with "
|
|
155
|
+
"--overlay-hostname or --tunnel.[/red]"
|
|
156
|
+
)
|
|
157
|
+
raise SystemExit(1)
|
|
158
|
+
else:
|
|
159
|
+
if overlay_hostname is not None and not tailscale_authkey:
|
|
160
|
+
console.print(
|
|
161
|
+
"[red]--tailscale-authkey is required with "
|
|
162
|
+
"--overlay-hostname.[/red]"
|
|
163
|
+
)
|
|
164
|
+
raise SystemExit(1)
|
|
165
|
+
if not run_locally:
|
|
166
|
+
if not hostname:
|
|
167
|
+
console.print(
|
|
168
|
+
"[red]--hostname is required with --overlay-hostname/"
|
|
169
|
+
"--tunnel --no-run-locally.[/red] Auto-detection would "
|
|
170
|
+
"report this machine's own hostname, not the future "
|
|
171
|
+
"client's — the client doesn't exist yet."
|
|
172
|
+
)
|
|
173
|
+
raise SystemExit(1)
|
|
174
|
+
if not machine_identity:
|
|
175
|
+
console.print(
|
|
176
|
+
"[red]--machine-identity is required with "
|
|
177
|
+
"--overlay-hostname/--tunnel --no-run-locally.[/red] "
|
|
178
|
+
"Auto-detection would report this machine's own "
|
|
179
|
+
"identity, not the future client's."
|
|
180
|
+
)
|
|
181
|
+
raise SystemExit(1)
|
|
182
|
+
|
|
183
|
+
# Step 1: idempotency / resume. Skipped only for the hand-off case
|
|
184
|
+
# (overlay_hostname or reverse_tunnel set, run_locally false) — that
|
|
185
|
+
# case has no local container/env-file lifecycle to resume.
|
|
186
|
+
if (
|
|
187
|
+
(not remote_mode or run_locally)
|
|
188
|
+
and env_file.exists()
|
|
189
|
+
and not force
|
|
190
|
+
):
|
|
191
|
+
_resume(env_file, console, docker)
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
if remote_mode:
|
|
195
|
+
if overlay_hostname is not None:
|
|
196
|
+
console.print(f"Registering overlay hostname: {overlay_hostname}")
|
|
197
|
+
else:
|
|
198
|
+
console.print("Registering a reverse-tunnel client...")
|
|
199
|
+
if run_locally:
|
|
200
|
+
resolved_hostname = _detect_hostname(hostname, console)
|
|
201
|
+
resolved_machine_identity = _detect_machine_identity(
|
|
202
|
+
machine_identity, console
|
|
203
|
+
)
|
|
204
|
+
resolved_gpu_present, resolved_gpu_model = _detect_gpu(
|
|
205
|
+
gpu_present, gpu_model, console
|
|
206
|
+
)
|
|
207
|
+
else:
|
|
208
|
+
resolved_hostname = hostname
|
|
209
|
+
resolved_machine_identity = machine_identity
|
|
210
|
+
resolved_gpu_present = bool(gpu_present)
|
|
211
|
+
resolved_gpu_model = gpu_model
|
|
212
|
+
else:
|
|
213
|
+
# Step 2: auto-detect (user overrides win)
|
|
214
|
+
resolved_hostname = _detect_hostname(hostname, console)
|
|
215
|
+
|
|
216
|
+
resolved_lan_ip = lan_ip or byo_detect.detect_lan_ip()
|
|
217
|
+
if not resolved_lan_ip:
|
|
218
|
+
console.print(
|
|
219
|
+
"[red]Could not detect LAN IP.[/red] "
|
|
220
|
+
"Pass --lan-ip explicitly."
|
|
221
|
+
)
|
|
222
|
+
raise SystemExit(1)
|
|
223
|
+
console.print(f"Detected LAN IP: {resolved_lan_ip}")
|
|
224
|
+
|
|
225
|
+
resolved_machine_identity = _detect_machine_identity(
|
|
226
|
+
machine_identity, console
|
|
227
|
+
)
|
|
228
|
+
resolved_gpu_present, resolved_gpu_model = _detect_gpu(
|
|
229
|
+
gpu_present, gpu_model, console
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Step 3 + 4: build + POST
|
|
233
|
+
ssl_provider = "self_signed" if insecure else "none"
|
|
234
|
+
client = RegistrationClient(
|
|
235
|
+
allocator_url, register_token, ssl_provider=ssl_provider
|
|
236
|
+
)
|
|
237
|
+
console.print(f"Registering with {allocator_url} …")
|
|
238
|
+
try:
|
|
239
|
+
if overlay_hostname is not None:
|
|
240
|
+
response = client.register(
|
|
241
|
+
hostname=resolved_hostname,
|
|
242
|
+
machine_identity=resolved_machine_identity,
|
|
243
|
+
overlay_hostname=overlay_hostname,
|
|
244
|
+
gpu_present=resolved_gpu_present,
|
|
245
|
+
gpu_model=resolved_gpu_model,
|
|
246
|
+
)
|
|
247
|
+
elif reverse_tunnel:
|
|
248
|
+
response = client.register(
|
|
249
|
+
hostname=resolved_hostname,
|
|
250
|
+
machine_identity=resolved_machine_identity,
|
|
251
|
+
reverse_tunnel=True,
|
|
252
|
+
gpu_present=resolved_gpu_present,
|
|
253
|
+
gpu_model=resolved_gpu_model,
|
|
254
|
+
)
|
|
255
|
+
else:
|
|
256
|
+
response = client.register(
|
|
257
|
+
hostname=resolved_hostname,
|
|
258
|
+
machine_identity=resolved_machine_identity,
|
|
259
|
+
lan_ip=resolved_lan_ip,
|
|
260
|
+
gpu_present=resolved_gpu_present,
|
|
261
|
+
gpu_model=resolved_gpu_model,
|
|
262
|
+
)
|
|
263
|
+
except AllocatorAuthError as e:
|
|
264
|
+
console.print(f"[red]{e}[/red]")
|
|
265
|
+
raise SystemExit(1) from e
|
|
266
|
+
except AllocatorConflictError as e:
|
|
267
|
+
console.print(f"[red]{e}[/red]")
|
|
268
|
+
raise SystemExit(1) from e
|
|
269
|
+
except AllocatorUnavailableError as e:
|
|
270
|
+
console.print(f"[red]Allocator unreachable: {e}[/red]")
|
|
271
|
+
raise SystemExit(1) from e
|
|
272
|
+
except AllocatorError as e:
|
|
273
|
+
console.print(f"[red]{e}[/red]")
|
|
274
|
+
raise SystemExit(1) from e
|
|
275
|
+
|
|
276
|
+
if reverse_tunnel and response.get("connectivity") != "reverse_tunnel":
|
|
277
|
+
# A version-skewed allocator that predates the sentinel may have
|
|
278
|
+
# silently ignored it and registered some other connectivity —
|
|
279
|
+
# this docker run will omit --publish (LOCAL flag says tunnel) and
|
|
280
|
+
# start.sh will never open one (CONNECTIVITY says otherwise), so
|
|
281
|
+
# the client would be unreachable by both paths while reporting
|
|
282
|
+
# healthy. Fail loudly instead of shipping that.
|
|
283
|
+
console.print(
|
|
284
|
+
"[red]--tunnel was requested but the allocator registered this "
|
|
285
|
+
f"client as connectivity={response.get('connectivity')!r} "
|
|
286
|
+
"instead of reverse_tunnel. The allocator likely predates "
|
|
287
|
+
"reverse-tunnel support and ignored the request.[/red]"
|
|
288
|
+
)
|
|
289
|
+
raise SystemExit(1)
|
|
290
|
+
|
|
291
|
+
# Step 5: persist env file (0600)
|
|
292
|
+
_write_env_file(
|
|
293
|
+
env_file,
|
|
294
|
+
response,
|
|
295
|
+
allocator_url=allocator_url,
|
|
296
|
+
overlay_hostname=overlay_hostname,
|
|
297
|
+
tailscale_authkey=tailscale_authkey,
|
|
298
|
+
)
|
|
299
|
+
console.print(
|
|
300
|
+
f"[green]Secrets saved to {env_file} (mode 0600)[/green]"
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
if remote_mode and not run_locally:
|
|
304
|
+
# No host for the CLI to act on — the box doesn't exist yet.
|
|
305
|
+
# Print everything the admin needs to paste into their own
|
|
306
|
+
# workload submission instead of docker-running anything. The
|
|
307
|
+
# loop below already emits OVERLAY_HOSTNAME/TAILSCALE_AUTHKEY or
|
|
308
|
+
# TUNNEL_* since _write_env_file writes whichever apply.
|
|
309
|
+
console.print(
|
|
310
|
+
"\n[bold]No local container will be started[/bold] — this "
|
|
311
|
+
"box doesn't exist yet. Paste the following into your "
|
|
312
|
+
"own workload submission's environment variables:\n"
|
|
313
|
+
)
|
|
314
|
+
for line in env_file.read_text().splitlines():
|
|
315
|
+
if line.startswith("#"):
|
|
316
|
+
continue
|
|
317
|
+
print(line)
|
|
318
|
+
if overlay_hostname is None:
|
|
319
|
+
return
|
|
320
|
+
# There is no docker run for us to add `-v` to on this path, so the
|
|
321
|
+
# operator has to arrange persistence themselves. Without it every
|
|
322
|
+
# workload restart joins the tailnet as a brand-new node and
|
|
323
|
+
# Tailscale suffixes the name (-1, -2, ...); the client reports its
|
|
324
|
+
# real name back regardless (see start.sh), so this is an
|
|
325
|
+
# optimization rather than a correctness requirement. See
|
|
326
|
+
# TAILSCALE_STATE_VOLUME for the run-locally equivalent.
|
|
327
|
+
console.print(
|
|
328
|
+
"\n[dim]Also give the workload persistent storage mounted at "
|
|
329
|
+
"/var/lib/tailscale. Without it, each restart rejoins the "
|
|
330
|
+
"tailnet as a new node and Tailscale appends a numeric suffix "
|
|
331
|
+
"to its name. The client reports its actual name back either "
|
|
332
|
+
"way, so this is an optimization, not a requirement.[/dim]"
|
|
333
|
+
)
|
|
334
|
+
return
|
|
335
|
+
|
|
336
|
+
# Step 6: GPU runtime pre-flight (only when --gpus all will be added)
|
|
337
|
+
if resolved_gpu_present:
|
|
338
|
+
_verify_gpu_runtime(docker)
|
|
339
|
+
|
|
340
|
+
# Step 7 + 8: docker run (always)
|
|
341
|
+
startup_script_path = _write_startup_script(response)
|
|
342
|
+
cmd = _build_docker_run(
|
|
343
|
+
env_file, response, resolved_gpu_present, startup_script_path,
|
|
344
|
+
overlay_hostname, reverse_tunnel=reverse_tunnel,
|
|
345
|
+
)
|
|
346
|
+
console.print(
|
|
347
|
+
f"[green]Registered as client #{response['client_id']}[/green]"
|
|
348
|
+
)
|
|
349
|
+
_exec_docker(cmd, console, docker)
|
|
350
|
+
if overlay_hostname is not None:
|
|
351
|
+
console.print(
|
|
352
|
+
"[dim]This container joins Tailscale as "
|
|
353
|
+
f"{overlay_hostname} on start — confirm with "
|
|
354
|
+
"`docker logs lablink-client`.[/dim]"
|
|
355
|
+
)
|
|
356
|
+
elif reverse_tunnel:
|
|
357
|
+
console.print(
|
|
358
|
+
"[dim]This container opens a tunnel to the allocator on "
|
|
359
|
+
"start — confirm with `docker logs lablink-client`.[/dim]"
|
|
360
|
+
)
|
|
361
|
+
_start_log_shipper(env_file, console)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _resume(env_file: Path, console: Console, docker: Docker) -> None:
|
|
365
|
+
"""Re-run mode for an already-registered host.
|
|
366
|
+
|
|
367
|
+
Does NOT mint a new client_secret. Restarts the container if stopped,
|
|
368
|
+
revives the shipper if dead, otherwise prints a no-op message.
|
|
369
|
+
|
|
370
|
+
Note: container image is NOT re-pulled — that's `--force` territory.
|
|
371
|
+
"""
|
|
372
|
+
status = docker.container_status("lablink-client")
|
|
373
|
+
container_action: str | None = None
|
|
374
|
+
|
|
375
|
+
if status == "missing":
|
|
376
|
+
console.print(
|
|
377
|
+
"[yellow]Already registered, but lablink-client container is "
|
|
378
|
+
"missing.[/yellow] Re-run with [bold]--force[/bold] to recreate "
|
|
379
|
+
"it (this mints a new client_secret)."
|
|
380
|
+
)
|
|
381
|
+
raise SystemExit(1)
|
|
382
|
+
if status == "daemon_error":
|
|
383
|
+
console.print(
|
|
384
|
+
"[red]Docker daemon is unreachable.[/red] Start Docker and re-run."
|
|
385
|
+
)
|
|
386
|
+
raise SystemExit(1)
|
|
387
|
+
if status in ("exited", "restarting"):
|
|
388
|
+
start = docker.start_container("lablink-client")
|
|
389
|
+
if not start.ok:
|
|
390
|
+
detail = (
|
|
391
|
+
start.stderr.strip()
|
|
392
|
+
or f"docker start exited {start.returncode}"
|
|
393
|
+
)
|
|
394
|
+
console.print(
|
|
395
|
+
f"[red]docker start lablink-client failed:[/red] {detail}"
|
|
396
|
+
)
|
|
397
|
+
raise SystemExit(1)
|
|
398
|
+
container_action = "restarted"
|
|
399
|
+
|
|
400
|
+
shipper_action: str | None = None
|
|
401
|
+
if _shipper_alive():
|
|
402
|
+
if container_action is None:
|
|
403
|
+
console.print(
|
|
404
|
+
"[green]Already registered. Container and log shipper "
|
|
405
|
+
"are running.[/green]"
|
|
406
|
+
)
|
|
407
|
+
return
|
|
408
|
+
else:
|
|
409
|
+
_start_log_shipper(env_file, console)
|
|
410
|
+
shipper_action = "restarted"
|
|
411
|
+
|
|
412
|
+
if container_action and shipper_action:
|
|
413
|
+
console.print(
|
|
414
|
+
"[green]Restarted container and log shipper.[/green] "
|
|
415
|
+
"To pull a newer client image, re-run with --force."
|
|
416
|
+
)
|
|
417
|
+
elif container_action:
|
|
418
|
+
console.print(
|
|
419
|
+
"[green]Restarted container.[/green] "
|
|
420
|
+
"To pull a newer client image, re-run with --force."
|
|
421
|
+
)
|
|
422
|
+
elif shipper_action:
|
|
423
|
+
console.print("[green]Restarted log shipper.[/green]")
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _write_env_file(
|
|
427
|
+
env_file: Path,
|
|
428
|
+
resp: dict,
|
|
429
|
+
*,
|
|
430
|
+
allocator_url: str,
|
|
431
|
+
overlay_hostname: str | None = None,
|
|
432
|
+
tailscale_authkey: str | None = None,
|
|
433
|
+
) -> None:
|
|
434
|
+
env_file.parent.mkdir(parents=True, exist_ok=True)
|
|
435
|
+
timestamp = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
436
|
+
# Prefer the URL the caller actually used to register — if it didn't
|
|
437
|
+
# work, registration would have failed before reaching this point, so
|
|
438
|
+
# it's a proven-reachable, correctly-scheme'd address. The server's
|
|
439
|
+
# own `allocator_url` in the response is derived from Flask's
|
|
440
|
+
# request.host_url, which is unreliable behind a Tailscale Funnel
|
|
441
|
+
# front door (Funnel doesn't add X-Forwarded-Proto, so the allocator
|
|
442
|
+
# can't tell it arrived over HTTPS) — it can silently downgrade an
|
|
443
|
+
# https:// registration to an http:// value that Funnel then only
|
|
444
|
+
# 302-redirects, breaking every subsequent POST (redirects turn POST
|
|
445
|
+
# into GET per RFC/requests convention, e.g. gpu_health/heartbeat
|
|
446
|
+
# reports 405ing). Only fall back to the response's value if the
|
|
447
|
+
# caller somehow didn't supply one.
|
|
448
|
+
resolved_url = allocator_url or resp.get("allocator_url")
|
|
449
|
+
allocator_host = urlparse(resolved_url).hostname or ""
|
|
450
|
+
# REGISTER_RESPONSE is the full server response, re-serialized as
|
|
451
|
+
# single-line JSON. The client's start.sh parses it to materialize
|
|
452
|
+
# /tmp/lablink-monitoring.json (and any future server-shipped settings).
|
|
453
|
+
# docker --env-file reads each line verbatim up to newline; json.dumps()
|
|
454
|
+
# without indent=... never emits a literal newline, so this is safe.
|
|
455
|
+
register_response_json = json.dumps(resp, separators=(",", ":"))
|
|
456
|
+
lines = [
|
|
457
|
+
f"# Generated by `lablink client register` on {timestamp}",
|
|
458
|
+
f"CLIENT_ID={resp['client_id']}",
|
|
459
|
+
f"VM_NAME={resp['client_id']}",
|
|
460
|
+
f"CLIENT_SECRET={resp['client_secret']}",
|
|
461
|
+
f"AGENT_TOKEN={resp['agent_token']}",
|
|
462
|
+
f"REGISTER_TOKEN={resp['register_token']}",
|
|
463
|
+
f"ALLOCATOR_URL={resolved_url}",
|
|
464
|
+
f"ALLOCATOR_HOST={allocator_host}",
|
|
465
|
+
f"CONNECTIVITY={resp['connectivity']}",
|
|
466
|
+
f"CLIENT_IMAGE={resp['client_image']}",
|
|
467
|
+
f"REGISTER_RESPONSE={register_response_json}",
|
|
468
|
+
]
|
|
469
|
+
# cfg.machine.repository / cfg.machine.software, shipped by the
|
|
470
|
+
# allocator's register response. These reach an AWS client through
|
|
471
|
+
# user_data.sh's `docker run -e TUTORIAL_REPO_TO_CLONE=... -e
|
|
472
|
+
# SUBJECT_SOFTWARE=...`; on a BYO box this env file is the only
|
|
473
|
+
# channel, and without them start.sh logs "TUTORIAL_REPO_TO_CLONE not
|
|
474
|
+
# set. Skipping clone step." and launches update_inuse_status with an
|
|
475
|
+
# empty client.software (lablink#405). Omitted entirely when unset so
|
|
476
|
+
# the --no-run-locally paste-into-Run:AI printout stays clean; a bare
|
|
477
|
+
# `VAR=` would be equivalent to start.sh's `-n` check either way.
|
|
478
|
+
# Absent keys (older allocator, newer CLI) behave the same as unset.
|
|
479
|
+
if resp.get("repository"):
|
|
480
|
+
lines.append(f"TUTORIAL_REPO_TO_CLONE={resp['repository']}")
|
|
481
|
+
if resp.get("subject_software"):
|
|
482
|
+
lines.append(f"SUBJECT_SOFTWARE={resp['subject_software']}")
|
|
483
|
+
# The allocator-minted tunnel values, detected from the response rather
|
|
484
|
+
# than passed in: both are response fields. CONNECTIVITY=reverse_tunnel
|
|
485
|
+
# is already written above and is what start.sh gates on, so a missing
|
|
486
|
+
# value fails loudly there instead of silently skipping the tunnel.
|
|
487
|
+
if resp.get("tunnel_url"):
|
|
488
|
+
# resp["tunnel_url"] is only the SIGNAL that this is a tunnel
|
|
489
|
+
# client; the URL VALUE comes from resolved_url (same reasoning as
|
|
490
|
+
# ALLOCATOR_URL above, lablink#396): resp["tunnel_url"] is
|
|
491
|
+
# canonical_base_url(request), which can't detect HTTPS on a manual
|
|
492
|
+
# deployment (ssl.provider: none keeps the X-Forwarded-Proto gate
|
|
493
|
+
# shut — see that function's own docstring). Behind the operator's
|
|
494
|
+
# own reverse proxy (which the rendered compose explicitly
|
|
495
|
+
# recommends), that means it reports http:// even when the
|
|
496
|
+
# allocator is only reachable over HTTPS, and a ws:// dial against
|
|
497
|
+
# that host simply doesn't work. resolved_url is what the caller
|
|
498
|
+
# proved reachable to get this far.
|
|
499
|
+
tunnel_url = resolved_url.replace("https://", "wss://", 1)
|
|
500
|
+
tunnel_url = tunnel_url.replace("http://", "ws://", 1)
|
|
501
|
+
lines.append(f"TUNNEL_URL={tunnel_url}")
|
|
502
|
+
lines.append(f"TUNNEL_PATH_PREFIX={resp['tunnel_path_prefix']}")
|
|
503
|
+
lines.append(f"TUNNEL_BIND_ADDR={resp['tunnel_bind_addr']}")
|
|
504
|
+
# wstunnel dials localhost inside the container; keep KasmVNC
|
|
505
|
+
# off the client's own LAN interface too.
|
|
506
|
+
lines.append("KASMVNC_LISTEN=127.0.0.1")
|
|
507
|
+
if overlay_hostname is not None:
|
|
508
|
+
# Written so a nested `docker run --env-file` (the run_locally
|
|
509
|
+
# path) carries these into the container automatically —
|
|
510
|
+
# start.sh's existing `if [ -n "$TAILSCALE_AUTHKEY" ]` gate then
|
|
511
|
+
# joins the tailnet with no client-image changes needed.
|
|
512
|
+
lines.append(f"OVERLAY_HOSTNAME={overlay_hostname}")
|
|
513
|
+
lines.append(f"TAILSCALE_AUTHKEY={tailscale_authkey}")
|
|
514
|
+
env_file.write_text("\n".join(lines) + "\n")
|
|
515
|
+
env_file.chmod(0o600)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _write_startup_script(resp: dict) -> Path | None:
|
|
519
|
+
"""Materialize the allocator-provided startup script to disk.
|
|
520
|
+
|
|
521
|
+
Returns the host path to bind-mount into the client container, or
|
|
522
|
+
None when the allocator returned no script (disabled, file missing,
|
|
523
|
+
or empty). Mode 0755 so root-in-container can exec it via ``bash``.
|
|
524
|
+
Stale files from prior registrations are removed when the current
|
|
525
|
+
response carries no payload, so a script disabled on the allocator
|
|
526
|
+
side is not silently kept alive locally.
|
|
527
|
+
"""
|
|
528
|
+
b64 = resp.get("startup_script_b64") or ""
|
|
529
|
+
if not b64:
|
|
530
|
+
DEFAULT_STARTUP_SCRIPT.unlink(missing_ok=True)
|
|
531
|
+
return None
|
|
532
|
+
DEFAULT_STARTUP_SCRIPT.parent.mkdir(parents=True, exist_ok=True)
|
|
533
|
+
DEFAULT_STARTUP_SCRIPT.write_bytes(base64.b64decode(b64))
|
|
534
|
+
DEFAULT_STARTUP_SCRIPT.chmod(0o755)
|
|
535
|
+
return DEFAULT_STARTUP_SCRIPT
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _build_docker_run(
|
|
539
|
+
env_file: Path,
|
|
540
|
+
resp: dict,
|
|
541
|
+
gpu_present: bool,
|
|
542
|
+
startup_script: Path | None,
|
|
543
|
+
overlay_hostname: str | None,
|
|
544
|
+
reverse_tunnel: bool = False,
|
|
545
|
+
) -> list[str]:
|
|
546
|
+
cmd = [
|
|
547
|
+
"docker", "run", "-d",
|
|
548
|
+
"--name", "lablink-client",
|
|
549
|
+
"--restart", "unless-stopped",
|
|
550
|
+
# Force a manifest check on every register so a republished image
|
|
551
|
+
# tag (e.g. fixes pushed to ghcr.io for the same :0.0.8a0 stream)
|
|
552
|
+
# actually lands on the BYO box; default `--pull missing` would
|
|
553
|
+
# silently reuse the locally cached layers and ship the broken
|
|
554
|
+
# bits forever. Costs one HEAD per register; layers that haven't
|
|
555
|
+
# changed are not re-downloaded.
|
|
556
|
+
"--pull", "always",
|
|
557
|
+
# The client image is published amd64-only, so an arm64 host (Apple
|
|
558
|
+
# Silicon) otherwise fails with "no matching manifest for
|
|
559
|
+
# linux/arm64/v8". No-op on native amd64.
|
|
560
|
+
"--platform", "linux/amd64",
|
|
561
|
+
]
|
|
562
|
+
if gpu_present:
|
|
563
|
+
cmd += ["--gpus", "all"]
|
|
564
|
+
if overlay_hostname is not None:
|
|
565
|
+
# start.sh's `tailscaled` needs to create a TUN network interface
|
|
566
|
+
# to route tailnet traffic to this container's own listening
|
|
567
|
+
# sockets (6080/7070). Without these, tailscaled can't open
|
|
568
|
+
# /dev/net/tun, dies immediately, and the subsequent `tailscale
|
|
569
|
+
# up` fails with "failed to connect to local tailscaled; it
|
|
570
|
+
# doesn't appear to be running". Gated on overlay_hostname since
|
|
571
|
+
# lan_direct/allocator_proxied clients never invoke `tailscale up`.
|
|
572
|
+
cmd += [
|
|
573
|
+
"--cap-add", "NET_ADMIN",
|
|
574
|
+
"--cap-add", "NET_RAW",
|
|
575
|
+
"--device", "/dev/net/tun",
|
|
576
|
+
# Persist the tailnet node identity; see TAILSCALE_STATE_VOLUME.
|
|
577
|
+
# Re-registering with a *different* --overlay-hostname is still
|
|
578
|
+
# fine: renaming a node you already own is allowed and yields the
|
|
579
|
+
# unsuffixed name, which is exactly what we want.
|
|
580
|
+
"-v", f"{TAILSCALE_STATE_VOLUME}:/var/lib/tailscale",
|
|
581
|
+
]
|
|
582
|
+
# Mount path mirrors the AWS tofu/user_data mount so the client
|
|
583
|
+
# start.sh finds the script at /docker_scripts/custom-startup.sh
|
|
584
|
+
# regardless of provider. Skipped when the allocator returned no
|
|
585
|
+
# script — docker would refuse the run otherwise (bind src must
|
|
586
|
+
# exist), and start.sh already no-ops when the file is absent.
|
|
587
|
+
if startup_script is not None:
|
|
588
|
+
cmd += [
|
|
589
|
+
"--mount",
|
|
590
|
+
(
|
|
591
|
+
f"type=bind,src={startup_script},"
|
|
592
|
+
"dst=/docker_scripts/custom-startup.sh,ro"
|
|
593
|
+
),
|
|
594
|
+
"-e",
|
|
595
|
+
f"STARTUP_ON_ERROR={resp.get('startup_on_error', 'continue')}",
|
|
596
|
+
"-e",
|
|
597
|
+
f"STARTUP_MAX_ATTEMPTS={resp.get('startup_max_attempts', 1)}",
|
|
598
|
+
"-e",
|
|
599
|
+
(
|
|
600
|
+
"STARTUP_BASE_DELAY_SECONDS="
|
|
601
|
+
f"{resp.get('startup_base_delay_seconds', 0)}"
|
|
602
|
+
),
|
|
603
|
+
"-e",
|
|
604
|
+
(
|
|
605
|
+
"STARTUP_SUCCESS_CHECK_B64="
|
|
606
|
+
f"{resp.get('startup_success_check_b64', '')}"
|
|
607
|
+
),
|
|
608
|
+
]
|
|
609
|
+
# Publish 7070 (agent's /api/session/start) and 6080 (KasmVNC) on
|
|
610
|
+
# the LAN IP so the allocator can reach them. `--network host` would
|
|
611
|
+
# also do this on Linux, but on Docker Desktop (Windows/macOS) it
|
|
612
|
+
# drops the container into the Docker VM's network instead of the
|
|
613
|
+
# host's, leaving the ports unreachable from the LAN — the
|
|
614
|
+
# allocator's password rotation just times out at the container's
|
|
615
|
+
# :7070. Explicit `--publish` behaves the same on every platform.
|
|
616
|
+
#
|
|
617
|
+
# Reverse-tunnel clients publish neither: the allocator reaches this
|
|
618
|
+
# container through the tunnel it dials out, and publishing would
|
|
619
|
+
# expose KasmVNC/the agent on the LAN this mode exists to avoid
|
|
620
|
+
# trusting.
|
|
621
|
+
if not reverse_tunnel:
|
|
622
|
+
cmd += [
|
|
623
|
+
"--publish", "7070:7070",
|
|
624
|
+
"--publish", "6080:6080",
|
|
625
|
+
]
|
|
626
|
+
cmd += [
|
|
627
|
+
"--env-file", str(env_file),
|
|
628
|
+
resp["client_image"],
|
|
629
|
+
]
|
|
630
|
+
return cmd
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _verify_gpu_runtime(docker: Docker) -> None:
|
|
634
|
+
"""Refuse to launch a GPU container on a host whose docker daemon
|
|
635
|
+
uses the systemd cgroup driver.
|
|
636
|
+
|
|
637
|
+
systemd reorganizes cgroups asynchronously (unit reloads, idle reaping,
|
|
638
|
+
OOM events) and revokes GPU device permissions from running containers
|
|
639
|
+
— nvidia-smi inside the client works at first, then fails after minutes,
|
|
640
|
+
and check_gpu reports Unhealthy, which makes assignment skip the row
|
|
641
|
+
(get_first_available_vm filters healthy='Unhealthy'). The AWS path's
|
|
642
|
+
user_data.sh writes ``exec-opts: native.cgroupdriver=cgroupfs`` to
|
|
643
|
+
avoid this; BYO operators have to set it themselves.
|
|
644
|
+
|
|
645
|
+
Inspecting the daemon config (via ``docker info``) is the only reliable
|
|
646
|
+
signal — a synchronous nvidia-smi smoke test would pass and then fail
|
|
647
|
+
later, after the env file + container already exist.
|
|
648
|
+
"""
|
|
649
|
+
if not docker.available():
|
|
650
|
+
# run_detached will report this with the right error; skip here.
|
|
651
|
+
return
|
|
652
|
+
console = Console()
|
|
653
|
+
try:
|
|
654
|
+
driver = docker.daemon_info("{{.CgroupDriver}}")
|
|
655
|
+
except DockerDaemonError as e:
|
|
656
|
+
console.print(
|
|
657
|
+
f"[red]Could not query docker daemon: {e}[/red]\n"
|
|
658
|
+
"Verify docker is running and re-run "
|
|
659
|
+
"`lablink client register --force`."
|
|
660
|
+
)
|
|
661
|
+
raise SystemExit(1) from e
|
|
662
|
+
|
|
663
|
+
if driver == "cgroupfs":
|
|
664
|
+
return
|
|
665
|
+
|
|
666
|
+
# Heredoc terminator MUST be flush-left for bash to recognize it.
|
|
667
|
+
# Rich indents block content; we render the shell snippet as a
|
|
668
|
+
# plain code-fence-style block to keep the closing `JSON` at column 0
|
|
669
|
+
# when the admin copy-pastes.
|
|
670
|
+
snippet = (
|
|
671
|
+
"sudo tee /etc/docker/daemon.json > /dev/null <<'JSON'\n"
|
|
672
|
+
"{\n"
|
|
673
|
+
' "default-runtime": "nvidia",\n'
|
|
674
|
+
' "runtimes": {\n'
|
|
675
|
+
' "nvidia": {\n'
|
|
676
|
+
' "path": "nvidia-container-runtime",\n'
|
|
677
|
+
' "runtimeArgs": []\n'
|
|
678
|
+
" }\n"
|
|
679
|
+
" },\n"
|
|
680
|
+
' "exec-opts": ["native.cgroupdriver=cgroupfs"]\n'
|
|
681
|
+
"}\n"
|
|
682
|
+
"JSON\n"
|
|
683
|
+
"sudo systemctl restart docker"
|
|
684
|
+
)
|
|
685
|
+
console.print(
|
|
686
|
+
f"[red]Docker cgroup driver is '{driver}', not 'cgroupfs'.[/red]\n"
|
|
687
|
+
"[bold]Your secrets file is saved.[/bold] After fixing daemon.json "
|
|
688
|
+
"below, re-run [bold]lablink client register --force[/bold] to rotate the "
|
|
689
|
+
"client secret and start the container.\n\n"
|
|
690
|
+
"Why this matters: GPU access from the client container will fail "
|
|
691
|
+
"after a few minutes (systemd reorganizes cgroups and revokes "
|
|
692
|
+
"device permissions on running containers), check_gpu will report "
|
|
693
|
+
"Unhealthy, and assignment will skip this client.\n\n"
|
|
694
|
+
"[bold]Fix on the host (copy-paste exactly, the closing 'JSON' "
|
|
695
|
+
"must be flush-left):[/bold]"
|
|
696
|
+
)
|
|
697
|
+
# Print snippet without Rich markup so indentation is preserved
|
|
698
|
+
# verbatim — no leading whitespace inserted around the JSON terminator.
|
|
699
|
+
print(snippet)
|
|
700
|
+
raise SystemExit(1)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _exec_docker(cmd: list[str], console: Console, docker: Docker) -> None:
|
|
704
|
+
try:
|
|
705
|
+
docker.require()
|
|
706
|
+
except DockerUnavailable:
|
|
707
|
+
console.print(
|
|
708
|
+
"[red]docker not found on PATH.[/red] Install Docker "
|
|
709
|
+
"and re-run `lablink client register --force`."
|
|
710
|
+
)
|
|
711
|
+
raise SystemExit(1)
|
|
712
|
+
# Remove any existing container with the target name. Quiet on
|
|
713
|
+
# success; we don't care if it didn't exist.
|
|
714
|
+
docker.remove_container("lablink-client", force=True)
|
|
715
|
+
console.print(
|
|
716
|
+
f"Starting client container (image: {cmd[-1]}) …"
|
|
717
|
+
)
|
|
718
|
+
result = docker.run_detached(cmd)
|
|
719
|
+
if not result.ok:
|
|
720
|
+
# `run_detached` streams, so stderr is empty on a real non-zero
|
|
721
|
+
# exit; it is populated only when the OS could not exec docker.
|
|
722
|
+
if result.stderr:
|
|
723
|
+
console.print(f"[red]Failed to exec docker: {result.stderr}[/red]")
|
|
724
|
+
raise SystemExit(1)
|
|
725
|
+
console.print(
|
|
726
|
+
f"[red]docker run exited {result.returncode}.[/red] "
|
|
727
|
+
"Check `docker logs lablink-client`."
|
|
728
|
+
)
|
|
729
|
+
raise SystemExit(result.returncode)
|
|
730
|
+
console.print(
|
|
731
|
+
"[green]Container running as lablink-client.[/green] "
|
|
732
|
+
"View logs with: docker logs -f lablink-client"
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _stop_existing_shipper(console: Console) -> None:
|
|
737
|
+
"""Terminate any running shipper recorded in the PID file.
|
|
738
|
+
|
|
739
|
+
Called before spawning a new shipper so ``--force`` re-register doesn't
|
|
740
|
+
leave the old shipper briefly tailing the replaced container and
|
|
741
|
+
POSTing duplicates against the same hostname. The cmdline guard
|
|
742
|
+
matches ``_shipper_alive`` so an unrelated PID-reused process is left
|
|
743
|
+
alone.
|
|
744
|
+
"""
|
|
745
|
+
if not PID_FILE.exists():
|
|
746
|
+
return
|
|
747
|
+
try:
|
|
748
|
+
pid = int(PID_FILE.read_text().strip())
|
|
749
|
+
except (OSError, ValueError):
|
|
750
|
+
PID_FILE.unlink(missing_ok=True)
|
|
751
|
+
return
|
|
752
|
+
try:
|
|
753
|
+
proc = psutil.Process(pid)
|
|
754
|
+
cmdline = proc.cmdline()
|
|
755
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
756
|
+
PID_FILE.unlink(missing_ok=True)
|
|
757
|
+
return
|
|
758
|
+
if not any("lablink_cli.log_shipper" in arg for arg in cmdline):
|
|
759
|
+
PID_FILE.unlink(missing_ok=True)
|
|
760
|
+
return
|
|
761
|
+
|
|
762
|
+
console.print(f"[dim]Stopping existing log shipper (PID {pid})...[/dim]")
|
|
763
|
+
try:
|
|
764
|
+
proc.terminate()
|
|
765
|
+
proc.wait(timeout=5)
|
|
766
|
+
except psutil.TimeoutExpired:
|
|
767
|
+
try:
|
|
768
|
+
proc.kill()
|
|
769
|
+
except psutil.NoSuchProcess:
|
|
770
|
+
pass
|
|
771
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
772
|
+
pass
|
|
773
|
+
# The shipper's SIGTERM handler removes the PID file; if we escalated
|
|
774
|
+
# to SIGKILL the handler never ran, so clean up here.
|
|
775
|
+
PID_FILE.unlink(missing_ok=True)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _start_log_shipper(env_file: Path, console: Console) -> None:
|
|
779
|
+
"""Spawn the log shipper as a detached background process.
|
|
780
|
+
|
|
781
|
+
The shipper survives this `register` invocation and runs until either
|
|
782
|
+
the user does ``docker stop lablink-client`` (shipper's docker-logs
|
|
783
|
+
subprocess exits and inspect reports missing) or the host reboots.
|
|
784
|
+
"""
|
|
785
|
+
_stop_existing_shipper(console)
|
|
786
|
+
|
|
787
|
+
log_dir = Path.home() / ".lablink"
|
|
788
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
789
|
+
shipper_log = log_dir / "log_shipper.log"
|
|
790
|
+
# Append-mode handle for the detached child's stdout+stderr. The
|
|
791
|
+
# shipper itself writes structured lines to this file via self_log();
|
|
792
|
+
# the open handle here is just a safety net for any stray print.
|
|
793
|
+
log_fd = open(shipper_log, "a", buffering=1)
|
|
794
|
+
|
|
795
|
+
cmd = [sys.executable, "-m", "lablink_cli.log_shipper", str(env_file)]
|
|
796
|
+
|
|
797
|
+
popen_kwargs: dict = {
|
|
798
|
+
"stdin": subprocess.DEVNULL,
|
|
799
|
+
"stdout": log_fd,
|
|
800
|
+
"stderr": log_fd,
|
|
801
|
+
"close_fds": True,
|
|
802
|
+
}
|
|
803
|
+
if os.name == "nt":
|
|
804
|
+
# Windows: detach so the child survives the parent's exit.
|
|
805
|
+
popen_kwargs["creationflags"] = (
|
|
806
|
+
subprocess.DETACHED_PROCESS
|
|
807
|
+
| subprocess.CREATE_NEW_PROCESS_GROUP
|
|
808
|
+
)
|
|
809
|
+
else:
|
|
810
|
+
# POSIX: new session detaches from the controlling TTY and parent
|
|
811
|
+
# process group, matching nohup semantics.
|
|
812
|
+
popen_kwargs["start_new_session"] = True
|
|
813
|
+
|
|
814
|
+
proc = subprocess.Popen(cmd, **popen_kwargs)
|
|
815
|
+
console.print(
|
|
816
|
+
f"[green]Log shipping started (PID {proc.pid}).[/green] "
|
|
817
|
+
f"Logs: {shipper_log}"
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _shipper_alive() -> bool:
|
|
822
|
+
"""True iff a live log-shipper process matching our PID file exists.
|
|
823
|
+
|
|
824
|
+
Two-stage check: PID present in PID file AND that PID belongs to a
|
|
825
|
+
process whose cmdline mentions ``lablink_cli.log_shipper``. The
|
|
826
|
+
cmdline guard prevents false positives from PID reuse after reboot.
|
|
827
|
+
"""
|
|
828
|
+
if not PID_FILE.exists():
|
|
829
|
+
return False
|
|
830
|
+
try:
|
|
831
|
+
pid = int(PID_FILE.read_text().strip())
|
|
832
|
+
except (OSError, ValueError):
|
|
833
|
+
return False
|
|
834
|
+
try:
|
|
835
|
+
proc = psutil.Process(pid)
|
|
836
|
+
cmdline = proc.cmdline()
|
|
837
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
838
|
+
return False
|
|
839
|
+
return any("lablink_cli.log_shipper" in arg for arg in cmdline)
|