#!/usr/bin/env bash
#
# refresh-base-digests.sh — move the Containerfile's pinned base images to
# whatever the tags point at today.
#
# Both FROM lines are pinned by digest (wiki `alloy-distribution`, "Rebuilding
# a past image"). A pin nobody can move is a pin people work around, so this is
# the supported way to move it: it resolves each tag, rewrites the line in
# place, and prints what changed so the diff is reviewable before it is
# committed. Run it deliberately — a base image move is a rebuild of
# everything, and it is a decision rather than maintenance.
#
# Multi-arch index digests, never per-arch manifests. CLAUDE.md forbids
# cross-compiling and Alloy is built natively on fw13 (amd64) and astra
# (arm64), so a per-arch pin would break one host. The script refuses a digest
# that does not resolve to an index carrying both.
#
# No skopeo dependency: it is not in the image and not on every dev box, and
# the registry v2 API answers this with two curls.
#
# Usage:
#   build/refresh-base-digests.sh            # rewrite the Containerfile
#   build/refresh-base-digests.sh --check    # exit 1 if a pin is unpullable
#
# --check reports and changes nothing, so it is safe to wire into anything. The
# astra sweep runs it nightly (`base-pins` in sweep.toml).
#
# It separates two states that used to report identically as STALE and are not
# the same thing at all:
#
#   MOVED  the tag now points somewhere else. The pin still resolves, the image
#          still builds, and moving it is a decision rather than maintenance.
#          Expected, and not a failure.
#   DEAD   the pinned digest is gone from the registry. Nobody can build the
#          image, and a machine that already has the layers cannot tell. Alloy
#          publishes nothing and the install path is "build it yourself"
#          (docs/IMAGE.md), so this is the whole install path broken.
#
# Only DEAD exits nonzero, so a red cell means unbuildable rather than behind.
# Both quay.io base digests were DEAD on 2026-08-06 and nothing noticed until a
# build ran on a host with a cold container store.
#
# Anything else (no network, no token, a registry answering 500) exits 3 and says
# `error:`, which is not a claim about the pins either way.

set -Eeuo pipefail

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CONTAINERFILE="$REPO_ROOT/Containerfile"

# The images to keep pinned, as full `registry/repository:tag` references.
#
# registry.fedoraproject.org rather than quay.io, and the reason is retention.
# Measured 2026-08-25, after the third time a quay pin died: every digest quay
# had garbage-collected was still served here, including the two the
# Containerfile was pinned to that morning.
#
#   fedora-bootc sha256:96bf9106  quay 404   registry.fedoraproject.org 200
#   fedora       sha256:9aae5331  quay 404   registry.fedoraproject.org 200
#   fedora-bootc sha256:0fd80baf  quay 404   registry.fedoraproject.org 200
#   fedora-bootc sha256:2266b67d  quay 404   registry.fedoraproject.org 200
#
# It is the same content: `fedora-bootc:43` resolves to the same index digest at
# both, and the index there carries all four architectures with its blobs
# reachable. So this is a retention change and not a content change, which is
# why it could be made without rebuilding anything.
#
# It is not the fix. Retention here is still somebody else's policy, unmeasured
# past a few weeks, and the mirror ruled on 2026-08-09 (GO alloy ebf30337) is
# what makes it ours. This buys the time to build that without the tree being
# unbuildable in the meantime.
IMAGES=(
  "registry.fedoraproject.org/fedora:43"
  "registry.fedoraproject.org/fedora-bootc:43"
)
CHECK_ONLY=0

# Exit 3, never 1: 1 is reserved for "a pin is dead", and a caller that cannot
# tell a dead pin from a dead network learns the wrong thing from both.
die() { printf 'error: %s\n' "$*" >&2; exit 3; }

# Every unguarded failure lands here, so an aborted run says `error:` on stderr
# like a deliberate one. Without it, a curl dying under `set -e` mid-substitution
# would exit nonzero silently and read as a finding about the pins.
#
# A `die` inside a command substitution exits only that subshell, and the failed
# assignment then trips this trap in every enclosing one. Status 3 is the marker
# that something already said what went wrong, so it propagates without a second
# and third guess at the same failure.
trap 'rc=$?; [ "$rc" -eq 3 ] && exit 3; die "unexpected failure at line ${LINENO}"' ERR

case "${1:-}" in
  --check) CHECK_ONLY=1 ;;
  -h|--help) sed -n '2,45p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
  "") ;;
  *) die "unknown argument: $1 (see --help)" ;;
esac

command -v curl >/dev/null || die "curl not found"
command -v python3 >/dev/null || die "python3 not found"
[ -f "$CONTAINERFILE" ] || die "no Containerfile at $CONTAINERFILE"

# The Accept header decides what comes back: ask for the index types first or
# the registry answers with a single-arch manifest and the pin silently becomes
# amd64-only. That failure would not show up until someone built on astra.
ACCEPT='Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json'

# An anonymous pull token, or nothing when the registry does not ask for one.
#
# Registries differ here and the difference is not cosmetic: quay.io serves
# public repos without credentials but still demands a bearer token on the
# manifest endpoint, while registry.fedoraproject.org answers a plain GET. So
# the token is discovered rather than assumed — ask once, and only chase a
# token if the registry replies 401 and says where to get one. Hardcoding
# either behaviour breaks the moment a base moves to the other kind of
# registry, which is exactly what happened to the version of this script that
# hardcoded quay.
token_for() {
  local registry="$1" repo="$2" challenge realm service body
  challenge="$(curl -sSI "https://${registry}/v2/${repo}/manifests/latest" \
    | tr -d '\r' | grep -i '^www-authenticate:' || true)"
  case "$challenge" in
    *Bearer*) ;;
    *) printf ''; return 0 ;;
  esac

  realm="$(printf '%s' "$challenge" | grep -oE 'realm="[^"]+"' | cut -d'"' -f2)"
  service="$(printf '%s' "$challenge" | grep -oE 'service="[^"]+"' | cut -d'"' -f2)"
  [ -n "$realm" ] || die "${registry} asked for a token and named no realm"

  body="$(curl -fsS "${realm}?service=${service}&scope=repository:${repo}:pull")" \
    || die "cannot reach ${realm} for a pull token on ${repo}"
  printf '%s' "$body" | python3 -c 'import sys, json; print(json.load(sys.stdin)["token"])' \
    || die "${registry} returned no usable pull token for ${repo}"
}


# Whether a reference still resolves at the registry: prints `alive`, `dead`, or
# dies. This is the whole point of the check — a digest is immutable, so the only
# thing that can happen to one is that it stops existing.
#
# Deliberately not `curl -f`: -f collapses every HTTP error into exit 22, and 404
# (the pin is gone) has to stay distinguishable from 500 (the registry is having
# a day). Only 200 and 404 are answers; anything else is an error about the run.
manifest_state() {
  local registry="$1" repo="$2" ref="$3" token status
  token="$(token_for "$registry" "$repo")"

  if [ -n "$token" ]; then
    status="$(curl -sS -o /dev/null -w '%{http_code}' -I \
        -H "Authorization: Bearer ${token}" -H "$ACCEPT" \
        "https://${registry}/v2/${repo}/manifests/${ref}")" \
      || die "cannot reach ${registry} for ${repo}"
  else
    status="$(curl -sS -o /dev/null -w '%{http_code}' -I -H "$ACCEPT" \
        "https://${registry}/v2/${repo}/manifests/${ref}")" \
      || die "cannot reach ${registry} for ${repo}"
  fi

  case "$status" in
    200) printf 'alive' ;;
    404) printf 'dead' ;;
    *) die "${registry}/${repo} answered ${status} for ${ref}, which is neither" ;;
  esac
}

# The index digest for a tag, asserted to be a real multi-arch index.
digest_for() {
  local registry="$1" repo="$2" tag="$3" token body digest
  token="$(token_for "$registry" "$repo")"
  local -a auth=()
  [ -z "$token" ] || auth=(-H "Authorization: Bearer ${token}")

  digest="$(curl -fsSI "${auth[@]}" \
      -H "$ACCEPT" \
      "https://${registry}/v2/${repo}/manifests/${tag}" \
    | grep -i '^docker-content-digest:' | tr -d '\r' | awk '{print $2}')"

  [ -n "$digest" ] || die "no digest for ${registry}/${repo}:${tag}"

  # Prove it is an index over both build architectures before pinning it.
  body="$(curl -fsS "${auth[@]}" \
      -H "$ACCEPT" \
      "https://${registry}/v2/${repo}/manifests/${digest}")"

  printf '%s' "$body" | python3 -c '
import json, sys
doc = json.load(sys.stdin)
arches = {m.get("platform", {}).get("architecture") for m in doc.get("manifests", [])}
missing = {"amd64", "arm64"} - arches
if missing:
    sys.exit("not a multi-arch index over the build hosts; missing " + ", ".join(sorted(missing)))
' || die "${repo}:${tag} resolved to something unusable: $digest"

  printf '%s' "$digest"
}

moved=0
dead=0

for image in "${IMAGES[@]}"; do
  ref="${image}"
  registry="${image%%/*}"
  repo="${image#*/}"
  tag="${repo#*:}"
  repo="${repo%:*}"

  current="$(grep -oE "^FROM ${ref}@sha256:[0-9a-f]{64}" "$CONTAINERFILE" | head -1 | grep -oE 'sha256:[0-9a-f]{64}' || true)"
  [ -n "$current" ] || die "no digest-pinned FROM line for ${ref} in the Containerfile"

  # The pin first, the tag second. Whether the tag advanced is interesting;
  # whether the thing we pinned can still be pulled is the finding.
  state="$(manifest_state "$registry" "$repo" "$current")"
  latest="$(digest_for "$registry" "$repo" "$tag")"

  if [ "$state" = "dead" ]; then
    dead=1
    printf 'DEAD     %s\n  pinned %s (gone from the registry)\n     now %s\n' \
      "$ref" "$current" "$latest"
  elif [ "$current" = "$latest" ]; then
    printf 'current  %s\n         %s\n' "$ref" "$current"
    continue
  else
    moved=1
    printf 'moved    %s\n     was %s\n     now %s\n' "$ref" "$current" "$latest"
  fi

  if [ "$CHECK_ONLY" -eq 0 ]; then
    # Anchored at the start of the line and matching the full old digest, so
    # this cannot touch a digest that happens to appear in a comment.
    python3 - "$CONTAINERFILE" "$ref" "$current" "$latest" <<'PY'
import sys

path, ref, old, new = sys.argv[1:5]
with open(path, encoding="utf-8") as handle:
    text = handle.read()

needle = f"FROM {ref}@{old}"
if text.count(needle) != 1:
    sys.exit(f"expected exactly one `{needle}`, found {text.count(needle)}")

with open(path, "w", encoding="utf-8") as handle:
    handle.write(text.replace(needle, f"FROM {ref}@{new}"))
PY
    printf '     rewrote %s\n' "$(basename "$CONTAINERFILE")"
  fi
done

if [ "$CHECK_ONLY" -eq 1 ]; then
  if [ "$dead" -eq 1 ]; then
    echo
    echo "A pinned base digest no longer exists. The image cannot be built from a"
    echo "cold container store until the pin moves: build/refresh-base-digests.sh"
    exit 1
  fi
  if [ "$moved" -eq 1 ]; then
    echo
    echo "Pins are behind their tags but still pullable. Moving them is a decision:"
    echo "it rebuilds everything, so run build/refresh-base-digests.sh when you mean to."
  fi
  exit 0
fi

if [ "$moved" -eq 1 ] || [ "$dead" -eq 1 ]; then
  echo
  echo "Base images moved. Everything rebuilds from scratch on the next build."
fi
