diff --git a/apps/registry/kustomization.yaml b/apps/registry/kustomization.yaml new file mode 100644 index 0000000..0924665 --- /dev/null +++ b/apps/registry/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - retention-script.yaml + - retention-cronjob.yaml +namespace: registry diff --git a/apps/registry/retention-cronjob.yaml b/apps/registry/retention-cronjob.yaml new file mode 100644 index 0000000..4eb6693 --- /dev/null +++ b/apps/registry/retention-cronjob.yaml @@ -0,0 +1,33 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: registry-keep-last-3-builds + namespace: registry +spec: + schedule: "*/15 * * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 2 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + backoffLimit: 1 + template: + spec: + restartPolicy: Never + containers: + - name: prune + image: python:3.12-alpine + command: ["python", "/scripts/prune.py"] + volumeMounts: + - name: script + mountPath: /scripts + - name: auth + mountPath: /auth + readOnly: true + volumes: + - name: script + configMap: + name: registry-retention-script + - name: auth + secret: + secretName: registry-regcred diff --git a/apps/registry/retention-script.yaml b/apps/registry/retention-script.yaml new file mode 100644 index 0000000..276817f --- /dev/null +++ b/apps/registry/retention-script.yaml @@ -0,0 +1,77 @@ +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}$') + + 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') + repos=[r for r in json.loads(body.decode()).get('repositories',[]) if r.startswith('nxtgauge-')] + + 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}')