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/tui_catalog.py
ADDED
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
"""pixie catalog manifest: TOML parser + URL canonicalisation.
|
|
2
|
+
|
|
3
|
+
A catalog manifest (TOML, ``${PIXIE_STATE_DIR}/catalog.toml`` by
|
|
4
|
+
default) lists named images with upstream ``src`` URLs and (optional)
|
|
5
|
+
pinned ``sha256`` digests:
|
|
6
|
+
|
|
7
|
+
.. code-block:: toml
|
|
8
|
+
|
|
9
|
+
version = 1
|
|
10
|
+
|
|
11
|
+
[[images]]
|
|
12
|
+
name = "ubuntu-server-22.04-pixie.img.zst"
|
|
13
|
+
src = "https://github.com/safl/pixie-images/releases/download/v0.1/ubuntu-22.04.img.zst"
|
|
14
|
+
sha256 = "abc123..."
|
|
15
|
+
format = "img.zst"
|
|
16
|
+
|
|
17
|
+
pixie holds no image bytes. The live env fetches each ``src``
|
|
18
|
+
directly (or via withcache when the cache is warm); this module is the
|
|
19
|
+
schema parser + URL canonicaliser + the ``stream_src`` proxy helper
|
|
20
|
+
pixie's /images route uses for oras-style sources.
|
|
21
|
+
``image_ref_for_src`` produces the stable provenance id machine
|
|
22
|
+
bindings target -- pure math over the canonical URL.
|
|
23
|
+
|
|
24
|
+
Module is stdlib-only -- ``tomllib`` is in Python 3.11+ stdlib,
|
|
25
|
+
``hashlib`` / ``urllib`` are too. ``pixie`` and ``pixie`` both
|
|
26
|
+
consume this module without dragging in any extra dependency beyond
|
|
27
|
+
their own.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import hashlib
|
|
33
|
+
import os
|
|
34
|
+
import tomllib
|
|
35
|
+
import urllib.error
|
|
36
|
+
import urllib.parse
|
|
37
|
+
import urllib.request
|
|
38
|
+
from collections.abc import Iterator
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Any, Self
|
|
42
|
+
|
|
43
|
+
from pixie import images as _images
|
|
44
|
+
|
|
45
|
+
# Manifest schema version this implementation understands.
|
|
46
|
+
SCHEMA_VERSION = 1
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CatalogError(Exception):
|
|
50
|
+
"""Raised when a catalog file fails to parse, validate, or fetch.
|
|
51
|
+
|
|
52
|
+
The message is operator-facing: both the ``pixie`` wizard and
|
|
53
|
+
pixie print it verbatim, so it uses "catalog" vocabulary to
|
|
54
|
+
match the UI (the sha256-sidecar checksum file is a distinct
|
|
55
|
+
"manifest" and keeps that term). Subclass only when a call
|
|
56
|
+
site needs to discriminate.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class CatalogEntry:
|
|
62
|
+
"""One image declared in a manifest.
|
|
63
|
+
|
|
64
|
+
``format`` defaults to whatever ``pixie.images.detect_format``
|
|
65
|
+
returns for ``name`` -- so a manifest entry named
|
|
66
|
+
``foo.img.zst`` doesn't need to repeat ``format = "img.zst"``.
|
|
67
|
+
Operators can still set it explicitly to disambiguate or to
|
|
68
|
+
name a format detection wouldn't infer (e.g. extension-less
|
|
69
|
+
files served from a CDN).
|
|
70
|
+
|
|
71
|
+
``sha256`` is optional in the schema. Both ``pixie --catalog``
|
|
72
|
+
(portable catalog: display + flash from src) and the withcache
|
|
73
|
+
sidecar flash from ``src`` without ever requiring a sha.
|
|
74
|
+
Digest verification happens at flash time for ``oras://``
|
|
75
|
+
(the manifest layer digest is the URL); for ``http(s)://`` TLS
|
|
76
|
+
is the in-flight guarantee. When ``sha256`` is set, the flash
|
|
77
|
+
verifies the streamed bytes against it on the wire (see
|
|
78
|
+
``pixie.flash``); it is also shown in the UI.
|
|
79
|
+
|
|
80
|
+
The schema decoupling is intentional: rolling tags (``oras://
|
|
81
|
+
...:latest``, ``github.com/.../releases/latest/download/...``)
|
|
82
|
+
have no stable sha at catalog-publish time. Pre-pinning would
|
|
83
|
+
freeze the catalog to whatever upstream looked like that
|
|
84
|
+
afternoon, defeating the rolling-publish design.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
name: str
|
|
88
|
+
src: str
|
|
89
|
+
sha256: str | None
|
|
90
|
+
format: str | None = None
|
|
91
|
+
size_bytes: int | None = None
|
|
92
|
+
description: str | None = None
|
|
93
|
+
# Informational architecture hint (``x86_64`` / ``arm64`` / ...);
|
|
94
|
+
# operator-facing display only, never used to filter or restrict
|
|
95
|
+
# the flash. Explicit ``arch = "..."`` in the manifest wins;
|
|
96
|
+
# otherwise inferred from ``name`` via
|
|
97
|
+
# :func:`pixie.images.detect_arch_from_name`. ``None`` when neither
|
|
98
|
+
# source resolves a recognised token.
|
|
99
|
+
arch: str | None = None
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def ref(self) -> str:
|
|
103
|
+
"""Stable provenance id, derived from ``src``.
|
|
104
|
+
|
|
105
|
+
``image_ref_for_src(canonicalise_src(self.src))`` -- a
|
|
106
|
+
64-hex sha256 over the canonical URL. Always present
|
|
107
|
+
(it's pure math on ``src``); same value across processes,
|
|
108
|
+
operators, and time. Distinct from ``sha256`` (which is
|
|
109
|
+
the *observed content* hash and can be ``None``).
|
|
110
|
+
|
|
111
|
+
Exposed as a property rather than a stored field so the
|
|
112
|
+
invariant ``ref == image_ref_for_src(src)`` cannot drift
|
|
113
|
+
-- there's no second copy to get out of sync.
|
|
114
|
+
"""
|
|
115
|
+
return image_ref_for_src(self.src)
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def from_dict(cls, raw: dict[str, Any]) -> Self:
|
|
119
|
+
for required in ("name", "src"):
|
|
120
|
+
if required not in raw:
|
|
121
|
+
raise CatalogError(
|
|
122
|
+
f"catalog entry missing required field: {required!r} (entry: {raw!r})"
|
|
123
|
+
)
|
|
124
|
+
src = str(raw["src"])
|
|
125
|
+
sha: str | None = None
|
|
126
|
+
if "sha256" in raw and raw["sha256"] is not None:
|
|
127
|
+
sha = str(raw["sha256"]).strip().lower()
|
|
128
|
+
if not _images.is_sha256_hex(sha):
|
|
129
|
+
raise CatalogError(
|
|
130
|
+
f"catalog entry {raw['name']!r}: sha256 must be a 64-char "
|
|
131
|
+
f"lower-case hex string, got {sha!r}"
|
|
132
|
+
)
|
|
133
|
+
# Trust-but-verify: if the inbound dict carries a ``ref``
|
|
134
|
+
# field, recompute it from ``src`` and reject on mismatch.
|
|
135
|
+
# Catches drift between a producer's canonicalisation and
|
|
136
|
+
# ours, and prevents a malformed import from binding
|
|
137
|
+
# machines to bytes the operator didn't intend. Bare-text
|
|
138
|
+
# ``ref`` is normalised to lower-case hex like ``sha256``.
|
|
139
|
+
if "ref" in raw and raw["ref"] is not None:
|
|
140
|
+
supplied_ref = str(raw["ref"]).strip().lower()
|
|
141
|
+
try:
|
|
142
|
+
expected_ref = image_ref_for_src(src)
|
|
143
|
+
except ValueError as exc:
|
|
144
|
+
raise CatalogError(
|
|
145
|
+
f"catalog entry {raw['name']!r}: cannot verify ``ref`` "
|
|
146
|
+
f"because ``src`` is malformed: {exc}"
|
|
147
|
+
) from exc
|
|
148
|
+
if supplied_ref != expected_ref:
|
|
149
|
+
raise CatalogError(
|
|
150
|
+
f"catalog entry {raw['name']!r}: ``ref`` mismatch: "
|
|
151
|
+
f"supplied {supplied_ref!r} but image_ref_for_src(src) "
|
|
152
|
+
f"= {expected_ref!r}. The ref must equal "
|
|
153
|
+
f"sha256(canonicalise_src(src)); either the producer's "
|
|
154
|
+
f"canonicalisation differs from ours or the entry was "
|
|
155
|
+
f"tampered with."
|
|
156
|
+
)
|
|
157
|
+
name = str(raw["name"])
|
|
158
|
+
return cls(
|
|
159
|
+
name=name,
|
|
160
|
+
src=src,
|
|
161
|
+
sha256=sha,
|
|
162
|
+
format=raw.get("format") or _images.detect_format(Path(name)),
|
|
163
|
+
size_bytes=int(raw["size_bytes"]) if raw.get("size_bytes") is not None else None,
|
|
164
|
+
description=raw.get("description"),
|
|
165
|
+
arch=raw.get("arch") or _images.detect_arch_from_name(name),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass(frozen=True)
|
|
170
|
+
class Catalog:
|
|
171
|
+
"""Parsed manifest. Wraps the entry list with a few lookup
|
|
172
|
+
helpers; otherwise just a typed container.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
version: int
|
|
176
|
+
entries: tuple[CatalogEntry, ...] = field(default_factory=tuple)
|
|
177
|
+
|
|
178
|
+
def by_name(self, name: str) -> CatalogEntry | None:
|
|
179
|
+
for entry in self.entries:
|
|
180
|
+
if entry.name == name:
|
|
181
|
+
return entry
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
def __iter__(self) -> Iterator[CatalogEntry]:
|
|
185
|
+
return iter(self.entries)
|
|
186
|
+
|
|
187
|
+
def __len__(self) -> int:
|
|
188
|
+
return len(self.entries)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def load_bytes(raw_bytes: bytes, *, source: str = "<bytes>") -> Catalog:
|
|
192
|
+
"""Parse a TOML manifest from an in-memory bytes buffer.
|
|
193
|
+
|
|
194
|
+
Used by ``pixie.tui``'s ``--catalog`` fetcher when the catalog
|
|
195
|
+
comes from HTTP / ORAS rather than a local path. ``source`` is
|
|
196
|
+
a label for error messages (a URL, a label like ``<http>``);
|
|
197
|
+
the bytes themselves carry no provenance.
|
|
198
|
+
"""
|
|
199
|
+
try:
|
|
200
|
+
raw = tomllib.loads(raw_bytes.decode("utf-8"))
|
|
201
|
+
except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
|
|
202
|
+
raise CatalogError(f"catalog at {source} is not valid TOML: {exc}") from exc
|
|
203
|
+
|
|
204
|
+
version = raw.get("version")
|
|
205
|
+
if version != SCHEMA_VERSION:
|
|
206
|
+
raise CatalogError(
|
|
207
|
+
f"catalog at {source}: version={version!r}, "
|
|
208
|
+
f"this pixie understands version={SCHEMA_VERSION}"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
images_raw = raw.get("images", [])
|
|
212
|
+
if not isinstance(images_raw, list):
|
|
213
|
+
raise CatalogError(
|
|
214
|
+
f"catalog at {source}: ``images`` must be an array of tables, "
|
|
215
|
+
f"got {type(images_raw).__name__}"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
entries: list[CatalogEntry] = []
|
|
219
|
+
seen_names: set[str] = set()
|
|
220
|
+
for raw_entry in images_raw:
|
|
221
|
+
if not isinstance(raw_entry, dict):
|
|
222
|
+
raise CatalogError(
|
|
223
|
+
f"catalog at {source}: ``images`` entry must be a table, "
|
|
224
|
+
f"got {type(raw_entry).__name__}"
|
|
225
|
+
)
|
|
226
|
+
entry = CatalogEntry.from_dict(raw_entry)
|
|
227
|
+
if entry.name in seen_names:
|
|
228
|
+
raise CatalogError(f"catalog at {source}: duplicate image name {entry.name!r}")
|
|
229
|
+
# Contract: a catalog manifest is a PUBLISHABLE artifact -- pixie
|
|
230
|
+
# serves one, the GitHub release publishes one, an operator points
|
|
231
|
+
# ``pixie --catalog`` at one. ``file://`` srcs are meaningless to any
|
|
232
|
+
# receiver other than the publisher's host (the path's gone the
|
|
233
|
+
# moment you copy the file elsewhere), so they cannot appear in a
|
|
234
|
+
# parsed catalog. Local files live in the image-root and are
|
|
235
|
+
# discovered separately (``images.list_images``); they never make
|
|
236
|
+
# it into a manifest.
|
|
237
|
+
if entry.src.startswith("file://"):
|
|
238
|
+
raise CatalogError(
|
|
239
|
+
f"catalog at {source}: entry {entry.name!r} has ``file://`` src "
|
|
240
|
+
f"({entry.src!r}); catalog manifests carry only remote sources "
|
|
241
|
+
f"(oras:// / http:// / https://) so the file is meaningful to a "
|
|
242
|
+
f"receiver. Local files belong in the image-root, not a manifest."
|
|
243
|
+
)
|
|
244
|
+
seen_names.add(entry.name)
|
|
245
|
+
entries.append(entry)
|
|
246
|
+
|
|
247
|
+
return Catalog(version=version, entries=tuple(entries))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def load(path: Path) -> Catalog:
|
|
251
|
+
"""Parse a TOML manifest from a local file path.
|
|
252
|
+
|
|
253
|
+
Thin wrapper around :func:`load_bytes` for the file-path case.
|
|
254
|
+
Raises :class:`CatalogError` on missing files plus everything
|
|
255
|
+
:func:`load_bytes` raises.
|
|
256
|
+
"""
|
|
257
|
+
if not path.exists():
|
|
258
|
+
raise CatalogError(f"catalog not found: {path}")
|
|
259
|
+
return load_bytes(path.read_bytes(), source=str(path))
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# Cap for ``fetch_bytes`` over http(s) and oras. 4 MiB is roomy for
|
|
263
|
+
# a hand-edited TOML index (hundreds of entries) while keeping a
|
|
264
|
+
# hostile / misconfigured remote from OOMing the caller. Local-file
|
|
265
|
+
# reads are uncapped: the operator's own filesystem is not a hostile
|
|
266
|
+
# boundary.
|
|
267
|
+
REMOTE_CATALOG_MAX_BYTES = 4 * 1024 * 1024
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def classify_source(source: str) -> str:
|
|
271
|
+
"""Return the dispatch kind for a catalog source: ``"path"``,
|
|
272
|
+
``"http"``, or ``"oras"``. Raises :class:`ValueError` otherwise.
|
|
273
|
+
|
|
274
|
+
Heuristic: an explicit ``http://`` / ``https://`` / ``oras://`` /
|
|
275
|
+
``file://`` scheme dispatches by scheme. Everything else (bare
|
|
276
|
+
paths like ``./catalog.toml`` or ``/etc/pixie/catalog.toml``) is
|
|
277
|
+
treated as a filesystem path. ``file://`` maps to ``"path"``.
|
|
278
|
+
"""
|
|
279
|
+
parsed = urllib.parse.urlparse(source)
|
|
280
|
+
if parsed.scheme in ("http", "https"):
|
|
281
|
+
if not parsed.netloc:
|
|
282
|
+
raise ValueError(
|
|
283
|
+
f"catalog URL missing a host: {source!r} (expected http(s)://<host>/<path>)"
|
|
284
|
+
)
|
|
285
|
+
return "http"
|
|
286
|
+
if parsed.scheme == "oras":
|
|
287
|
+
return "oras"
|
|
288
|
+
if parsed.scheme in ("", "file"):
|
|
289
|
+
return "path"
|
|
290
|
+
raise ValueError(
|
|
291
|
+
f"catalog source must be a local path or http(s):// / oras:// URL; "
|
|
292
|
+
f"got scheme {parsed.scheme!r} in {source!r}"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def fetch_bytes(source: str, *, timeout: float = 30.0) -> bytes:
|
|
297
|
+
"""Fetch a catalog TOML's raw bytes from a path / http(s) / oras source.
|
|
298
|
+
|
|
299
|
+
Caps remote responses at :data:`REMOTE_CATALOG_MAX_BYTES`. Resolves
|
|
300
|
+
``oras://`` references through :mod:`withcache.oras` (anonymous-pull flow
|
|
301
|
+
against the OCI registry). Returns the raw TOML bytes; the caller
|
|
302
|
+
feeds these to :func:`load_bytes`.
|
|
303
|
+
"""
|
|
304
|
+
kind = classify_source(source)
|
|
305
|
+
if kind == "path":
|
|
306
|
+
parsed = urllib.parse.urlparse(source)
|
|
307
|
+
path = Path(parsed.path) if parsed.scheme == "file" else Path(source)
|
|
308
|
+
return path.read_bytes()
|
|
309
|
+
if kind == "oras":
|
|
310
|
+
# Defer the import so callers that never use oras don't pay
|
|
311
|
+
# the import cost. (``withcache.oras`` is pure-stdlib, so this is
|
|
312
|
+
# mostly cosmetic, but keeps the load graph tidy.)
|
|
313
|
+
from pixie import oras as _oras
|
|
314
|
+
|
|
315
|
+
resolved = _oras.resolve_ref(source, timeout=timeout)
|
|
316
|
+
req = urllib.request.Request(resolved.blob_url, headers=resolved.headers)
|
|
317
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
318
|
+
raw = resp.read(REMOTE_CATALOG_MAX_BYTES + 1)
|
|
319
|
+
else: # kind == "http"
|
|
320
|
+
with urllib.request.urlopen(source, timeout=timeout) as resp:
|
|
321
|
+
raw = resp.read(REMOTE_CATALOG_MAX_BYTES + 1)
|
|
322
|
+
if len(raw) > REMOTE_CATALOG_MAX_BYTES:
|
|
323
|
+
raise CatalogError(
|
|
324
|
+
f"catalog response from {source} exceeded "
|
|
325
|
+
f"{REMOTE_CATALOG_MAX_BYTES} bytes; refusing to parse"
|
|
326
|
+
)
|
|
327
|
+
return bytes(raw)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def load_source(source: str, *, timeout: float = 30.0) -> Catalog:
|
|
331
|
+
"""Fetch + parse a catalog from any supported source.
|
|
332
|
+
|
|
333
|
+
Convenience wrapper combining :func:`fetch_bytes` and
|
|
334
|
+
:func:`load_bytes`. Used by ``pixie --catalog`` and pixie's
|
|
335
|
+
catalog ingestion path.
|
|
336
|
+
"""
|
|
337
|
+
raw = fetch_bytes(source, timeout=timeout)
|
|
338
|
+
return load_bytes(raw, source=source)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def default_manifest_path() -> Path | None:
|
|
342
|
+
"""Resolve the manifest path from the environment.
|
|
343
|
+
|
|
344
|
+
Order: ``$PIXIE_CATALOG_FILE`` (explicit path), else
|
|
345
|
+
``${PIXIE_STATE_DIR}/catalog.toml`` (default colocation). Returns
|
|
346
|
+
``None`` if no path is configured AND the default-colocated
|
|
347
|
+
file does not exist -- so the absence of a catalog is not an
|
|
348
|
+
error, it's just "no catalog configured".
|
|
349
|
+
"""
|
|
350
|
+
explicit = os.environ.get("PIXIE_CATALOG_FILE")
|
|
351
|
+
if explicit:
|
|
352
|
+
return Path(explicit)
|
|
353
|
+
state_dir = Path(os.environ.get("PIXIE_STATE_DIR", "/var/lib/pixie"))
|
|
354
|
+
candidate = state_dir / "catalog.toml"
|
|
355
|
+
if candidate.exists():
|
|
356
|
+
return candidate
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# ---------------------------------------------------------------------------
|
|
361
|
+
# Image-ref derivation
|
|
362
|
+
#
|
|
363
|
+
# Every catalog entry has a stable identifier ``bty_image_ref`` derived from
|
|
364
|
+
# its ``src`` URL. The same canonicalisation rules apply to all source
|
|
365
|
+
# schemes so trivial variations don't produce phantom-duplicate entries.
|
|
366
|
+
# See ``docs/src/reference.md`` for the locked rule tables; tests in
|
|
367
|
+
# ``tests/test_catalog.py`` cover every row of each table.
|
|
368
|
+
|
|
369
|
+
_HTTP_DEFAULT_PORTS = {"http": 80, "https": 443}
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _canonicalise_file(src: str) -> str:
|
|
373
|
+
"""Canonicalise a ``file://<root-relative-path>`` src.
|
|
374
|
+
|
|
375
|
+
- strip the ``file://`` prefix
|
|
376
|
+
- reject any ``..`` segment (no escaping image-root)
|
|
377
|
+
- reject NUL bytes
|
|
378
|
+
- drop ``.`` and empty path segments (collapse ``./``, ``//``,
|
|
379
|
+
leading ``/``)
|
|
380
|
+
- preserve case (Linux filesystems are case-sensitive)
|
|
381
|
+
- reject empty result
|
|
382
|
+
"""
|
|
383
|
+
assert src.startswith("file://")
|
|
384
|
+
path = src[len("file://") :]
|
|
385
|
+
if "\x00" in path:
|
|
386
|
+
raise ValueError(f"file:// src contains NUL byte: {src!r}")
|
|
387
|
+
segments = path.split("/")
|
|
388
|
+
if any(seg == ".." for seg in segments):
|
|
389
|
+
raise ValueError(f"file:// src contains '..' segment: {src!r}")
|
|
390
|
+
kept = [seg for seg in segments if seg and seg != "."]
|
|
391
|
+
if not kept:
|
|
392
|
+
raise ValueError(f"file:// src normalises to empty path: {src!r}")
|
|
393
|
+
return "file://" + "/".join(kept)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _canonicalise_http(src: str) -> str:
|
|
397
|
+
"""Canonicalise an ``http://`` / ``https://`` src.
|
|
398
|
+
|
|
399
|
+
- lower-case scheme + host
|
|
400
|
+
- strip default port (``:80`` for http, ``:443`` for https)
|
|
401
|
+
- preserve path / query / fragment / trailing slash / percent-
|
|
402
|
+
encoding literally (servers can disambiguate by these)
|
|
403
|
+
"""
|
|
404
|
+
parsed = urllib.parse.urlsplit(src)
|
|
405
|
+
scheme = parsed.scheme.lower()
|
|
406
|
+
if scheme not in ("http", "https"):
|
|
407
|
+
raise ValueError(f"unexpected scheme {scheme!r} for http canonicaliser: {src!r}")
|
|
408
|
+
host = parsed.hostname
|
|
409
|
+
if not host:
|
|
410
|
+
raise ValueError(f"http(s) src missing host: {src!r}")
|
|
411
|
+
host = host.lower()
|
|
412
|
+
netloc = host
|
|
413
|
+
if parsed.port is not None and parsed.port != _HTTP_DEFAULT_PORTS[scheme]:
|
|
414
|
+
netloc = f"{host}:{parsed.port}"
|
|
415
|
+
return urllib.parse.urlunsplit((scheme, netloc, parsed.path, parsed.query, parsed.fragment))
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _canonicalise_oras(src: str) -> str:
|
|
419
|
+
"""Canonicalise an ``oras://`` src.
|
|
420
|
+
|
|
421
|
+
- lower-case host + repository (DNS / OCI distribution spec)
|
|
422
|
+
- preserve tag literally (OCI tags are case-sensitive)
|
|
423
|
+
- preserve digest literally
|
|
424
|
+
- validates structure via ``withcache.oras.parse_ref`` so a malformed
|
|
425
|
+
ref errors here rather than mid-flash
|
|
426
|
+
|
|
427
|
+
Lower-cases the ``<host>/<repo>`` prefix BEFORE handing to
|
|
428
|
+
parse_ref so operators who type ``oras://Ghcr.IO/Owner/...``
|
|
429
|
+
don't get rejected by parse_ref's lowercase-only repo regex
|
|
430
|
+
(which enforces the OCI spec but is stricter than what real
|
|
431
|
+
registries accept).
|
|
432
|
+
"""
|
|
433
|
+
from pixie import oras as _oras
|
|
434
|
+
|
|
435
|
+
body = src[len("oras://") :]
|
|
436
|
+
if not body:
|
|
437
|
+
raise ValueError("empty oras src")
|
|
438
|
+
# Locate the tag/digest separator and lower-case only the
|
|
439
|
+
# <host>/<repo> prefix; tag / digest after it keep their case.
|
|
440
|
+
if "@" in body:
|
|
441
|
+
idx = body.rindex("@")
|
|
442
|
+
elif ":" in body:
|
|
443
|
+
idx = body.rindex(":")
|
|
444
|
+
else:
|
|
445
|
+
# No separator -- parse_ref will reject below.
|
|
446
|
+
idx = len(body)
|
|
447
|
+
prefix = body[:idx].lower()
|
|
448
|
+
suffix = body[idx:]
|
|
449
|
+
try:
|
|
450
|
+
ref = _oras.parse_ref(f"oras://{prefix}{suffix}")
|
|
451
|
+
except _oras.OrasError as exc:
|
|
452
|
+
raise ValueError(f"malformed oras src: {exc}") from exc
|
|
453
|
+
if ref.digest is not None:
|
|
454
|
+
return f"oras://{ref.host}/{ref.repository}@{ref.digest}"
|
|
455
|
+
return f"oras://{ref.host}/{ref.repository}:{ref.tag}"
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def canonicalise_src(src: str) -> str:
|
|
459
|
+
"""Return the canonical form of a catalog ``src`` URL.
|
|
460
|
+
|
|
461
|
+
Dispatches on scheme:
|
|
462
|
+
|
|
463
|
+
- ``file://<rel-path>`` -- root-relative; segments normalised.
|
|
464
|
+
- ``http(s)://...`` -- scheme + host lower-cased; default
|
|
465
|
+
port stripped.
|
|
466
|
+
- ``oras://...`` -- host + repo lower-cased.
|
|
467
|
+
|
|
468
|
+
Raises :class:`ValueError` for any other scheme or malformed
|
|
469
|
+
input. The full per-scheme rule table lives in
|
|
470
|
+
``docs/src/reference.md``; tests cover every row.
|
|
471
|
+
|
|
472
|
+
Scheme prefix matching is case-insensitive (so ``HTTPS://...``
|
|
473
|
+
dispatches to the http canonicaliser, which then lower-cases
|
|
474
|
+
the scheme). Other parts of each scheme have scheme-specific
|
|
475
|
+
case rules in their respective helpers.
|
|
476
|
+
"""
|
|
477
|
+
if not src:
|
|
478
|
+
raise ValueError("empty src")
|
|
479
|
+
lowered = src.lower()
|
|
480
|
+
if lowered.startswith("file://"):
|
|
481
|
+
return _canonicalise_file(src)
|
|
482
|
+
if lowered.startswith(("http://", "https://")):
|
|
483
|
+
return _canonicalise_http(src)
|
|
484
|
+
if lowered.startswith("oras://"):
|
|
485
|
+
return _canonicalise_oras(src)
|
|
486
|
+
raise ValueError(f"unsupported src scheme: {src!r} (expected file://, http(s)://, or oras://)")
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def image_ref_for_src(src: str) -> str:
|
|
490
|
+
"""Compute the ``bty_image_ref`` for a catalog ``src``.
|
|
491
|
+
|
|
492
|
+
Returns a 64-character lowercase hex string -- the SHA-256 of
|
|
493
|
+
the canonical form of ``src`` (see :func:`canonicalise_src`).
|
|
494
|
+
Same algorithm for every source kind so the catalog has a
|
|
495
|
+
uniform identifier space.
|
|
496
|
+
|
|
497
|
+
The image-ref is **stable provenance**: identical src strings
|
|
498
|
+
produce identical refs across operators, machines, and time.
|
|
499
|
+
It is **not** a content hash; an oras rolling tag's ref stays
|
|
500
|
+
the same when the underlying content changes. The observed
|
|
501
|
+
content hash lives separately in ``CatalogEntry.disk_image_sha``
|
|
502
|
+
(populated on first cache).
|
|
503
|
+
"""
|
|
504
|
+
canonical = canonicalise_src(src)
|
|
505
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def stream_src(
|
|
509
|
+
src: str,
|
|
510
|
+
*,
|
|
511
|
+
chunk_size: int = 1 << 20,
|
|
512
|
+
timeout: float = 300.0,
|
|
513
|
+
) -> tuple[Iterator[bytes], int | None]:
|
|
514
|
+
"""Open a remote ``src`` (oras:// / http(s)://) and return
|
|
515
|
+
``(chunk_iterator, content_length_or_None)`` for streaming the bytes
|
|
516
|
+
straight through to a client WITHOUT caching to disk.
|
|
517
|
+
|
|
518
|
+
pixie's image-serve path uses this to proxy a remote image to a
|
|
519
|
+
flashing client: bytes flow source -> server -> client as they
|
|
520
|
+
arrive (no buffer-then-serve, no .tmp), so the client's
|
|
521
|
+
``curl | dd`` starts writing immediately and a large image never
|
|
522
|
+
times out a probe or thrashes a cache. ``file://`` srcs are already
|
|
523
|
+
local and raise ``ValueError``.
|
|
524
|
+
|
|
525
|
+
The returned iterator owns the connection and closes it when
|
|
526
|
+
exhausted (or when the consumer stops iterating + it's GC'd).
|
|
527
|
+
"""
|
|
528
|
+
if src.startswith("oras://"):
|
|
529
|
+
from pixie import oras as _oras
|
|
530
|
+
|
|
531
|
+
resolved = _oras.resolve_ref(src, timeout=timeout)
|
|
532
|
+
req = urllib.request.Request(resolved.blob_url, headers=resolved.headers)
|
|
533
|
+
resp = urllib.request.urlopen(req, timeout=timeout)
|
|
534
|
+
elif src.startswith(("http://", "https://")):
|
|
535
|
+
resp = urllib.request.urlopen(src, timeout=timeout)
|
|
536
|
+
else:
|
|
537
|
+
raise ValueError(f"stream_src handles only oras:// / http(s):// srcs: {src!r}")
|
|
538
|
+
try:
|
|
539
|
+
cl = resp.headers.get("Content-Length")
|
|
540
|
+
total = int(cl) if cl is not None else None
|
|
541
|
+
except (ValueError, AttributeError):
|
|
542
|
+
total = None
|
|
543
|
+
|
|
544
|
+
def _chunks() -> Iterator[bytes]:
|
|
545
|
+
# Track emitted bytes so an upstream close that arrives before
|
|
546
|
+
# ``Content-Length`` is reached surfaces as a hard error
|
|
547
|
+
# instead of a silent short-read. Without this, the
|
|
548
|
+
# StreamingResponse downstream finishes cleanly while the
|
|
549
|
+
# client (curl / dd) detects the mismatch as exit 18; the
|
|
550
|
+
# pixie journal contains no record of WHICH blob truncated
|
|
551
|
+
# OR by how much. Mirrors withcache's ``TruncatedDownload``
|
|
552
|
+
# guard on its own fill path (safl/withcache#7), but for the
|
|
553
|
+
# pixie -> oras-registry hop which bypasses withcache.
|
|
554
|
+
emitted = 0
|
|
555
|
+
try:
|
|
556
|
+
while True:
|
|
557
|
+
chunk = resp.read(chunk_size)
|
|
558
|
+
if not chunk:
|
|
559
|
+
break
|
|
560
|
+
emitted += len(chunk)
|
|
561
|
+
yield chunk
|
|
562
|
+
finally:
|
|
563
|
+
resp.close()
|
|
564
|
+
if total is not None and emitted < total:
|
|
565
|
+
# Raise AFTER ``resp.close()`` so the upstream connection
|
|
566
|
+
# is reaped cleanly; the in-flight HTTP response has
|
|
567
|
+
# already been emitted up to ``emitted`` bytes. The
|
|
568
|
+
# exception propagates up to the caller as a CatalogError
|
|
569
|
+
# which they can surface / log as they see fit.
|
|
570
|
+
raise CatalogError(f"upstream short read on {src!r}: got {emitted} of {total} bytes")
|
|
571
|
+
|
|
572
|
+
return _chunks(), total
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def parse_sha256_manifest(text: str, target_name: str | None = None) -> str:
|
|
576
|
+
"""Parse a sha256sum-style manifest body and return the matching
|
|
577
|
+
digest.
|
|
578
|
+
|
|
579
|
+
Accepted shapes:
|
|
580
|
+
|
|
581
|
+
- Single-line bare digest: ``"abc123...def\\n"`` (64 lower-hex).
|
|
582
|
+
- sha256sum output: ``"<digest> <filename>\\n"`` -- one or more
|
|
583
|
+
lines, with two-space or whitespace separator. ``*`` and
|
|
584
|
+
``./`` filename prefixes are stripped (sha256sum binary-mode
|
|
585
|
+
marker / relative-path noise).
|
|
586
|
+
|
|
587
|
+
If ``target_name`` is given, return the digest whose filename
|
|
588
|
+
matches; otherwise return the digest of the first usable line.
|
|
589
|
+
Raises :class:`CatalogError` on empty input, malformed digests,
|
|
590
|
+
or a missing target filename.
|
|
591
|
+
"""
|
|
592
|
+
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
|
593
|
+
if not lines:
|
|
594
|
+
raise CatalogError("empty sha256 manifest")
|
|
595
|
+
|
|
596
|
+
candidates: list[tuple[str, str | None]] = []
|
|
597
|
+
for raw in lines:
|
|
598
|
+
parts = raw.split(maxsplit=1)
|
|
599
|
+
digest = parts[0].strip().lower()
|
|
600
|
+
if not _images.is_sha256_hex(digest):
|
|
601
|
+
raise CatalogError(f"malformed sha256 line in manifest: {raw!r}")
|
|
602
|
+
name = parts[1].lstrip("*./").strip() if len(parts) == 2 else None
|
|
603
|
+
candidates.append((digest, name))
|
|
604
|
+
|
|
605
|
+
if target_name is not None:
|
|
606
|
+
for digest, name in candidates:
|
|
607
|
+
if name == target_name:
|
|
608
|
+
return digest
|
|
609
|
+
raise CatalogError(
|
|
610
|
+
f"sha256 manifest does not list a digest for {target_name!r}; "
|
|
611
|
+
f"available names: {sorted(n for _, n in candidates if n)}"
|
|
612
|
+
)
|
|
613
|
+
# No target requested: take the first line. Caller's choice.
|
|
614
|
+
return candidates[0][0]
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
# sha256sum-style manifests are tiny in practice: one line per
|
|
618
|
+
# artifact at ~80 bytes each. 1 MiB caps a maliciously-large or
|
|
619
|
+
# wrong-URL response without rejecting any plausible real
|
|
620
|
+
# manifest. Without this cap, a ``sha_url`` that points at a
|
|
621
|
+
# multi-GB file (operator typo into the image url field) reads
|
|
622
|
+
# the whole body into memory.
|
|
623
|
+
_SHA_MANIFEST_MAX_BYTES = 1 << 20
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def fetch_sha256_for_url(image_url: str, sha_url: str, *, timeout: float = 30.0) -> str:
|
|
627
|
+
"""Fetch ``sha_url``, parse it, and return the sha256 digest of
|
|
628
|
+
the file that ``image_url`` would download.
|
|
629
|
+
|
|
630
|
+
The match is filename-based: ``Path(urlparse(image_url).path).name``
|
|
631
|
+
is the target. Most upstream conventions ship a per-artifact
|
|
632
|
+
sha256 manifest with that exact filename, so the lookup is
|
|
633
|
+
direct. If the manifest only carries one entry, that entry is
|
|
634
|
+
returned regardless of name.
|
|
635
|
+
|
|
636
|
+
Raises :class:`CatalogError` if the body is larger than
|
|
637
|
+
:data:`_SHA_MANIFEST_MAX_BYTES` (defends against the operator
|
|
638
|
+
pasting an *image* URL into the sha_url field by accident).
|
|
639
|
+
"""
|
|
640
|
+
target = Path(urllib.parse.urlparse(image_url).path).name
|
|
641
|
+
req = urllib.request.Request(sha_url)
|
|
642
|
+
try:
|
|
643
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
644
|
+
# Read one byte past the cap so we can distinguish
|
|
645
|
+
# "fits in the cap" from "exceeded the cap"; the OS
|
|
646
|
+
# short-reads aren't reliable here.
|
|
647
|
+
raw = resp.read(_SHA_MANIFEST_MAX_BYTES + 1)
|
|
648
|
+
except (urllib.error.URLError, ConnectionError, TimeoutError) as exc:
|
|
649
|
+
raise CatalogError(f"GET {sha_url} failed: {exc}") from exc
|
|
650
|
+
if len(raw) > _SHA_MANIFEST_MAX_BYTES:
|
|
651
|
+
raise CatalogError(
|
|
652
|
+
f"sha256 manifest at {sha_url} is larger than "
|
|
653
|
+
f"{_SHA_MANIFEST_MAX_BYTES} bytes; refusing to parse "
|
|
654
|
+
f"(did you paste an image URL into the sha_url field?)"
|
|
655
|
+
)
|
|
656
|
+
body = raw.decode("utf-8")
|
|
657
|
+
return parse_sha256_manifest(body, target_name=target or None)
|