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/__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
|