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
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""TftpServer: manage one ``in.tftpd`` child.
|
|
2
|
+
|
|
3
|
+
Argv shape:
|
|
4
|
+
|
|
5
|
+
in.tftpd --foreground --listen --address <bind>:<port> --secure <root>
|
|
6
|
+
|
|
7
|
+
``--listen`` runs in daemon-listener mode (not inetd); ``--foreground``
|
|
8
|
+
keeps it as a supervised child so our poll() sees it live (without it,
|
|
9
|
+
tftpd-hpa double-forks and the parent exits rc=0, which our start()
|
|
10
|
+
grace period would mis-flag as a startup failure); ``--secure`` chroots
|
|
11
|
+
into ``<root>`` so a malformed RRQ can't wander outside the served
|
|
12
|
+
directory.
|
|
13
|
+
|
|
14
|
+
The wrapper is deliberately narrow -- no reload, no diff-sync, no
|
|
15
|
+
multi-server. TFTP is boot-time-critical + operator-visible; a single
|
|
16
|
+
supervised in.tftpd is the correct shape.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import contextlib
|
|
22
|
+
import logging
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
_log = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
DEFAULT_TFTP_BIN = "in.tftpd"
|
|
32
|
+
# NBPs get baked into the container image at build time (Containerfile
|
|
33
|
+
# copies undionly.kpxe + ipxe.efi from the ipxe package into this path).
|
|
34
|
+
# Runtime code should treat this path as read-only.
|
|
35
|
+
DEFAULT_TFTP_ROOT = Path("/usr/share/pixie/tftp")
|
|
36
|
+
DEFAULT_TFTP_PORT = 69
|
|
37
|
+
_SPAWN_STARTUP_GRACE = 0.2 # seconds -- give in.tftpd a moment to bind or fail
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TftpServer:
|
|
41
|
+
"""Wraps a single ``in.tftpd`` child. Thread-safe.
|
|
42
|
+
|
|
43
|
+
* ``bind`` -- interface to listen on (default ``0.0.0.0``).
|
|
44
|
+
* ``port`` -- UDP port (default 69). Non-root callers must
|
|
45
|
+
override; udp/69 requires ``CAP_NET_BIND_SERVICE`` or root.
|
|
46
|
+
* ``root`` -- directory served. Callers guarantee this exists
|
|
47
|
+
+ is readable; the class does not create it.
|
|
48
|
+
* ``bin`` -- path to the ``in.tftpd`` binary. Overridable for
|
|
49
|
+
unit tests that pretend they have a tftp server.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
bind: str = "0.0.0.0",
|
|
56
|
+
port: int = DEFAULT_TFTP_PORT,
|
|
57
|
+
root: Path = DEFAULT_TFTP_ROOT,
|
|
58
|
+
bin: str = DEFAULT_TFTP_BIN,
|
|
59
|
+
) -> None:
|
|
60
|
+
self.bind = bind
|
|
61
|
+
self.port = port
|
|
62
|
+
self.root = Path(root)
|
|
63
|
+
self.bin = bin
|
|
64
|
+
self._proc: subprocess.Popen[bytes] | None = None
|
|
65
|
+
self._lock = threading.Lock()
|
|
66
|
+
|
|
67
|
+
def start(self) -> None:
|
|
68
|
+
"""Spawn ``in.tftpd``. Idempotent: no-op if already running.
|
|
69
|
+
|
|
70
|
+
Raises :class:`RuntimeError` if the binary is missing, the
|
|
71
|
+
root directory does not exist, or the child exits within the
|
|
72
|
+
spawn grace period.
|
|
73
|
+
"""
|
|
74
|
+
with self._lock:
|
|
75
|
+
if self._proc is not None and self._proc.poll() is None:
|
|
76
|
+
return
|
|
77
|
+
self._proc = None
|
|
78
|
+
|
|
79
|
+
if not self.root.is_dir():
|
|
80
|
+
raise RuntimeError(f"tftp root does not exist: {self.root!s}")
|
|
81
|
+
|
|
82
|
+
argv = [
|
|
83
|
+
self.bin,
|
|
84
|
+
# ``--foreground`` is essential -- without it,
|
|
85
|
+
# tftpd-hpa's ``--listen`` mode daemonises (double-forks
|
|
86
|
+
# + parent exits rc=0) and our supervisor mis-detects
|
|
87
|
+
# the immediate parent-exit as a startup failure even
|
|
88
|
+
# though the grandchild is happily bound to udp/69.
|
|
89
|
+
# Verified live 2026-07-14 on 10.20.30.10 booting a
|
|
90
|
+
# real UEFI target.
|
|
91
|
+
"--foreground",
|
|
92
|
+
"--listen",
|
|
93
|
+
"--address",
|
|
94
|
+
f"{self.bind}:{self.port}",
|
|
95
|
+
"--secure",
|
|
96
|
+
str(self.root),
|
|
97
|
+
]
|
|
98
|
+
_log.info("tftp spawn: %s", " ".join(argv))
|
|
99
|
+
try:
|
|
100
|
+
proc = subprocess.Popen(argv, stdout=sys.stderr, stderr=sys.stderr)
|
|
101
|
+
except FileNotFoundError as exc:
|
|
102
|
+
raise RuntimeError(f"tftp binary not found: {self.bin!r}") from exc
|
|
103
|
+
|
|
104
|
+
time.sleep(_SPAWN_STARTUP_GRACE)
|
|
105
|
+
if proc.poll() is not None:
|
|
106
|
+
rc = proc.returncode
|
|
107
|
+
raise RuntimeError(
|
|
108
|
+
f"in.tftpd exited immediately (rc={rc}, bind={self.bind}:{self.port}, "
|
|
109
|
+
f"root={self.root!s}); check binary exists + port + root permissions"
|
|
110
|
+
)
|
|
111
|
+
self._proc = proc
|
|
112
|
+
|
|
113
|
+
def stop(self) -> None:
|
|
114
|
+
"""Terminate the child. Idempotent."""
|
|
115
|
+
with self._lock:
|
|
116
|
+
proc = self._proc
|
|
117
|
+
self._proc = None
|
|
118
|
+
if proc is None:
|
|
119
|
+
return
|
|
120
|
+
if proc.poll() is None:
|
|
121
|
+
proc.terminate()
|
|
122
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
123
|
+
proc.wait(timeout=3)
|
|
124
|
+
if proc.poll() is None:
|
|
125
|
+
proc.kill()
|
|
126
|
+
|
|
127
|
+
def is_running(self) -> bool:
|
|
128
|
+
with self._lock:
|
|
129
|
+
return self._proc is not None and self._proc.poll() is None
|
pixie/tui/__init__.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""pixie.tui - the pixie terminal interface.
|
|
2
|
+
|
|
3
|
+
Module name is historical (Rich-based wizard module); the
|
|
4
|
+
console script is ``pixie``. This module is intentionally
|
|
5
|
+
lightweight: it imports nothing from :mod:`rich` at module level
|
|
6
|
+
so an install without the ``[tui]`` extra can still ``import
|
|
7
|
+
pixie.tui`` for introspection without crashing. The actual Rich-
|
|
8
|
+
based app lives in :mod:`pixie.tui._app`, which is loaded only
|
|
9
|
+
when ``pixie`` is invoked.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
import pixie
|
|
18
|
+
|
|
19
|
+
# Default ``--server`` value for the wizard. ``pixie`` is the
|
|
20
|
+
# canonical LAN-DNS / mDNS hostname operators are encouraged to point
|
|
21
|
+
# at their pixie server, so ``pixie --mac X`` against a fresh box Just
|
|
22
|
+
# Works without any flags. Owned here (the [tui]-extra-free entry
|
|
23
|
+
# module) so the argparse default and ``BtyTui``'s constructor default
|
|
24
|
+
# can both depend on it without the import dragging in Rich.
|
|
25
|
+
DEFAULT_SERVER = "pixie"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main(argv: list[str] | None = None, *, prog: str = "pixie") -> None:
|
|
29
|
+
"""Console-script entry point for ``pixie``.
|
|
30
|
+
|
|
31
|
+
Defers loading the Rich-based app until invocation time so a
|
|
32
|
+
missing ``[tui]`` extra produces a clear "reinstall with extras"
|
|
33
|
+
message rather than a raw ``ModuleNotFoundError``.
|
|
34
|
+
|
|
35
|
+
The deploy-bootstrap surface lives in :mod:`pixie.deploy` (the
|
|
36
|
+
``pixie-lab init`` console script) so this script stays lean -- one
|
|
37
|
+
job, one wizard.
|
|
38
|
+
"""
|
|
39
|
+
parser = argparse.ArgumentParser(
|
|
40
|
+
prog=prog,
|
|
41
|
+
description=(
|
|
42
|
+
f"{prog}: flash images onto target disks, locally or via PXE. "
|
|
43
|
+
f"Three modes:\n\n"
|
|
44
|
+
f" {prog} - interactive wizard\n"
|
|
45
|
+
f" (local image-root only)\n"
|
|
46
|
+
f" {prog} --catalog <URL> - interactive wizard with\n"
|
|
47
|
+
f" the given catalog pre-loaded\n"
|
|
48
|
+
f" (equivalent to picking [c]\n"
|
|
49
|
+
f" on the source screen and\n"
|
|
50
|
+
f" typing the URL).\n"
|
|
51
|
+
f" {prog} --mac <MAC> - server-driven mode:\n"
|
|
52
|
+
f" fetches a plan from\n"
|
|
53
|
+
f" --server's /pxe/<MAC>/plan\n"
|
|
54
|
+
f" and acts on it (auto-flash,\n"
|
|
55
|
+
f" interactive, or local-boot,\n"
|
|
56
|
+
f" whatever the server says).\n\n"
|
|
57
|
+
"The operator-facing surface is intentionally narrow: in\n"
|
|
58
|
+
"server-driven mode every knob (image, target disk, catalog\n"
|
|
59
|
+
"overlay) comes from the pixie's machine record, not the\n"
|
|
60
|
+
"cmdline. --catalog is only useful for hand-driven runs.\n\n"
|
|
61
|
+
"To bootstrap a pixie + withcache + nbdmux container deploy,\n"
|
|
62
|
+
"use the sibling ``pixie-lab init`` script (runnable via\n"
|
|
63
|
+
"``uvx pixie-lab init`` without installing)."
|
|
64
|
+
),
|
|
65
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"--version",
|
|
69
|
+
action="version",
|
|
70
|
+
version=f"{prog} {pixie.__version__}",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--server",
|
|
74
|
+
type=str,
|
|
75
|
+
default=DEFAULT_SERVER,
|
|
76
|
+
help=f"pixie base URL or hostname. Default ``{DEFAULT_SERVER}`` "
|
|
77
|
+
"(operator convenience: pair with a LAN DNS entry pointing at "
|
|
78
|
+
"the pixie server and ``pixie --mac X`` just works). The netboot "
|
|
79
|
+
"and USB-PXE paths pass this explicitly via ``pixie.server=`` "
|
|
80
|
+
"on the kernel cmdline. Bare hostnames are accepted; missing "
|
|
81
|
+
"scheme defaults to ``http://``.",
|
|
82
|
+
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"--mac",
|
|
85
|
+
type=str,
|
|
86
|
+
default=None,
|
|
87
|
+
help="Self-MAC of this client (e.g. ``aa:bb:cc:dd:ee:ff``). "
|
|
88
|
+
"When supplied, pixie switches to server-driven mode: it "
|
|
89
|
+
"GETs ``<server>/pxe/<mac>/plan`` and dispatches on the "
|
|
90
|
+
"returned plan (auto-flash, interactive, or no-op). The "
|
|
91
|
+
"live env passes this via ``pixie.mac=`` on the kernel cmdline.",
|
|
92
|
+
)
|
|
93
|
+
parser.add_argument(
|
|
94
|
+
"--catalog",
|
|
95
|
+
type=str,
|
|
96
|
+
default=None,
|
|
97
|
+
help="Catalog URL or path to pre-load (http(s):// for HTTP, "
|
|
98
|
+
"oras:// for OCI, or a local path). When given, the SELECT_CATALOG "
|
|
99
|
+
"screen is skipped and the wizard jumps straight to SELECT_IMAGE "
|
|
100
|
+
"with this catalog overlaying the local image-root (equivalent "
|
|
101
|
+
"to picking ``[c]`` on the source screen and typing the URL). "
|
|
102
|
+
"Ignored in server-driven mode (``--mac`` set) because the server "
|
|
103
|
+
"supplies the catalog as part of /pxe/<mac>/plan.",
|
|
104
|
+
)
|
|
105
|
+
args = parser.parse_args(argv)
|
|
106
|
+
|
|
107
|
+
# Lifecycle progress -- the launch path has two slow phases an
|
|
108
|
+
# operator stares at without feedback otherwise:
|
|
109
|
+
#
|
|
110
|
+
# 1. ``from pixie.tui._app import BtyTui`` (1-3s): pulls Rich
|
|
111
|
+
# + pixie.catalog + pixie.flash + withcache.oras into the
|
|
112
|
+
# interpreter. On slower hardware (low-end mini-PCs, EPYC
|
|
113
|
+
# bringup boxes) this is several seconds of "blinking
|
|
114
|
+
# cursor".
|
|
115
|
+
# 2. ``BtyTui(...).run()`` -> the wizard prints its first
|
|
116
|
+
# header (Rich is no-alt-screen, so prior stderr output
|
|
117
|
+
# stays visible above the header). On the live env's
|
|
118
|
+
# framebuffer console first print is typically under a
|
|
119
|
+
# second after import.
|
|
120
|
+
#
|
|
121
|
+
# Print progress to stderr BEFORE the import + the run. The
|
|
122
|
+
# operator sees: wrapper banner (from /usr/local/sbin/pixie-on-tty1
|
|
123
|
+
# on the live env) -> these progress lines -> the pixie header.
|
|
124
|
+
# The blank-screen window narrows to a few hundred ms while
|
|
125
|
+
# Rich's Console initialises.
|
|
126
|
+
#
|
|
127
|
+
# Also mirror to ``/run/pixie.status`` so an operator who Alt-F2'd
|
|
128
|
+
# to tty2 can ``cat`` it without having to read tty1's transient
|
|
129
|
+
# output. ``/run`` is tmpfs on the live env so this is forgotten
|
|
130
|
+
# on reboot; cheap to write.
|
|
131
|
+
def _progress(msg: str) -> None:
|
|
132
|
+
line = f"{prog}: {msg}"
|
|
133
|
+
print(line, file=sys.stderr, flush=True)
|
|
134
|
+
try:
|
|
135
|
+
with open("/run/pixie.status", "a", encoding="utf-8") as f:
|
|
136
|
+
f.write(line + "\n")
|
|
137
|
+
except OSError:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
_progress(f"v{pixie.__version__} starting...")
|
|
141
|
+
_progress("loading UI dependencies (Rich)...")
|
|
142
|
+
try:
|
|
143
|
+
from pixie.tui._app import BtyTui
|
|
144
|
+
except ImportError as exc:
|
|
145
|
+
print(
|
|
146
|
+
f"{prog} {pixie.__version__}: required dependency is not installed "
|
|
147
|
+
f"({exc.name or exc}); reinstall with "
|
|
148
|
+
'`pipx install "pixie-lab[tui]"`',
|
|
149
|
+
file=sys.stderr,
|
|
150
|
+
)
|
|
151
|
+
sys.exit(1)
|
|
152
|
+
_progress("dependencies loaded")
|
|
153
|
+
if args.mac:
|
|
154
|
+
_progress(f"server-driven mode: server={args.server} mac={args.mac}")
|
|
155
|
+
_progress("starting interface (first paint may take a few seconds)...")
|
|
156
|
+
|
|
157
|
+
# Lifecycle bookends, broadcast via /dev/kmsg + /dev/console
|
|
158
|
+
# (the same fanout the chain-test markers + flash milestones
|
|
159
|
+
# use). The pair lets an operator on IPMI SoL, on the kernel
|
|
160
|
+
# serial log, or tailing ``journalctl -u pixie-on-tty1`` follow
|
|
161
|
+
# along regardless of which pixie mode runs (auto-flash from a
|
|
162
|
+
# plan, interactive wizard, USB-local). The mid-flight
|
|
163
|
+
# markers (``auto-flash starting``, ``download NN%``,
|
|
164
|
+
# ``write NN%``, ``flash complete; rebooting``) fire between
|
|
165
|
+
# these bookends as they always have.
|
|
166
|
+
#
|
|
167
|
+
# The import lives here, not at module top, because the
|
|
168
|
+
# missing-dep branch above must still produce a clean
|
|
169
|
+
# "reinstall with extras" message instead of crashing on the
|
|
170
|
+
# import itself; once we reach this line, ``_app`` is known
|
|
171
|
+
# to import cleanly.
|
|
172
|
+
from pixie.tui._app import _emit_console_marker
|
|
173
|
+
|
|
174
|
+
_emit_console_marker(f"pixie: entered v{pixie.__version__}")
|
|
175
|
+
try:
|
|
176
|
+
BtyTui(server=args.server, mac=args.mac, catalog=args.catalog).run()
|
|
177
|
+
finally:
|
|
178
|
+
# ``finally`` so the marker fires for every exit path:
|
|
179
|
+
# clean run, SystemExit from a sys.exit(N) deep inside
|
|
180
|
+
# the wizard, KeyboardInterrupt, an unhandled exception,
|
|
181
|
+
# and the post-flash ``_do_reboot`` (which returns
|
|
182
|
+
# promptly before systemd kills us). Best-effort under
|
|
183
|
+
# contextlib.suppress inside ``_emit_console_marker``;
|
|
184
|
+
# never raises.
|
|
185
|
+
_emit_console_marker(f"pixie: exiting v{pixie.__version__}")
|