Skip to main content

max / alloy

11.1 KB · 268 lines History Blame Raw
1 #!/usr/bin/env bash
2 #
3 # refresh-base-digests.sh — move the Containerfile's pinned base images to
4 # whatever the tags point at today.
5 #
6 # Both FROM lines are pinned by digest (wiki `alloy-distribution`, "Rebuilding
7 # a past image"). A pin nobody can move is a pin people work around, so this is
8 # the supported way to move it: it resolves each tag, rewrites the line in
9 # place, and prints what changed so the diff is reviewable before it is
10 # committed. Run it deliberately — a base image move is a rebuild of
11 # everything, and it is a decision rather than maintenance.
12 #
13 # Multi-arch index digests, never per-arch manifests. CLAUDE.md forbids
14 # cross-compiling and Alloy is built natively on fw13 (amd64) and astra
15 # (arm64), so a per-arch pin would break one host. The script refuses a digest
16 # that does not resolve to an index carrying both.
17 #
18 # No skopeo dependency: it is not in the image and not on every dev box, and
19 # the registry v2 API answers this with two curls.
20 #
21 # Usage:
22 # build/refresh-base-digests.sh # rewrite the Containerfile
23 # build/refresh-base-digests.sh --check # exit 1 if a pin is unpullable
24 #
25 # --check reports and changes nothing, so it is safe to wire into anything. The
26 # astra sweep runs it nightly (`base-pins` in sweep.toml).
27 #
28 # It separates two states that used to report identically as STALE and are not
29 # the same thing at all:
30 #
31 # MOVED the tag now points somewhere else. The pin still resolves, the image
32 # still builds, and moving it is a decision rather than maintenance.
33 # Expected, and not a failure.
34 # DEAD the pinned digest is gone from the registry. Nobody can build the
35 # image, and a machine that already has the layers cannot tell. Alloy
36 # publishes nothing and the install path is "build it yourself"
37 # (docs/IMAGE.md), so this is the whole install path broken.
38 #
39 # Only DEAD exits nonzero, so a red cell means unbuildable rather than behind.
40 # Both quay.io base digests were DEAD on 2026-08-06 and nothing noticed until a
41 # build ran on a host with a cold container store.
42 #
43 # Anything else (no network, no token, a registry answering 500) exits 3 and says
44 # `error:`, which is not a claim about the pins either way.
45
46 set -Eeuo pipefail
47
48 REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
49 CONTAINERFILE="$REPO_ROOT/Containerfile"
50
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.
72 IMAGES=(
73 "registry.fedoraproject.org/fedora:43"
74 "registry.fedoraproject.org/fedora-bootc:43"
75 )
76 CHECK_ONLY=0
77
78 # Exit 3, never 1: 1 is reserved for "a pin is dead", and a caller that cannot
79 # tell a dead pin from a dead network learns the wrong thing from both.
80 die() { printf 'error: %s\n' "$*" >&2; exit 3; }
81
82 # Every unguarded failure lands here, so an aborted run says `error:` on stderr
83 # like a deliberate one. Without it, a curl dying under `set -e` mid-substitution
84 # would exit nonzero silently and read as a finding about the pins.
85 #
86 # A `die` inside a command substitution exits only that subshell, and the failed
87 # assignment then trips this trap in every enclosing one. Status 3 is the marker
88 # that something already said what went wrong, so it propagates without a second
89 # and third guess at the same failure.
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 # The Accept header decides what comes back: ask for the index types first or
104 # the registry answers with a single-arch manifest and the pin silently becomes
105 # amd64-only. That failure would not show up until someone built on astra.
106 ACCEPT='Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json'
107
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.
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 # Whether a reference still resolves at the registry: prints `alive`, `dead`, or
139 # dies. This is the whole point of the check — a digest is immutable, so the only
140 # thing that can happen to one is that it stops existing.
141 #
142 # Deliberately not `curl -f`: -f collapses every HTTP error into exit 22, and 404
143 # (the pin is gone) has to stay distinguishable from 500 (the registry is having
144 # a day). Only 200 and 404 are answers; anything else is an error about the run.
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 # The index digest for a tag, asserted to be a real multi-arch index.
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 # Prove it is an index over both build architectures before pinning it.
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 # The pin first, the tag second. Whether the tag advanced is interesting;
212 # whether the thing we pinned can still be pulled is the finding.
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 # Anchored at the start of the line and matching the full old digest, so
230 # this cannot touch a digest that happens to appear in a comment.
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