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.
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/PKG-INFO +1 -2
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/pyproject.toml +1 -2
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/app.py +19 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/cleanup.py +9 -4
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/deploy.py +16 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/deploy_compose.py +193 -31
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/doctor.py +23 -69
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/logs.py +81 -17
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/register.py +26 -144
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/reset_overlay.py +1 -1
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/status.py +56 -87
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/utils.py +12 -39
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/config/schema.py +27 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/docker.py +0 -26
- lablink_cli-0.2.0/src/lablink_cli/manual.py +167 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/tui/logs_viewer.py +21 -1
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/tui/wizard.py +5 -2
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli.egg-info/PKG-INFO +1 -2
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli.egg-info/SOURCES.txt +2 -2
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli.egg-info/requires.txt +0 -1
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_cleanup.py +3 -3
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_deploy.py +24 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_deploy_compose.py +328 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_docker.py +1 -61
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_doctor.py +17 -48
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_logs.py +149 -11
- lablink_cli-0.2.0/tests/test_manual.py +186 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_register.py +39 -347
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_status.py +37 -11
- lablink_cli-0.2.0/tests/test_status_manual.py +257 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_utils_full.py +37 -1
- lablink_cli-0.1.0/src/lablink_cli/log_shipper.py +0 -441
- lablink_cli-0.1.0/tests/test_log_shipper.py +0 -673
- lablink_cli-0.1.0/tests/test_status_manual.py +0 -334
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/README.md +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/setup.cfg +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/__init__.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/api.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/byo_detect.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/__init__.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/export_metrics.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/launch.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/setup.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/stats.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/commands/unregister.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/config/__init__.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/deployment_metrics.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/templates/docker-compose.tailscale-override.yml +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/templates/docker-compose.yml +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/tofu_source.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli/tui/__init__.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli.egg-info/dependency_links.txt +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli.egg-info/entry_points.txt +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/src/lablink_cli.egg-info/top_level.txt +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_api.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_app.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_byo_detect.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_cleanup_full.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_deployment_metrics.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_docker_isolation.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_doctor_full.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_export_metrics.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_launch.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_registration_client.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_reset_overlay.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_schema.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_setup.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_setup_full.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_stats.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_status_full.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_tofu_source.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_unregister.py +0 -0
- {lablink_cli-0.1.0 → lablink_cli-0.2.0}/tests/test_utils.py +0 -0
- {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.
|
|
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.
|
|
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,
|
|
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.
|
|
@@ -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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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(
|
|
444
|
-
"""Check the
|
|
422
|
+
def _check_log_shipper(docker: Docker) -> dict:
|
|
423
|
+
"""Check the in-container ship_logs worker is running.
|
|
445
424
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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["
|
|
495
|
-
|
|
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(
|