#!/usr/bin/env bash # push-image.sh — build and publish the runner toolchain image, multi-arch. # # Why this script exists: doing it by hand hit four obstacles in a row. # # 1. 413 Payload Too Large. gitea.langenoja.se is behind Cloudflare, which # caps request bodies well below a registry layer. Fixed by pinning the # hostname to the Istio gateway on the LAN (below) so the push never # traverses Cloudflare. # 2. Docker only treats a name as a registry HOST if it contains a dot or a # colon. Pushing to the bare MagicDNS name `gitea-system-gitea-tailscale-http` # was parsed as a Docker Hub NAMESPACE, so Docker Hub refused the auth. # 3. The Gitea PAT needs `write:package`. `read:package` is pull-only. # 4. Multi-arch is required: forgejo-runner runs on arm64 nodes and # zegit-runner on amd64, neither deployment sets a nodeSelector, and both # use this one tag. # # The hostname MUST stay gitea.langenoja.se in the image reference, because that # is what the cluster pulls. So we keep the name and change only the ROUTE, via # an /etc/hosts pin that is removed again on exit. # # The Istio gateway serves a *.langenoja.se wildcard cert, so TLS validates # normally — no insecure-registries, no --tls-verify=false. set -euo pipefail TAG="${1:-}" GATEWAY_IP="${GATEWAY_IP:-10.40.0.10}" REGISTRY="gitea.langenoja.se" REPO="$REGISTRY/zegit-zoo/zegit-forgejo-runner" MARKER="# added by push-image.sh" HOSTS_LINE="$GATEWAY_IP $REGISTRY $MARKER" die() { echo "error: $*" >&2; exit 1; } if [ "$TAG" = "--unpin" ]; then MARKER="# added by push-image.sh" if grep -qF "$MARKER" /etc/hosts 2>/dev/null; then sudo sed -i "\|${MARKER}\$|d" /etc/hosts; echo "removed the leftover pin" else echo "no pin of ours in /etc/hosts; nothing to do" fi exit 0 fi [ -n "$TAG" ] || die "usage: $0 | --unpin (e.g. $0 12.11.1-ci4)" command -v docker >/dev/null || die "docker not on PATH" # Three cases, and they must not be confused: # OURS — a line carrying our marker. A LEFTOVER from a run that died # before its trap fired. Reuse it, and DO remove it on exit. # THEIRS — a line without our marker. Deliberate; never touch it, and # refuse if it points somewhere other than the gateway. # ABSENT — add ours. # The earlier version could not tell OURS from THEIRS, so a leftover pin was # treated as deliberate and survived every subsequent run. That is the bug that # made an operator revert /etc/hosts by hand. PIN_OWNED=0 if grep -qF "$MARKER" /etc/hosts 2>/dev/null; then PIN_OWNED=1 echo "found a leftover pin from an interrupted run; reusing and will clean it up" elif grep -qE "^[^#]*[[:space:]]$REGISTRY([[:space:]]|\$)" /etc/hosts 2>/dev/null; then existing=$(grep -E "^[^#]*[[:space:]]$REGISTRY([[:space:]]|\$)" /etc/hosts | head -1) [ "$(echo "$existing" | awk '{print $1}')" = "$GATEWAY_IP" ] \ || die "/etc/hosts already pins $REGISTRY elsewhere (not ours, not the gateway): $existing" echo "an unmarked pin to the gateway already exists; leaving it exactly as found" fi # Removes ONLY lines carrying our marker, so a deliberate pin is never touched. unpin() { if grep -qF "$MARKER" /etc/hosts 2>/dev/null; then sudo sed -i "\|${MARKER}\$|d" /etc/hosts echo "removed the /etc/hosts pin" fi } cleanup() { [ "$PIN_OWNED" = "1" ] && unpin; } # INT and TERM too: EXIT alone leaves the pin behind on Ctrl-C in some shells. trap cleanup EXIT INT TERM if [ "$PIN_OWNED" = "0" ] && ! grep -qE "^[^#]*[[:space:]]$REGISTRY([[:space:]]|\$)" /etc/hosts 2>/dev/null; then echo "pinning $REGISTRY -> $GATEWAY_IP (bypasses Cloudflare; removed on exit)" echo "$HOSTS_LINE" | sudo tee -a /etc/hosts >/dev/null PIN_OWNED=1 fi # Prove the route before building anything: 401 means the registry answered and # wants auth, which is success here. A timeout means the pin or the LAN is wrong. code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 20 "https://$REGISTRY/v2/" || true) case "$code" in 401|200) echo "registry reachable via the pin (HTTP $code)" ;; *) die "registry not reachable via $GATEWAY_IP (HTTP ${code:-timeout}). Is this host on the LAN?" ;; esac docker login "$REGISTRY" || die "login failed — the PAT needs write:package, not read:package" for arch in amd64 arm64; do echo "building linux/$arch" docker build --platform "linux/$arch" -t "$REPO:$TAG-$arch" . \ || die "build failed for $arch (arm64 needs qemu/binfmt registered)" # Explicit tag, never $_ — in zsh $_ is the last ARGUMENT, which here is '.' docker push "$REPO:$TAG-$arch" || die "push failed for $arch" done docker manifest rm "$REPO:$TAG" >/dev/null 2>&1 || true docker manifest create "$REPO:$TAG" --amend "$REPO:$TAG-amd64" --amend "$REPO:$TAG-arm64" docker manifest push "$REPO:$TAG" # Verify by reading it back, rather than trusting the push's exit code. echo "verifying the published manifest lists both architectures" got=$(docker manifest inspect "$REPO:$TAG" | grep -o '"architecture": *"[a-z0-9]*"' | sort -u | wc -l) [ "$got" -ge 2 ] || die "published manifest does not list two architectures" docker manifest inspect "$REPO:$TAG" | grep -o '"architecture": *"[a-z0-9]*"' | sort -u cat <