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