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/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """pixie: bare-metal netboot appliance in one container.
2
+
3
+ See PLAN.md at the repo root for the design rationale, locked
4
+ decisions, and ordered roadmap.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from importlib.metadata import PackageNotFoundError
10
+ from importlib.metadata import version as _pkg_version
11
+
12
+ try:
13
+ __version__ = _pkg_version("pixie-lab")
14
+ except PackageNotFoundError:
15
+ # Not installed (development checkout without editable install).
16
+ # Never raise on import; downstream code assumes ``pixie.__version__``
17
+ # is always a string.
18
+ __version__ = "0.0.0.dev0+unknown"
19
+
20
+ __all__ = ["__version__"]
pixie/_util.py ADDED
@@ -0,0 +1,38 @@
1
+ """Stdlib-only helpers shared across pixie modules.
2
+
3
+ Keep this module import-cheap (no fastapi / jinja pulls) so the CLI +
4
+ fetch worker can import it before the web layer boots.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import UTC, datetime
10
+
11
+ CHUNK = 64 * 1024 # 64 KiB streaming block for download + tar extract
12
+
13
+
14
+ def now_iso() -> str:
15
+ """UTC ISO-8601 timestamp with second precision + trailing Z. Written
16
+ directly into catalog + event rows without further formatting."""
17
+ return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
18
+
19
+
20
+ def human_size(n: int) -> str:
21
+ """Bytes to short human string. 1024-based (KiB/MiB/GiB/TiB)."""
22
+ f = float(n)
23
+ for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
24
+ if f < 1024 or unit == "TiB":
25
+ return f"{f:.0f} {unit}" if unit == "B" else f"{f:.1f} {unit}"
26
+ f /= 1024
27
+ return f"{f:.1f} TiB"
28
+
29
+
30
+ def parse_size(s: str | int) -> int:
31
+ """Parse '0', '1024', '50M', '20G', '1.5T' into bytes (1024-based)."""
32
+ s = str(s).strip()
33
+ if not s:
34
+ return 0
35
+ units = {"K": 1024, "M": 1024**2, "G": 1024**3, "T": 1024**4}
36
+ if s[-1].upper() in units:
37
+ return int(float(s[:-1]) * units[s[-1].upper()])
38
+ return int(s)
@@ -0,0 +1,25 @@
1
+ """Operator-curated image library.
2
+
3
+ Pixie's catalog holds two peer entry shapes: **disk images**
4
+ (``format`` = ``img.gz`` / ``img.zst`` / ``img.xz`` / ``img`` / ...)
5
+ that pixie can flash to a target disk or serve over NBD for nbdboot,
6
+ and **netboot bundles** (``format`` = ``tar.gz``) whose contents pixie
7
+ unpacks into a content-addressed artifacts directory (vmlinuz +
8
+ initrd + manifest.json) so image-native nbdboot has kernel + initrd
9
+ URLs to serve.
10
+
11
+ Disk-image entries can carry a ``netboot_src`` field pointing at the
12
+ sibling bundle by URL; the two are peer catalog entries rather than
13
+ one nested under the other, so a nbdboot-only workflow can fetch a
14
+ bundle standalone and never touch a disk image.
15
+
16
+ There is ONE verb: :func:`pixie.catalog.fetch.fetch`. Downloads +
17
+ sha256s the bytes; if the entry's ``format`` is ``tar.gz``, additionally
18
+ unpacks vmlinuz + initrd + manifest.json into
19
+ ``<state_dir>/artifacts/<content-sha256>/``. Presence on disk IS
20
+ readiness; no ready/pending vocabulary, no auto-fetch, no misses page.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ DEFAULT_CATALOG_URL = "https://github.com/safl/nosi/releases/latest/download/catalog.toml"
@@ -0,0 +1,513 @@
1
+ """Fetch pipeline: download a catalog entry's src to disk, sha256 it,
2
+ and (for tar.gz netboot bundles) unpack vmlinuz + initrd +
3
+ manifest.json into a content-addressed artifacts directory.
4
+
5
+ Pixie's one fetch verb. Presence on disk IS readiness; there is no
6
+ warming stage, no ready/pending vocabulary, no misses. If a fetch
7
+ half-completes, its ``.inflight`` tmpfile survives the crash but the
8
+ catalog entry stays unfetched; a subsequent Fetch starts from zero.
9
+ (Resume-on-truncation, ORAS retry loop, etc are v0.3+ concerns.)
10
+
11
+ Runs synchronously inside the caller's thread. The routes layer wraps
12
+ each Fetch call in an :class:`asyncio.Task` so the HTTP handler
13
+ returns 202 immediately while the download proceeds; see
14
+ ``pixie.catalog._routes``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import gzip
20
+ import hashlib
21
+ import io
22
+ import json
23
+ import logging
24
+ import lzma
25
+ import os
26
+ import shutil
27
+ import subprocess
28
+ import tarfile
29
+ import tempfile
30
+ import urllib.error
31
+ import urllib.request
32
+ from collections.abc import Callable
33
+ from dataclasses import dataclass, field
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ from pixie import oras
38
+ from pixie._util import CHUNK, now_iso
39
+ from pixie.catalog._schema import CatalogEntry
40
+ from pixie.catalog._store import CatalogStore
41
+
42
+ _log = logging.getLogger(__name__)
43
+
44
+ # Progress callback: the fetch pipeline invokes this at each phase
45
+ # transition + periodically during long streaming loops. The dict
46
+ # payload is JSON-serialisable (str / int / None) so a route can echo
47
+ # it straight back to the UI. Callers pass ``None`` when they don't
48
+ # care about progress; the fetcher handles that as a no-op.
49
+ ProgressReporter = Callable[[dict[str, Any]], None] | None
50
+
51
+
52
+ class FetchError(Exception):
53
+ """Fetch pipeline failure. String is operator-shaped: it's what
54
+ ``/catalog`` renders as the ``last_error`` on the entry row."""
55
+
56
+
57
+ @dataclass
58
+ class FetchResult:
59
+ """Post-fetch summary. Handler returns to the caller so it can
60
+ update UI + optionally trigger downstream side-effects."""
61
+
62
+ name: str
63
+ content_sha256: str
64
+ size_bytes: int
65
+ format: str
66
+ # Set only for tar.gz netboot bundles; None for plain disk images.
67
+ artifact_files: list[str] = field(default_factory=list)
68
+
69
+
70
+ # ------------------------------------------------------------------------
71
+ # HTTPS + ORAS URL resolution
72
+ # ------------------------------------------------------------------------
73
+
74
+
75
+ def _resolve_fetch_url(src: str) -> tuple[str, dict[str, str]]:
76
+ """Turn a catalog entry's ``src`` into a plain HTTPS URL + auth
77
+ headers the origin expects. ``oras://`` refs go through the ORAS
78
+ adapter; ``https://`` refs are returned as-is."""
79
+ if src.startswith("oras://"):
80
+ resolved = oras.resolve_ref(src)
81
+ headers = dict(resolved.headers or {})
82
+ return resolved.blob_url, headers
83
+ if src.startswith(("http://", "https://")):
84
+ return src, {}
85
+ raise FetchError(f"unsupported src scheme: {src!r}")
86
+
87
+
88
+ # ------------------------------------------------------------------------
89
+ # Streaming download with sha256
90
+ # ------------------------------------------------------------------------
91
+
92
+
93
+ def _stream_to_tmpfile(
94
+ url: str,
95
+ headers: dict[str, str],
96
+ dest_dir: Path,
97
+ progress: ProgressReporter = None,
98
+ ) -> tuple[Path, str, int]:
99
+ """Download bytes from ``url`` into ``dest_dir/<uuid>.inflight``,
100
+ streaming sha256 alongside. Returns (path, sha256, size).
101
+
102
+ Raises :class:`FetchError` on HTTP/network failure or on empty
103
+ body. Callers pass a ``dest_dir`` that lives on the same
104
+ filesystem as the final blob path so the ``os.replace`` at commit
105
+ time is atomic.
106
+
107
+ ``progress`` is called at start (``phase='downloading'`` +
108
+ ``total_bytes``) then throttled to at most one call per 500 ms
109
+ while bytes stream in, and once again on completion. The payload
110
+ always carries ``phase`` + ``bytes_downloaded`` + ``total_bytes``
111
+ (``None`` when the server omits Content-Length).
112
+ """
113
+ dest_dir.mkdir(parents=True, exist_ok=True)
114
+ fd, tmp_name = tempfile.mkstemp(prefix="fetch-", suffix=".inflight", dir=str(dest_dir))
115
+ tmp_path = Path(tmp_name)
116
+ os.close(fd)
117
+
118
+ req = urllib.request.Request(url, method="GET")
119
+ for k, v in headers.items():
120
+ req.add_header(k, v)
121
+ req.add_header("User-Agent", "pixie-fetch/0.2")
122
+
123
+ sha = hashlib.sha256()
124
+ written = 0
125
+ last_emit = 0.0
126
+ try:
127
+ with urllib.request.urlopen(req, timeout=60) as resp, open(tmp_path, "wb") as out:
128
+ total_bytes: int | None
129
+ try:
130
+ total_bytes = int(resp.headers.get("Content-Length") or 0) or None
131
+ except ValueError:
132
+ total_bytes = None
133
+ if progress is not None:
134
+ progress(
135
+ {
136
+ "phase": "downloading",
137
+ "bytes_downloaded": 0,
138
+ "total_bytes": total_bytes,
139
+ }
140
+ )
141
+ while True:
142
+ chunk = resp.read(CHUNK)
143
+ if not chunk:
144
+ break
145
+ out.write(chunk)
146
+ sha.update(chunk)
147
+ written += len(chunk)
148
+ # Throttled progress emit: at most every 500 ms so a
149
+ # gigabit-line download doesn't hammer the state dict.
150
+ # ``time.monotonic`` is safe for interval comparisons
151
+ # (unaffected by clock jumps).
152
+ if progress is not None:
153
+ now = _monotonic()
154
+ if now - last_emit >= 0.5:
155
+ progress(
156
+ {
157
+ "phase": "downloading",
158
+ "bytes_downloaded": written,
159
+ "total_bytes": total_bytes,
160
+ }
161
+ )
162
+ last_emit = now
163
+ if progress is not None:
164
+ progress(
165
+ {
166
+ "phase": "downloading",
167
+ "bytes_downloaded": written,
168
+ "total_bytes": total_bytes,
169
+ }
170
+ )
171
+ except (urllib.error.URLError, OSError) as exc:
172
+ tmp_path.unlink(missing_ok=True)
173
+ raise FetchError(f"download failed for {url}: {exc}") from exc
174
+
175
+ if written == 0:
176
+ tmp_path.unlink(missing_ok=True)
177
+ raise FetchError(f"empty body from {url}")
178
+
179
+ return tmp_path, sha.hexdigest(), written
180
+
181
+
182
+ def _monotonic() -> float:
183
+ """Indirection so tests can freeze / step the clock without
184
+ touching ``time`` globally. Kept trivial so mypy still sees it
185
+ as ``() -> float``."""
186
+ import time as _time
187
+
188
+ return _time.monotonic()
189
+
190
+
191
+ # ------------------------------------------------------------------------
192
+ # Fetch verb: one call per catalog entry
193
+ # ------------------------------------------------------------------------
194
+
195
+
196
+ def fetch(
197
+ entry: CatalogEntry,
198
+ store: CatalogStore,
199
+ progress: ProgressReporter = None,
200
+ ) -> FetchResult:
201
+ """Download + sha256 + (for tar.gz) unpack. Idempotent: if the
202
+ entry is already fetched AND its blob still exists on disk, this
203
+ returns the existing FetchResult immediately.
204
+
205
+ For non-tar.gz formats: writes to ``blobs/<sha>/blob``.
206
+
207
+ For ``tar.gz`` netboot bundles: streams the bytes to a tmpfile,
208
+ computes sha256, then extracts vmlinuz + initrd + manifest.json
209
+ into ``artifacts/<sha>/``. The tmpfile is discarded (bundles are
210
+ only useful unpacked; the tar.gz itself is not served).
211
+
212
+ ``progress`` is a live-status callback the routes layer wires to
213
+ ``app.state.fetch_states[entry.name]``. Called at every phase
214
+ transition (``downloading`` -> ``decompressing`` -> ``unpacking``
215
+ -> done) so the operator UI can render a live status pill without
216
+ polling the disk.
217
+
218
+ Raises :class:`FetchError` on any failure. Catalog row is updated
219
+ with content_sha + size + fetched_at only on success.
220
+ """
221
+ # Fast path: already fetched + blob still on disk.
222
+ if entry.is_fetched():
223
+ if entry.format == "tar.gz":
224
+ manifest = store.artifact_path(entry.content_sha256, "manifest.json")
225
+ if manifest.is_file():
226
+ return FetchResult(
227
+ name=entry.name,
228
+ content_sha256=entry.content_sha256,
229
+ size_bytes=entry.size_bytes,
230
+ format=entry.format,
231
+ artifact_files=_list_artifact_files(store.artifact_dir(entry.content_sha256)),
232
+ )
233
+ else:
234
+ blob = store.blob_path(entry.content_sha256)
235
+ if blob.is_file():
236
+ return FetchResult(
237
+ name=entry.name,
238
+ content_sha256=entry.content_sha256,
239
+ size_bytes=entry.size_bytes,
240
+ format=entry.format,
241
+ )
242
+
243
+ url, headers = _resolve_fetch_url(entry.src)
244
+ _log.info("fetch %r: streaming from %s", entry.name, url)
245
+ tmp_path, sha256, size = _stream_to_tmpfile(
246
+ url, headers, store.state_dir / "tmp", progress=progress
247
+ )
248
+
249
+ try:
250
+ if entry.format == "tar.gz":
251
+ if progress is not None:
252
+ progress({"phase": "unpacking"})
253
+ artifact_dir = store.artifact_dir(sha256)
254
+ _unpack_netboot_bundle(tmp_path, artifact_dir)
255
+ artifacts = _list_artifact_files(artifact_dir)
256
+ _log.info(
257
+ "fetch %r: extracted %d artifacts to %s", entry.name, len(artifacts), artifact_dir
258
+ )
259
+ elif entry.format in _COMPRESSED_IMG_FORMATS:
260
+ # ``img.gz`` / ``img.zst`` / ``img.xz`` publish the SOURCE
261
+ # as compressed bytes but the appliance's boot chain (NBD
262
+ # -> initrd -> mount) needs raw block-device bytes.
263
+ # Decompress on the way in so the on-disk blob IS the
264
+ # mountable disk image. Content-address on the RAW sha
265
+ # (post-decompression) so /pxe/<mac> plans + NBD exports
266
+ # stay stable across recompressions of the same source.
267
+ if progress is not None:
268
+ progress({"phase": "decompressing", "format": entry.format})
269
+ decompressed_tmp, sha256, size = _decompress_to_tmpfile(
270
+ tmp_path, entry.format, store.state_dir / "tmp"
271
+ )
272
+ tmp_path.unlink(missing_ok=True)
273
+ tmp_path = decompressed_tmp
274
+ _log.info(
275
+ "fetch %r: decompressed %s -> %d raw bytes (sha=%s)",
276
+ entry.name,
277
+ entry.format,
278
+ size,
279
+ sha256[:12],
280
+ )
281
+ blob_dir = store.blob_path(sha256).parent
282
+ blob_dir.mkdir(parents=True, exist_ok=True)
283
+ blob_path = store.blob_path(sha256)
284
+ os.replace(tmp_path, blob_path)
285
+ tmp_path = blob_path
286
+ artifacts = []
287
+ else:
288
+ blob_dir = store.blob_path(sha256).parent
289
+ blob_dir.mkdir(parents=True, exist_ok=True)
290
+ blob_path = store.blob_path(sha256)
291
+ # Atomic: rename within same filesystem.
292
+ os.replace(tmp_path, blob_path)
293
+ tmp_path = blob_path # so the finally-cleanup no-ops
294
+ artifacts = []
295
+ finally:
296
+ # If tmp_path was a leftover ``.inflight`` and NOT the final
297
+ # blob_path, remove it. For the tar.gz path the tmpfile is
298
+ # explicitly discarded (bundle contents live in artifacts/).
299
+ if entry.format == "tar.gz" and tmp_path.exists():
300
+ tmp_path.unlink(missing_ok=True)
301
+
302
+ store.mark_fetched(entry.name, content_sha256=sha256, size_bytes=size)
303
+ return FetchResult(
304
+ name=entry.name,
305
+ content_sha256=sha256,
306
+ size_bytes=size,
307
+ format=entry.format,
308
+ artifact_files=artifacts,
309
+ )
310
+
311
+
312
+ _COMPRESSED_IMG_FORMATS = frozenset({"img.gz", "img.zst", "img.xz"})
313
+
314
+
315
+ def _decompress_to_tmpfile(src: Path, fmt: str, tmp_dir: Path) -> tuple[Path, str, int]:
316
+ """Stream-decompress ``src`` (a downloaded compressed disk image)
317
+ into a fresh tmpfile under ``tmp_dir``. Returns the tmpfile path
318
+ plus the sha256 + size of the DECOMPRESSED bytes.
319
+
320
+ Uses stdlib decoders for ``.gz`` and ``.xz``; shells out to the
321
+ ``zstd`` binary for ``.zst`` (the Zstandard stdlib module lands in
322
+ Python 3.14+ but pixie still supports 3.11). Raises
323
+ :class:`FetchError` with an operator-actionable message when the
324
+ input is corrupt or the decoder is missing.
325
+ """
326
+ tmp_dir.mkdir(parents=True, exist_ok=True)
327
+ tmp_path = Path(tempfile.mkstemp(prefix="decompress-", suffix=".raw", dir=tmp_dir)[1])
328
+ hasher = hashlib.sha256()
329
+ size = 0
330
+ try:
331
+ if fmt == "img.gz":
332
+ with gzip.open(src, "rb") as src_fh, open(tmp_path, "wb") as dst_fh:
333
+ while chunk := src_fh.read(CHUNK):
334
+ dst_fh.write(chunk)
335
+ hasher.update(chunk)
336
+ size += len(chunk)
337
+ elif fmt == "img.xz":
338
+ with lzma.open(src, "rb") as src_fh, open(tmp_path, "wb") as dst_fh:
339
+ while chunk := src_fh.read(CHUNK):
340
+ dst_fh.write(chunk)
341
+ hasher.update(chunk)
342
+ size += len(chunk)
343
+ elif fmt == "img.zst":
344
+ if shutil.which("zstd") is None:
345
+ raise FetchError(
346
+ "img.zst decompression needs the 'zstd' binary on PATH; install zstd"
347
+ )
348
+ with open(tmp_path, "wb") as dst_fh:
349
+ proc = subprocess.Popen(
350
+ ["zstd", "-d", "--stdout", "--quiet", str(src)],
351
+ stdout=subprocess.PIPE,
352
+ )
353
+ assert proc.stdout is not None
354
+ while chunk := proc.stdout.read(CHUNK):
355
+ dst_fh.write(chunk)
356
+ hasher.update(chunk)
357
+ size += len(chunk)
358
+ rc = proc.wait()
359
+ if rc != 0:
360
+ raise FetchError(f"zstd -d exited {rc} on {src}")
361
+ else:
362
+ raise FetchError(f"unsupported compressed img format: {fmt!r}")
363
+ except (OSError, EOFError, lzma.LZMAError, gzip.BadGzipFile) as exc:
364
+ tmp_path.unlink(missing_ok=True)
365
+ raise FetchError(f"decompress {fmt} failed: {exc}") from exc
366
+ return tmp_path, hasher.hexdigest(), size
367
+
368
+
369
+ # ------------------------------------------------------------------------
370
+ # Netboot bundle unpack
371
+ # ------------------------------------------------------------------------
372
+
373
+
374
+ _REQUIRED_ARTIFACTS = frozenset({"vmlinuz", "initrd", "manifest.json"})
375
+
376
+
377
+ def _unpack_netboot_bundle(tar_gz_path: Path, artifact_dir: Path) -> None:
378
+ """Extract ``tar_gz_path`` into ``artifact_dir`` atomically.
379
+
380
+ Contract with nosi's ``cijoe/scripts/netboot_bundle_pack.py``: the
381
+ tar.gz carries ``vmlinuz`` + ``initrd`` + ``manifest.json`` at the
382
+ archive root. Anything else is ignored (tar filters block
383
+ absolute paths + parent traversal for safety).
384
+
385
+ Atomicity: extract into a sibling staging dir, then rename onto
386
+ the final path. Callers get either a complete artifact directory
387
+ or none at all; no partial state that a subsequent serve could
388
+ trip on.
389
+ """
390
+ if artifact_dir.exists() and (artifact_dir / "manifest.json").exists():
391
+ # Idempotent: same content sha, already unpacked.
392
+ return
393
+
394
+ artifact_dir.parent.mkdir(parents=True, exist_ok=True)
395
+ staging = Path(tempfile.mkdtemp(prefix="unpack-", dir=str(artifact_dir.parent)))
396
+ try:
397
+ with tarfile.open(str(tar_gz_path), mode="r:gz") as tar:
398
+ for member in tar.getmembers():
399
+ if not member.isfile():
400
+ continue
401
+ name = os.path.basename(member.name)
402
+ if not name or name.startswith(".") or name != member.name:
403
+ # Reject nested paths + hidden files. nosi bakes
404
+ # the three known filenames at the archive root.
405
+ continue
406
+ src = tar.extractfile(member)
407
+ if src is None:
408
+ continue
409
+ dest = staging / name
410
+ with dest.open("wb") as out:
411
+ shutil.copyfileobj(src, out, length=CHUNK)
412
+
413
+ missing = _REQUIRED_ARTIFACTS - {p.name for p in staging.iterdir()}
414
+ if missing:
415
+ raise FetchError(
416
+ f"netboot bundle {tar_gz_path.name} missing required artifacts: {sorted(missing)}"
417
+ )
418
+
419
+ # Validate manifest.json is well-formed (its presence flips
420
+ # readiness in the nbdboot flow; a garbled file would show up
421
+ # as a mysterious boot failure otherwise).
422
+ try:
423
+ manifest = json.loads((staging / "manifest.json").read_text(encoding="utf-8"))
424
+ except (OSError, json.JSONDecodeError) as exc:
425
+ raise FetchError(f"netboot bundle manifest.json is not valid JSON: {exc}") from exc
426
+ if not isinstance(manifest, dict):
427
+ raise FetchError("netboot bundle manifest.json is not a JSON object")
428
+
429
+ # Commit: rename staging onto the final path.
430
+ if artifact_dir.exists():
431
+ shutil.rmtree(artifact_dir)
432
+ os.rename(staging, artifact_dir)
433
+ except Exception:
434
+ if staging.exists():
435
+ shutil.rmtree(staging, ignore_errors=True)
436
+ raise
437
+
438
+
439
+ def _list_artifact_files(artifact_dir: Path) -> list[str]:
440
+ if not artifact_dir.is_dir():
441
+ return []
442
+ return sorted(p.name for p in artifact_dir.iterdir() if p.is_file())
443
+
444
+
445
+ # ------------------------------------------------------------------------
446
+ # Helper: catalog entry from a nosi-style TOML row
447
+ # ------------------------------------------------------------------------
448
+
449
+
450
+ def entry_from_dict(row: dict[str, object]) -> CatalogEntry:
451
+ """Turn a nosi-style catalog dict row into a ``CatalogEntry``. Used
452
+ by the /catalog/entries POST route + operator UI form."""
453
+ now = now_iso()
454
+ return CatalogEntry(
455
+ name=str(row.get("name") or "").strip(),
456
+ src=str(row.get("src") or "").strip(),
457
+ format=str(row.get("format") or "").strip(),
458
+ arch=str(row.get("arch") or ""),
459
+ description=str(row.get("description") or ""),
460
+ netboot_src=str(row.get("netboot_src") or ""),
461
+ added_at=now,
462
+ )
463
+
464
+
465
+ # ------------------------------------------------------------------------
466
+ # Development shim: BytesIO -> file so tests can inject bytes
467
+ # ------------------------------------------------------------------------
468
+
469
+
470
+ def stream_bytes_to_blob(
471
+ payload: bytes,
472
+ entry: CatalogEntry,
473
+ store: CatalogStore,
474
+ ) -> FetchResult:
475
+ """Test/support helper: pretend the fetch succeeded with ``payload``.
476
+ Skips the HTTP round-trip; useful for unit tests that don't want
477
+ to bring up an http.server. Handles both the disk-image and
478
+ tar.gz paths symmetrically with :func:`fetch`.
479
+ """
480
+ sha256 = hashlib.sha256(payload).hexdigest()
481
+ size = len(payload)
482
+ tmp = store.state_dir / "tmp"
483
+ tmp.mkdir(parents=True, exist_ok=True)
484
+ tmp_path = tmp / f"synthetic-{sha256[:12]}.inflight"
485
+ tmp_path.write_bytes(payload)
486
+
487
+ try:
488
+ if entry.format == "tar.gz":
489
+ artifact_dir = store.artifact_dir(sha256)
490
+ _unpack_netboot_bundle(tmp_path, artifact_dir)
491
+ artifacts = _list_artifact_files(artifact_dir)
492
+ else:
493
+ blob_dir = store.blob_path(sha256).parent
494
+ blob_dir.mkdir(parents=True, exist_ok=True)
495
+ os.replace(tmp_path, store.blob_path(sha256))
496
+ tmp_path = store.blob_path(sha256)
497
+ artifacts = []
498
+ finally:
499
+ if entry.format == "tar.gz" and tmp_path.exists():
500
+ tmp_path.unlink(missing_ok=True)
501
+
502
+ store.mark_fetched(entry.name, content_sha256=sha256, size_bytes=size)
503
+ return FetchResult(
504
+ name=entry.name,
505
+ content_sha256=sha256,
506
+ size_bytes=size,
507
+ format=entry.format,
508
+ artifact_files=artifacts,
509
+ )
510
+
511
+
512
+ # tests; keep imports for future consumers so the module stays stable.
513
+ _ = io.BytesIO