Files
app-catalog-felhom.eu/scripts/image_digest.py
T
admin 6db08a5eb3
gates / gates (push) Successful in 1s
test record: an image move must carry its proof (09 decision 13, part 4)
update_ladder: in .felhom.yml, one JSON entry per line (spiked live on
controller v0.266.0 and v0.267.0 first). Two gates: check-test-record.py
(static, CI too) and check-test-record-move.py (history + registry for
moved refs only). 16 decoys, 3 red-proofs. The ONLY writer is
upgrade-test.py --write-ladder (bench AND box proven, digests resolved).
Harness v3: box fixtures on the bench, files_may_change.
Backfill: the 21 moves of 2026-09-22, 21 proven from their records.
No image: line moved.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-23 20:52:32 +02:00

110 lines
4.4 KiB
Python

#!/usr/bin/env python3
"""image_digest.py — what digest does the registry serve for an image reference TODAY?
`09` §3 decision 17 / §6.4 part 6: the catalog records each pin's digest at push time, so a box can
compare against it and pull that exact image. This is the one resolver both the harness (which writes
the digest into a ladder entry) and `check-test-record.py` (which compares it at push time) call, so
the two can never disagree about which digest a ref "is".
The digest returned is the one Docker records in `RepoDigests` after a pull: the top-level manifest's
`Docker-Content-Digest` (an image INDEX for a multi-arch image, a single manifest otherwise), asked
for with every manifest media type accepted — the same content negotiation `docker pull` performs.
Standard library only (urllib): the catalog CI runner carries python3 and git and nothing else, and
a resolver that needs `requests` is one that silently skips there.
python3 scripts/image_digest.py postgres:16-alpine ghcr.io/diced/zipline:4.7.0
Exit 0 when every ref resolved; 2 when any could not be resolved (never 1 — this tool accuses
nothing, it only measures).
"""
import json
import sys
import urllib.error
import urllib.parse
import urllib.request
ACCEPT = ",".join([
"application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.docker.distribution.manifest.v2+json",
])
UA = "felhom-catalog-digest/1.0 (read-only)"
def split_ref(ref):
"""'ghcr.io/a/b:1.2' -> ('ghcr.io', 'a/b', '1.2'). A digest suffix is dropped; Docker Hub
short names get `library/`."""
ref = ref.split("@", 1)[0]
first = ref.split("/", 1)[0]
if "/" in ref and ("." in first or ":" in first or first == "localhost"):
host, rest = ref.split("/", 1)
else:
host, rest = "registry-1.docker.io", ref
if "/" not in rest:
rest = "library/" + rest
if ":" in rest.rsplit("/", 1)[-1]:
repo, tag = rest.rsplit(":", 1)
else:
repo, tag = rest, "latest"
if host == "docker.io":
host = "registry-1.docker.io"
return host, repo, tag
def _bearer(www_auth):
"""Anonymous token from a `WWW-Authenticate: Bearer realm=…,service=…,scope=…` challenge."""
parts = {}
for p in www_auth[len("Bearer "):].split(","):
if "=" in p:
k, v = p.split("=", 1)
parts[k.strip()] = v.strip().strip('"')
q = {k: parts[k] for k in ("service", "scope") if k in parts}
url = parts["realm"] + ("?" + urllib.parse.urlencode(q) if q else "")
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=30) as r:
j = json.load(r)
return j.get("token") or j.get("access_token")
def resolve(ref, timeout=30):
"""(digest, None) or (None, why). Read-only: one HEAD, one token fetch at most."""
host, repo, tag = split_ref(ref)
url = "https://%s/v2/%s/manifests/%s" % (host, repo, tag)
headers = {"Accept": ACCEPT, "User-Agent": UA}
for attempt in (1, 2):
req = urllib.request.Request(url, headers=headers, method="HEAD")
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
d = r.headers.get("Docker-Content-Digest")
if d and d.startswith("sha256:") and len(d) == 71:
return d, None
return None, "no Docker-Content-Digest header (HTTP %s)" % r.status
except urllib.error.HTTPError as e:
if e.code == 401 and attempt == 1 and "Bearer" in (e.headers.get("WWW-Authenticate") or ""):
try:
tok = _bearer(e.headers["WWW-Authenticate"])
except Exception as te: # noqa: BLE001 — any failure here is "could not resolve"
return None, "token fetch failed: %s" % te
headers["Authorization"] = "Bearer " + tok
continue
return None, "HTTP %d" % e.code
except Exception as e: # noqa: BLE001
return None, "%s: %s" % (type(e).__name__, e)
return None, "unauthorised after a token"
def main(argv):
worst = 0
for ref in argv:
d, why = resolve(ref)
print("%s\t%s" % (ref, d or ("UNRESOLVED: " + why)))
if not d:
worst = 2
return worst
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))