| 48 |
48 |
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
| 49 |
49 |
|
CONTAINERFILE="$REPO_ROOT/Containerfile"
|
| 50 |
50 |
|
|
| 51 |
|
- |
# The images to keep pinned, as `repository:tag`. Both are quay.io; if a base
|
| 52 |
|
- |
# ever comes from elsewhere this needs a registry column rather than a guess.
|
|
51 |
+ |
# The images to keep pinned, as full `registry/repository:tag` references.
|
|
52 |
+ |
#
|
|
53 |
+ |
# registry.fedoraproject.org rather than quay.io, and the reason is retention.
|
|
54 |
+ |
# Measured 2026-08-25, after the third time a quay pin died: every digest quay
|
|
55 |
+ |
# had garbage-collected was still served here, including the two the
|
|
56 |
+ |
# Containerfile was pinned to that morning.
|
|
57 |
+ |
#
|
|
58 |
+ |
# fedora-bootc sha256:96bf9106 quay 404 registry.fedoraproject.org 200
|
|
59 |
+ |
# fedora sha256:9aae5331 quay 404 registry.fedoraproject.org 200
|
|
60 |
+ |
# fedora-bootc sha256:0fd80baf quay 404 registry.fedoraproject.org 200
|
|
61 |
+ |
# fedora-bootc sha256:2266b67d quay 404 registry.fedoraproject.org 200
|
|
62 |
+ |
#
|
|
63 |
+ |
# It is the same content: `fedora-bootc:43` resolves to the same index digest at
|
|
64 |
+ |
# both, and the index there carries all four architectures with its blobs
|
|
65 |
+ |
# reachable. So this is a retention change and not a content change, which is
|
|
66 |
+ |
# why it could be made without rebuilding anything.
|
|
67 |
+ |
#
|
|
68 |
+ |
# It is not the fix. Retention here is still somebody else's policy, unmeasured
|
|
69 |
+ |
# past a few weeks, and the mirror ruled on 2026-08-09 (GO alloy ebf30337) is
|
|
70 |
+ |
# what makes it ours. This buys the time to build that without the tree being
|
|
71 |
+ |
# unbuildable in the meantime.
|
| 53 |
72 |
|
IMAGES=(
|
| 54 |
|
- |
"fedora/fedora:43"
|
| 55 |
|
- |
"fedora/fedora-bootc:43"
|
|
73 |
+ |
"registry.fedoraproject.org/fedora:43"
|
|
74 |
+ |
"registry.fedoraproject.org/fedora-bootc:43"
|
| 56 |
75 |
|
)
|
| 57 |
|
- |
|
| 58 |
|
- |
REGISTRY="quay.io"
|
| 59 |
76 |
|
CHECK_ONLY=0
|
| 60 |
77 |
|
|
| 61 |
78 |
|
# Exit 3, never 1: 1 is reserved for "a pin is dead", and a caller that cannot
|
| 88 |
105 |
|
# amd64-only. That failure would not show up until someone built on astra.
|
| 89 |
106 |
|
ACCEPT='Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json'
|
| 90 |
107 |
|
|
| 91 |
|
- |
# An anonymous pull token. quay.io serves public repos without credentials but
|
| 92 |
|
- |
# still wants a bearer token on the manifest endpoint.
|
|
108 |
+ |
# An anonymous pull token, or nothing when the registry does not ask for one.
|
|
109 |
+ |
#
|
|
110 |
+ |
# Registries differ here and the difference is not cosmetic: quay.io serves
|
|
111 |
+ |
# public repos without credentials but still demands a bearer token on the
|
|
112 |
+ |
# manifest endpoint, while registry.fedoraproject.org answers a plain GET. So
|
|
113 |
+ |
# the token is discovered rather than assumed — ask once, and only chase a
|
|
114 |
+ |
# token if the registry replies 401 and says where to get one. Hardcoding
|
|
115 |
+ |
# either behaviour breaks the moment a base moves to the other kind of
|
|
116 |
+ |
# registry, which is exactly what happened to the version of this script that
|
|
117 |
+ |
# hardcoded quay.
|
| 93 |
118 |
|
token_for() {
|
| 94 |
|
- |
local body
|
| 95 |
|
- |
body="$(curl -fsS "https://${REGISTRY}/v2/auth?service=${REGISTRY}&scope=repository:${1}:pull")" \
|
| 96 |
|
- |
|| die "cannot reach ${REGISTRY} for a pull token on ${1}"
|
|
119 |
+ |
local registry="$1" repo="$2" challenge realm service body
|
|
120 |
+ |
challenge="$(curl -sSI "https://${registry}/v2/${repo}/manifests/latest" \
|
|
121 |
+ |
| tr -d '\r' | grep -i '^www-authenticate:' || true)"
|
|
122 |
+ |
case "$challenge" in
|
|
123 |
+ |
*Bearer*) ;;
|
|
124 |
+ |
*) printf ''; return 0 ;;
|
|
125 |
+ |
esac
|
|
126 |
+ |
|
|
127 |
+ |
realm="$(printf '%s' "$challenge" | grep -oE 'realm="[^"]+"' | cut -d'"' -f2)"
|
|
128 |
+ |
service="$(printf '%s' "$challenge" | grep -oE 'service="[^"]+"' | cut -d'"' -f2)"
|
|
129 |
+ |
[ -n "$realm" ] || die "${registry} asked for a token and named no realm"
|
|
130 |
+ |
|
|
131 |
+ |
body="$(curl -fsS "${realm}?service=${service}&scope=repository:${repo}:pull")" \
|
|
132 |
+ |
|| die "cannot reach ${realm} for a pull token on ${repo}"
|
| 97 |
133 |
|
printf '%s' "$body" | python3 -c 'import sys, json; print(json.load(sys.stdin)["token"])' \
|
| 98 |
|
- |
|| die "${REGISTRY} returned no usable pull token for ${1}"
|
|
134 |
+ |
|| die "${registry} returned no usable pull token for ${repo}"
|
| 99 |
135 |
|
}
|
| 100 |
136 |
|
|
|
137 |
+ |
|
| 101 |
138 |
|
# Whether a reference still resolves at the registry: prints `alive`, `dead`, or
|
| 102 |
139 |
|
# dies. This is the whole point of the check — a digest is immutable, so the only
|
| 103 |
140 |
|
# thing that can happen to one is that it stops existing.
|
| 106 |
143 |
|
# (the pin is gone) has to stay distinguishable from 500 (the registry is having
|
| 107 |
144 |
|
# a day). Only 200 and 404 are answers; anything else is an error about the run.
|
| 108 |
145 |
|
manifest_state() {
|
| 109 |
|
- |
local repo="$1" ref="$2" token status
|
| 110 |
|
- |
token="$(token_for "$repo")"
|
|
146 |
+ |
local registry="$1" repo="$2" ref="$3" token status
|
|
147 |
+ |
token="$(token_for "$registry" "$repo")"
|
| 111 |
148 |
|
|
| 112 |
|
- |
status="$(curl -sS -o /dev/null -w '%{http_code}' -I \
|
| 113 |
|
- |
-H "Authorization: Bearer ${token}" \
|
| 114 |
|
- |
-H "$ACCEPT" \
|
| 115 |
|
- |
"https://${REGISTRY}/v2/${repo}/manifests/${ref}")" \
|
| 116 |
|
- |
|| die "cannot reach ${REGISTRY} for ${repo}"
|
|
149 |
+ |
if [ -n "$token" ]; then
|
|
150 |
+ |
status="$(curl -sS -o /dev/null -w '%{http_code}' -I \
|
|
151 |
+ |
-H "Authorization: Bearer ${token}" -H "$ACCEPT" \
|
|
152 |
+ |
"https://${registry}/v2/${repo}/manifests/${ref}")" \
|
|
153 |
+ |
|| die "cannot reach ${registry} for ${repo}"
|
|
154 |
+ |
else
|
|
155 |
+ |
status="$(curl -sS -o /dev/null -w '%{http_code}' -I -H "$ACCEPT" \
|
|
156 |
+ |
"https://${registry}/v2/${repo}/manifests/${ref}")" \
|
|
157 |
+ |
|| die "cannot reach ${registry} for ${repo}"
|
|
158 |
+ |
fi
|
| 117 |
159 |
|
|
| 118 |
160 |
|
case "$status" in
|
| 119 |
161 |
|
200) printf 'alive' ;;
|
| 120 |
162 |
|
404) printf 'dead' ;;
|
| 121 |
|
- |
*) die "${REGISTRY}/${repo} answered ${status} for ${ref}, which is neither" ;;
|
|
163 |
+ |
*) die "${registry}/${repo} answered ${status} for ${ref}, which is neither" ;;
|
| 122 |
164 |
|
esac
|
| 123 |
165 |
|
}
|
| 124 |
166 |
|
|
| 125 |
167 |
|
# The index digest for a tag, asserted to be a real multi-arch index.
|
| 126 |
168 |
|
digest_for() {
|
| 127 |
|
- |
local repo="$1" tag="$2" token body digest
|
| 128 |
|
- |
token="$(token_for "$repo")"
|
|
169 |
+ |
local registry="$1" repo="$2" tag="$3" token body digest
|
|
170 |
+ |
token="$(token_for "$registry" "$repo")"
|
|
171 |
+ |
local -a auth=()
|
|
172 |
+ |
[ -z "$token" ] || auth=(-H "Authorization: Bearer ${token}")
|
| 129 |
173 |
|
|
| 130 |
|
- |
digest="$(curl -fsSI \
|
| 131 |
|
- |
-H "Authorization: Bearer ${token}" \
|
|
174 |
+ |
digest="$(curl -fsSI "${auth[@]}" \
|
| 132 |
175 |
|
-H "$ACCEPT" \
|
| 133 |
|
- |
"https://${REGISTRY}/v2/${repo}/manifests/${tag}" \
|
|
176 |
+ |
"https://${registry}/v2/${repo}/manifests/${tag}" \
|
| 134 |
177 |
|
| grep -i '^docker-content-digest:' | tr -d '\r' | awk '{print $2}')"
|
| 135 |
178 |
|
|
| 136 |
|
- |
[ -n "$digest" ] || die "no digest for ${repo}:${tag}"
|
|
179 |
+ |
[ -n "$digest" ] || die "no digest for ${registry}/${repo}:${tag}"
|
| 137 |
180 |
|
|
| 138 |
181 |
|
# Prove it is an index over both build architectures before pinning it.
|
| 139 |
|
- |
body="$(curl -fsS \
|
| 140 |
|
- |
-H "Authorization: Bearer ${token}" \
|
|
182 |
+ |
body="$(curl -fsS "${auth[@]}" \
|
| 141 |
183 |
|
-H "$ACCEPT" \
|
| 142 |
|
- |
"https://${REGISTRY}/v2/${repo}/manifests/${digest}")"
|
|
184 |
+ |
"https://${registry}/v2/${repo}/manifests/${digest}")"
|
| 143 |
185 |
|
|
| 144 |
186 |
|
printf '%s' "$body" | python3 -c '
|
| 145 |
187 |
|
import json, sys
|
| 157 |
199 |
|
dead=0
|
| 158 |
200 |
|
|
| 159 |
201 |
|
for image in "${IMAGES[@]}"; do
|
| 160 |
|
- |
repo="${image%:*}"
|
| 161 |
|
- |
tag="${image#*:}"
|
| 162 |
|
- |
ref="${REGISTRY}/${repo}:${tag}"
|
|
202 |
+ |
ref="${image}"
|
|
203 |
+ |
registry="${image%%/*}"
|
|
204 |
+ |
repo="${image#*/}"
|
|
205 |
+ |
tag="${repo#*:}"
|
|
206 |
+ |
repo="${repo%:*}"
|
| 163 |
207 |
|
|
| 164 |
208 |
|
current="$(grep -oE "^FROM ${ref}@sha256:[0-9a-f]{64}" "$CONTAINERFILE" | head -1 | grep -oE 'sha256:[0-9a-f]{64}' || true)"
|
| 165 |
209 |
|
[ -n "$current" ] || die "no digest-pinned FROM line for ${ref} in the Containerfile"
|
| 166 |
210 |
|
|
| 167 |
211 |
|
# The pin first, the tag second. Whether the tag advanced is interesting;
|
| 168 |
212 |
|
# whether the thing we pinned can still be pulled is the finding.
|
| 169 |
|
- |
state="$(manifest_state "$repo" "$current")"
|
| 170 |
|
- |
latest="$(digest_for "$repo" "$tag")"
|
|
213 |
+ |
state="$(manifest_state "$registry" "$repo" "$current")"
|
|
214 |
+ |
latest="$(digest_for "$registry" "$repo" "$tag")"
|
| 171 |
215 |
|
|
| 172 |
216 |
|
if [ "$state" = "dead" ]; then
|
| 173 |
217 |
|
dead=1
|