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.
- pixie/__init__.py +20 -0
- pixie/_util.py +38 -0
- pixie/catalog/__init__.py +25 -0
- pixie/catalog/_fetcher.py +513 -0
- pixie/catalog/_routes.py +392 -0
- pixie/catalog/_schema.py +263 -0
- pixie/catalog/_store.py +234 -0
- pixie/deploy/__init__.py +26 -0
- pixie/deploy/_main.py +321 -0
- pixie/deploy/_templates.py +153 -0
- pixie/disks.py +85 -0
- pixie/events/__init__.py +28 -0
- pixie/events/_kinds.py +203 -0
- pixie/events/_log.py +210 -0
- pixie/events/_routes.py +42 -0
- pixie/exports/__init__.py +17 -0
- pixie/exports/_routes.py +211 -0
- pixie/exports/_store.py +156 -0
- pixie/exports/_supervisor.py +240 -0
- pixie/flash.py +1971 -0
- pixie/images.py +473 -0
- pixie/machines/__init__.py +16 -0
- pixie/machines/_routes.py +190 -0
- pixie/machines/_store.py +623 -0
- pixie/oras.py +547 -0
- pixie/pivot/__init__.py +180 -0
- pixie/pivot/nbdboot +348 -0
- pixie/pxe/__init__.py +19 -0
- pixie/pxe/_renderer.py +244 -0
- pixie/pxe/_routes.py +386 -0
- pixie/tftp/__init__.py +21 -0
- pixie/tftp/_supervisor.py +129 -0
- pixie/tui/__init__.py +185 -0
- pixie/tui/_app.py +2219 -0
- pixie/tui_catalog.py +657 -0
- pixie/web/__init__.py +6 -0
- pixie/web/_auth.py +70 -0
- pixie/web/_settings_store.py +211 -0
- pixie/web/_static/.gitkeep +0 -0
- pixie/web/_static/bootstrap-icons.min.css +5 -0
- pixie/web/_static/bootstrap.min.css +12 -0
- pixie/web/_static/fonts/bootstrap-icons.woff +0 -0
- pixie/web/_static/fonts/bootstrap-icons.woff2 +0 -0
- pixie/web/_static/htmx.min.js +1 -0
- pixie/web/_static/pixie-favicon.png +0 -0
- pixie/web/_static/sse.js +290 -0
- pixie/web/_table_state.py +261 -0
- pixie/web/_templates/_partials/page_description.html +22 -0
- pixie/web/_templates/_partials/recent_events.html +47 -0
- pixie/web/_templates/_partials/table_helpers.html +189 -0
- pixie/web/_templates/catalog.html +364 -0
- pixie/web/_templates/catalog_detail.html +386 -0
- pixie/web/_templates/dashboard.html +256 -0
- pixie/web/_templates/events.html +125 -0
- pixie/web/_templates/ipxe/bootstrap.j2 +12 -0
- pixie/web/_templates/ipxe/exit.j2 +10 -0
- pixie/web/_templates/ipxe/nbdboot.j2 +57 -0
- pixie/web/_templates/ipxe/pixie-live-env.j2 +55 -0
- pixie/web/_templates/ipxe/unavailable.j2 +14 -0
- pixie/web/_templates/layout.html +345 -0
- pixie/web/_templates/login.html +40 -0
- pixie/web/_templates/machine_detail.html +786 -0
- pixie/web/_templates/machines.html +186 -0
- pixie/web/_templates/settings.html +265 -0
- pixie/web/main.py +1910 -0
- pixie_lab-0.1.0.dist-info/METADATA +107 -0
- pixie_lab-0.1.0.dist-info/RECORD +70 -0
- pixie_lab-0.1.0.dist-info/WHEEL +4 -0
- pixie_lab-0.1.0.dist-info/entry_points.txt +3 -0
- 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
|
+
]
|