#!/usr/bin/env python3 """check-infra-pins.py — the MONTHLY re-test's infrastructure half (R-838, 2026-10-04). The box's three built-in containers (traefik, cloudflared, filebrowser) are pinned in internal/infra/infra.go and are NOT catalog templates, so retest-floating.py never sees them. Before R-838 cloudflared sat four months behind and nothing noticed. This script prints, for each pin, the newest upstream release in the SAME channel (traefik v3.x, cloudflared YYYY.M.P, filebrowser N.N.N-stable — never a beta) and says BEHIND when the pin is older. It REPORTS; it changes nothing. A raise is a controller release (edit the constant, read the release notes between the two versions for breaking changes, prove it on 9202 then both demo boxes: the runbook's "Infrastructure pins" section). Network: Docker Hub's public tag API only. Exit 0 = all current, 1 = at least one BEHIND, 2 = could not check. Run: python3 scripts/check-infra-pins.py """ import json import os import re import sys import urllib.request HERE = os.path.dirname(os.path.abspath(__file__)) INFRA = os.path.join(HERE, "..", "internal", "infra", "infra.go") CHANNELS = { "TraefikImage": ("library/traefik", re.compile(r"^v3\.(\d+)\.(\d+)$")), "CloudflaredImage": ("cloudflare/cloudflared", re.compile(r"^(\d{4})\.(\d+)\.(\d+)$")), "FileBrowserImage": ("gtstef/filebrowser", re.compile(r"^(\d+)\.(\d+)\.(\d+)-stable$")), } def pins(): src = open(INFRA).read() out = {} for const in CHANNELS: m = re.search(r'\b%s\s*=\s*"([^"]+)"' % const, src) if not m: raise SystemExit(f"check-infra-pins: {const} not found in {INFRA}") out[const] = m.group(1) return out def tags(repo): url = f"https://hub.docker.com/v2/repositories/{repo}/tags?page_size=100&ordering=last_updated" names = [] for _ in range(3): # three pages are plenty for "the newest in a channel" with urllib.request.urlopen(url, timeout=30) as r: d = json.load(r) names += [t["name"] for t in d.get("results", [])] url = d.get("next") if not url: break return names def key(m): return tuple(int(x) for x in m.groups()) def main(): rc = 0 for const, image in pins().items(): repo, rx = CHANNELS[const] tag = image.split(":", 1)[1] cur = rx.match(tag) try: cands = [m for m in (rx.match(t) for t in tags(repo)) if m] except Exception as e: # noqa: BLE001 — a report, not a gate print(f"{const:17} {image:40} could not check: {e}") rc = max(rc, 2) continue if not cands or not cur: print(f"{const:17} {image:40} no comparable tag found") rc = max(rc, 2) continue newest = max(cands, key=key) state = "current" if key(newest) <= key(cur) else "BEHIND" if state == "BEHIND": rc = max(rc, 1) print(f"{const:17} {image:40} newest {newest.string:16} {state}") return rc if __name__ == "__main__": sys.exit(main())