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.
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/PKG-INFO +2 -3
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/pyproject.toml +2 -3
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/__init__.py +2 -2
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/app.py +26 -7
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/cleanup.py +9 -4
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/deploy.py +16 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/deploy_compose.py +241 -53
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/doctor.py +165 -83
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/logs.py +81 -17
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/register.py +26 -144
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/reset_overlay.py +1 -1
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/status.py +66 -87
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/utils.py +12 -39
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/config/schema.py +41 -4
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/docker.py +0 -26
- lablink_cli-0.3.0/src/lablink_cli/manual.py +167 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/tui/logs_viewer.py +130 -47
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/tui/wizard.py +25 -2
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli.egg-info/PKG-INFO +2 -3
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli.egg-info/SOURCES.txt +2 -2
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli.egg-info/requires.txt +1 -2
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_cleanup.py +3 -3
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_deploy.py +24 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_deploy_compose.py +432 -7
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_docker.py +1 -61
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_doctor.py +153 -58
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_logs.py +149 -11
- lablink_cli-0.3.0/tests/test_manual.py +186 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_register.py +39 -347
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_schema.py +7 -3
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_status.py +37 -11
- lablink_cli-0.3.0/tests/test_status_manual.py +257 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_utils_full.py +37 -1
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_wizard.py +75 -0
- lablink_cli-0.1.0a1/src/lablink_cli/log_shipper.py +0 -441
- lablink_cli-0.1.0a1/tests/test_log_shipper.py +0 -673
- lablink_cli-0.1.0a1/tests/test_status_manual.py +0 -334
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/README.md +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/setup.cfg +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/api.py +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/byo_detect.py +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/__init__.py +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/export_metrics.py +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/launch.py +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/setup.py +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/stats.py +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/commands/unregister.py +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/config/__init__.py +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/deployment_metrics.py +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/templates/docker-compose.tailscale-override.yml +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/templates/docker-compose.yml +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/tofu_source.py +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli/tui/__init__.py +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli.egg-info/dependency_links.txt +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli.egg-info/entry_points.txt +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/src/lablink_cli.egg-info/top_level.txt +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_api.py +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_app.py +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_byo_detect.py +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_cleanup_full.py +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_deployment_metrics.py +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_docker_isolation.py +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_doctor_full.py +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_export_metrics.py +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_launch.py +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_registration_client.py +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_reset_overlay.py +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_setup.py +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_setup_full.py +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_stats.py +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_status_full.py +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_tofu_source.py +0 -0
- {lablink_cli-0.1.0a1 → lablink_cli-0.3.0}/tests/test_unregister.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 = "
|
|
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
|
-
"
|
|
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
|
|
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.
|
|
271
|
-
"manual.participant_exposure is 'cloudflare_tunnel'
|
|
272
|
-
"redeploys (the previous value is carried
|
|
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,
|
|
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 =
|
|
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
|
-
`
|
|
306
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
339
|
-
"
|
|
340
|
-
"is
|
|
341
|
-
"'
|
|
342
|
-
"and no
|
|
343
|
-
"deployment
|
|
344
|
-
"Generate
|
|
345
|
-
"
|
|
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
|
-
|
|
384
|
-
"
|
|
385
|
-
"
|
|
386
|
-
"
|
|
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)
|
|
389
|
-
"install command,
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
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 {
|
|
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(
|