lablink-cli 0.1.0a1__tar.gz → 0.3.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.0a1 → lablink_cli-0.3.0}/PKG-INFO +2 -3
  2. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/pyproject.toml +2 -3
  3. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/__init__.py +2 -2
  4. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/app.py +26 -7
  5. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/cleanup.py +9 -4
  6. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/deploy.py +16 -0
  7. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/deploy_compose.py +241 -53
  8. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/doctor.py +165 -83
  9. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/logs.py +81 -17
  10. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/register.py +26 -144
  11. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/reset_overlay.py +1 -1
  12. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/status.py +66 -87
  13. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/utils.py +12 -39
  14. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/config/schema.py +41 -4
  15. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/docker.py +0 -26
  16. lablink_cli-0.3.0/src/lablink_cli/manual.py +167 -0
  17. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/tui/logs_viewer.py +130 -47
  18. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/tui/wizard.py +25 -2
  19. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli.egg-info/PKG-INFO +2 -3
  20. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli.egg-info/SOURCES.txt +2 -2
  21. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli.egg-info/requires.txt +1 -2
  22. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_cleanup.py +3 -3
  23. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_deploy.py +24 -0
  24. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_deploy_compose.py +432 -7
  25. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_docker.py +1 -61
  26. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_doctor.py +153 -58
  27. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_logs.py +149 -11
  28. lablink_cli-0.3.0/tests/test_manual.py +186 -0
  29. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_register.py +39 -347
  30. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_schema.py +7 -3
  31. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_status.py +37 -11
  32. lablink_cli-0.3.0/tests/test_status_manual.py +257 -0
  33. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_utils_full.py +37 -1
  34. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_wizard.py +75 -0
  35. lablink_cli-0.1.0a1/src/lablink_cli/log_shipper.py +0 -441
  36. lablink_cli-0.1.0a1/tests/test_log_shipper.py +0 -673
  37. lablink_cli-0.1.0a1/tests/test_status_manual.py +0 -334
  38. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/README.md +0 -0
  39. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/setup.cfg +0 -0
  40. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/api.py +0 -0
  41. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/byo_detect.py +0 -0
  42. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/__init__.py +0 -0
  43. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/export_metrics.py +0 -0
  44. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/launch.py +0 -0
  45. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/setup.py +0 -0
  46. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/stats.py +0 -0
  47. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/unregister.py +0 -0
  48. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/config/__init__.py +0 -0
  49. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/deployment_metrics.py +0 -0
  50. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/templates/docker-compose.tailscale-override.yml +0 -0
  51. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/templates/docker-compose.yml +0 -0
  52. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/tofu_source.py +0 -0
  53. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/tui/__init__.py +0 -0
  54. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli.egg-info/dependency_links.txt +0 -0
  55. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli.egg-info/entry_points.txt +0 -0
  56. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli.egg-info/top_level.txt +0 -0
  57. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_api.py +0 -0
  58. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_app.py +0 -0
  59. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_byo_detect.py +0 -0
  60. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_cleanup_full.py +0 -0
  61. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_deployment_metrics.py +0 -0
  62. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_docker_isolation.py +0 -0
  63. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_doctor_full.py +0 -0
  64. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_export_metrics.py +0 -0
  65. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_launch.py +0 -0
  66. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_registration_client.py +0 -0
  67. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_reset_overlay.py +0 -0
  68. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_setup.py +0 -0
  69. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_setup_full.py +0 -0
  70. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_stats.py +0 -0
  71. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_status_full.py +0 -0
  72. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_tofu_source.py +0 -0
  73. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_unregister.py +0 -0
  74. {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_utils.py +0 -0
@@ -1,19 +1,18 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lablink-cli
3
- Version: 0.1.0a1
3
+ Version: 0.3.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
7
7
  Project-URL: Issues, https://github.com/talmolab/lablink/issues
8
8
  Requires-Python: >=3.10
9
9
  Description-Content-Type: text/markdown
10
- Requires-Dist: lablink-allocator-service[config]>=0.2.0
10
+ Requires-Dist: lablink-allocator-service[config]>=0.4.0
11
11
  Requires-Dist: typer>=0.15
12
12
  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.0a1"
8
+ version = "0.3.0"
9
9
  description = "CLI tool for deploying and managing LabLink infrastructure"
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.10"
@@ -13,13 +13,12 @@ dependencies = [
13
13
  # >=0.2.0: deploy_compose imports PUBLIC_HOSTNAME_HINT,
14
14
  # is_valid_public_hostname and is_weak_admin_password, none of which
15
15
  # exist in 0.1.2 — an unpinned resolve breaks `provider: manual`.
16
- "lablink-allocator-service[config]>=0.2.0",
16
+ "lablink-allocator-service[config]>=0.4.0",
17
17
  "typer>=0.15",
18
18
  "textual>=3.0",
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]
@@ -1,8 +1,8 @@
1
1
  """LabLink CLI - Deploy and manage LabLink infrastructure."""
2
2
 
3
3
  TEMPLATE_REPO = "talmolab/lablink-template"
4
- TEMPLATE_VERSION = "v0.3.0"
4
+ TEMPLATE_VERSION = "v0.3.1"
5
5
  # SHA-256 of the GitHub release tarball for TEMPLATE_VERSION.
6
6
  # Update this when bumping TEMPLATE_VERSION.
7
7
  # To compute: curl -sL <tarball_url> | sha256sum
8
- TEMPLATE_SHA256 = "3d3f8803990f80e6bcda0b3098178d909bc454315fdf2304e70dbee5d43e3bb6"
8
+ TEMPLATE_SHA256 = "97f2f96c7bbdcaeeec8bd681394883409357613bbe40f97115a7a974d61a7660"
@@ -258,23 +258,41 @@ def deploy(
258
258
  None,
259
259
  "--tailscale-authkey",
260
260
  help="Tailscale auth key for the allocator's own tailnet sidecar. "
261
- "Required on the first deploy when manual.connectivity is "
261
+ "Needed on the first deploy when manual.connectivity is "
262
262
  "'mesh_overlay' and/or manual.participant_exposure is "
263
- "'tailscale_funnel'; optional on redeploys (the previous value is "
264
- "carried forward). Manual provider only.",
263
+ "'tailscale_funnel' (prompted if omitted); optional on redeploys "
264
+ "(the previous value is carried forward). Manual provider only.",
265
265
  ),
266
266
  cloudflare_tunnel_token: str = typer.Option(
267
267
  None,
268
268
  "--cloudflare-tunnel-token",
269
269
  help="Cloudflare Tunnel token for publishing the allocator at "
270
- "manual.public_hostname. Required on the first deploy when "
271
- "manual.participant_exposure is 'cloudflare_tunnel'; optional on "
272
- "redeploys (the previous value is carried forward). Supply it again "
273
- "to rotate. Manual provider only.",
270
+ "manual.public_hostname. Needed on the first deploy when "
271
+ "manual.participant_exposure is 'cloudflare_tunnel' (prompted if "
272
+ "omitted); optional on redeploys (the previous value is carried "
273
+ "forward). Supply it again to rotate. Manual provider only.",
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.",
274
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.
@@ -99,6 +89,27 @@ def _read_env_value(env_path: Path, key: str) -> str | None:
99
89
  return None
100
90
 
101
91
 
92
+ def _prompt_secret(label: str, hint: str) -> str:
93
+ """Prompt for a credential the deploy needs but was not given a flag for.
94
+
95
+ Typer's own `prompt=` on the flag would fire on every deploy that omits
96
+ it, including AWS ones that never want the value. The two callers below
97
+ already know the credential is required, so the prompt lives there.
98
+
99
+ getpass keeps the value off the terminal (and out of a `vhs` recording),
100
+ matching how the admin password is collected in `deploy.py`.
101
+ """
102
+ import getpass
103
+
104
+ console.print(hint)
105
+ value = getpass.getpass(f" {label}: ").strip()
106
+ if not value:
107
+ console.print(f"[red]A {label.lower()} is required.[/red]")
108
+ raise SystemExit(1)
109
+ console.print()
110
+ return value
111
+
112
+
102
113
  def _needs_tailscale_sidecar(cfg: Config) -> bool:
103
114
  """True if a tailnet join is needed for either of two independent
104
115
  reasons: reaching mesh-overlay clients, or publishing the allocator
@@ -292,8 +303,11 @@ def run_deploy_compose(
292
303
  tailscale_authkey: str | None = None,
293
304
  cloudflare_tunnel_token: str | None = None,
294
305
  docker: Docker | None = None,
306
+ render_only: bool = False,
295
307
  ) -> None:
296
- """Bring up the allocator stack via docker-compose.
308
+ """Bring up the allocator stack via docker-compose, or (with
309
+ `render_only=True`) render it for an external container platform
310
+ instead.
297
311
 
298
312
  Renders the compose working directory (`compose_workdir(cfg)`),
299
313
  runs `docker compose up -d`, polls the allocator's `/api/health`
@@ -302,8 +316,16 @@ def run_deploy_compose(
302
316
 
303
317
  `yes=True` skips the interactive confirmation prompt.
304
318
  `workdir_root` overrides `DEFAULT_COMPOSE_DIR` (used by tests).
305
- `tailscale_authkey` is required when a tailnet join is needed for
306
- either `cfg.manual.connectivity == "mesh_overlay"` or
319
+ `render_only=True` renders the compose bundle (config.yaml,
320
+ custom-startup.sh, the canonical-URL file) and writes the
321
+ `RUNTIME_FILENAME` marker, then prints a launch sheet and returns —
322
+ no docker on this machine is required or used. For running the
323
+ allocator image as a workload on an external container platform
324
+ (Run:AI, Kubernetes) instead of via `docker compose up`. Rejected
325
+ up front when the config needs the Tailscale sidecar, which such
326
+ platforms do not grant kernel TUN/NET_ADMIN access for.
327
+ `tailscale_authkey` is prompted for (hidden) when a tailnet join is
328
+ needed for either `cfg.manual.connectivity == "mesh_overlay"` or
307
329
  `cfg.manual.participant_exposure == "tailscale_funnel"`, unless a
308
330
  value is already on record in this deployment's existing `.env`
309
331
  (carried forward on ordinary redeploys by `render_compose_dir`) or
@@ -312,7 +334,7 @@ def run_deploy_compose(
312
334
  `lablink destroy`, which wipes the working directory — including
313
335
  `.env` — but keeps that volume specifically so this doesn't force a
314
336
  needless re-auth).
315
- `cloudflare_tunnel_token` is required when
337
+ `cloudflare_tunnel_token` is prompted for (hidden) when
316
338
  `cfg.manual.participant_exposure == "cloudflare_tunnel"`, unless a
317
339
  value is already on record in this deployment's existing `.env`. There
318
340
  is no state-volume equivalent here: the tunnel's identity lives in
@@ -322,29 +344,49 @@ def run_deploy_compose(
322
344
  target = compose_workdir(cfg, workdir_root)
323
345
 
324
346
  needs_sidecar = _needs_tailscale_sidecar(cfg)
347
+
348
+ # Preflight: --render-only can't serve a config that needs the
349
+ # Tailscale sidecar — a managed external container platform (the
350
+ # whole point of --render-only) grants the workload no kernel TUN
351
+ # device / NET_ADMIN, which containerboot requires to join a tailnet.
352
+ # Placed before ANY docker use — including the
353
+ # _tailscale_state_volume_exists check just below, which calls
354
+ # docker.volume_exists() and therefore docker.require() — so a
355
+ # docker-less machine gets this clean message instead of an unhandled
356
+ # DockerUnavailable traceback.
357
+ if render_only and needs_sidecar:
358
+ console.print(
359
+ "[red]--render-only cannot serve this config: "
360
+ f"manual.connectivity={cfg.manual.connectivity!r} / "
361
+ f"manual.participant_exposure={cfg.manual.participant_exposure!r} "
362
+ "need the Tailscale sidecar (kernel TUN device + NET_ADMIN), "
363
+ "which managed container platforms do not grant. Use "
364
+ "connectivity=reverse_tunnel with "
365
+ "participant_exposure=cloudflare_tunnel instead.[/red]"
366
+ )
367
+ raise SystemExit(1)
368
+
325
369
  if needs_sidecar:
326
370
  # Checking ".env exists" alone (i.e. "is this a redeploy") isn't
327
371
  # enough: a redeploy that *switches* to needing the sidecar has
328
372
  # an existing .env, but that .env has no TS_AUTHKEY line to carry
329
373
  # forward. Read the actual prior value (if any) so that case
330
- # still requires --tailscale-authkey instead of silently
331
- # rendering an empty key.
374
+ # still prompts instead of silently rendering an empty key.
332
375
  previous_authkey = _read_env_value(target / ".env", "TS_AUTHKEY")
333
376
  if (
334
377
  not tailscale_authkey
335
378
  and not previous_authkey
336
379
  and not _tailscale_state_volume_exists(target, docker=docker)
337
380
  ):
338
- console.print(
339
- "[red]A Tailscale sidecar is needed (manual.connectivity "
340
- "is 'mesh_overlay' and/or manual.participant_exposure is "
341
- "'tailscale_funnel') but no --tailscale-authkey was given, "
342
- "and no previous value is on record for this "
343
- "deployment.[/red]\n"
344
- "Generate an authkey from your Tailscale admin console "
345
- "and re-run with --tailscale-authkey <key>."
381
+ tailscale_authkey = _prompt_secret(
382
+ "Tailscale authkey",
383
+ "A Tailscale sidecar is needed (manual.connectivity is "
384
+ "'mesh_overlay' and/or manual.participant_exposure is "
385
+ "'tailscale_funnel') and no authkey is on record for this "
386
+ "deployment.\n"
387
+ "Generate one from your Tailscale admin console "
388
+ "(Settings > Keys).",
346
389
  )
347
- raise SystemExit(1)
348
390
 
349
391
  # Preflight: cloudflare_tunnel needs a hostname and a token. The
350
392
  # hostname is also checked by get_config_errors(), but `lablink deploy`
@@ -380,16 +422,14 @@ def run_deploy_compose(
380
422
  raise SystemExit(1)
381
423
  previous_cf_token = _read_env_value(target / ".env", "CLOUDFLARE_TUNNEL_TOKEN")
382
424
  if not cloudflare_tunnel_token and not previous_cf_token:
383
- console.print(
384
- "[red]manual.participant_exposure is 'cloudflare_tunnel' but "
385
- "no --cloudflare-tunnel-token was given, and no previous "
386
- "value is on record for this deployment.[/red]\n"
425
+ cloudflare_tunnel_token = _prompt_secret(
426
+ "Cloudflare tunnel token",
427
+ "manual.participant_exposure is 'cloudflare_tunnel' and no "
428
+ "token is on record for this deployment.\n"
387
429
  "Create a tunnel in Cloudflare's Zero Trust dashboard "
388
- "(Networks > Tunnels), copy the token from its Docker "
389
- "install command, and re-run with "
390
- "--cloudflare-tunnel-token <token>."
430
+ "(Networks > Tunnels) and copy the token from its Docker "
431
+ "install command.",
391
432
  )
392
- raise SystemExit(1)
393
433
 
394
434
  # Preflight: SSL provider must be one the compose template supports.
395
435
  # The allocator image has no TLS terminator, so only ssl=none works
@@ -431,16 +471,19 @@ def run_deploy_compose(
431
471
  )
432
472
  raise SystemExit(1)
433
473
 
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)
474
+ # Preflight: docker on PATH. Not needed for --render-only — the whole
475
+ # point is running the image on a platform without a local docker
476
+ # daemon.
477
+ if not render_only:
478
+ try:
479
+ docker.require()
480
+ except DockerUnavailable:
481
+ console.print(
482
+ "[red]docker not found on PATH.[/red] "
483
+ "Install Docker Engine + the Compose plugin "
484
+ "(https://docs.docker.com/engine/install/) and re-run."
485
+ )
486
+ raise SystemExit(1)
444
487
 
445
488
  # Resolve admin credentials (mirrors AWS deploy.py). The wizard does
446
489
  # NOT collect admin user/password — they're resolved here. Write the
@@ -473,13 +516,35 @@ def run_deploy_compose(
473
516
  raise SystemExit(1)
474
517
 
475
518
  if not yes:
476
- action = "create" if not target.exists() else "update"
519
+ existed_before = target.exists()
520
+ action = "create" if not existed_before else "update"
521
+ verb = (
522
+ f"render an external-runtime bundle in {target}"
523
+ if render_only
524
+ else f"{action} compose stack in {target}"
525
+ )
477
526
  console.print(
478
- f"About to {action} compose stack in {target}\n"
527
+ f"About to {verb}\n"
479
528
  f" provider: manual\n"
480
529
  f" ssl: {cfg.ssl.provider}\n"
481
530
  f" admin user: {admin_user}\n"
482
531
  )
532
+ # A workdir that already exists with no external marker is a live
533
+ # compose-managed deployment. --render-only would flip it to
534
+ # external — after which `lablink destroy` stops managing any
535
+ # local compose stack for it at all, orphaning one still running.
536
+ # Only the marker transition is checked here (no docker probe):
537
+ # the transition itself is the signal, regardless of whether a
538
+ # stack happens to be up right now.
539
+ if render_only and existed_before and deployment_runtime(target) == "compose":
540
+ console.print(
541
+ "[bold red]Warning:[/bold red] this deployment is currently "
542
+ "managed by docker-compose. --render-only will flip it to "
543
+ "external-runtime — afterward, `lablink destroy` no longer "
544
+ "manages any local docker-compose stack for it. If a "
545
+ "compose stack is running for this deployment, tear it "
546
+ "down FIRST with `lablink destroy`, before proceeding here."
547
+ )
483
548
  if not typer.confirm("Proceed?", default=True):
484
549
  console.print("Aborted.")
485
550
  raise SystemExit(1)
@@ -514,6 +579,62 @@ def run_deploy_compose(
514
579
  )
515
580
  console.print(f"[green]Rendered {target}[/green]")
516
581
 
582
+ # Write or clear the runtime marker `deployment_runtime()` reads
583
+ # back (status/logs/destroy branch on it). render_only returns
584
+ # immediately after — no docker.compose/health-poll/summary,
585
+ # since an external platform runs the image, not this machine.
586
+ marker = target / RUNTIME_FILENAME
587
+ if render_only:
588
+ marker.write_text("external\n")
589
+
590
+ # A bundle with no recorded public URL leaves every day-2
591
+ # command broken: `status`/`logs` have no address to reach the
592
+ # externally-run allocator at, and (until now) `status` simply
593
+ # probed localhost, which has nothing listening on it for this
594
+ # mode. Read the canonical-URL file directly rather than
595
+ # importing status._public_url, to avoid a needless
596
+ # cross-module reach for one string check.
597
+ canonical_url = (target / CANONICAL_URL_FILENAME).read_text().strip()
598
+ if not canonical_url.startswith(("http://", "https://")):
599
+ console.print(
600
+ "[red]--render-only produced a bundle with no public "
601
+ "URL recorded.[/red]\n"
602
+ "Day-2 `lablink status` / `lablink logs` need one to "
603
+ "reach the externally-run allocator. The only exposure "
604
+ "mode --render-only supports today is "
605
+ "manual.participant_exposure: cloudflare_tunnel (with "
606
+ "manual.public_hostname set) — update your config and "
607
+ "re-render."
608
+ )
609
+ raise SystemExit(1)
610
+
611
+ # Render-only's own success write, mirroring the compose
612
+ # path's success write below (allocator_deploy_end_time /
613
+ # allocator_total_deployment_duration_seconds / status) —
614
+ # the render IS the deploy action here, so without this the
615
+ # in_progress record written above would never be finalized
616
+ # (the early return skips the compose path's write entirely).
617
+ # No compose_up/health_check phases ran in this path, so the
618
+ # duration sums to 0 — an honest reflection of "no docker
619
+ # phases," not a stand-in for a real elapsed time.
620
+ metrics.allocator_deploy_end_time = datetime.now(timezone.utc).isoformat()
621
+ metrics.allocator_total_deployment_duration_seconds = round(
622
+ sum(
623
+ v
624
+ for v in (
625
+ metrics.allocator_compose_up_duration_seconds,
626
+ metrics.allocator_health_check_duration_seconds,
627
+ )
628
+ if v is not None
629
+ ),
630
+ 3,
631
+ )
632
+ metrics.status = "success"
633
+ write_metrics(metrics_path, metrics)
634
+ _print_launch_sheet(cfg, target, console)
635
+ return
636
+ marker.unlink(missing_ok=True)
637
+
517
638
  # Explicitly disable Funnel *before* _compose_up, whenever the new
518
639
  # config no longer wants it — this must run before --remove-orphans
519
640
  # potentially deletes the sidecar (a removed container can't be
@@ -860,6 +981,34 @@ def _print_last_log_lines(lines: int = 30, *, docker: Docker) -> None:
860
981
  console.print(_redact_secrets(result.stdout))
861
982
 
862
983
 
984
+ def _print_launch_sheet(cfg: Config, workdir: Path, console: Console) -> None:
985
+ """Print everything needed to launch the rendered bundle on an
986
+ external container platform (the platform is the container runtime;
987
+ nothing needs docker on this machine)."""
988
+ cf_token = _read_env_value(workdir / ".env", "CLOUDFLARE_TUNNEL_TOKEN")
989
+ console.print("\n[bold]Bundle rendered — launch it on your platform:[/bold]")
990
+ console.print(f" Image: {_allocator_image(cfg)} (default command)")
991
+ console.print(" Env vars:")
992
+ console.print(f" PARTICIPANT_EXPOSURE={cfg.manual.participant_exposure}")
993
+ if cfg.manual.participant_exposure == "cloudflare_tunnel":
994
+ console.print(f" CLOUDFLARE_TUNNEL_TOKEN={cf_token or '<missing>'}")
995
+ console.print(" Mounts (read-only files, all rendered in this dir):")
996
+ console.print(f" {workdir}/config.yaml -> /config/config.yaml")
997
+ console.print(f" {workdir}/custom-startup.sh -> /config/custom-startup.sh")
998
+ console.print(f" {workdir}/allocator-url -> /config/allocator-url")
999
+ console.print(
1000
+ " Persistent volume (optional): /var/lib/postgresql "
1001
+ "(DB survives pod restarts)"
1002
+ )
1003
+ console.print(" Inbound ports: none required — every leg dials out.")
1004
+ console.print(" The image expects to run as root (postgres + nginx).")
1005
+ console.print(
1006
+ "\n After boot, the BYO register token prints in the workload's "
1007
+ "log\n (also at /var/log/lablink/allocator.log inside the "
1008
+ "container).\n See docs: CLI → External runtime (Run:AI walkthrough)."
1009
+ )
1010
+
1011
+
863
1012
  def _print_summary(
864
1013
  cfg: Config,
865
1014
  *,
@@ -916,6 +1065,15 @@ def _print_summary(
916
1065
  " Allocator URL (LAN): (no LAN IP detected — pass the "
917
1066
  "operator host's reachable address manually)"
918
1067
  )
1068
+ # public_url and lan_direct are mutually exclusive (preflight above), so
1069
+ # this chain encodes the connectivity rule without re-reading it.
1070
+ admin_url = public_url or lan_url or local_url
1071
+ # soft_wrap: a real Funnel URL plus the column prefix overruns 80 cols.
1072
+ console.print(
1073
+ f" Admin URL: {admin_url}/admin",
1074
+ soft_wrap=True,
1075
+ highlight=False,
1076
+ )
919
1077
  console.print(f" Admin user: {cfg.app.admin_user}")
920
1078
  if register_token:
921
1079
  console.print(f" Register token: {register_token}")
@@ -1146,6 +1304,36 @@ def run_destroy_compose(
1146
1304
  )
1147
1305
  return
1148
1306
 
1307
+ if deployment_runtime(target) == "external":
1308
+ console.print(
1309
+ "[bold]This deployment runs on an external platform[/bold] — "
1310
+ "there is nothing to stop here.\n"
1311
+ "Delete the platform workload yourself (e.g. "
1312
+ "`runai workspace delete <name> -p <project>`); any Postgres "
1313
+ "data lives in the volume you attached there.\n"
1314
+ f"Removing only the local bundle at {target}."
1315
+ )
1316
+ if not yes:
1317
+ confirmation = typer.prompt(
1318
+ f"Type 'yes' to remove the local bundle at {target}",
1319
+ default="no",
1320
+ show_default=False,
1321
+ )
1322
+ if confirmation.strip().lower() != "yes":
1323
+ console.print("Aborted.")
1324
+ raise SystemExit(1)
1325
+ shutil.rmtree(target)
1326
+ console.print(f"[green]Removed {target}.[/green]")
1327
+ console.print(
1328
+ "\n[bold]Reminder:[/bold] this only removed the local rendered "
1329
+ "bundle — every BYO client still has `lablink-client` running, "
1330
+ "and any client submitted as its own platform workload is "
1331
+ "still running there too.\n"
1332
+ "Run [bold]lablink client unregister[/bold] on each BYO box, "
1333
+ "and delete each client workload on the platform yourself."
1334
+ )
1335
+ return
1336
+
1149
1337
  if not yes:
1150
1338
  if not keep_data:
1151
1339
  console.print(