pixie-lab 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.
Files changed (70) hide show
  1. pixie/__init__.py +20 -0
  2. pixie/_util.py +38 -0
  3. pixie/catalog/__init__.py +25 -0
  4. pixie/catalog/_fetcher.py +513 -0
  5. pixie/catalog/_routes.py +392 -0
  6. pixie/catalog/_schema.py +263 -0
  7. pixie/catalog/_store.py +234 -0
  8. pixie/deploy/__init__.py +26 -0
  9. pixie/deploy/_main.py +321 -0
  10. pixie/deploy/_templates.py +153 -0
  11. pixie/disks.py +85 -0
  12. pixie/events/__init__.py +28 -0
  13. pixie/events/_kinds.py +203 -0
  14. pixie/events/_log.py +210 -0
  15. pixie/events/_routes.py +42 -0
  16. pixie/exports/__init__.py +17 -0
  17. pixie/exports/_routes.py +211 -0
  18. pixie/exports/_store.py +156 -0
  19. pixie/exports/_supervisor.py +240 -0
  20. pixie/flash.py +1971 -0
  21. pixie/images.py +473 -0
  22. pixie/machines/__init__.py +16 -0
  23. pixie/machines/_routes.py +190 -0
  24. pixie/machines/_store.py +623 -0
  25. pixie/oras.py +547 -0
  26. pixie/pivot/__init__.py +180 -0
  27. pixie/pivot/nbdboot +348 -0
  28. pixie/pxe/__init__.py +19 -0
  29. pixie/pxe/_renderer.py +244 -0
  30. pixie/pxe/_routes.py +386 -0
  31. pixie/tftp/__init__.py +21 -0
  32. pixie/tftp/_supervisor.py +129 -0
  33. pixie/tui/__init__.py +185 -0
  34. pixie/tui/_app.py +2219 -0
  35. pixie/tui_catalog.py +657 -0
  36. pixie/web/__init__.py +6 -0
  37. pixie/web/_auth.py +70 -0
  38. pixie/web/_settings_store.py +211 -0
  39. pixie/web/_static/.gitkeep +0 -0
  40. pixie/web/_static/bootstrap-icons.min.css +5 -0
  41. pixie/web/_static/bootstrap.min.css +12 -0
  42. pixie/web/_static/fonts/bootstrap-icons.woff +0 -0
  43. pixie/web/_static/fonts/bootstrap-icons.woff2 +0 -0
  44. pixie/web/_static/htmx.min.js +1 -0
  45. pixie/web/_static/pixie-favicon.png +0 -0
  46. pixie/web/_static/sse.js +290 -0
  47. pixie/web/_table_state.py +261 -0
  48. pixie/web/_templates/_partials/page_description.html +22 -0
  49. pixie/web/_templates/_partials/recent_events.html +47 -0
  50. pixie/web/_templates/_partials/table_helpers.html +189 -0
  51. pixie/web/_templates/catalog.html +364 -0
  52. pixie/web/_templates/catalog_detail.html +386 -0
  53. pixie/web/_templates/dashboard.html +256 -0
  54. pixie/web/_templates/events.html +125 -0
  55. pixie/web/_templates/ipxe/bootstrap.j2 +12 -0
  56. pixie/web/_templates/ipxe/exit.j2 +10 -0
  57. pixie/web/_templates/ipxe/nbdboot.j2 +57 -0
  58. pixie/web/_templates/ipxe/pixie-live-env.j2 +55 -0
  59. pixie/web/_templates/ipxe/unavailable.j2 +14 -0
  60. pixie/web/_templates/layout.html +345 -0
  61. pixie/web/_templates/login.html +40 -0
  62. pixie/web/_templates/machine_detail.html +786 -0
  63. pixie/web/_templates/machines.html +186 -0
  64. pixie/web/_templates/settings.html +265 -0
  65. pixie/web/main.py +1910 -0
  66. pixie_lab-0.1.0.dist-info/METADATA +107 -0
  67. pixie_lab-0.1.0.dist-info/RECORD +70 -0
  68. pixie_lab-0.1.0.dist-info/WHEEL +4 -0
  69. pixie_lab-0.1.0.dist-info/entry_points.txt +3 -0
  70. pixie_lab-0.1.0.dist-info/licenses/LICENSE +674 -0
pixie/tui/_app.py ADDED
@@ -0,0 +1,2219 @@
1
+ """pixie.tui - terminal UI for image inspection and flashing.
2
+
3
+ Rich-based. No Textual. No event loop. No alt-screen.
4
+
5
+ Each "screen" is a sequence of Rich-rendered Panels followed by a
6
+ ``Prompt.ask`` input. Boring, stable, fast: the screen draws once
7
+ (~30-100ms on a kernel framebuffer console), the operator types a
8
+ choice, the next screen draws. No reactive properties, no compose
9
+ tree, no CSS cascading, no DataTable layout passes.
10
+
11
+ The wizard flow is a plain Python ``while True`` loop dispatching
12
+ on the current ``_WizardStage``. Esc-back-nav is the literal
13
+ ``b`` / ``back`` token returned from the prompt; Enter-to-advance
14
+ is the number the operator types.
15
+
16
+ Performance design notes:
17
+
18
+ * Rich prints are synchronous one-shot writes -- no per-frame
19
+ diffing, no allocation of intermediate render trees.
20
+ * The only Live-update region is the flash-progress bar, and
21
+ even that is bounded: one update per second from the
22
+ ``FlashProgress`` callback, no more.
23
+ * Lists are static tables rendered once. Filter? The operator
24
+ reads the list; on framebuffer console with <30 entries the
25
+ cost of "live filter" isn't worth its complexity.
26
+ * No modal overlays: "confirm before flash" is a panel printed
27
+ inline, followed by a y/N prompt. Visually identical to the
28
+ operator (one focused question at a time); zero alt-screen
29
+ overhead.
30
+
31
+ Catalog sources (same as the old Textual UI):
32
+
33
+ * Local image-root (always scanned).
34
+ * Optional ``--catalog SOURCE`` overlay (local TOML, http(s),
35
+ or oras://).
36
+
37
+ PXE-interactive use: ``--catalog http://pixie:8080/catalog.toml``
38
+ plus ``--mac <MAC>`` so the TUI POSTs ``/pxe/<mac>/status`` after a
39
+ flash (derived from the catalog URL's scheme+host).
40
+
41
+ Public surface preserved from the prior Textual implementation:
42
+
43
+ * ``BtyTui`` class with ``run()``.
44
+ * ``_TuiImage`` dataclass (catalog row shape).
45
+ * ``load_catalog_from_source(...)``,
46
+ ``post_pxe_status(...)``, ``post_inventory(...)`` helpers.
47
+ * ``_format_mib``, ``_parse_size_to_bytes`` formatters.
48
+ * ``_WizardStage`` enum.
49
+
50
+ This module no longer imports textual; pure-rich + stdlib.
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import contextlib
56
+ import json as _json
57
+ import os
58
+ import subprocess
59
+ import sys
60
+ import threading
61
+ import urllib.error
62
+ import urllib.parse
63
+ import urllib.request
64
+ from dataclasses import dataclass, field
65
+ from enum import IntEnum
66
+ from pathlib import Path
67
+ from typing import Any
68
+
69
+ from rich.console import Console
70
+ from rich.panel import Panel
71
+ from rich.progress import (
72
+ BarColumn,
73
+ Progress,
74
+ TaskID,
75
+ TaskProgressColumn,
76
+ TextColumn,
77
+ TimeElapsedColumn,
78
+ TimeRemainingColumn,
79
+ TransferSpeedColumn,
80
+ )
81
+ from rich.prompt import Prompt
82
+ from rich.table import Table
83
+
84
+ import pixie
85
+ from pixie import disks, flash, images
86
+ from pixie import tui_catalog as _catalog
87
+ from pixie.tui import DEFAULT_SERVER as _DEFAULT_SERVER
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Public-API helpers (preserved across the textual -> rich rewrite so
91
+ # external callers + the test suite's model layer don't have to change).
92
+ # ---------------------------------------------------------------------------
93
+
94
+
95
+ class _WizardStage(IntEnum):
96
+ """The five wizard stages, derived from selection state.
97
+
98
+ Forward advance: an operator commit (Enter on an image / disk
99
+ / confirm) sets the corresponding state field, which flips the
100
+ derived stage. Esc / ``b`` back-nav clears the most-recent
101
+ commit, dropping the stage by one.
102
+
103
+ ``SELECT_CATALOG`` is auto-skipped on startup when either a
104
+ ``--catalog`` URL was passed OR the local image-root already
105
+ contains images -- the operator has data to work with, no
106
+ catalog selection needed up front. Back-nav from ``SELECT_IMAGE``
107
+ re-enters ``SELECT_CATALOG`` so the operator can switch source
108
+ mid-session (e.g. plug-in to a USB stick but then realize they
109
+ want the remote release catalog).
110
+ """
111
+
112
+ SELECT_CATALOG = 1
113
+ SELECT_IMAGE = 2
114
+ SELECT_DISK = 3
115
+ CONFIRM_FLASH = 4
116
+ REBOOT_OR_DONE = 5
117
+
118
+
119
+ @dataclass
120
+ class _TuiImage:
121
+ """Unified catalog row.
122
+
123
+ Either ``path`` (local file) or ``url`` (remote / oras / catalog
124
+ pointer) is populated. The rest of the TUI consumes this
125
+ shape uniformly so local + remote sources blend into one list.
126
+ """
127
+
128
+ name: str
129
+ fmt: str | None
130
+ size_bytes: int
131
+ path: Path | None = None
132
+ url: str | None = None
133
+ # Declared content sha256 (bare hex) for a URL source, when the
134
+ # catalog or PXE plan committed to one. Threaded into the flash so
135
+ # the bytes are verified on the wire. ``None`` for local files and
136
+ # sources with no declared digest.
137
+ sha: str | None = None
138
+ # Informational architecture hint (``x86_64`` / ``arm64`` / ...).
139
+ # Shown as a column in the image table; never restricts flash
140
+ # eligibility -- pixie writes whatever bytes the operator picks.
141
+ arch: str | None = None
142
+
143
+
144
+ def _normalise_server_url(server: str) -> str:
145
+ """Turn a bare hostname or full URL into a normalised base URL.
146
+
147
+ Operator convenience: ``--server pixie`` should work as
148
+ well as ``--server http://pixie:8080``. When no scheme is
149
+ present we default to ``http://``. Trailing slashes are stripped
150
+ so concatenations like ``f"{server}/pxe/{mac}/plan"`` produce
151
+ a clean path.
152
+ """
153
+ server = server.strip().rstrip("/")
154
+ if "://" not in server:
155
+ server = f"http://{server}"
156
+ return server
157
+
158
+
159
+ def _basename_from_url(url: str) -> str | None:
160
+ """Last path segment of ``url``, url-decoded, or ``None`` if the
161
+ URL has no useful filename component.
162
+
163
+ Used by the auto-flash path to derive a display name for the
164
+ image being flashed. The URL may have a query string and
165
+ arbitrary percent-encoded characters; we want the human-
166
+ readable last segment.
167
+ """
168
+ try:
169
+ parsed = urllib.parse.urlparse(url)
170
+ except ValueError:
171
+ return None
172
+ name = Path(parsed.path).name
173
+ if not name:
174
+ return None
175
+ return urllib.parse.unquote(name)
176
+
177
+
178
+ def _format_mib(size_bytes: int | None) -> str:
179
+ """Format a byte count as comma-grouped MiB.
180
+
181
+ Negative / None render as ``?`` so a probe that couldn't
182
+ determine a virtual size (e.g. a streamed raw URL whose
183
+ Content-Length the server didn't advertise) shows a clean
184
+ placeholder rather than crashing the prompt.
185
+ """
186
+ if size_bytes is None or size_bytes < 0:
187
+ return "?"
188
+ return f"{size_bytes / (1 << 20):,.1f} MiB"
189
+
190
+
191
+ _SIZE_SUFFIX_MULTIPLIERS = {
192
+ "K": 1 << 10,
193
+ "M": 1 << 20,
194
+ "G": 1 << 30,
195
+ "T": 1 << 40,
196
+ "P": 1 << 50,
197
+ }
198
+
199
+
200
+ def _parse_size_to_bytes(s: str) -> int:
201
+ """Parse an lsblk-style human-readable size (``500G``, ``1.5T``)
202
+ to bytes. Empty / unrecognised input returns 0 (caller can
203
+ render as ``?``).
204
+ """
205
+ s = s.strip().upper()
206
+ if not s:
207
+ return 0
208
+ if s[-1] in _SIZE_SUFFIX_MULTIPLIERS:
209
+ try:
210
+ n = float(s[:-1])
211
+ except ValueError:
212
+ return 0
213
+ return int(n * _SIZE_SUFFIX_MULTIPLIERS[s[-1]])
214
+ try:
215
+ return int(s)
216
+ except ValueError:
217
+ return 0
218
+
219
+
220
+ def load_catalog_from_source(source: str, *, timeout: float = 30.0) -> list[_TuiImage]:
221
+ """Load catalog rows from a path / URL into the TUI shape.
222
+
223
+ Thin projection over :func:`pixie.catalog.load_source`. Same
224
+ accepted sources as before: local TOML path, http(s):// URL,
225
+ oras:// reference.
226
+ """
227
+ parsed_catalog = _catalog.load_source(source, timeout=timeout)
228
+ return [
229
+ _TuiImage(
230
+ name=entry.name,
231
+ fmt=entry.format,
232
+ size_bytes=entry.size_bytes or 0,
233
+ url=entry.src,
234
+ sha=entry.sha256,
235
+ arch=entry.arch,
236
+ )
237
+ for entry in parsed_catalog.entries
238
+ ]
239
+
240
+
241
+ def post_pxe_status(
242
+ pxe_done_base: str, mac: str, status: str, reason: str = "", *, timeout: float = 10.0
243
+ ) -> None:
244
+ """POST ``<base>/pxe/{mac}/status`` with ``{"status": ..., "reason": ...}``
245
+ -- the live env's terminal flash signal.
246
+
247
+ ``status`` is ``"done"`` or ``"failed"``; ``reason`` (optional, capped)
248
+ rides only with a failure so the pixie can show it on the machine's
249
+ timeline instead of the box sitting at "awaiting flash" forever. Silent
250
+ on success; raises ``urllib.error.URLError`` on transport failure (callers
251
+ decide whether to surface, the failure paths wrap in
252
+ ``contextlib.suppress``).
253
+ """
254
+ base = pxe_done_base.rstrip("/")
255
+ payload: dict[str, str] = {"status": status}
256
+ reason = reason.strip()
257
+ if reason:
258
+ payload["reason"] = reason[:500]
259
+ data = _json.dumps(payload).encode("utf-8")
260
+ req = urllib.request.Request(
261
+ f"{base}/pxe/{mac}/status",
262
+ data=data,
263
+ method="POST",
264
+ headers={"Content-Type": "application/json"},
265
+ )
266
+ with urllib.request.urlopen(req, timeout=timeout):
267
+ pass
268
+
269
+
270
+ def post_inventory(
271
+ pxe_done_base: str,
272
+ mac: str,
273
+ disks_payload: list[dict[str, object]],
274
+ *,
275
+ lshw: object | None = None,
276
+ timeout: float = 10.0,
277
+ ) -> None:
278
+ """POST ``<pxe_done_base>/pxe/{mac}/inventory`` with the live env's
279
+ local disk inventory, plus the optional full ``lshw -json`` tree.
280
+
281
+ ``lshw`` (when supplied) is the parsed JSON from ``lshw -json``;
282
+ pixie stores it as a supplementary hardware blob. The flasher
283
+ never consumes it -- ``disks_payload`` (from lsblk) is the contract.
284
+ """
285
+ base = pxe_done_base.rstrip("/")
286
+ payload: dict[str, object] = {"disks": disks_payload}
287
+ if lshw is not None:
288
+ payload["lshw"] = lshw
289
+ body = _json.dumps(payload).encode("utf-8")
290
+ req = urllib.request.Request(
291
+ f"{base}/pxe/{mac}/inventory",
292
+ data=body,
293
+ method="POST",
294
+ headers={"Content-Type": "application/json"},
295
+ )
296
+ with urllib.request.urlopen(req, timeout=timeout):
297
+ pass
298
+
299
+
300
+ def collect_lshw(*, timeout: float = 30.0) -> object | None:
301
+ """Best-effort full hardware inventory via ``lshw -json``.
302
+
303
+ Returns the parsed JSON (lshw emits an object, or a list on some
304
+ versions), or ``None`` if lshw is missing, errors, times out, or
305
+ emits unparseable output. Bounded so a slow probe can't wedge the
306
+ inventory post; lshw needs root, which the live env has.
307
+ """
308
+ try:
309
+ proc = subprocess.run(
310
+ ["lshw", "-json"],
311
+ capture_output=True,
312
+ text=True,
313
+ check=False,
314
+ timeout=timeout,
315
+ )
316
+ except (FileNotFoundError, subprocess.SubprocessError, OSError):
317
+ return None
318
+ if proc.returncode != 0 or not proc.stdout.strip():
319
+ return None
320
+ try:
321
+ parsed: object = _json.loads(proc.stdout)
322
+ except (ValueError, TypeError):
323
+ return None
324
+ return parsed
325
+
326
+
327
+ # v0.46 stopped publishing a pixie-side catalog.toml mirror and pointed
328
+ # pixie at the upstream image-builder (``safl/nosi``); the wizard's
329
+ # ``[d] default`` shortcut tracks the same source so both consumers
330
+ # resolve to one catalog. The pixie release no longer ships a
331
+ # catalog.toml asset, so the old URL would 404.
332
+ _BTY_DEFAULT_CATALOG_URL = "https://github.com/safl/nosi/releases/latest/download/catalog.toml"
333
+
334
+
335
+ # ---------------------------------------------------------------------------
336
+ # Rendering style: blue/gray dominant, with a sparing dash of
337
+ # muted yellow. Pure-text fallback works on the framebuffer
338
+ # console too; the colour bytes are ANSI escapes the kernel
339
+ # terminal driver understands. Names are kept in the 16-colour
340
+ # set so the look is identical across SSH, serial, and the live
341
+ # env's framebuffer tty1 (where any 256-colour mapping collapses
342
+ # to its nearest neighbour and the design intent gets lost).
343
+ # ---------------------------------------------------------------------------
344
+
345
+ _PRIMARY = "bright_blue" # dominant -- headers, table titles, primary columns.
346
+ # ``bright_blue`` (16-colour canonical, ANSI code 94 fg / xterm
347
+ # default #5555ff) was chosen over plain ``blue`` (#0000aa) so the
348
+ # non-bold instances (column data: path / format / size / etc.)
349
+ # stay readable against the framebuffer console's black background
350
+ # -- plain blue rendered too dark for non-bold body text. Bold
351
+ # headers still pop because of the weight.
352
+ _MUTED = "bright_black" # secondary -- byline columns, parenthesised hints. Canonical
353
+ # 16-colour ANSI gray with no 256-colour tint (grey62 read as
354
+ # teal-ish on dark dev terminals); renders identically across SSH,
355
+ # serial, and the live env's framebuffer.
356
+ _ACCENT = "yellow" # the dash: row indices + prompts + stage breadcrumb only
357
+ _DANGER = "red"
358
+ _OK = "green"
359
+ # Very dark grey for subtle zebra striping. On 256-colour terminals
360
+ # (SSH, dev consoles) renders as a faint band; on the live env's
361
+ # 16-colour framebuffer it down-converts to black and disappears,
362
+ # which is the desired behaviour -- the stripe is a nicety on
363
+ # capable terminals, not a feature anyone should depend on.
364
+ _STRIPE = "grey11"
365
+
366
+
367
+ # ---------------------------------------------------------------------------
368
+ # Wizard state. Tiny dataclass; no reactive properties, just fields.
369
+ # ---------------------------------------------------------------------------
370
+
371
+
372
+ @dataclass
373
+ class _State:
374
+ image_root: Path
375
+ catalog_source: str | None = None
376
+ mac: str | None = None
377
+ pxe_done_base: str | None = None
378
+
379
+ selected_image: _TuiImage | None = None
380
+ selected_disk: dict[str, Any] | None = None
381
+ post_flash: bool = False
382
+
383
+ # ``True`` iff the operator has either explicitly chosen a
384
+ # catalog source via the SELECT_CATALOG screen (``[d]`` /
385
+ # ``[c]`` / ``[l] local-only``) OR the wizard auto-skipped that
386
+ # step at startup because ``--catalog`` was set OR the local
387
+ # ``image_root`` had images at startup. Back-nav from
388
+ # SELECT_IMAGE clears this flag so the operator can re-enter
389
+ # SELECT_CATALOG mid-session.
390
+ catalog_chosen: bool = False
391
+
392
+ # Cached lists; refreshed on demand.
393
+ _images: list[_TuiImage] = field(default_factory=list)
394
+ _disks: list[dict[str, Any]] = field(default_factory=list)
395
+
396
+ def stage(self) -> _WizardStage:
397
+ if self.post_flash:
398
+ return _WizardStage.REBOOT_OR_DONE
399
+ if not self.catalog_chosen:
400
+ return _WizardStage.SELECT_CATALOG
401
+ if self.selected_image is None:
402
+ return _WizardStage.SELECT_IMAGE
403
+ if self.selected_disk is None:
404
+ return _WizardStage.SELECT_DISK
405
+ return _WizardStage.CONFIRM_FLASH
406
+
407
+ def back(self) -> None:
408
+ """Clear the most-recent commit. Esc / ``b`` from a prompt
409
+ calls this. SELECT_CATALOG (top stage) -> no-op.
410
+ """
411
+ if self.post_flash:
412
+ self.post_flash = False
413
+ self.selected_disk = None
414
+ return
415
+ if self.selected_disk is not None:
416
+ self.selected_disk = None
417
+ return
418
+ if self.selected_image is not None:
419
+ self.selected_image = None
420
+ return
421
+ if self.catalog_chosen:
422
+ # Back from SELECT_IMAGE -> SELECT_CATALOG. Clear the
423
+ # chosen flag so stage() returns SELECT_CATALOG;
424
+ # ``catalog_source`` itself stays so the catalog screen
425
+ # shows the previous choice as the implicit default.
426
+ self.catalog_chosen = False
427
+ return
428
+ # SELECT_CATALOG: no-op (top of wizard).
429
+
430
+
431
+ # ---------------------------------------------------------------------------
432
+ # Local-side enumeration: images + disks. Pure functions that build
433
+ # the TUI shape. The catalog overlay is loaded separately via
434
+ # ``load_catalog_from_source``.
435
+ # ---------------------------------------------------------------------------
436
+
437
+
438
+ def _list_local_images(image_root: Path) -> list[_TuiImage]:
439
+ """Local image-root scan -> TUI rows. Each file with a recognised
440
+ image extension (``.qcow2`` / ``.img`` / ``.img.{gz,zst,xz,bz2}`` /
441
+ ``.iso`` / ``.iso.gz``) surfaces as a local row (path-bearing).
442
+ Catalog entries (the pixie's remote images, oras://, http://)
443
+ overlay on top via ``--catalog URL`` -- they are NOT discovered on
444
+ the local filesystem; the PIXIE_IMAGES partition is plain local
445
+ image files only.
446
+ """
447
+ if not image_root.exists() or not image_root.is_dir():
448
+ return []
449
+ return [
450
+ _TuiImage(
451
+ name=img.name,
452
+ fmt=img.format,
453
+ size_bytes=img.size_bytes or 0,
454
+ path=img.path,
455
+ arch=img.arch,
456
+ )
457
+ for img in images.list_images(image_root)
458
+ ]
459
+
460
+
461
+ def _list_disks() -> list[dict[str, Any]]:
462
+ """Disk inventory via ``disks.list_disks``. Filters down to
463
+ flash-eligible candidates: must be a block device, not
464
+ read-only, not a loop / ram device.
465
+ """
466
+ try:
467
+ all_disks = disks.list_disks()
468
+ except (FileNotFoundError, subprocess.SubprocessError, OSError):
469
+ return []
470
+ return [d for d in all_disks if d.get("type") == "disk" and not d.get("ro")]
471
+
472
+
473
+ def _uefi_boot_registration_enabled() -> bool:
474
+ """Whether to register a UEFI NVRAM boot entry after a flash.
475
+
476
+ OFF by default. Most firmware boots a freshly-flashed disk on its
477
+ own, and writing NVRAM on every flash proved risky on server boards
478
+ -- it once reordered an EPYC box's BootOrder so it stopped
479
+ netbooting. Opt in by setting ``PIXIE_REGISTER_UEFI_BOOT`` to a truthy
480
+ value (``1`` / ``true`` / ``yes`` / ``on``) only for firmware that
481
+ won't boot the disk without an explicit entry.
482
+ """
483
+ return os.environ.get("PIXIE_REGISTER_UEFI_BOOT", "").strip().lower() in (
484
+ "1",
485
+ "true",
486
+ "yes",
487
+ "on",
488
+ )
489
+
490
+
491
+ # ---------------------------------------------------------------------------
492
+ # The TUI itself. Sequential-screen wizard driven by a plain loop.
493
+ # ---------------------------------------------------------------------------
494
+
495
+
496
+ class BtyTui:
497
+ """The pixie terminal UI -- Rich-based, no event loop.
498
+
499
+ ``run()`` is the entry point. The wizard advances through five
500
+ stages (SELECT_CATALOG, SELECT_IMAGE, SELECT_DISK, CONFIRM_FLASH,
501
+ REBOOT_OR_DONE) until the operator quits. ``SELECT_CATALOG`` is
502
+ auto-skipped at launch when ``--catalog`` was passed or the local
503
+ image-root already has images; see :class:`_WizardStage` for the
504
+ derivation rules.
505
+
506
+ Each screen is a method that renders + prompts + returns a
507
+ string token. The dispatcher uses the token to advance, back,
508
+ quit, refresh, or switch catalog source.
509
+ """
510
+
511
+ def __init__(
512
+ self,
513
+ server: str = _DEFAULT_SERVER,
514
+ mac: str | None = None,
515
+ catalog: str | None = None,
516
+ ) -> None:
517
+ """Two-mode TUI:
518
+
519
+ * ``mac`` unset -> interactive wizard, local image-root only.
520
+ PIXIE_IMAGE_ROOT env var still picks the root. ``catalog``,
521
+ if given, pre-fills the catalog source and skips the
522
+ SELECT_CATALOG screen (equivalent to picking ``[c]`` and
523
+ typing the URL).
524
+ * ``mac`` set -> server-driven mode. ``run()`` GETs
525
+ ``<server>/pxe/<mac>/plan`` and dispatches:
526
+ - ``plan.mode == "flash"`` -> ``_run_auto`` (no prompts)
527
+ - ``plan.mode == "interactive"`` -> interactive wizard with
528
+ the catalog the server suggests
529
+ - ``plan.mode == "inventory"`` -> post disk inventory, then
530
+ reboot (boot_mode=pixie-inventory; next contact serves the ipxe-exit chain)
531
+ - ``plan.mode == "exit"`` -> exit cleanly (nothing to do
532
+ from pixie's side; the firmware falls through to its
533
+ native boot order and picks the next bootable device)
534
+ - 404 / network failure -> interactive wizard with
535
+ server's ``/catalog.toml`` as the catalog source
536
+ ``catalog`` is ignored in server-driven mode -- the server's
537
+ plan response carries the catalog the operator should see.
538
+ """
539
+ self._console = Console(highlight=False)
540
+ # Single source of truth for the PIXIE_IMAGE_ROOT -> default
541
+ # resolution; mirrors what pixie uses rather than re-hardcoding
542
+ # the default path here.
543
+ resolved_root = images.default_image_root()
544
+ # ``server`` is the pixie URL or hostname (bare hostnames
545
+ # get an ``http://`` scheme defaulted in below). Stored on the
546
+ # state so the header Panel can show it; also the base for
547
+ # ``/pxe/<mac>/done`` POST after a successful flash.
548
+ self._server_url = _normalise_server_url(server)
549
+ pxe_done = self._server_url if mac else None
550
+ # Three startup paths:
551
+ # 1. ``mac`` set -> server-driven mode. catalog_chosen=True so
552
+ # the interactive fallback lands on SELECT_IMAGE directly
553
+ # with the server's catalog pre-loaded. The --catalog flag
554
+ # is ignored here; the server's plan dictates source.
555
+ # 2. ``catalog`` set (no mac) -> hand-driven interactive run
556
+ # with a known catalog. Skip SELECT_CATALOG; behave as if
557
+ # the operator typed ``[c]`` + the URL.
558
+ # 3. Neither set -> auto-skip SELECT_CATALOG only when the
559
+ # local image-root already has images; otherwise prompt.
560
+ if mac:
561
+ catalog_chosen = True
562
+ catalog_source: str | None = self._server_url.rstrip("/") + "/catalog.toml"
563
+ elif catalog:
564
+ catalog_chosen = True
565
+ catalog_source = catalog
566
+ else:
567
+ catalog_source = None
568
+ catalog_chosen = bool(_list_local_images(resolved_root))
569
+ self._state = _State(
570
+ image_root=resolved_root,
571
+ catalog_source=catalog_source,
572
+ mac=mac,
573
+ pxe_done_base=pxe_done,
574
+ catalog_chosen=catalog_chosen,
575
+ )
576
+ # Catalog load errors (transient network / bad TOML) -- surface
577
+ # via a soft banner on the image-pick screen rather than
578
+ # aborting the TUI.
579
+ self._catalog_load_error: str | None = None
580
+ # ``_auto`` is flipped True later by ``run()`` if the
581
+ # server's plan says ``mode=flash``. Without a mac, stays False.
582
+ self._auto = False
583
+ self._auto_image: str | None = None
584
+ # Declared content sha256 from the plan (``disk_image_sha``), so
585
+ # the auto-flash verifies even when the image URL is a withcache
586
+ # or direct origin that doesn't embed the digest in its path.
587
+ self._auto_image_sha: str | None = None
588
+ self._auto_target_disk_serial: str | None = None
589
+ # Image format from the plan. The image URL's name segment can be
590
+ # a descriptive title (oras) with no extension, so format can't be
591
+ # detected from the URL -- the server passes it explicitly.
592
+ self._auto_format: str | None = None
593
+ # Descriptive image name from the plan (the catalog title). The
594
+ # image URL's basename can be a synthesised "image.<fmt>", so use
595
+ # this for the flash-screen display instead.
596
+ self._auto_name: str | None = None
597
+
598
+ # ---------- entry --------------------------------------------------
599
+
600
+ def run(self) -> None:
601
+ """Drive the wizard until the operator quits, OR run the
602
+ server-driven path when a ``mac`` was supplied.
603
+ """
604
+ # Best-effort inventory post at startup. Network failures are
605
+ # non-fatal; the TUI is still useful even if pixie can't be
606
+ # reached.
607
+ if self._state.pxe_done_base and self._state.mac:
608
+ self._auto_post_inventory()
609
+
610
+ # Server-driven mode: fetch the plan from
611
+ # <server>/pxe/<mac>/plan and dispatch.
612
+ #
613
+ # For every plan we print a banner BEFORE the dispatch so the
614
+ # operator (or anyone watching the live env's tty1) sees what
615
+ # the server said and what ``pixie`` is about to do. A silent
616
+ # exit / silent wizard launch is indistinguishable from a
617
+ # crash on a framebuffer console.
618
+ if self._state.mac:
619
+ plan_action = self._fetch_and_dispatch_plan()
620
+ if plan_action == "flash":
621
+ self._console.print(
622
+ Panel(
623
+ f"Server reports [{_ACCENT}]mode=flash[/] for "
624
+ f"[{_PRIMARY}]{self._state.mac}[/]:\n\n"
625
+ f" image : {self._auto_image}\n"
626
+ f" serial : {self._auto_target_disk_serial}\n\n"
627
+ f"[{_MUTED}]Running the flash without prompts; "
628
+ "the same chrome the interactive wizard uses.[/]",
629
+ title="Plan: flash",
630
+ border_style=_PRIMARY,
631
+ )
632
+ )
633
+ rc = self._run_auto()
634
+ if rc == 0:
635
+ sys.exit(0)
636
+ # Deterministic auto-flash failure (target disk not
637
+ # found / plan rejected / flash error). Do NOT sys.exit
638
+ # non-zero: pixie-on-tty1.service has Restart=on-failure,
639
+ # so that relaunches pixie every few seconds in a tight
640
+ # reject -> exit -> relaunch loop (the operator just sees
641
+ # the same rejection flash by). Fall back to the wizard
642
+ # instead -- the box stays up, the operator sees the
643
+ # rejection reasons printed above and can pick by hand or
644
+ # fix the assignment on the pixie.
645
+ self._auto = False
646
+ self._catalog_load_error = (
647
+ "Auto-flash could not proceed (see the panel above). "
648
+ "Pick an image and target disk by hand, or fix the "
649
+ "assignment in the pixie UI (/ui/machines)."
650
+ )
651
+ if plan_action == "exit":
652
+ # The server says nothing to do here (boot_mode=ipxe-exit
653
+ # or an unrecognised policy). Print a Panel so an operator
654
+ # hand-running ``pixie --mac X`` from a workstation sees WHY
655
+ # the tool is exiting -- a silent ``sys.exit(0)`` looks
656
+ # like a crash. The live env never reaches this path:
657
+ # ipxe-exit short-circuits at the iPXE chain (boots the
658
+ # local disk directly, no live-env chain).
659
+ self._console.print(
660
+ Panel(
661
+ f"Server reports [{_ACCENT}]mode=exit[/] for "
662
+ f"[{_PRIMARY}]{self._state.mac}[/] -- nothing for "
663
+ "pixie to do here.\n\n"
664
+ f"[{_MUTED}]The firmware / local disk boots directly "
665
+ "(ipxe-exit or already provisioned); no flash, no wizard.[/]",
666
+ title="Plan: nothing to do",
667
+ border_style=_PRIMARY,
668
+ )
669
+ )
670
+ sys.exit(0)
671
+ if plan_action == "inventory":
672
+ # boot_mode=pixie-inventory: the box booted the live env
673
+ # only to (re)report its disks. Post inventory
674
+ # synchronously so it lands, then reboot -- the next PXE
675
+ # contact (saw_flasher_boot armed by the /boot fetch)
676
+ # serves the ipxe-exit chain to boot the local disk.
677
+ # No wizard, no flash.
678
+ self._console.print(
679
+ Panel(
680
+ f"Server reports [{_ACCENT}]mode=inventory[/] for "
681
+ f"[{_PRIMARY}]{self._state.mac}[/].\n\n"
682
+ f"[{_MUTED}]Reporting this box's disks to pixie, then "
683
+ "rebooting to boot the local disk.[/]",
684
+ title="Plan: inventory + reboot",
685
+ border_style=_PRIMARY,
686
+ )
687
+ )
688
+ self._post_inventory_sync()
689
+ self._do_reboot() # exits via systemctl reboot
690
+ sys.exit(0)
691
+ # ``interactive`` falls through to the wizard below. Print a
692
+ # banner first so the operator knows the server delegated
693
+ # the pick to them (vs. having auto-flashed). The auto-flash
694
+ # FAILURE fall-through reaches the wizard too, but it already
695
+ # printed its own rejection panel, so skip this banner there.
696
+ if plan_action == "interactive":
697
+ self._console.print(
698
+ Panel(
699
+ f"Server reports [{_ACCENT}]mode=interactive[/] for "
700
+ f"[{_PRIMARY}]{self._state.mac}[/]:\n\n"
701
+ f" catalog : {self._state.catalog_source or '(none)'}\n\n"
702
+ f"[{_MUTED}]Dropping into the wizard so you can pick "
703
+ "the image + target disk by hand.[/]",
704
+ title="Plan: interactive",
705
+ border_style=_PRIMARY,
706
+ )
707
+ )
708
+
709
+ try:
710
+ self._main_loop()
711
+ except KeyboardInterrupt:
712
+ self._console.print()
713
+ self._console.print(
714
+ f"[{_MUTED}]Interrupted -- exiting.[/]",
715
+ )
716
+ except SystemExit:
717
+ raise
718
+ except Exception: # pragma: no cover - last-resort safety net
719
+ self._console.print_exception(show_locals=False)
720
+ sys.exit(1)
721
+
722
+ def _fetch_and_dispatch_plan(self) -> str:
723
+ """GET ``<server>/pxe/<mac>/plan`` and prep the wizard for
724
+ dispatch. Returns one of:
725
+
726
+ * ``"flash"`` -- plan is an auto-flash; ``_run_auto`` should
727
+ run next. ``_auto_image`` + ``_auto_target_disk_serial``
728
+ are populated.
729
+ * ``"interactive"`` -- plan asks the operator to pick; fall
730
+ through to the interactive wizard. The catalog source on
731
+ state may be updated to the server's suggestion.
732
+ * ``"exit"`` -- plan is "nothing to do here"; exit cleanly.
733
+
734
+ Network / parse failures fall through to ``"interactive"``
735
+ with the previously-set catalog (the server's
736
+ ``/catalog.toml`` -- set in ``__init__``). The operator can
737
+ retry from the interactive screen or re-run with the same
738
+ cmdline.
739
+ """
740
+ assert self._state.mac is not None
741
+ url = f"{self._server_url.rstrip('/')}/pxe/{self._state.mac}/plan"
742
+ try:
743
+ req = urllib.request.Request(url, headers={"Accept": "application/json"})
744
+ with urllib.request.urlopen(req, timeout=15) as resp:
745
+ payload = _json.loads(resp.read().decode("utf-8"))
746
+ except (urllib.error.URLError, OSError, TimeoutError, ValueError) as exc:
747
+ # Soft failure: surface as a transient catalog-load
748
+ # error and fall through to interactive. Operator sees
749
+ # the banner on the image-select screen.
750
+ self._catalog_load_error = f"plan fetch failed: {exc}"
751
+ return "interactive"
752
+
753
+ mode = payload.get("mode")
754
+ if mode == "flash":
755
+ self._auto_image = payload.get("image")
756
+ self._auto_target_disk_serial = payload.get("target_disk_serial")
757
+ self._auto_format = payload.get("format")
758
+ self._auto_name = payload.get("name")
759
+ self._auto_image_sha = payload.get("disk_image_sha")
760
+ if not self._auto_image or not self._auto_target_disk_serial:
761
+ self._catalog_load_error = (
762
+ f"server returned mode=flash but missing image/target_disk_serial: {payload!r}"
763
+ )
764
+ return "interactive"
765
+ self._auto = True
766
+ return "flash"
767
+ if mode == "interactive":
768
+ # Server may suggest a specific catalog. If it does, use
769
+ # it; if not, keep whatever was set in __init__ (which
770
+ # defaults to ``<server>/catalog.toml`` for server-driven
771
+ # mode). Crucially, ``pxe_done_base`` stays at
772
+ # ``self._server_url`` regardless of what the plan's
773
+ # catalog field points at -- the completion POST goes
774
+ # back to the pixie that handed us the plan, NOT
775
+ # to whichever (possibly third-party) host hosts the
776
+ # catalog TOML.
777
+ suggested = payload.get("catalog")
778
+ if isinstance(suggested, str) and suggested:
779
+ self._state.catalog_source = suggested
780
+ return "interactive"
781
+ if mode == "exit":
782
+ return "exit"
783
+ if mode == "inventory":
784
+ return "inventory"
785
+ # Unknown mode -- treat as interactive so the operator gets
786
+ # SOMETHING they can act on, plus a banner explaining why.
787
+ self._catalog_load_error = f"unknown plan mode {mode!r}; falling back to interactive"
788
+ return "interactive"
789
+
790
+ def _post_auto_failure(self, reason: str) -> None:
791
+ """Tell the pixie an unattended (netboot) flash failed.
792
+
793
+ "When it knows and it is capable": the failure paths below call this
794
+ the moment they detect a non-recoverable failure, and ``mac`` +
795
+ ``pxe_done_base`` (the netboot context) being set IS the capability
796
+ check -- a USB/interactive run has neither and stays silent. Without
797
+ this the server sits at "awaiting flash" forever even though the live
798
+ env knew. Best-effort: a server we cannot reach must not mask the
799
+ on-console failure (the red Panel was already printed).
800
+ """
801
+ if not (self._state.pxe_done_base and self._state.mac):
802
+ return
803
+ with contextlib.suppress(urllib.error.URLError, OSError, TimeoutError):
804
+ post_pxe_status(self._state.pxe_done_base, self._state.mac, "failed", reason)
805
+
806
+ def _run_auto(self) -> int:
807
+ """Scripted flash path: plan-driven, no prompts.
808
+
809
+ The pixie's /pxe/<mac>/plan response provides every
810
+ argument needed for the flash. ``pixie``'s job is to:
811
+
812
+ 1. Look up the disk whose serial matches the plan's
813
+ ``target_disk_serial`` (the operator picked it in
814
+ /ui/machines).
815
+ 2. Build the flash plan.
816
+ 3. Run the flash with the standard Rich progress bar.
817
+ 4. POST ``/pxe/<mac>/done`` to the pixie.
818
+ 5. Reboot.
819
+
820
+ On any failure: prints a red Panel + exits non-zero, no reboot.
821
+ Uses the same screens/panels the interactive wizard uses, so
822
+ operator-visible chrome is identical between scripted +
823
+ interactive runs.
824
+ """
825
+ assert self._auto_image is not None
826
+ assert self._auto_target_disk_serial is not None
827
+
828
+ # Stable serial-console marker for the PXE chain test
829
+ # (cijoe/configs/test-pxe.toml chain_markers). Pinned plain-
830
+ # text so downstream observers (test scripts, BMC serial-log
831
+ # tailers, operators inspecting journalctl) can detect that
832
+ # pixie entered auto-flash mode rather than the wizard.
833
+ _emit_console_marker("pixie: auto-flash starting")
834
+
835
+ # Pre-fill wizard state from plan-fetched values. ``--image``
836
+ # accepts a path or URL; ``_TuiImage`` keys differ.
837
+ image_arg = self._auto_image
838
+ if "://" in image_arg:
839
+ self._state.selected_image = _TuiImage(
840
+ name=self._auto_name or _basename_from_url(image_arg) or "auto-flash-image",
841
+ fmt=self._auto_format,
842
+ size_bytes=0,
843
+ url=image_arg,
844
+ sha=self._auto_image_sha,
845
+ )
846
+ else:
847
+ image_path = Path(image_arg)
848
+ self._state.selected_image = _TuiImage(
849
+ name=self._auto_name or image_path.name or "auto-flash-image",
850
+ fmt=self._auto_format,
851
+ size_bytes=0,
852
+ path=image_path,
853
+ )
854
+
855
+ # Look up the disk by serial. lsblk-via-disks.list_disks
856
+ # returns dicts with a ``serial`` field; the pixie
857
+ # picked this serial when the operator chose a target in
858
+ # /ui/machines. A no-match means the drive was swapped or
859
+ # the inventory is stale -- refuse rather than guess.
860
+ target_serial = self._auto_target_disk_serial
861
+ try:
862
+ all_disks = disks.list_disks()
863
+ except (FileNotFoundError, subprocess.SubprocessError, OSError) as exc:
864
+ self._console.clear()
865
+ self._print_header(stage=3, title="Auto-flash: disk lookup failed")
866
+ self._console.print(
867
+ Panel(
868
+ f"[{_DANGER}]lsblk failed: {exc}[/]",
869
+ border_style=_DANGER,
870
+ title="Disk inventory failed",
871
+ )
872
+ )
873
+ self._post_auto_failure(f"disk inventory failed: {exc}")
874
+ return 1
875
+ matched: dict[str, Any] | None = None
876
+ for disk in all_disks:
877
+ raw = disk.get("serial")
878
+ present = raw.strip() if isinstance(raw, str) else raw
879
+ # ``present`` guards the catastrophic case: never match a
880
+ # serial-less disk (present None/"") even if target_serial
881
+ # were somehow empty -- flashing the wrong disk is total
882
+ # data loss. Upstream guarantees target_serial is truthy;
883
+ # this is defence in depth.
884
+ if present and present == target_serial:
885
+ matched = disk
886
+ break
887
+ if matched is None:
888
+ self._console.clear()
889
+ self._print_header(stage=3, title="Auto-flash: target disk not found")
890
+ visible = (
891
+ ", ".join(
892
+ f"{d.get('path')} (serial={d.get('serial')!r})"
893
+ for d in all_disks
894
+ if d.get("type") == "disk"
895
+ )
896
+ or "(none)"
897
+ )
898
+ self._console.print(
899
+ Panel(
900
+ f"[{_DANGER}]No disk on this host has serial="
901
+ f"{target_serial!r}.[/]\n\n"
902
+ f"Current disks: {visible}\n\n"
903
+ "The operator's pick in /ui/machines is stale; "
904
+ "re-pick after the next inventory and retry.",
905
+ border_style=_DANGER,
906
+ title="No matching disk",
907
+ )
908
+ )
909
+ self._post_auto_failure(
910
+ f"target disk serial {target_serial!r} not present on this host"
911
+ )
912
+ return 2
913
+ self._state.selected_disk = matched
914
+
915
+ # Probe + plan (same code path the interactive screen 4
916
+ # uses). Errors surface as a red Panel and a non-zero exit.
917
+ self._console.clear()
918
+ self._print_header(stage=4, title="Auto-flash: probing")
919
+ plan_or_error = self._probe_and_plan(
920
+ self._state.selected_image,
921
+ Path(str(matched.get("path"))),
922
+ )
923
+ if isinstance(plan_or_error, str):
924
+ self._console.print(
925
+ Panel(
926
+ f"[{_DANGER}]Probe failed:[/]\n\n{plan_or_error}",
927
+ border_style=_DANGER,
928
+ title="Plan rejected",
929
+ )
930
+ )
931
+ self._post_auto_failure(f"plan rejected on probe: {plan_or_error}")
932
+ return 3
933
+ plan, errors = plan_or_error
934
+ if errors:
935
+ self._print_flash_plan(plan, errors)
936
+ self._post_auto_failure("plan validation failed: " + "; ".join(errors))
937
+ return 4
938
+
939
+ # Run the flash with the same Rich progress bar interactive
940
+ # operators see. ``_screen_flash_running`` sets
941
+ # ``self._state.post_flash`` on success.
942
+ self._screen_flash_running(plan)
943
+ if not self._state.post_flash:
944
+ self._post_auto_failure("flash did not complete (write or verify failed)")
945
+ return 5
946
+
947
+ # Best-effort completion signal (pixie's per-MAC timeline).
948
+ if self._state.pxe_done_base and self._state.mac:
949
+ with contextlib.suppress(urllib.error.URLError, OSError, TimeoutError):
950
+ post_pxe_status(self._state.pxe_done_base, self._state.mac, "done")
951
+
952
+ # Stable serial-console marker for the PXE chain test
953
+ # (cijoe/configs/test-pxe.toml chain_markers). Rich Panels
954
+ # vary across terminal widths; this plain-text line is the
955
+ # contract downstream observers (test scripts, CI dashboards,
956
+ # operators tailing the BMC serial log) can pin against.
957
+ _emit_console_marker("pixie: flash complete; rebooting")
958
+
959
+ # Always reboot on success -- auto-mode exists for the
960
+ # unattended netboot flow where reboot is the whole point.
961
+ self._do_reboot() # exits via systemctl reboot; unreachable on success
962
+ return 0
963
+
964
+ # ---------- main loop ----------------------------------------------
965
+
966
+ def _main_loop(self) -> None:
967
+ while True:
968
+ stage = self._state.stage()
969
+ if stage is _WizardStage.SELECT_CATALOG:
970
+ action = self._screen_select_catalog()
971
+ elif stage is _WizardStage.SELECT_IMAGE:
972
+ action = self._screen_select_image()
973
+ elif stage is _WizardStage.SELECT_DISK:
974
+ action = self._screen_select_disk()
975
+ elif stage is _WizardStage.CONFIRM_FLASH:
976
+ action = self._screen_confirm_flash()
977
+ else:
978
+ action = self._screen_reboot_or_done()
979
+
980
+ if action == "quit":
981
+ return
982
+ # All other actions (back / continue / refresh) loop.
983
+
984
+ # ---------- screens ------------------------------------------------
985
+
986
+ def _screen_select_catalog(self) -> str:
987
+ """Stage 1: pick the image source.
988
+
989
+ Three options: pixie's default release catalog, a custom
990
+ catalog URL (http(s):// or oras://), or local-only (no
991
+ remote overlay, just scan ``image_root``). The screen is
992
+ auto-skipped at startup when ``--catalog`` was set or the
993
+ local image-root already has images; back-nav from
994
+ SELECT_IMAGE re-enters this screen so the operator can
995
+ switch source mid-session.
996
+ """
997
+ self._console.clear()
998
+ self._print_header(stage=1, title="Pick an image source")
999
+ self._console.print(
1000
+ Panel(
1001
+ f"[{_ACCENT}]d[/] load pixie's [bold]default[/] catalog "
1002
+ "(published with pixie as a release artifact)\n"
1003
+ f"[{_ACCENT}]c[/] provide a [bold]custom[/] http(s):// or oras:// URL "
1004
+ "to a catalog that you host\n"
1005
+ f"[{_ACCENT}]l[/] [bold]local only[/] -- skip remote catalog, "
1006
+ "use images already in the image-root",
1007
+ title="How should pixie find images?",
1008
+ border_style=_PRIMARY,
1009
+ )
1010
+ )
1011
+ prompt_text = self._render_prompt_line(
1012
+ title="Pick an image source",
1013
+ extras=(
1014
+ ("d", "default"),
1015
+ ("c", "custom"),
1016
+ ("l", "local only"),
1017
+ ("q", "quit"),
1018
+ ),
1019
+ )
1020
+ choice = self._ask(prompt_text)
1021
+ if choice in ("q", "quit"):
1022
+ return "quit"
1023
+ if choice in ("d", "default"):
1024
+ self._state.catalog_source = _BTY_DEFAULT_CATALOG_URL
1025
+ self._state.catalog_chosen = True
1026
+ return "continue"
1027
+ if choice in ("c", "custom"):
1028
+ self._screen_change_catalog()
1029
+ # Mark chosen even if the operator cancelled the URL
1030
+ # input -- ``catalog_source`` either got set (success)
1031
+ # or stayed at whatever it was (cancel). Either way the
1032
+ # operator decided to proceed; back-nav from image
1033
+ # screen re-enters this stage if they change their mind.
1034
+ self._state.catalog_chosen = True
1035
+ return "continue"
1036
+ if choice in ("l", "local"):
1037
+ self._state.catalog_source = None
1038
+ self._state.catalog_chosen = True
1039
+ return "continue"
1040
+ self._console.print(f"[{_DANGER}]Unrecognised choice {choice!r}; type d, c, l, or q.[/]")
1041
+ self._pause_for_ack()
1042
+ return "continue"
1043
+
1044
+ def _screen_select_image(self) -> str:
1045
+ """Stage 2: pick an image.
1046
+
1047
+ Combines local image-root scan + the optional ``--catalog``
1048
+ overlay into one numbered list. Operator types a number or
1049
+ a single-letter command. The catalog source itself was
1050
+ chosen in stage 1; ``b`` here re-enters that screen.
1051
+ """
1052
+ # Serial-console marker for chain tests + BMC log tailers:
1053
+ # signals the interactive path reached the picker (i.e. the
1054
+ # CLI honored a mode=interactive plan and successfully loaded
1055
+ # a catalog). Distinguishes pixie-tui from pixie-flash-*
1056
+ # boots on the same serial log; the auto path emits its own
1057
+ # "auto-flash starting" marker instead. Kept idempotent --
1058
+ # the wizard re-enters this screen on back-nav, and firing
1059
+ # the marker each time is fine (log tailers dedupe on match).
1060
+ _emit_console_marker("pixie: wizard select_image", local_tty=False)
1061
+ self._refresh_images()
1062
+ self._console.clear()
1063
+ self._print_header(stage=2, title="Pick an image to flash")
1064
+ if self._state._images:
1065
+ self._print_image_table(self._state._images)
1066
+ else:
1067
+ self._print_empty_catalog_panel()
1068
+
1069
+ prompt_text = self._render_prompt_line(
1070
+ title="Pick an image to flash",
1071
+ extras=(
1072
+ ("#", "pick"),
1073
+ ("b", "back (change catalog)"),
1074
+ ("r", "refresh"),
1075
+ ("q", "quit"),
1076
+ ),
1077
+ )
1078
+ choice = self._ask(prompt_text)
1079
+ if choice in ("q", "quit"):
1080
+ return "quit"
1081
+ if choice in ("", "r", "refresh"):
1082
+ return "continue"
1083
+ if choice in ("b", "back"):
1084
+ self._state.back()
1085
+ return "continue"
1086
+ idx = self._parse_index(choice, len(self._state._images))
1087
+ if idx is not None:
1088
+ self._state.selected_image = self._state._images[idx]
1089
+ else:
1090
+ self._console.print(
1091
+ f"[{_DANGER}]"
1092
+ + self._describe_index_miss(choice, len(self._state._images), "image")
1093
+ + "[/]"
1094
+ )
1095
+ self._pause_for_ack()
1096
+ return "continue"
1097
+
1098
+ def _screen_select_disk(self) -> str:
1099
+ """Stage 2: pick a disk.
1100
+
1101
+ Refreshed every entry to catch hotplug. Filtered to block
1102
+ devices of type ``disk`` (skips loop / ram / partitions).
1103
+ """
1104
+ self._refresh_disks()
1105
+ self._console.clear()
1106
+ self._print_header(stage=3, title="Pick a target disk")
1107
+ if self._state._disks:
1108
+ self._print_disk_table(self._state._disks)
1109
+ else:
1110
+ self._console.print(
1111
+ Panel(
1112
+ f"[{_DANGER}]No flash-eligible disks detected.[/]\n\n"
1113
+ f"[{_MUTED}]Check ``lsblk`` on tty2 to see what the kernel sees.[/]",
1114
+ border_style=_DANGER,
1115
+ title="No disks",
1116
+ )
1117
+ )
1118
+
1119
+ # ``[r]`` is not advertised here: Enter re-runs
1120
+ # ``_refresh_disks`` (called on every screen entry), so an
1121
+ # explicit refresh key is redundant. Hot-plugged disks show
1122
+ # up on the next Enter.
1123
+ prompt_text = self._render_prompt_line(
1124
+ title="Pick a target disk",
1125
+ extras=(
1126
+ ("#", "pick"),
1127
+ ("b", "back"),
1128
+ ("q", "quit"),
1129
+ ),
1130
+ )
1131
+ choice = self._ask(prompt_text)
1132
+ if choice in ("q", "quit"):
1133
+ return "quit"
1134
+ if choice in ("b", "back", "esc"):
1135
+ self._state.back()
1136
+ return "continue"
1137
+ if choice in ("r", "refresh", ""):
1138
+ return "continue"
1139
+ idx = self._parse_index(choice, len(self._state._disks))
1140
+ if idx is not None:
1141
+ self._state.selected_disk = self._state._disks[idx]
1142
+ else:
1143
+ self._console.print(
1144
+ f"[{_DANGER}]"
1145
+ + self._describe_index_miss(choice, len(self._state._disks), "disk")
1146
+ + "[/]"
1147
+ )
1148
+ self._pause_for_ack()
1149
+ return "continue"
1150
+
1151
+ def _screen_confirm_flash(self) -> str:
1152
+ """Stage 3: probe image + target, render plan, y/N confirm.
1153
+
1154
+ Probing runs synchronously with a Rich Status spinner so
1155
+ the operator sees something during the 1-3s of subprocess
1156
+ calls (``lsblk``, ``qemu-img info``, etc.).
1157
+ """
1158
+ image = self._state.selected_image
1159
+ disk = self._state.selected_disk
1160
+ assert image is not None and disk is not None # stage gate
1161
+
1162
+ disk_path = Path(str(disk.get("path") or disk.get("name") or ""))
1163
+ self._console.clear()
1164
+ self._print_header(stage=4, title="Confirm flash plan")
1165
+
1166
+ # Probe both ends with a spinner so the screen isn't blank
1167
+ # during the lsblk + qemu-img info round-trips.
1168
+ plan_or_error = self._probe_and_plan(image, disk_path)
1169
+ if isinstance(plan_or_error, str):
1170
+ self._console.print(
1171
+ Panel(
1172
+ f"[{_DANGER}]Probe failed:[/]\n\n{plan_or_error}",
1173
+ border_style=_DANGER,
1174
+ title="Plan rejected",
1175
+ )
1176
+ )
1177
+ choice = self._ask(
1178
+ self._render_prompt_line(
1179
+ title="Probe failed",
1180
+ extras=(("b", "back"), ("q", "quit")),
1181
+ )
1182
+ )
1183
+ if choice in ("q", "quit"):
1184
+ return "quit"
1185
+ self._state.back()
1186
+ return "continue"
1187
+
1188
+ plan, errors = plan_or_error
1189
+ self._print_flash_plan(plan, errors)
1190
+
1191
+ if errors:
1192
+ self._console.print(
1193
+ Panel(
1194
+ f"[{_DANGER}]Validation FAILED:[/]\n" + "\n".join(f" - {e}" for e in errors),
1195
+ border_style=_DANGER,
1196
+ title="Plan rejected",
1197
+ )
1198
+ )
1199
+ choice = self._ask(
1200
+ self._render_prompt_line(
1201
+ title="Plan rejected",
1202
+ extras=(("b", "back"), ("q", "quit")),
1203
+ )
1204
+ )
1205
+ if choice in ("q", "quit"):
1206
+ return "quit"
1207
+ self._state.back()
1208
+ return "continue"
1209
+
1210
+ prompt_text = self._render_prompt_line(
1211
+ title="Confirm flash plan",
1212
+ extras=(
1213
+ ("y", "yes, flash"),
1214
+ ("b", "back"),
1215
+ ("q", "quit"),
1216
+ ),
1217
+ )
1218
+ choice = self._ask(prompt_text)
1219
+ if choice in ("q", "quit"):
1220
+ return "quit"
1221
+ if choice in ("b", "back", "n", "no", ""):
1222
+ self._state.back()
1223
+ return "continue"
1224
+ if choice in ("y", "yes"):
1225
+ self._screen_flash_running(plan)
1226
+ return "continue"
1227
+ self._console.print(f"[{_DANGER}]Unrecognised choice {choice!r}.[/]")
1228
+ self._pause_for_ack()
1229
+ return "continue"
1230
+
1231
+ def _screen_flash_running(self, plan: flash.FlashPlan) -> None:
1232
+ """Run the flash in a background thread; the main thread
1233
+ sits in a Rich Live() with a Progress bar updated from the
1234
+ ``FlashProgress`` callback.
1235
+
1236
+ On success, sets ``self._state.post_flash = True`` so the
1237
+ next ``stage()`` returns REBOOT_OR_DONE.
1238
+ """
1239
+ self._console.clear()
1240
+ self._print_header(stage=4, title="Flashing...")
1241
+
1242
+ progress = Progress(
1243
+ TextColumn("[bold]{task.description}"),
1244
+ BarColumn(bar_width=None),
1245
+ TaskProgressColumn(),
1246
+ TextColumn("[{task.fields[bytes_human]}]"),
1247
+ TransferSpeedColumn(),
1248
+ TimeElapsedColumn(),
1249
+ TimeRemainingColumn(),
1250
+ console=self._console,
1251
+ transient=False,
1252
+ expand=True,
1253
+ )
1254
+
1255
+ # Shared state between the flash worker thread and the
1256
+ # rendering loop. Only the worker writes; the main thread
1257
+ # reads via the Progress callback shim below.
1258
+ shared: dict[str, Any] = {"result": None, "error": None, "stage": "starting"}
1259
+
1260
+ with progress:
1261
+ # The write bar is always indeterminate. The "total" for
1262
+ # the writer is fundamentally unreliable: gzip wraps its
1263
+ # uncompressed-size trailer mod 2^32, qcow2 virtual size
1264
+ # need not equal the bytes dd ends up writing, and the
1265
+ # ``size_bytes`` fallback is the COMPRESSED upstream size
1266
+ # which is always smaller than the decompressed write
1267
+ # count. Rather than show a percentage that misleads,
1268
+ # leave ``total=None`` so Rich's BarColumn draws the
1269
+ # pulsing scanner block; the bytes-human counter shows
1270
+ # the running write count so the operator can still see
1271
+ # the disk is being written. The download bar IS
1272
+ # determinate (Content-Length is reliable) and gets added
1273
+ # lazily on the first ``downloading_progress`` event so
1274
+ # local-file flashes do not show a permanently-empty
1275
+ # network bar.
1276
+ task_id = progress.add_task(
1277
+ "queued",
1278
+ total=None,
1279
+ bytes_human="0",
1280
+ )
1281
+ download_task_id: TaskID | None = None
1282
+
1283
+ # SoL-friendly milestone markers. The Rich progress bars
1284
+ # above are rendered to /dev/tty1 (the framebuffer VT) and
1285
+ # invisible to an operator watching via IPMI SoL or
1286
+ # following journalctl. The milestone emitter writes plain
1287
+ # ``pixie: download NN%`` / ``pixie: write NN%`` lines via
1288
+ # /dev/kmsg at 25/50/75/100 crossings so the same operator
1289
+ # gets at most four heartbeats per stage on whichever
1290
+ # serial console the kernel is registered with. Skipped
1291
+ # silently when the total is unknown (some write paths
1292
+ # can't pre-compute decompressed size); the ``starting``
1293
+ # and ``flash complete; rebooting`` bookends still fire.
1294
+ download_emitter = _MilestoneEmitter("download")
1295
+ write_emitter = _MilestoneEmitter("write")
1296
+
1297
+ def _on_progress(ev: flash.FlashProgress) -> None:
1298
+ # Called from the flash thread. ``progress`` is
1299
+ # thread-safe (Rich's Progress mutex), so direct
1300
+ # updates are fine.
1301
+ nonlocal download_task_id
1302
+ shared["stage"] = ev.event
1303
+ if ev.event == "started":
1304
+ progress.update(task_id, description="starting flash")
1305
+ elif ev.event == "writing":
1306
+ progress.update(task_id, description=f"writing ({ev.note or '?'})")
1307
+ elif ev.event == "writing_progress":
1308
+ if ev.bytes_written is not None:
1309
+ # Indeterminate bar; just surface the running
1310
+ # byte count alongside the pulse.
1311
+ progress.update(
1312
+ task_id,
1313
+ completed=ev.bytes_written,
1314
+ bytes_human=_format_mib(ev.bytes_written),
1315
+ )
1316
+ write_emitter.update(ev.bytes_written, ev.total_bytes)
1317
+ elif ev.event == "downloading_progress":
1318
+ if ev.bytes_downloaded is None:
1319
+ return
1320
+ if download_task_id is None:
1321
+ # Rich renders tasks in insertion order; we
1322
+ # want download above writing, but the write
1323
+ # task was created first. Workaround: a new
1324
+ # task added at runtime lands at the bottom,
1325
+ # which is fine; the operator still sees
1326
+ # both bars and the labels disambiguate.
1327
+ download_task_id = progress.add_task(
1328
+ "downloading",
1329
+ total=ev.total_bytes,
1330
+ bytes_human=_format_progress_bytes(ev.bytes_downloaded, ev.total_bytes),
1331
+ )
1332
+ progress.update(
1333
+ download_task_id,
1334
+ completed=ev.bytes_downloaded,
1335
+ total=ev.total_bytes,
1336
+ bytes_human=_format_progress_bytes(ev.bytes_downloaded, ev.total_bytes),
1337
+ )
1338
+ download_emitter.update(ev.bytes_downloaded, ev.total_bytes)
1339
+ elif ev.event == "synced":
1340
+ progress.update(task_id, description="syncing buffers")
1341
+ elif ev.event == "partprobed":
1342
+ progress.update(task_id, description="partprobed")
1343
+ elif ev.event == "done":
1344
+ progress.update(task_id, description="done")
1345
+ if download_task_id is not None:
1346
+ progress.update(download_task_id, description="downloaded")
1347
+ elif ev.event == "failed":
1348
+ progress.update(task_id, description=f"FAILED: {ev.note}")
1349
+ elif ev.event == "subprocess_log":
1350
+ # Rich's Progress is a Live; ``console.print``
1351
+ # inside the live context erases the live
1352
+ # region, prints the line, and redraws -- so
1353
+ # the log line lands above the progress widget
1354
+ # without corrupting it.
1355
+ self._console.print(f"[{_MUTED}]{ev.note}[/]")
1356
+
1357
+ # ``cancel_event`` lets the main thread (which holds
1358
+ # KeyboardInterrupt) tell the worker thread's flash pipeline
1359
+ # to SIGTERM its subprocesses. Without this, hitting Ctrl+C
1360
+ # during a flash interrupts only the main thread's join();
1361
+ # the daemon worker keeps its dd/curl/zstd children running
1362
+ # and the operator's "abort" leaves a partial-write in flight
1363
+ # on the target disk with no cleanup.
1364
+ cancel_event = threading.Event()
1365
+
1366
+ def _runner() -> None:
1367
+ try:
1368
+ flash.execute_plan(
1369
+ plan,
1370
+ progress=_on_progress,
1371
+ cancel=cancel_event.is_set,
1372
+ )
1373
+ shared["result"] = "ok"
1374
+ except flash.FlashCancelled as exc:
1375
+ shared["result"] = "cancelled"
1376
+ shared["error"] = str(exc)
1377
+ except flash.FlashError as exc:
1378
+ shared["result"] = "failed"
1379
+ shared["error"] = str(exc)
1380
+ except Exception as exc:
1381
+ shared["result"] = "failed"
1382
+ shared["error"] = f"unexpected: {exc!r}"
1383
+
1384
+ t = threading.Thread(target=_runner, name="pixie-flash", daemon=True)
1385
+ t.start()
1386
+ try:
1387
+ t.join()
1388
+ except KeyboardInterrupt:
1389
+ # Operator pressed Ctrl+C while the flash was running.
1390
+ # Set the cancel flag; ``execute_plan``'s watchdog will
1391
+ # SIGTERM its subprocesses (1s grace then SIGKILL) and
1392
+ # the runner will land ``FlashCancelled`` on shared.
1393
+ # Re-join with no timeout so we don't return while the
1394
+ # subprocesses are still tearing down: a partially-killed
1395
+ # dd that's still flushing the page cache wedges the
1396
+ # screen redraw if we race past it.
1397
+ cancel_event.set()
1398
+ self._console.print(f"[{_MUTED}]Cancelling -- waiting for dd/curl to exit...[/]")
1399
+ t.join()
1400
+
1401
+ # Defensive: Rich's ``Live`` (which ``Progress`` uses) hides
1402
+ # the cursor while running via ANSI ``\033[?25l`` and is
1403
+ # supposed to restore it on ``with`` exit. On the live env's
1404
+ # framebuffer console and BMC virtual KVMs the restore is
1405
+ # sometimes lost (terminal state desync). Explicitly re-show
1406
+ # the cursor here so the next screen's prompt is visible.
1407
+ self._console.show_cursor(True)
1408
+
1409
+ if shared["result"] == "ok":
1410
+ self._console.print(
1411
+ Panel(
1412
+ f"[{_OK}]Flash completed.[/]",
1413
+ border_style=_OK,
1414
+ title="Done",
1415
+ )
1416
+ )
1417
+ self._register_uefi_boot_entry(plan)
1418
+ self._post_pxe_done_if_configured()
1419
+ self._state.post_flash = True
1420
+ elif shared["result"] == "cancelled":
1421
+ # Surface cancellation distinctly from a pipeline failure
1422
+ # so the operator knows the disk is in a partial-write
1423
+ # state that needs a re-flash (vs. a true error they'd
1424
+ # report). dd's subprocesses were SIGTERM'd; the on-disk
1425
+ # state is the prefix that dd had written + flushed.
1426
+ self._console.print(
1427
+ Panel(
1428
+ f"[{_DANGER}]Flash cancelled.[/]\n\n"
1429
+ f"[{_MUTED}]The target disk holds a partial write. "
1430
+ f"Re-flash before booting.[/]",
1431
+ border_style=_DANGER,
1432
+ title="Cancelled",
1433
+ )
1434
+ )
1435
+ self._pause_for_ack()
1436
+ else:
1437
+ self._console.print(
1438
+ Panel(
1439
+ f"[{_DANGER}]Flash FAILED.[/]\n\n"
1440
+ f"[{_MUTED}]{shared.get('error') or 'unknown error'}[/]",
1441
+ border_style=_DANGER,
1442
+ title="Flash failed",
1443
+ )
1444
+ )
1445
+ self._pause_for_ack()
1446
+
1447
+ def _screen_reboot_or_done(self) -> str:
1448
+ """Stage 4: flash succeeded. Offer reboot.
1449
+
1450
+ Esc / ``b`` from here goes back to Stage 2 (same disk, pick
1451
+ again) so the operator can flash another disk with the same
1452
+ image without re-selecting the image. Full reset happens on
1453
+ a further ``b``.
1454
+ """
1455
+ self._console.clear()
1456
+ self._print_header(stage=5, title="Flash complete -- ready to reboot")
1457
+ d = self._state.selected_disk or {}
1458
+ disk_brief = f"{d.get('path', '?')} ({d.get('size', '?')} {d.get('model') or ''})".strip()
1459
+ self._console.print(
1460
+ Panel(
1461
+ f"[{_OK}]Image written to {disk_brief}.[/]\n\n"
1462
+ f"Reboot now to boot the freshly flashed disk.",
1463
+ border_style=_OK,
1464
+ title="Done",
1465
+ )
1466
+ )
1467
+
1468
+ prompt_text = self._render_prompt_line(
1469
+ title="Reboot to boot from the freshly flashed disk",
1470
+ extras=(
1471
+ ("Enter/y", "reboot now (default)"),
1472
+ ("n", "don't reboot, stay"),
1473
+ ("b", "back"),
1474
+ ("q", "quit"),
1475
+ ),
1476
+ )
1477
+ choice = self._ask(prompt_text)
1478
+ # Enter (empty) defaults to REBOOT: the operator just flashed and
1479
+ # the obvious next step is to boot the new disk. Defaulting Enter
1480
+ # to "quit" surprised operators (flashed box sat un-rebooted).
1481
+ # The destructive step already happened, so reboot-on-Enter is
1482
+ # safe -- explicit n/q/back still opt out.
1483
+ if choice in ("n", "no"):
1484
+ return "quit" # stay; don't reboot
1485
+ if choice in ("q", "quit"):
1486
+ return "quit"
1487
+ if choice in ("b", "back"):
1488
+ self._state.back()
1489
+ return "continue"
1490
+ if choice in ("y", "yes", "r", "reboot", ""):
1491
+ self._do_reboot()
1492
+ return "quit" # unreachable on success; defensive
1493
+ self._console.print(f"[{_DANGER}]Unrecognised choice {choice!r}.[/]")
1494
+ self._pause_for_ack()
1495
+ return "continue"
1496
+
1497
+ # ---------- auxiliary screens -------------------------------------
1498
+
1499
+ def _screen_change_catalog(self) -> None:
1500
+ """Switch the catalog source. Prompt for a new URL / path.
1501
+
1502
+ Empty input = clear catalog (local-only mode). Invalid
1503
+ sources surface as a soft banner on the next image-pick
1504
+ screen rather than crashing.
1505
+ """
1506
+ self._console.clear()
1507
+ self._print_header(stage=1, title="Switch catalog source")
1508
+ self._console.print(
1509
+ Panel(
1510
+ f"Current source: [{_PRIMARY}]{self._state.catalog_source or '(local only)'}[/]\n\n"
1511
+ "Enter a new source:\n"
1512
+ " - local TOML path: ``/etc/pixie/catalog.toml``\n"
1513
+ " - HTTP URL: ``http://pixie:8080/catalog.toml``\n"
1514
+ " - ORAS reference: ``oras://ghcr.io/owner/repo:tag``\n"
1515
+ " - empty: clear catalog (local image-root only)",
1516
+ title="Catalog source",
1517
+ )
1518
+ )
1519
+ prompt = "[bold]>[/] [new catalog source, empty to clear, q to abort]"
1520
+ new_source = self._ask(prompt).strip()
1521
+ if new_source in ("q", "quit"):
1522
+ return
1523
+ self._state.catalog_source = new_source or None
1524
+ # ``pxe_done_base`` stays anchored to ``self._server_url``
1525
+ # (set in __init__ when ``--mac`` was supplied) regardless
1526
+ # of what catalog source the operator picks here. The
1527
+ # completion POST goes to the pixie we got the plan
1528
+ # from, NOT to whichever host the catalog TOML happens to
1529
+ # live on. See the matching guard in
1530
+ # ``_fetch_and_dispatch_plan`` for the same bug pattern.
1531
+
1532
+ # ---------- rendering helpers -------------------------------------
1533
+
1534
+ def _print_header(self, *, stage: int, title: str) -> None:
1535
+ """Single Rich-Panel header carrying everything that's "world
1536
+ state" for the screen.
1537
+
1538
+ Title (on the top border): ``-={[ pixie vX.Y.Z ]}=-`` -- the
1539
+ decoration that used to live in the standalone ASCII banner.
1540
+ Same two-tone treatment (blue brackets, yellow version) via
1541
+ Rich markup; the Panel itself uses ``border_style=_PRIMARY``
1542
+ so the box-drawing renders blue. Rich's ``safe_box`` falls
1543
+ back to ASCII frames on dumb / monochrome terminals.
1544
+
1545
+ Body lines:
1546
+
1547
+ * ``Steps:`` -- the wizard breadcrumb. Active stage in
1548
+ accent yellow + bold, others muted. The active-step
1549
+ highlight tracks the ``stage`` arg.
1550
+ * ``image_root:`` -- always shown.
1551
+ * ``catalog:`` -- always shown; reads ``local only``
1552
+ (italic) when no ``--catalog`` source is set.
1553
+ * ``mac:`` -- shown only when set (PXE-driven runs).
1554
+
1555
+ The per-screen ``title`` is NOT rendered here -- it surfaces
1556
+ attached to the leader on the prompt line
1557
+ (``> <title>: _``, via :meth:`_render_prompt_line`). The
1558
+ arg is kept on this method for symmetry with the call
1559
+ sites; every screen passes the same title to both this
1560
+ method and the prompt builder.
1561
+ """
1562
+ del title # surfaced near the prompt instead
1563
+ labels = ("Catalog", "Image", "Disk", "Flash", "Reboot")
1564
+
1565
+ # Steps breadcrumb with active-step highlight.
1566
+ crumb_parts = []
1567
+ for n, label in enumerate(labels, start=1):
1568
+ if n == stage:
1569
+ crumb_parts.append(f"[bold {_ACCENT}]{n}.{label}[/]")
1570
+ else:
1571
+ crumb_parts.append(f"[{_MUTED}]{n}.{label}[/]")
1572
+ crumb = " -> ".join(crumb_parts)
1573
+
1574
+ # Source-summary lines. Catalog is always shown so an
1575
+ # operator scanning the header never wonders whether the
1576
+ # state is missing.
1577
+ catalog_value = (
1578
+ f"[{_PRIMARY}]{self._state.catalog_source}[/]"
1579
+ if self._state.catalog_source
1580
+ else "[italic]local only[/italic]"
1581
+ )
1582
+ # ``image_root:`` is the longest label; pad shorter ones so
1583
+ # the colons align in the rendered body. Body groups two
1584
+ # sections, separated by a divider line:
1585
+ # 1. Wizard process: steps + data sources
1586
+ # 2. Wizard state: what the operator has committed so far
1587
+ body_lines = [
1588
+ f"[{_MUTED}]Steps: [/] {crumb}",
1589
+ f"[{_MUTED}]image_root:[/] [{_PRIMARY}]{self._state.image_root}[/]",
1590
+ f"[{_MUTED}]catalog: [/] {catalog_value}",
1591
+ ]
1592
+ if self._state.mac:
1593
+ body_lines.append(f"[{_MUTED}]mac: [/] [{_PRIMARY}]{self._state.mac}[/]")
1594
+
1595
+ # State-collected lines (selected image / disk). Only emit
1596
+ # the divider + section when there's at least one commit;
1597
+ # stage-1 boots with nothing selected, no need for an
1598
+ # empty section.
1599
+ state_lines: list[str] = []
1600
+ if self._state.selected_image:
1601
+ state_lines.append(
1602
+ f"[{_MUTED}]image: [/] [{_PRIMARY}]{self._state.selected_image.name}[/]"
1603
+ )
1604
+ if self._state.selected_disk:
1605
+ d = self._state.selected_disk
1606
+ disk_brief = (
1607
+ f"{d.get('path', '?')} ({d.get('size', '?')} {d.get('model') or ''})".strip()
1608
+ )
1609
+ state_lines.append(f"[{_MUTED}]disk: [/] [{_PRIMARY}]{disk_brief}[/]")
1610
+ if state_lines:
1611
+ body_lines.append("") # blank divider line inside the panel
1612
+ body_lines.extend(state_lines)
1613
+
1614
+ # Panel title: ``-={[ pixie vX.Y.Z ]}=-`` with blue brackets +
1615
+ # yellow version. Rich preserves markup in the title slot.
1616
+ panel_title = (
1617
+ f"[bold {_PRIMARY}]-={{[ [/]"
1618
+ f"[bold {_ACCENT}]pixie v{pixie.__version__}[/]"
1619
+ f"[bold {_PRIMARY}] ]}}=-[/]"
1620
+ )
1621
+ self._console.print(
1622
+ Panel(
1623
+ "\n".join(body_lines),
1624
+ title=panel_title,
1625
+ border_style=_PRIMARY,
1626
+ )
1627
+ )
1628
+ if self._catalog_load_error:
1629
+ self._console.print(f"[{_DANGER}]catalog load failed: {self._catalog_load_error}[/]")
1630
+ self._console.print()
1631
+
1632
+ def _print_image_table(self, rows: list[_TuiImage]) -> None:
1633
+ table = Table(
1634
+ show_header=True,
1635
+ header_style=f"bold {_PRIMARY}",
1636
+ row_styles=("", f"on {_STRIPE}"),
1637
+ expand=True,
1638
+ )
1639
+ table.add_column("#", justify="right", style=_ACCENT, no_wrap=True)
1640
+ table.add_column("Name")
1641
+ table.add_column("Format", style=_PRIMARY, no_wrap=True)
1642
+ table.add_column("Arch", style=_PRIMARY, no_wrap=True)
1643
+ table.add_column("Size", justify="right", no_wrap=True)
1644
+ table.add_column("Source", style=_MUTED)
1645
+ for i, row in enumerate(rows, start=1):
1646
+ source = "local" if row.path else "remote"
1647
+ table.add_row(
1648
+ str(i),
1649
+ row.name,
1650
+ row.fmt or "?",
1651
+ row.arch or "?",
1652
+ _format_mib(row.size_bytes) if row.size_bytes else "-",
1653
+ source,
1654
+ )
1655
+ self._console.print(table)
1656
+ self._console.print()
1657
+
1658
+ def _print_disk_table(self, rows: list[dict[str, Any]]) -> None:
1659
+ table = Table(
1660
+ show_header=True,
1661
+ header_style=f"bold {_PRIMARY}",
1662
+ row_styles=("", f"on {_STRIPE}"),
1663
+ expand=True,
1664
+ )
1665
+ table.add_column("#", justify="right", style=_ACCENT, no_wrap=True)
1666
+ table.add_column("Path", style=_PRIMARY, no_wrap=True)
1667
+ table.add_column("Size", justify="right", no_wrap=True)
1668
+ table.add_column("Model")
1669
+ table.add_column("Transport", style=_MUTED, no_wrap=True)
1670
+ table.add_column("Serial", style=_MUTED, no_wrap=True)
1671
+ for i, d in enumerate(rows, start=1):
1672
+ table.add_row(
1673
+ str(i),
1674
+ str(d.get("path") or d.get("name") or "?"),
1675
+ str(d.get("size") or "?"),
1676
+ str(d.get("model") or ""),
1677
+ str(d.get("tran") or d.get("transport") or ""),
1678
+ str(d.get("serial") or ""),
1679
+ )
1680
+ self._console.print(table)
1681
+ self._console.print()
1682
+
1683
+ def _print_empty_catalog_panel(self) -> None:
1684
+ body = (
1685
+ f"No images visible.\n\n"
1686
+ f"[{_MUTED}]Add some via:[/]\n"
1687
+ f" - drop files into [{_PRIMARY}]{self._state.image_root}[/]\n"
1688
+ f" - [{_ACCENT}]d[/] load pixie's default catalog "
1689
+ f"(published with pixie as a release artifact)\n"
1690
+ f" - [{_ACCENT}]c[/] provide an http(s):// or oras:// URL "
1691
+ f"to a catalog that you host"
1692
+ )
1693
+ self._console.print(Panel(body, title="Catalog is empty"))
1694
+ self._console.print()
1695
+
1696
+ def _print_flash_plan(self, plan: flash.FlashPlan, errors: list[str]) -> None:
1697
+ """Rich rendering of the plan -- replaces the
1698
+ FlashConfirmScreen modal's body.
1699
+ """
1700
+ image_lines = [
1701
+ f" image: {plan.image.url or plan.image.path}",
1702
+ f" format: {plan.image.format or '?'}",
1703
+ f" size on disk: {_format_mib(plan.image.size_bytes)}"
1704
+ f" ({plan.image.size_bytes or 0} bytes)",
1705
+ ]
1706
+ if plan.image.virtual_size_bytes is not None:
1707
+ image_lines.append(f" virtual size: {_format_mib(plan.image.virtual_size_bytes)}")
1708
+ target_lines = [
1709
+ f" target: {plan.target.path}",
1710
+ f" size: {_format_mib(plan.target.size_bytes)}",
1711
+ ]
1712
+ body = "[bold]Image[/]\n" + "\n".join(image_lines)
1713
+ body += "\n\n[bold]Target[/]\n" + "\n".join(target_lines)
1714
+ if errors:
1715
+ # Show WHY it was rejected -- the caller passes the reasons
1716
+ # but the panel used to drop them, leaving the operator with
1717
+ # a bare "rejected" and no clue (e.g. mounted partitions /
1718
+ # image-too-big / unrecognised format).
1719
+ body += f"\n\n[bold {_DANGER}]Rejected:[/]\n" + "\n".join(f" - {e}" for e in errors)
1720
+ border_style = _DANGER if errors else _OK
1721
+ title = "[red]Flash plan (rejected)[/]" if errors else "[green]Flash plan[/]"
1722
+ self._console.print(Panel(body, border_style=border_style, title=title))
1723
+
1724
+ def _render_prompt_line(
1725
+ self,
1726
+ *,
1727
+ extras: tuple[tuple[str, str], ...],
1728
+ title: str,
1729
+ ) -> str:
1730
+ """Build the prompt label shown by ``Prompt.ask``.
1731
+
1732
+ Layout: keybinding guide on the line above, action statement
1733
+ attached to the leader on the prompt line itself:
1734
+
1735
+ \\[k] label \\[k] label ... <- keybinding guide
1736
+ > <title>: _ <- leader + action + cursor
1737
+
1738
+ ``>`` marks the action point; the ``title`` immediately after
1739
+ describes the action (``Pick an image to flash``, ``Confirm
1740
+ flash plan``, ...). For screens whose input is a choice from
1741
+ a set (``y/b/q`` etc.), the options should live in ``extras``
1742
+ as keybindings -- the prompt line itself stays focused on the
1743
+ action, not on enumerating valid keys.
1744
+ """
1745
+ self._print_keybindings(extras)
1746
+ return f"[bold]>[/] [bold]{title}[/]"
1747
+
1748
+ def _print_keybindings(self, extras: tuple[tuple[str, str], ...]) -> None:
1749
+ """Print the secondary-key guide above the prompt.
1750
+
1751
+ Renders as one dim line: ``[k] label [k] label ...``.
1752
+ Skips printing when ``extras`` is empty (the confirm screens
1753
+ encode their keys in the choice_hint itself).
1754
+ """
1755
+ if not extras:
1756
+ return
1757
+ # ``\[[accent]K[/]] label`` -> renders as ``[K] label`` with K
1758
+ # in the accent colour. ``\[`` is Rich's escape for a literal
1759
+ # bracket; ``[/]`` closes the most-recent open tag.
1760
+ cells = [f"\\[[{_ACCENT}]{key}[/{_ACCENT}]] {label}" for key, label in extras]
1761
+ self._console.print(f"[{_MUTED}]" + " ".join(cells) + "[/]")
1762
+
1763
+ def _sanitize_tty(self) -> None:
1764
+ """Restore the controlling tty to canonical mode + echo before
1765
+ reading a prompt.
1766
+
1767
+ Companion to ``show_cursor(True)``: that fixes an invisible
1768
+ *cursor*, this fixes *dead input*. After a long flash (Rich
1769
+ ``Live`` region + ``curl``/``dd`` subprocesses writing to the
1770
+ same tty), the live env's framebuffer console / BMC KVM has
1771
+ been seen to come back with ``ECHO``/``ICANON`` cleared -- the
1772
+ operator types ``y``/Enter and nothing reaches ``input()``, so
1773
+ the post-flash reboot prompt looks wedged (the symptom that
1774
+ forced a Ctrl+Alt+Del). Re-asserting ICANON+ECHO+ISIG (and
1775
+ ICRNL so Enter delivers ``\\n``) makes the prompt readable
1776
+ again regardless of what disturbed the tty.
1777
+
1778
+ Best-effort: silently no-ops when stdin isn't a real tty
1779
+ (tests, piped input) or ``termios`` is unavailable.
1780
+ """
1781
+ try:
1782
+ import termios
1783
+ except ImportError:
1784
+ return
1785
+ with contextlib.suppress(OSError, termios.error, ValueError):
1786
+ fd = sys.stdin.fileno()
1787
+ if not os.isatty(fd):
1788
+ return
1789
+ attrs = termios.tcgetattr(fd)
1790
+ attrs[0] |= termios.ICRNL # iflag: CR -> NL so Enter submits
1791
+ attrs[3] |= ( # lflag: canonical line editing + echo + signals
1792
+ termios.ICANON | termios.ECHO | termios.ECHOE | termios.ECHOK | termios.ISIG
1793
+ )
1794
+ termios.tcsetattr(fd, termios.TCSADRAIN, attrs)
1795
+
1796
+ def _ask(self, prompt_text: str) -> str:
1797
+ """Single-line prompt with a leading newline so it's clearly
1798
+ separated from the rendered panel above. ``show_default=False``
1799
+ suppresses Rich's ``()`` annotation after the prompt label --
1800
+ the empty-string default is still honoured (Enter returns
1801
+ ``""`` which the screens map to ``refresh``); we just don't
1802
+ render the parens.
1803
+
1804
+ Belt-and-braces ``show_cursor(True)`` + ``_sanitize_tty()``
1805
+ before every prompt: a prior screen's Rich ``Live`` region
1806
+ (the flash progress bar) plus the flash subprocesses can
1807
+ leave the live env's framebuffer console / BMC KVM with the
1808
+ cursor hidden AND input echo/canonical-mode disabled. The
1809
+ operator then types at an invisible cursor (or types and
1810
+ nothing happens) and thinks the TUI is wedged. Re-asserting
1811
+ both costs two cheap syscalls and removes the failure mode.
1812
+ """
1813
+ self._console.show_cursor(True)
1814
+ self._sanitize_tty()
1815
+ try:
1816
+ answer = (
1817
+ Prompt.ask(
1818
+ prompt_text,
1819
+ console=self._console,
1820
+ default="",
1821
+ show_default=False,
1822
+ )
1823
+ .strip()
1824
+ .lower()
1825
+ )
1826
+ except (EOFError, KeyboardInterrupt):
1827
+ return "q"
1828
+ return answer
1829
+
1830
+ def _pause_for_ack(self) -> None:
1831
+ """Tiny ``press Enter to continue``. Used after an error
1832
+ message so the operator sees it before the screen redraws.
1833
+ """
1834
+ self._sanitize_tty()
1835
+ with contextlib.suppress(EOFError, KeyboardInterrupt):
1836
+ Prompt.ask(
1837
+ f"[{_MUTED}](press Enter to continue)[/]",
1838
+ console=self._console,
1839
+ default="",
1840
+ show_default=False,
1841
+ )
1842
+
1843
+ # ---------- model helpers (probe, plan, post) ---------------------
1844
+
1845
+ def _refresh_images(self) -> None:
1846
+ """Combine local + (optional) catalog overlay into one
1847
+ sorted list. Catalog load errors surface via
1848
+ ``self._catalog_load_error``.
1849
+
1850
+ Prints a one-line ``loading catalog ...`` indicator BEFORE
1851
+ the blocking fetch so an operator on a slow / broken network
1852
+ sees where the wait is going. On a healthy LAN the fetch
1853
+ finishes inside a second and the indicator scrolls past
1854
+ instantly; on a stuck DNS / slow server it tells the
1855
+ operator the box is waiting on the network, not wedged.
1856
+ """
1857
+ local = _list_local_images(self._state.image_root)
1858
+ remote: list[_TuiImage] = []
1859
+ self._catalog_load_error = None
1860
+ if self._state.catalog_source:
1861
+ self._console.print(
1862
+ f"[{_MUTED}]loading catalog from {self._state.catalog_source} (timeout 30s) ...[/]"
1863
+ )
1864
+ try:
1865
+ remote = load_catalog_from_source(self._state.catalog_source)
1866
+ except (
1867
+ _catalog.CatalogError,
1868
+ urllib.error.URLError,
1869
+ OSError,
1870
+ ValueError,
1871
+ ) as exc:
1872
+ self._catalog_load_error = f"{type(exc).__name__}: {exc}"
1873
+ # Dedup local + remote by the canonical ``bty_image_ref``
1874
+ # (``sha256(canonicalise_src(url))``). Local rows take
1875
+ # precedence: a catalog entry (operator-set --catalog overlay)
1876
+ # and a catalog entry that target the SAME upstream
1877
+ # (typically ``oras://...``) collapse to one row instead of
1878
+ # showing both. Plain local image files without a ``url``
1879
+ # (raw ``.img.gz`` etc. dropped into PIXIE_IMAGES) skip the
1880
+ # dedup -- they're unique by filesystem identity.
1881
+ seen_refs: set[str] = set()
1882
+ merged: list[_TuiImage] = []
1883
+ for img in local:
1884
+ if img.url:
1885
+ # Malformed URL (not http/https/oras/file): let the
1886
+ # row through but don't gate dedup on it.
1887
+ with contextlib.suppress(ValueError):
1888
+ seen_refs.add(_catalog.image_ref_for_src(img.url))
1889
+ merged.append(img)
1890
+ for img in remote:
1891
+ if img.url:
1892
+ try:
1893
+ ref = _catalog.image_ref_for_src(img.url)
1894
+ except ValueError:
1895
+ merged.append(img)
1896
+ continue
1897
+ if ref in seen_refs:
1898
+ continue
1899
+ seen_refs.add(ref)
1900
+ merged.append(img)
1901
+ self._state._images = merged
1902
+
1903
+ def _refresh_disks(self) -> None:
1904
+ self._state._disks = _list_disks()
1905
+
1906
+ def _parse_index(self, choice: str, n: int) -> int | None:
1907
+ """Parse a 1-based numeric choice into a 0-based list index.
1908
+ Returns ``None`` for non-numeric / out-of-range input.
1909
+ """
1910
+ if not choice:
1911
+ return None
1912
+ try:
1913
+ idx = int(choice) - 1
1914
+ except ValueError:
1915
+ return None
1916
+ if 0 <= idx < n:
1917
+ return idx
1918
+ return None
1919
+
1920
+ def _describe_index_miss(self, choice: str, n: int, kind: str) -> str:
1921
+ """Compose a context-specific error message for a failed index parse.
1922
+
1923
+ Distinguishes "empty list" (catalog or disk inventory has no rows --
1924
+ no number is valid), "out of range" (a number was typed but the
1925
+ list is shorter), and "not a number" (everything else).
1926
+ ``kind`` is the singular noun the screen is selecting (``"image"``
1927
+ or ``"disk"``); ``n`` is the current list length.
1928
+ """
1929
+ # Backslash-escape ``[k]`` brackets so Rich renders them as
1930
+ # literal text rather than swallowing them as unknown style
1931
+ # tags (the strings below all flow through the
1932
+ # ``[{_DANGER}]...[/]`` wrapper at the call site).
1933
+ if n == 0:
1934
+ if kind == "image":
1935
+ return (
1936
+ f"No images available; {choice!r} can't pick one. "
1937
+ f"Press Enter to re-scan, \\[c] for a custom catalog source, "
1938
+ f"or \\[d] for the pixie default catalog."
1939
+ )
1940
+ return (
1941
+ f"No disks available; {choice!r} can't pick one. "
1942
+ f"Press Enter to re-scan, or check that a target disk is attached."
1943
+ )
1944
+ if choice.lstrip("-").isdigit():
1945
+ return f"{choice!r} is out of range; valid {kind} numbers are 1..{n}."
1946
+ return f"Unrecognised choice {choice!r}; type a number 1..{n} or one of the listed keys."
1947
+
1948
+ def _probe_and_plan(
1949
+ self,
1950
+ image: _TuiImage,
1951
+ disk_path: Path,
1952
+ ) -> tuple[flash.FlashPlan, list[str]] | str:
1953
+ """Probe both ends + build + validate. Returns ``(plan,
1954
+ errors)`` on success, or a string error message on probe
1955
+ failure (image URL unreachable, target gone, etc.).
1956
+
1957
+ Rendered with a Rich Status spinner so the 1-3s of
1958
+ subprocess calls don't look like a wedge.
1959
+ """
1960
+ from rich.status import Status
1961
+
1962
+ with Status(
1963
+ f"[{_ACCENT}]probing image + target ...[/]",
1964
+ console=self._console,
1965
+ ):
1966
+ try:
1967
+ if image.url is not None:
1968
+ image_info = flash.probe_image_url(
1969
+ image.url, format_hint=image.fmt, expected_sha=image.sha
1970
+ )
1971
+ else:
1972
+ assert image.path is not None # local row guarantees a path
1973
+ image_info = flash.probe_image(image.path)
1974
+ except (FileNotFoundError, ValueError) as exc:
1975
+ return f"image probe failed: {exc}"
1976
+
1977
+ try:
1978
+ target_info = flash.probe_target(disk_path)
1979
+ except (FileNotFoundError, ValueError) as exc:
1980
+ return f"target probe failed: {exc}"
1981
+
1982
+ plan = flash.make_plan(image_info, target_info)
1983
+ errors = flash.validate_plan(plan)
1984
+ return plan, errors
1985
+
1986
+ def _register_uefi_boot_entry(self, plan: flash.FlashPlan) -> None:
1987
+ """After a successful flash, optionally register a UEFI NVRAM
1988
+ boot entry for the freshly-written disk.
1989
+
1990
+ OFF by default (opt in via ``PIXIE_REGISTER_UEFI_BOOT``): most
1991
+ firmware boots the flashed disk on its own, and touching NVRAM
1992
+ proved risky on server boards. When enabled it's best-effort and
1993
+ UEFI-only (no-op on BIOS); the outcome is printed to the console
1994
+ and never blocks the post-flash transition.
1995
+ """
1996
+ if not _uefi_boot_registration_enabled():
1997
+ return
1998
+ try:
1999
+ msg = flash.register_uefi_boot_entry(plan.target.path)
2000
+ except Exception as exc: # boot-entry setup must never fail the flash
2001
+ self._console.print(
2002
+ f"[{_DANGER}]pixie: could not register UEFI boot entry: {exc}[/] "
2003
+ f"[{_MUTED}](flash succeeded; firmware may not boot the disk)[/]"
2004
+ )
2005
+ return
2006
+ style = _OK if msg.startswith("registered") else _MUTED
2007
+ self._console.print(f"[{style}]pixie: {msg}[/]")
2008
+
2009
+ def _post_pxe_done_if_configured(self) -> None:
2010
+ """Best-effort: POST ``/pxe/<mac>/done`` after a successful
2011
+ flash so the pixie server's last_flashed_at + pixie-flash-once
2012
+ flip can fire. Failure is logged via the soft banner; does
2013
+ NOT block the post-flash transition (lesson from v0.20.1).
2014
+ """
2015
+ if self._state.pxe_done_base is None or self._state.mac is None:
2016
+ return
2017
+ try:
2018
+ post_pxe_status(self._state.pxe_done_base, self._state.mac, "done")
2019
+ except urllib.error.URLError as exc:
2020
+ self._console.print(
2021
+ f"[{_DANGER}]post-flash signal failed:[/] {exc} "
2022
+ f"[{_MUTED}](flash succeeded; pixie didn't update)[/]"
2023
+ )
2024
+
2025
+ def _post_inventory_sync(self) -> None:
2026
+ """Post the disk inventory and block until it completes (or
2027
+ fails), so a ``mode=inventory`` boot doesn't reboot before
2028
+ pixie has the disks. Best-effort (the box reboots either way),
2029
+ but the outcome is printed to the console so a failed post isn't
2030
+ invisible -- a silent swallow here used to leave operators with
2031
+ a box that re-armed the ipxe-exit chain yet reported no disks."""
2032
+ if self._state.pxe_done_base is None or self._state.mac is None:
2033
+ return
2034
+ base = self._state.pxe_done_base
2035
+ try:
2036
+ payload = disks.list_disks()
2037
+ except (FileNotFoundError, subprocess.SubprocessError, OSError) as exc:
2038
+ self._console.print(f"[{_DANGER}]pixie: lsblk failed; no inventory to post: {exc}[/]")
2039
+ return
2040
+ lshw = collect_lshw()
2041
+ try:
2042
+ post_inventory(base, self._state.mac, payload, lshw=lshw)
2043
+ self._console.print(
2044
+ f"[{_OK}]pixie: posted inventory -- {len(payload)} disk(s)"
2045
+ f"{', + lshw' if lshw is not None else ', no lshw'} -> {base}[/]"
2046
+ )
2047
+ except (urllib.error.URLError, ConnectionError, TimeoutError, OSError) as exc:
2048
+ self._console.print(
2049
+ f"[{_DANGER}]pixie: inventory POST to {base}/pxe/{self._state.mac}/inventory "
2050
+ f"FAILED: {exc}[/]"
2051
+ )
2052
+
2053
+ def _auto_post_inventory(self) -> None:
2054
+ """Background-thread post of the disk inventory so a slow
2055
+ pixie doesn't delay the first paint.
2056
+ """
2057
+ if self._state.pxe_done_base is None or self._state.mac is None:
2058
+ return
2059
+ base = self._state.pxe_done_base
2060
+ mac = self._state.mac
2061
+
2062
+ def _runner() -> None:
2063
+ try:
2064
+ payload = disks.list_disks()
2065
+ except (FileNotFoundError, subprocess.SubprocessError, OSError):
2066
+ return
2067
+ lshw = collect_lshw()
2068
+ with contextlib.suppress(urllib.error.URLError, ConnectionError, TimeoutError):
2069
+ post_inventory(base, mac, payload, lshw=lshw)
2070
+
2071
+ t = threading.Thread(target=_runner, name="pixie-inventory", daemon=True)
2072
+ t.start()
2073
+
2074
+ def _do_reboot(self) -> None:
2075
+ self._console.print(f"[{_ACCENT}]Rebooting now ...[/]")
2076
+ # ``systemctl reboot`` returns promptly after handing off to
2077
+ # systemd; bound it so a wedged systemd can't hang the live env
2078
+ # at the flash-done screen forever (the box reboots out from
2079
+ # under us on success, so a generous cap is fine).
2080
+ with contextlib.suppress(FileNotFoundError, OSError, subprocess.TimeoutExpired):
2081
+ subprocess.run(["systemctl", "reboot"], check=False, timeout=15)
2082
+
2083
+
2084
+ # ---------------------------------------------------------------------------
2085
+ # Module-level helpers used by the screens but exposed for test
2086
+ # isolation.
2087
+ # ---------------------------------------------------------------------------
2088
+
2089
+
2090
+ def _format_progress_bytes(written: int | None, total: int | None) -> str:
2091
+ """Format ``{written} / {total}`` in MiB. Either side may be
2092
+ None; ``_format_mib`` renders that (and negatives) as ``?``.
2093
+ """
2094
+ return f"{_format_mib(written)} / {_format_mib(total)}"
2095
+
2096
+
2097
+ def _emit_console_marker(line: str, *, local_tty: bool = True) -> None:
2098
+ """Write a chain-test marker line to every kernel console.
2099
+
2100
+ The PXE chain test (cijoe/configs/test-pxe.toml) reads the
2101
+ client VM's QEMU serial log -- which is whatever the kernel
2102
+ cmdline names with the LAST ``console=ttyS*,115200`` token.
2103
+ Writing only to ``stderr`` would stay on /dev/tty1 because
2104
+ pixie-on-tty1.service routes ``StandardError=tty`` ->
2105
+ ``TTYPath=/dev/tty1``.
2106
+
2107
+ Three sinks, each best-effort (any can be missing or
2108
+ unwritable on a workstation run):
2109
+
2110
+ * ``stderr`` -- the operator-facing path; appears on /dev/tty1
2111
+ under the service, on the terminal under a bare ``pixie`` run.
2112
+ * ``/dev/kmsg`` -- the kernel log device. Writes here go
2113
+ through ``printk`` which broadcasts to ALL registered
2114
+ consoles regardless of which one /dev/console happens to
2115
+ resolve to. This is the one the chain test actually needs
2116
+ when the cmdline lists more than one ``console=ttyS*`` and
2117
+ Linux's preferred-console-vs-/dev/console picks an
2118
+ unexpected sink. Prefixed with a syslog priority so kmsg
2119
+ accepts it as a single line.
2120
+ * ``/dev/console`` -- the historical path; kept because on a
2121
+ single-serial-console live env it's the most direct route
2122
+ to the captured log and one fewer hop than printk.
2123
+
2124
+ ``local_tty=False`` skips the stderr + /dev/console sinks and
2125
+ leaves only /dev/kmsg active. Used by the in-flash milestone
2126
+ emitter so its lines don't scramble the Rich Live progress
2127
+ bar painted on the same /dev/tty1 that stderr resolves to
2128
+ under pixie-on-tty1.service. The kmsg path still fans out via
2129
+ ``printk`` to every registered serial console, which is what
2130
+ a SoL / IPMI observer needs.
2131
+ """
2132
+ if local_tty:
2133
+ print(line, file=sys.stderr, flush=True)
2134
+ # ``<6>`` = LOG_INFO; ``/dev/kmsg`` parses a leading
2135
+ # ``<prio>`` token so the line shows up as a normal kernel-log
2136
+ # entry on every registered console without raising the log
2137
+ # level. Without the prefix the write still succeeds but the
2138
+ # priority defaults to LOG_NOTICE.
2139
+ with contextlib.suppress(OSError), open("/dev/kmsg", "w", encoding="utf-8") as kmsg:
2140
+ kmsg.write("<6>" + line + "\n")
2141
+ if local_tty:
2142
+ with contextlib.suppress(OSError), open("/dev/console", "w", encoding="utf-8") as console:
2143
+ console.write(line + "\n")
2144
+ console.flush()
2145
+
2146
+
2147
+ class _MilestoneEmitter:
2148
+ """Emit a percentage milestone marker once per 25 / 50 / 75 / 100
2149
+ crossing, via :func:`_emit_console_marker`.
2150
+
2151
+ The flash progress callback fires many times per second. Calling
2152
+ ``_emit_console_marker`` on every event would flood the kernel log
2153
+ and the SoL stream. This helper fires AT MOST four times per stage
2154
+ (25, 50, 75, 100), at the first event that crosses each boundary.
2155
+
2156
+ Skipped silently when the total is unknown (``None`` or ``<= 0``)
2157
+ -- some write paths (gzip, qcow2) can't pre-compute the post-
2158
+ decompression size, so we emit just the ``starting`` / ``complete``
2159
+ bookends in that case. Cheap enough to construct unconditionally.
2160
+ """
2161
+
2162
+ def __init__(self, stage: str) -> None:
2163
+ self._stage = stage
2164
+ # Milestones to fire, in order. Popped from the front as
2165
+ # each one fires so a single ``update`` that jumps past two
2166
+ # thresholds at once still emits both in order.
2167
+ self._pending = [25, 50, 75, 100]
2168
+
2169
+ def update(self, done: int, total: int | None) -> None:
2170
+ if not total or total <= 0:
2171
+ return
2172
+ pct = min(100, (done * 100) // total)
2173
+ while self._pending and pct >= self._pending[0]:
2174
+ # Write the milestone DIRECTLY to /dev/console, which
2175
+ # resolves to the LAST ``console=`` cmdline target
2176
+ # (ttyS0 on every pixie cmdline -- USB, PXE, chain
2177
+ # test). That means the bytes hit the serial UART
2178
+ # only: a SoL / IPMI operator still sees the heartbeat
2179
+ # via the same UART they're watching for everything
2180
+ # else, and the chain test still captures it via
2181
+ # QEMU's ``-serial file:``.
2182
+ #
2183
+ # v0.55.11 routed milestones through /dev/kmsg to
2184
+ # reach all registered consoles, but kmsg goes
2185
+ # through ``printk`` which fans out to /dev/tty0 too.
2186
+ # The kernel timestamp + line text WAS landing on
2187
+ # the framebuffer console, just covered by Rich's
2188
+ # next paint within ~100ms -- invisible to the
2189
+ # operator's eye but enough to displace the
2190
+ # framebuffer cursor by one line. Rich's internal
2191
+ # cursor tracker then thought the top of its render
2192
+ # region was higher than it was, and the next bar
2193
+ # render landed below the prior one. Three milestones
2194
+ # = three stacked bar pairs.
2195
+ #
2196
+ # Direct /dev/console write skips printk entirely,
2197
+ # so /dev/tty0 (and Rich's render region) is never
2198
+ # touched. Best-effort: a workstation run without
2199
+ # /dev/console writable swallows the OSError.
2200
+ line = f"pixie: {self._stage} {self._pending[0]}%\n"
2201
+ with (
2202
+ contextlib.suppress(OSError),
2203
+ open("/dev/console", "w", encoding="utf-8") as console,
2204
+ ):
2205
+ console.write(line)
2206
+ console.flush()
2207
+ self._pending.pop(0)
2208
+
2209
+
2210
+ __all__ = [
2211
+ "BtyTui",
2212
+ "_TuiImage",
2213
+ "_WizardStage",
2214
+ "_format_mib",
2215
+ "_parse_size_to_bytes",
2216
+ "load_catalog_from_source",
2217
+ "post_inventory",
2218
+ "post_pxe_status",
2219
+ ]