lablink-cli 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,1814 @@
1
+ """Textual TUI wizard for generating LabLink config."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from textual import on
8
+ from textual.app import App, ComposeResult
9
+ from textual.binding import Binding
10
+ from textual.containers import Center, Container, Horizontal, VerticalScroll
11
+ from textual.screen import Screen
12
+ from textual.widgets import (
13
+ Button,
14
+ Footer,
15
+ Header,
16
+ Input,
17
+ Label,
18
+ OptionList,
19
+ RadioButton,
20
+ RadioSet,
21
+ TextArea,
22
+ )
23
+ from textual.widgets.option_list import Option
24
+
25
+ from lablink_cli.config.schema import (
26
+ AMI_MAP,
27
+ AWS_REGIONS,
28
+ CPU_INSTANCE_TYPES,
29
+ DEPLOYMENT_NAME_RE,
30
+ GPU_INSTANCE_TYPES,
31
+ VALID_ENVIRONMENTS,
32
+ Config,
33
+ config_to_dict,
34
+ save_config,
35
+ validate_config,
36
+ )
37
+
38
+ DEFAULT_CONFIG_DIR = Path.home() / ".lablink"
39
+ DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.yaml"
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Screen 1: Deployment Name & Environment
44
+ # ---------------------------------------------------------------------------
45
+ class DeploymentScreen(Screen):
46
+ """Configure deployment name and environment."""
47
+
48
+ BINDINGS = [Binding("escape", "quit", "Quit")]
49
+
50
+ def action_quit(self) -> None:
51
+ self.app.exit()
52
+
53
+ def compose(self) -> ComposeResult:
54
+ cfg = self.app.config
55
+
56
+ # Determine pre-selected environment index
57
+ env_list = list(VALID_ENVIRONMENTS)
58
+ try:
59
+ env_idx = env_list.index(cfg.environment)
60
+ except ValueError:
61
+ env_idx = len(env_list) - 1 # default to prod
62
+
63
+ yield Header()
64
+ with VerticalScroll():
65
+ yield Label(
66
+ "Step 1: Deployment Identity",
67
+ classes="step-title",
68
+ )
69
+ yield Label(
70
+ "Name your lab (e.g., 'sleap-lablink' for a SLEAP course).\n"
71
+ "This prevents resource conflicts if multiple labs "
72
+ "share the same AWS account.",
73
+ classes="step-description",
74
+ )
75
+
76
+ yield Label(
77
+ "Deployment Name", classes="field-label"
78
+ )
79
+ yield Input(
80
+ value=cfg.deployment_name or "",
81
+ placeholder="e.g. sleap-lablink, deeplabcut-lablink",
82
+ id="deployment-name",
83
+ )
84
+ yield Label(
85
+ "3-32 chars, lowercase kebab-case "
86
+ "(letters, digits, hyphens)",
87
+ classes="step-description",
88
+ id="name-hint",
89
+ )
90
+
91
+ yield Label(
92
+ "Environment", classes="field-label"
93
+ )
94
+ with RadioSet(id="env-select"):
95
+ for i, env in enumerate(env_list):
96
+ yield RadioButton(
97
+ env, value=(i == env_idx)
98
+ )
99
+
100
+ yield Label(
101
+ "", id="deploy-error", classes="error"
102
+ )
103
+
104
+ with Center():
105
+ with Horizontal(classes="nav-buttons"):
106
+ yield Button(
107
+ "Next", variant="primary", id="next"
108
+ )
109
+ yield Footer()
110
+
111
+ @on(Button.Pressed, "#next")
112
+ def _next(self) -> None:
113
+ name = self.query_one(
114
+ "#deployment-name", Input
115
+ ).value.strip()
116
+ error_label = self.query_one("#deploy-error", Label)
117
+
118
+ # Validate deployment name
119
+ if not name:
120
+ error_label.update(
121
+ "Deployment name is required"
122
+ )
123
+ error_label.display = True
124
+ return
125
+ if (
126
+ len(name) < 3
127
+ or len(name) > 32
128
+ or not DEPLOYMENT_NAME_RE.match(name)
129
+ ):
130
+ error_label.update(
131
+ "Must be 3-32 chars, lowercase kebab-case "
132
+ "(e.g., 'sleap-lablink')"
133
+ )
134
+ error_label.display = True
135
+ return
136
+
137
+ error_label.display = False
138
+ self.app.config.deployment_name = name
139
+
140
+ # Read environment from radio set
141
+ env_radio = self.query_one("#env-select", RadioSet)
142
+ env_list = list(VALID_ENVIRONMENTS)
143
+ self.app.config.environment = env_list[
144
+ env_radio.pressed_index
145
+ ]
146
+
147
+ self.app.push_screen(ProviderScreen())
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Screen 2: Provider (AWS vs Manual BYO)
152
+ # ---------------------------------------------------------------------------
153
+ class ProviderScreen(Screen):
154
+ """Choose the VM provisioning provider."""
155
+
156
+ BINDINGS = [Binding("escape", "back", "Back")]
157
+
158
+ def action_back(self) -> None:
159
+ self.app.pop_screen()
160
+
161
+ def compose(self) -> ComposeResult:
162
+ cfg = self.app.config
163
+ current = getattr(cfg, "provider", "aws") or "aws"
164
+
165
+ yield Header()
166
+ with VerticalScroll():
167
+ yield Label(
168
+ "Step 2: Provider",
169
+ classes="step-title",
170
+ )
171
+ yield Label(
172
+ "Choose how client VMs are provisioned.\n"
173
+ "AWS provisions EC2 instances via OpenTofu.\n"
174
+ "Manual (BYO) skips provisioning — you supply Linux GPU\n"
175
+ "boxes that register themselves with `lablink client register`.",
176
+ classes="step-description",
177
+ )
178
+
179
+ yield Label("Provider", classes="field-label")
180
+ with RadioSet(id="provider-select"):
181
+ yield RadioButton(
182
+ "aws — AWS EC2 (default)",
183
+ value=(current == "aws"),
184
+ id="provider-aws",
185
+ )
186
+ yield RadioButton(
187
+ "manual — Bring-Your-Own boxes",
188
+ value=(current == "manual"),
189
+ id="provider-manual",
190
+ )
191
+
192
+ with Center():
193
+ with Horizontal(classes="nav-buttons"):
194
+ yield Button("Back", id="back")
195
+ yield Button("Next", variant="primary", id="next")
196
+ yield Footer()
197
+
198
+ @on(Button.Pressed, "#back")
199
+ def _back(self) -> None:
200
+ self.app.pop_screen()
201
+
202
+ @on(Button.Pressed, "#next")
203
+ def _next(self) -> None:
204
+ cfg = self.app.config
205
+ rb = self.query_one("#provider-select", RadioSet)
206
+ chosen = "aws"
207
+ if rb.pressed_button and rb.pressed_button.id == "provider-manual":
208
+ chosen = "manual"
209
+ cfg.provider = chosen
210
+
211
+ if chosen == "manual":
212
+ # For manual, force ssl.provider to a supported value if it
213
+ # was previously set to a public-TLS option.
214
+ if cfg.ssl.provider in ("letsencrypt", "acm", "cloudflare"):
215
+ cfg.ssl.provider = "none"
216
+ if not cfg.machine.image:
217
+ cfg.machine.image = ManualMachineScreen.DEFAULT_IMAGE
218
+ self.app.push_screen(ManualMachineScreen())
219
+ else:
220
+ self.app.push_screen(RegionScreen())
221
+
222
+
223
+ # ---------------------------------------------------------------------------
224
+ # Screen 3 (Manual path only): Client image
225
+ # ---------------------------------------------------------------------------
226
+ class ManualMachineScreen(Screen):
227
+ """Configure the client Docker image for manual (BYO) deployments."""
228
+
229
+ BINDINGS = [Binding("escape", "back", "Back")]
230
+
231
+ DEFAULT_IMAGE = "ghcr.io/talmolab/lablink-client-base-image:latest"
232
+
233
+ def action_back(self) -> None:
234
+ self.app.pop_screen()
235
+
236
+ def compose(self) -> ComposeResult:
237
+ cfg = self.app.config
238
+ current_image = cfg.machine.image or self.DEFAULT_IMAGE
239
+
240
+ yield Header()
241
+ with VerticalScroll():
242
+ yield Label(
243
+ "Step 3: Client image",
244
+ classes="step-title",
245
+ )
246
+ yield Label(
247
+ "Docker image that BYO boxes will pull and run after "
248
+ "they register. Defaults to the latest published image.",
249
+ classes="step-description",
250
+ )
251
+ yield Label("Client image", classes="field-label")
252
+ yield Input(
253
+ value=current_image,
254
+ placeholder=self.DEFAULT_IMAGE,
255
+ id="client-image",
256
+ )
257
+ with Center():
258
+ with Horizontal(classes="nav-buttons"):
259
+ yield Button("Back", id="back")
260
+ yield Button("Next", variant="primary", id="next")
261
+ yield Footer()
262
+
263
+ @on(Button.Pressed, "#back")
264
+ def _back(self) -> None:
265
+ self.app.pop_screen()
266
+
267
+ @on(Button.Pressed, "#next")
268
+ def _next(self) -> None:
269
+ cfg = self.app.config
270
+ image = self.query_one("#client-image", Input).value.strip()
271
+ cfg.machine.image = image or self.DEFAULT_IMAGE
272
+ # Skip Region + Machine instance-type + EIP — go to connectivity.
273
+ self.app.push_screen(ManualConnectivityScreen())
274
+
275
+
276
+ def _tailnet_needed(connectivity: str, participant_exposure: str) -> bool:
277
+ """True when something in this config actually reads overlay_tailnet.
278
+
279
+ Mirrors the validator's rule (see get_config_errors): the tailnet is
280
+ required by mesh_overlay connectivity *or* by tailscale_funnel exposure,
281
+ and by nothing else. Keep the two in step — if they disagree, the wizard
282
+ either blocks on a field it disabled or offers one nothing consumes.
283
+ """
284
+ return connectivity == "mesh_overlay" or participant_exposure == "tailscale_funnel"
285
+
286
+
287
+ # ---------------------------------------------------------------------------
288
+ # Screen 4 (Manual path only): Client connectivity
289
+ # ---------------------------------------------------------------------------
290
+ class ManualConnectivityScreen(Screen):
291
+ """How the student's browser reaches a manual client's KasmVNC desktop."""
292
+
293
+ BINDINGS = [Binding("escape", "back", "Back")]
294
+
295
+ def action_back(self) -> None:
296
+ self.app.pop_screen()
297
+
298
+ def compose(self) -> ComposeResult:
299
+ cfg = self.app.config
300
+ current = getattr(cfg.manual, "connectivity", "lan_direct") or "lan_direct"
301
+ current_exposure = (
302
+ getattr(cfg.manual, "participant_exposure", "none") or "none"
303
+ )
304
+ current_hostname = getattr(cfg.manual, "public_hostname", "") or ""
305
+
306
+ yield Header()
307
+ with VerticalScroll():
308
+ # No "Step N:" prefix here deliberately — DnsScreen (the next
309
+ # screen on this path) hardcodes "Step 4: DNS & SSL" and is
310
+ # shared with the AWS path, so inserting a step between it and
311
+ # ManualMachineScreen's "Step 3" would collide with that label
312
+ # rather than shifting it. Renumbering DnsScreen is out of
313
+ # scope here (it's shared, and the AWS path already has its
314
+ # own pre-existing step-count drift across RegionScreen/
315
+ # MachineScreen).
316
+ yield Label(
317
+ "Client connectivity",
318
+ classes="step-title",
319
+ )
320
+ yield Label(
321
+ "How the student's browser reaches a client's KasmVNC desktop.\n"
322
+ "lan_direct: the client is on the allocator's own LAN (default).\n"
323
+ "mesh_overlay: the client isn't on the allocator's LAN (e.g. a\n"
324
+ "Run:AI-hosted workload) — reached over a Tailscale tailnet instead.\n"
325
+ "reverse_tunnel: the client can't accept inbound connections at\n"
326
+ "all — it dials out and holds a tunnel open instead.",
327
+ classes="step-description",
328
+ )
329
+
330
+ yield Label("Connectivity", classes="field-label")
331
+ with RadioSet(id="connectivity-select"):
332
+ yield RadioButton(
333
+ "lan_direct — client is on the allocator's LAN (default)",
334
+ value=(current == "lan_direct"),
335
+ id="connectivity-lan-direct",
336
+ )
337
+ yield RadioButton(
338
+ "mesh_overlay — client reached over Tailscale",
339
+ value=(current == "mesh_overlay"),
340
+ id="connectivity-mesh-overlay",
341
+ )
342
+ yield RadioButton(
343
+ "reverse_tunnel — client dials out and holds a tunnel",
344
+ value=(current == "reverse_tunnel"),
345
+ id="connectivity-reverse-tunnel",
346
+ )
347
+
348
+ yield Label(
349
+ "Tailscale tailnet domain — needed only by mesh_overlay or by\n"
350
+ "tailscale_funnel exposure below (e.g. example.ts.net)",
351
+ classes="field-label",
352
+ id="overlay-tailnet-label",
353
+ )
354
+ # Disabled when neither of the two things that consume it is
355
+ # selected, rather than hidden: DnsScreen already uses `disabled`
356
+ # for fields a mode doesn't apply to, and Textual leaves disabled
357
+ # widgets out of the Tab order, so an operator can see the field
358
+ # exists and why without being able to type into a value nothing
359
+ # would read. reverse_tunnel needs no address of its own.
360
+ yield Input(
361
+ value=cfg.manual.overlay_tailnet or "",
362
+ placeholder="example.ts.net",
363
+ id="overlay-tailnet",
364
+ disabled=not _tailnet_needed(current, current_exposure),
365
+ )
366
+
367
+ yield Label(
368
+ "Participant exposure",
369
+ classes="step-title",
370
+ )
371
+ yield Label(
372
+ "How participants (not clients) reach the allocator when it\n"
373
+ "isn't on their LAN. Independent of connectivity above.\n"
374
+ "none: allocator stays LAN-only, as today (default).\n"
375
+ "tailscale_funnel: published at <machine>.<tailnet>.ts.net —\n"
376
+ "no domain needed, but the hostname is not yours to choose.\n"
377
+ "cloudflare_tunnel: published at a hostname you choose.\n"
378
+ "Needs a domain whose nameservers point at Cloudflare, plus a\n"
379
+ "tunnel token from Cloudflare's Zero Trust dashboard. Note\n"
380
+ "that Cloudflare decrypts all traffic at its edge, including\n"
381
+ "admin logins and participant desktop streams; Funnel does\n"
382
+ "not. See docs/configuration.md for the one-time setup.",
383
+ classes="step-description",
384
+ )
385
+ with RadioSet(id="participant-exposure-select"):
386
+ yield RadioButton(
387
+ "none — allocator stays LAN-only (default)",
388
+ value=(current_exposure == "none"),
389
+ id="participant-exposure-none",
390
+ )
391
+ yield RadioButton(
392
+ "tailscale_funnel — publish to participants via "
393
+ "Tailscale Funnel",
394
+ value=(current_exposure == "tailscale_funnel"),
395
+ id="participant-exposure-funnel",
396
+ )
397
+ yield RadioButton(
398
+ "cloudflare_tunnel — publish at a hostname you choose",
399
+ value=(current_exposure == "cloudflare_tunnel"),
400
+ id="participant-exposure-cloudflare",
401
+ )
402
+
403
+ # Hard-wrapped: .field-label doesn't wrap, so a single long line
404
+ # widens this screen's virtual width past an 80-column terminal
405
+ # (the overflow class of bug #399 fixed).
406
+ yield Label(
407
+ "Public hostname (cloudflare_tunnel only — the hostname\n"
408
+ "you configured as the tunnel's public hostname in\n"
409
+ "Cloudflare, e.g. lab.smithlab.org)",
410
+ classes="field-label",
411
+ )
412
+ yield Input(
413
+ value=current_hostname,
414
+ placeholder="lab.smithlab.org",
415
+ id="public-hostname",
416
+ # Mirrors #overlay-tailnet: the initial state has to be right
417
+ # before any RadioSet.Changed fires, since re-entering the
418
+ # wizard on an existing config never touches the radios.
419
+ disabled=current_exposure != "cloudflare_tunnel",
420
+ )
421
+
422
+ yield Label("", id="connectivity-error", classes="error")
423
+ with Center():
424
+ with Horizontal(classes="nav-buttons"):
425
+ yield Button("Back", id="back")
426
+ yield Button("Next", variant="primary", id="next")
427
+ yield Footer()
428
+
429
+ def on_mount(self) -> None:
430
+ self.query_one("#connectivity-error").display = False
431
+
432
+ def _pressed(self, selector: str) -> str:
433
+ rb = self.query_one(selector, RadioSet)
434
+ return (rb.pressed_button.id or "") if rb.pressed_button else ""
435
+
436
+ @on(RadioSet.Changed)
437
+ def _sync_conditional_fields(self, event: RadioSet.Changed) -> None:
438
+ """Enable each address field only for the modes that consume it.
439
+
440
+ Driven by BOTH radio sets, not just connectivity: the tailnet is
441
+ required by mesh_overlay *and* by tailscale_funnel exposure, so a
442
+ reverse_tunnel deployment that also publishes itself via Funnel
443
+ still needs one. reverse_tunnel on its own needs no address.
444
+
445
+ The two fields are mutually exclusive in practice — Funnel's
446
+ hostname is Tailscale's to assign and Cloudflare's needs no tailnet
447
+ — so leaving both editable invites filling in the one that will be
448
+ ignored. Neither field is *cleared* on switching away: a value the
449
+ operator already typed is preserved for switching back, and an
450
+ ignored `public_hostname` is documented as harmless.
451
+ """
452
+ connectivity = {
453
+ "connectivity-mesh-overlay": "mesh_overlay",
454
+ "connectivity-reverse-tunnel": "reverse_tunnel",
455
+ }.get(self._pressed("#connectivity-select"), "lan_direct")
456
+ pressed_exposure = self._pressed("#participant-exposure-select")
457
+ exposure = (
458
+ "tailscale_funnel"
459
+ if pressed_exposure == "participant-exposure-funnel"
460
+ else "none"
461
+ )
462
+ self.query_one("#overlay-tailnet", Input).disabled = not _tailnet_needed(
463
+ connectivity, exposure
464
+ )
465
+ self.query_one("#public-hostname", Input).disabled = (
466
+ pressed_exposure != "participant-exposure-cloudflare"
467
+ )
468
+
469
+ @on(Button.Pressed, "#back")
470
+ def _back(self) -> None:
471
+ self.app.pop_screen()
472
+
473
+ @on(Button.Pressed, "#next")
474
+ def _next(self) -> None:
475
+ cfg = self.app.config
476
+ rb = self.query_one("#connectivity-select", RadioSet)
477
+ chosen = {
478
+ "connectivity-mesh-overlay": "mesh_overlay",
479
+ "connectivity-reverse-tunnel": "reverse_tunnel",
480
+ }.get(rb.pressed_button and rb.pressed_button.id, "lan_direct")
481
+ cfg.manual.connectivity = chosen
482
+ cfg.manual.overlay_tailnet = self.query_one(
483
+ "#overlay-tailnet", Input
484
+ ).value.strip()
485
+
486
+ rb_exposure = self.query_one("#participant-exposure-select", RadioSet)
487
+ chosen_exposure = "none"
488
+ pressed = rb_exposure.pressed_button
489
+ if pressed and pressed.id == "participant-exposure-funnel":
490
+ chosen_exposure = "tailscale_funnel"
491
+ elif pressed and pressed.id == "participant-exposure-cloudflare":
492
+ chosen_exposure = "cloudflare_tunnel"
493
+ cfg.manual.participant_exposure = chosen_exposure
494
+ cfg.manual.public_hostname = self.query_one(
495
+ "#public-hostname", Input
496
+ ).value.strip()
497
+
498
+ errors = [
499
+ e for e in validate_config(cfg)
500
+ if (
501
+ "connectivity" in e
502
+ or "overlay_tailnet" in e
503
+ or "participant_exposure" in e
504
+ # The cloudflare_tunnel hostname error names this field;
505
+ # omitting it here would let an invalid config through.
506
+ or "public_hostname" in e
507
+ )
508
+ # admin_password isn't collected until deploy time (resolve_admin_
509
+ # credentials runs in deploy_compose.py, not the wizard) — the
510
+ # weak-password gate would always spuriously fire here otherwise,
511
+ # since cfg.app.admin_password is still unset at this point.
512
+ and "admin_password" not in e
513
+ ]
514
+ error_label = self.query_one("#connectivity-error", Label)
515
+ if errors:
516
+ error_label.update("\n".join(errors))
517
+ error_label.display = True
518
+ return
519
+ error_label.display = False
520
+
521
+ # The manual path skips DnsScreen: the compose stack reads neither
522
+ # cfg.dns nor cfg.eip, and reads cfg.ssl.provider only to reject
523
+ # anything but "none" (SUPPORTED_SSL_FOR_MANUAL in deploy_compose).
524
+ # Asking for DNS and a TLS provider that the deploy then refuses is
525
+ # a trap, not a choice.
526
+ #
527
+ # Pinning both here is mandatory, not tidiness: SSLConfig.provider
528
+ # defaults to "letsencrypt", and DnsScreen is the only place the
529
+ # wizard ever writes cfg.ssl.provider. Skipping it without this
530
+ # would leave every manual config failing that preflight — and an
531
+ # inherited AWS config would carry a real domain through too.
532
+ cfg.ssl.provider = "none"
533
+ cfg.dns.enabled = False
534
+
535
+ self.app.push_screen(StartupScreen())
536
+
537
+
538
+ # ---------------------------------------------------------------------------
539
+ # Screen 2 (AWS path): AWS Region
540
+ # ---------------------------------------------------------------------------
541
+ class RegionScreen(Screen):
542
+ """Select AWS region."""
543
+
544
+ BINDINGS = [Binding("escape", "back", "Back")]
545
+
546
+ def action_back(self) -> None:
547
+ self.app.pop_screen()
548
+
549
+ def compose(self) -> ComposeResult:
550
+ yield Header()
551
+ with VerticalScroll():
552
+ yield Label(
553
+ "Step 2: AWS Region", classes="step-title"
554
+ )
555
+ yield Label(
556
+ "Select the AWS region closest to your students.\n"
557
+ "This affects latency and VM availability.",
558
+ classes="step-description",
559
+ )
560
+ yield OptionList(
561
+ *[
562
+ Option(
563
+ f"{r['id']:20s} {r['name']}",
564
+ id=r["id"],
565
+ )
566
+ for r in AWS_REGIONS
567
+ ],
568
+ id="region-list",
569
+ )
570
+ with Center():
571
+ with Horizontal(classes="nav-buttons"):
572
+ yield Button("Back", id="back")
573
+ yield Button("Next", variant="primary", id="next")
574
+ yield Footer()
575
+
576
+ def on_mount(self) -> None:
577
+ """Highlight the region the config already names.
578
+
579
+ OptionList highlights index 0 on its own, so without this the screen
580
+ shows us-east-1 as the current choice no matter what the config says.
581
+ An operator whose deployment lives elsewhere then either accepts a row
582
+ that misreports their setting, or presses Enter on it and silently
583
+ moves app.region — along with machine.ami_id, which is region-scoped.
584
+ In --template mode this is how a config ends up naming a region that
585
+ holds none of the resources lablink-template's setup.sh just created.
586
+
587
+ A region absent from AWS_REGIONS clears the highlight rather than
588
+ pointing at an unrelated row: nothing on this screen represents it.
589
+ """
590
+ region_ids = [r["id"] for r in AWS_REGIONS]
591
+ configured = self.app.config.app.region
592
+ self.query_one("#region-list", OptionList).highlighted = (
593
+ region_ids.index(configured) if configured in region_ids else None
594
+ )
595
+
596
+ @on(OptionList.OptionSelected)
597
+ def _select(self, event: OptionList.OptionSelected) -> None:
598
+ region = str(event.option.id)
599
+ self.app.config.app.region = region
600
+ # Auto-select AMI for the chosen region
601
+ if region in AMI_MAP:
602
+ self.app.config.machine.ami_id = AMI_MAP[region]
603
+
604
+ @on(Button.Pressed, "#back")
605
+ def _back(self) -> None:
606
+ self.app.pop_screen()
607
+
608
+ @on(Button.Pressed, "#next")
609
+ def _next(self) -> None:
610
+ self.app.push_screen(MachineScreen())
611
+
612
+
613
+ # ---------------------------------------------------------------------------
614
+ # Screen 3: Machine Configuration
615
+ # ---------------------------------------------------------------------------
616
+ class MachineScreen(Screen):
617
+ """Configure client VM instance type and software."""
618
+
619
+ BINDINGS = [Binding("escape", "back", "Back")]
620
+
621
+ def compose(self) -> ComposeResult:
622
+ yield Header()
623
+ with VerticalScroll():
624
+ yield Label(
625
+ "Step 3: Machine Configuration",
626
+ classes="step-title",
627
+ )
628
+ yield Label(
629
+ "Select the instance type for student VMs. "
630
+ "GPU instances are recommended for ML workloads.\n"
631
+ "Docs: https://aws.amazon.com/ec2/instance-types/",
632
+ classes="step-description",
633
+ )
634
+
635
+ yield Label("Instance Type", classes="field-label")
636
+ gpu_options = [
637
+ Option(
638
+ f"{t['type']:18s} {t['gpu']:14s} "
639
+ f"{t['vcpu']} vCPU {t['ram']:8s} {t['cost']}",
640
+ id=t["type"],
641
+ )
642
+ for t in GPU_INSTANCE_TYPES
643
+ ]
644
+ cpu_options = [
645
+ Option(
646
+ f"{t['type']:18s} {'—':14s} "
647
+ f"{t['vcpu']} vCPU {t['ram']:8s} {t['cost']}",
648
+ id=t["type"],
649
+ )
650
+ for t in CPU_INSTANCE_TYPES
651
+ ]
652
+ yield OptionList(
653
+ Option("── GPU Instances ──", disabled=True),
654
+ *gpu_options,
655
+ None,
656
+ Option(
657
+ "── CPU Only (no GPU) ──", disabled=True
658
+ ),
659
+ *cpu_options,
660
+ id="instance-list",
661
+ )
662
+
663
+ cfg = self.app.config
664
+
665
+ yield Label(
666
+ "Software Name (the tool students will use)",
667
+ classes="field-label",
668
+ )
669
+ yield Input(
670
+ value=cfg.machine.software or "",
671
+ placeholder="e.g. sleap, deeplabcut, napari",
672
+ id="software",
673
+ )
674
+
675
+ yield Label(
676
+ "Git Repository (course materials cloned into each VM)",
677
+ classes="field-label",
678
+ )
679
+ yield Input(
680
+ value=cfg.machine.repository or "",
681
+ placeholder=(
682
+ "https://github.com/org/repo.git"
683
+ ),
684
+ id="repository",
685
+ )
686
+
687
+ with Center():
688
+ with Horizontal(classes="nav-buttons"):
689
+ yield Button("Back", id="back")
690
+ yield Button(
691
+ "Next", variant="primary", id="next"
692
+ )
693
+ yield Footer()
694
+
695
+ @on(OptionList.OptionSelected, "#instance-list")
696
+ def _select_instance(
697
+ self, event: OptionList.OptionSelected
698
+ ) -> None:
699
+ self.app.config.machine.machine_type = str(
700
+ event.option.id
701
+ )
702
+
703
+ @on(Button.Pressed, "#back")
704
+ def _back(self) -> None:
705
+ self.app.pop_screen()
706
+
707
+ @on(Button.Pressed, "#next")
708
+ def _next(self) -> None:
709
+ software = self.query_one("#software", Input).value
710
+ repository = self.query_one("#repository", Input).value
711
+
712
+ if software:
713
+ self.app.config.machine.software = software
714
+ self.app.config.machine.repository = (
715
+ repository if repository else None
716
+ )
717
+
718
+ self.app.push_screen(DnsScreen())
719
+
720
+
721
+ # ---------------------------------------------------------------------------
722
+ # Screen 4: DNS & SSL
723
+ # ---------------------------------------------------------------------------
724
+ class DnsScreen(Screen):
725
+ """Configure DNS and SSL settings."""
726
+
727
+ BINDINGS = [Binding("escape", "back", "Back")]
728
+
729
+ PROVIDER_BY_BUTTON_ID = {
730
+ "dns-none": "none",
731
+ "dns-letsencrypt": "letsencrypt",
732
+ "dns-cloudflare": "cloudflare",
733
+ "dns-acm": "acm",
734
+ "dns-self_signed": "self_signed",
735
+ }
736
+
737
+ def compose(self) -> ComposeResult:
738
+ cfg = self.app.config
739
+
740
+ # Determine which radio button to pre-select.
741
+ # Indices follow AWS-style ordering (0..4); when manual provider is
742
+ # selected we hide indices 1..3 but the same numbering is used
743
+ # for the value-checking logic below.
744
+ if not cfg.dns.enabled and cfg.ssl.provider == "self_signed":
745
+ default_idx = 4
746
+ elif not cfg.dns.enabled:
747
+ default_idx = 0
748
+ elif cfg.ssl.provider == "letsencrypt":
749
+ default_idx = 1
750
+ elif cfg.ssl.provider == "cloudflare":
751
+ default_idx = 2
752
+ elif cfg.ssl.provider == "acm":
753
+ default_idx = 3
754
+ else:
755
+ default_idx = 0
756
+
757
+ is_manual = getattr(cfg, "provider", "aws") == "manual"
758
+
759
+ # Initial disabled state for the three text inputs, computed from
760
+ # the pre-selected provider (after manual filtering).
761
+ if default_idx == 1 and not is_manual:
762
+ domain_disabled = False
763
+ email_disabled = False
764
+ acm_disabled = True
765
+ elif default_idx == 2 and not is_manual:
766
+ domain_disabled = False
767
+ email_disabled = True
768
+ acm_disabled = True
769
+ elif default_idx == 3 and not is_manual:
770
+ domain_disabled = False
771
+ email_disabled = True
772
+ acm_disabled = False
773
+ else:
774
+ domain_disabled = True
775
+ email_disabled = True
776
+ acm_disabled = True
777
+
778
+ yield Header()
779
+ with VerticalScroll():
780
+ yield Label(
781
+ "Step 4: DNS & SSL", classes="step-title"
782
+ )
783
+
784
+ # Mode toggle (Guided default, Advanced opt-in).
785
+ with RadioSet(id="dns-screen-mode"):
786
+ yield RadioButton(
787
+ "Guided — common presets",
788
+ value=True,
789
+ id="screen-mode-guided",
790
+ )
791
+ yield RadioButton(
792
+ "Advanced — edit every field directly",
793
+ value=False,
794
+ id="screen-mode-advanced",
795
+ )
796
+
797
+ with Container(id="dns-guided"):
798
+ yield Label("Access Method", classes="field-label")
799
+ with RadioSet(id="dns-mode"):
800
+ yield RadioButton(
801
+ "IP Only — simplest setup, access via IP, no SSL",
802
+ value=(default_idx == 0),
803
+ id="dns-none",
804
+ )
805
+ if not is_manual:
806
+ yield RadioButton(
807
+ "Let's Encrypt — free automatic SSL, requires a "
808
+ "domain (https://letsencrypt.org/)",
809
+ value=(default_idx == 1),
810
+ id="dns-letsencrypt",
811
+ )
812
+ yield RadioButton(
813
+ "CloudFlare — use if your domain is already on "
814
+ "CloudFlare (https://www.cloudflare.com/application-services/products/ssl/)",
815
+ value=(default_idx == 2),
816
+ id="dns-cloudflare",
817
+ )
818
+ yield RadioButton(
819
+ "AWS ACM — AWS-managed SSL with load balancer, "
820
+ "requires certificate "
821
+ "(https://docs.aws.amazon.com/acm/latest/userguide/acm-overview.html)",
822
+ value=(default_idx == 3),
823
+ id="dns-acm",
824
+ )
825
+ yield RadioButton(
826
+ "Self-signed — browser warns once; fine for closed-LAN labs",
827
+ value=(default_idx == 4),
828
+ id="dns-self_signed",
829
+ )
830
+
831
+ yield Label(
832
+ "Domain Name",
833
+ classes="field-label",
834
+ id="domain-label",
835
+ )
836
+ yield Input(
837
+ value=cfg.dns.domain or "",
838
+ placeholder="lablink.example.com",
839
+ id="domain",
840
+ disabled=domain_disabled,
841
+ )
842
+
843
+ yield Label(
844
+ "Email (for SSL certificates)",
845
+ classes="field-label",
846
+ id="email-label",
847
+ )
848
+ yield Input(
849
+ value=cfg.ssl.email or "",
850
+ placeholder="admin@example.com",
851
+ id="ssl-email",
852
+ disabled=email_disabled,
853
+ )
854
+
855
+ yield Label(
856
+ "ACM Certificate ARN",
857
+ classes="field-label",
858
+ id="acm-label",
859
+ )
860
+ yield Input(
861
+ value=cfg.ssl.certificate_arn or "",
862
+ placeholder=(
863
+ "arn:aws:acm:region:account:certificate/id"
864
+ ),
865
+ id="acm-arn",
866
+ disabled=acm_disabled,
867
+ )
868
+
869
+ tag = (
870
+ f"{cfg.deployment_name or '<deployment_name>'}"
871
+ f"-eip-"
872
+ f"{cfg.environment or '<environment>'}"
873
+ )
874
+ yield Label(
875
+ "Persistent EIP required for Cloudflare.\n"
876
+ "Tag your pre-allocated EIP with:\n"
877
+ f" Name = {tag}\n"
878
+ "Example:\n"
879
+ " aws ec2 create-tags --resources eipalloc-XXXXX \\\n"
880
+ f" --tags Key=Name,Value={tag}",
881
+ id="eip-help",
882
+ classes="step-description",
883
+ )
884
+
885
+ with Container(id="dns-advanced"):
886
+ yield Label(
887
+ "Advanced — direct config edit. "
888
+ "Values from current config are pre-filled.",
889
+ classes="step-description",
890
+ )
891
+
892
+ yield Label("DNS", classes="field-label")
893
+
894
+ yield Label("Enabled", classes="field-label")
895
+ with RadioSet(id="adv-dns-enabled"):
896
+ yield RadioButton(
897
+ "Yes",
898
+ value=cfg.dns.enabled,
899
+ id="adv-dns-enabled-yes",
900
+ )
901
+ yield RadioButton(
902
+ "No",
903
+ value=not cfg.dns.enabled,
904
+ id="adv-dns-enabled-no",
905
+ )
906
+
907
+ yield Label(
908
+ "OpenTofu-managed records",
909
+ classes="field-label",
910
+ )
911
+ with RadioSet(id="adv-dns-tfmanaged"):
912
+ yield RadioButton(
913
+ "Yes",
914
+ value=cfg.dns.terraform_managed,
915
+ id="adv-dns-tfmanaged-yes",
916
+ )
917
+ yield RadioButton(
918
+ "No",
919
+ value=not cfg.dns.terraform_managed,
920
+ id="adv-dns-tfmanaged-no",
921
+ )
922
+
923
+ yield Label("Domain", classes="field-label")
924
+ yield Input(
925
+ value=cfg.dns.domain or "",
926
+ placeholder="lablink.example.com",
927
+ id="adv-dns-domain",
928
+ )
929
+
930
+ yield Label(
931
+ "Zone ID (optional)", classes="field-label"
932
+ )
933
+ yield Input(
934
+ value=cfg.dns.zone_id or "",
935
+ placeholder="Z0123456789ABCDEFG",
936
+ id="adv-dns-zone-id",
937
+ )
938
+
939
+ yield Label("SSL", classes="field-label")
940
+ yield Label("Provider", classes="field-label")
941
+ with RadioSet(id="adv-ssl-provider"):
942
+ yield RadioButton(
943
+ "none",
944
+ value=(cfg.ssl.provider == "none"),
945
+ id="adv-ssl-none",
946
+ )
947
+ yield RadioButton(
948
+ "letsencrypt",
949
+ value=(cfg.ssl.provider == "letsencrypt"),
950
+ id="adv-ssl-letsencrypt",
951
+ )
952
+ yield RadioButton(
953
+ "cloudflare",
954
+ value=(cfg.ssl.provider == "cloudflare"),
955
+ id="adv-ssl-cloudflare",
956
+ )
957
+ yield RadioButton(
958
+ "acm",
959
+ value=(cfg.ssl.provider == "acm"),
960
+ id="adv-ssl-acm",
961
+ )
962
+ yield RadioButton(
963
+ "self_signed",
964
+ value=(cfg.ssl.provider == "self_signed"),
965
+ id="adv-ssl-self_signed",
966
+ )
967
+
968
+ yield Label("Email", classes="field-label")
969
+ yield Input(
970
+ value=cfg.ssl.email or "",
971
+ placeholder="admin@example.com",
972
+ id="adv-ssl-email",
973
+ )
974
+
975
+ yield Label(
976
+ "ACM Certificate ARN", classes="field-label"
977
+ )
978
+ yield Input(
979
+ value=cfg.ssl.certificate_arn or "",
980
+ placeholder=(
981
+ "arn:aws:acm:region:account:certificate/id"
982
+ ),
983
+ id="adv-ssl-acm-arn",
984
+ )
985
+
986
+ yield Label("EIP", classes="field-label")
987
+ yield Label("Strategy", classes="field-label")
988
+ with RadioSet(id="adv-eip-strategy"):
989
+ yield RadioButton(
990
+ "dynamic",
991
+ value=(cfg.eip.strategy == "dynamic"),
992
+ id="adv-eip-dynamic",
993
+ )
994
+ yield RadioButton(
995
+ "persistent",
996
+ value=(cfg.eip.strategy == "persistent"),
997
+ id="adv-eip-persistent",
998
+ )
999
+
1000
+ tag = (
1001
+ f"{cfg.deployment_name or '<deployment_name>'}"
1002
+ f"-eip-"
1003
+ f"{cfg.environment or '<environment>'}"
1004
+ )
1005
+ yield Label(
1006
+ "Persistent EIP requires a pre-allocated EIP tagged:\n"
1007
+ f" Name = {tag}",
1008
+ id="adv-eip-help",
1009
+ classes="step-description",
1010
+ )
1011
+
1012
+ yield Label(
1013
+ "",
1014
+ id="dns-validation-error",
1015
+ classes="step-description",
1016
+ )
1017
+
1018
+ with Center():
1019
+ with Horizontal(classes="nav-buttons"):
1020
+ yield Button("Back", id="back")
1021
+ yield Button(
1022
+ "Next", variant="primary", id="next"
1023
+ )
1024
+ yield Footer()
1025
+
1026
+ @on(RadioSet.Changed, "#dns-mode")
1027
+ def _dns_changed(self, event: RadioSet.Changed) -> None:
1028
+ provider = self.PROVIDER_BY_BUTTON_ID.get(event.pressed.id, "none")
1029
+
1030
+ domain_input = self.query_one("#domain", Input)
1031
+ email_input = self.query_one("#ssl-email", Input)
1032
+ acm_input = self.query_one("#acm-arn", Input)
1033
+
1034
+ domain_needed = provider in ("letsencrypt", "cloudflare", "acm")
1035
+ email_needed = provider == "letsencrypt"
1036
+ acm_needed = provider == "acm"
1037
+
1038
+ domain_input.disabled = not domain_needed
1039
+ email_input.disabled = not email_needed
1040
+ acm_input.disabled = not acm_needed
1041
+
1042
+ # Toggle EIP-help visibility with the selected provider.
1043
+ self.query_one("#eip-help").display = (provider == "cloudflare")
1044
+
1045
+ @on(RadioSet.Changed, "#dns-screen-mode")
1046
+ def _screen_mode_changed(self, event: RadioSet.Changed) -> None:
1047
+ is_advanced = event.pressed.id == "screen-mode-advanced"
1048
+ if is_advanced:
1049
+ # Save the Guided state to cfg so Advanced sees the latest.
1050
+ self._save_guided()
1051
+ self._refresh_advanced_from_cfg()
1052
+ else:
1053
+ # Going from Advanced back to Guided: save Advanced first.
1054
+ self._save_advanced()
1055
+ self._refresh_guided_from_cfg()
1056
+ self.query_one("#dns-guided").display = not is_advanced
1057
+ self.query_one("#dns-advanced").display = is_advanced
1058
+
1059
+ def _refresh_advanced_from_cfg(self) -> None:
1060
+ cfg = self.app.config
1061
+
1062
+ def _select(radioset_id: str, button_id: str) -> None:
1063
+ # We're called from RadioSet message handlers which run with
1064
+ # `prevent(RadioButton.Changed)` active, so simply setting
1065
+ # button.value won't propagate through RadioSet's single-selection
1066
+ # logic. Mutate values directly and update the RadioSet's
1067
+ # `_pressed_button` so callers see consistent state.
1068
+ radio_set = self.query_one(radioset_id, RadioSet)
1069
+ target: RadioButton | None = None
1070
+ for btn in radio_set.query(RadioButton):
1071
+ if btn.id == button_id:
1072
+ target = btn
1073
+ else:
1074
+ if btn.value:
1075
+ btn.value = False
1076
+ if target is not None:
1077
+ target.value = True
1078
+ radio_set._pressed_button = target
1079
+
1080
+ _select(
1081
+ "#adv-dns-enabled",
1082
+ "adv-dns-enabled-yes"
1083
+ if cfg.dns.enabled
1084
+ else "adv-dns-enabled-no",
1085
+ )
1086
+ _select(
1087
+ "#adv-dns-tfmanaged",
1088
+ "adv-dns-tfmanaged-yes"
1089
+ if cfg.dns.terraform_managed
1090
+ else "adv-dns-tfmanaged-no",
1091
+ )
1092
+ self.query_one("#adv-dns-domain").value = (
1093
+ cfg.dns.domain or ""
1094
+ )
1095
+ self.query_one("#adv-dns-zone-id").value = (
1096
+ cfg.dns.zone_id or ""
1097
+ )
1098
+ _select(
1099
+ "#adv-ssl-provider",
1100
+ {
1101
+ "none": "adv-ssl-none",
1102
+ "letsencrypt": "adv-ssl-letsencrypt",
1103
+ "cloudflare": "adv-ssl-cloudflare",
1104
+ "acm": "adv-ssl-acm",
1105
+ "self_signed": "adv-ssl-self_signed",
1106
+ }.get(cfg.ssl.provider, "adv-ssl-none"),
1107
+ )
1108
+ self.query_one("#adv-ssl-email").value = cfg.ssl.email or ""
1109
+ self.query_one("#adv-ssl-acm-arn").value = (
1110
+ cfg.ssl.certificate_arn or ""
1111
+ )
1112
+ _select(
1113
+ "#adv-eip-strategy",
1114
+ "adv-eip-persistent"
1115
+ if cfg.eip.strategy == "persistent"
1116
+ else "adv-eip-dynamic",
1117
+ )
1118
+ self.query_one("#adv-eip-help").display = (
1119
+ cfg.eip.strategy == "persistent"
1120
+ )
1121
+
1122
+ def _refresh_guided_from_cfg(self) -> None:
1123
+ cfg = self.app.config
1124
+ self.query_one("#domain").value = cfg.dns.domain or ""
1125
+ self.query_one("#ssl-email").value = cfg.ssl.email or ""
1126
+ self.query_one("#acm-arn").value = (
1127
+ cfg.ssl.certificate_arn or ""
1128
+ )
1129
+
1130
+ target_id = {
1131
+ "none": "dns-none",
1132
+ "letsencrypt": "dns-letsencrypt",
1133
+ "cloudflare": "dns-cloudflare",
1134
+ "acm": "dns-acm",
1135
+ "self_signed": "dns-self_signed",
1136
+ }.get(cfg.ssl.provider, "dns-none")
1137
+ if not cfg.dns.enabled and cfg.ssl.provider == "self_signed":
1138
+ target_id = "dns-self_signed"
1139
+ elif not cfg.dns.enabled and cfg.ssl.provider == "none":
1140
+ target_id = "dns-none"
1141
+ # Same caveat as `_refresh_advanced_from_cfg._select`: this runs from
1142
+ # inside a RadioSet message handler with RadioButton.Changed prevented,
1143
+ # so we mutate values directly and reset `_pressed_button`.
1144
+ dns_mode = self.query_one("#dns-mode", RadioSet)
1145
+ target: RadioButton | None = None
1146
+ for btn in dns_mode.query(RadioButton):
1147
+ if btn.id == target_id:
1148
+ target = btn
1149
+ else:
1150
+ if btn.value:
1151
+ btn.value = False
1152
+ if target is not None:
1153
+ target.value = True
1154
+ dns_mode._pressed_button = target
1155
+ self.query_one("#eip-help").display = (
1156
+ cfg.ssl.provider == "cloudflare"
1157
+ )
1158
+
1159
+ @on(RadioSet.Changed, "#adv-eip-strategy")
1160
+ def _adv_eip_changed(self, event: RadioSet.Changed) -> None:
1161
+ self.query_one("#adv-eip-help").display = (
1162
+ event.pressed.id == "adv-eip-persistent"
1163
+ )
1164
+
1165
+ def on_mount(self) -> None:
1166
+ cfg = self.app.config
1167
+ is_cloudflare = (
1168
+ cfg.dns.enabled
1169
+ and cfg.ssl.provider == "cloudflare"
1170
+ )
1171
+ self.query_one("#eip-help").display = is_cloudflare
1172
+ self.query_one("#dns-guided").display = True
1173
+ self.query_one("#dns-advanced").display = False
1174
+ self.query_one("#adv-eip-help").display = (
1175
+ cfg.eip.strategy == "persistent"
1176
+ )
1177
+ self.query_one("#dns-validation-error").display = False
1178
+
1179
+ @on(Button.Pressed, "#back")
1180
+ def _back(self) -> None:
1181
+ self.app.pop_screen()
1182
+
1183
+ def _save_guided(self) -> None:
1184
+ radio = self.query_one("#dns-mode", RadioSet)
1185
+ pressed_button = getattr(radio, "pressed_button", None)
1186
+ if pressed_button is not None:
1187
+ pressed_id = pressed_button.id
1188
+ else:
1189
+ pressed_id = "dns-none"
1190
+ for btn in radio.query(RadioButton):
1191
+ if btn.value:
1192
+ pressed_id = btn.id
1193
+ break
1194
+ provider = self.PROVIDER_BY_BUTTON_ID.get(pressed_id, "none")
1195
+ cfg = self.app.config
1196
+
1197
+ domain = self.query_one("#domain", Input).value
1198
+ email = self.query_one("#ssl-email", Input).value
1199
+ acm_arn = self.query_one("#acm-arn", Input).value
1200
+
1201
+ if provider == "none":
1202
+ cfg.dns.enabled = False
1203
+ cfg.ssl.provider = "none"
1204
+ cfg.eip.strategy = "dynamic"
1205
+ elif provider == "letsencrypt":
1206
+ cfg.dns.enabled = True
1207
+ cfg.dns.terraform_managed = True
1208
+ cfg.dns.domain = domain
1209
+ cfg.ssl.provider = "letsencrypt"
1210
+ cfg.ssl.email = email
1211
+ cfg.eip.strategy = "dynamic"
1212
+ elif provider == "cloudflare":
1213
+ cfg.dns.enabled = True
1214
+ cfg.dns.terraform_managed = False
1215
+ cfg.dns.domain = domain
1216
+ cfg.ssl.provider = "cloudflare"
1217
+ cfg.eip.strategy = "persistent"
1218
+ elif provider == "acm":
1219
+ cfg.dns.enabled = True
1220
+ cfg.dns.terraform_managed = True
1221
+ cfg.dns.domain = domain
1222
+ cfg.ssl.provider = "acm"
1223
+ cfg.ssl.certificate_arn = acm_arn
1224
+ cfg.eip.strategy = "dynamic"
1225
+ elif provider == "self_signed":
1226
+ cfg.dns.enabled = False
1227
+ cfg.ssl.provider = "self_signed"
1228
+ cfg.eip.strategy = "dynamic"
1229
+
1230
+ def _save_advanced(self) -> None:
1231
+ cfg = self.app.config
1232
+
1233
+ def _selected_id(radioset_id: str) -> str:
1234
+ for btn in self.query_one(radioset_id).query(RadioButton):
1235
+ if btn.value:
1236
+ return btn.id or ""
1237
+ return ""
1238
+
1239
+ cfg.dns.enabled = (
1240
+ _selected_id("#adv-dns-enabled") == "adv-dns-enabled-yes"
1241
+ )
1242
+ cfg.dns.terraform_managed = (
1243
+ _selected_id("#adv-dns-tfmanaged")
1244
+ == "adv-dns-tfmanaged-yes"
1245
+ )
1246
+ cfg.dns.domain = self.query_one("#adv-dns-domain").value
1247
+ cfg.dns.zone_id = self.query_one("#adv-dns-zone-id").value
1248
+
1249
+ provider_map = {
1250
+ "adv-ssl-none": "none",
1251
+ "adv-ssl-letsencrypt": "letsencrypt",
1252
+ "adv-ssl-cloudflare": "cloudflare",
1253
+ "adv-ssl-acm": "acm",
1254
+ "adv-ssl-self_signed": "self_signed",
1255
+ }
1256
+ cfg.ssl.provider = provider_map.get(
1257
+ _selected_id("#adv-ssl-provider"), "none"
1258
+ )
1259
+ cfg.ssl.email = self.query_one("#adv-ssl-email").value
1260
+ cfg.ssl.certificate_arn = self.query_one(
1261
+ "#adv-ssl-acm-arn"
1262
+ ).value
1263
+
1264
+ cfg.eip.strategy = (
1265
+ "persistent"
1266
+ if _selected_id("#adv-eip-strategy")
1267
+ == "adv-eip-persistent"
1268
+ else "dynamic"
1269
+ )
1270
+
1271
+ @on(Button.Pressed, "#next")
1272
+ def _next(self) -> None:
1273
+ from lablink_cli.config.schema import validate_config
1274
+
1275
+ is_advanced = self.query_one("#dns-advanced").display
1276
+ if is_advanced:
1277
+ self._save_advanced()
1278
+ else:
1279
+ self._save_guided()
1280
+
1281
+ if is_advanced:
1282
+ errors = validate_config(self.app.config)
1283
+ if errors:
1284
+ err_label = self.query_one("#dns-validation-error")
1285
+ err_label.update("\n".join(errors))
1286
+ err_label.display = True
1287
+ return
1288
+
1289
+ self.app.push_screen(StartupScreen())
1290
+
1291
+
1292
+ # ---------------------------------------------------------------------------
1293
+ # Screen 5: Startup Script
1294
+ # ---------------------------------------------------------------------------
1295
+ STARTUP_TEMPLATE_PATH = (
1296
+ Path(__file__).resolve().parent.parent
1297
+ / "terraform"
1298
+ / "config"
1299
+ / "startup-template.sh"
1300
+ )
1301
+
1302
+
1303
+ class StartupScreen(Screen):
1304
+ """Configure custom startup script for client VMs."""
1305
+
1306
+ BINDINGS = [Binding("escape", "back", "Back")]
1307
+
1308
+ def compose(self) -> ComposeResult:
1309
+ cfg = self.app.config
1310
+
1311
+ yield Header()
1312
+ with VerticalScroll():
1313
+ yield Label(
1314
+ "Step 5: Client Startup Script",
1315
+ classes="step-title",
1316
+ )
1317
+ yield Label(
1318
+ "Optional script that runs inside each "
1319
+ "client VM container after launch.",
1320
+ classes="step-description",
1321
+ )
1322
+
1323
+ yield Label("Startup Script", classes="field-label")
1324
+ with RadioSet(id="startup-mode"):
1325
+ yield RadioButton(
1326
+ "None (no startup script)",
1327
+ value=not cfg.startup_script.enabled,
1328
+ )
1329
+ yield RadioButton(
1330
+ "Use template (edit below)",
1331
+ value=(
1332
+ cfg.startup_script.enabled
1333
+ and not self._has_custom_path()
1334
+ ),
1335
+ )
1336
+ yield RadioButton(
1337
+ "Use file from disk",
1338
+ value=(
1339
+ cfg.startup_script.enabled
1340
+ and self._has_custom_path()
1341
+ ),
1342
+ )
1343
+
1344
+ # Determine initial mode
1345
+ is_template = (
1346
+ cfg.startup_script.enabled
1347
+ and not self._has_custom_path()
1348
+ )
1349
+ is_file = (
1350
+ cfg.startup_script.enabled
1351
+ and self._has_custom_path()
1352
+ )
1353
+
1354
+ # Template editor
1355
+ template_content = self._load_template()
1356
+ yield TextArea(
1357
+ template_content,
1358
+ id="script-editor",
1359
+ language="bash",
1360
+ disabled=not is_template,
1361
+ )
1362
+
1363
+ # File path input
1364
+ yield Label(
1365
+ "Script file path",
1366
+ classes="field-label",
1367
+ id="path-label",
1368
+ )
1369
+ yield Input(
1370
+ value=(
1371
+ cfg.startup_script.path
1372
+ if self._has_custom_path()
1373
+ else ""
1374
+ ),
1375
+ placeholder="/path/to/startup.sh",
1376
+ id="script-path",
1377
+ disabled=not is_file,
1378
+ )
1379
+ yield Button(
1380
+ "Check path",
1381
+ id="check-path",
1382
+ disabled=not is_file,
1383
+ )
1384
+ yield Label(
1385
+ "",
1386
+ id="path-status",
1387
+ )
1388
+
1389
+ yield Label(
1390
+ "On error", classes="field-label"
1391
+ )
1392
+ with RadioSet(id="on-error"):
1393
+ yield RadioButton(
1394
+ "Continue (log and proceed)",
1395
+ value=(
1396
+ cfg.startup_script.on_error
1397
+ == "continue"
1398
+ ),
1399
+ )
1400
+ yield RadioButton(
1401
+ "Fail (stop VM setup)",
1402
+ value=(
1403
+ cfg.startup_script.on_error == "fail"
1404
+ ),
1405
+ )
1406
+
1407
+ yield Label(
1408
+ "Max attempts", classes="field-label"
1409
+ )
1410
+ yield Input(
1411
+ value=str(cfg.startup_script.max_attempts),
1412
+ type="integer",
1413
+ id="max-attempts",
1414
+ )
1415
+
1416
+ yield Label(
1417
+ "Base delay (seconds)", classes="field-label"
1418
+ )
1419
+ yield Input(
1420
+ value=str(cfg.startup_script.base_delay_seconds),
1421
+ type="integer",
1422
+ id="base-delay",
1423
+ )
1424
+
1425
+ yield Label(
1426
+ "Success check command (optional)",
1427
+ classes="field-label",
1428
+ )
1429
+ yield Input(
1430
+ value=cfg.startup_script.success_check,
1431
+ placeholder=(
1432
+ "e.g. /home/client/.local/bin/sleap --version"
1433
+ ),
1434
+ id="success-check",
1435
+ )
1436
+
1437
+ with Center():
1438
+ with Horizontal(classes="nav-buttons"):
1439
+ yield Button("Back", id="back")
1440
+ yield Button(
1441
+ "Next", variant="primary", id="next"
1442
+ )
1443
+ yield Footer()
1444
+
1445
+ def _has_custom_path(self) -> bool:
1446
+ cfg = self.app.config
1447
+ return (
1448
+ cfg.startup_script.enabled
1449
+ and cfg.startup_script.path
1450
+ and cfg.startup_script.path
1451
+ != "config/custom-startup.sh"
1452
+ )
1453
+
1454
+ def _load_template(self) -> str:
1455
+ # Load existing user script if available, otherwise bundled template
1456
+ existing_script = DEFAULT_CONFIG_DIR / "custom-startup.sh"
1457
+ if existing_script.exists():
1458
+ return existing_script.read_text()
1459
+ if STARTUP_TEMPLATE_PATH.exists():
1460
+ return STARTUP_TEMPLATE_PATH.read_text()
1461
+ return "#!/bin/bash\necho 'Custom startup script'\n"
1462
+
1463
+ @on(RadioSet.Changed, "#startup-mode")
1464
+ def _mode_changed(self, event: RadioSet.Changed) -> None:
1465
+ idx = event.index
1466
+ editor = self.query_one("#script-editor", TextArea)
1467
+ path_input = self.query_one("#script-path", Input)
1468
+
1469
+ check_btn = self.query_one("#check-path", Button)
1470
+ if idx == 0:
1471
+ # None
1472
+ editor.disabled = True
1473
+ path_input.disabled = True
1474
+ check_btn.disabled = True
1475
+ elif idx == 1:
1476
+ # Template
1477
+ editor.disabled = False
1478
+ path_input.disabled = True
1479
+ check_btn.disabled = True
1480
+ elif idx == 2:
1481
+ # File from disk
1482
+ editor.disabled = True
1483
+ path_input.disabled = False
1484
+ check_btn.disabled = False
1485
+
1486
+ @on(Button.Pressed, "#check-path")
1487
+ def _check_path(self) -> None:
1488
+ path_input = self.query_one("#script-path", Input)
1489
+ status = self.query_one("#path-status", Label)
1490
+ local_path = path_input.value.strip()
1491
+ if not local_path:
1492
+ status.update("No path entered.")
1493
+ return
1494
+ p = Path(local_path)
1495
+ if not p.exists():
1496
+ status.update(f"Not found: {local_path}")
1497
+ elif not p.is_file():
1498
+ status.update(f"Not a file: {local_path}")
1499
+ else:
1500
+ status.update(f"Found: {local_path}")
1501
+
1502
+ @on(Button.Pressed, "#back")
1503
+ def _back(self) -> None:
1504
+ self.app.pop_screen()
1505
+
1506
+ @on(Button.Pressed, "#next")
1507
+ def _next(self) -> None:
1508
+ cfg = self.app.config
1509
+ radio = self.query_one("#startup-mode", RadioSet)
1510
+ idx = radio.pressed_index
1511
+
1512
+ error_radio = self.query_one("#on-error", RadioSet)
1513
+ cfg.startup_script.on_error = (
1514
+ "fail"
1515
+ if error_radio.pressed_index == 1
1516
+ else "continue"
1517
+ )
1518
+
1519
+ max_attempts_value = self.query_one(
1520
+ "#max-attempts", Input
1521
+ ).value
1522
+ cfg.startup_script.max_attempts = (
1523
+ int(max_attempts_value) if max_attempts_value else 3
1524
+ )
1525
+ base_delay_value = self.query_one(
1526
+ "#base-delay", Input
1527
+ ).value
1528
+ cfg.startup_script.base_delay_seconds = (
1529
+ int(base_delay_value) if base_delay_value else 30
1530
+ )
1531
+ cfg.startup_script.success_check = self.query_one(
1532
+ "#success-check", Input
1533
+ ).value.strip()
1534
+
1535
+ if idx == 0:
1536
+ # Disabled
1537
+ cfg.startup_script.enabled = False
1538
+ cfg.startup_script.path = ""
1539
+ self.app._startup_script_content = None
1540
+ elif idx == 1:
1541
+ # Template — save editor content
1542
+ cfg.startup_script.enabled = True
1543
+ cfg.startup_script.path = (
1544
+ "config/custom-startup.sh"
1545
+ )
1546
+ editor = self.query_one(
1547
+ "#script-editor", TextArea
1548
+ )
1549
+ self.app._startup_script_content = editor.text
1550
+ elif idx == 2:
1551
+ # File from disk — read content, normalize path
1552
+ local_path = self.query_one(
1553
+ "#script-path", Input
1554
+ ).value.strip()
1555
+ try:
1556
+ self.app._startup_script_content = (
1557
+ Path(local_path).read_text()
1558
+ )
1559
+ cfg.startup_script.enabled = True
1560
+ cfg.startup_script.path = (
1561
+ "config/custom-startup.sh"
1562
+ )
1563
+ except (FileNotFoundError, OSError):
1564
+ cfg.startup_script.enabled = False
1565
+ self.app._startup_script_content = None
1566
+
1567
+ self.app.push_screen(MonitoringScreen())
1568
+
1569
+
1570
+ # ---------------------------------------------------------------------------
1571
+ # Screen 6: Session Metrics (Tier 1 Monitoring)
1572
+ # ---------------------------------------------------------------------------
1573
+ class MonitoringScreen(Screen):
1574
+ """Toggle Tier 1 session-metrics collection.
1575
+
1576
+ Single switch only: enabled / disabled. All other MonitoringConfig
1577
+ fields (process_allowlist, watch_dir, intervals) keep their dataclass
1578
+ defaults — operators who need to customize them still hand-edit
1579
+ lablink.yaml. This screen is SLEAP-specific and expected to be
1580
+ removed when monitoring is generalized or dropped.
1581
+ """
1582
+
1583
+ BINDINGS = [Binding("escape", "back", "Back")]
1584
+
1585
+ def compose(self) -> ComposeResult:
1586
+ cfg = self.app.config
1587
+
1588
+ yield Header()
1589
+ with VerticalScroll():
1590
+ yield Label(
1591
+ "Step 6: Session Metrics (optional)",
1592
+ classes="step-title",
1593
+ )
1594
+ yield Label(
1595
+ "Collect anonymous per-VM session metrics "
1596
+ "(Tier 1 monitoring). Currently SLEAP-tuned — leave "
1597
+ "disabled for non-SLEAP workloads.",
1598
+ classes="step-description",
1599
+ )
1600
+
1601
+ yield Label("Session metrics", classes="field-label")
1602
+ with RadioSet(id="monitoring-mode"):
1603
+ yield RadioButton(
1604
+ "Disabled (default)",
1605
+ value=not cfg.monitoring.enabled,
1606
+ )
1607
+ yield RadioButton(
1608
+ "Enabled",
1609
+ value=cfg.monitoring.enabled,
1610
+ )
1611
+
1612
+ with Center():
1613
+ with Horizontal(classes="nav-buttons"):
1614
+ yield Button("Back", id="back")
1615
+ yield Button("Next", variant="primary", id="next")
1616
+ yield Footer()
1617
+
1618
+ @on(Button.Pressed, "#back")
1619
+ def _back(self) -> None:
1620
+ self.app.pop_screen()
1621
+
1622
+ @on(Button.Pressed, "#next")
1623
+ def _next(self) -> None:
1624
+ cfg = self.app.config
1625
+ radio = self.query_one("#monitoring-mode", RadioSet)
1626
+ cfg.monitoring.enabled = radio.pressed_index == 1
1627
+ self.app.push_screen(ReviewScreen())
1628
+
1629
+
1630
+ # ---------------------------------------------------------------------------
1631
+ # Screen 7: Review & Save
1632
+ # ---------------------------------------------------------------------------
1633
+ class ReviewScreen(Screen):
1634
+ """Review configuration and save."""
1635
+
1636
+ BINDINGS = [Binding("escape", "back", "Back")]
1637
+
1638
+ def compose(self) -> ComposeResult:
1639
+ yield Header()
1640
+ with VerticalScroll():
1641
+ yield Label(
1642
+ "Step 7: Review & Save",
1643
+ classes="step-title",
1644
+ )
1645
+ yield TextArea(
1646
+ id="review-yaml",
1647
+ read_only=True,
1648
+ language="yaml",
1649
+ )
1650
+ yield Label(
1651
+ "", id="save-path-label",
1652
+ classes="step-description",
1653
+ )
1654
+ errors_label = Label("", id="errors", classes="error")
1655
+ errors_label.display = False
1656
+ yield errors_label
1657
+ with Center():
1658
+ with Horizontal(classes="nav-buttons"):
1659
+ yield Button("Back", id="back")
1660
+ yield Button(
1661
+ "Save & Exit",
1662
+ variant="success",
1663
+ id="save",
1664
+ )
1665
+ yield Footer()
1666
+
1667
+ def on_mount(self) -> None:
1668
+ import yaml
1669
+
1670
+ cfg_dict = config_to_dict(self.app.config)
1671
+ yaml_str = yaml.dump(
1672
+ cfg_dict, default_flow_style=False, sort_keys=False
1673
+ )
1674
+ self.query_one("#review-yaml", TextArea).text = yaml_str
1675
+
1676
+ self.query_one("#save-path-label", Label).update(
1677
+ f"Config will be saved to: {self.app.save_path}"
1678
+ )
1679
+
1680
+ errors = validate_config(self.app.config)
1681
+ if errors:
1682
+ label = self.query_one("#errors", Label)
1683
+ label.update("\n".join(f" * {e}" for e in errors))
1684
+ label.display = True
1685
+
1686
+ @on(Button.Pressed, "#back")
1687
+ def _back(self) -> None:
1688
+ self.app.pop_screen()
1689
+
1690
+ @on(Button.Pressed, "#save")
1691
+ def _save(self) -> None:
1692
+ errors = validate_config(self.app.config)
1693
+ if errors:
1694
+ return
1695
+ save_path = self.app.save_path
1696
+ save_config(self.app.config, save_path)
1697
+
1698
+ # Write startup script if provided
1699
+ content = getattr(
1700
+ self.app, "_startup_script_content", None
1701
+ )
1702
+ if content:
1703
+ script_path = (
1704
+ save_path.parent / "custom-startup.sh"
1705
+ )
1706
+ script_path.write_text(content)
1707
+ script_path.chmod(0o755)
1708
+
1709
+ self.app.exit(
1710
+ message=f"Config saved to {save_path}"
1711
+ )
1712
+
1713
+
1714
+ # ---------------------------------------------------------------------------
1715
+ # Main App
1716
+ # ---------------------------------------------------------------------------
1717
+ class ConfigWizard(App):
1718
+ """LabLink configuration wizard."""
1719
+
1720
+ TITLE = "LabLink Setup Wizard"
1721
+ CSS = """
1722
+ Screen {
1723
+ align: center middle;
1724
+ }
1725
+ /* Only the screen's own scroll viewport claims the available height.
1726
+ Must stay scoped to a direct child of Screen: Textual's RadioSet is
1727
+ itself a VerticalScroll subclass, and a bare `VerticalScroll` type
1728
+ selector matches subclasses — that overrode every RadioSet's
1729
+ `height: auto` with `1fr`, collapsing the radio boxes to 0 rows. */
1730
+ Screen > VerticalScroll {
1731
+ height: 1fr;
1732
+ }
1733
+ #dns-guided, #dns-advanced {
1734
+ height: auto;
1735
+ }
1736
+ .step-title {
1737
+ text-style: bold;
1738
+ color: $accent;
1739
+ margin: 1 2;
1740
+ text-align: center;
1741
+ width: 100%;
1742
+ }
1743
+ .step-description {
1744
+ color: $text-muted;
1745
+ margin: 0 2 1 2;
1746
+ text-align: center;
1747
+ width: 100%;
1748
+ }
1749
+ .field-label {
1750
+ margin: 1 2 0 2;
1751
+ text-style: bold;
1752
+ }
1753
+ Input {
1754
+ margin: 0 2;
1755
+ }
1756
+ OptionList {
1757
+ margin: 0 2;
1758
+ height: auto;
1759
+ max-height: 12;
1760
+ }
1761
+ RadioSet {
1762
+ margin: 0 2;
1763
+ /* Redundant with Textual's default, stated explicitly so a future
1764
+ container rule can't silently collapse the options again. */
1765
+ height: auto;
1766
+ }
1767
+ TextArea {
1768
+ margin: 0 2;
1769
+ /* Scale with the terminal instead of a hard 20 rows, which on a
1770
+ short terminal was taller than the whole form viewport and buried
1771
+ the fields below it. The percentage resolves against the scroll
1772
+ viewport; `1fr` would not work here — like RadioSet above it only
1773
+ gets the space fixed-size siblings leave over, which on the
1774
+ startup-script form is nothing. */
1775
+ height: 60%;
1776
+ min-height: 6;
1777
+ max-height: 24;
1778
+ }
1779
+ .nav-buttons {
1780
+ margin: 1 0;
1781
+ height: auto;
1782
+ }
1783
+ .nav-buttons Button {
1784
+ margin: 0 1;
1785
+ }
1786
+ #check-path {
1787
+ margin: 1 2;
1788
+ }
1789
+ #path-status {
1790
+ margin: 0 2;
1791
+ color: $text-muted;
1792
+ }
1793
+ .error {
1794
+ color: $error;
1795
+ margin: 1 2;
1796
+ }
1797
+ """
1798
+
1799
+ BINDINGS = [
1800
+ Binding("q", "quit", "Quit"),
1801
+ ]
1802
+
1803
+ def __init__(
1804
+ self,
1805
+ existing_config: Config | None = None,
1806
+ save_path: Path | None = None,
1807
+ ) -> None:
1808
+ super().__init__()
1809
+ self.config = existing_config if existing_config else Config()
1810
+ self.save_path = save_path or DEFAULT_CONFIG_PATH
1811
+ self._startup_script_content: str | None = None
1812
+
1813
+ def on_mount(self) -> None:
1814
+ self.push_screen(DeploymentScreen())