1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
|
#!/bin/bash
#
# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# registry-gc.sh — garbage-collect the LAN registry's blob store.
#
# The registry retains every blob ever pushed; without garbage collection the
# storage dir grows without bound until the disk fills and the nightly image
# builds fail with "no space left on device". This runs the distribution
# garbage collector safely:
# * refuses to run while a build/push is in flight,
# * stops the registry so the manifest/blob graph is stable,
# * deletes only untagged manifests (-m) and the blobs they alone
# reference; every tag is preserved,
# * restarts the registry via an EXIT trap, even on failure,
# * verifies a tag still resolves afterwards.
# Intended as a weekly cron job, after the build chain and before the daily
# cache prune. See README.
set -euo pipefail
PROJECT_VERSION="1.1.1" # bump via sed across all scripts; see CLAUDE.md Releases
HERE="$(cd "$(dirname "$0")" && pwd)"
source "${HERE}/config"
LOG_TAG=registry-gc
source "${HERE}/lib.sh"
: "${REGISTRY_CONTAINER:=registry}"
usage() {
cat <<EOF
Usage: registry-gc.sh [OPTIONS]
Garbage-collect the blob store of the LAN registry container.
Options:
--dry-run Scan and report what would be deleted; delete nothing and
leave the registry running.
--container NAME Registry container name (default: ${REGISTRY_CONTAINER}).
-V Print version and exit.
-h, --help Show this help.
EOF
}
DRY_RUN=false
while [[ $# -gt 0 ]]; do
case "$1" in
-V) echo "registry-gc.sh $PROJECT_VERSION"; exit 0 ;;
--dry-run) DRY_RUN=true; shift ;;
--container) REGISTRY_CONTAINER="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) _err "unknown argument: $1" ;;
esac
done
require_docker
docker inspect "${REGISTRY_CONTAINER}" >/dev/null 2>&1 \
|| _err "registry container not found: ${REGISTRY_CONTAINER}"
# Resolve the host directory backing the container's /var/lib/registry mount.
STORAGE="$(docker inspect \
--format '{{range .Mounts}}{{if eq .Destination "/var/lib/registry"}}{{.Source}}{{end}}{{end}}' \
"${REGISTRY_CONTAINER}")"
[[ -n "${STORAGE}" ]] || _err "no /var/lib/registry mount on ${REGISTRY_CONTAINER}"
[[ -d "${STORAGE}" ]] || _err "registry storage dir missing: ${STORAGE}"
REG_IMAGE="$(docker inspect --format '{{.Config.Image}}' "${REGISTRY_CONTAINER}")"
REGISTRY_PORT="${REGISTRY##*:}"
_log "registry=${REGISTRY_CONTAINER} image=${REG_IMAGE} storage=${STORAGE}"
# GC config: same filesystem root as the live registry, read-only maintenance
# (the documented safe mode for collection), delete enabled so unreferenced
# blobs are actually reclaimed.
CFG_DIR="$(mktemp -d /tmp/registry-gc.XXXXXX)"
trap 'rm -rf "${CFG_DIR}"' EXIT
cat > "${CFG_DIR}/config.yml" <<'YAML'
version: 0.1
storage:
filesystem:
rootdirectory: /var/lib/registry
delete:
enabled: true
maintenance:
readonly:
enabled: true
YAML
# Run the collector in a throwaway container against the same storage.
gc() {
docker run --rm \
-v "${STORAGE}:/var/lib/registry" \
-v "${CFG_DIR}/config.yml:/etc/docker/registry/config.yml:ro" \
"${REG_IMAGE}" bin/registry garbage-collect "$@" \
/etc/docker/registry/config.yml
}
# Hard safety gate: distribution 2.8.x GC does not follow OCI image indexes /
# manifest lists, so `-m` deletes their child manifests (distribution issue
# #3178). Every tagged manifest must be a plain schema2/OCI image manifest.
# If any tag is an index, refuse: rebuild the images without provenance (the
# build scripts pass --provenance=false) before collecting again.
assert_gc_safe() {
local base="http://localhost:${REGISTRY_PORT}"
local repos repo tags tag ct
repos="$(curl -sf "${base}/v2/_catalog" \
| sed -n 's/.*"repositories":\[\([^]]*\)\].*/\1/p' | tr ',' '\n' | tr -d ' "')"
[[ -n "${repos}" ]] || _err "cannot read registry catalog at ${base}/v2/_catalog"
for repo in ${repos}; do
tags="$(curl -sf "${base}/v2/${repo}/tags/list" \
| sed -n 's/.*"tags":\[\([^]]*\)\].*/\1/p' | tr ',' '\n' | tr -d ' "')"
for tag in ${tags}; do
ct="$(curl -sfI -H 'Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json' \
"${base}/v2/${repo}/manifests/${tag}" \
| tr -d '\r' | awk 'tolower($1)=="content-type:"{print $2}')"
case "${ct}" in
*index*|*manifest.list*)
_err "tag ${repo}:${tag} is an OCI index/manifest list (${ct}); refusing to GC. Rebuild images with --provenance=false (see build scripts)." ;;
esac
_log " tag ${repo}:${tag} -> ${ct:-unknown}"
done
done
}
assert_gc_safe
if [[ "${DRY_RUN}" == "true" ]]; then
_log "dry-run: scanning ${STORAGE} (registry left running; nothing deleted)"
gc --dry-run -m
_log "dry-run complete; no changes made."
exit 0
fi
# Never race a build/push: the blob graph must be stable while we sweep.
if pgrep -f 'bootstrap.sh|build-full-image.sh|build-sbo-testbuild.sh' >/dev/null; then
_err "a build script is running; refusing to GC. Re-run after the chain finishes."
fi
stopped=false
restart_registry() {
[[ "${stopped}" == "true" ]] || return 0
_log "restarting ${REGISTRY_CONTAINER}..."
docker start "${REGISTRY_CONTAINER}" >/dev/null
local i
for i in $(seq 1 30); do
curl -sf -o /dev/null "http://localhost:${REGISTRY_PORT}/v2/" && break
sleep 1
done
stopped=false
}
trap 'restart_registry; rm -rf "${CFG_DIR}"' EXIT
before_size="$(du -sh "${STORAGE}" 2>/dev/null | cut -f1)"
snap_tag="${REGISTRY}/sbo-base:current"
snap_digest="$(docker inspect --format '{{index .RepoDigests 0}}' "${snap_tag}" 2>/dev/null || true)"
_log "stopping ${REGISTRY_CONTAINER} for a stable blob graph..."
docker stop "${REGISTRY_CONTAINER}" >/dev/null && stopped=true
_log "garbage-collect dry-run (audit; deletes nothing)..."
gc --dry-run -m
_log "garbage-collect (untagged manifests + their blobs; tags preserved)..."
gc -m
restart_registry
after_size="$(du -sh "${STORAGE}" 2>/dev/null | cut -f1)"
_log "storage: ${before_size} -> ${after_size}"
if [[ -n "${snap_digest}" ]]; then
if docker pull "${snap_tag}" >/dev/null 2>&1; then
new_digest="$(docker inspect --format '{{index .RepoDigests 0}}' "${snap_tag}" 2>/dev/null || true)"
if [[ "${new_digest}" == "${snap_digest}" ]]; then
_log "verify OK: ${snap_tag} still resolves, digest unchanged"
else
_warn "verify: ${snap_tag} digest changed: ${snap_digest} -> ${new_digest}"
fi
else
_warn "verify FAILED: ${snap_tag} no longer pulls after GC"
fi
fi
_log "=== Done ==="
|