A per-arch push came back as "is a manifest list". Since Buildx 0.10,
provenance and SBOM attestations are ON by default, so even a SINGLE-platform
build publishes an OCI index (image + attestation) rather than a plain
manifest — and `docker manifest create` refuses to nest an index inside an
index.
Two changes, either of which alone would fix it, kept together because they
fail in different directions:
--provenance=false --sbom=false keeps each per-arch push a plain manifest.
Probed for support first, so an older
docker that lacks the flags still works.
imagetools create composes correctly whether the sources are
plain manifests OR indexes, so attestations
reappearing later cannot break it again.
Falls back to docker manifest when buildx
is unavailable, with the actionable hint in
the failure message.
Verification now accepts either inspector's output and asserts both platforms
are present by name.
144 lines
6.7 KiB
Bash
Executable File
144 lines
6.7 KiB
Bash
Executable File
#!/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 <tag> | --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"
|
|
|
|
# --provenance=false --sbom=false: since Buildx 0.10 attestations are ON by
|
|
# default, so even a SINGLE-platform build publishes an OCI INDEX (image +
|
|
# attestation) instead of a plain manifest. `docker manifest create` then
|
|
# refuses it with "is a manifest list". Suppressing them keeps each arch a
|
|
# plain manifest. Harmless if the flags are unsupported — we probe first.
|
|
PROV_FLAGS=""
|
|
if docker build --help 2>/dev/null | grep -q -- '--provenance'; then
|
|
PROV_FLAGS="--provenance=false --sbom=false"
|
|
fi
|
|
|
|
for arch in amd64 arm64; do
|
|
echo "building linux/$arch"
|
|
# shellcheck disable=SC2086 # PROV_FLAGS is deliberately word-split
|
|
docker build $PROV_FLAGS --platform "linux/$arch" -t "$REPO:$TAG-$arch" . \
|
|
|| die "build failed for $arch (arm64 needs qemu/binfmt: docker run --privileged --rm tonistiigi/binfmt --install arm64)"
|
|
# Explicit tag, never $_ — in zsh $_ is the last ARGUMENT, which here is '.'
|
|
docker push "$REPO:$TAG-$arch" || die "push failed for $arch"
|
|
done
|
|
|
|
# `docker buildx imagetools create` rather than `docker manifest create`: it
|
|
# composes correctly whether the sources are plain manifests OR indexes, so it
|
|
# does not fail with "is a manifest list" when attestations sneak back in. It
|
|
# is a client-side registry operation, so it honours the /etc/hosts pin too.
|
|
if docker buildx imagetools create --help >/dev/null 2>&1; then
|
|
docker buildx imagetools create -t "$REPO:$TAG" "$REPO:$TAG-amd64" "$REPO:$TAG-arm64" \
|
|
|| die "imagetools create failed"
|
|
else
|
|
docker manifest rm "$REPO:$TAG" >/dev/null 2>&1 || true
|
|
docker manifest create "$REPO:$TAG" --amend "$REPO:$TAG-amd64" --amend "$REPO:$TAG-arm64" \
|
|
|| die "manifest create failed — if it says \"is a manifest list\", rebuild with --provenance=false"
|
|
docker manifest push "$REPO:$TAG"
|
|
fi
|
|
|
|
# Verify by reading it back, rather than trusting the push's exit code.
|
|
echo "verifying the published manifest lists both architectures"
|
|
inspect=$(docker buildx imagetools inspect "$REPO:$TAG" 2>/dev/null || docker manifest inspect "$REPO:$TAG")
|
|
for want in amd64 arm64; do
|
|
echo "$inspect" | grep -q "$want" || die "published manifest is missing linux/$want"
|
|
done
|
|
echo "$inspect" | grep -iE 'platform|architecture' | sort -u | head -6
|
|
|
|
cat <<EOF
|
|
|
|
published $REPO:$TAG
|
|
|
|
Next, in flux-config, bump BOTH deployments that use this image:
|
|
applications/forgejo-runner/base/deployment-zegit-runner.yaml
|
|
applications/forgejo-runner/base/deployment-forgejo-runner.yaml
|
|
(markus-runner uses markus/gitea-runner and is unaffected.)
|
|
EOF
|