apiVersion: v1 kind: ConfigMap metadata: name: registry-retention-script namespace: registry data: prune.py: | import base64, json, re, urllib.request, urllib.error REG='https://registry.nxtgauge.com' CFG='/auth/.dockerconfigjson' PATTERN=re.compile(r'^[0-9a-f]{40}$') # Base images that MUST NEVER be deleted, even if their names start with # nxtgauge- in the future. These are the FROM lines in our Dockerfiles # (alpine for rust, node variants for frontend/admin, etc.). If any of # these are missing the entire build pipeline breaks. BASE_IMAGES = { 'alpine', 'node', 'rust', 'busybox', 'golang', 'nginx', 'postgres', 'redis', } # Project-image prefix that we DO prune. Anything outside this is sacred. PROJECT_PREFIX = 'nxtgauge-' with open(CFG,'r') as f: dcfg=json.load(f) auth=dcfg['auths']['registry.nxtgauge.com']['auth'] HEAD={'Authorization': f'Basic {auth}'} def req(url, headers=None, method='GET'): h=dict(HEAD) if headers: h.update(headers) r=urllib.request.Request(url, headers=h, method=method) with urllib.request.urlopen(r, timeout=30) as resp: return resp.status, dict(resp.headers), resp.read() _, _, body = req(f'{REG}/v2/_catalog?n=1000') all_repos=json.loads(body.decode()).get('repositories',[]) # EXPLICIT SAFETY: only consider repos that match the project prefix. # This double-belt-and-suspenders: base images (alpine/node/rust) are # also in BASE_IMAGES as a fallback in case the prefix is ever changed. repos=[r for r in all_repos if r.startswith(PROJECT_PREFIX) and r not in BASE_IMAGES] # Sanity check: log if any base image is missing missing_base = [b for b in BASE_IMAGES if b in all_repos or True] # always present present = set(all_repos) for b in BASE_IMAGES: if b not in present: print(f'[WARN] base image {b} not in registry catalog - re-push required!') deleted=0 for repo in sorted(repos): try: _, _, tb=req(f'{REG}/v2/{repo}/tags/list') tags=(json.loads(tb.decode()).get('tags') or []) except Exception as e: print(f'[{repo}] tags/list failed: {e}') continue sha=[t for t in tags if PATTERN.match(t)] if len(sha)<=1: print(f'[{repo}] sha={len(sha)} no prune') continue rows=[] for t in sha: created='1970-01-01T00:00:00Z' digest=None try: _, h, mb=req(f'{REG}/v2/{repo}/manifests/{t}', headers={'Accept':'application/vnd.docker.distribution.manifest.v2+json'}) digest=h.get('Docker-Content-Digest') m=json.loads(mb.decode()) cfg=(m.get('config') or {}).get('digest') if cfg: _, _, cb=req(f'{REG}/v2/{repo}/blobs/{cfg}') created=json.loads(cb.decode()).get('created', created) except Exception: created='9999-12-31T23:59:59Z' rows.append((created, t, digest)) rows.sort(key=lambda x: x[0], reverse=True) KEEP_N=3 # keep last 3 SHA builds (was 1; bumped to prevent auth-blast-radius wipeouts) keep_set=set(t for _, t, _ in rows[:KEEP_N]) # preserve any -latest aliases regardless of age keep_set.update(t for t in tags if t.endswith('-latest')) keep_list=sorted(keep_set) print(f'[{repo}] sha_total={len(rows)} keep={keep_list} remove={max(0, len(rows)-len(keep_set))}') for _, t, d in rows: if t in keep_set or not d: continue try: req(f'{REG}/v2/{repo}/manifests/{d}', method='DELETE') deleted+=1 print(f' deleted {repo}:{t}') except urllib.error.HTTPError as e: print(f' delete failed {repo}:{t} code={e.code}') except Exception as e: print(f' delete failed {repo}:{t} err={e}') print(f'deleted_manifests={deleted}')