25cb3eb9c1
gates / gates (push) Successful in 29s
- R-463 CLOSED: rallly, outline, sparkyfitness -> PostgreSQL 18 (catalog 25ffd89 / aeb0cd6 / 1666572), each proven on the bench and on 9202 through the guarded Update with one undo case; zipline, adventurelog, immich stay on 16 by 09 decision 42's rule (upstream runs 16 / 16 / 14). - Part F: bookstack, kimai, audiobookshelf, n8n, navidrome, grafana, komga moved on both venues; immich not (R-732: its first start OOM-killed its database on the bench). - R-687 item 4 PROVEN LIVE on demo-hp: two deferrals while the leg stepped two apps, the whole-guest backup on the first poll after, success; config + window put back and read back. - Catalog currency audit (Part D): 25/53 behind inside a major, 19 across; night-updatable 28 -> 31 (+1). - R-446 and R-440 narrowed (measured on demo-hp); R-624 corrected (outline, rallly, zipline have routes); R-548 note (demo-hp's local tier refused for space since 09-27). - New rows: R-730 (the ISO 1.29.0 build commit cannot be proven -> no tag; installer-v* is the script's line), R-731 (tag-shape switches), R-732. Register 361 -> 364. - 09 §3: the 2026-09-30 operator notes (by day; Tester-2 pre-checks done; decision 52 not needed, not recorded); §6.4 dated currency note. STATUS, CONTEXT, REPORT-pg-last-six-2026-09-30.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
235 lines
8.8 KiB
Python
235 lines
8.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Catalog currency audit, 2026-09-30. Read-only, DooPlex-only, anonymous public registry API.
|
|
|
|
Adapted from documentation/audits/update-arc-2026-09-21/00-drift.py (same bearer-token flow and the
|
|
same Link pagination for /v2/<repo>/tags/list; same lscr.io date-rebuild exclusion; same unstable
|
|
markers). Changes, and why:
|
|
- reads the pins straight from the catalog checkout (scripts/ladder.py images_in — the same reading
|
|
the gates make), not from a hand-built pins.json;
|
|
- one generic tag tokenizer for every pin (numbers vs the text between them; a long hex token is a
|
|
build hash, a wildcard). Two tags are comparable only when the text between their numbers is
|
|
identical and they have the same count of numbers. So `postgres:16-alpine` compares only with
|
|
`<N>-alpine`, `nextcloud:34.0.4-apache` only with `<a>.<b>.<c>-apache`;
|
|
- reports BOTH the newest within the pin's major (first number equal) and the newest at all;
|
|
- classifies a pin as FLOATING when it names a version LINE, not an exact release: fewer than three
|
|
version numbers in the tag's leading version run (e.g. `mariadb:12.3`, `postgres:16-alpine`,
|
|
`redis:7-alpine`, `postgis:16-3.5-alpine`, immich's `postgres:16-vectorchord...`);
|
|
- for floating pins, also resolves the tag's CURRENT digest (HEAD, no pull, no rate-limit count on
|
|
Docker Hub) and compares it with the digest the app's ladder recorded when the step was tested.
|
|
Nothing is pulled; nothing is written outside the output directory.
|
|
|
|
Usage: python3 00-currency.py <catalog-root> <outdir>
|
|
"""
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import time
|
|
|
|
import requests
|
|
|
|
CAT = sys.argv[1]
|
|
OUT = sys.argv[2]
|
|
sys.path.insert(0, os.path.join(CAT, "scripts"))
|
|
import ladder # noqa: E402
|
|
|
|
S = requests.Session()
|
|
S.headers.update({"User-Agent": "felhom-catalog-currency-audit/1.0 (read-only measurement)"})
|
|
|
|
|
|
def bearer(www):
|
|
parts = www[len("Bearer "):].split(",")
|
|
d = {}
|
|
for p in parts:
|
|
k, v = p.split("=", 1)
|
|
d[k.strip()] = v.strip('"')
|
|
r = S.get(d["realm"], params={"service": d.get("service"), "scope": d.get("scope")}, timeout=20)
|
|
r.raise_for_status()
|
|
j = r.json()
|
|
return j.get("token") or j.get("access_token")
|
|
|
|
|
|
def get(url, headers=None, method="GET", params=None, _tok={}):
|
|
headers = dict(headers or {})
|
|
host = url.split("/")[2]
|
|
if host in _tok:
|
|
headers["Authorization"] = "Bearer " + _tok[host]
|
|
r = S.request(method, url, headers=headers, params=params, timeout=30)
|
|
if r.status_code == 401 and "WWW-Authenticate" in r.headers:
|
|
tok = bearer(r.headers["WWW-Authenticate"])
|
|
headers["Authorization"] = "Bearer " + tok
|
|
r = S.request(method, url, headers=headers, params=params, timeout=30)
|
|
return r
|
|
|
|
|
|
def tags_all(host, repo):
|
|
url = f"https://{host}/v2/{repo}/tags/list"
|
|
r = get(url, params={"n": 1000})
|
|
r.raise_for_status()
|
|
auth = r.request.headers.get("Authorization")
|
|
tags = list(r.json().get("tags") or [])
|
|
link = r.headers.get("Link")
|
|
pages = 1
|
|
while link and pages < 1000:
|
|
m = re.search(r'<([^>]+)>;\s*rel="next"', link)
|
|
if not m:
|
|
break
|
|
nxt = m.group(1)
|
|
if nxt.startswith("/"):
|
|
nxt = f"https://{host}{nxt}"
|
|
r = S.get(nxt, headers={"Authorization": auth} if auth else {}, timeout=30)
|
|
r.raise_for_status()
|
|
tags.extend(r.json().get("tags") or [])
|
|
link = r.headers.get("Link")
|
|
pages += 1
|
|
return tags
|
|
|
|
|
|
ACCEPT = ("application/vnd.docker.distribution.manifest.v2+json,"
|
|
"application/vnd.docker.distribution.manifest.list.v2+json,"
|
|
"application/vnd.oci.image.manifest.v1+json,application/vnd.oci.image.index.v1+json")
|
|
|
|
|
|
def digest(host, repo, tag):
|
|
r = get(f"https://{host}/v2/{repo}/manifests/{tag}", headers={"Accept": ACCEPT}, method="HEAD")
|
|
if r.status_code != 200:
|
|
return None, f"HTTP {r.status_code}"
|
|
return r.headers.get("Docker-Content-Digest"), None
|
|
|
|
|
|
def host_repo(ref_repo):
|
|
for h in ("ghcr.io", "lscr.io", "registry.gitlab.com", "gitea.dooplex.hu", "quay.io"):
|
|
if ref_repo.startswith(h + "/"):
|
|
return h, ref_repo[len(h) + 1:]
|
|
if "/" not in ref_repo:
|
|
return "registry-1.docker.io", "library/" + ref_repo
|
|
return "registry-1.docker.io", ref_repo
|
|
|
|
|
|
def split_ref(ref):
|
|
ref = ref.split("@")[0]
|
|
slash = ref.rfind("/")
|
|
colon = ref.rfind(":")
|
|
if colon > slash:
|
|
return ref[:colon], ref[colon + 1:]
|
|
return ref, "latest"
|
|
|
|
|
|
UNSTABLE = ("rc", "beta", "alpha", "dev", "nightly", "canary", "edge", "preview", "snapshot", "-pr", "test", "unstable")
|
|
HASH_RE = re.compile(r"(?<![0-9a-z])(?=[0-9a-f]*[a-f])(?=[0-9a-f]*[0-9])[0-9a-f]{7,40}(?![0-9a-z])")
|
|
|
|
|
|
def tokenize(tag):
|
|
"""(shape, numbers) or None when the tag carries no number."""
|
|
t = HASH_RE.sub("<hash>", tag.lower())
|
|
parts = re.split(r"(\d+)", t)
|
|
nums = tuple(int(p) for p in parts[1::2])
|
|
if not nums:
|
|
return None
|
|
shape = tuple(parts[0::2])
|
|
return shape, nums
|
|
|
|
|
|
def lead_run_len(tag):
|
|
m = re.search(r"\d+(?:\.\d+)*", tag)
|
|
return len(m.group(0).split(".")) if m else 0
|
|
|
|
|
|
def is_floating(tag):
|
|
if not re.search(r"\d", tag):
|
|
return True # `latest`-style: no version at all
|
|
return lead_run_len(tag) < 3
|
|
|
|
|
|
def unstable(tag):
|
|
low = tag.lower()
|
|
return any(mk in low for mk in UNSTABLE)
|
|
|
|
|
|
def newest(tags, cur):
|
|
c = tokenize(cur)
|
|
if not c:
|
|
return None
|
|
shape, nums = c
|
|
best_all = best_maj = None
|
|
for t in tags:
|
|
if unstable(t) and not unstable(cur):
|
|
continue
|
|
p = tokenize(t)
|
|
if not p or p[0] != shape or len(p[1]) != len(nums):
|
|
continue
|
|
if any(n >= 20000 for n in p[1]) and not any(n >= 20000 for n in nums):
|
|
continue # lscr.io-style date rebuild tag, not a release (see 00-drift.py)
|
|
if best_all is None or p[1] > best_all[1]:
|
|
best_all = (t, p[1])
|
|
if p[1][0] == nums[0] and (best_maj is None or p[1] > best_maj[1]):
|
|
best_maj = (t, p[1])
|
|
return {"cur": nums, "all": best_all, "maj": best_maj}
|
|
|
|
|
|
def ladder_digests(app_dir):
|
|
fy = os.path.join(app_dir, ".felhom.yml")
|
|
entries, _r, _e = ladder.parse(open(fy, encoding="utf-8").read())
|
|
if not entries:
|
|
return {}, None
|
|
head = entries[-1]
|
|
return head.get("digest", {}), head.get("tested_at")
|
|
|
|
|
|
def main():
|
|
tdir = os.path.join(CAT, "templates")
|
|
rows = []
|
|
cache = {}
|
|
for app in sorted(os.listdir(tdir)):
|
|
d = os.path.join(tdir, app)
|
|
imgs = ladder.images_in(open(os.path.join(d, "docker-compose.yml"), encoding="utf-8").read())
|
|
ldig, ltested = ladder_digests(d)
|
|
for svc, ref in imgs.items():
|
|
repo, tag = split_ref(ref)
|
|
host, hrepo = host_repo(repo)
|
|
row = {"app": app, "service": svc, "ref": ref, "repo": repo, "tag": tag, "host": host,
|
|
"floating": is_floating(tag)}
|
|
if host == "gitea.dooplex.hu":
|
|
row["status"] = "internal"
|
|
rows.append(row)
|
|
print(f"{app}/{svc}: internal image, not upstream")
|
|
continue
|
|
try:
|
|
if (host, hrepo) not in cache:
|
|
cache[(host, hrepo)] = tags_all(host, hrepo)
|
|
time.sleep(0.15)
|
|
tags = cache[(host, hrepo)]
|
|
row["n_tags"] = len(tags)
|
|
row["pinned_tag_listed"] = tag in tags
|
|
res = newest(tags, tag)
|
|
if res is None:
|
|
row["status"] = "unparsed"
|
|
else:
|
|
row["status"] = "ok"
|
|
ba, bm = res["all"], res["maj"]
|
|
row["newest_all"] = ba[0] if ba else None
|
|
row["newest_major"] = bm[0] if bm else None
|
|
row["behind_within_major"] = bool(bm and bm[1] > res["cur"])
|
|
row["behind_across_major"] = bool(ba and ba[1][0] > res["cur"][0])
|
|
row["major"] = res["cur"][0]
|
|
if row["floating"]:
|
|
dg, err = digest(host, hrepo, tag)
|
|
row["current_digest"] = dg
|
|
row["digest_error"] = err
|
|
row["ladder_digest"] = ldig.get(svc)
|
|
row["ladder_tested_at"] = ltested
|
|
if dg and ldig.get(svc):
|
|
row["moved_since_tested"] = dg != ldig.get(svc)
|
|
except Exception as e: # recorded, never guessed
|
|
row["status"] = "error"
|
|
row["error"] = f"{type(e).__name__}: {e}"[:300]
|
|
rows.append(row)
|
|
print(f"{app}/{svc}: {tag} -> maj={row.get('newest_major')} all={row.get('newest_all')} "
|
|
f"float={row['floating']} {row['status']} {row.get('error','')}")
|
|
json.dump(rows, open(os.path.join(OUT, "01-currency-raw.json"), "w"), indent=1)
|
|
print("rows", len(rows))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|