Files
zeekayandHanzo Dev d1e1c5e7e4 pin: commit as Zach Kelling, Hanzo Dev as co-author
Pins were authored by lux-ci <[email protected]>, so every deploy the pipeline
moved stayed off the owner's history. Pinned-by still says whether CI or a
person ran it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 14:34:33 -07:00

411 lines
21 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# pin — move a service's image, and be the only thing that does.
#
# cd.hanzo.ai does not watch the registry. It watches this directory: the
# lux-fleet ApplicationSet (applicationset.yaml) has a git generator over
# deploy/*/*.yaml, and each file it finds becomes one Application whose values
# are that file. So publishing an image changes nothing. Moving the pin here is
# the deploy, and it is the whole deploy.
#
# A pin is TWO fields and ONE fact. `tag` is how a human says which release;
# `digest` is what the kubelet pulls. _helpers.tpl renders `repo:tag@digest`,
# and a reference carrying both RESOLVES BY DIGEST. Written from two registry
# reads they can name two different releases, and the pod then reports the tag
# while running the digest — a deploy that succeeds at doing nothing. So there
# is one read: `HEAD /v2/<path>/manifests/<tag>` answers the tag that was asked
# for, and Docker-Content-Digest is the digest OF THAT ANSWER. Both are written
# in one edit and read back together.
#
# Why pin the digest when the tag reads better: `pullPolicy: IfNotPresent` is
# what the fleet runs, so a node that ever pulled `:v1.2.3` keeps those bytes.
# Registry tags are mutable. The digest is the identity; the tag is the name.
#
# deploy/pin.sh <namespace>/<service> <version>
# deploy/pin.sh --verify [<namespace>/<service>…]
# deploy/pin.sh --which <repository> # who runs this image
# deploy/pin.sh --freeze <namespace>/<service>… # a moving tag names bytes
#
# Refusals, each a way a release can put something unintended into production:
# non-semver — a sha cannot be read, ordered, or rolled back to by a human;
# an unpublished tag — resolved BEFORE the pin moves, so a pin can never name
# an image the registry does not have;
# a caller-chosen repository — read from the values file, never from argv, so
# a careless build can only move its own service between its own releases;
# going backward — refused unless PIN_ROLLBACK=1, which is a different act;
# a service with no values file — adding a service is deliberate.
#
# The invariants are charts/app/pin.sh's, which owns hanzo's fleet under
# charts/app/values/. That script's values root is fixed at its own directory,
# so it cannot be pointed at this one; when it takes a root, this file goes.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" # deploy/
die() { echo "::error::pin: $*" >&2; exit 1; }
# Being overtaken is not a failure. A release asking for a version the pin is
# already past has got what it wanted, so it gets a status of its own.
overtaken() { echo "::notice::pin: $*" >&2; exit 3; }
# Every manifest type a pullable image can be stored as. A registry answers 404
# when the tag exists but is stored as a type the request does not accept, which
# is indistinguishable here from a tag that was never published.
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'
# A bearer token out of a registry's token endpoint, or nothing. Prints nothing
# rather than a traceback when the body is not the JSON that was expected,
# because the caller's next line already says what a missing token means. GitLab
# and a few others answer `access_token` where the spec says `token`.
read_token() {
python3 -c 'import sys,json
try:
d = json.load(sys.stdin); sys.stdout.write(d.get("token") or d.get("access_token") or "")
except Exception: pass' 2>/dev/null
}
# THE ONE READ. Prints the digest, or `!<reason>` with exit 1 so the verifier can
# report every service instead of stopping at the first.
#
# AUTH IS ASKED FOR, NOT GUESSED. Every registry here answers a 401 that SAYS
# where its tokens come from — `Www-Authenticate: Bearer realm=…,service=…` — so
# the probe is sent unauthenticated first and the challenge is read off the
# refusal. Before this the realm was assembled per host from a table, which had
# ghcr's shape and therefore reported quay.io's three cert-manager images and
# registry.digitalocean.com's lux-admin as UNPULLABLE — four false alarms, from
# four registries that were serving normally. A table of hosts is a guess about
# other people's servers; the challenge is their own answer.
resolve_digest() {
local repository="$1" tag="$2" first host path chal realm service token headers code digest
first="${repository%%/*}"
# Which registry a reference names is a property of the reference, and the rule
# is the one every client uses: a first segment carrying a dot or a port is a
# host; anything else is Docker Hub, where a bare name means library/.
# Third-party images are resolved here too — openresty:alpine and
# python:3.12-alpine are moving pointers exactly like :latest, and a base image
# that changes under a service is harder to see than one that changes with it.
if printf '%s' "$first" | grep -q '[.:]'; then
host="$first"; path="${repository#*/}"
else
host="registry-1.docker.io"
case "$repository" in */*) path="$repository" ;; *) path="library/$repository" ;; esac
fi
# HEAD, not GET: the digest is a header, so the body is bytes nobody reads.
# -L because registry.k8s.io answers 307 to a regional mirror, and an
# unfollowed redirect is indistinguishable here from a tag that is not there.
probe() {
curl -sSIL --max-time 30 -H "Accept: $ACCEPT" ${1:+-H "Authorization: Bearer $1"} \
"https://${host}/v2/${path}/manifests/${tag}" 2>/dev/null
}
headers="$(probe "")" \
|| { echo "!manifest request for ${repository}:${tag} failed (network)"; return 1; }
if printf '%s' "$headers" | head -1 | grep -q ' 401'; then
chal="$(printf '%s' "$headers" | tr -d '\r' | sed -n 's/^[Ww][Ww][Ww]-[Aa]uthenticate: *[Bb]earer *//p' | head -1)"
realm="$(printf '%s' "$chal" | sed -n 's/.*realm="\([^"]*\)".*/\1/p')"
service="$(printf '%s' "$chal" | sed -n 's/.*service="\([^"]*\)".*/\1/p')"
[ -n "$realm" ] || { echo "!${repository}:${tag} refused anonymously and named no token realm"; return 1; }
# Credentials are offered only where a private image needs them. They are in
# scope in any build that pushed the image it is now pinning.
if [ -n "${GHCR_USER:-}" ] && [ -n "${GHCR_TOKEN:-}" ] && [ "$host" = "ghcr.io" ]; then
token="$(curl -fsSL --max-time 30 -u "$GHCR_USER:$GHCR_TOKEN" \
"${realm}?scope=repository:${path}:pull&service=${service}" 2>/dev/null | read_token)" || token=""
else
token="$(curl -fsSL --max-time 30 \
"${realm}?scope=repository:${path}:pull&service=${service}" 2>/dev/null | read_token)" || token=""
fi
[ -n "$token" ] || { echo "!could not obtain a pull token for ${host}/${path}"; return 1; }
headers="$(probe "$token")" \
|| { echo "!manifest request for ${repository}:${tag} failed (network or auth)"; return 1; }
fi
# The LAST status line and the LAST digest header: -L leaves one set of headers
# per hop, and only the final hop answered.
code="$(printf '%s' "$headers" | grep -E '^HTTP/' | tail -1 | awk '{print $2}')"
[ "$code" = "200" ] || { echo "!${repository}:${tag} is not pullable (manifest probe returned ${code:-none})"; return 1; }
digest="$(printf '%s' "$headers" | tr -d '\r' | sed -n 's/^[Dd]ocker-[Cc]ontent-[Dd]igest: *//p' | tail -1)"
# A 200 with no header would let the caller write `repo:tag@`, which nothing
# can pull. Refuse instead.
[[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] \
|| { echo "!${repository}:${tag} answered 200 but named no Docker-Content-Digest"; return 1; }
echo "$digest"
}
# Read one key out of a values file's TOP-LEVEL image: block. Indented comments
# stay in the block — these files carry the reasoning for every value in them —
# so only a line starting in column 0 ends it. A tag YAML would read as a number
# is written quoted; the quotes are syntax, not part of the name.
image_field() {
awk -v key="$2" '
/^image:/ { in_image = 1; next }
in_image && /^[^[:space:]]/ { in_image = 0 }
in_image && $1 == key":" { v = $2; gsub(/^[\047"]|[\047"]$/, "", v); print v; exit }
' "$1"
}
# ONE EDIT, in place. These files carry the reasoning for every value in them, so
# a YAML round-trip would reflow it away. A file that never carried a digest gains
# one directly beneath the tag, at its indentation — the pair is one fact.
#
# Both scalars are written by the same pass and read back together, because the
# edit is the only place they can come apart. A fourth argument rewrites
# pullPolicy at the same time: freezing a moving tag and stopping the re-pull are
# one decision, not two, and doing them in separate passes is how a file ends up
# with a digest it re-resolves on every restart.
write_pin() {
local values="$1" tag="$2" dig="$3" policy="${4:-}" has=0 q="" wrote_tag wrote_dig
[ -n "$(image_field "$values" digest)" ] && has=1
# A TAG IS A STRING, AND SOME STRINGS ARE NUMBERS. `1.17` written bare is a
# YAML float, and a float loses its trailing zero: `1.10` reads back as 1.1 and
# the chart then asks for an image the registry does not have. So a tag YAML
# would read as a number is written quoted. This replaced the whole token,
# quotes included, and consul's `tag: '1.17'` came back bare on the first run.
[[ "$tag" =~ ^[+-]?[0-9]+([.][0-9]*)?$ ]] && q="'"
awk -v tag="$tag" -v dig="$dig" -v has_digest="$has" -v policy="$policy" -v q="$q" '
/^image:/ { in_image = 1; print; next }
in_image && /^[^[:space:]]/ { in_image = 0 }
in_image && $1 == "tag:" && !wrote_tag {
match($0, /^[[:space:]]*/); ind = substr($0, 1, RLENGTH)
sub(/tag:[[:space:]]*[^[:space:]#]+/, "tag: " q tag q)
print; wrote_tag = 1
if (has_digest == 0) print ind "digest: " dig
next }
in_image && $1 == "digest:" && !wrote_digest {
sub(/digest:[[:space:]]*[^[:space:]#]*/, "digest: " dig)
print; wrote_digest = 1; next }
in_image && $1 == "pullPolicy:" && policy != "" {
sub(/pullPolicy:[[:space:]]*[^[:space:]#]+/, "pullPolicy: " policy)
print; next }
{ print }
' "$values" > "$values.pin" && mv "$values.pin" "$values"
wrote_tag="$(image_field "$values" tag)"
wrote_dig="$(image_field "$values" digest)"
[ "$wrote_tag" = "$tag" ] \
|| die "wrote deploy/$values but image.tag reads back as '$wrote_tag', expected '$tag'"
[ "$wrote_dig" = "$dig" ] \
|| die "wrote deploy/$values but image.digest reads back as '$wrote_dig', expected '$dig' — the file names a tag and a digest that disagree, which deploys the digest"
}
# A service resolves to exactly one values file. Zero means it is not in the
# inventory; more than one means two namespaces hold the name and the caller
# must say which — the same collision the ApplicationSet's name template guards.
resolve_values() {
local pat line; local -a m=()
case "$1" in
*/*) pat="$1.yaml" ;;
*) pat="./*/$1.yaml" ;;
esac
while IFS= read -r line; do [ -n "$line" ] && m+=("$line"); done < <(ls -1 $pat 2>/dev/null)
case "${#m[@]}" in
0) die "no values file for '$1' — a service is added deliberately, not by a release" ;;
1) echo "${m[0]#./}" ;;
*) die "'$1' is declared in ${#m[@]} namespaces (${m[*]}) — name one as <namespace>/$1" ;;
esac
}
# ── VERIFY ────────────────────────────────────────────────────────────────────
#
# Every values file naming a digest must name the digest its own tag resolves to.
# Run over the whole inventory in CI: the failure this catches is silent, and the
# only moment it is cheap to catch is before it merges.
verify() {
local -a files=(); local s line
if [ "$#" -gt 0 ]; then
for s in "$@"; do files+=("$(resolve_values "$s")"); done
else
while IFS= read -r line; do files+=("$line"); done < <(ls -1 ./*/*.yaml | sed 's|^\./||')
fi
local bad=0 checked=0 f repository tag digest live name
for f in "${files[@]}"; do
repository="$(image_field "$f" repository)"
tag="$(image_field "$f" tag)"
digest="$(image_field "$f" digest)"
name="${f%.yaml}"
[ -n "$repository" ] && [ -n "$tag" ] || { printf ' %-44s %s\n' "$name" "— no tag to check"; continue; }
checked=$((checked + 1))
live="$(resolve_digest "$repository" "$tag")" || {
printf ' %-44s %-26s %s\n' "$name" "$tag" "UNPULLABLE ${live#!}"; bad=$((bad + 1)); continue; }
if [ -z "$digest" ]; then
printf ' %-44s %-26s %s\n' "$name" "$tag" "tag only (resolves ${live:0:19}…)"
elif [ "$digest" = "$live" ]; then
printf ' %-44s %-26s %s\n' "$name" "$tag" "ok ${live:0:19}…"
else
printf ' %-44s %-26s %s\n' "$name" "$tag" "DRIFT"
printf ' %-44s pinned digest: %s\n' "" "$digest"
printf ' %-44s tag resolves : %s\n' "" "$live"
bad=$((bad + 1))
fi
done
echo
[ "$bad" -eq 0 ] || { echo "::error::pin --verify: $bad of $checked pins do not agree with the registry"; return 1; }
echo "pin --verify: $checked pins agree with the registry"
}
# ── WHICH ─────────────────────────────────────────────────────────────────────
#
# WHO RUNS THIS IMAGE. A release knows the repository it published; it does not
# know, and must not have to be told, which namespaces run it. The directory IS
# the inventory, so the answer is read from it rather than kept in a second list
# that has to be edited when a service moves — the list this replaces named four
# paths that did not exist.
#
# Prints `<namespace>/<service>` per line, or nothing, which is not an error: an
# image with no pin here is published and simply not deployed by this fleet.
which_services() {
local repository="$1" f
[ -n "$repository" ] || die "usage: pin.sh --which <repository>"
for f in ./*/*.yaml; do
[ "$(image_field "$f" repository)" = "$repository" ] || continue
f="${f#./}"; echo "${f%.yaml}"
done
}
# ── FREEZE ────────────────────────────────────────────────────────────────────
#
# WHICH BYTES DOES THIS MOVING NAME MEAN TODAY.
#
# `latest`, `main` and `alpine` are pointers, not versions. Two nodes pulling the
# same values file a week apart run different code and both report the same tag,
# and there is nothing to roll back to because the name that was serving now
# means something else. Freeze records the digest the tag resolves to right now
# and leaves the tag beside it, so the reference reads `repo:latest@sha256:…` —
# still legible, no longer ambiguous.
#
# pullPolicy goes to IfNotPresent in the same edit. A digest IS an identity;
# re-pulling it every restart asks the registry a question whose answer cannot
# have changed, and leaving Always beside a digest reads as if it could.
#
# This does not replace a release. It stops a name from drifting until the
# service publishes versions and pin.sh <service> <version> can move it.
freeze() {
local -a files=(); local s line
if [ "$#" -gt 0 ]; then
for s in "$@"; do files+=("$(resolve_values "$s")"); done
else
die "usage: pin.sh --freeze <namespace>/<service>…"
fi
local f repository tag digest live
for f in "${files[@]}"; do
repository="$(image_field "$f" repository)"
tag="$(image_field "$f" tag)"
digest="$(image_field "$f" digest)"
[ -n "$repository" ] && [ -n "$tag" ] || { echo " ${f%.yaml}: no tag to freeze"; continue; }
live="$(resolve_digest "$repository" "$tag")" \
|| die "${live#!} — refusing to freeze a tag the registry does not have"
[ "$digest" = "$live" ] && { echo " ${f%.yaml}: already frozen at $live"; continue; }
write_pin "$f" "$tag" "$live" IfNotPresent
echo " ${f%.yaml}: ${repository}:${tag} -> $live"
done
}
if [ "${1:-}" = "--verify" ]; then shift; verify "$@"; exit $?; fi
if [ "${1:-}" = "--which" ]; then shift; which_services "${1:-}"; exit 0; fi
if [ "${1:-}" = "--freeze" ]; then shift; freeze "$@"; exit $?; fi
# ── PIN ───────────────────────────────────────────────────────────────────────
SERVICE="${1:-}"; VERSION="${2:-}"
[ -n "$SERVICE" ] && [ -n "$VERSION" ] \
|| die "usage: pin.sh <namespace>/<service> <version> | pin.sh --verify [<namespace>/<service>…]"
# Accept a leading v so a caller can pass its git tag or its image tag. The bare
# number is compared; the PREFIX comes from the values file, because the fleet is
# not consistent about it and pinning one convention writes tags that do not exist.
VERSION="${VERSION#v}"
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \
|| die "version '$VERSION' is not semver x.y.z — refusing to pin (semver only, never a sha)"
VALUES="$(resolve_values "$SERVICE")"
REPOSITORY="$(image_field "$VALUES" repository)"
CURRENT="$(image_field "$VALUES" tag)"
CURRENT_DIGEST="$(image_field "$VALUES" digest)"
[ -n "$REPOSITORY" ] || die "deploy/$VALUES declares no image.repository"
[ -n "$CURRENT" ] || die "deploy/$VALUES declares no image.tag"
CURRENT_BARE="${CURRENT#v}"
# Only a VERSION carries an order or a convention, and the current tag is not
# guaranteed to be one — a floating `latest` or a hand-written sha is exactly
# when the pin still has to work. So nothing below is derived from a value
# already known to be unreadable: a sha and a semver compare as strings, and
# `sort -V` would read a forward release as a rollback.
if [[ "$CURRENT_BARE" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
PREFIX=""; case "$CURRENT" in v*) PREFIX="v" ;; esac
TAG="${PREFIX}${VERSION}"
# Checked before the same-version short-circuit: a rollback and a no-op are
# different acts and only one of them may be silent.
if [ "$CURRENT_BARE" != "$VERSION" ] && [ "${PIN_ROLLBACK:-0}" != "1" ] \
&& [ "$(printf '%s\n%s\n' "$CURRENT_BARE" "$VERSION" | sort -V | head -1)" = "$VERSION" ]; then
overtaken "$SERVICE is pinned at $CURRENT and $VERSION is older — leaving it (PIN_ROLLBACK=1 to go back deliberately)"
fi
DIGEST="$(resolve_digest "$REPOSITORY" "$TAG")" \
|| die "${DIGEST#!} — refusing to pin an image the registry does not have"
else
# The file names no convention, so the registry names it. Exactly one spelling
# may answer: two make the tag ambiguous, zero means it was never published,
# and either turns the recovery into a guess.
V_DIGEST="$(resolve_digest "$REPOSITORY" "v$VERSION")" || V_DIGEST=""
B_DIGEST="$(resolve_digest "$REPOSITORY" "$VERSION")" || B_DIGEST=""
if [ -n "$V_DIGEST" ] && [ -n "$B_DIGEST" ]; then
die "$REPOSITORY publishes BOTH v$VERSION and $VERSION, and deploy/$VALUES names no convention to choose by (its tag '$CURRENT' is not a version) — set image.tag to the intended spelling and re-run"
elif [ -n "$V_DIGEST" ]; then TAG="v$VERSION"; DIGEST="$V_DIGEST"
elif [ -n "$B_DIGEST" ]; then TAG="$VERSION"; DIGEST="$B_DIGEST"
else die "neither v$VERSION nor $VERSION is pullable from $REPOSITORY — refusing to pin an image the registry does not have"
fi
echo "pin: $SERVICE carried '$CURRENT', which is not a version — the registry names the convention '$TAG'"
fi
# The only early exit, and only when BOTH halves already name what the registry
# just answered. A file whose tag is current but whose digest is stale is the bug
# this exists to end, so it falls through and is corrected.
if [ "$CURRENT_BARE" = "$VERSION" ] && [ "$CURRENT_DIGEST" = "$DIGEST" ]; then
echo "pin: $SERVICE already at $CURRENT ($DIGEST) — nothing to do"; exit 0
fi
write_pin "$VALUES" "$TAG" "$DIGEST"
REPO_ROOT="$(git rev-parse --show-toplevel)"
git -C "$REPO_ROOT" add "deploy/$VALUES"
if git -C "$REPO_ROOT" diff --cached --quiet; then
echo "pin: deploy/$VALUES already reads $TAG@$DIGEST at HEAD — nothing to commit"; exit 0
fi
# WHO MOVED THIS PIN. The author is the owner and Hanzo Dev is the co-author,
# whoever runs it, so the author cannot say "did CI deploy, or did a person?" —
# the question that says whether the automation works. CI sets GITHUB_RUN_ID;
# a person at a terminal does not, and Pinned-by records which it was.
PINNED_BY="hand"
[ -n "${GITHUB_RUN_ID:-}" ] && PINNED_BY="ci run ${GITHUB_RUN_ID}"
git -C "$REPO_ROOT" -c user.name="Zach Kelling" -c user.email="[email protected]" \
commit --trailer "Co-authored-by: Hanzo Dev <[email protected]>" -m "$SERVICE $CURRENT -> $TAG
${REPOSITORY}:${TAG}
${DIGEST}
Tag and digest are one registry answer, resolved together and written in one
edit, so the name a human reads and the bytes the kubelet pulls cannot be two
different releases. cd.hanzo.ai reconciles the change from here.
Pinned-by: ${PINNED_BY}"
echo "pin: $SERVICE $CURRENT -> $TAG ($DIGEST) [deploy/$VALUES]"