lablink-cli 0.2.0__tar.gz → 0.4.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.2.0 → lablink_cli-0.4.0}/PKG-INFO +2 -2
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/pyproject.toml +2 -2
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/__init__.py +2 -2
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/app.py +7 -7
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/deploy_compose.py +39 -22
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/doctor.py +142 -14
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/config/schema.py +14 -4
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli.egg-info/PKG-INFO +2 -2
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli.egg-info/requires.txt +1 -1
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_deploy_compose.py +65 -7
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_doctor.py +136 -10
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_schema.py +7 -3
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/README.md +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/setup.cfg +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/api.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/byo_detect.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/__init__.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/cleanup.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/deploy.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/export_metrics.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/launch.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/logs.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/register.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/reset_overlay.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/setup.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/stats.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/status.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/unregister.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/utils.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/config/__init__.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/deployment_metrics.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/docker.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/manual.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/templates/docker-compose.tailscale-override.yml +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/templates/docker-compose.yml +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/tofu_source.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/tui/__init__.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/tui/logs_viewer.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/tui/wizard.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli.egg-info/SOURCES.txt +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli.egg-info/dependency_links.txt +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli.egg-info/entry_points.txt +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli.egg-info/top_level.txt +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_api.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_app.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_byo_detect.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_cleanup.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_cleanup_full.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_deploy.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_deployment_metrics.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_docker.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_docker_isolation.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_doctor_full.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_export_metrics.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_launch.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_logs.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_manual.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_register.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_registration_client.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_reset_overlay.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_setup.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_setup_full.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_stats.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_status.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_status_full.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_status_manual.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_tofu_source.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_unregister.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_utils.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_utils_full.py +0 -0
- {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_wizard.py +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lablink-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -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.4.0"
|
|
9
9
|
description = "CLI tool for deploying and managing LabLink infrastructure"
|
|
10
10
|
readme = "README.md"
|
|
11
11
|
requires-python = ">=3.10"
|
|
@@ -13,7 +13,7 @@ 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",
|
|
@@ -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,19 +258,19 @@ 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
274
|
),
|
|
275
275
|
render_only: bool = typer.Option(
|
|
276
276
|
False,
|
|
@@ -89,6 +89,27 @@ def _read_env_value(env_path: Path, key: str) -> str | None:
|
|
|
89
89
|
return None
|
|
90
90
|
|
|
91
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
|
+
|
|
92
113
|
def _needs_tailscale_sidecar(cfg: Config) -> bool:
|
|
93
114
|
"""True if a tailnet join is needed for either of two independent
|
|
94
115
|
reasons: reaching mesh-overlay clients, or publishing the allocator
|
|
@@ -303,8 +324,8 @@ def run_deploy_compose(
|
|
|
303
324
|
(Run:AI, Kubernetes) instead of via `docker compose up`. Rejected
|
|
304
325
|
up front when the config needs the Tailscale sidecar, which such
|
|
305
326
|
platforms do not grant kernel TUN/NET_ADMIN access for.
|
|
306
|
-
`tailscale_authkey` is
|
|
307
|
-
either `cfg.manual.connectivity == "mesh_overlay"` or
|
|
327
|
+
`tailscale_authkey` is prompted for (hidden) when a tailnet join is
|
|
328
|
+
needed for either `cfg.manual.connectivity == "mesh_overlay"` or
|
|
308
329
|
`cfg.manual.participant_exposure == "tailscale_funnel"`, unless a
|
|
309
330
|
value is already on record in this deployment's existing `.env`
|
|
310
331
|
(carried forward on ordinary redeploys by `render_compose_dir`) or
|
|
@@ -313,7 +334,7 @@ def run_deploy_compose(
|
|
|
313
334
|
`lablink destroy`, which wipes the working directory — including
|
|
314
335
|
`.env` — but keeps that volume specifically so this doesn't force a
|
|
315
336
|
needless re-auth).
|
|
316
|
-
`cloudflare_tunnel_token` is
|
|
337
|
+
`cloudflare_tunnel_token` is prompted for (hidden) when
|
|
317
338
|
`cfg.manual.participant_exposure == "cloudflare_tunnel"`, unless a
|
|
318
339
|
value is already on record in this deployment's existing `.env`. There
|
|
319
340
|
is no state-volume equivalent here: the tunnel's identity lives in
|
|
@@ -350,24 +371,22 @@ def run_deploy_compose(
|
|
|
350
371
|
# enough: a redeploy that *switches* to needing the sidecar has
|
|
351
372
|
# an existing .env, but that .env has no TS_AUTHKEY line to carry
|
|
352
373
|
# forward. Read the actual prior value (if any) so that case
|
|
353
|
-
# still
|
|
354
|
-
# rendering an empty key.
|
|
374
|
+
# still prompts instead of silently rendering an empty key.
|
|
355
375
|
previous_authkey = _read_env_value(target / ".env", "TS_AUTHKEY")
|
|
356
376
|
if (
|
|
357
377
|
not tailscale_authkey
|
|
358
378
|
and not previous_authkey
|
|
359
379
|
and not _tailscale_state_volume_exists(target, docker=docker)
|
|
360
380
|
):
|
|
361
|
-
|
|
362
|
-
"
|
|
363
|
-
"is
|
|
364
|
-
"'
|
|
365
|
-
"and no
|
|
366
|
-
"deployment
|
|
367
|
-
"Generate
|
|
368
|
-
"
|
|
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).",
|
|
369
389
|
)
|
|
370
|
-
raise SystemExit(1)
|
|
371
390
|
|
|
372
391
|
# Preflight: cloudflare_tunnel needs a hostname and a token. The
|
|
373
392
|
# hostname is also checked by get_config_errors(), but `lablink deploy`
|
|
@@ -403,16 +422,14 @@ def run_deploy_compose(
|
|
|
403
422
|
raise SystemExit(1)
|
|
404
423
|
previous_cf_token = _read_env_value(target / ".env", "CLOUDFLARE_TUNNEL_TOKEN")
|
|
405
424
|
if not cloudflare_tunnel_token and not previous_cf_token:
|
|
406
|
-
|
|
407
|
-
"
|
|
408
|
-
"
|
|
409
|
-
"
|
|
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"
|
|
410
429
|
"Create a tunnel in Cloudflare's Zero Trust dashboard "
|
|
411
|
-
"(Networks > Tunnels)
|
|
412
|
-
"install command,
|
|
413
|
-
"--cloudflare-tunnel-token <token>."
|
|
430
|
+
"(Networks > Tunnels) and copy the token from its Docker "
|
|
431
|
+
"install command.",
|
|
414
432
|
)
|
|
415
|
-
raise SystemExit(1)
|
|
416
433
|
|
|
417
434
|
# Preflight: SSL provider must be one the compose template supports.
|
|
418
435
|
# The allocator image has no TLS terminator, so only ssl=none works
|
|
@@ -240,9 +240,112 @@ def _check_s3_bucket(cfg) -> dict:
|
|
|
240
240
|
return result
|
|
241
241
|
|
|
242
242
|
|
|
243
|
+
# Mirrors data.aws_ami.default_client in the client terraform. Ubuntu 24.04 is pinned
|
|
244
|
+
# there because 26.04 is published on the same dates, and this filter has to match or
|
|
245
|
+
# doctor would verify a different image than the deployment uses.
|
|
246
|
+
DEFAULT_CLIENT_AMI_FILTER = (
|
|
247
|
+
"Deep Learning Base OSS Nvidia Driver GPU AMI (Ubuntu 24.04)*"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _check_default_client_ami(region: str, published: str | None) -> dict:
|
|
252
|
+
"""Resolve the image an empty machine.ami_id would get, in *region*."""
|
|
253
|
+
result = {"check": "Client AMI", "status": "fail"}
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
from lablink_cli.commands.setup import _get_session
|
|
257
|
+
|
|
258
|
+
images = (
|
|
259
|
+
_get_session(region)
|
|
260
|
+
.client("ec2")
|
|
261
|
+
.describe_images(
|
|
262
|
+
Owners=["amazon"],
|
|
263
|
+
Filters=[{"Name": "name", "Values": [DEFAULT_CLIENT_AMI_FILTER]}],
|
|
264
|
+
)["Images"]
|
|
265
|
+
)
|
|
266
|
+
except Exception as e:
|
|
267
|
+
result["status"] = "warn"
|
|
268
|
+
result["detail"] = (
|
|
269
|
+
f"machine.ami_id is empty (resolve automatically); could not verify a "
|
|
270
|
+
f"Deep Learning Base AMI in {region}: {e}"
|
|
271
|
+
)
|
|
272
|
+
return result
|
|
273
|
+
|
|
274
|
+
if images:
|
|
275
|
+
newest = max(images, key=lambda i: i["CreationDate"])
|
|
276
|
+
result["status"] = "pass"
|
|
277
|
+
result["detail"] = (
|
|
278
|
+
f"empty → {newest['ImageId']} ({newest['Name']}) in {region}"
|
|
279
|
+
)
|
|
280
|
+
return result
|
|
281
|
+
|
|
282
|
+
hint = f"set it to {published}" if published else (
|
|
283
|
+
f"set it to an image that exists in {region}"
|
|
284
|
+
)
|
|
285
|
+
result["detail"] = (
|
|
286
|
+
f"machine.ami_id is empty, but AWS publishes no Deep Learning Base AMI in "
|
|
287
|
+
f"{region} to fall back to — {hint}"
|
|
288
|
+
)
|
|
289
|
+
return result
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _check_viewer_streaming(cfg) -> dict:
|
|
293
|
+
"""Warn when the viewer will be served over plain HTTP.
|
|
294
|
+
|
|
295
|
+
KasmVNC's H.264 path needs the browser's WebCodecs VideoDecoder, which Chrome
|
|
296
|
+
exposes only on secure origins. With ssl.provider "none" the viewer is served
|
|
297
|
+
over http://, the codec probe reports "WebCodecs API not available", and every
|
|
298
|
+
session falls back to JPEG/WebP stills — re-encoding damaged regions from
|
|
299
|
+
scratch every frame instead of encoding inter-frame change.
|
|
300
|
+
|
|
301
|
+
Nothing else surfaces this. The deploy is green, sessions work, and the
|
|
302
|
+
server-side encoder is fine; it simply never gets asked for video mode. The
|
|
303
|
+
symptom reaches an operator as "the desktop feels laggy", which is a long way
|
|
304
|
+
from "ssl.provider is none".
|
|
305
|
+
"""
|
|
306
|
+
result = {"check": "Viewer streaming", "status": "pass"}
|
|
307
|
+
|
|
308
|
+
if cfg is None:
|
|
309
|
+
result["status"] = "warn"
|
|
310
|
+
result["detail"] = "Skipped (no valid config)"
|
|
311
|
+
return result
|
|
312
|
+
|
|
313
|
+
provider = (getattr(getattr(cfg, "ssl", None), "provider", "") or "none").lower()
|
|
314
|
+
|
|
315
|
+
if provider == "none":
|
|
316
|
+
result["status"] = "warn"
|
|
317
|
+
result["detail"] = (
|
|
318
|
+
"ssl.provider is 'none', so the viewer is served over HTTP and H.264 "
|
|
319
|
+
"streaming cannot engage — sessions fall back to JPEG/WebP. Configure an "
|
|
320
|
+
"SSL provider, or to test as-is port-forward the allocator "
|
|
321
|
+
"(ssh -L 8443:localhost:5000 ...) and open http://localhost:8443, since "
|
|
322
|
+
"localhost counts as a secure origin"
|
|
323
|
+
)
|
|
324
|
+
return result
|
|
325
|
+
|
|
326
|
+
result["detail"] = f"H.264 available — viewer served over HTTPS ({provider})"
|
|
327
|
+
return result
|
|
328
|
+
|
|
329
|
+
|
|
243
330
|
def _check_ami(cfg) -> dict:
|
|
244
|
-
"""Check that
|
|
245
|
-
|
|
331
|
+
"""Check that the configured client AMI exists in the configured region.
|
|
332
|
+
|
|
333
|
+
Client VMs are the only ones needing a custom image, since the GPU drivers are
|
|
334
|
+
baked in, which makes machine.ami_id the one region-scoped value a deployment
|
|
335
|
+
has to get right. The allocator boots stock Ubuntu resolved per region from an
|
|
336
|
+
SSM parameter and constrains nothing.
|
|
337
|
+
|
|
338
|
+
AMI_MAP is consulted only to suggest a value. Asking EC2 whether the configured
|
|
339
|
+
image actually exists is the whole point: a region absent from the table is fine
|
|
340
|
+
if the operator copied an image into it, and a region present in the table is no
|
|
341
|
+
guarantee that the ID in *their* config is right.
|
|
342
|
+
|
|
343
|
+
An empty machine.ami_id is not a missing value — it asks the client terraform to
|
|
344
|
+
resolve AWS's Deep Learning Base AMI for the deployment's region (see
|
|
345
|
+
data.aws_ami.default_client). So the empty case resolves the same image the
|
|
346
|
+
deployment would and reports which one, rather than reporting a gap.
|
|
347
|
+
"""
|
|
348
|
+
result = {"check": "Client AMI", "status": "fail"}
|
|
246
349
|
|
|
247
350
|
if cfg is None:
|
|
248
351
|
result["status"] = "warn"
|
|
@@ -252,17 +355,39 @@ def _check_ami(cfg) -> dict:
|
|
|
252
355
|
from lablink_cli.config.schema import AMI_MAP
|
|
253
356
|
|
|
254
357
|
region = cfg.app.region
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
)
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
)
|
|
358
|
+
ami_id = getattr(getattr(cfg, "machine", None), "ami_id", None)
|
|
359
|
+
published = AMI_MAP.get(region)
|
|
360
|
+
|
|
361
|
+
if not ami_id:
|
|
362
|
+
return _check_default_client_ami(region, published)
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
from lablink_cli.commands.setup import _get_session
|
|
366
|
+
|
|
367
|
+
session = _get_session(region)
|
|
368
|
+
session.client("ec2").describe_images(ImageIds=[ami_id])
|
|
369
|
+
except Exception as e:
|
|
370
|
+
if "InvalidAMIID" in str(e):
|
|
371
|
+
if published:
|
|
372
|
+
hint = f"use {published}"
|
|
373
|
+
else:
|
|
374
|
+
hint = (
|
|
375
|
+
f"clear machine.ami_id to resolve a Deep Learning Base AMI for "
|
|
376
|
+
f"{region} automatically, or copy an image from "
|
|
377
|
+
f"{', '.join(AMI_MAP)} into {region}"
|
|
378
|
+
)
|
|
379
|
+
result["detail"] = f"{ami_id} does not exist in {region} — {hint}"
|
|
380
|
+
else:
|
|
381
|
+
# No credentials, no ec2:DescribeImages, network trouble: unverified is
|
|
382
|
+
# not the same as wrong, so warn rather than failing a good config.
|
|
383
|
+
result["status"] = "warn"
|
|
384
|
+
result["detail"] = f"Could not verify {ami_id} in {region}: {e}"
|
|
385
|
+
return result
|
|
386
|
+
|
|
387
|
+
result["status"] = "pass"
|
|
388
|
+
result["detail"] = f"{ami_id} exists in {region}"
|
|
389
|
+
if published and ami_id != published:
|
|
390
|
+
result["detail"] += " (custom, not the image LabLink publishes there)"
|
|
266
391
|
|
|
267
392
|
return result
|
|
268
393
|
|
|
@@ -288,9 +413,12 @@ def _check_aws_prereqs() -> None:
|
|
|
288
413
|
# 5. S3 state bucket
|
|
289
414
|
checks.append(_check_s3_bucket(cfg))
|
|
290
415
|
|
|
291
|
-
# 6. AMI
|
|
416
|
+
# 6. Client AMI
|
|
292
417
|
checks.append(_check_ami(cfg))
|
|
293
418
|
|
|
419
|
+
# 7. Viewer streaming
|
|
420
|
+
checks.append(_check_viewer_streaming(cfg))
|
|
421
|
+
|
|
294
422
|
_render_checks(
|
|
295
423
|
checks,
|
|
296
424
|
pass_message=(
|
|
@@ -114,12 +114,22 @@ def validate_config(cfg: Config) -> list[str]:
|
|
|
114
114
|
return errors
|
|
115
115
|
|
|
116
116
|
|
|
117
|
-
#
|
|
117
|
+
# Regions LabLink publishes a client image in (Ubuntu 24.04 + Docker + NVIDIA driver).
|
|
118
|
+
#
|
|
119
|
+
# This is a suggestion, not a limit. Only client VMs need a custom image, because the
|
|
120
|
+
# GPU drivers are baked in; the allocator boots stock Ubuntu resolved per region from
|
|
121
|
+
# an SSM parameter, so it constrains nothing. Any region works if machine.ami_id names
|
|
122
|
+
# an image that exists there — a copy of one of these, or an AWS Deep Learning Base
|
|
123
|
+
# AMI — and doctor asks EC2 rather than trusting this table.
|
|
124
|
+
#
|
|
125
|
+
# AMI IDs are region-scoped, so every entry here is a distinct ID. A repeated ID means
|
|
126
|
+
# some region points at another region's image, which is a config that passes every
|
|
127
|
+
# local check and then fails to launch: that is exactly what this map used to do, with
|
|
128
|
+
# four regions sharing one us-west-2 ID.
|
|
118
129
|
AMI_MAP: dict[str, str] = {
|
|
119
|
-
"us-east-1": "ami-0601752c11b394251",
|
|
120
|
-
"us-east-2": "ami-0601752c11b394251",
|
|
121
|
-
"us-west-1": "ami-0601752c11b394251",
|
|
122
130
|
"us-west-2": "ami-0601752c11b394251",
|
|
131
|
+
"us-east-1": "ami-0c3412413810adacc",
|
|
132
|
+
"us-east-2": "ami-0cd7567480c4840a0",
|
|
123
133
|
}
|
|
124
134
|
|
|
125
135
|
# Common GPU instance types
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lablink-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -423,14 +423,41 @@ class TestRenderComposeDirParticipantExposure:
|
|
|
423
423
|
|
|
424
424
|
|
|
425
425
|
class TestDeployComposeMeshOverlayPreflight:
|
|
426
|
+
@patch("getpass.getpass", return_value="")
|
|
426
427
|
@patch("lablink_cli.commands.deploy_compose._tailscale_state_volume_exists")
|
|
427
|
-
def
|
|
428
|
+
def test_first_deploy_without_authkey_and_empty_prompt_rejected(
|
|
429
|
+
self, mock_state_exists, mock_getpass, tmp_path
|
|
430
|
+
):
|
|
431
|
+
"""Declining the prompt (empty answer) is still a hard stop — the
|
|
432
|
+
sidecar cannot join a tailnet without a key."""
|
|
428
433
|
from lablink_cli.commands.deploy_compose import run_deploy_compose
|
|
429
434
|
|
|
430
435
|
mock_state_exists.return_value = False
|
|
431
436
|
cfg = _manual_cfg(connectivity="mesh_overlay", overlay_tailnet="example.ts.net")
|
|
432
437
|
with pytest.raises(SystemExit):
|
|
433
438
|
run_deploy_compose(cfg, yes=True, workdir_root=tmp_path)
|
|
439
|
+
mock_getpass.assert_called_once()
|
|
440
|
+
|
|
441
|
+
@patch("getpass.getpass", return_value="tskey-typed")
|
|
442
|
+
@patch("lablink_cli.commands.deploy_compose._print_summary")
|
|
443
|
+
@patch("lablink_cli.commands.deploy_compose._health_poll")
|
|
444
|
+
@patch("lablink_cli.commands.deploy_compose._compose_up")
|
|
445
|
+
@patch("lablink_cli.commands.deploy_compose._tailscale_state_volume_exists")
|
|
446
|
+
def test_first_deploy_without_authkey_prompts_and_uses_the_answer(
|
|
447
|
+
self, mock_state_exists, mock_up, mock_poll, mock_summary, mock_getpass,
|
|
448
|
+
tmp_path,
|
|
449
|
+
):
|
|
450
|
+
"""getpass, not input(): the key must stay off the terminal (and out
|
|
451
|
+
of shell history) exactly like the admin password."""
|
|
452
|
+
from lablink_cli.commands.deploy_compose import run_deploy_compose
|
|
453
|
+
|
|
454
|
+
mock_state_exists.return_value = False
|
|
455
|
+
cfg = _manual_cfg(connectivity="mesh_overlay", overlay_tailnet="example.ts.net")
|
|
456
|
+
run_deploy_compose(cfg, yes=True, workdir_root=tmp_path)
|
|
457
|
+
|
|
458
|
+
mock_getpass.assert_called_once()
|
|
459
|
+
env_content = (tmp_path / "testlab" / ".env").read_text()
|
|
460
|
+
assert "TS_AUTHKEY=tskey-typed" in env_content
|
|
434
461
|
|
|
435
462
|
@patch("lablink_cli.commands.deploy_compose._print_summary")
|
|
436
463
|
@patch("lablink_cli.commands.deploy_compose._health_poll")
|
|
@@ -493,15 +520,18 @@ class TestDeployComposeMeshOverlayPreflight:
|
|
|
493
520
|
@patch("lablink_cli.commands.deploy_compose._print_summary")
|
|
494
521
|
@patch("lablink_cli.commands.deploy_compose._health_poll")
|
|
495
522
|
@patch("lablink_cli.commands.deploy_compose._compose_up")
|
|
523
|
+
@patch("getpass.getpass", return_value="")
|
|
496
524
|
def test_switch_from_lan_direct_without_authkey_rejected(
|
|
497
|
-
self, mock_up, mock_poll, mock_summary, mock_state_exists,
|
|
525
|
+
self, mock_getpass, mock_up, mock_poll, mock_summary, mock_state_exists,
|
|
526
|
+
tmp_path,
|
|
498
527
|
):
|
|
499
528
|
"""Regression guard: an existing lan_direct deployment (its .env
|
|
500
529
|
has no TS_AUTHKEY line) that switches manual.connectivity to
|
|
501
|
-
mesh_overlay must still be
|
|
530
|
+
mesh_overlay must still be asked for an authkey.
|
|
502
531
|
".env exists" alone is not a valid proxy for "an authkey is on
|
|
503
532
|
record" — without this guard the preflight silently skipped the
|
|
504
|
-
check and render_compose_dir wrote TS_AUTHKEY= (empty).
|
|
533
|
+
check and render_compose_dir wrote TS_AUTHKEY= (empty). The empty
|
|
534
|
+
prompt answer here stands in for the operator having none."""
|
|
505
535
|
from lablink_cli.commands.deploy_compose import run_deploy_compose
|
|
506
536
|
|
|
507
537
|
mock_state_exists.return_value = False
|
|
@@ -540,7 +570,10 @@ class TestDeployComposeMeshOverlayPreflight:
|
|
|
540
570
|
|
|
541
571
|
class TestDeployComposeParticipantExposurePreflight:
|
|
542
572
|
@patch("lablink_cli.commands.deploy_compose._tailscale_state_volume_exists")
|
|
543
|
-
|
|
573
|
+
@patch("getpass.getpass", return_value="")
|
|
574
|
+
def test_lan_direct_with_funnel_requires_authkey(
|
|
575
|
+
self, mock_getpass, mock_state_exists, tmp_path
|
|
576
|
+
):
|
|
544
577
|
"""A lan_direct deployment that enables tailscale_funnel still
|
|
545
578
|
needs the sidecar to join a tailnet — same requirement as
|
|
546
579
|
mesh_overlay, generalized."""
|
|
@@ -2418,7 +2451,10 @@ class TestCloudflareTunnelPreflights:
|
|
|
2418
2451
|
**kw,
|
|
2419
2452
|
)
|
|
2420
2453
|
|
|
2421
|
-
|
|
2454
|
+
@patch("getpass.getpass", return_value="")
|
|
2455
|
+
def test_first_deploy_without_token_and_empty_prompt_is_rejected(
|
|
2456
|
+
self, mock_getpass, tmp_path
|
|
2457
|
+
):
|
|
2422
2458
|
from lablink_cli.commands.deploy_compose import run_deploy_compose
|
|
2423
2459
|
|
|
2424
2460
|
with pytest.raises(SystemExit):
|
|
@@ -2427,9 +2463,31 @@ class TestCloudflareTunnelPreflights:
|
|
|
2427
2463
|
yes=True,
|
|
2428
2464
|
workdir_root=tmp_path,
|
|
2429
2465
|
# Supplied so the sidecar's own authkey preflight can't be
|
|
2430
|
-
# what
|
|
2466
|
+
# what prompts — this test is about the tunnel token.
|
|
2431
2467
|
tailscale_authkey="tskey-abc",
|
|
2432
2468
|
)
|
|
2469
|
+
mock_getpass.assert_called_once()
|
|
2470
|
+
|
|
2471
|
+
@patch("getpass.getpass", return_value="eyJhIjoiTYPED")
|
|
2472
|
+
@patch("lablink_cli.commands.deploy_compose._verify_public_hostname")
|
|
2473
|
+
@patch("lablink_cli.commands.deploy_compose._print_summary")
|
|
2474
|
+
@patch("lablink_cli.commands.deploy_compose._health_poll")
|
|
2475
|
+
@patch("lablink_cli.commands.deploy_compose._compose_up")
|
|
2476
|
+
def test_first_deploy_without_token_prompts_and_uses_the_answer(
|
|
2477
|
+
self, mock_up, mock_poll, mock_summary, mock_verify, mock_getpass, tmp_path
|
|
2478
|
+
):
|
|
2479
|
+
from lablink_cli.commands.deploy_compose import run_deploy_compose
|
|
2480
|
+
|
|
2481
|
+
run_deploy_compose(
|
|
2482
|
+
self._cf_cfg(),
|
|
2483
|
+
yes=True,
|
|
2484
|
+
workdir_root=tmp_path,
|
|
2485
|
+
tailscale_authkey="tskey-abc",
|
|
2486
|
+
)
|
|
2487
|
+
|
|
2488
|
+
mock_getpass.assert_called_once()
|
|
2489
|
+
env_content = (tmp_path / "testlab" / ".env").read_text()
|
|
2490
|
+
assert "CLOUDFLARE_TUNNEL_TOKEN=eyJhIjoiTYPED" in env_content
|
|
2433
2491
|
|
|
2434
2492
|
def test_empty_public_hostname_is_rejected(self, tmp_path):
|
|
2435
2493
|
from lablink_cli.commands.deploy_compose import run_deploy_compose
|
|
@@ -5,8 +5,11 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
from unittest.mock import MagicMock, patch
|
|
7
7
|
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
8
10
|
|
|
9
11
|
from lablink_cli.commands.doctor import (
|
|
12
|
+
_check_viewer_streaming,
|
|
10
13
|
_check_ami,
|
|
11
14
|
_check_opentofu,
|
|
12
15
|
)
|
|
@@ -121,24 +124,108 @@ class TestCheckOpenTofu:
|
|
|
121
124
|
# ------------------------------------------------------------------
|
|
122
125
|
# _check_ami
|
|
123
126
|
# ------------------------------------------------------------------
|
|
127
|
+
def _ami_cfg(region, ami_id):
|
|
128
|
+
cfg = MagicMock()
|
|
129
|
+
cfg.app.region = region
|
|
130
|
+
cfg.machine.ami_id = ami_id
|
|
131
|
+
return cfg
|
|
132
|
+
|
|
133
|
+
|
|
124
134
|
class TestCheckAmi:
|
|
125
135
|
def test_no_config(self):
|
|
126
136
|
result = _check_ami(None)
|
|
127
137
|
assert result["status"] == "warn"
|
|
128
138
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
139
|
+
@patch("lablink_cli.commands.setup._get_session")
|
|
140
|
+
def test_empty_ami_id_resolves_the_fallback(self, mock_session):
|
|
141
|
+
"""Empty is the recommended value, not a gap: the client terraform resolves a
|
|
142
|
+
Deep Learning Base AMI for the region, so doctor resolves the same image and
|
|
143
|
+
reports which one."""
|
|
144
|
+
mock_session.return_value.client.return_value.describe_images.return_value = {
|
|
145
|
+
"Images": [
|
|
146
|
+
{"ImageId": "ami-old", "Name": "DLAMI 24.04 20260101",
|
|
147
|
+
"CreationDate": "2026-01-01T00:00:00.000Z"},
|
|
148
|
+
{"ImageId": "ami-new", "Name": "DLAMI 24.04 20260828",
|
|
149
|
+
"CreationDate": "2026-08-28T00:00:00.000Z"},
|
|
150
|
+
]
|
|
151
|
+
}
|
|
152
|
+
result = _check_ami(_ami_cfg("eu-west-1", ""))
|
|
133
153
|
assert result["status"] == "pass"
|
|
134
|
-
assert "ami-" in result["detail"]
|
|
154
|
+
assert "ami-new" in result["detail"], "must pick the newest, like most_recent"
|
|
155
|
+
assert "ami-old" not in result["detail"]
|
|
156
|
+
|
|
157
|
+
@patch("lablink_cli.commands.setup._get_session")
|
|
158
|
+
def test_empty_ami_id_fails_where_no_fallback_is_published(self, mock_session):
|
|
159
|
+
mock_session.return_value.client.return_value.describe_images.return_value = {
|
|
160
|
+
"Images": []
|
|
161
|
+
}
|
|
162
|
+
result = _check_ami(_ami_cfg("us-west-2", ""))
|
|
163
|
+
assert result["status"] == "fail"
|
|
164
|
+
# should suggest the published image for the region
|
|
165
|
+
assert "ami-0601752c11b394251" in result["detail"]
|
|
135
166
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
167
|
+
@patch("lablink_cli.commands.setup._get_session")
|
|
168
|
+
def test_empty_ami_id_warns_when_unverifiable(self, mock_session):
|
|
169
|
+
mock_session.return_value.client.return_value.describe_images.side_effect = (
|
|
170
|
+
Exception("AuthFailure")
|
|
171
|
+
)
|
|
172
|
+
result = _check_ami(_ami_cfg("eu-west-1", ""))
|
|
173
|
+
assert result["status"] == "warn"
|
|
174
|
+
|
|
175
|
+
@patch("lablink_cli.commands.setup._get_session")
|
|
176
|
+
def test_wrong_region_ami_suggests_clearing_the_field(self, mock_session):
|
|
177
|
+
"""The fix an operator most wants in an unpublished region is 'clear it'."""
|
|
178
|
+
mock_session.return_value.client.return_value.describe_images.side_effect = (
|
|
179
|
+
Exception("An error occurred (InvalidAMIID.NotFound)")
|
|
180
|
+
)
|
|
181
|
+
result = _check_ami(_ami_cfg("eu-west-1", "ami-0601752c11b394251"))
|
|
140
182
|
assert result["status"] == "fail"
|
|
141
|
-
assert "
|
|
183
|
+
assert "clear machine.ami_id" in result["detail"]
|
|
184
|
+
|
|
185
|
+
@patch("lablink_cli.commands.setup._get_session")
|
|
186
|
+
def test_existing_ami_passes(self, mock_session):
|
|
187
|
+
result = _check_ami(_ami_cfg("us-west-2", "ami-0601752c11b394251"))
|
|
188
|
+
assert result["status"] == "pass"
|
|
189
|
+
mock_session.return_value.client.return_value.describe_images.assert_called_once_with(
|
|
190
|
+
ImageIds=["ami-0601752c11b394251"]
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
@patch("lablink_cli.commands.setup._get_session")
|
|
194
|
+
def test_missing_ami_fails(self, mock_session):
|
|
195
|
+
mock_session.return_value.client.return_value.describe_images.side_effect = (
|
|
196
|
+
Exception("An error occurred (InvalidAMIID.NotFound)")
|
|
197
|
+
)
|
|
198
|
+
result = _check_ami(_ami_cfg("us-west-2", "ami-00000000000000000"))
|
|
199
|
+
assert result["status"] == "fail"
|
|
200
|
+
assert "does not exist in us-west-2" in result["detail"]
|
|
201
|
+
|
|
202
|
+
@patch("lablink_cli.commands.setup._get_session")
|
|
203
|
+
def test_unpublished_region_with_its_own_image_passes(self, mock_session):
|
|
204
|
+
"""The old check failed any region absent from AMI_MAP. An operator who
|
|
205
|
+
copied an image into eu-west-1 has a working config, and saying otherwise
|
|
206
|
+
sends them to fix something that is not broken."""
|
|
207
|
+
result = _check_ami(_ami_cfg("eu-west-1", "ami-0abcdef1234567890"))
|
|
208
|
+
assert result["status"] == "pass"
|
|
209
|
+
assert "eu-west-1" in result["detail"]
|
|
210
|
+
|
|
211
|
+
@patch("lablink_cli.commands.setup._get_session")
|
|
212
|
+
def test_unverifiable_warns_rather_than_fails(self, mock_session):
|
|
213
|
+
"""No credentials or no ec2:DescribeImages means unknown, not wrong."""
|
|
214
|
+
mock_session.return_value.client.return_value.describe_images.side_effect = (
|
|
215
|
+
Exception("AuthFailure")
|
|
216
|
+
)
|
|
217
|
+
result = _check_ami(_ami_cfg("us-west-2", "ami-0601752c11b394251"))
|
|
218
|
+
assert result["status"] == "warn"
|
|
219
|
+
|
|
220
|
+
@patch("lablink_cli.commands.setup._get_session")
|
|
221
|
+
def test_us_east_1_no_longer_gets_a_us_west_2_ami(self, mock_session):
|
|
222
|
+
"""AMI_MAP used to hand us-east-1 the us-west-2 ID and call it a pass."""
|
|
223
|
+
from lablink_cli.config.schema import AMI_MAP
|
|
224
|
+
|
|
225
|
+
assert AMI_MAP["us-east-1"] != AMI_MAP["us-west-2"]
|
|
226
|
+
result = _check_ami(_ami_cfg("us-east-1", AMI_MAP["us-west-2"]))
|
|
227
|
+
assert result["status"] == "pass"
|
|
228
|
+
assert "custom" in result["detail"]
|
|
142
229
|
|
|
143
230
|
|
|
144
231
|
# ------------------------------------------------------------------
|
|
@@ -354,3 +441,42 @@ class TestRunClientDoctor:
|
|
|
354
441
|
assert "Registered" in out
|
|
355
442
|
assert "Log shipper" in out
|
|
356
443
|
assert "All checks passed" in out
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# ------------------------------------------------------------------
|
|
447
|
+
# _check_viewer_streaming
|
|
448
|
+
# ------------------------------------------------------------------
|
|
449
|
+
def _ssl_cfg(provider):
|
|
450
|
+
cfg = MagicMock()
|
|
451
|
+
cfg.ssl.provider = provider
|
|
452
|
+
return cfg
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
class TestCheckViewerStreaming:
|
|
456
|
+
def test_no_config(self):
|
|
457
|
+
result = _check_viewer_streaming(None)
|
|
458
|
+
assert result["status"] == "warn"
|
|
459
|
+
|
|
460
|
+
def test_http_deployment_warns_with_a_way_to_test(self):
|
|
461
|
+
"""The whole point: an HTTP deployment silently loses H.264, and the
|
|
462
|
+
operator sees 'laggy desktop', not 'ssl.provider is none'."""
|
|
463
|
+
result = _check_viewer_streaming(_ssl_cfg("none"))
|
|
464
|
+
assert result["status"] == "warn"
|
|
465
|
+
assert "JPEG/WebP" in result["detail"]
|
|
466
|
+
assert "localhost" in result["detail"], "must name the port-forward workaround"
|
|
467
|
+
|
|
468
|
+
def test_warns_rather_than_fails(self):
|
|
469
|
+
"""HTTP is a supported deployment, just a slower one — failing preflight
|
|
470
|
+
over a performance cliff would block a legitimate config."""
|
|
471
|
+
assert _check_viewer_streaming(_ssl_cfg("none"))["status"] != "fail"
|
|
472
|
+
|
|
473
|
+
@pytest.mark.parametrize("provider", ["letsencrypt", "cloudflare", "acm"])
|
|
474
|
+
def test_https_providers_pass(self, provider):
|
|
475
|
+
result = _check_viewer_streaming(_ssl_cfg(provider))
|
|
476
|
+
assert result["status"] == "pass"
|
|
477
|
+
assert provider in result["detail"]
|
|
478
|
+
|
|
479
|
+
def test_missing_ssl_section_is_treated_as_http(self):
|
|
480
|
+
cfg = MagicMock()
|
|
481
|
+
cfg.ssl.provider = None
|
|
482
|
+
assert _check_viewer_streaming(cfg)["status"] == "warn"
|
|
@@ -235,10 +235,14 @@ class TestReferenceData:
|
|
|
235
235
|
assert not DEPLOYMENT_NAME_RE.match("-invalid")
|
|
236
236
|
assert not DEPLOYMENT_NAME_RE.match("invalid-")
|
|
237
237
|
|
|
238
|
-
def
|
|
239
|
-
|
|
240
|
-
|
|
238
|
+
def test_ami_map_gives_each_region_its_own_image(self):
|
|
239
|
+
"""AMI IDs are region-scoped, so a repeated ID means some region is
|
|
240
|
+
pointing at another region's image — which is how this map used to
|
|
241
|
+
claim four regions with one us-west-2 AMI."""
|
|
242
|
+
assert "us-west-2" in AMI_MAP
|
|
243
|
+
for ami in AMI_MAP.values():
|
|
241
244
|
assert ami.startswith("ami-")
|
|
245
|
+
assert len(set(AMI_MAP.values())) == len(AMI_MAP)
|
|
242
246
|
|
|
243
247
|
def test_gpu_instance_types_structure(self):
|
|
244
248
|
assert len(GPU_INSTANCE_TYPES) > 0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|