#!/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 here (resolve() asks for it by itself); 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. A ref carrying `@sha256:…` asks for THAT manifest (R-746): the answer is the digest only when the registry still serves it, never the tag's current digest — a caller asking "is this digest still served" got a false yes.""" host, repo, tag = split_ref(ref) want = ref.split("@", 1)[1] if "@" in ref else "" if want and not (want.startswith("sha256:") and len(want) == 71): return None, "not a sha256 digest: %s" % want url = "https://%s/v2/%s/manifests/%s" % (host, repo, want or 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 want and d != want: return None, "the registry answered %s for %s" % (d, want) 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:]))