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
@@ -0,0 +1,392 @@
1
+ """HTTP routes for the catalog + blob + artifacts surface.
2
+
3
+ Mounted from :mod:`pixie.web.main` at repo-root paths so operator
4
+ muscle memory (``/catalog``, ``/b/``, ``/artifacts/``) survives the
5
+ merge from bty + withcache + nbdmux into one process.
6
+
7
+ Read routes (``GET /catalog``, ``GET /b/<sha>/<name>``,
8
+ ``GET /artifacts/<sha>/{file}``) are OPEN by design: the PXE-boot
9
+ targets that hit ``/artifacts/`` and ``/b/`` cannot hold a session
10
+ cookie, and the LAN-only trust model matches nbdmux's original
11
+ posture. Write routes (``POST`` / ``DELETE``) require a valid pixie
12
+ session.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import re
19
+ from concurrent.futures import ThreadPoolExecutor
20
+ from typing import Any
21
+
22
+ from fastapi import APIRouter, Depends, HTTPException, Request, status
23
+ from fastapi.responses import FileResponse, Response
24
+ from pydantic import BaseModel, Field
25
+
26
+ from pixie._util import now_iso
27
+ from pixie.catalog._fetcher import FetchError, entry_from_dict, fetch
28
+ from pixie.catalog._store import CatalogStore
29
+ from pixie.events._kinds import (
30
+ CATALOG_ENTRY_ADDED,
31
+ CATALOG_ENTRY_DELETED,
32
+ CATALOG_FETCH_DONE,
33
+ CATALOG_FETCH_FAILED,
34
+ CATALOG_FETCH_STARTED,
35
+ )
36
+ from pixie.web._auth import require_auth
37
+
38
+ # Named field-safety regex for the content sha URL segment. iPXE fires
39
+ # at ``/artifacts/<sha>/vmlinuz``; a bad sha is a client bug + we 404
40
+ # rather than let a caller poke around the artifacts tree.
41
+ _SHA_RE = re.compile(r"^[0-9a-f]{64}$")
42
+
43
+ # Filenames the netboot bundle carries + we serve. iPXE templates
44
+ # reference these; keep the allowlist tight so ``/artifacts/<sha>/../..``
45
+ # style traversal never even reaches the store.
46
+ _ARTIFACT_FILES = frozenset({"vmlinuz", "initrd", "manifest.json"})
47
+
48
+
49
+ class AddEntryBody(BaseModel):
50
+ """Operator-facing body for ``POST /catalog/entries``. Deliberately
51
+ tight: only fields the operator writes at add time; content_sha /
52
+ size / fetched_at are populated by the fetch pipeline."""
53
+
54
+ name: str = Field(..., min_length=1)
55
+ src: str = Field(..., min_length=1)
56
+ format: str = Field(..., min_length=1)
57
+ arch: str = ""
58
+ description: str = ""
59
+ netboot_src: str = ""
60
+
61
+
62
+ def _get_store(request: Request) -> CatalogStore:
63
+ """Route-scoped dep: catalog store lives on app.state, attached at
64
+ startup by ``create_app``. Not passed via Depends so the router
65
+ can stay a thin wrapper."""
66
+ store: CatalogStore | None = getattr(request.app.state, "catalog_store", None)
67
+ if store is None:
68
+ raise HTTPException(
69
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
70
+ detail="catalog store not initialised",
71
+ )
72
+ return store
73
+
74
+
75
+ def _get_fetch_pool(request: Request) -> ThreadPoolExecutor:
76
+ """The fetch pipeline is stdlib blocking IO (``urllib`` + tarfile).
77
+ Route handlers submit ``fetch(...)`` to a shared thread pool so
78
+ concurrent downloads don't block the event loop."""
79
+ pool: ThreadPoolExecutor | None = getattr(request.app.state, "fetch_pool", None)
80
+ if pool is None:
81
+ raise HTTPException(
82
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
83
+ detail="fetch pool not initialised",
84
+ )
85
+ return pool
86
+
87
+
88
+ def _fetch_states(request: Request) -> dict[str, dict[str, Any]]:
89
+ """Per-name fetch tracker used by ``GET /catalog`` to advertise
90
+ in-flight / error state to the operator UI. Values shape:
91
+ ``{"state": "fetching" | "error", "started_at": iso,
92
+ "error": str | None}``.
93
+ """
94
+ states: dict[str, dict[str, Any]] | None = getattr(request.app.state, "fetch_states", None)
95
+ if states is None:
96
+ raise HTTPException(
97
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
98
+ detail="fetch state map not initialised",
99
+ )
100
+ return states
101
+
102
+
103
+ def _decode_sha(seg: str) -> str:
104
+ if not _SHA_RE.match(seg):
105
+ raise HTTPException(status_code=404, detail="not found")
106
+ return seg
107
+
108
+
109
+ def _emit_event(
110
+ request: Request,
111
+ kind: str,
112
+ *,
113
+ subject_kind: str = "",
114
+ subject_id: str = "",
115
+ summary: str = "",
116
+ details: dict[str, Any] | None = None,
117
+ ) -> None:
118
+ """Fire-and-forget event emit. Skips silently when the events log
119
+ isn't attached (e.g. lightweight unit-test app construction)."""
120
+ log = getattr(request.app.state, "events_log", None)
121
+ if log is None:
122
+ return
123
+ log.emit(
124
+ kind,
125
+ subject_kind=subject_kind,
126
+ subject_id=subject_id,
127
+ summary=summary,
128
+ details=details or {},
129
+ )
130
+
131
+
132
+ router = APIRouter()
133
+
134
+
135
+ # ----------------------- catalog CRUD --------------------------------
136
+
137
+
138
+ @router.get("/catalog")
139
+ def list_catalog(request: Request) -> dict[str, Any]:
140
+ """Every catalog entry the operator has staged, downloaded or not.
141
+ Presence in this list does not imply the bytes are on disk (see
142
+ ``fetched``). Ships transient fetch state so the UI can render a
143
+ "downloading" pill without a second poll."""
144
+ store = _get_store(request)
145
+ states = _fetch_states(request)
146
+ entries: list[dict[str, Any]] = []
147
+ for e in store.list_entries():
148
+ row = e.to_dict()
149
+ st = states.get(e.name)
150
+ if st:
151
+ row["fetch_state"] = st.get("state")
152
+ if st.get("started_at"):
153
+ row["fetch_started_at"] = st["started_at"]
154
+ if st.get("error"):
155
+ row["fetch_error"] = st["error"]
156
+ entries.append(row)
157
+ return {"entries": entries}
158
+
159
+
160
+ @router.api_route("/catalog.toml", methods=["GET", "HEAD"], response_class=Response)
161
+ def catalog_toml(request: Request) -> Response:
162
+ """TOML projection of the current catalog for the ported pixie
163
+ CLI's interactive wizard.
164
+
165
+ The wizard (``pixie.tui._app._TuiState.__init__``) defaults its
166
+ catalog source to ``<server>/catalog.toml`` in server-driven
167
+ mode, and its parser (``pixie.tui_catalog.load_bytes``) only
168
+ understands ``version = 1`` + ``[[images]]``. Without this
169
+ endpoint, a pixie-tui-bound machine's live env falls into
170
+ interactive mode but immediately fails catalog-load with a
171
+ 404, leaving the operator on an "empty catalog" screen when
172
+ the real problem is a wire mismatch. Emits only downloaded
173
+ entries so a pick is guaranteed to have bytes on disk when
174
+ the flash pipeline reaches them."""
175
+ store = _get_store(request)
176
+ lines = ["version = 1", ""]
177
+ for e in store.list_entries():
178
+ if not e.content_sha256:
179
+ # Skip un-downloaded entries: the wizard can't flash them
180
+ # anyway (auto-flash and interactive both call
181
+ # ``flash._probe_image_url_http`` which needs the bytes)
182
+ # so surfacing them just clutters the picker.
183
+ continue
184
+ lines.append("[[images]]")
185
+ lines.append(f'name = "{e.name}"')
186
+ lines.append(f'src = "{e.src}"')
187
+ lines.append(f'format = "{e.format}"')
188
+ if e.arch:
189
+ lines.append(f'arch = "{e.arch}"')
190
+ if e.description:
191
+ # Description is operator-provided free text; escape " and
192
+ # \ so a curly-quote or backslash won't tank the TOML parse.
193
+ safe = e.description.replace("\\", "\\\\").replace('"', '\\"')
194
+ lines.append(f'description = "{safe}"')
195
+ if e.netboot_src:
196
+ lines.append(f'netboot_src = "{e.netboot_src}"')
197
+ if e.content_sha256:
198
+ lines.append(f'sha256 = "{e.content_sha256}"')
199
+ if e.size_bytes:
200
+ lines.append(f"size_bytes = {e.size_bytes}")
201
+ lines.append("")
202
+ body = ("\n".join(lines) + "\n").encode("utf-8")
203
+ return Response(content=body, media_type="application/toml")
204
+
205
+
206
+ @router.post("/catalog/entries", status_code=201)
207
+ def add_entry(
208
+ request: Request,
209
+ body: AddEntryBody,
210
+ _auth: None = Depends(require_auth),
211
+ ) -> dict[str, Any]:
212
+ """Stage a new catalog entry. Does NOT fetch bytes; the operator
213
+ hits Fetch as a separate step (or a UI Fetch button POSTs both
214
+ add + fetch in a single click)."""
215
+ store = _get_store(request)
216
+ if store.get_entry(body.name):
217
+ raise HTTPException(
218
+ status_code=status.HTTP_409_CONFLICT,
219
+ detail=f"entry {body.name!r} already exists",
220
+ )
221
+ entry = entry_from_dict(body.model_dump())
222
+ store.upsert(entry)
223
+ _emit_event(
224
+ request,
225
+ CATALOG_ENTRY_ADDED,
226
+ subject_kind="entry",
227
+ subject_id=entry.name,
228
+ summary=f"{entry.name} ({entry.format})",
229
+ )
230
+ return {"entry": entry.to_dict()}
231
+
232
+
233
+ @router.delete("/catalog/entries", status_code=204)
234
+ def delete_entry(
235
+ request: Request,
236
+ name: str,
237
+ _auth: None = Depends(require_auth),
238
+ ) -> Response:
239
+ """Delete a catalog entry by name (``?name=<name>`` query param).
240
+ Blob + artifact bytes are NOT removed here even if the entry was
241
+ the last reference; a separate GC route walks the store."""
242
+ store = _get_store(request)
243
+ if not store.delete(name):
244
+ raise HTTPException(
245
+ status_code=status.HTTP_404_NOT_FOUND,
246
+ detail=f"no entry with name={name!r}",
247
+ )
248
+ _fetch_states(request).pop(name, None)
249
+ _emit_event(
250
+ request,
251
+ CATALOG_ENTRY_DELETED,
252
+ subject_kind="entry",
253
+ subject_id=name,
254
+ summary=name,
255
+ )
256
+ return Response(status_code=204)
257
+
258
+
259
+ @router.post("/catalog/entries/{name}/fetch", status_code=202)
260
+ async def start_fetch(
261
+ request: Request,
262
+ name: str,
263
+ _auth: None = Depends(require_auth),
264
+ ) -> dict[str, Any]:
265
+ """Kick off a fetch for the named entry. Returns 202 immediately;
266
+ the actual download runs in the fetch pool. ``GET /catalog``
267
+ reflects fetching / error state via the ``fetch_state`` field."""
268
+ store = _get_store(request)
269
+ entry = store.get_entry(name)
270
+ if entry is None:
271
+ raise HTTPException(
272
+ status_code=status.HTTP_404_NOT_FOUND,
273
+ detail=f"no entry with name={name!r}",
274
+ )
275
+
276
+ states = _fetch_states(request)
277
+ if states.get(name, {}).get("state") == "fetching":
278
+ # In-flight already; return the current state instead of
279
+ # spawning a second task.
280
+ return {"state": "fetching", "started_at": states[name].get("started_at")}
281
+
282
+ states[name] = {"state": "fetching", "started_at": now_iso(), "error": None}
283
+ pool = _get_fetch_pool(request)
284
+ loop = asyncio.get_event_loop()
285
+ log = getattr(request.app.state, "events_log", None)
286
+ log_emit = log.emit if log is not None else None
287
+
288
+ if log_emit is not None:
289
+ log_emit(
290
+ CATALOG_FETCH_STARTED,
291
+ subject_kind="entry",
292
+ subject_id=name,
293
+ summary=f"{name} <- {entry.src}",
294
+ )
295
+
296
+ def _run() -> None:
297
+ try:
298
+ result = fetch(entry, store)
299
+ states[name] = {"state": "done", "started_at": states[name].get("started_at")}
300
+ if log_emit is not None:
301
+ log_emit(
302
+ CATALOG_FETCH_DONE,
303
+ subject_kind="entry",
304
+ subject_id=name,
305
+ summary=f"{name}: {result.size_bytes} bytes, sha {result.content_sha256[:12]}",
306
+ details={
307
+ "content_sha256": result.content_sha256,
308
+ "size_bytes": result.size_bytes,
309
+ },
310
+ )
311
+ except FetchError as exc:
312
+ states[name] = {
313
+ "state": "error",
314
+ "started_at": states[name].get("started_at"),
315
+ "error": str(exc),
316
+ }
317
+ if log_emit is not None:
318
+ log_emit(
319
+ CATALOG_FETCH_FAILED,
320
+ subject_kind="entry",
321
+ subject_id=name,
322
+ summary=str(exc),
323
+ )
324
+ except Exception as exc:
325
+ states[name] = {
326
+ "state": "error",
327
+ "started_at": states[name].get("started_at"),
328
+ "error": f"internal: {exc}",
329
+ }
330
+ if log_emit is not None:
331
+ log_emit(
332
+ CATALOG_FETCH_FAILED,
333
+ subject_kind="entry",
334
+ subject_id=name,
335
+ summary=f"internal: {exc}",
336
+ )
337
+
338
+ loop.run_in_executor(pool, _run)
339
+ return {"state": "fetching", "started_at": states[name].get("started_at")}
340
+
341
+
342
+ # ----------------------- blob + artifact serve -----------------------
343
+
344
+
345
+ @router.api_route("/b/{sha}/{name:path}", methods=["GET", "HEAD"])
346
+ def serve_blob(request: Request, sha: str, name: str) -> FileResponse:
347
+ """Content-addressed blob serve: ``/b/<content_sha256>/<display-name>``.
348
+
349
+ ``name`` is display-only (so operators + logs see a recognisable
350
+ filename); the sha is what routes the request. Any entry with a
351
+ matching ``content_sha256`` -- multiple entries CAN share the
352
+ same content -- serves the same bytes at the same URL. Renaming
353
+ a catalog entry does not change its blob URL.
354
+
355
+ Open route: iPXE targets don't carry sessions. HEAD is served
356
+ too: the ported pixie CLI's ``flash._probe_image_url_http``
357
+ HEADs the image URL to read Content-Length before it commits to
358
+ an auto-flash plan, and when that URL is pixie's own /b/... a
359
+ 405 there aborts the flash before ``dd`` fires.
360
+ """
361
+ store = _get_store(request)
362
+ sha = _decode_sha(sha)
363
+ blob = store.blob_path(sha)
364
+ if not blob.is_file():
365
+ raise HTTPException(status_code=404, detail="blob not found")
366
+ # Sanitise the ``name`` for Content-Disposition; iPXE doesn't
367
+ # care, but operator curl -O should land on a reasonable filename.
368
+ display = name.rsplit("/", 1)[-1] or f"pixie-{sha[:12]}.bin"
369
+ # Starlette's FileResponse strips the body on HEAD automatically
370
+ # (its ``__call__`` skips file streaming when scope.method ==
371
+ # "HEAD") while keeping Content-Length + Content-Type headers,
372
+ # which is exactly what the CLI's probe wants.
373
+ return FileResponse(str(blob), filename=display)
374
+
375
+
376
+ @router.api_route("/artifacts/{sha}/{filename}", methods=["GET", "HEAD"])
377
+ def serve_artifact(request: Request, sha: str, filename: str) -> FileResponse:
378
+ """Content-addressed netboot artifact serve. iPXE.s ipxe_nbdboot
379
+ plan points at ``/artifacts/<content_sha256>/{vmlinuz,initrd}``.
380
+
381
+ Open route + narrow allowlist for the ``filename`` segment; any
382
+ other file name 404s so a caller can never coax a lookup outside
383
+ ``artifact_dir/<sha>/``.
384
+ """
385
+ store = _get_store(request)
386
+ sha = _decode_sha(sha)
387
+ if filename not in _ARTIFACT_FILES:
388
+ raise HTTPException(status_code=404, detail="not found")
389
+ target = store.artifact_path(sha, filename)
390
+ if not target.is_file():
391
+ raise HTTPException(status_code=404, detail="artifact not found")
392
+ return FileResponse(str(target))
@@ -0,0 +1,263 @@
1
+ """Catalog entry schema + TOML parse/serialize.
2
+
3
+ A catalog entry captures what the operator staged and what pixie
4
+ learned about it after Fetch. Two entry shapes share the schema:
5
+
6
+ **Disk-image entry** (``format`` in ``BINDABLE_FORMATS``): flashable
7
+ onto a target disk and mountable over NBD for nbdboot. May carry a
8
+ ``netboot_src`` URL pointing at a sibling netboot-bundle entry.
9
+
10
+ **Netboot-bundle entry** (``format = "tar.gz"``): a build-time
11
+ extract of vmlinuz + initrd + manifest.json from a disk image, so
12
+ image-native nbdboot serves the image's own kernel. Fetched entries
13
+ of this shape unpack into
14
+ ``<state_dir>/artifacts/<content_sha256>/``.
15
+
16
+ Cross-references between the two shapes are by URL (``netboot_src``),
17
+ NOT by name. The name-based ``netboot_ref`` field that older nosi
18
+ tags shipped is accepted on read with a warning + dropped on write.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import tomllib
25
+ from dataclasses import dataclass, field
26
+ from typing import Any
27
+
28
+ from pixie._util import now_iso
29
+
30
+ _log = logging.getLogger(__name__)
31
+
32
+ # Formats that pixie can flash to a target disk (via the flash engine
33
+ # in a later PR) and serve over NBD for nbdboot. Netboot bundles carry
34
+ # ``tar.gz`` and land in a different serving path (artifacts, not
35
+ # blobs).
36
+ BINDABLE_FORMATS: frozenset[str] = frozenset(
37
+ {"img", "img.gz", "img.zst", "img.xz", "img.bz2", "qcow2"}
38
+ )
39
+
40
+ # Known scalar-string fields on a catalog entry. Anything else in the
41
+ # TOML gets dropped on write (silently); on read it's kept in
42
+ # ``extra`` so a future pixie can round-trip fields it doesn't yet
43
+ # know about.
44
+ _KNOWN_STR_KEYS: tuple[str, ...] = (
45
+ "name",
46
+ "src",
47
+ "resolved_src",
48
+ "format",
49
+ "arch",
50
+ "description",
51
+ "netboot_src",
52
+ "content_sha256",
53
+ "fetched_at",
54
+ "added_at",
55
+ )
56
+
57
+
58
+ @dataclass
59
+ class CatalogEntry:
60
+ """One row in the operator's image library.
61
+
62
+ ``name`` is the natural key + display label. ``src`` is the fetch
63
+ URL (``oras://`` or ``https://``). ``format`` decides which post-
64
+ fetch pipeline runs. ``netboot_src``, when set on a disk-image
65
+ entry, is the URL of a sibling netboot-bundle entry -- pixie
66
+ resolves it by URL match against the catalog's ``src`` field, no
67
+ name-string comparison.
68
+
69
+ Fields written by the operator at add time: ``name``, ``src``,
70
+ ``format``, ``arch``, ``description``, ``netboot_src``. Fields
71
+ populated by the fetch pipeline: ``content_sha256``,
72
+ ``size_bytes``, ``fetched_at``. ``added_at`` is set at add time.
73
+ """
74
+
75
+ name: str
76
+ src: str
77
+ format: str
78
+ arch: str = ""
79
+ description: str = ""
80
+ netboot_src: str = ""
81
+ content_sha256: str = ""
82
+ size_bytes: int = 0
83
+ fetched_at: str = ""
84
+ added_at: str = field(default_factory=now_iso)
85
+ # Unknown TOML keys we round-trip so a future pixie schema addition
86
+ # (or an operator-hand-edited catalog with fields we don't parse)
87
+ # survives.
88
+ extra: dict[str, Any] = field(default_factory=dict)
89
+
90
+ def is_fetched(self) -> bool:
91
+ return bool(self.content_sha256)
92
+
93
+ def is_bindable(self) -> bool:
94
+ """True iff the format is a disk image pixie can flash or
95
+ serve over NBD. Netboot bundles return False."""
96
+ return self.format in BINDABLE_FORMATS
97
+
98
+ @property
99
+ def bindable(self) -> bool:
100
+ """Attribute-style alias of :meth:`is_bindable` so Jinja
101
+ templates + ``getattr(entry, 'bindable', False)`` filters
102
+ work without the ``()`` call. Same value; do not diverge."""
103
+ return self.is_bindable()
104
+
105
+ @property
106
+ def fetched(self) -> bool:
107
+ """Attribute-style alias of :meth:`is_fetched` for the same
108
+ Jinja / getattr convenience as :attr:`bindable`."""
109
+ return self.is_fetched()
110
+
111
+ def to_dict(self) -> dict[str, Any]:
112
+ """JSON-serialisable view. Skips zero-y fields so the JSON
113
+ stays compact + easy to grep."""
114
+ out: dict[str, Any] = {
115
+ "name": self.name,
116
+ "src": self.src,
117
+ "format": self.format,
118
+ "added_at": self.added_at,
119
+ }
120
+ for key in ("arch", "description", "netboot_src", "content_sha256", "fetched_at"):
121
+ val = getattr(self, key)
122
+ if val:
123
+ out[key] = val
124
+ if self.size_bytes:
125
+ out["size_bytes"] = self.size_bytes
126
+ out["fetched"] = self.is_fetched()
127
+ out["bindable"] = self.is_bindable()
128
+ if self.extra:
129
+ out["extra"] = self.extra
130
+ return out
131
+
132
+
133
+ def parse_catalog_toml(raw: bytes | str) -> list[CatalogEntry]:
134
+ """Parse a nosi-shaped ``catalog.toml`` into ``CatalogEntry``
135
+ objects. Skips rows that lack the required (``name``, ``src``,
136
+ ``format``) triad.
137
+
138
+ Backward-compat: an entry with ``netboot_ref = "<name-string>"``
139
+ (older nosi tags) logs a warning and is dropped on the field --
140
+ pixie does not resolve name-based cross-references. The rest of
141
+ the row is kept; the entry just won't advertise a netboot
142
+ sibling until nosi ships the ``netboot_src`` variant.
143
+ """
144
+ text = raw.decode("utf-8") if isinstance(raw, bytes) else raw
145
+ doc = tomllib.loads(text)
146
+
147
+ version = doc.get("version")
148
+ if version not in (None, 1):
149
+ _log.warning("catalog.toml: unknown version=%r; parsing anyway", version)
150
+
151
+ entries: list[CatalogEntry] = []
152
+ # Legacy netboot_ref (name-string) capture: nosi's published
153
+ # catalog.toml uses ``netboot_ref = "<sibling entry name>"``
154
+ # rather than the URL-based ``netboot_src``. Pixie's nbdboot
155
+ # renderer resolves the pair through ``netboot_src`` only, so a
156
+ # name-based ref would leave every nbdboot bind unrenderable.
157
+ # Capture the ref during the first pass, then resolve name ->
158
+ # src through the just-parsed set below.
159
+ _pending_ref: dict[str, str] = {}
160
+ for row in doc.get("images", []):
161
+ if not isinstance(row, dict):
162
+ continue
163
+ name = str(row.get("name") or "").strip()
164
+ src = str(row.get("src") or "").strip()
165
+ fmt = str(row.get("format") or "").strip()
166
+ if not (name and src and fmt):
167
+ _log.warning("catalog.toml: dropping entry missing name/src/format: %r", row)
168
+ continue
169
+
170
+ size_bytes = row.get("size_bytes") or 0
171
+ try:
172
+ size_int = int(size_bytes)
173
+ except (TypeError, ValueError):
174
+ size_int = 0
175
+
176
+ netboot_src = str(row.get("netboot_src") or "").strip()
177
+ netboot_ref = str(row.get("netboot_ref") or "").strip()
178
+ if netboot_ref and not netboot_src:
179
+ _pending_ref[name] = netboot_ref
180
+
181
+ entry = CatalogEntry(
182
+ name=name,
183
+ src=src,
184
+ format=fmt,
185
+ arch=str(row.get("arch") or ""),
186
+ description=str(row.get("description") or ""),
187
+ netboot_src=netboot_src,
188
+ content_sha256=str(row.get("content_sha256") or row.get("sha256") or ""),
189
+ size_bytes=size_int,
190
+ fetched_at=str(row.get("fetched_at") or ""),
191
+ added_at=str(row.get("added_at") or now_iso()),
192
+ )
193
+ # Stash anything we haven't consumed so serialize() can round-trip it.
194
+ extra = {
195
+ k: v
196
+ for k, v in row.items()
197
+ if k not in _KNOWN_STR_KEYS and k not in ("netboot_ref", "size_bytes", "sha256")
198
+ }
199
+ if extra:
200
+ entry.extra = extra
201
+ entries.append(entry)
202
+
203
+ # Second pass: resolve legacy netboot_ref (name -> URL) against
204
+ # entries in this same batch. A ref that names an absent entry
205
+ # logs a warning and leaves netboot_src empty (renderer surfaces
206
+ # a readable "unavailable" plan; better than a silent orphan).
207
+ if _pending_ref:
208
+ by_name = {e.name: e.src for e in entries}
209
+ for entry in entries:
210
+ ref = _pending_ref.get(entry.name)
211
+ if not ref:
212
+ continue
213
+ resolved = by_name.get(ref)
214
+ if resolved:
215
+ entry.netboot_src = resolved
216
+ else:
217
+ _log.warning(
218
+ "catalog.toml entry %r netboot_ref -> %r resolved to no "
219
+ "entry in the same catalog; leaving netboot_src empty",
220
+ entry.name,
221
+ ref,
222
+ )
223
+ return entries
224
+
225
+
226
+ def serialise_catalog(entries: list[CatalogEntry]) -> bytes:
227
+ """Serialise entries back to TOML matching the nosi shape.
228
+
229
+ Hand-rolled emitter (no ``tomli_w`` dep): the schema is flat, and a
230
+ conservative "known keys only" emitter beats surprising an operator
231
+ with re-quoted strings from a general TOML writer.
232
+ """
233
+ lines: list[str] = ["version = 1", ""]
234
+ for e in entries:
235
+ lines.append("[[images]]")
236
+ for key in (
237
+ "name",
238
+ "src",
239
+ "format",
240
+ "arch",
241
+ "description",
242
+ "netboot_src",
243
+ "content_sha256",
244
+ "fetched_at",
245
+ "added_at",
246
+ ):
247
+ val = getattr(e, key, "") or ""
248
+ if not val:
249
+ continue
250
+ escaped = str(val).replace("\\", "\\\\").replace('"', '\\"')
251
+ lines.append(f'{key} = "{escaped}"')
252
+ if e.size_bytes:
253
+ lines.append(f"size_bytes = {e.size_bytes}")
254
+ for key, val in e.extra.items():
255
+ # Only round-trip scalar strings/ints; anything else came
256
+ # from an unusual source and we don't want to reformat it.
257
+ if isinstance(val, str):
258
+ escaped = val.replace("\\", "\\\\").replace('"', '\\"')
259
+ lines.append(f'{key} = "{escaped}"')
260
+ elif isinstance(val, int):
261
+ lines.append(f"{key} = {val}")
262
+ lines.append("")
263
+ return "\n".join(lines).encode("utf-8")