pixie-lab 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. pixie/__init__.py +20 -0
  2. pixie/_util.py +38 -0
  3. pixie/catalog/__init__.py +25 -0
  4. pixie/catalog/_fetcher.py +513 -0
  5. pixie/catalog/_routes.py +392 -0
  6. pixie/catalog/_schema.py +263 -0
  7. pixie/catalog/_store.py +234 -0
  8. pixie/deploy/__init__.py +26 -0
  9. pixie/deploy/_main.py +321 -0
  10. pixie/deploy/_templates.py +153 -0
  11. pixie/disks.py +85 -0
  12. pixie/events/__init__.py +28 -0
  13. pixie/events/_kinds.py +203 -0
  14. pixie/events/_log.py +210 -0
  15. pixie/events/_routes.py +42 -0
  16. pixie/exports/__init__.py +17 -0
  17. pixie/exports/_routes.py +211 -0
  18. pixie/exports/_store.py +156 -0
  19. pixie/exports/_supervisor.py +240 -0
  20. pixie/flash.py +1971 -0
  21. pixie/images.py +473 -0
  22. pixie/machines/__init__.py +16 -0
  23. pixie/machines/_routes.py +190 -0
  24. pixie/machines/_store.py +623 -0
  25. pixie/oras.py +547 -0
  26. pixie/pivot/__init__.py +180 -0
  27. pixie/pivot/nbdboot +348 -0
  28. pixie/pxe/__init__.py +19 -0
  29. pixie/pxe/_renderer.py +244 -0
  30. pixie/pxe/_routes.py +386 -0
  31. pixie/tftp/__init__.py +21 -0
  32. pixie/tftp/_supervisor.py +129 -0
  33. pixie/tui/__init__.py +185 -0
  34. pixie/tui/_app.py +2219 -0
  35. pixie/tui_catalog.py +657 -0
  36. pixie/web/__init__.py +6 -0
  37. pixie/web/_auth.py +70 -0
  38. pixie/web/_settings_store.py +211 -0
  39. pixie/web/_static/.gitkeep +0 -0
  40. pixie/web/_static/bootstrap-icons.min.css +5 -0
  41. pixie/web/_static/bootstrap.min.css +12 -0
  42. pixie/web/_static/fonts/bootstrap-icons.woff +0 -0
  43. pixie/web/_static/fonts/bootstrap-icons.woff2 +0 -0
  44. pixie/web/_static/htmx.min.js +1 -0
  45. pixie/web/_static/pixie-favicon.png +0 -0
  46. pixie/web/_static/sse.js +290 -0
  47. pixie/web/_table_state.py +261 -0
  48. pixie/web/_templates/_partials/page_description.html +22 -0
  49. pixie/web/_templates/_partials/recent_events.html +47 -0
  50. pixie/web/_templates/_partials/table_helpers.html +189 -0
  51. pixie/web/_templates/catalog.html +364 -0
  52. pixie/web/_templates/catalog_detail.html +386 -0
  53. pixie/web/_templates/dashboard.html +256 -0
  54. pixie/web/_templates/events.html +125 -0
  55. pixie/web/_templates/ipxe/bootstrap.j2 +12 -0
  56. pixie/web/_templates/ipxe/exit.j2 +10 -0
  57. pixie/web/_templates/ipxe/nbdboot.j2 +57 -0
  58. pixie/web/_templates/ipxe/pixie-live-env.j2 +55 -0
  59. pixie/web/_templates/ipxe/unavailable.j2 +14 -0
  60. pixie/web/_templates/layout.html +345 -0
  61. pixie/web/_templates/login.html +40 -0
  62. pixie/web/_templates/machine_detail.html +786 -0
  63. pixie/web/_templates/machines.html +186 -0
  64. pixie/web/_templates/settings.html +265 -0
  65. pixie/web/main.py +1910 -0
  66. pixie_lab-0.1.0.dist-info/METADATA +107 -0
  67. pixie_lab-0.1.0.dist-info/RECORD +70 -0
  68. pixie_lab-0.1.0.dist-info/WHEEL +4 -0
  69. pixie_lab-0.1.0.dist-info/entry_points.txt +3 -0
  70. pixie_lab-0.1.0.dist-info/licenses/LICENSE +674 -0
pixie/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"]