lablink-cli 0.1.0a1__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,1794 @@
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
+ @on(OptionList.OptionSelected)
577
+ def _select(self, event: OptionList.OptionSelected) -> None:
578
+ region = str(event.option.id)
579
+ self.app.config.app.region = region
580
+ # Auto-select AMI for the chosen region
581
+ if region in AMI_MAP:
582
+ self.app.config.machine.ami_id = AMI_MAP[region]
583
+
584
+ @on(Button.Pressed, "#back")
585
+ def _back(self) -> None:
586
+ self.app.pop_screen()
587
+
588
+ @on(Button.Pressed, "#next")
589
+ def _next(self) -> None:
590
+ self.app.push_screen(MachineScreen())
591
+
592
+
593
+ # ---------------------------------------------------------------------------
594
+ # Screen 3: Machine Configuration
595
+ # ---------------------------------------------------------------------------
596
+ class MachineScreen(Screen):
597
+ """Configure client VM instance type and software."""
598
+
599
+ BINDINGS = [Binding("escape", "back", "Back")]
600
+
601
+ def compose(self) -> ComposeResult:
602
+ yield Header()
603
+ with VerticalScroll():
604
+ yield Label(
605
+ "Step 3: Machine Configuration",
606
+ classes="step-title",
607
+ )
608
+ yield Label(
609
+ "Select the instance type for student VMs. "
610
+ "GPU instances are recommended for ML workloads.\n"
611
+ "Docs: https://aws.amazon.com/ec2/instance-types/",
612
+ classes="step-description",
613
+ )
614
+
615
+ yield Label("Instance Type", classes="field-label")
616
+ gpu_options = [
617
+ Option(
618
+ f"{t['type']:18s} {t['gpu']:14s} "
619
+ f"{t['vcpu']} vCPU {t['ram']:8s} {t['cost']}",
620
+ id=t["type"],
621
+ )
622
+ for t in GPU_INSTANCE_TYPES
623
+ ]
624
+ cpu_options = [
625
+ Option(
626
+ f"{t['type']:18s} {'—':14s} "
627
+ f"{t['vcpu']} vCPU {t['ram']:8s} {t['cost']}",
628
+ id=t["type"],
629
+ )
630
+ for t in CPU_INSTANCE_TYPES
631
+ ]
632
+ yield OptionList(
633
+ Option("── GPU Instances ──", disabled=True),
634
+ *gpu_options,
635
+ None,
636
+ Option(
637
+ "── CPU Only (no GPU) ──", disabled=True
638
+ ),
639
+ *cpu_options,
640
+ id="instance-list",
641
+ )
642
+
643
+ cfg = self.app.config
644
+
645
+ yield Label(
646
+ "Software Name (the tool students will use)",
647
+ classes="field-label",
648
+ )
649
+ yield Input(
650
+ value=cfg.machine.software or "",
651
+ placeholder="e.g. sleap, deeplabcut, napari",
652
+ id="software",
653
+ )
654
+
655
+ yield Label(
656
+ "Git Repository (course materials cloned into each VM)",
657
+ classes="field-label",
658
+ )
659
+ yield Input(
660
+ value=cfg.machine.repository or "",
661
+ placeholder=(
662
+ "https://github.com/org/repo.git"
663
+ ),
664
+ id="repository",
665
+ )
666
+
667
+ with Center():
668
+ with Horizontal(classes="nav-buttons"):
669
+ yield Button("Back", id="back")
670
+ yield Button(
671
+ "Next", variant="primary", id="next"
672
+ )
673
+ yield Footer()
674
+
675
+ @on(OptionList.OptionSelected, "#instance-list")
676
+ def _select_instance(
677
+ self, event: OptionList.OptionSelected
678
+ ) -> None:
679
+ self.app.config.machine.machine_type = str(
680
+ event.option.id
681
+ )
682
+
683
+ @on(Button.Pressed, "#back")
684
+ def _back(self) -> None:
685
+ self.app.pop_screen()
686
+
687
+ @on(Button.Pressed, "#next")
688
+ def _next(self) -> None:
689
+ software = self.query_one("#software", Input).value
690
+ repository = self.query_one("#repository", Input).value
691
+
692
+ if software:
693
+ self.app.config.machine.software = software
694
+ self.app.config.machine.repository = (
695
+ repository if repository else None
696
+ )
697
+
698
+ self.app.push_screen(DnsScreen())
699
+
700
+
701
+ # ---------------------------------------------------------------------------
702
+ # Screen 4: DNS & SSL
703
+ # ---------------------------------------------------------------------------
704
+ class DnsScreen(Screen):
705
+ """Configure DNS and SSL settings."""
706
+
707
+ BINDINGS = [Binding("escape", "back", "Back")]
708
+
709
+ PROVIDER_BY_BUTTON_ID = {
710
+ "dns-none": "none",
711
+ "dns-letsencrypt": "letsencrypt",
712
+ "dns-cloudflare": "cloudflare",
713
+ "dns-acm": "acm",
714
+ "dns-self_signed": "self_signed",
715
+ }
716
+
717
+ def compose(self) -> ComposeResult:
718
+ cfg = self.app.config
719
+
720
+ # Determine which radio button to pre-select.
721
+ # Indices follow AWS-style ordering (0..4); when manual provider is
722
+ # selected we hide indices 1..3 but the same numbering is used
723
+ # for the value-checking logic below.
724
+ if not cfg.dns.enabled and cfg.ssl.provider == "self_signed":
725
+ default_idx = 4
726
+ elif not cfg.dns.enabled:
727
+ default_idx = 0
728
+ elif cfg.ssl.provider == "letsencrypt":
729
+ default_idx = 1
730
+ elif cfg.ssl.provider == "cloudflare":
731
+ default_idx = 2
732
+ elif cfg.ssl.provider == "acm":
733
+ default_idx = 3
734
+ else:
735
+ default_idx = 0
736
+
737
+ is_manual = getattr(cfg, "provider", "aws") == "manual"
738
+
739
+ # Initial disabled state for the three text inputs, computed from
740
+ # the pre-selected provider (after manual filtering).
741
+ if default_idx == 1 and not is_manual:
742
+ domain_disabled = False
743
+ email_disabled = False
744
+ acm_disabled = True
745
+ elif default_idx == 2 and not is_manual:
746
+ domain_disabled = False
747
+ email_disabled = True
748
+ acm_disabled = True
749
+ elif default_idx == 3 and not is_manual:
750
+ domain_disabled = False
751
+ email_disabled = True
752
+ acm_disabled = False
753
+ else:
754
+ domain_disabled = True
755
+ email_disabled = True
756
+ acm_disabled = True
757
+
758
+ yield Header()
759
+ with VerticalScroll():
760
+ yield Label(
761
+ "Step 4: DNS & SSL", classes="step-title"
762
+ )
763
+
764
+ # Mode toggle (Guided default, Advanced opt-in).
765
+ with RadioSet(id="dns-screen-mode"):
766
+ yield RadioButton(
767
+ "Guided — common presets",
768
+ value=True,
769
+ id="screen-mode-guided",
770
+ )
771
+ yield RadioButton(
772
+ "Advanced — edit every field directly",
773
+ value=False,
774
+ id="screen-mode-advanced",
775
+ )
776
+
777
+ with Container(id="dns-guided"):
778
+ yield Label("Access Method", classes="field-label")
779
+ with RadioSet(id="dns-mode"):
780
+ yield RadioButton(
781
+ "IP Only — simplest setup, access via IP, no SSL",
782
+ value=(default_idx == 0),
783
+ id="dns-none",
784
+ )
785
+ if not is_manual:
786
+ yield RadioButton(
787
+ "Let's Encrypt — free automatic SSL, requires a "
788
+ "domain (https://letsencrypt.org/)",
789
+ value=(default_idx == 1),
790
+ id="dns-letsencrypt",
791
+ )
792
+ yield RadioButton(
793
+ "CloudFlare — use if your domain is already on "
794
+ "CloudFlare (https://www.cloudflare.com/application-services/products/ssl/)",
795
+ value=(default_idx == 2),
796
+ id="dns-cloudflare",
797
+ )
798
+ yield RadioButton(
799
+ "AWS ACM — AWS-managed SSL with load balancer, "
800
+ "requires certificate "
801
+ "(https://docs.aws.amazon.com/acm/latest/userguide/acm-overview.html)",
802
+ value=(default_idx == 3),
803
+ id="dns-acm",
804
+ )
805
+ yield RadioButton(
806
+ "Self-signed — browser warns once; fine for closed-LAN labs",
807
+ value=(default_idx == 4),
808
+ id="dns-self_signed",
809
+ )
810
+
811
+ yield Label(
812
+ "Domain Name",
813
+ classes="field-label",
814
+ id="domain-label",
815
+ )
816
+ yield Input(
817
+ value=cfg.dns.domain or "",
818
+ placeholder="lablink.example.com",
819
+ id="domain",
820
+ disabled=domain_disabled,
821
+ )
822
+
823
+ yield Label(
824
+ "Email (for SSL certificates)",
825
+ classes="field-label",
826
+ id="email-label",
827
+ )
828
+ yield Input(
829
+ value=cfg.ssl.email or "",
830
+ placeholder="admin@example.com",
831
+ id="ssl-email",
832
+ disabled=email_disabled,
833
+ )
834
+
835
+ yield Label(
836
+ "ACM Certificate ARN",
837
+ classes="field-label",
838
+ id="acm-label",
839
+ )
840
+ yield Input(
841
+ value=cfg.ssl.certificate_arn or "",
842
+ placeholder=(
843
+ "arn:aws:acm:region:account:certificate/id"
844
+ ),
845
+ id="acm-arn",
846
+ disabled=acm_disabled,
847
+ )
848
+
849
+ tag = (
850
+ f"{cfg.deployment_name or '<deployment_name>'}"
851
+ f"-eip-"
852
+ f"{cfg.environment or '<environment>'}"
853
+ )
854
+ yield Label(
855
+ "Persistent EIP required for Cloudflare.\n"
856
+ "Tag your pre-allocated EIP with:\n"
857
+ f" Name = {tag}\n"
858
+ "Example:\n"
859
+ " aws ec2 create-tags --resources eipalloc-XXXXX \\\n"
860
+ f" --tags Key=Name,Value={tag}",
861
+ id="eip-help",
862
+ classes="step-description",
863
+ )
864
+
865
+ with Container(id="dns-advanced"):
866
+ yield Label(
867
+ "Advanced — direct config edit. "
868
+ "Values from current config are pre-filled.",
869
+ classes="step-description",
870
+ )
871
+
872
+ yield Label("DNS", classes="field-label")
873
+
874
+ yield Label("Enabled", classes="field-label")
875
+ with RadioSet(id="adv-dns-enabled"):
876
+ yield RadioButton(
877
+ "Yes",
878
+ value=cfg.dns.enabled,
879
+ id="adv-dns-enabled-yes",
880
+ )
881
+ yield RadioButton(
882
+ "No",
883
+ value=not cfg.dns.enabled,
884
+ id="adv-dns-enabled-no",
885
+ )
886
+
887
+ yield Label(
888
+ "OpenTofu-managed records",
889
+ classes="field-label",
890
+ )
891
+ with RadioSet(id="adv-dns-tfmanaged"):
892
+ yield RadioButton(
893
+ "Yes",
894
+ value=cfg.dns.terraform_managed,
895
+ id="adv-dns-tfmanaged-yes",
896
+ )
897
+ yield RadioButton(
898
+ "No",
899
+ value=not cfg.dns.terraform_managed,
900
+ id="adv-dns-tfmanaged-no",
901
+ )
902
+
903
+ yield Label("Domain", classes="field-label")
904
+ yield Input(
905
+ value=cfg.dns.domain or "",
906
+ placeholder="lablink.example.com",
907
+ id="adv-dns-domain",
908
+ )
909
+
910
+ yield Label(
911
+ "Zone ID (optional)", classes="field-label"
912
+ )
913
+ yield Input(
914
+ value=cfg.dns.zone_id or "",
915
+ placeholder="Z0123456789ABCDEFG",
916
+ id="adv-dns-zone-id",
917
+ )
918
+
919
+ yield Label("SSL", classes="field-label")
920
+ yield Label("Provider", classes="field-label")
921
+ with RadioSet(id="adv-ssl-provider"):
922
+ yield RadioButton(
923
+ "none",
924
+ value=(cfg.ssl.provider == "none"),
925
+ id="adv-ssl-none",
926
+ )
927
+ yield RadioButton(
928
+ "letsencrypt",
929
+ value=(cfg.ssl.provider == "letsencrypt"),
930
+ id="adv-ssl-letsencrypt",
931
+ )
932
+ yield RadioButton(
933
+ "cloudflare",
934
+ value=(cfg.ssl.provider == "cloudflare"),
935
+ id="adv-ssl-cloudflare",
936
+ )
937
+ yield RadioButton(
938
+ "acm",
939
+ value=(cfg.ssl.provider == "acm"),
940
+ id="adv-ssl-acm",
941
+ )
942
+ yield RadioButton(
943
+ "self_signed",
944
+ value=(cfg.ssl.provider == "self_signed"),
945
+ id="adv-ssl-self_signed",
946
+ )
947
+
948
+ yield Label("Email", classes="field-label")
949
+ yield Input(
950
+ value=cfg.ssl.email or "",
951
+ placeholder="admin@example.com",
952
+ id="adv-ssl-email",
953
+ )
954
+
955
+ yield Label(
956
+ "ACM Certificate ARN", classes="field-label"
957
+ )
958
+ yield Input(
959
+ value=cfg.ssl.certificate_arn or "",
960
+ placeholder=(
961
+ "arn:aws:acm:region:account:certificate/id"
962
+ ),
963
+ id="adv-ssl-acm-arn",
964
+ )
965
+
966
+ yield Label("EIP", classes="field-label")
967
+ yield Label("Strategy", classes="field-label")
968
+ with RadioSet(id="adv-eip-strategy"):
969
+ yield RadioButton(
970
+ "dynamic",
971
+ value=(cfg.eip.strategy == "dynamic"),
972
+ id="adv-eip-dynamic",
973
+ )
974
+ yield RadioButton(
975
+ "persistent",
976
+ value=(cfg.eip.strategy == "persistent"),
977
+ id="adv-eip-persistent",
978
+ )
979
+
980
+ tag = (
981
+ f"{cfg.deployment_name or '<deployment_name>'}"
982
+ f"-eip-"
983
+ f"{cfg.environment or '<environment>'}"
984
+ )
985
+ yield Label(
986
+ "Persistent EIP requires a pre-allocated EIP tagged:\n"
987
+ f" Name = {tag}",
988
+ id="adv-eip-help",
989
+ classes="step-description",
990
+ )
991
+
992
+ yield Label(
993
+ "",
994
+ id="dns-validation-error",
995
+ classes="step-description",
996
+ )
997
+
998
+ with Center():
999
+ with Horizontal(classes="nav-buttons"):
1000
+ yield Button("Back", id="back")
1001
+ yield Button(
1002
+ "Next", variant="primary", id="next"
1003
+ )
1004
+ yield Footer()
1005
+
1006
+ @on(RadioSet.Changed, "#dns-mode")
1007
+ def _dns_changed(self, event: RadioSet.Changed) -> None:
1008
+ provider = self.PROVIDER_BY_BUTTON_ID.get(event.pressed.id, "none")
1009
+
1010
+ domain_input = self.query_one("#domain", Input)
1011
+ email_input = self.query_one("#ssl-email", Input)
1012
+ acm_input = self.query_one("#acm-arn", Input)
1013
+
1014
+ domain_needed = provider in ("letsencrypt", "cloudflare", "acm")
1015
+ email_needed = provider == "letsencrypt"
1016
+ acm_needed = provider == "acm"
1017
+
1018
+ domain_input.disabled = not domain_needed
1019
+ email_input.disabled = not email_needed
1020
+ acm_input.disabled = not acm_needed
1021
+
1022
+ # Toggle EIP-help visibility with the selected provider.
1023
+ self.query_one("#eip-help").display = (provider == "cloudflare")
1024
+
1025
+ @on(RadioSet.Changed, "#dns-screen-mode")
1026
+ def _screen_mode_changed(self, event: RadioSet.Changed) -> None:
1027
+ is_advanced = event.pressed.id == "screen-mode-advanced"
1028
+ if is_advanced:
1029
+ # Save the Guided state to cfg so Advanced sees the latest.
1030
+ self._save_guided()
1031
+ self._refresh_advanced_from_cfg()
1032
+ else:
1033
+ # Going from Advanced back to Guided: save Advanced first.
1034
+ self._save_advanced()
1035
+ self._refresh_guided_from_cfg()
1036
+ self.query_one("#dns-guided").display = not is_advanced
1037
+ self.query_one("#dns-advanced").display = is_advanced
1038
+
1039
+ def _refresh_advanced_from_cfg(self) -> None:
1040
+ cfg = self.app.config
1041
+
1042
+ def _select(radioset_id: str, button_id: str) -> None:
1043
+ # We're called from RadioSet message handlers which run with
1044
+ # `prevent(RadioButton.Changed)` active, so simply setting
1045
+ # button.value won't propagate through RadioSet's single-selection
1046
+ # logic. Mutate values directly and update the RadioSet's
1047
+ # `_pressed_button` so callers see consistent state.
1048
+ radio_set = self.query_one(radioset_id, RadioSet)
1049
+ target: RadioButton | None = None
1050
+ for btn in radio_set.query(RadioButton):
1051
+ if btn.id == button_id:
1052
+ target = btn
1053
+ else:
1054
+ if btn.value:
1055
+ btn.value = False
1056
+ if target is not None:
1057
+ target.value = True
1058
+ radio_set._pressed_button = target
1059
+
1060
+ _select(
1061
+ "#adv-dns-enabled",
1062
+ "adv-dns-enabled-yes"
1063
+ if cfg.dns.enabled
1064
+ else "adv-dns-enabled-no",
1065
+ )
1066
+ _select(
1067
+ "#adv-dns-tfmanaged",
1068
+ "adv-dns-tfmanaged-yes"
1069
+ if cfg.dns.terraform_managed
1070
+ else "adv-dns-tfmanaged-no",
1071
+ )
1072
+ self.query_one("#adv-dns-domain").value = (
1073
+ cfg.dns.domain or ""
1074
+ )
1075
+ self.query_one("#adv-dns-zone-id").value = (
1076
+ cfg.dns.zone_id or ""
1077
+ )
1078
+ _select(
1079
+ "#adv-ssl-provider",
1080
+ {
1081
+ "none": "adv-ssl-none",
1082
+ "letsencrypt": "adv-ssl-letsencrypt",
1083
+ "cloudflare": "adv-ssl-cloudflare",
1084
+ "acm": "adv-ssl-acm",
1085
+ "self_signed": "adv-ssl-self_signed",
1086
+ }.get(cfg.ssl.provider, "adv-ssl-none"),
1087
+ )
1088
+ self.query_one("#adv-ssl-email").value = cfg.ssl.email or ""
1089
+ self.query_one("#adv-ssl-acm-arn").value = (
1090
+ cfg.ssl.certificate_arn or ""
1091
+ )
1092
+ _select(
1093
+ "#adv-eip-strategy",
1094
+ "adv-eip-persistent"
1095
+ if cfg.eip.strategy == "persistent"
1096
+ else "adv-eip-dynamic",
1097
+ )
1098
+ self.query_one("#adv-eip-help").display = (
1099
+ cfg.eip.strategy == "persistent"
1100
+ )
1101
+
1102
+ def _refresh_guided_from_cfg(self) -> None:
1103
+ cfg = self.app.config
1104
+ self.query_one("#domain").value = cfg.dns.domain or ""
1105
+ self.query_one("#ssl-email").value = cfg.ssl.email or ""
1106
+ self.query_one("#acm-arn").value = (
1107
+ cfg.ssl.certificate_arn or ""
1108
+ )
1109
+
1110
+ target_id = {
1111
+ "none": "dns-none",
1112
+ "letsencrypt": "dns-letsencrypt",
1113
+ "cloudflare": "dns-cloudflare",
1114
+ "acm": "dns-acm",
1115
+ "self_signed": "dns-self_signed",
1116
+ }.get(cfg.ssl.provider, "dns-none")
1117
+ if not cfg.dns.enabled and cfg.ssl.provider == "self_signed":
1118
+ target_id = "dns-self_signed"
1119
+ elif not cfg.dns.enabled and cfg.ssl.provider == "none":
1120
+ target_id = "dns-none"
1121
+ # Same caveat as `_refresh_advanced_from_cfg._select`: this runs from
1122
+ # inside a RadioSet message handler with RadioButton.Changed prevented,
1123
+ # so we mutate values directly and reset `_pressed_button`.
1124
+ dns_mode = self.query_one("#dns-mode", RadioSet)
1125
+ target: RadioButton | None = None
1126
+ for btn in dns_mode.query(RadioButton):
1127
+ if btn.id == target_id:
1128
+ target = btn
1129
+ else:
1130
+ if btn.value:
1131
+ btn.value = False
1132
+ if target is not None:
1133
+ target.value = True
1134
+ dns_mode._pressed_button = target
1135
+ self.query_one("#eip-help").display = (
1136
+ cfg.ssl.provider == "cloudflare"
1137
+ )
1138
+
1139
+ @on(RadioSet.Changed, "#adv-eip-strategy")
1140
+ def _adv_eip_changed(self, event: RadioSet.Changed) -> None:
1141
+ self.query_one("#adv-eip-help").display = (
1142
+ event.pressed.id == "adv-eip-persistent"
1143
+ )
1144
+
1145
+ def on_mount(self) -> None:
1146
+ cfg = self.app.config
1147
+ is_cloudflare = (
1148
+ cfg.dns.enabled
1149
+ and cfg.ssl.provider == "cloudflare"
1150
+ )
1151
+ self.query_one("#eip-help").display = is_cloudflare
1152
+ self.query_one("#dns-guided").display = True
1153
+ self.query_one("#dns-advanced").display = False
1154
+ self.query_one("#adv-eip-help").display = (
1155
+ cfg.eip.strategy == "persistent"
1156
+ )
1157
+ self.query_one("#dns-validation-error").display = False
1158
+
1159
+ @on(Button.Pressed, "#back")
1160
+ def _back(self) -> None:
1161
+ self.app.pop_screen()
1162
+
1163
+ def _save_guided(self) -> None:
1164
+ radio = self.query_one("#dns-mode", RadioSet)
1165
+ pressed_button = getattr(radio, "pressed_button", None)
1166
+ if pressed_button is not None:
1167
+ pressed_id = pressed_button.id
1168
+ else:
1169
+ pressed_id = "dns-none"
1170
+ for btn in radio.query(RadioButton):
1171
+ if btn.value:
1172
+ pressed_id = btn.id
1173
+ break
1174
+ provider = self.PROVIDER_BY_BUTTON_ID.get(pressed_id, "none")
1175
+ cfg = self.app.config
1176
+
1177
+ domain = self.query_one("#domain", Input).value
1178
+ email = self.query_one("#ssl-email", Input).value
1179
+ acm_arn = self.query_one("#acm-arn", Input).value
1180
+
1181
+ if provider == "none":
1182
+ cfg.dns.enabled = False
1183
+ cfg.ssl.provider = "none"
1184
+ cfg.eip.strategy = "dynamic"
1185
+ elif provider == "letsencrypt":
1186
+ cfg.dns.enabled = True
1187
+ cfg.dns.terraform_managed = True
1188
+ cfg.dns.domain = domain
1189
+ cfg.ssl.provider = "letsencrypt"
1190
+ cfg.ssl.email = email
1191
+ cfg.eip.strategy = "dynamic"
1192
+ elif provider == "cloudflare":
1193
+ cfg.dns.enabled = True
1194
+ cfg.dns.terraform_managed = False
1195
+ cfg.dns.domain = domain
1196
+ cfg.ssl.provider = "cloudflare"
1197
+ cfg.eip.strategy = "persistent"
1198
+ elif provider == "acm":
1199
+ cfg.dns.enabled = True
1200
+ cfg.dns.terraform_managed = True
1201
+ cfg.dns.domain = domain
1202
+ cfg.ssl.provider = "acm"
1203
+ cfg.ssl.certificate_arn = acm_arn
1204
+ cfg.eip.strategy = "dynamic"
1205
+ elif provider == "self_signed":
1206
+ cfg.dns.enabled = False
1207
+ cfg.ssl.provider = "self_signed"
1208
+ cfg.eip.strategy = "dynamic"
1209
+
1210
+ def _save_advanced(self) -> None:
1211
+ cfg = self.app.config
1212
+
1213
+ def _selected_id(radioset_id: str) -> str:
1214
+ for btn in self.query_one(radioset_id).query(RadioButton):
1215
+ if btn.value:
1216
+ return btn.id or ""
1217
+ return ""
1218
+
1219
+ cfg.dns.enabled = (
1220
+ _selected_id("#adv-dns-enabled") == "adv-dns-enabled-yes"
1221
+ )
1222
+ cfg.dns.terraform_managed = (
1223
+ _selected_id("#adv-dns-tfmanaged")
1224
+ == "adv-dns-tfmanaged-yes"
1225
+ )
1226
+ cfg.dns.domain = self.query_one("#adv-dns-domain").value
1227
+ cfg.dns.zone_id = self.query_one("#adv-dns-zone-id").value
1228
+
1229
+ provider_map = {
1230
+ "adv-ssl-none": "none",
1231
+ "adv-ssl-letsencrypt": "letsencrypt",
1232
+ "adv-ssl-cloudflare": "cloudflare",
1233
+ "adv-ssl-acm": "acm",
1234
+ "adv-ssl-self_signed": "self_signed",
1235
+ }
1236
+ cfg.ssl.provider = provider_map.get(
1237
+ _selected_id("#adv-ssl-provider"), "none"
1238
+ )
1239
+ cfg.ssl.email = self.query_one("#adv-ssl-email").value
1240
+ cfg.ssl.certificate_arn = self.query_one(
1241
+ "#adv-ssl-acm-arn"
1242
+ ).value
1243
+
1244
+ cfg.eip.strategy = (
1245
+ "persistent"
1246
+ if _selected_id("#adv-eip-strategy")
1247
+ == "adv-eip-persistent"
1248
+ else "dynamic"
1249
+ )
1250
+
1251
+ @on(Button.Pressed, "#next")
1252
+ def _next(self) -> None:
1253
+ from lablink_cli.config.schema import validate_config
1254
+
1255
+ is_advanced = self.query_one("#dns-advanced").display
1256
+ if is_advanced:
1257
+ self._save_advanced()
1258
+ else:
1259
+ self._save_guided()
1260
+
1261
+ if is_advanced:
1262
+ errors = validate_config(self.app.config)
1263
+ if errors:
1264
+ err_label = self.query_one("#dns-validation-error")
1265
+ err_label.update("\n".join(errors))
1266
+ err_label.display = True
1267
+ return
1268
+
1269
+ self.app.push_screen(StartupScreen())
1270
+
1271
+
1272
+ # ---------------------------------------------------------------------------
1273
+ # Screen 5: Startup Script
1274
+ # ---------------------------------------------------------------------------
1275
+ STARTUP_TEMPLATE_PATH = (
1276
+ Path(__file__).resolve().parent.parent
1277
+ / "terraform"
1278
+ / "config"
1279
+ / "startup-template.sh"
1280
+ )
1281
+
1282
+
1283
+ class StartupScreen(Screen):
1284
+ """Configure custom startup script for client VMs."""
1285
+
1286
+ BINDINGS = [Binding("escape", "back", "Back")]
1287
+
1288
+ def compose(self) -> ComposeResult:
1289
+ cfg = self.app.config
1290
+
1291
+ yield Header()
1292
+ with VerticalScroll():
1293
+ yield Label(
1294
+ "Step 5: Client Startup Script",
1295
+ classes="step-title",
1296
+ )
1297
+ yield Label(
1298
+ "Optional script that runs inside each "
1299
+ "client VM container after launch.",
1300
+ classes="step-description",
1301
+ )
1302
+
1303
+ yield Label("Startup Script", classes="field-label")
1304
+ with RadioSet(id="startup-mode"):
1305
+ yield RadioButton(
1306
+ "None (no startup script)",
1307
+ value=not cfg.startup_script.enabled,
1308
+ )
1309
+ yield RadioButton(
1310
+ "Use template (edit below)",
1311
+ value=(
1312
+ cfg.startup_script.enabled
1313
+ and not self._has_custom_path()
1314
+ ),
1315
+ )
1316
+ yield RadioButton(
1317
+ "Use file from disk",
1318
+ value=(
1319
+ cfg.startup_script.enabled
1320
+ and self._has_custom_path()
1321
+ ),
1322
+ )
1323
+
1324
+ # Determine initial mode
1325
+ is_template = (
1326
+ cfg.startup_script.enabled
1327
+ and not self._has_custom_path()
1328
+ )
1329
+ is_file = (
1330
+ cfg.startup_script.enabled
1331
+ and self._has_custom_path()
1332
+ )
1333
+
1334
+ # Template editor
1335
+ template_content = self._load_template()
1336
+ yield TextArea(
1337
+ template_content,
1338
+ id="script-editor",
1339
+ language="bash",
1340
+ disabled=not is_template,
1341
+ )
1342
+
1343
+ # File path input
1344
+ yield Label(
1345
+ "Script file path",
1346
+ classes="field-label",
1347
+ id="path-label",
1348
+ )
1349
+ yield Input(
1350
+ value=(
1351
+ cfg.startup_script.path
1352
+ if self._has_custom_path()
1353
+ else ""
1354
+ ),
1355
+ placeholder="/path/to/startup.sh",
1356
+ id="script-path",
1357
+ disabled=not is_file,
1358
+ )
1359
+ yield Button(
1360
+ "Check path",
1361
+ id="check-path",
1362
+ disabled=not is_file,
1363
+ )
1364
+ yield Label(
1365
+ "",
1366
+ id="path-status",
1367
+ )
1368
+
1369
+ yield Label(
1370
+ "On error", classes="field-label"
1371
+ )
1372
+ with RadioSet(id="on-error"):
1373
+ yield RadioButton(
1374
+ "Continue (log and proceed)",
1375
+ value=(
1376
+ cfg.startup_script.on_error
1377
+ == "continue"
1378
+ ),
1379
+ )
1380
+ yield RadioButton(
1381
+ "Fail (stop VM setup)",
1382
+ value=(
1383
+ cfg.startup_script.on_error == "fail"
1384
+ ),
1385
+ )
1386
+
1387
+ yield Label(
1388
+ "Max attempts", classes="field-label"
1389
+ )
1390
+ yield Input(
1391
+ value=str(cfg.startup_script.max_attempts),
1392
+ type="integer",
1393
+ id="max-attempts",
1394
+ )
1395
+
1396
+ yield Label(
1397
+ "Base delay (seconds)", classes="field-label"
1398
+ )
1399
+ yield Input(
1400
+ value=str(cfg.startup_script.base_delay_seconds),
1401
+ type="integer",
1402
+ id="base-delay",
1403
+ )
1404
+
1405
+ yield Label(
1406
+ "Success check command (optional)",
1407
+ classes="field-label",
1408
+ )
1409
+ yield Input(
1410
+ value=cfg.startup_script.success_check,
1411
+ placeholder=(
1412
+ "e.g. /home/client/.local/bin/sleap --version"
1413
+ ),
1414
+ id="success-check",
1415
+ )
1416
+
1417
+ with Center():
1418
+ with Horizontal(classes="nav-buttons"):
1419
+ yield Button("Back", id="back")
1420
+ yield Button(
1421
+ "Next", variant="primary", id="next"
1422
+ )
1423
+ yield Footer()
1424
+
1425
+ def _has_custom_path(self) -> bool:
1426
+ cfg = self.app.config
1427
+ return (
1428
+ cfg.startup_script.enabled
1429
+ and cfg.startup_script.path
1430
+ and cfg.startup_script.path
1431
+ != "config/custom-startup.sh"
1432
+ )
1433
+
1434
+ def _load_template(self) -> str:
1435
+ # Load existing user script if available, otherwise bundled template
1436
+ existing_script = DEFAULT_CONFIG_DIR / "custom-startup.sh"
1437
+ if existing_script.exists():
1438
+ return existing_script.read_text()
1439
+ if STARTUP_TEMPLATE_PATH.exists():
1440
+ return STARTUP_TEMPLATE_PATH.read_text()
1441
+ return "#!/bin/bash\necho 'Custom startup script'\n"
1442
+
1443
+ @on(RadioSet.Changed, "#startup-mode")
1444
+ def _mode_changed(self, event: RadioSet.Changed) -> None:
1445
+ idx = event.index
1446
+ editor = self.query_one("#script-editor", TextArea)
1447
+ path_input = self.query_one("#script-path", Input)
1448
+
1449
+ check_btn = self.query_one("#check-path", Button)
1450
+ if idx == 0:
1451
+ # None
1452
+ editor.disabled = True
1453
+ path_input.disabled = True
1454
+ check_btn.disabled = True
1455
+ elif idx == 1:
1456
+ # Template
1457
+ editor.disabled = False
1458
+ path_input.disabled = True
1459
+ check_btn.disabled = True
1460
+ elif idx == 2:
1461
+ # File from disk
1462
+ editor.disabled = True
1463
+ path_input.disabled = False
1464
+ check_btn.disabled = False
1465
+
1466
+ @on(Button.Pressed, "#check-path")
1467
+ def _check_path(self) -> None:
1468
+ path_input = self.query_one("#script-path", Input)
1469
+ status = self.query_one("#path-status", Label)
1470
+ local_path = path_input.value.strip()
1471
+ if not local_path:
1472
+ status.update("No path entered.")
1473
+ return
1474
+ p = Path(local_path)
1475
+ if not p.exists():
1476
+ status.update(f"Not found: {local_path}")
1477
+ elif not p.is_file():
1478
+ status.update(f"Not a file: {local_path}")
1479
+ else:
1480
+ status.update(f"Found: {local_path}")
1481
+
1482
+ @on(Button.Pressed, "#back")
1483
+ def _back(self) -> None:
1484
+ self.app.pop_screen()
1485
+
1486
+ @on(Button.Pressed, "#next")
1487
+ def _next(self) -> None:
1488
+ cfg = self.app.config
1489
+ radio = self.query_one("#startup-mode", RadioSet)
1490
+ idx = radio.pressed_index
1491
+
1492
+ error_radio = self.query_one("#on-error", RadioSet)
1493
+ cfg.startup_script.on_error = (
1494
+ "fail"
1495
+ if error_radio.pressed_index == 1
1496
+ else "continue"
1497
+ )
1498
+
1499
+ max_attempts_value = self.query_one(
1500
+ "#max-attempts", Input
1501
+ ).value
1502
+ cfg.startup_script.max_attempts = (
1503
+ int(max_attempts_value) if max_attempts_value else 3
1504
+ )
1505
+ base_delay_value = self.query_one(
1506
+ "#base-delay", Input
1507
+ ).value
1508
+ cfg.startup_script.base_delay_seconds = (
1509
+ int(base_delay_value) if base_delay_value else 30
1510
+ )
1511
+ cfg.startup_script.success_check = self.query_one(
1512
+ "#success-check", Input
1513
+ ).value.strip()
1514
+
1515
+ if idx == 0:
1516
+ # Disabled
1517
+ cfg.startup_script.enabled = False
1518
+ cfg.startup_script.path = ""
1519
+ self.app._startup_script_content = None
1520
+ elif idx == 1:
1521
+ # Template — save editor content
1522
+ cfg.startup_script.enabled = True
1523
+ cfg.startup_script.path = (
1524
+ "config/custom-startup.sh"
1525
+ )
1526
+ editor = self.query_one(
1527
+ "#script-editor", TextArea
1528
+ )
1529
+ self.app._startup_script_content = editor.text
1530
+ elif idx == 2:
1531
+ # File from disk — read content, normalize path
1532
+ local_path = self.query_one(
1533
+ "#script-path", Input
1534
+ ).value.strip()
1535
+ try:
1536
+ self.app._startup_script_content = (
1537
+ Path(local_path).read_text()
1538
+ )
1539
+ cfg.startup_script.enabled = True
1540
+ cfg.startup_script.path = (
1541
+ "config/custom-startup.sh"
1542
+ )
1543
+ except (FileNotFoundError, OSError):
1544
+ cfg.startup_script.enabled = False
1545
+ self.app._startup_script_content = None
1546
+
1547
+ self.app.push_screen(MonitoringScreen())
1548
+
1549
+
1550
+ # ---------------------------------------------------------------------------
1551
+ # Screen 6: Session Metrics (Tier 1 Monitoring)
1552
+ # ---------------------------------------------------------------------------
1553
+ class MonitoringScreen(Screen):
1554
+ """Toggle Tier 1 session-metrics collection.
1555
+
1556
+ Single switch only: enabled / disabled. All other MonitoringConfig
1557
+ fields (process_allowlist, watch_dir, intervals) keep their dataclass
1558
+ defaults — operators who need to customize them still hand-edit
1559
+ lablink.yaml. This screen is SLEAP-specific and expected to be
1560
+ removed when monitoring is generalized or dropped.
1561
+ """
1562
+
1563
+ BINDINGS = [Binding("escape", "back", "Back")]
1564
+
1565
+ def compose(self) -> ComposeResult:
1566
+ cfg = self.app.config
1567
+
1568
+ yield Header()
1569
+ with VerticalScroll():
1570
+ yield Label(
1571
+ "Step 6: Session Metrics (optional)",
1572
+ classes="step-title",
1573
+ )
1574
+ yield Label(
1575
+ "Collect anonymous per-VM session metrics "
1576
+ "(Tier 1 monitoring). Currently SLEAP-tuned — leave "
1577
+ "disabled for non-SLEAP workloads.",
1578
+ classes="step-description",
1579
+ )
1580
+
1581
+ yield Label("Session metrics", classes="field-label")
1582
+ with RadioSet(id="monitoring-mode"):
1583
+ yield RadioButton(
1584
+ "Disabled (default)",
1585
+ value=not cfg.monitoring.enabled,
1586
+ )
1587
+ yield RadioButton(
1588
+ "Enabled",
1589
+ value=cfg.monitoring.enabled,
1590
+ )
1591
+
1592
+ with Center():
1593
+ with Horizontal(classes="nav-buttons"):
1594
+ yield Button("Back", id="back")
1595
+ yield Button("Next", variant="primary", id="next")
1596
+ yield Footer()
1597
+
1598
+ @on(Button.Pressed, "#back")
1599
+ def _back(self) -> None:
1600
+ self.app.pop_screen()
1601
+
1602
+ @on(Button.Pressed, "#next")
1603
+ def _next(self) -> None:
1604
+ cfg = self.app.config
1605
+ radio = self.query_one("#monitoring-mode", RadioSet)
1606
+ cfg.monitoring.enabled = radio.pressed_index == 1
1607
+ self.app.push_screen(ReviewScreen())
1608
+
1609
+
1610
+ # ---------------------------------------------------------------------------
1611
+ # Screen 7: Review & Save
1612
+ # ---------------------------------------------------------------------------
1613
+ class ReviewScreen(Screen):
1614
+ """Review configuration and save."""
1615
+
1616
+ BINDINGS = [Binding("escape", "back", "Back")]
1617
+
1618
+ def compose(self) -> ComposeResult:
1619
+ yield Header()
1620
+ with VerticalScroll():
1621
+ yield Label(
1622
+ "Step 7: Review & Save",
1623
+ classes="step-title",
1624
+ )
1625
+ yield TextArea(
1626
+ id="review-yaml",
1627
+ read_only=True,
1628
+ language="yaml",
1629
+ )
1630
+ yield Label(
1631
+ "", id="save-path-label",
1632
+ classes="step-description",
1633
+ )
1634
+ errors_label = Label("", id="errors", classes="error")
1635
+ errors_label.display = False
1636
+ yield errors_label
1637
+ with Center():
1638
+ with Horizontal(classes="nav-buttons"):
1639
+ yield Button("Back", id="back")
1640
+ yield Button(
1641
+ "Save & Exit",
1642
+ variant="success",
1643
+ id="save",
1644
+ )
1645
+ yield Footer()
1646
+
1647
+ def on_mount(self) -> None:
1648
+ import yaml
1649
+
1650
+ cfg_dict = config_to_dict(self.app.config)
1651
+ yaml_str = yaml.dump(
1652
+ cfg_dict, default_flow_style=False, sort_keys=False
1653
+ )
1654
+ self.query_one("#review-yaml", TextArea).text = yaml_str
1655
+
1656
+ self.query_one("#save-path-label", Label).update(
1657
+ f"Config will be saved to: {self.app.save_path}"
1658
+ )
1659
+
1660
+ errors = validate_config(self.app.config)
1661
+ if errors:
1662
+ label = self.query_one("#errors", Label)
1663
+ label.update("\n".join(f" * {e}" for e in errors))
1664
+ label.display = True
1665
+
1666
+ @on(Button.Pressed, "#back")
1667
+ def _back(self) -> None:
1668
+ self.app.pop_screen()
1669
+
1670
+ @on(Button.Pressed, "#save")
1671
+ def _save(self) -> None:
1672
+ errors = validate_config(self.app.config)
1673
+ if errors:
1674
+ return
1675
+ save_path = self.app.save_path
1676
+ save_config(self.app.config, save_path)
1677
+
1678
+ # Write startup script if provided
1679
+ content = getattr(
1680
+ self.app, "_startup_script_content", None
1681
+ )
1682
+ if content:
1683
+ script_path = (
1684
+ save_path.parent / "custom-startup.sh"
1685
+ )
1686
+ script_path.write_text(content)
1687
+ script_path.chmod(0o755)
1688
+
1689
+ self.app.exit(
1690
+ message=f"Config saved to {save_path}"
1691
+ )
1692
+
1693
+
1694
+ # ---------------------------------------------------------------------------
1695
+ # Main App
1696
+ # ---------------------------------------------------------------------------
1697
+ class ConfigWizard(App):
1698
+ """LabLink configuration wizard."""
1699
+
1700
+ TITLE = "LabLink Setup Wizard"
1701
+ CSS = """
1702
+ Screen {
1703
+ align: center middle;
1704
+ }
1705
+ /* Only the screen's own scroll viewport claims the available height.
1706
+ Must stay scoped to a direct child of Screen: Textual's RadioSet is
1707
+ itself a VerticalScroll subclass, and a bare `VerticalScroll` type
1708
+ selector matches subclasses — that overrode every RadioSet's
1709
+ `height: auto` with `1fr`, collapsing the radio boxes to 0 rows. */
1710
+ Screen > VerticalScroll {
1711
+ height: 1fr;
1712
+ }
1713
+ #dns-guided, #dns-advanced {
1714
+ height: auto;
1715
+ }
1716
+ .step-title {
1717
+ text-style: bold;
1718
+ color: $accent;
1719
+ margin: 1 2;
1720
+ text-align: center;
1721
+ width: 100%;
1722
+ }
1723
+ .step-description {
1724
+ color: $text-muted;
1725
+ margin: 0 2 1 2;
1726
+ text-align: center;
1727
+ width: 100%;
1728
+ }
1729
+ .field-label {
1730
+ margin: 1 2 0 2;
1731
+ text-style: bold;
1732
+ }
1733
+ Input {
1734
+ margin: 0 2;
1735
+ }
1736
+ OptionList {
1737
+ margin: 0 2;
1738
+ height: auto;
1739
+ max-height: 12;
1740
+ }
1741
+ RadioSet {
1742
+ margin: 0 2;
1743
+ /* Redundant with Textual's default, stated explicitly so a future
1744
+ container rule can't silently collapse the options again. */
1745
+ height: auto;
1746
+ }
1747
+ TextArea {
1748
+ margin: 0 2;
1749
+ /* Scale with the terminal instead of a hard 20 rows, which on a
1750
+ short terminal was taller than the whole form viewport and buried
1751
+ the fields below it. The percentage resolves against the scroll
1752
+ viewport; `1fr` would not work here — like RadioSet above it only
1753
+ gets the space fixed-size siblings leave over, which on the
1754
+ startup-script form is nothing. */
1755
+ height: 60%;
1756
+ min-height: 6;
1757
+ max-height: 24;
1758
+ }
1759
+ .nav-buttons {
1760
+ margin: 1 0;
1761
+ height: auto;
1762
+ }
1763
+ .nav-buttons Button {
1764
+ margin: 0 1;
1765
+ }
1766
+ #check-path {
1767
+ margin: 1 2;
1768
+ }
1769
+ #path-status {
1770
+ margin: 0 2;
1771
+ color: $text-muted;
1772
+ }
1773
+ .error {
1774
+ color: $error;
1775
+ margin: 1 2;
1776
+ }
1777
+ """
1778
+
1779
+ BINDINGS = [
1780
+ Binding("q", "quit", "Quit"),
1781
+ ]
1782
+
1783
+ def __init__(
1784
+ self,
1785
+ existing_config: Config | None = None,
1786
+ save_path: Path | None = None,
1787
+ ) -> None:
1788
+ super().__init__()
1789
+ self.config = existing_config if existing_config else Config()
1790
+ self.save_path = save_path or DEFAULT_CONFIG_PATH
1791
+ self._startup_script_content: str | None = None
1792
+
1793
+ def on_mount(self) -> None:
1794
+ self.push_screen(DeploymentScreen())