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/pxe/_renderer.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Plan renderer: machine + catalog -> iPXE script.
|
|
2
|
+
|
|
3
|
+
Owns the boot-mode dispatch table and the nbdboot resolution:
|
|
4
|
+
|
|
5
|
+
* ``ipxe-exit`` -> ``ipxe/exit.j2`` unconditionally.
|
|
6
|
+
* ``nbdboot`` -> walk catalog[image_sha] -> netboot_src -> catalog[netboot_src]
|
|
7
|
+
to find the netboot-bundle catalog entry, use its ``content_sha256``
|
|
8
|
+
as the artifacts key, ensure an NBD export exists for the disk-image
|
|
9
|
+
blob, and render ``ipxe/nbdboot.j2`` with the resolved fields. If any
|
|
10
|
+
step fails (no bound image, netboot bundle not fetched, NBD spawn
|
|
11
|
+
refused) the renderer emits the ``unavailable.j2`` template with the
|
|
12
|
+
reason baked into the plan comment.
|
|
13
|
+
|
|
14
|
+
The renderer is pure (no side effects) apart from the NBD spawn --
|
|
15
|
+
which is idempotent per (name, blob_path), so a spurious render does
|
|
16
|
+
not accumulate exports.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
26
|
+
|
|
27
|
+
from pixie.catalog._schema import CatalogEntry
|
|
28
|
+
from pixie.catalog._store import CatalogStore
|
|
29
|
+
from pixie.exports._store import Export, ExportsStore
|
|
30
|
+
from pixie.exports._supervisor import NbdServer
|
|
31
|
+
from pixie.machines._store import LIVE_ENV_MODES, Machine
|
|
32
|
+
|
|
33
|
+
_log = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "web" / "_templates" / "ipxe"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# NBD name = ``pixie-`` + short sha. Short enough to type at a
|
|
39
|
+
# ``nbdinfo`` prompt, unique-per-content across machines.
|
|
40
|
+
def _export_name_for(image_sha: str) -> str:
|
|
41
|
+
return f"pixie-{image_sha[:12]}.img"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
DEFAULT_OVERLAY_SIZE = "10G"
|
|
45
|
+
|
|
46
|
+
# The pixie-* live-env boot modes are the store's
|
|
47
|
+
# :data:`LIVE_ENV_MODES` set; imported rather than re-authored so a
|
|
48
|
+
# new mode added to the store's :data:`BOOT_MODES` frontier can't
|
|
49
|
+
# fall through here silently (which used to render as
|
|
50
|
+
# ``unknown boot_mode``, indistinguishable from an unbounded
|
|
51
|
+
# operator typo).
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class RenderContext:
|
|
56
|
+
"""Everything the renderer needs to produce a plan for one MAC."""
|
|
57
|
+
|
|
58
|
+
host: str
|
|
59
|
+
port: int
|
|
60
|
+
nbd_host: str
|
|
61
|
+
overlay_size: str = DEFAULT_OVERLAY_SIZE
|
|
62
|
+
# Extra tokens appended verbatim to the pixie-live-env kernel
|
|
63
|
+
# cmdline. Operator-set via PIXIE_LIVE_ENV_EXTRA_CMDLINE.
|
|
64
|
+
# Intended for hardware-specific workarounds -- e.g. the
|
|
65
|
+
# GIGABYTE MC12-LE0 needs pci=nommconf to bring up its Intel
|
|
66
|
+
# i210 NICs under kernel 6.12 -- without a live-env rebake.
|
|
67
|
+
# Empty by default; the template shims it in unconditionally so
|
|
68
|
+
# no-op is a legal value.
|
|
69
|
+
extra_cmdline: str = ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class PlanRenderer:
|
|
73
|
+
"""Assemble iPXE plans. Constructed once at app-startup; called
|
|
74
|
+
per ``GET /pxe/<mac>``."""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
catalog: CatalogStore,
|
|
80
|
+
exports: ExportsStore,
|
|
81
|
+
nbd: NbdServer,
|
|
82
|
+
live_env_dir: Path | None = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
self._catalog = catalog
|
|
85
|
+
self._exports = exports
|
|
86
|
+
self._nbd = nbd
|
|
87
|
+
# Where the netboot-pc bake's vmlinuz + initrd + squashfs are
|
|
88
|
+
# staged on disk. When set + the three files exist, the
|
|
89
|
+
# ``pixie-*`` boot modes chain into the live env; otherwise
|
|
90
|
+
# they fall back to the ``unavailable`` plan so a bound
|
|
91
|
+
# target does not silently boot nothing.
|
|
92
|
+
self._live_env_dir = live_env_dir
|
|
93
|
+
self._env = Environment(
|
|
94
|
+
loader=FileSystemLoader(str(_TEMPLATES_DIR)),
|
|
95
|
+
autoescape=select_autoescape(disabled_extensions=("j2",)),
|
|
96
|
+
keep_trailing_newline=True,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def _live_env_ready(self) -> bool:
|
|
100
|
+
"""True iff the netboot-pc artifacts are staged on disk under
|
|
101
|
+
``self._live_env_dir``. Called per-render so an operator
|
|
102
|
+
dropping the files in without a pixie restart takes effect
|
|
103
|
+
on the next PXE hit."""
|
|
104
|
+
if self._live_env_dir is None:
|
|
105
|
+
return False
|
|
106
|
+
return all(
|
|
107
|
+
(self._live_env_dir / name).is_file() for name in ("vmlinuz", "initrd", "live.squashfs")
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def render(self, machine: Machine, ctx: RenderContext) -> str:
|
|
111
|
+
mode = machine.boot_mode
|
|
112
|
+
# Per-machine ``extra_cmdline`` fully overrides the deploy-wide
|
|
113
|
+
# default so an operator can pin a hardware-specific workaround
|
|
114
|
+
# to ONE quirky target without dragging every other machine's
|
|
115
|
+
# cmdline along with it. Blank per-machine value falls back to
|
|
116
|
+
# the ctx-carried (settings-then-env) default.
|
|
117
|
+
effective_extra = machine.extra_cmdline or ctx.extra_cmdline
|
|
118
|
+
if mode == "ipxe-exit":
|
|
119
|
+
return self._env.get_template("exit.j2").render(mac=machine.mac)
|
|
120
|
+
if mode == "nbdboot":
|
|
121
|
+
return self._render_nbdboot(machine, ctx, effective_extra)
|
|
122
|
+
if mode in LIVE_ENV_MODES:
|
|
123
|
+
if not self._live_env_ready():
|
|
124
|
+
# netboot-pc bake artifacts have not been staged on
|
|
125
|
+
# this deploy yet; degrade to the readable
|
|
126
|
+
# unavailable plan so a bound target lands on a
|
|
127
|
+
# legible screen instead of a bty-media initrd.
|
|
128
|
+
return self._unavailable(
|
|
129
|
+
machine,
|
|
130
|
+
f"boot_mode={mode!r} needs pixie live-env media; "
|
|
131
|
+
f"stage vmlinuz+initrd+squashfs under $PIXIE_LIVE_ENV_DIR",
|
|
132
|
+
)
|
|
133
|
+
return self._env.get_template("pixie-live-env.j2").render(
|
|
134
|
+
mac=machine.mac,
|
|
135
|
+
boot_mode=mode,
|
|
136
|
+
host=ctx.host,
|
|
137
|
+
port=ctx.port,
|
|
138
|
+
extra_cmdline=effective_extra,
|
|
139
|
+
)
|
|
140
|
+
# Unknown mode: refuse loudly rather than falling through.
|
|
141
|
+
return self._env.get_template("unavailable.j2").render(
|
|
142
|
+
mac=machine.mac,
|
|
143
|
+
reason=f"unknown boot_mode {mode!r}",
|
|
144
|
+
reason_slug="unknown-boot-mode",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def render_bootstrap(self, ctx: RenderContext) -> str:
|
|
148
|
+
return self._env.get_template("bootstrap.j2").render(host=ctx.host, port=ctx.port)
|
|
149
|
+
|
|
150
|
+
# ---------- nbdboot resolution ---------------------------------
|
|
151
|
+
|
|
152
|
+
def _render_nbdboot(self, machine: Machine, ctx: RenderContext, extra_cmdline: str = "") -> str:
|
|
153
|
+
image_sha = machine.image_content_sha256
|
|
154
|
+
if not image_sha:
|
|
155
|
+
return self._unavailable(
|
|
156
|
+
machine, "no image bound; set image_content_sha256 to a fetched entry"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
disk_entry = self._catalog_entry_by_sha(image_sha)
|
|
160
|
+
if disk_entry is None:
|
|
161
|
+
return self._unavailable(
|
|
162
|
+
machine,
|
|
163
|
+
f"no catalog entry with content_sha256={image_sha[:12]}; re-fetch it",
|
|
164
|
+
)
|
|
165
|
+
if not disk_entry.netboot_src:
|
|
166
|
+
return self._unavailable(
|
|
167
|
+
machine,
|
|
168
|
+
f"catalog entry {disk_entry.name!r} has no netboot_src; "
|
|
169
|
+
"advertise a sibling bundle before selecting nbdboot",
|
|
170
|
+
)
|
|
171
|
+
bundle_entry = self._catalog.get_entry_by_src(disk_entry.netboot_src)
|
|
172
|
+
if bundle_entry is None:
|
|
173
|
+
return self._unavailable(
|
|
174
|
+
machine,
|
|
175
|
+
f"netboot_src {disk_entry.netboot_src} has no catalog entry",
|
|
176
|
+
)
|
|
177
|
+
if not bundle_entry.content_sha256:
|
|
178
|
+
return self._unavailable(
|
|
179
|
+
machine,
|
|
180
|
+
f"netboot bundle {bundle_entry.name!r} not fetched yet",
|
|
181
|
+
)
|
|
182
|
+
artifact_dir = self._catalog.artifact_dir(bundle_entry.content_sha256)
|
|
183
|
+
if not (artifact_dir / "manifest.json").is_file():
|
|
184
|
+
return self._unavailable(
|
|
185
|
+
machine,
|
|
186
|
+
f"netboot bundle {bundle_entry.name!r} not unpacked "
|
|
187
|
+
f"(manifest.json missing under artifacts/{bundle_entry.content_sha256[:12]})",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Blob for the disk image must exist too; that's what the NBD
|
|
191
|
+
# export streams over the wire.
|
|
192
|
+
blob = self._catalog.blob_path(image_sha)
|
|
193
|
+
if not blob.is_file():
|
|
194
|
+
return self._unavailable(
|
|
195
|
+
machine,
|
|
196
|
+
f"disk image {disk_entry.name!r} blob missing on disk; re-fetch it",
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Ensure an NBD export for this content is running. Idempotent
|
|
200
|
+
# per name+blob.
|
|
201
|
+
export_name = _export_name_for(image_sha)
|
|
202
|
+
try:
|
|
203
|
+
port = self._ensure_export(export_name, image_sha, blob)
|
|
204
|
+
except RuntimeError as exc:
|
|
205
|
+
return self._unavailable(
|
|
206
|
+
machine, f"nbdkit refused to start for export {export_name!r}: {exc}"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
return self._env.get_template("nbdboot.j2").render(
|
|
210
|
+
mac=machine.mac,
|
|
211
|
+
host=ctx.host,
|
|
212
|
+
port=ctx.port,
|
|
213
|
+
nbd_host=ctx.nbd_host,
|
|
214
|
+
nbd_port=port,
|
|
215
|
+
nbd_name=export_name,
|
|
216
|
+
bundle_sha=bundle_entry.content_sha256,
|
|
217
|
+
overlay_size=ctx.overlay_size,
|
|
218
|
+
extra_cmdline=extra_cmdline,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def _unavailable(self, machine: Machine, reason: str) -> str:
|
|
222
|
+
_log.info("pxe %s unavailable: %s", machine.mac, reason)
|
|
223
|
+
slug = reason.split(";", 1)[0].replace(" ", "-").lower()[:60]
|
|
224
|
+
return self._env.get_template("unavailable.j2").render(
|
|
225
|
+
mac=machine.mac,
|
|
226
|
+
reason=reason,
|
|
227
|
+
reason_slug=slug,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def _catalog_entry_by_sha(self, sha: str) -> CatalogEntry | None:
|
|
231
|
+
for e in self._catalog.list_entries():
|
|
232
|
+
if e.content_sha256 == sha:
|
|
233
|
+
return e
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
def _ensure_export(self, name: str, image_sha: str, blob: Path) -> int:
|
|
237
|
+
"""Idempotent: register an export in the store + spawn nbdkit
|
|
238
|
+
if not already running; return its port."""
|
|
239
|
+
row = self._exports.get(name)
|
|
240
|
+
if row is None:
|
|
241
|
+
self._exports.upsert(Export(name=name, content_sha256=image_sha))
|
|
242
|
+
port = self._nbd.spawn(name, blob)
|
|
243
|
+
self._exports.update_runtime(name, nbd_port=port, status="running", error="")
|
|
244
|
+
return port
|
pixie/pxe/_routes.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""PXE + boot flow routes.
|
|
2
|
+
|
|
3
|
+
* ``GET /pxe-bootstrap.ipxe``: iPXE bootstrap that chain-loads the
|
|
4
|
+
per-MAC plan. Fetched over HTTP.
|
|
5
|
+
* ``GET /pxe/{mac}``: per-machine iPXE plan. Discovery-side write:
|
|
6
|
+
every hit upserts the row (creating on first contact) + refreshes
|
|
7
|
+
``last_seen_at``.
|
|
8
|
+
* ``POST /pxe/{mac}/inventory``: accepts a JSON body (``{"lshw":
|
|
9
|
+
..., "disks": [...]}``) from a live-env target that has just
|
|
10
|
+
collected its hardware inventory. Stores the blob on the machine
|
|
11
|
+
row; ``/ui/machines`` renders it.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import contextlib
|
|
17
|
+
import logging
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
21
|
+
from fastapi.responses import PlainTextResponse
|
|
22
|
+
|
|
23
|
+
from pixie.events._kinds import (
|
|
24
|
+
MACHINE_INVENTORY_UPDATED,
|
|
25
|
+
PXE_PLAN_RENDERED,
|
|
26
|
+
PXE_PLAN_UNAVAILABLE,
|
|
27
|
+
PXE_STATUS_RECEIVED,
|
|
28
|
+
)
|
|
29
|
+
from pixie.machines._store import BadMac, MachinesStore, normalise_mac
|
|
30
|
+
from pixie.pxe._renderer import PlanRenderer, RenderContext
|
|
31
|
+
|
|
32
|
+
_log = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
router = APIRouter()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _client_ip(request: Request) -> str:
|
|
38
|
+
"""Best-effort client IP for ``last_seen_ip``. Trusts
|
|
39
|
+
``X-Forwarded-For`` only when the caller is inside pixie's own
|
|
40
|
+
process (uvicorn), which is fine for LAN + is what bty did too.
|
|
41
|
+
"""
|
|
42
|
+
return (request.client.host if request.client else "") or ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _get_machines(request: Request) -> MachinesStore:
|
|
46
|
+
store: MachinesStore | None = getattr(request.app.state, "machines_store", None)
|
|
47
|
+
if store is None:
|
|
48
|
+
raise HTTPException(status_code=503, detail="machines store not initialised")
|
|
49
|
+
return store
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _get_renderer(request: Request) -> PlanRenderer:
|
|
53
|
+
r: PlanRenderer | None = getattr(request.app.state, "pxe_renderer", None)
|
|
54
|
+
if r is None:
|
|
55
|
+
raise HTTPException(status_code=503, detail="pxe renderer not initialised")
|
|
56
|
+
return r
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _render_context(request: Request) -> RenderContext:
|
|
60
|
+
"""Derive the base URL + NBD-facing host from the incoming request.
|
|
61
|
+
|
|
62
|
+
LAN-only trust model: whatever host the target used to reach us
|
|
63
|
+
is the host we tell it to keep using. Operators who front pixie
|
|
64
|
+
behind a proxy set ``PIXIE_PUBLIC_HOST`` to override.
|
|
65
|
+
"""
|
|
66
|
+
import os
|
|
67
|
+
|
|
68
|
+
override = (os.environ.get("PIXIE_PUBLIC_HOST") or "").strip()
|
|
69
|
+
# ``request.url.hostname`` is what iPXE resolved -- either the LAN
|
|
70
|
+
# IP or a hostname the operator configured DHCP to hand out. Falls
|
|
71
|
+
# back to 127.0.0.1 for uvicorn edge cases.
|
|
72
|
+
host = override or (request.url.hostname or "127.0.0.1")
|
|
73
|
+
port = request.url.port or 8080
|
|
74
|
+
nbd_host = (os.environ.get("PIXIE_NBD_PUBLIC_HOST") or "").strip() or host
|
|
75
|
+
# Extra tokens appended verbatim to the pixie-live-env kernel
|
|
76
|
+
# cmdline. Resolution chain lives on the settings store: /ui/settings
|
|
77
|
+
# override (persisted in state.db) -> $PIXIE_LIVE_ENV_EXTRA_CMDLINE
|
|
78
|
+
# (envvars-style pin) -> empty. Known-good values per hardware are
|
|
79
|
+
# documented in docs/src/hardware-quirks.md.
|
|
80
|
+
extra_cmdline = ""
|
|
81
|
+
settings = getattr(request.app.state, "settings_store", None)
|
|
82
|
+
if settings is not None:
|
|
83
|
+
extra_cmdline = settings.resolve_live_env_extra_cmdline()
|
|
84
|
+
return RenderContext(host=host, port=port, nbd_host=nbd_host, extra_cmdline=extra_cmdline)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@router.get("/pxe-bootstrap.ipxe", response_class=PlainTextResponse)
|
|
88
|
+
def pxe_bootstrap(request: Request) -> PlainTextResponse:
|
|
89
|
+
"""iPXE fetches this from the TFTP or HTTP chain to reach the
|
|
90
|
+
per-MAC plan. Deliberately independent of any machine row so a
|
|
91
|
+
new-to-pixie target never 404s on its first hop."""
|
|
92
|
+
ctx = _render_context(request)
|
|
93
|
+
body = _get_renderer(request).render_bootstrap(ctx)
|
|
94
|
+
return PlainTextResponse(body, media_type="text/plain")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@router.post("/pxe/{mac}/inventory", status_code=204)
|
|
98
|
+
async def pxe_inventory(request: Request, mac: str) -> PlainTextResponse:
|
|
99
|
+
"""Accept an inventory JSON body from a live-env target.
|
|
100
|
+
|
|
101
|
+
The body shape is what bty-tui (and now pixie-tui) POST after
|
|
102
|
+
``lshw -json`` + ``lsblk``: ``{"lshw": <object|list>, "disks":
|
|
103
|
+
[...]}``. We store the whole thing verbatim; the /ui/machines
|
|
104
|
+
page renders selected fields, the JSON API returns it as-is."""
|
|
105
|
+
try:
|
|
106
|
+
canon = normalise_mac(mac)
|
|
107
|
+
except BadMac as exc:
|
|
108
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
payload = await request.json()
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
raise HTTPException(status_code=400, detail=f"body must be JSON: {exc}") from exc
|
|
114
|
+
if not isinstance(payload, dict):
|
|
115
|
+
raise HTTPException(status_code=400, detail="body must be a JSON object")
|
|
116
|
+
|
|
117
|
+
machines = _get_machines(request)
|
|
118
|
+
machines.set_inventory(canon, payload)
|
|
119
|
+
|
|
120
|
+
log = getattr(request.app.state, "events_log", None)
|
|
121
|
+
if log is not None:
|
|
122
|
+
details: dict[str, Any] = {}
|
|
123
|
+
disks = payload.get("disks")
|
|
124
|
+
if isinstance(disks, list):
|
|
125
|
+
details["disks_count"] = len(disks)
|
|
126
|
+
details["has_lshw"] = payload.get("lshw") is not None
|
|
127
|
+
log.emit(
|
|
128
|
+
MACHINE_INVENTORY_UPDATED,
|
|
129
|
+
subject_kind="machine",
|
|
130
|
+
subject_id=canon,
|
|
131
|
+
summary=f"{canon} posted inventory",
|
|
132
|
+
details=details,
|
|
133
|
+
)
|
|
134
|
+
return PlainTextResponse("", status_code=204)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@router.get("/machines/{mac}/inventory")
|
|
138
|
+
def get_inventory(request: Request, mac: str) -> dict[str, Any]:
|
|
139
|
+
"""Return the stored inventory for a machine, or 404 if none has
|
|
140
|
+
been posted yet."""
|
|
141
|
+
try:
|
|
142
|
+
canon = normalise_mac(mac)
|
|
143
|
+
except BadMac as exc:
|
|
144
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
145
|
+
row = _get_machines(request).get(canon)
|
|
146
|
+
if row is None or not row.inventory:
|
|
147
|
+
raise HTTPException(status_code=404, detail=f"no inventory for {canon}")
|
|
148
|
+
return {
|
|
149
|
+
"mac": row.mac,
|
|
150
|
+
"inventory_at": row.inventory_at,
|
|
151
|
+
"inventory": row.inventory,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@router.get("/pxe/{mac}", response_class=PlainTextResponse)
|
|
156
|
+
def pxe_plan(request: Request, mac: str) -> PlainTextResponse:
|
|
157
|
+
"""Discovery + plan render. Every hit upserts the machine (creates
|
|
158
|
+
on first contact) and refreshes ``last_seen_at``; then the plan
|
|
159
|
+
is rendered per the current boot_mode."""
|
|
160
|
+
try:
|
|
161
|
+
canon = normalise_mac(mac)
|
|
162
|
+
except BadMac as exc:
|
|
163
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
164
|
+
|
|
165
|
+
machines = _get_machines(request)
|
|
166
|
+
row = machines.touch_seen(canon, ip=_client_ip(request))
|
|
167
|
+
|
|
168
|
+
ctx = _render_context(request)
|
|
169
|
+
body = _get_renderer(request).render(row, ctx)
|
|
170
|
+
# Every plan render emits one event so an operator can grep the
|
|
171
|
+
# log for "every time this MAC PXE'd + what mode it got". The
|
|
172
|
+
# unavailable variant is distinguished by the renderer emitting
|
|
173
|
+
# ``exit`` instead of a real chain -- the plan body carries the
|
|
174
|
+
# reason as a comment, which we extract for details.
|
|
175
|
+
log = getattr(request.app.state, "events_log", None)
|
|
176
|
+
if log is not None:
|
|
177
|
+
is_unavailable = "\nexit\n" in body and "unavailable" in body.lower()
|
|
178
|
+
if is_unavailable:
|
|
179
|
+
log.emit(
|
|
180
|
+
PXE_PLAN_UNAVAILABLE,
|
|
181
|
+
subject_kind="machine",
|
|
182
|
+
subject_id=canon,
|
|
183
|
+
summary=f"{canon}: boot_mode={row.boot_mode} not renderable",
|
|
184
|
+
details={"boot_mode": row.boot_mode},
|
|
185
|
+
)
|
|
186
|
+
else:
|
|
187
|
+
log.emit(
|
|
188
|
+
PXE_PLAN_RENDERED,
|
|
189
|
+
subject_kind="machine",
|
|
190
|
+
subject_id=canon,
|
|
191
|
+
summary=f"{canon}: boot_mode={row.boot_mode}",
|
|
192
|
+
details={"boot_mode": row.boot_mode},
|
|
193
|
+
)
|
|
194
|
+
# Always ``text/plain`` per bty's convention; iPXE parses on
|
|
195
|
+
# bytes, not on Content-Type.
|
|
196
|
+
return PlainTextResponse(body, media_type="text/plain")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@router.post("/pxe/{mac}/status", status_code=204)
|
|
200
|
+
async def pxe_status(request: Request, mac: str) -> PlainTextResponse:
|
|
201
|
+
"""Accept a status token from the target's initrd or live env.
|
|
202
|
+
|
|
203
|
+
Pixie.s ramboot initrd + pixie-tui both fire tokens like
|
|
204
|
+
``ramboot.up`` / ``ramboot.nbd_connect_failed`` / ``ramboot.die``
|
|
205
|
+
so an operator watching /ui/events sees the boot flow land or
|
|
206
|
+
fail. The body carries either ``status=<token>`` in a form or a
|
|
207
|
+
JSON object -- normalise both to a string.
|
|
208
|
+
"""
|
|
209
|
+
try:
|
|
210
|
+
canon = normalise_mac(mac)
|
|
211
|
+
except BadMac as exc:
|
|
212
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
213
|
+
ctype = (request.headers.get("content-type") or "").lower()
|
|
214
|
+
status_token = ""
|
|
215
|
+
if "application/json" in ctype:
|
|
216
|
+
try:
|
|
217
|
+
payload = await request.json()
|
|
218
|
+
except Exception:
|
|
219
|
+
payload = None
|
|
220
|
+
if isinstance(payload, dict):
|
|
221
|
+
status_token = str(payload.get("status") or "").strip()
|
|
222
|
+
else:
|
|
223
|
+
form = await request.form()
|
|
224
|
+
raw = form.get("status")
|
|
225
|
+
if isinstance(raw, str):
|
|
226
|
+
status_token = raw.strip()
|
|
227
|
+
if not status_token:
|
|
228
|
+
# Fall back to a raw body read for the wget-style
|
|
229
|
+
# ``--post-data=status=X`` shape busybox uses in the nbdboot
|
|
230
|
+
# initrd (application/x-www-form-urlencoded handled above,
|
|
231
|
+
# but old busybox drops the content-type header).
|
|
232
|
+
raw_body = (await request.body()).decode("utf-8", errors="replace").strip()
|
|
233
|
+
if raw_body.startswith("status="):
|
|
234
|
+
status_token = raw_body.split("=", 1)[1]
|
|
235
|
+
if not status_token:
|
|
236
|
+
raise HTTPException(status_code=400, detail="missing status token")
|
|
237
|
+
log = getattr(request.app.state, "events_log", None)
|
|
238
|
+
if log is not None:
|
|
239
|
+
log.emit(
|
|
240
|
+
PXE_STATUS_RECEIVED,
|
|
241
|
+
subject_kind="machine",
|
|
242
|
+
subject_id=canon,
|
|
243
|
+
summary=f"{canon}: {status_token}",
|
|
244
|
+
details={"status": status_token},
|
|
245
|
+
)
|
|
246
|
+
# pixie-flash-once completes -> flip to ipxe-exit so the next PXE
|
|
247
|
+
# boot loads the disk. pixie-flash-always keeps re-arming; the
|
|
248
|
+
# operator explicitly picked "flash every boot". Any other status
|
|
249
|
+
# (started, failed, etc.) or boot_mode is a pure event emit.
|
|
250
|
+
if status_token == "done":
|
|
251
|
+
machines = _get_machines(request)
|
|
252
|
+
row = machines.get(canon)
|
|
253
|
+
if row is not None and row.boot_mode == "pixie-flash-once":
|
|
254
|
+
# Guard would trip if inventory is missing on the row
|
|
255
|
+
# (shouldn't happen; getting here required a flash-once
|
|
256
|
+
# bind which itself passed the guard). Swallow so the
|
|
257
|
+
# /done POST still returns 204.
|
|
258
|
+
with contextlib.suppress(ValueError):
|
|
259
|
+
machines.upsert_binding(
|
|
260
|
+
canon,
|
|
261
|
+
boot_mode="ipxe-exit",
|
|
262
|
+
image_content_sha256=row.image_content_sha256,
|
|
263
|
+
labels=list(row.labels),
|
|
264
|
+
target_disk_serial=row.target_disk_serial,
|
|
265
|
+
)
|
|
266
|
+
return PlainTextResponse("", status_code=204)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@router.get("/pxe/{mac}/plan")
|
|
270
|
+
def pxe_plan_json(request: Request, mac: str) -> dict[str, Any]:
|
|
271
|
+
"""JSON plan the LIVE-ENV pixie CLI GETs after boot.
|
|
272
|
+
|
|
273
|
+
Distinct from ``GET /pxe/{mac}`` (which returns iPXE): once the
|
|
274
|
+
live env has booted and the pixie CLI is up on tty1, it GETs
|
|
275
|
+
THIS endpoint to figure out what to do:
|
|
276
|
+
|
|
277
|
+
``mode=exit`` -> nothing to do here, exit cleanly.
|
|
278
|
+
``mode=inventory`` -> POST /pxe/{mac}/inventory + reboot.
|
|
279
|
+
``mode=interactive`` -> drop the operator into the wizard.
|
|
280
|
+
``mode=flash`` -> auto-flash the bound image (requires
|
|
281
|
+
``image_content_sha256`` + a target
|
|
282
|
+
disk on the machine row).
|
|
283
|
+
|
|
284
|
+
The mapping mirrors bty's shape so the ported pixie CLI needs
|
|
285
|
+
zero cmdline changes. For the minimal live-env slice landing
|
|
286
|
+
here we only surface ``inventory`` + ``exit`` -- the flash path
|
|
287
|
+
lands with a follow-up PR that adds the target-disk fields on
|
|
288
|
+
the machine row + the ``POST /pxe/{mac}/done`` endpoint that
|
|
289
|
+
flips ``pixie-flash-once`` observers to see the machine as
|
|
290
|
+
flashed."""
|
|
291
|
+
try:
|
|
292
|
+
canon = normalise_mac(mac)
|
|
293
|
+
except BadMac as exc:
|
|
294
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
295
|
+
|
|
296
|
+
machines = _get_machines(request)
|
|
297
|
+
row = machines.get(canon)
|
|
298
|
+
if row is None:
|
|
299
|
+
# A GET /plan without a prior discovery hit is unusual (the
|
|
300
|
+
# target would already have iPXE'd via /pxe/{mac}) but
|
|
301
|
+
# possible during testing. Fall through to ``exit``: the CLI
|
|
302
|
+
# will POST inventory anyway (that path does not touch the
|
|
303
|
+
# machine row).
|
|
304
|
+
return {"mode": "exit"}
|
|
305
|
+
|
|
306
|
+
mode = row.boot_mode
|
|
307
|
+
if mode == "pixie-inventory":
|
|
308
|
+
return {"mode": "inventory"}
|
|
309
|
+
if mode == "pixie-tui":
|
|
310
|
+
return {"mode": "interactive"}
|
|
311
|
+
if mode in ("pixie-flash-once", "pixie-flash-always"):
|
|
312
|
+
# Resolve the bound catalog entry so the live env can fetch
|
|
313
|
+
# the bytes + know the format. Bind-time validation (see
|
|
314
|
+
# machines._store.upsert_binding) guarantees both fields are
|
|
315
|
+
# non-empty for a flash mode; if either goes missing between
|
|
316
|
+
# bind and here (schema drift, out-of-band DB edit), fall
|
|
317
|
+
# back to the interactive wizard so the operator can pick.
|
|
318
|
+
if not (row.image_content_sha256 and row.target_disk_serial):
|
|
319
|
+
return {"mode": "interactive"}
|
|
320
|
+
catalog = request.app.state.catalog_store
|
|
321
|
+
entry = None
|
|
322
|
+
for e in catalog.list_entries():
|
|
323
|
+
if e.content_sha256 == row.image_content_sha256:
|
|
324
|
+
entry = e
|
|
325
|
+
break
|
|
326
|
+
if entry is None:
|
|
327
|
+
return {"mode": "interactive"}
|
|
328
|
+
ctx = _render_context(request)
|
|
329
|
+
# URL-quote the entry name for the image URL path segment:
|
|
330
|
+
# nosi's operator-facing names contain spaces + parens
|
|
331
|
+
# ("nosi debian-13-headless (x86_64, 2026.W29)") and pushing
|
|
332
|
+
# them through the client's ``urllib.request.urlopen`` on
|
|
333
|
+
# the live env drops the path from the URL entirely (parser
|
|
334
|
+
# rejects raw whitespace in a URL). The blob route serves
|
|
335
|
+
# by sha only, so the display name is decorative; encode it
|
|
336
|
+
# for safe transport, leave the top-level ``name`` field
|
|
337
|
+
# decoded for operator-readable logging on the client side.
|
|
338
|
+
import urllib.parse as _urlparse
|
|
339
|
+
|
|
340
|
+
image_url = (
|
|
341
|
+
f"http://{ctx.host}:{ctx.port}"
|
|
342
|
+
f"/b/{row.image_content_sha256}/{_urlparse.quote(entry.name, safe='')}"
|
|
343
|
+
)
|
|
344
|
+
# ``entry.format`` in the catalog is the ORIGINAL upstream
|
|
345
|
+
# format (``img.gz`` / ``img.zst`` / ``img.xz`` / ``img``).
|
|
346
|
+
# Pixie's fetcher decompresses ``img.gz`` / ``img.zst`` /
|
|
347
|
+
# ``img.xz`` at fetch time and stores the DECOMPRESSED bytes
|
|
348
|
+
# in the blob (see catalog._fetcher._COMPRESSED_IMG_FORMATS
|
|
349
|
+
# + _decompress_to_tmpfile). The blob route serves those
|
|
350
|
+
# decompressed bytes verbatim, so the flash pipeline in the
|
|
351
|
+
# live env must NOT try to gunzip/unzstd/unxz the stream --
|
|
352
|
+
# the bytes on the wire are already raw ``img``.
|
|
353
|
+
#
|
|
354
|
+
# Advertise ``format=img`` for any compressed variant whose
|
|
355
|
+
# bytes are pre-decompressed on disk. Leaves plain ``img``
|
|
356
|
+
# + ``tar.gz`` (bundle) untouched. Without this, a
|
|
357
|
+
# ``format=img.gz`` payload from an entry we already
|
|
358
|
+
# decompressed sends the CLI into gunzip-on-raw-bytes and
|
|
359
|
+
# the flash never completes.
|
|
360
|
+
_COMPRESSED_IMG_FORMATS = {"img.gz", "img.zst", "img.xz"}
|
|
361
|
+
plan_format = "img" if entry.format in _COMPRESSED_IMG_FORMATS else entry.format
|
|
362
|
+
plan: dict[str, Any] = {
|
|
363
|
+
"mode": "flash",
|
|
364
|
+
"image": image_url,
|
|
365
|
+
"target_disk_serial": row.target_disk_serial,
|
|
366
|
+
"name": entry.name,
|
|
367
|
+
"disk_image_sha": row.image_content_sha256,
|
|
368
|
+
}
|
|
369
|
+
if plan_format:
|
|
370
|
+
plan["format"] = plan_format
|
|
371
|
+
return plan
|
|
372
|
+
if mode == "nbdboot":
|
|
373
|
+
# nbdboot targets normally boot the image's own kernel +
|
|
374
|
+
# initrd -- no pixie CLI in the picture -- so this branch
|
|
375
|
+
# only fires under the nbdboot chain test, which pivots
|
|
376
|
+
# through the pixie live env as a stand-in. Return
|
|
377
|
+
# ``interactive`` (not ``exit``): ``exit`` triggers a
|
|
378
|
+
# ``sys.exit(0)`` inside the CLI that races the
|
|
379
|
+
# daemon-thread inventory post, so an ``exit`` here
|
|
380
|
+
# occasionally kills the CLI before inventory reaches
|
|
381
|
+
# pixie. ``interactive`` keeps the CLI up (wizard on
|
|
382
|
+
# tty1) which is harmless in the test and never runs
|
|
383
|
+
# on a real nbdboot boot.
|
|
384
|
+
return {"mode": "interactive"}
|
|
385
|
+
# ipxe-exit / unknown -> nothing to do from the live env's side.
|
|
386
|
+
return {"mode": "exit"}
|
pixie/tftp/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""TFTP subprocess supervision.
|
|
2
|
+
|
|
3
|
+
pixie ships an in-container ``in.tftpd`` (from the tftpd-hpa package)
|
|
4
|
+
so a target's BIOS-PXE / UEFI-PXE first hop can chain into pixie's
|
|
5
|
+
HTTP bootstrap without an external TFTP daemon on the LAN.
|
|
6
|
+
|
|
7
|
+
The supervisor is a thin wrapper around ``subprocess.Popen`` tuned to
|
|
8
|
+
pixie's FastAPI lifespan: start on app boot, poll for early death
|
|
9
|
+
(same 200ms grace as :class:`pixie.exports._supervisor.NbdServer`),
|
|
10
|
+
terminate on shutdown.
|
|
11
|
+
|
|
12
|
+
Runs OFF by default in unit / dev where udp/69 needs root -- flipped
|
|
13
|
+
on inside the container via ``PIXIE_TFTP_ENABLED=1`` (set by the
|
|
14
|
+
compose file's default env).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from pixie.tftp._supervisor import DEFAULT_TFTP_ROOT, TftpServer
|
|
20
|
+
|
|
21
|
+
__all__ = ["DEFAULT_TFTP_ROOT", "TftpServer"]
|