pixie-lab 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. pixie/__init__.py +20 -0
  2. pixie/_util.py +38 -0
  3. pixie/catalog/__init__.py +25 -0
  4. pixie/catalog/_fetcher.py +513 -0
  5. pixie/catalog/_routes.py +392 -0
  6. pixie/catalog/_schema.py +263 -0
  7. pixie/catalog/_store.py +234 -0
  8. pixie/deploy/__init__.py +26 -0
  9. pixie/deploy/_main.py +321 -0
  10. pixie/deploy/_templates.py +153 -0
  11. pixie/disks.py +85 -0
  12. pixie/events/__init__.py +28 -0
  13. pixie/events/_kinds.py +203 -0
  14. pixie/events/_log.py +210 -0
  15. pixie/events/_routes.py +42 -0
  16. pixie/exports/__init__.py +17 -0
  17. pixie/exports/_routes.py +211 -0
  18. pixie/exports/_store.py +156 -0
  19. pixie/exports/_supervisor.py +240 -0
  20. pixie/flash.py +1971 -0
  21. pixie/images.py +473 -0
  22. pixie/machines/__init__.py +16 -0
  23. pixie/machines/_routes.py +190 -0
  24. pixie/machines/_store.py +623 -0
  25. pixie/oras.py +547 -0
  26. pixie/pivot/__init__.py +180 -0
  27. pixie/pivot/nbdboot +348 -0
  28. pixie/pxe/__init__.py +19 -0
  29. pixie/pxe/_renderer.py +244 -0
  30. pixie/pxe/_routes.py +386 -0
  31. pixie/tftp/__init__.py +21 -0
  32. pixie/tftp/_supervisor.py +129 -0
  33. pixie/tui/__init__.py +185 -0
  34. pixie/tui/_app.py +2219 -0
  35. pixie/tui_catalog.py +657 -0
  36. pixie/web/__init__.py +6 -0
  37. pixie/web/_auth.py +70 -0
  38. pixie/web/_settings_store.py +211 -0
  39. pixie/web/_static/.gitkeep +0 -0
  40. pixie/web/_static/bootstrap-icons.min.css +5 -0
  41. pixie/web/_static/bootstrap.min.css +12 -0
  42. pixie/web/_static/fonts/bootstrap-icons.woff +0 -0
  43. pixie/web/_static/fonts/bootstrap-icons.woff2 +0 -0
  44. pixie/web/_static/htmx.min.js +1 -0
  45. pixie/web/_static/pixie-favicon.png +0 -0
  46. pixie/web/_static/sse.js +290 -0
  47. pixie/web/_table_state.py +261 -0
  48. pixie/web/_templates/_partials/page_description.html +22 -0
  49. pixie/web/_templates/_partials/recent_events.html +47 -0
  50. pixie/web/_templates/_partials/table_helpers.html +189 -0
  51. pixie/web/_templates/catalog.html +364 -0
  52. pixie/web/_templates/catalog_detail.html +386 -0
  53. pixie/web/_templates/dashboard.html +256 -0
  54. pixie/web/_templates/events.html +125 -0
  55. pixie/web/_templates/ipxe/bootstrap.j2 +12 -0
  56. pixie/web/_templates/ipxe/exit.j2 +10 -0
  57. pixie/web/_templates/ipxe/nbdboot.j2 +57 -0
  58. pixie/web/_templates/ipxe/pixie-live-env.j2 +55 -0
  59. pixie/web/_templates/ipxe/unavailable.j2 +14 -0
  60. pixie/web/_templates/layout.html +345 -0
  61. pixie/web/_templates/login.html +40 -0
  62. pixie/web/_templates/machine_detail.html +786 -0
  63. pixie/web/_templates/machines.html +186 -0
  64. pixie/web/_templates/settings.html +265 -0
  65. pixie/web/main.py +1910 -0
  66. pixie_lab-0.1.0.dist-info/METADATA +107 -0
  67. pixie_lab-0.1.0.dist-info/RECORD +70 -0
  68. pixie_lab-0.1.0.dist-info/WHEEL +4 -0
  69. pixie_lab-0.1.0.dist-info/entry_points.txt +3 -0
  70. pixie_lab-0.1.0.dist-info/licenses/LICENSE +674 -0
pixie/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)
pixie/web/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """FastAPI app wiring for pixie.
2
+
3
+ Skeleton at 0.1.0: healthz + session-cookie login/logout + placeholder
4
+ dashboard. Feature routers (catalog, exports, machines, PXE, TFTP, ...)
5
+ land in later PRs.
6
+ """