| 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 |
set -Eeuo pipefail |
| 47 |
|
| 48 |
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" |
| 49 |
CONTAINERFILE="$REPO_ROOT/Containerfile" |
| 50 |
|
| 51 |
|
| 52 |
|
| 53 |
|
| 54 |
|
| 55 |
|
| 56 |
|
| 57 |
|
| 58 |
|
| 59 |
|
| 60 |
|
| 61 |
|
| 62 |
|
| 63 |
|
| 64 |
|
| 65 |
|
| 66 |
|
| 67 |
|
| 68 |
|
| 69 |
|
| 70 |
|
| 71 |
|
| 72 |
IMAGES=( |
| 73 |
"registry.fedoraproject.org/fedora:43" |
| 74 |
"registry.fedoraproject.org/fedora-bootc:43" |
| 75 |
) |
| 76 |
CHECK_ONLY=0 |
| 77 |
|
| 78 |
|
| 79 |
|
| 80 |
die() { printf 'error: %s\n' "$*" >&2; exit 3; } |
| 81 |
|
| 82 |
|
| 83 |
|
| 84 |
|
| 85 |
|
| 86 |
|
| 87 |
|
| 88 |
|
| 89 |
|
| 90 |
trap 'rc=$?; [ "$rc" -eq 3 ] && exit 3; die "unexpected failure at line ${LINENO}"' ERR |
| 91 |
|
| 92 |
case "${1:-}" in |
| 93 |
--check) CHECK_ONLY=1 ;; |
| 94 |
-h|--help) sed -n '2,45p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; |
| 95 |
"") ;; |
| 96 |
*) die "unknown argument: $1 (see --help)" ;; |
| 97 |
esac |
| 98 |
|
| 99 |
command -v curl >/dev/null || die "curl not found" |
| 100 |
command -v python3 >/dev/null || die "python3 not found" |
| 101 |
[ -f "$CONTAINERFILE" ] || die "no Containerfile at $CONTAINERFILE" |
| 102 |
|
| 103 |
|
| 104 |
|
| 105 |
|
| 106 |
ACCEPT='Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' |
| 107 |
|
| 108 |
|
| 109 |
|
| 110 |
|
| 111 |
|
| 112 |
|
| 113 |
|
| 114 |
|
| 115 |
|
| 116 |
|
| 117 |
|
| 118 |
token_for() { |
| 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}" |
| 133 |
printf '%s' "$body" | python3 -c 'import sys, json; print(json.load(sys.stdin)["token"])' \ |
| 134 |
|| die "${registry} returned no usable pull token for ${repo}" |
| 135 |
} |
| 136 |
|
| 137 |
|
| 138 |
|
| 139 |
|
| 140 |
|
| 141 |
|
| 142 |
|
| 143 |
|
| 144 |
|
| 145 |
manifest_state() { |
| 146 |
local registry="$1" repo="$2" ref="$3" token status |
| 147 |
token="$(token_for "$registry" "$repo")" |
| 148 |
|
| 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 |
| 159 |
|
| 160 |
case "$status" in |
| 161 |
200) printf 'alive' ;; |
| 162 |
404) printf 'dead' ;; |
| 163 |
*) die "${registry}/${repo} answered ${status} for ${ref}, which is neither" ;; |
| 164 |
esac |
| 165 |
} |
| 166 |
|
| 167 |
|
| 168 |
digest_for() { |
| 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}") |
| 173 |
|
| 174 |
digest="$(curl -fsSI "${auth[@]}" \ |
| 175 |
-H "$ACCEPT" \ |
| 176 |
"https://${registry}/v2/${repo}/manifests/${tag}" \ |
| 177 |
| grep -i '^docker-content-digest:' | tr -d '\r' | awk '{print $2}')" |
| 178 |
|
| 179 |
[ -n "$digest" ] || die "no digest for ${registry}/${repo}:${tag}" |
| 180 |
|
| 181 |
|
| 182 |
body="$(curl -fsS "${auth[@]}" \ |
| 183 |
-H "$ACCEPT" \ |
| 184 |
"https://${registry}/v2/${repo}/manifests/${digest}")" |
| 185 |
|
| 186 |
printf '%s' "$body" | python3 -c ' |
| 187 |
import json, sys |
| 188 |
doc = json.load(sys.stdin) |
| 189 |
arches = {m.get("platform", {}).get("architecture") for m in doc.get("manifests", [])} |
| 190 |
missing = {"amd64", "arm64"} - arches |
| 191 |
if missing: |
| 192 |
sys.exit("not a multi-arch index over the build hosts; missing " + ", ".join(sorted(missing))) |
| 193 |
' || die "${repo}:${tag} resolved to something unusable: $digest" |
| 194 |
|
| 195 |
printf '%s' "$digest" |
| 196 |
} |
| 197 |
|
| 198 |
moved=0 |
| 199 |
dead=0 |
| 200 |
|
| 201 |
for image in "${IMAGES[@]}"; do |
| 202 |
ref="${image}" |
| 203 |
registry="${image%%/*}" |
| 204 |
repo="${image#*/}" |
| 205 |
tag="${repo#*:}" |
| 206 |
repo="${repo%:*}" |
| 207 |
|
| 208 |
current="$(grep -oE "^FROM ${ref}@sha256:[0-9a-f]{64}" "$CONTAINERFILE" | head -1 | grep -oE 'sha256:[0-9a-f]{64}' || true)" |
| 209 |
[ -n "$current" ] || die "no digest-pinned FROM line for ${ref} in the Containerfile" |
| 210 |
|
| 211 |
|
| 212 |
|
| 213 |
state="$(manifest_state "$registry" "$repo" "$current")" |
| 214 |
latest="$(digest_for "$registry" "$repo" "$tag")" |
| 215 |
|
| 216 |
if [ "$state" = "dead" ]; then |
| 217 |
dead=1 |
| 218 |
printf 'DEAD %s\n pinned %s (gone from the registry)\n now %s\n' \ |
| 219 |
"$ref" "$current" "$latest" |
| 220 |
elif [ "$current" = "$latest" ]; then |
| 221 |
printf 'current %s\n %s\n' "$ref" "$current" |
| 222 |
continue |
| 223 |
else |
| 224 |
moved=1 |
| 225 |
printf 'moved %s\n was %s\n now %s\n' "$ref" "$current" "$latest" |
| 226 |
fi |
| 227 |
|
| 228 |
if [ "$CHECK_ONLY" -eq 0 ]; then |
| 229 |
|
| 230 |
|
| 231 |
python3 - "$CONTAINERFILE" "$ref" "$current" "$latest" <<'PY' |
| 232 |
import sys |
| 233 |
|
| 234 |
path, ref, old, new = sys.argv[1:5] |
| 235 |
with open(path, encoding="utf-8") as handle: |
| 236 |
text = handle.read() |
| 237 |
|
| 238 |
needle = f"FROM {ref}@{old}" |
| 239 |
if text.count(needle) != 1: |
| 240 |
sys.exit(f"expected exactly one `{needle}`, found {text.count(needle)}") |
| 241 |
|
| 242 |
with open(path, "w", encoding="utf-8") as handle: |
| 243 |
handle.write(text.replace(needle, f"FROM {ref}@{new}")) |
| 244 |
PY |
| 245 |
printf ' rewrote %s\n' "$(basename "$CONTAINERFILE")" |
| 246 |
fi |
| 247 |
done |
| 248 |
|
| 249 |
if [ "$CHECK_ONLY" -eq 1 ]; then |
| 250 |
if [ "$dead" -eq 1 ]; then |
| 251 |
echo |
| 252 |
echo "A pinned base digest no longer exists. The image cannot be built from a" |
| 253 |
echo "cold container store until the pin moves: build/refresh-base-digests.sh" |
| 254 |
exit 1 |
| 255 |
fi |
| 256 |
if [ "$moved" -eq 1 ]; then |
| 257 |
echo |
| 258 |
echo "Pins are behind their tags but still pullable. Moving them is a decision:" |
| 259 |
echo "it rebuilds everything, so run build/refresh-base-digests.sh when you mean to." |
| 260 |
fi |
| 261 |
exit 0 |
| 262 |
fi |
| 263 |
|
| 264 |
if [ "$moved" -eq 1 ] || [ "$dead" -eq 1 ]; then |
| 265 |
echo |
| 266 |
echo "Base images moved. Everything rebuilds from scratch on the next build." |
| 267 |
fi |
| 268 |
|