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.
Files changed (71) hide show
  1. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/PKG-INFO +2 -2
  2. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/pyproject.toml +2 -2
  3. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/__init__.py +2 -2
  4. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/app.py +7 -7
  5. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/deploy_compose.py +39 -22
  6. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/doctor.py +142 -14
  7. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/config/schema.py +14 -4
  8. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli.egg-info/PKG-INFO +2 -2
  9. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli.egg-info/requires.txt +1 -1
  10. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_deploy_compose.py +65 -7
  11. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_doctor.py +136 -10
  12. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_schema.py +7 -3
  13. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/README.md +0 -0
  14. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/setup.cfg +0 -0
  15. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/api.py +0 -0
  16. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/byo_detect.py +0 -0
  17. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/__init__.py +0 -0
  18. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/cleanup.py +0 -0
  19. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/deploy.py +0 -0
  20. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/export_metrics.py +0 -0
  21. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/launch.py +0 -0
  22. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/logs.py +0 -0
  23. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/register.py +0 -0
  24. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/reset_overlay.py +0 -0
  25. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/setup.py +0 -0
  26. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/stats.py +0 -0
  27. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/status.py +0 -0
  28. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/unregister.py +0 -0
  29. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/commands/utils.py +0 -0
  30. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/config/__init__.py +0 -0
  31. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/deployment_metrics.py +0 -0
  32. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/docker.py +0 -0
  33. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/manual.py +0 -0
  34. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/templates/docker-compose.tailscale-override.yml +0 -0
  35. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/templates/docker-compose.yml +0 -0
  36. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/tofu_source.py +0 -0
  37. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/tui/__init__.py +0 -0
  38. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/tui/logs_viewer.py +0 -0
  39. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli/tui/wizard.py +0 -0
  40. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli.egg-info/SOURCES.txt +0 -0
  41. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli.egg-info/dependency_links.txt +0 -0
  42. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli.egg-info/entry_points.txt +0 -0
  43. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/src/lablink_cli.egg-info/top_level.txt +0 -0
  44. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_api.py +0 -0
  45. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_app.py +0 -0
  46. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_byo_detect.py +0 -0
  47. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_cleanup.py +0 -0
  48. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_cleanup_full.py +0 -0
  49. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_deploy.py +0 -0
  50. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_deployment_metrics.py +0 -0
  51. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_docker.py +0 -0
  52. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_docker_isolation.py +0 -0
  53. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_doctor_full.py +0 -0
  54. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_export_metrics.py +0 -0
  55. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_launch.py +0 -0
  56. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_logs.py +0 -0
  57. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_manual.py +0 -0
  58. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_register.py +0 -0
  59. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_registration_client.py +0 -0
  60. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_reset_overlay.py +0 -0
  61. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_setup.py +0 -0
  62. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_setup_full.py +0 -0
  63. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_stats.py +0 -0
  64. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_status.py +0 -0
  65. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_status_full.py +0 -0
  66. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_status_manual.py +0 -0
  67. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_tofu_source.py +0 -0
  68. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_unregister.py +0 -0
  69. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_utils.py +0 -0
  70. {lablink_cli-0.2.0 → lablink_cli-0.4.0}/tests/test_utils_full.py +0 -0
  71. {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.2.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.2.0
10
+ Requires-Dist: lablink-allocator-service[config]>=0.4.0
11
11
  Requires-Dist: typer>=0.15
12
12
  Requires-Dist: textual>=3.0
13
13
  Requires-Dist: rich>=13.0
@@ -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.2.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.2.0",
16
+ "lablink-allocator-service[config]>=0.4.0",
17
17
  "typer>=0.15",
18
18
  "textual>=3.0",
19
19
  "rich>=13.0",
@@ -1,8 +1,8 @@
1
1
  """LabLink CLI - Deploy and manage LabLink infrastructure."""
2
2
 
3
3
  TEMPLATE_REPO = "talmolab/lablink-template"
4
- TEMPLATE_VERSION = "v0.3.0"
4
+ TEMPLATE_VERSION = "v0.3.1"
5
5
  # SHA-256 of the GitHub release tarball for TEMPLATE_VERSION.
6
6
  # Update this when bumping TEMPLATE_VERSION.
7
7
  # To compute: curl -sL <tarball_url> | sha256sum
8
- TEMPLATE_SHA256 = "3d3f8803990f80e6bcda0b3098178d909bc454315fdf2304e70dbee5d43e3bb6"
8
+ TEMPLATE_SHA256 = "97f2f96c7bbdcaeeec8bd681394883409357613bbe40f97115a7a974d61a7660"
@@ -258,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
- "Required on the first deploy when manual.connectivity is "
261
+ "Needed on the first deploy when manual.connectivity is "
262
262
  "'mesh_overlay' and/or manual.participant_exposure is "
263
- "'tailscale_funnel'; optional on redeploys (the previous value is "
264
- "carried forward). Manual provider only.",
263
+ "'tailscale_funnel' (prompted if omitted); optional on redeploys "
264
+ "(the previous value is carried forward). Manual provider only.",
265
265
  ),
266
266
  cloudflare_tunnel_token: str = typer.Option(
267
267
  None,
268
268
  "--cloudflare-tunnel-token",
269
269
  help="Cloudflare Tunnel token for publishing the allocator at "
270
- "manual.public_hostname. Required on the first deploy when "
271
- "manual.participant_exposure is 'cloudflare_tunnel'; optional on "
272
- "redeploys (the previous value is carried forward). Supply it again "
273
- "to rotate. Manual provider only.",
270
+ "manual.public_hostname. Needed on the first deploy when "
271
+ "manual.participant_exposure is 'cloudflare_tunnel' (prompted if "
272
+ "omitted); optional on redeploys (the previous value is carried "
273
+ "forward). Supply it again to rotate. Manual provider only.",
274
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 required when a tailnet join is needed for
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 required when
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 requires --tailscale-authkey instead of silently
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
- console.print(
362
- "[red]A Tailscale sidecar is needed (manual.connectivity "
363
- "is 'mesh_overlay' and/or manual.participant_exposure is "
364
- "'tailscale_funnel') but no --tailscale-authkey was given, "
365
- "and no previous value is on record for this "
366
- "deployment.[/red]\n"
367
- "Generate an authkey from your Tailscale admin console "
368
- "and re-run with --tailscale-authkey <key>."
381
+ tailscale_authkey = _prompt_secret(
382
+ "Tailscale authkey",
383
+ "A Tailscale sidecar is needed (manual.connectivity is "
384
+ "'mesh_overlay' and/or manual.participant_exposure is "
385
+ "'tailscale_funnel') and no authkey is on record for this "
386
+ "deployment.\n"
387
+ "Generate one from your Tailscale admin console "
388
+ "(Settings > Keys).",
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
- console.print(
407
- "[red]manual.participant_exposure is 'cloudflare_tunnel' but "
408
- "no --cloudflare-tunnel-token was given, and no previous "
409
- "value is on record for this deployment.[/red]\n"
425
+ cloudflare_tunnel_token = _prompt_secret(
426
+ "Cloudflare tunnel token",
427
+ "manual.participant_exposure is 'cloudflare_tunnel' and no "
428
+ "token is on record for this deployment.\n"
410
429
  "Create a tunnel in Cloudflare's Zero Trust dashboard "
411
- "(Networks > Tunnels), copy the token from its Docker "
412
- "install command, and re-run with "
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 an AMI is available for the configured region."""
245
- result = {"check": "AMI for region", "status": "fail"}
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
- if region in AMI_MAP:
256
- result["status"] = "pass"
257
- result["detail"] = (
258
- f"{region} {AMI_MAP[region]}"
259
- )
260
- else:
261
- result["status"] = "fail"
262
- result["detail"] = (
263
- f"No AMI defined for region '{region}'. "
264
- f"Supported: {', '.join(AMI_MAP.keys())}"
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 for region
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
- # AMI IDs by region (Ubuntu 24.04 with Docker + Nvidia GPU Driver)
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.2.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.2.0
10
+ Requires-Dist: lablink-allocator-service[config]>=0.4.0
11
11
  Requires-Dist: typer>=0.15
12
12
  Requires-Dist: textual>=3.0
13
13
  Requires-Dist: rich>=13.0
@@ -1,4 +1,4 @@
1
- lablink-allocator-service[config]>=0.2.0
1
+ lablink-allocator-service[config]>=0.4.0
2
2
  typer>=0.15
3
3
  textual>=3.0
4
4
  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 test_first_deploy_without_authkey_rejected(self, mock_state_exists, tmp_path):
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, tmp_path
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 required to pass --tailscale-authkey.
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
- def test_lan_direct_with_funnel_requires_authkey(self, mock_state_exists, tmp_path):
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
- def test_first_deploy_without_token_is_rejected(self, tmp_path):
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 raises — this test is about the tunnel token.
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
- def test_supported_region(self):
130
- cfg = MagicMock()
131
- cfg.app.region = "us-east-1"
132
- result = _check_ami(cfg)
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
- def test_unsupported_region(self):
137
- cfg = MagicMock()
138
- cfg.app.region = "ap-south-1"
139
- result = _check_ami(cfg)
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 "No AMI" in result["detail"]
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 test_ami_map_has_regions(self):
239
- assert len(AMI_MAP) > 0
240
- for region, ami in AMI_MAP.items():
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