804884a749
gates / gates (push) Successful in 2s
The digest was dropped and the tag's current digest returned, so 'is this digest still served' got a false yes (measured: redis:7-alpine@sha256:000…0 resolved to the tag's digest; now HTTP 404). A malformed digest is refused without a request. test_image_digest.py (no network); red-proof: the pre-fix resolver fails 3 of 4 cases. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
118 lines
4.9 KiB
Python
118 lines
4.9 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 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:]))
|