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/catalog/_routes.py
ADDED
|
@@ -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))
|
pixie/catalog/_schema.py
ADDED
|
@@ -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")
|