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/oras.py ADDED
@@ -0,0 +1,547 @@
1
+ """ORAS / OCI registry adapter for fetching disk images and other artifacts.
2
+
3
+ Resolves ``oras://`` references against an OCI v2 registry into a plain
4
+ HTTPS blob URL plus the auth headers the registry expects. The fetch
5
+ pipeline (``pixie.catalog._fetcher``) calls in here when the operator
6
+ hits Fetch on an ``oras://`` catalog entry so the existing Range-resume
7
+ fetch loop can stream the bytes; library consumers use the same helpers
8
+ to validate catalog entries and pre-resolve content digests.
9
+
10
+ Hard-forked from withcache 0.13.3's ``withcache.oras`` on 2026-07-13.
11
+ Kept as stdlib-only (no ``requests``/``httpx`` dep) so a slim install
12
+ still gets ORAS resolution.
13
+
14
+ URL shape::
15
+
16
+ oras://<host>[:port]/<owner>/<repo>[/<extra>]:<tag>
17
+ oras://<host>[:port]/<owner>/<repo>[/<extra>]@sha256:<64-hex>
18
+
19
+ Digest-pinned references skip the manifest fetch (the digest IS the
20
+ address); tag references go through manifest resolution + layer pick.
21
+
22
+ Why ``oras://`` and not ``ghcr:``
23
+ ---------------------------------
24
+
25
+ The ORAS spelling disambiguates from container references. A reader
26
+ who sees ``ghcr.io/safl/nosi/debian-sysdev:latest`` in a docs example
27
+ might reach for ``docker pull`` or ``podman run`` -- which would
28
+ fail and leave them confused, because nosi publishes disk-image
29
+ artifacts, not runnable container images. ``oras://`` is the
30
+ ecosystem term for OCI-Registry-As-Storage; an operator googling it
31
+ lands at oras.land which explicitly explains "store arbitrary
32
+ artifacts, not just containers". The ``://`` form also composes
33
+ with other registries -- ``oras://quay.io/...``,
34
+ ``oras://registry.example.com:5000/...`` -- without per-registry
35
+ schemes.
36
+
37
+ .. _ORAS: https://oras.land/
38
+
39
+ Auth
40
+ ----
41
+
42
+ Spec-compliant OCI v2 registries (GHCR included) return 401 on every
43
+ request even for public packages. Their ``/token`` endpoint
44
+ mints anonymous tokens on a plain credential-less GET. So the flow
45
+ is: hit ``https://<host>/token``, take the returned bearer, set
46
+ ``Authorization: Bearer`` on the manifest + blob requests. No
47
+ registry login, no PAT, no secrets shipped.
48
+
49
+ The token endpoint is built from the URL's host (``ghcr.io`` ->
50
+ ``https://ghcr.io/token``), which works for GHCR and any registry
51
+ that follows the same convention. Registries with non-standard auth
52
+ flows (private registries with custom realms, e.g.) would need the
53
+ proper ``WWW-Authenticate`` challenge dance instead -- noted as
54
+ future work; not needed for the homelab / nosi use case this module
55
+ ships for.
56
+
57
+ Layer picker
58
+ ------------
59
+
60
+ A nosi manifest carries two layers: the ``.img.gz`` disk image and a
61
+ ``.sha256`` sidecar. The picker drops layers whose
62
+ ``org.opencontainers.image.title`` annotation ends in a known sidecar
63
+ suffix (``.sha256``, ``.sha512``, ``.sig``, ``.asc``, ``.pem``,
64
+ ``.cert``, ``.sbom``, ``.att``, ``.json``), then takes the largest
65
+ remaining layer by declared size. Manifests with no useful
66
+ annotations fall through to the largest layer overall -- a reasonable
67
+ bet that the image bytes dwarf any metadata sidecar.
68
+ """
69
+
70
+ from __future__ import annotations
71
+
72
+ import json
73
+ import re
74
+ import time
75
+ import urllib.error
76
+ import urllib.parse
77
+ import urllib.request
78
+ from dataclasses import dataclass
79
+ from typing import Any
80
+
81
+ ORAS_SCHEME = "oras://"
82
+
83
+ # Transient HTTP statuses worth retrying: 429 (rate limit, common on
84
+ # GHCR / Docker Hub under load) plus the gateway/server-blip 5xx range.
85
+ # Everything else (401/403 auth, 404 not-found, other 4xx) is permanent
86
+ # and raised immediately, since retrying would just stall the caller.
87
+ _RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504})
88
+ _RETRY_ATTEMPTS = 3
89
+ _RETRY_BACKOFF = 0.5 # seconds; exponential: 0.5, 1.0 between attempts
90
+
91
+ # Defensive ceiling on the metadata-fetch response body. Real-world
92
+ # OCI tokens are <2 KiB and manifests are <100 KiB; anything beyond
93
+ # this is either a misconfigured registry or a deliberately hostile
94
+ # response. Capping the read keeps a runaway registry from exhausting
95
+ # the host process's RAM on a tag resolve. Blob bytes do NOT go
96
+ # through this helper (they stream via the cache-host's resume loop
97
+ # with its own bounds).
98
+ _MAX_METADATA_BYTES = 10 * 1024 * 1024 # 10 MiB
99
+
100
+ # Accept type covers OCI v1 + Docker v2 manifest media types so the
101
+ # registry doesn't bounce us with a 406 if the package was originally
102
+ # pushed as a Docker manifest.
103
+ _MANIFEST_ACCEPT = (
104
+ "application/vnd.oci.image.manifest.v1+json,"
105
+ "application/vnd.docker.distribution.manifest.v2+json"
106
+ )
107
+
108
+ # Layer titles ending in any of these are non-image sidecars (sha
109
+ # sums, signatures, attestations); skip them when picking the image
110
+ # layer so a future ``oras attach`` on the same artifact doesn't
111
+ # silently start being served.
112
+ _SIDECAR_SUFFIXES = (
113
+ ".sha256",
114
+ ".sha512",
115
+ ".sig",
116
+ ".asc",
117
+ ".pem",
118
+ ".cert",
119
+ ".sbom",
120
+ ".att",
121
+ ".json",
122
+ )
123
+
124
+ # Layer mediaTypes that are NEVER disk images. A manifest whose largest
125
+ # layer carries one of these is a Helm chart, a Cosign signature, an
126
+ # in-toto attestation, a SPDX SBOM, etc. Without this filter
127
+ # ``pick_image_layer`` would happily pick the Helm tarball or the
128
+ # signature blob and downstream consumers (e.g. ``bty.flash``) would
129
+ # write its bytes onto an operator's target disk (tar headers /
130
+ # signature bytes into the MBR). An operator typing ``oras://`` at a
131
+ # non-image artifact deserves a clear error, not corruption.
132
+ _NON_IMAGE_LAYER_MEDIA_TYPES = (
133
+ # Helm OCI charts.
134
+ "application/vnd.cncf.helm.chart.v1.tar+gzip",
135
+ "application/vnd.cncf.helm.chart.provenance.v1.prov",
136
+ # Cosign signatures.
137
+ "application/vnd.dev.cosign.simplesigning.v1+json",
138
+ "application/vnd.dev.cosign.artifact.sig.v1+json",
139
+ # in-toto attestations.
140
+ "application/vnd.in-toto+json",
141
+ "application/vnd.dev.cosign.artifact.bundle.v1+json",
142
+ "application/vnd.dsse.envelope.v1+json",
143
+ # SBOMs.
144
+ "application/spdx+json",
145
+ "application/vnd.cyclonedx+json",
146
+ # OCI empty descriptor (used by artifact references with no payload).
147
+ "application/vnd.oci.empty.v1+json",
148
+ )
149
+
150
+
151
+ def _is_non_image_layer(layer: dict[str, Any]) -> bool:
152
+ media_type = layer.get("mediaType")
153
+ if not isinstance(media_type, str):
154
+ return False
155
+ return media_type in _NON_IMAGE_LAYER_MEDIA_TYPES
156
+
157
+
158
+ class OrasError(OSError):
159
+ """Raised on parse / resolution / fetch errors against an OCI registry.
160
+
161
+ Inherits from :class:`OSError` so it's caught by callers that
162
+ handle remote-I/O failures generically -- semantically the same
163
+ family as :class:`urllib.error.URLError`, which also subclasses
164
+ :class:`OSError`. Code paths that need to distinguish ORAS-
165
+ specific failures from arbitrary network errors still can:
166
+ :class:`OrasError` is a strict subclass."""
167
+
168
+
169
+ @dataclass(frozen=True)
170
+ class OrasRef:
171
+ """Parsed ``oras://`` reference.
172
+
173
+ Exactly one of ``tag`` / ``digest`` is set. ``digest`` references
174
+ skip the manifest fetch (the digest is content-addressed, so the
175
+ blob URL is fully determined). ``tag`` references go through the
176
+ manifest to resolve a layer digest first.
177
+ """
178
+
179
+ host: str # e.g. "ghcr.io" or "registry.example.com:5000"
180
+ repository: str # e.g. "safl/nosi/debian-sysdev"
181
+ tag: str | None = None
182
+ digest: str | None = None
183
+
184
+ @property
185
+ def manifest_locator(self) -> str:
186
+ """Value used in the ``/manifests/<X>`` URL path."""
187
+ if self.digest is not None:
188
+ return self.digest
189
+ assert self.tag is not None, "OrasRef must have either tag or digest set"
190
+ return self.tag
191
+
192
+
193
+ # Host: DNS hostname (or registry.example.com:5000 with optional port).
194
+ # Repository: lowercase alnum + ``/_.-``, must contain at least one
195
+ # ``/`` after the host (owner + repo). Tag: OCI tag charset (alnum +
196
+ # ``._-``). Digest: only sha256 today; future algorithms would need
197
+ # extending. Layout overall::
198
+ #
199
+ # <host>[:port]/<repo>(:<tag>|@sha256:<hex>)
200
+ #
201
+ # applied to the body after stripping the ``oras://`` scheme.
202
+ _REF_RE = re.compile(
203
+ r"^"
204
+ r"(?P<host>[a-zA-Z0-9][a-zA-Z0-9.-]*(?::[0-9]+)?)"
205
+ r"/"
206
+ r"(?P<repo>[a-z0-9][a-z0-9/_.-]*)"
207
+ r"(?:(?:@(?P<digest>sha256:[0-9a-f]{64}))"
208
+ r"|(?::(?P<tag>[A-Za-z0-9._-]+)))"
209
+ r"$"
210
+ )
211
+
212
+
213
+ def parse_ref(ref: str) -> OrasRef:
214
+ """Parse an ``oras://`` reference into a :class:`OrasRef`.
215
+
216
+ Accepts the two canonical forms::
217
+
218
+ oras://<host>/<owner>/<repo>[/<extra>]:<tag>
219
+ oras://<host>/<owner>/<repo>[/<extra>]@sha256:<64-hex>
220
+
221
+ Raises :class:`OrasError` on any malformed input. The repository
222
+ component must contain at least one ``/`` -- a bare top-level
223
+ path like ``oras://ghcr.io/nosi:latest`` is rejected because OCI's
224
+ URL scheme requires owner+repo under the host.
225
+ """
226
+ if not ref.startswith(ORAS_SCHEME):
227
+ raise OrasError(f"not an oras:// reference: {ref!r}")
228
+ body = ref[len(ORAS_SCHEME) :]
229
+ if not body:
230
+ raise OrasError(f"empty oras:// reference: {ref!r}")
231
+ match = _REF_RE.match(body)
232
+ if match is None:
233
+ raise OrasError(
234
+ f"malformed oras:// reference {ref!r}: "
235
+ f"expected oras://<host>/<owner>/<repo>:<tag> or "
236
+ f"oras://<host>/<owner>/<repo>@sha256:<hex>"
237
+ )
238
+ repo = match.group("repo")
239
+ if "/" not in repo:
240
+ raise OrasError(
241
+ f"oras:// reference must include <host>/<owner>/<repo>: "
242
+ f"{ref!r} (got bare repo {repo!r} after host)"
243
+ )
244
+ return OrasRef(
245
+ host=match.group("host"),
246
+ repository=repo,
247
+ tag=match.group("tag"),
248
+ digest=match.group("digest"),
249
+ )
250
+
251
+
252
+ def _urlopen_retry(req: urllib.request.Request | str, *, timeout: float) -> bytes:
253
+ """GET ``req`` and return the body bytes, retrying transient
254
+ failures with exponential backoff.
255
+
256
+ Retries on :data:`_RETRYABLE_STATUS` (429 / 5xx) and on raw
257
+ connection errors / timeouts. Permanent HTTP errors (401/403/404,
258
+ other 4xx) raise immediately -- a retry can't fix an auth or
259
+ not-found failure, only delay the inevitable. After
260
+ :data:`_RETRY_ATTEMPTS` the last error is re-raised so the caller's
261
+ ``except`` (which wraps into :class:`OrasError`) still fires.
262
+ """
263
+ last: BaseException | None = None
264
+ for attempt in range(_RETRY_ATTEMPTS):
265
+ try:
266
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
267
+ # Cap the read: a registry that returns a 500 MiB
268
+ # "manifest" should fail loud, not silently consume
269
+ # process RAM. ``read(N)`` returns up to N bytes; we
270
+ # take N+1 and reject the response if it overflows.
271
+ data = bytes(resp.read(_MAX_METADATA_BYTES + 1))
272
+ if len(data) > _MAX_METADATA_BYTES:
273
+ raise OrasError(f"oras metadata response exceeded {_MAX_METADATA_BYTES} bytes")
274
+ return data
275
+ except urllib.error.HTTPError as exc:
276
+ if exc.code not in _RETRYABLE_STATUS:
277
+ raise
278
+ last = exc
279
+ except (urllib.error.URLError, TimeoutError, ConnectionError, OSError) as exc:
280
+ last = exc
281
+ if attempt < _RETRY_ATTEMPTS - 1:
282
+ time.sleep(_RETRY_BACKOFF * (2**attempt))
283
+ assert last is not None # loop ran at least once
284
+ raise last
285
+
286
+
287
+ def parse_www_authenticate(header: str) -> dict[str, str]:
288
+ """Parse an OCI ``WWW-Authenticate: Bearer realm="...",service="...",
289
+ scope="..."`` challenge into a dict of its (lower-cased) params.
290
+
291
+ Returns ``{}`` for a non-Bearer or unparseable challenge so callers
292
+ can treat "no usable challenge" uniformly.
293
+ """
294
+ header = header.strip()
295
+ if header[:7].lower() != "bearer ":
296
+ return {}
297
+ return {m.group(1).lower(): m.group(2) for m in re.finditer(r'(\w+)="([^"]*)"', header[7:])}
298
+
299
+
300
+ def _token_from_endpoint(url: str, host: str, repository: str, *, timeout: float) -> str:
301
+ """GET a token endpoint and extract the bearer. Raises OrasError."""
302
+ payload = json.loads(_urlopen_retry(url, timeout=timeout))
303
+ # OCI registries return ``token``; some spell it ``access_token``.
304
+ token = payload.get("token") or payload.get("access_token")
305
+ if not isinstance(token, str) or not token:
306
+ raise OrasError(
307
+ f"oras token response for {host}/{repository} did not contain "
308
+ f"a token (keys: {sorted(payload.keys())})"
309
+ )
310
+ return token
311
+
312
+
313
+ def _discover_bearer_challenge(host: str, *, timeout: float) -> dict[str, str]:
314
+ """Probe ``GET https://{host}/v2/`` and return the parsed Bearer
315
+ challenge from the 401 response's ``WWW-Authenticate`` header.
316
+
317
+ The spec-compliant way to find a registry's token endpoint when it
318
+ isn't the GHCR-convention ``https://<host>/token`` (Docker Hub uses
319
+ ``auth.docker.io``, e.g.). Returns ``{}`` if the registry answers
320
+ 200 (no auth) or sends no usable challenge."""
321
+ req = urllib.request.Request(f"https://{host}/v2/", method="GET")
322
+ try:
323
+ with urllib.request.urlopen(req, timeout=timeout):
324
+ return {} # 200: no auth challenge to discover
325
+ except urllib.error.HTTPError as exc:
326
+ if exc.code != 401 or exc.headers is None:
327
+ return {}
328
+ return parse_www_authenticate(exc.headers.get("WWW-Authenticate", ""))
329
+ except (urllib.error.URLError, TimeoutError, OSError):
330
+ return {}
331
+
332
+
333
+ def fetch_anonymous_token(host: str, repository: str, *, timeout: float = 30.0) -> str:
334
+ """Grab an anonymous bearer token for ``repository:pull``.
335
+
336
+ Spec-compliant OCI v2 registries expose a token endpoint that mints
337
+ short-lived anonymous bearers for public packages (no creds, no
338
+ PAT). Two-step:
339
+
340
+ 1. Try the GHCR / oras.land convention ``https://<host>/token`` --
341
+ one request for the common case.
342
+ 2. If that fails, fall back to the spec discovery: ping
343
+ ``GET /v2/``, read the realm advertised in the ``WWW-Authenticate``
344
+ Bearer challenge, and fetch from there. This covers registries
345
+ whose token endpoint isn't ``<host>/token`` (Docker Hub ->
346
+ ``auth.docker.io``, Quay's custom realm, etc.).
347
+ """
348
+ scope = f"repository:{urllib.parse.quote(repository, safe='/')}:pull"
349
+ conv_url = f"https://{host}/token?service={host}&scope={scope}"
350
+ try:
351
+ return _token_from_endpoint(conv_url, host, repository, timeout=timeout)
352
+ except (OSError, json.JSONDecodeError, ValueError, OrasError) as conv_exc:
353
+ # Convention failed; try spec discovery before giving up.
354
+ challenge = _discover_bearer_challenge(host, timeout=timeout)
355
+ realm = challenge.get("realm")
356
+ if realm:
357
+ service = challenge.get("service", host)
358
+ disc_url = f"{realm}?service={urllib.parse.quote(service, safe='')}&scope={scope}"
359
+ try:
360
+ return _token_from_endpoint(disc_url, host, repository, timeout=timeout)
361
+ except (OSError, json.JSONDecodeError, ValueError, OrasError) as exc:
362
+ raise OrasError(
363
+ f"oras token fetch failed for {host}/{repository} via discovered "
364
+ f"realm {realm}: {exc}"
365
+ ) from exc
366
+ # No usable discovery; surface the original conventional error.
367
+ raise OrasError(
368
+ f"oras token fetch failed for {host}/{repository}: {conv_exc}"
369
+ ) from conv_exc
370
+
371
+
372
+ def fetch_manifest(ref: OrasRef, token: str, *, timeout: float = 30.0) -> dict[str, Any]:
373
+ """Fetch the OCI manifest for ``ref`` using a previously-acquired token.
374
+
375
+ Returns the parsed JSON. Raises :class:`OrasError` on network or
376
+ parse failure. The caller is responsible for layer selection.
377
+ """
378
+ locator = urllib.parse.quote(ref.manifest_locator, safe=":")
379
+ url = f"https://{ref.host}/v2/{ref.repository}/manifests/{locator}"
380
+ request = urllib.request.Request(
381
+ url,
382
+ headers={
383
+ "Authorization": f"Bearer {token}",
384
+ "Accept": _MANIFEST_ACCEPT,
385
+ },
386
+ )
387
+ try:
388
+ payload = json.loads(_urlopen_retry(request, timeout=timeout))
389
+ except (OSError, json.JSONDecodeError, ValueError) as exc:
390
+ raise OrasError(
391
+ f"oras manifest fetch failed for "
392
+ f"{ref.host}/{ref.repository}:{ref.manifest_locator}: {exc}"
393
+ ) from exc
394
+ if not isinstance(payload, dict):
395
+ raise OrasError(
396
+ f"oras manifest for "
397
+ f"{ref.host}/{ref.repository}:{ref.manifest_locator} "
398
+ f"is not a JSON object"
399
+ )
400
+ return payload
401
+
402
+
403
+ def _layer_title(layer: dict[str, Any]) -> str:
404
+ annotations = layer.get("annotations")
405
+ if not isinstance(annotations, dict):
406
+ return ""
407
+ title = annotations.get("org.opencontainers.image.title", "")
408
+ return title if isinstance(title, str) else ""
409
+
410
+
411
+ def pick_image_layer(manifest: dict[str, Any]) -> dict[str, Any]:
412
+ """Pick the disk-image layer from a (possibly multi-layer) manifest.
413
+
414
+ Drops sidecar-looking layers by title-annotation suffix, then takes
415
+ the largest by declared ``size``. A manifest with no usable
416
+ annotations falls through to the largest layer overall -- the image
417
+ bytes will dwarf any metadata blob in practice.
418
+
419
+ Raises :class:`OrasError` if the manifest has no layers at all.
420
+ """
421
+ layers = manifest.get("layers")
422
+ if not isinstance(layers, list) or not layers:
423
+ # A multi-arch image *index* (``manifests`` instead of
424
+ # ``layers``) is a common cause: a rolling tag that resolves
425
+ # to an OCI index rather than a single artifact manifest. Name
426
+ # it so the operator points at a concrete manifest/digest
427
+ # instead of staring at "no layers".
428
+ if isinstance(manifest.get("manifests"), list) and manifest["manifests"]:
429
+ raise OrasError(
430
+ "oras ref resolved to a multi-arch image index, not a single "
431
+ "artifact manifest; reference a concrete platform manifest by "
432
+ "its @sha256:<digest> instead of the index tag"
433
+ )
434
+ raise OrasError("manifest has no layers")
435
+
436
+ # First pass: drop layers whose mediaType is definitively not a
437
+ # disk image (Helm chart, Cosign sig, SBOM, attestation, ...).
438
+ # Unlike title-suffix sidecars, these are never the right pick AND
439
+ # never get a fallback; flashing a Helm chart's tar+gzip into a
440
+ # disk's MBR is the data-loss scenario this guard exists for.
441
+ rejected_media_types: list[str] = []
442
+ image_candidates: list[dict[str, Any]] = []
443
+ for layer in layers:
444
+ if not isinstance(layer, dict):
445
+ continue
446
+ if _is_non_image_layer(layer):
447
+ mt = layer.get("mediaType")
448
+ if isinstance(mt, str):
449
+ rejected_media_types.append(mt)
450
+ continue
451
+ image_candidates.append(layer)
452
+ if not image_candidates:
453
+ if rejected_media_types:
454
+ unique = ", ".join(sorted(set(rejected_media_types)))
455
+ raise OrasError(
456
+ f"oras ref resolved to a non-disk-image artifact "
457
+ f"(layer mediaTypes: {unique}); refusing to serve "
458
+ f"signature / Helm chart / SBOM blobs as disk images"
459
+ )
460
+ raise OrasError("manifest has no usable layers")
461
+
462
+ # Second pass: among the image-eligible layers, prefer ones that
463
+ # DON'T look like title-annotation sidecars (.sha256 / .sig / ...
464
+ # filenames). If every remaining layer carries a sidecar-looking
465
+ # title, fall back to "pick the largest" so the resolver gets a
466
+ # chance to fail loudly downstream rather than this picker
467
+ # mis-classifying.
468
+ image_like: list[dict[str, Any]] = []
469
+ for layer in image_candidates:
470
+ title = _layer_title(layer)
471
+ if title and any(title.endswith(suffix) for suffix in _SIDECAR_SUFFIXES):
472
+ continue
473
+ image_like.append(layer)
474
+ candidates = image_like or image_candidates
475
+ return max(candidates, key=lambda layer: layer.get("size") or 0)
476
+
477
+
478
+ @dataclass(frozen=True)
479
+ class ResolvedBlob:
480
+ """Everything a fetcher needs to stream the image bytes.
481
+
482
+ ``blob_url`` is the final ``/v2/<repo>/blobs/sha256:<digest>``
483
+ endpoint. ``headers`` carries the bearer the registry expects. The
484
+ ``digest`` is what the fetcher should verify the downloaded bytes
485
+ against -- when the caller started from a tag, this is the digest
486
+ the registry resolved to right now, frozen for the rest of the
487
+ fetch.
488
+ """
489
+
490
+ blob_url: str
491
+ headers: dict[str, str]
492
+ digest: str
493
+ size: int | None
494
+ title: str | None
495
+
496
+
497
+ def resolve_ref(ref: str | OrasRef, *, timeout: float = 30.0) -> ResolvedBlob:
498
+ """Resolve an ``oras://`` reference (or pre-parsed :class:`OrasRef`)
499
+ to a :class:`ResolvedBlob`.
500
+
501
+ For tag references: anonymous token -> manifest -> layer pick ->
502
+ ``ResolvedBlob`` with the layer's content-addressed digest. The
503
+ digest is frozen at resolve time so a tag that moves under us
504
+ between resolve and fetch still produces the bytes we committed to.
505
+
506
+ For digest-pinned references: anonymous token only; the blob URL is
507
+ fully determined by the digest, the manifest fetch is skipped, and
508
+ size / title come back as ``None`` (the descriptor's optional
509
+ ``size_bytes`` field can carry that info instead if known).
510
+ """
511
+ if isinstance(ref, str):
512
+ ref = parse_ref(ref)
513
+ token = fetch_anonymous_token(ref.host, ref.repository, timeout=timeout)
514
+ headers = {"Authorization": f"Bearer {token}"}
515
+
516
+ if ref.digest is not None:
517
+ digest = ref.digest
518
+ size: int | None = None
519
+ title: str | None = None
520
+ else:
521
+ manifest = fetch_manifest(ref, token, timeout=timeout)
522
+ layer = pick_image_layer(manifest)
523
+ raw_digest = layer.get("digest")
524
+ if not isinstance(raw_digest, str) or not raw_digest.startswith("sha256:"):
525
+ raise OrasError(
526
+ f"picked layer for "
527
+ f"{ref.host}/{ref.repository}:{ref.manifest_locator} "
528
+ f"has unusable digest {raw_digest!r}"
529
+ )
530
+ digest = raw_digest
531
+ layer_size = layer.get("size")
532
+ size = layer_size if isinstance(layer_size, int) else None
533
+ title = _layer_title(layer) or None
534
+
535
+ blob_url = f"https://{ref.host}/v2/{ref.repository}/blobs/{digest}"
536
+ return ResolvedBlob(
537
+ blob_url=blob_url,
538
+ headers=headers,
539
+ digest=digest,
540
+ size=size,
541
+ title=title,
542
+ )
543
+
544
+
545
+ def is_oras_url(url: str) -> bool:
546
+ """True iff ``url`` is an ``oras://`` reference rather than http(s)://."""
547
+ return url.startswith(ORAS_SCHEME)