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.
- lablink_cli/__init__.py +8 -0
- lablink_cli/api.py +428 -0
- lablink_cli/app.py +938 -0
- lablink_cli/byo_detect.py +112 -0
- lablink_cli/commands/__init__.py +0 -0
- lablink_cli/commands/cleanup.py +647 -0
- lablink_cli/commands/deploy.py +863 -0
- lablink_cli/commands/deploy_compose.py +1203 -0
- lablink_cli/commands/doctor.py +549 -0
- lablink_cli/commands/export_metrics.py +244 -0
- lablink_cli/commands/launch.py +236 -0
- lablink_cli/commands/logs.py +434 -0
- lablink_cli/commands/register.py +839 -0
- lablink_cli/commands/reset_overlay.py +109 -0
- lablink_cli/commands/setup.py +347 -0
- lablink_cli/commands/stats.py +133 -0
- lablink_cli/commands/status.py +934 -0
- lablink_cli/commands/unregister.py +188 -0
- lablink_cli/commands/utils.py +552 -0
- lablink_cli/config/__init__.py +0 -0
- lablink_cli/config/schema.py +212 -0
- lablink_cli/deployment_metrics.py +94 -0
- lablink_cli/docker.py +419 -0
- lablink_cli/log_shipper.py +441 -0
- lablink_cli/templates/docker-compose.tailscale-override.yml +55 -0
- lablink_cli/templates/docker-compose.yml +67 -0
- lablink_cli/tofu_source.py +169 -0
- lablink_cli/tui/__init__.py +0 -0
- lablink_cli/tui/logs_viewer.py +413 -0
- lablink_cli/tui/wizard.py +1814 -0
- lablink_cli-0.1.0.dist-info/METADATA +76 -0
- lablink_cli-0.1.0.dist-info/RECORD +35 -0
- lablink_cli-0.1.0.dist-info/WHEEL +5 -0
- lablink_cli-0.1.0.dist-info/entry_points.txt +2 -0
- lablink_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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())
|