test record: an image move must carry its proof (09 decision 13, part 4)
gates / gates (push) Successful in 1s
gates / gates (push) Successful in 1s
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
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
#!/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:]))
|
||||
Reference in New Issue
Block a user