lablink-cli 0.1.0__tar.gz → 0.2.0__tar.gz

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.
Files changed (74) hide show
  1. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/PKG-INFO +1 -2
  2. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/pyproject.toml +1 -2
  3. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/app.py +19 -0
  4. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/cleanup.py +9 -4
  5. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/deploy.py +16 -0
  6. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/deploy_compose.py +193 -31
  7. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/doctor.py +23 -69
  8. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/logs.py +81 -17
  9. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/register.py +26 -144
  10. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/reset_overlay.py +1 -1
  11. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/status.py +56 -87
  12. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/utils.py +12 -39
  13. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/config/schema.py +27 -0
  14. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/docker.py +0 -26
  15. lablink_cli-0.2.0/src/lablink_cli/manual.py +167 -0
  16. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/tui/logs_viewer.py +21 -1
  17. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/tui/wizard.py +5 -2
  18. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli.egg-info/PKG-INFO +1 -2
  19. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli.egg-info/SOURCES.txt +2 -2
  20. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli.egg-info/requires.txt +0 -1
  21. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_cleanup.py +3 -3
  22. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_deploy.py +24 -0
  23. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_deploy_compose.py +328 -0
  24. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_docker.py +1 -61
  25. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_doctor.py +17 -48
  26. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_logs.py +149 -11
  27. lablink_cli-0.2.0/tests/test_manual.py +186 -0
  28. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_register.py +39 -347
  29. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_status.py +37 -11
  30. lablink_cli-0.2.0/tests/test_status_manual.py +257 -0
  31. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_utils_full.py +37 -1
  32. lablink_cli-0.1.0/src/lablink_cli/log_shipper.py +0 -441
  33. lablink_cli-0.1.0/tests/test_log_shipper.py +0 -673
  34. lablink_cli-0.1.0/tests/test_status_manual.py +0 -334
  35. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/README.md +0 -0
  36. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/setup.cfg +0 -0
  37. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/__init__.py +0 -0
  38. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/api.py +0 -0
  39. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/byo_detect.py +0 -0
  40. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/__init__.py +0 -0
  41. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/export_metrics.py +0 -0
  42. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/launch.py +0 -0
  43. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/setup.py +0 -0
  44. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/stats.py +0 -0
  45. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/unregister.py +0 -0
  46. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/config/__init__.py +0 -0
  47. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/deployment_metrics.py +0 -0
  48. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/templates/docker-compose.tailscale-override.yml +0 -0
  49. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/templates/docker-compose.yml +0 -0
  50. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/tofu_source.py +0 -0
  51. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/tui/__init__.py +0 -0
  52. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli.egg-info/dependency_links.txt +0 -0
  53. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli.egg-info/entry_points.txt +0 -0
  54. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli.egg-info/top_level.txt +0 -0
  55. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_api.py +0 -0
  56. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_app.py +0 -0
  57. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_byo_detect.py +0 -0
  58. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_cleanup_full.py +0 -0
  59. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_deployment_metrics.py +0 -0
  60. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_docker_isolation.py +0 -0
  61. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_doctor_full.py +0 -0
  62. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_export_metrics.py +0 -0
  63. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_launch.py +0 -0
  64. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_registration_client.py +0 -0
  65. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_reset_overlay.py +0 -0
  66. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_schema.py +0 -0
  67. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_setup.py +0 -0
  68. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_setup_full.py +0 -0
  69. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_stats.py +0 -0
  70. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_status_full.py +0 -0
  71. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_tofu_source.py +0 -0
  72. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_unregister.py +0 -0
  73. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_utils.py +0 -0
  74. {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_wizard.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lablink-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: CLI tool for deploying and managing LabLink infrastructure
5
5
  Author-email: Elizabeth Berrigan <eberrigan@salk.edu>, Talmo Pereira <talmo@salk.edu>, Andrew Park <hep003@ucsd.edu>
6
6
  Project-URL: Homepage, https://github.com/talmolab/lablink
@@ -13,7 +13,6 @@ Requires-Dist: textual>=3.0
13
13
  Requires-Dist: rich>=13.0
14
14
  Requires-Dist: pyyaml>=6.0
15
15
  Requires-Dist: boto3>=1.35
16
- Requires-Dist: psutil>=5.9
17
16
  Provides-Extra: dev
18
17
  Requires-Dist: pytest; extra == "dev"
19
18
  Requires-Dist: pytest-cov; extra == "dev"
@@ -5,7 +5,7 @@ authors = [
5
5
  {name = "Talmo Pereira", email = "talmo@salk.edu"},
6
6
  {name = "Andrew Park", email = "hep003@ucsd.edu"},
7
7
  ]
8
- version = "0.1.0"
8
+ version = "0.2.0"
9
9
  description = "CLI tool for deploying and managing LabLink infrastructure"
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.10"
@@ -19,7 +19,6 @@ dependencies = [
19
19
  "rich>=13.0",
20
20
  "pyyaml>=6.0",
21
21
  "boto3>=1.35",
22
- "psutil>=5.9",
23
22
  ]
24
23
 
25
24
  [project.optional-dependencies]
@@ -272,9 +272,27 @@ def deploy(
272
272
  "redeploys (the previous value is carried forward). Supply it again "
273
273
  "to rotate. Manual provider only.",
274
274
  ),
275
+ render_only: bool = typer.Option(
276
+ False,
277
+ "--render-only",
278
+ help="Render the deployment bundle and print a launch sheet "
279
+ "instead of starting containers — for running the allocator "
280
+ "image on an external container platform (Run:AI, Kubernetes) "
281
+ "where no Docker daemon is available. Manual provider only.",
282
+ ),
275
283
  ) -> None:
276
284
  """Deploy LabLink infrastructure (AWS OpenTofu or docker-compose)."""
277
285
  cfg = _load_cfg(config)
286
+
287
+ if render_only and cfg.provider != "manual":
288
+ from rich.console import Console
289
+
290
+ Console().print(
291
+ "[red]--render-only is only meaningful with provider: manual "
292
+ f"(this config has provider: {cfg.provider}).[/red]"
293
+ )
294
+ raise typer.Exit(1)
295
+
278
296
  if cfg.provider == "manual":
279
297
  from lablink_cli.commands.deploy_compose import run_deploy_compose
280
298
 
@@ -283,6 +301,7 @@ def deploy(
283
301
  yes=yes,
284
302
  tailscale_authkey=tailscale_authkey,
285
303
  cloudflare_tunnel_token=cloudflare_tunnel_token,
304
+ render_only=render_only,
286
305
  )
287
306
  return
288
307
 
@@ -16,6 +16,7 @@ from rich.panel import Panel
16
16
 
17
17
  from lablink_allocator_service.conf.structured_config import Config
18
18
 
19
+ from lablink_cli import manual
19
20
  from lablink_cli.commands.setup import (
20
21
  _get_session,
21
22
  check_credentials,
@@ -28,8 +29,6 @@ from lablink_cli.docker import Docker, default_docker
28
29
 
29
30
  console = Console()
30
31
 
31
- DEFAULT_COMPOSE_DIR = Path.home() / ".lablink" / "compose"
32
-
33
32
 
34
33
  def _delete_if_exists(
35
34
  action: str, fn, *args, **kwargs
@@ -614,15 +613,21 @@ def run_cleanup(
614
613
  # Manual-provider cleanup
615
614
  # ------------------------------------------------------------------
616
615
  def _run_cleanup_manual(
617
- cfg: Config, *, dry_run: bool, docker: Docker | None = None
616
+ cfg: Config,
617
+ *,
618
+ dry_run: bool,
619
+ docker: Docker | None = None,
620
+ workdir_root: Path | None = None,
618
621
  ) -> None:
619
622
  """Tear down a local docker-compose allocator stack.
620
623
 
621
624
  Runs `docker compose down --volumes` in the deployment workdir and
622
625
  then removes the workdir itself. No AWS API calls are made.
626
+
627
+ `workdir_root` overrides the default compose root (used by tests).
623
628
  """
624
629
  docker = docker or default_docker()
625
- workdir = DEFAULT_COMPOSE_DIR / (cfg.deployment_name or "lablink")
630
+ workdir = manual.workdir(cfg, workdir_root)
626
631
 
627
632
  console.print(
628
633
  f"[bold]Manual cleanup:[/bold] {cfg.deployment_name or 'lablink'}"
@@ -273,6 +273,20 @@ def _prompt_passwords() -> dict[str, str]:
273
273
  }
274
274
 
275
275
 
276
+ def _warn_letsencrypt_rate_limit(cfg: Config) -> None:
277
+ """Warn that LE issues ≤5 certs/domain/7 days — redeploys fail opaquely."""
278
+ if cfg.ssl.provider != "letsencrypt":
279
+ return
280
+ console.print(
281
+ " [yellow]Note:[/yellow] Let's Encrypt issues at most 5 "
282
+ "certificates per domain per 7 days. Repeated deploys of "
283
+ f"'{cfg.dns.domain}' within a week will hit this limit and the "
284
+ "site will fail with ERR_SSL_PROTOCOL_ERROR. Use a fresh "
285
+ "subdomain for repeated test deploys."
286
+ )
287
+ console.print()
288
+
289
+
276
290
  def _build_health_poll_target(cfg: Config, ec2_ip: str) -> dict:
277
291
  """Pick the post-deploy poll URL + timeout. Caddy is Host-bound under
278
292
  letsencrypt/cloudflare, so those must poll the domain, not the IP."""
@@ -372,6 +386,8 @@ def run_deploy(
372
386
  )
373
387
  console.print()
374
388
 
389
+ _warn_letsencrypt_rate_limit(cfg)
390
+
375
391
  # Validate AWS credentials
376
392
  check_credentials(_get_session(cfg.app.region))
377
393
 
@@ -35,9 +35,15 @@ from lablink_cli.deployment_metrics import (
35
35
  write_metrics,
36
36
  )
37
37
  from lablink_cli.docker import Docker, DockerUnavailable, default_docker
38
+ from lablink_cli.manual import (
39
+ CANONICAL_URL_FILENAME,
40
+ DEFAULT_COMPOSE_DIR, # noqa: F401 — re-exported for callers/tests
41
+ DEFAULT_HTTP_PORT,
42
+ RUNTIME_FILENAME,
43
+ deployment_runtime,
44
+ workdir as compose_workdir,
45
+ )
38
46
 
39
- DEFAULT_COMPOSE_DIR = Path.home() / ".lablink" / "compose"
40
- DEFAULT_HTTP_PORT = "80"
41
47
  HEALTH_POLL_TIMEOUT_SECONDS = 300
42
48
  ALLOCATOR_IMAGE_BASE = "ghcr.io/talmolab/lablink-allocator-image"
43
49
  # Only ssl=none is supported by the manual-provider compose stack today:
@@ -61,25 +67,9 @@ FUNNEL_ENABLE_RETRY_DELAY_SECONDS = 2
61
67
  # a few seconds; 6 tries 5s apart leaves ~25s of headroom over that.
62
68
  PUBLIC_HOSTNAME_MAX_ATTEMPTS = 6
63
69
  PUBLIC_HOSTNAME_RETRY_DELAY_SECONDS = 5
64
- # Name of the file carrying the allocator's real public URL, staged next to
65
- # config.yaml and bind-mounted to /config/<name>. Must stay in sync with
66
- # config_helpers.CANONICAL_URL_FILENAME in the allocator package — duplicated
67
- # rather than imported because each package's CI job installs only its own
68
- # dependencies, so a cross-package import would fail there. Guarded by
69
- # test_deploy_compose.py::TestCanonicalUrlFile::test_filename_matches_allocator.
70
- CANONICAL_URL_FILENAME = "allocator-url"
71
-
72
70
  console = Console()
73
71
 
74
72
 
75
- def compose_workdir(cfg: Config, root: Path | None = None) -> Path:
76
- """Path to the rendered compose working directory for this deployment.
77
-
78
- `root` overrides `DEFAULT_COMPOSE_DIR` (used by tests via `workdir_root`).
79
- """
80
- name = cfg.deployment_name or "lablink"
81
- return (root or DEFAULT_COMPOSE_DIR) / name
82
-
83
73
 
84
74
  def _read_env_value(env_path: Path, key: str) -> str | None:
85
75
  """Read a single KEY=value line from an existing .env file.
@@ -292,8 +282,11 @@ def run_deploy_compose(
292
282
  tailscale_authkey: str | None = None,
293
283
  cloudflare_tunnel_token: str | None = None,
294
284
  docker: Docker | None = None,
285
+ render_only: bool = False,
295
286
  ) -> None:
296
- """Bring up the allocator stack via docker-compose.
287
+ """Bring up the allocator stack via docker-compose, or (with
288
+ `render_only=True`) render it for an external container platform
289
+ instead.
297
290
 
298
291
  Renders the compose working directory (`compose_workdir(cfg)`),
299
292
  runs `docker compose up -d`, polls the allocator's `/api/health`
@@ -302,6 +295,14 @@ def run_deploy_compose(
302
295
 
303
296
  `yes=True` skips the interactive confirmation prompt.
304
297
  `workdir_root` overrides `DEFAULT_COMPOSE_DIR` (used by tests).
298
+ `render_only=True` renders the compose bundle (config.yaml,
299
+ custom-startup.sh, the canonical-URL file) and writes the
300
+ `RUNTIME_FILENAME` marker, then prints a launch sheet and returns —
301
+ no docker on this machine is required or used. For running the
302
+ allocator image as a workload on an external container platform
303
+ (Run:AI, Kubernetes) instead of via `docker compose up`. Rejected
304
+ up front when the config needs the Tailscale sidecar, which such
305
+ platforms do not grant kernel TUN/NET_ADMIN access for.
305
306
  `tailscale_authkey` is required when a tailnet join is needed for
306
307
  either `cfg.manual.connectivity == "mesh_overlay"` or
307
308
  `cfg.manual.participant_exposure == "tailscale_funnel"`, unless a
@@ -322,6 +323,28 @@ def run_deploy_compose(
322
323
  target = compose_workdir(cfg, workdir_root)
323
324
 
324
325
  needs_sidecar = _needs_tailscale_sidecar(cfg)
326
+
327
+ # Preflight: --render-only can't serve a config that needs the
328
+ # Tailscale sidecar — a managed external container platform (the
329
+ # whole point of --render-only) grants the workload no kernel TUN
330
+ # device / NET_ADMIN, which containerboot requires to join a tailnet.
331
+ # Placed before ANY docker use — including the
332
+ # _tailscale_state_volume_exists check just below, which calls
333
+ # docker.volume_exists() and therefore docker.require() — so a
334
+ # docker-less machine gets this clean message instead of an unhandled
335
+ # DockerUnavailable traceback.
336
+ if render_only and needs_sidecar:
337
+ console.print(
338
+ "[red]--render-only cannot serve this config: "
339
+ f"manual.connectivity={cfg.manual.connectivity!r} / "
340
+ f"manual.participant_exposure={cfg.manual.participant_exposure!r} "
341
+ "need the Tailscale sidecar (kernel TUN device + NET_ADMIN), "
342
+ "which managed container platforms do not grant. Use "
343
+ "connectivity=reverse_tunnel with "
344
+ "participant_exposure=cloudflare_tunnel instead.[/red]"
345
+ )
346
+ raise SystemExit(1)
347
+
325
348
  if needs_sidecar:
326
349
  # Checking ".env exists" alone (i.e. "is this a redeploy") isn't
327
350
  # enough: a redeploy that *switches* to needing the sidecar has
@@ -431,16 +454,19 @@ def run_deploy_compose(
431
454
  )
432
455
  raise SystemExit(1)
433
456
 
434
- # Preflight: docker on PATH.
435
- try:
436
- docker.require()
437
- except DockerUnavailable:
438
- console.print(
439
- "[red]docker not found on PATH.[/red] "
440
- "Install Docker Engine + the Compose plugin "
441
- "(https://docs.docker.com/engine/install/) and re-run."
442
- )
443
- raise SystemExit(1)
457
+ # Preflight: docker on PATH. Not needed for --render-only — the whole
458
+ # point is running the image on a platform without a local docker
459
+ # daemon.
460
+ if not render_only:
461
+ try:
462
+ docker.require()
463
+ except DockerUnavailable:
464
+ console.print(
465
+ "[red]docker not found on PATH.[/red] "
466
+ "Install Docker Engine + the Compose plugin "
467
+ "(https://docs.docker.com/engine/install/) and re-run."
468
+ )
469
+ raise SystemExit(1)
444
470
 
445
471
  # Resolve admin credentials (mirrors AWS deploy.py). The wizard does
446
472
  # NOT collect admin user/password — they're resolved here. Write the
@@ -473,13 +499,35 @@ def run_deploy_compose(
473
499
  raise SystemExit(1)
474
500
 
475
501
  if not yes:
476
- action = "create" if not target.exists() else "update"
502
+ existed_before = target.exists()
503
+ action = "create" if not existed_before else "update"
504
+ verb = (
505
+ f"render an external-runtime bundle in {target}"
506
+ if render_only
507
+ else f"{action} compose stack in {target}"
508
+ )
477
509
  console.print(
478
- f"About to {action} compose stack in {target}\n"
510
+ f"About to {verb}\n"
479
511
  f" provider: manual\n"
480
512
  f" ssl: {cfg.ssl.provider}\n"
481
513
  f" admin user: {admin_user}\n"
482
514
  )
515
+ # A workdir that already exists with no external marker is a live
516
+ # compose-managed deployment. --render-only would flip it to
517
+ # external — after which `lablink destroy` stops managing any
518
+ # local compose stack for it at all, orphaning one still running.
519
+ # Only the marker transition is checked here (no docker probe):
520
+ # the transition itself is the signal, regardless of whether a
521
+ # stack happens to be up right now.
522
+ if render_only and existed_before and deployment_runtime(target) == "compose":
523
+ console.print(
524
+ "[bold red]Warning:[/bold red] this deployment is currently "
525
+ "managed by docker-compose. --render-only will flip it to "
526
+ "external-runtime — afterward, `lablink destroy` no longer "
527
+ "manages any local docker-compose stack for it. If a "
528
+ "compose stack is running for this deployment, tear it "
529
+ "down FIRST with `lablink destroy`, before proceeding here."
530
+ )
483
531
  if not typer.confirm("Proceed?", default=True):
484
532
  console.print("Aborted.")
485
533
  raise SystemExit(1)
@@ -514,6 +562,62 @@ def run_deploy_compose(
514
562
  )
515
563
  console.print(f"[green]Rendered {target}[/green]")
516
564
 
565
+ # Write or clear the runtime marker `deployment_runtime()` reads
566
+ # back (status/logs/destroy branch on it). render_only returns
567
+ # immediately after — no docker.compose/health-poll/summary,
568
+ # since an external platform runs the image, not this machine.
569
+ marker = target / RUNTIME_FILENAME
570
+ if render_only:
571
+ marker.write_text("external\n")
572
+
573
+ # A bundle with no recorded public URL leaves every day-2
574
+ # command broken: `status`/`logs` have no address to reach the
575
+ # externally-run allocator at, and (until now) `status` simply
576
+ # probed localhost, which has nothing listening on it for this
577
+ # mode. Read the canonical-URL file directly rather than
578
+ # importing status._public_url, to avoid a needless
579
+ # cross-module reach for one string check.
580
+ canonical_url = (target / CANONICAL_URL_FILENAME).read_text().strip()
581
+ if not canonical_url.startswith(("http://", "https://")):
582
+ console.print(
583
+ "[red]--render-only produced a bundle with no public "
584
+ "URL recorded.[/red]\n"
585
+ "Day-2 `lablink status` / `lablink logs` need one to "
586
+ "reach the externally-run allocator. The only exposure "
587
+ "mode --render-only supports today is "
588
+ "manual.participant_exposure: cloudflare_tunnel (with "
589
+ "manual.public_hostname set) — update your config and "
590
+ "re-render."
591
+ )
592
+ raise SystemExit(1)
593
+
594
+ # Render-only's own success write, mirroring the compose
595
+ # path's success write below (allocator_deploy_end_time /
596
+ # allocator_total_deployment_duration_seconds / status) —
597
+ # the render IS the deploy action here, so without this the
598
+ # in_progress record written above would never be finalized
599
+ # (the early return skips the compose path's write entirely).
600
+ # No compose_up/health_check phases ran in this path, so the
601
+ # duration sums to 0 — an honest reflection of "no docker
602
+ # phases," not a stand-in for a real elapsed time.
603
+ metrics.allocator_deploy_end_time = datetime.now(timezone.utc).isoformat()
604
+ metrics.allocator_total_deployment_duration_seconds = round(
605
+ sum(
606
+ v
607
+ for v in (
608
+ metrics.allocator_compose_up_duration_seconds,
609
+ metrics.allocator_health_check_duration_seconds,
610
+ )
611
+ if v is not None
612
+ ),
613
+ 3,
614
+ )
615
+ metrics.status = "success"
616
+ write_metrics(metrics_path, metrics)
617
+ _print_launch_sheet(cfg, target, console)
618
+ return
619
+ marker.unlink(missing_ok=True)
620
+
517
621
  # Explicitly disable Funnel *before* _compose_up, whenever the new
518
622
  # config no longer wants it — this must run before --remove-orphans
519
623
  # potentially deletes the sidecar (a removed container can't be
@@ -860,6 +964,34 @@ def _print_last_log_lines(lines: int = 30, *, docker: Docker) -> None:
860
964
  console.print(_redact_secrets(result.stdout))
861
965
 
862
966
 
967
+ def _print_launch_sheet(cfg: Config, workdir: Path, console: Console) -> None:
968
+ """Print everything needed to launch the rendered bundle on an
969
+ external container platform (the platform is the container runtime;
970
+ nothing needs docker on this machine)."""
971
+ cf_token = _read_env_value(workdir / ".env", "CLOUDFLARE_TUNNEL_TOKEN")
972
+ console.print("\n[bold]Bundle rendered — launch it on your platform:[/bold]")
973
+ console.print(f" Image: {_allocator_image(cfg)} (default command)")
974
+ console.print(" Env vars:")
975
+ console.print(f" PARTICIPANT_EXPOSURE={cfg.manual.participant_exposure}")
976
+ if cfg.manual.participant_exposure == "cloudflare_tunnel":
977
+ console.print(f" CLOUDFLARE_TUNNEL_TOKEN={cf_token or '<missing>'}")
978
+ console.print(" Mounts (read-only files, all rendered in this dir):")
979
+ console.print(f" {workdir}/config.yaml -> /config/config.yaml")
980
+ console.print(f" {workdir}/custom-startup.sh -> /config/custom-startup.sh")
981
+ console.print(f" {workdir}/allocator-url -> /config/allocator-url")
982
+ console.print(
983
+ " Persistent volume (optional): /var/lib/postgresql "
984
+ "(DB survives pod restarts)"
985
+ )
986
+ console.print(" Inbound ports: none required — every leg dials out.")
987
+ console.print(" The image expects to run as root (postgres + nginx).")
988
+ console.print(
989
+ "\n After boot, the BYO register token prints in the workload's "
990
+ "log\n (also at /var/log/lablink/allocator.log inside the "
991
+ "container).\n See docs: CLI → External runtime (Run:AI walkthrough)."
992
+ )
993
+
994
+
863
995
  def _print_summary(
864
996
  cfg: Config,
865
997
  *,
@@ -1155,6 +1287,36 @@ def run_destroy_compose(
1155
1287
  )
1156
1288
  return
1157
1289
 
1290
+ if deployment_runtime(target) == "external":
1291
+ console.print(
1292
+ "[bold]This deployment runs on an external platform[/bold] — "
1293
+ "there is nothing to stop here.\n"
1294
+ "Delete the platform workload yourself (e.g. "
1295
+ "`runai workspace delete <name> -p <project>`); any Postgres "
1296
+ "data lives in the volume you attached there.\n"
1297
+ f"Removing only the local bundle at {target}."
1298
+ )
1299
+ if not yes:
1300
+ confirmation = typer.prompt(
1301
+ f"Type 'yes' to remove the local bundle at {target}",
1302
+ default="no",
1303
+ show_default=False,
1304
+ )
1305
+ if confirmation.strip().lower() != "yes":
1306
+ console.print("Aborted.")
1307
+ raise SystemExit(1)
1308
+ shutil.rmtree(target)
1309
+ console.print(f"[green]Removed {target}.[/green]")
1310
+ console.print(
1311
+ "\n[bold]Reminder:[/bold] this only removed the local rendered "
1312
+ "bundle — every BYO client still has `lablink-client` running, "
1313
+ "and any client submitted as its own platform workload is "
1314
+ "still running there too.\n"
1315
+ "Run [bold]lablink client unregister[/bold] on each BYO box, "
1316
+ "and delete each client workload on the platform yourself."
1317
+ )
1318
+ return
1319
+
1158
1320
  if not yes:
1159
1321
  if not keep_data:
1160
1322
  console.print(
@@ -366,27 +366,6 @@ def _check_manual_prereqs(*, docker: Docker | None = None) -> None:
366
366
  # client on this machine actually working?"
367
367
  # --------------------------------------------------------------------
368
368
 
369
- # A shipper that is alive but hasn't shipped in this long is reporting a
370
- # problem no liveness check can see — the process is up and the container is
371
- # healthy, but nothing is reaching the allocator. That combination went
372
- # unnoticed for a week (lablink#428), which is the reason this command exists.
373
- SHIPPER_STALE_AFTER_S = 15 * 60
374
-
375
-
376
- def _format_age(seconds: float) -> str:
377
- """Coarse human age ("6d", "3h", "20m") for the staleness message.
378
-
379
- A raw minute count reads as noise once it passes a few hours
380
- ("8687 min ago"), and this is the one line an operator scans to decide
381
- whether logs are flowing.
382
- """
383
- if seconds >= 86400:
384
- return f"{int(seconds // 86400)}d"
385
- if seconds >= 3600:
386
- return f"{int(seconds // 3600)}h"
387
- return f"{int(seconds // 60)}m"
388
-
389
-
390
369
  def _check_client_registered() -> dict:
391
370
  """Check that `lablink client register` has run on this box."""
392
371
  from lablink_cli.commands.register import DEFAULT_ENV_FILE
@@ -409,7 +388,7 @@ def _check_client_container(docker: Docker) -> dict:
409
388
  "daemon_error" when the daemon is unreachable, so a separate probe would
410
389
  only duplicate the same `docker inspect` call.
411
390
  """
412
- from lablink_cli.log_shipper import CONTAINER_NAME
391
+ from lablink_cli.commands.register import CONTAINER_NAME
413
392
 
414
393
  result = {"check": "Client container", "status": "fail"}
415
394
  status = docker.container_status(CONTAINER_NAME)
@@ -440,59 +419,34 @@ def _check_client_container(docker: Docker) -> dict:
440
419
  return result
441
420
 
442
421
 
443
- def _check_log_shipper(now: float | None = None) -> dict:
444
- """Check the log shipper is alive AND actually shipping.
422
+ def _check_log_shipper(docker: Docker) -> dict:
423
+ """Check the in-container ship_logs worker is running.
445
424
 
446
- Liveness alone is not enough. A shipper can sit blocked on a quiet
447
- container with a full buffer, process up, nothing delivered — so this
448
- also reports how long ago a batch last landed.
425
+ The shipper lives inside the client container start.sh feeds every
426
+ service's output through the client package's ship_logs worker when
427
+ SHIP_LOGS=1 so the probe is a pgrep inside the container. The old
428
+ host-side staleness check (lablink#428's alive-but-not-shipping
429
+ hazard) moved in-container with it: a worker that can't reach the
430
+ allocator says so in `docker logs lablink-client`
431
+ ("ship_logs: dropped N lines after retries").
449
432
  """
450
- import time
451
- from datetime import datetime, timezone
452
-
453
- from lablink_cli.commands.register import _shipper_alive
454
- from lablink_cli.log_shipper import STATE_FILE, read_last_shipped_ts
433
+ from lablink_cli.commands.register import CONTAINER_NAME
455
434
 
456
435
  result = {"check": "Log shipper", "status": "fail"}
457
436
 
458
- if not _shipper_alive():
459
- result["detail"] = (
460
- "Not running — client logs are not reaching the allocator. "
461
- "Run `lablink client register` to restart it."
462
- )
463
- return result
464
-
465
- last = read_last_shipped_ts(STATE_FILE)
466
- if last is None:
467
- result["status"] = "warn"
468
- result["detail"] = (
469
- "Running, but has never shipped a batch. Normal for the first "
470
- "minute after registering; otherwise check the allocator URL "
471
- "and client secret."
472
- )
473
- return result
474
-
475
- try:
476
- shipped_at = datetime.strptime(last, "%Y-%m-%dT%H:%M:%SZ").replace(
477
- tzinfo=timezone.utc
478
- )
479
- except ValueError:
480
- result["status"] = "warn"
481
- result["detail"] = f"Running; unparseable last-shipped value {last!r}"
482
- return result
483
-
484
- current = now if now is not None else time.time()
485
- age_s = current - shipped_at.timestamp()
486
- if age_s > SHIPPER_STALE_AFTER_S:
487
- result["status"] = "warn"
488
- result["detail"] = (
489
- f"Running, but last shipped {_format_age(age_s)} ago ({last}). "
490
- "The process is up but nothing is reaching the allocator."
491
- )
437
+ probe = docker.exec_in(CONTAINER_NAME, ["pgrep", "-f", "ship_logs"])
438
+ if probe.ok:
439
+ result["status"] = "pass"
440
+ result["detail"] = "ship_logs worker running inside the container"
492
441
  return result
493
442
 
494
- result["status"] = "pass"
495
- result["detail"] = f"Running; last shipped {last}"
443
+ result["detail"] = (
444
+ "No ship_logs worker inside the container — client logs are not "
445
+ "reaching the allocator. The container is either down (see the "
446
+ "check above), running an image that predates in-container "
447
+ "shipping, or missing SHIP_LOGS=1 in its env. Re-run "
448
+ "`lablink client register --force` to recreate it."
449
+ )
496
450
  return result
497
451
 
498
452
 
@@ -512,7 +466,7 @@ def run_client_doctor(*, docker: Docker | None = None) -> None:
512
466
  checks = [
513
467
  _check_client_registered(),
514
468
  _check_client_container(docker),
515
- _check_log_shipper(),
469
+ _check_log_shipper(docker),
516
470
  ]
517
471
 
518
472
  _render_checks(