max / alloy
1 file changed,
+102 insertions,
-27 deletions
| @@ -20,12 +20,30 @@ | |||
| 20 | 20 | # | |
| 21 | 21 | # Usage: | |
| 22 | 22 | # build/refresh-base-digests.sh # rewrite the Containerfile | |
| 23 | - | # build/refresh-base-digests.sh --check # exit 1 if the pins are stale | |
| 23 | + | # build/refresh-base-digests.sh --check # exit 1 if a pin is unpullable | |
| 24 | 24 | # | |
| 25 | - | # --check is what a periodic reminder would run. It reports and changes | |
| 26 | - | # nothing, so it is safe to wire into anything. | |
| 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. | |
| 27 | 45 | ||
| 28 | - | set -euo pipefail | |
| 46 | + | set -Eeuo pipefail | |
| 29 | 47 | ||
| 30 | 48 | REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" | |
| 31 | 49 | CONTAINERFILE="$REPO_ROOT/Containerfile" | |
| @@ -40,11 +58,23 @@ | |||
| 40 | 58 | REGISTRY="quay.io" | |
| 41 | 59 | CHECK_ONLY=0 | |
| 42 | 60 | ||
| 43 | - | die() { printf 'error: %s\n' "$*" >&2; exit 1; } | |
| 61 | + | # Exit 3, never 1: 1 is reserved for "a pin is dead", and a caller that cannot | |
| 62 | + | # tell a dead pin from a dead network learns the wrong thing from both. | |
| 63 | + | die() { printf 'error: %s\n' "$*" >&2; exit 3; } | |
| 64 | + | ||
| 65 | + | # Every unguarded failure lands here, so an aborted run says `error:` on stderr | |
| 66 | + | # like a deliberate one. Without it, a curl dying under `set -e` mid-substitution | |
| 67 | + | # would exit nonzero silently and read as a finding about the pins. | |
| 68 | + | # | |
| 69 | + | # A `die` inside a command substitution exits only that subshell, and the failed | |
| 70 | + | # assignment then trips this trap in every enclosing one. Status 3 is the marker | |
| 71 | + | # that something already said what went wrong, so it propagates without a second | |
| 72 | + | # and third guess at the same failure. | |
| 73 | + | trap 'rc=$?; [ "$rc" -eq 3 ] && exit 3; die "unexpected failure at line ${LINENO}"' ERR | |
| 44 | 74 | ||
| 45 | 75 | case "${1:-}" in | |
| 46 | 76 | --check) CHECK_ONLY=1 ;; | |
| 47 | - | -h|--help) sed -n '2,28p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; | |
| 77 | + | -h|--help) sed -n '2,45p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; | |
| 48 | 78 | "") ;; | |
| 49 | 79 | *) die "unknown argument: $1 (see --help)" ;; | |
| 50 | 80 | esac | |
| @@ -53,25 +83,53 @@ | |||
| 53 | 83 | command -v python3 >/dev/null || die "python3 not found" | |
| 54 | 84 | [ -f "$CONTAINERFILE" ] || die "no Containerfile at $CONTAINERFILE" | |
| 55 | 85 | ||
| 56 | - | # An anonymous pull token. quay.io serves public repos without credentials but | |
| 57 | - | # still wants a bearer token on the manifest endpoint. | |
| 58 | - | token_for() { | |
| 59 | - | curl -fsS "https://${REGISTRY}/v2/auth?service=${REGISTRY}&scope=repository:${1}:pull" \ | |
| 60 | - | | python3 -c 'import sys, json; print(json.load(sys.stdin)["token"])' | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | # The index digest for a tag, asserted to be a real multi-arch index. | |
| 64 | - | # | |
| 65 | 86 | # The Accept header decides what comes back: ask for the index types first or | |
| 66 | 87 | # the registry answers with a single-arch manifest and the pin silently becomes | |
| 67 | 88 | # amd64-only. That failure would not show up until someone built on astra. | |
| 89 | + | ACCEPT='Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' | |
| 90 | + | ||
| 91 | + | # An anonymous pull token. quay.io serves public repos without credentials but | |
| 92 | + | # still wants a bearer token on the manifest endpoint. | |
| 93 | + | 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}" | |
| 97 | + | printf '%s' "$body" | python3 -c 'import sys, json; print(json.load(sys.stdin)["token"])' \ | |
| 98 | + | || die "${REGISTRY} returned no usable pull token for ${1}" | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | # Whether a reference still resolves at the registry: prints `alive`, `dead`, or | |
| 102 | + | # dies. This is the whole point of the check — a digest is immutable, so the only | |
| 103 | + | # thing that can happen to one is that it stops existing. | |
| 104 | + | # | |
| 105 | + | # Deliberately not `curl -f`: -f collapses every HTTP error into exit 22, and 404 | |
| 106 | + | # (the pin is gone) has to stay distinguishable from 500 (the registry is having | |
| 107 | + | # a day). Only 200 and 404 are answers; anything else is an error about the run. | |
| 108 | + | manifest_state() { | |
| 109 | + | local repo="$1" ref="$2" token status | |
| 110 | + | token="$(token_for "$repo")" | |
| 111 | + | ||
| 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}" | |
| 117 | + | ||
| 118 | + | case "$status" in | |
| 119 | + | 200) printf 'alive' ;; | |
| 120 | + | 404) printf 'dead' ;; | |
| 121 | + | *) die "${REGISTRY}/${repo} answered ${status} for ${ref}, which is neither" ;; | |
| 122 | + | esac | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | # The index digest for a tag, asserted to be a real multi-arch index. | |
| 68 | 126 | digest_for() { | |
| 69 | 127 | local repo="$1" tag="$2" token body digest | |
| 70 | 128 | token="$(token_for "$repo")" | |
| 71 | 129 | ||
| 72 | 130 | digest="$(curl -fsSI \ | |
| 73 | 131 | -H "Authorization: Bearer ${token}" \ | |
| 74 | - | -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' \ | |
| 132 | + | -H "$ACCEPT" \ | |
| 75 | 133 | "https://${REGISTRY}/v2/${repo}/manifests/${tag}" \ | |
| 76 | 134 | | grep -i '^docker-content-digest:' | tr -d '\r' | awk '{print $2}')" | |
| 77 | 135 | ||
| @@ -80,7 +138,7 @@ | |||
| 80 | 138 | # Prove it is an index over both build architectures before pinning it. | |
| 81 | 139 | body="$(curl -fsS \ | |
| 82 | 140 | -H "Authorization: Bearer ${token}" \ | |
| 83 | - | -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' \ | |
| 141 | + | -H "$ACCEPT" \ | |
| 84 | 142 | "https://${REGISTRY}/v2/${repo}/manifests/${digest}")" | |
| 85 | 143 | ||
| 86 | 144 | printf '%s' "$body" | python3 -c ' | |
| @@ -95,7 +153,8 @@ | |||
| 95 | 153 | printf '%s' "$digest" | |
| 96 | 154 | } | |
| 97 | 155 | ||
| 98 | - | stale=0 | |
| 156 | + | moved=0 | |
| 157 | + | dead=0 | |
| 99 | 158 | ||
| 100 | 159 | for image in "${IMAGES[@]}"; do | |
| 101 | 160 | repo="${image%:*}" | |
| @@ -105,16 +164,23 @@ | |||
| 105 | 164 | current="$(grep -oE "^FROM ${ref}@sha256:[0-9a-f]{64}" "$CONTAINERFILE" | head -1 | grep -oE 'sha256:[0-9a-f]{64}' || true)" | |
| 106 | 165 | [ -n "$current" ] || die "no digest-pinned FROM line for ${ref} in the Containerfile" | |
| 107 | 166 | ||
| 167 | + | # The pin first, the tag second. Whether the tag advanced is interesting; | |
| 168 | + | # whether the thing we pinned can still be pulled is the finding. | |
| 169 | + | state="$(manifest_state "$repo" "$current")" | |
| 108 | 170 | latest="$(digest_for "$repo" "$tag")" | |
| 109 | 171 | ||
| 110 | - | if [ "$current" = "$latest" ]; then | |
| 172 | + | if [ "$state" = "dead" ]; then | |
| 173 | + | dead=1 | |
| 174 | + | printf 'DEAD %s\n pinned %s (gone from the registry)\n now %s\n' \ | |
| 175 | + | "$ref" "$current" "$latest" | |
| 176 | + | elif [ "$current" = "$latest" ]; then | |
| 111 | 177 | printf 'current %s\n %s\n' "$ref" "$current" | |
| 112 | 178 | continue | |
| 179 | + | else | |
| 180 | + | moved=1 | |
| 181 | + | printf 'moved %s\n was %s\n now %s\n' "$ref" "$current" "$latest" | |
| 113 | 182 | fi | |
| 114 | 183 | ||
| 115 | - | stale=1 | |
| 116 | - | printf 'STALE %s\n was %s\n now %s\n' "$ref" "$current" "$latest" | |
| 117 | - | ||
| 118 | 184 | if [ "$CHECK_ONLY" -eq 0 ]; then | |
| 119 | 185 | # Anchored at the start of the line and matching the full old digest, so | |
| 120 | 186 | # this cannot touch a digest that happens to appear in a comment. | |
| @@ -136,13 +202,22 @@ | |||
| 136 | 202 | fi | |
| 137 | 203 | done | |
| 138 | 204 | ||
| 139 | - | if [ "$CHECK_ONLY" -eq 1 ] && [ "$stale" -eq 1 ]; then | |
| 140 | - | echo | |
| 141 | - | echo "Pins are behind their tags. Run build/refresh-base-digests.sh to move them." | |
| 142 | - | exit 1 | |
| 205 | + | if [ "$CHECK_ONLY" -eq 1 ]; then | |
| 206 | + | if [ "$dead" -eq 1 ]; then | |
| 207 | + | echo | |
| 208 | + | echo "A pinned base digest no longer exists. The image cannot be built from a" | |
| 209 | + | echo "cold container store until the pin moves: build/refresh-base-digests.sh" | |
| 210 | + | exit 1 | |
| 211 | + | fi | |
| 212 | + | if [ "$moved" -eq 1 ]; then | |
| 213 | + | echo | |
| 214 | + | echo "Pins are behind their tags but still pullable. Moving them is a decision:" | |
| 215 | + | echo "it rebuilds everything, so run build/refresh-base-digests.sh when you mean to." | |
| 216 | + | fi | |
| 217 | + | exit 0 | |
| 143 | 218 | fi | |
| 144 | 219 | ||
| 145 | - | if [ "$stale" -eq 1 ]; then | |
| 220 | + | if [ "$moved" -eq 1 ] || [ "$dead" -eq 1 ]; then | |
| 146 | 221 | echo | |
| 147 | 222 | echo "Base images moved. Everything rebuilds from scratch on the next build." | |
| 148 | 223 | fi |