From a476bb535242a69c99e34ff87a1820c60382aabd Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 11 Nov 2026 16:44:24 -0800 Subject: [PATCH] coraza-spoa: multi-arch (amd64+arm64) by cross-compiling; push-by-digest + imagetools index Dockerfile: Go stages pinned to $BUILDPLATFORM, GOOS/GOARCH from TARGETOS/ TARGETARCH (CGO already off); amd64 rootfs/binary byte-identical to before. Workflow: one trusted x86 job, amd64 + arm64 pushed untagged by digest to both registries, arm64 continue-on-error, tag assembled by scripts/assemble-multiarch-index.sh (arm64 only from this run's successful step; otherwise amd64-only). Offline test + local e2e rehearsal script. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0185ukXjXyosSkunDsdWz3mb --- .gitea/workflows/build-push-coraza.yaml | 55 ++++++-- CLAUDE.md | 1 + coraza-spoa/Dockerfile | 17 ++- coraza-spoa/README.md | 30 +++++ scripts/assemble-multiarch-index.sh | 90 +++++++++++++ scripts/e2e-coraza-multiarch.sh | 54 ++++++++ scripts/test-assemble-multiarch-index.py | 164 +++++++++++++++++++++++ 7 files changed, 399 insertions(+), 12 deletions(-) create mode 100755 scripts/assemble-multiarch-index.sh create mode 100755 scripts/e2e-coraza-multiarch.sh create mode 100644 scripts/test-assemble-multiarch-index.py diff --git a/.gitea/workflows/build-push-coraza.yaml b/.gitea/workflows/build-push-coraza.yaml index 133d6b9..b1f19f5 100644 --- a/.gitea/workflows/build-push-coraza.yaml +++ b/.gitea/workflows/build-push-coraza.yaml @@ -11,12 +11,24 @@ on: - main paths: - 'coraza-spoa/**' + - 'scripts/assemble-multiarch-index.sh' - '.gitea/workflows/build-push-coraza.yaml' workflow_dispatch: +# Multi-arch (linux/amd64 + linux/arm64) in ONE job on the trusted x86 runner, +# no QEMU: coraza-spoa/Dockerfile cross-compiles the Go binary +# (--platform=$BUILDPLATFORM + GOOS/GOARCH). Each platform is pushed BY DIGEST +# (untagged) to BOTH registries, then scripts/assemble-multiarch-index.sh +# creates the :latest index from those digests. The arm64 step is +# continue-on-error: if it fails, :latest is published amd64-only exactly as it +# was before multi-arch, and a previous run's arm64 is never re-attached (the +# arm64 digest is read only from THIS run's step output). Registry write +# credentials exist only on this x86 job. Template for the other WHP image +# repos: docs/arm64/IMAGE-PIPELINE-PLAN.md sections 4.1 / 4.4. jobs: Build-and-Push: runs-on: ubuntu-latest + timeout-minutes: 30 steps: - name: Checkout uses: actions/checkout@v4 @@ -39,9 +51,6 @@ jobs: [ -n "$url" ] || echo "::warning::pkg-cache unreachable - building from upstream repositories" echo "url=${url}" >> "$GITHUB_OUTPUT" - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx uses: https://github.com/docker/setup-buildx-action@v3 @@ -52,7 +61,7 @@ jobs: username: ${{ secrets.CI_USER }} password: ${{ secrets.CI_TOKEN }} - # Mirror to GitHub Container Registry — see build-push.yaml for the + # Mirror to GitHub Container Registry - see build-push.yaml for the # secret/username convention. - name: Login to GHCR uses: docker/login-action@v3 @@ -61,14 +70,42 @@ jobs: username: shadowdao password: ${{ secrets.GHCR_TOKEN }} - - name: Build Image + # amd64: required. Failure here fails the job and publishes nothing, as + # before. Pushed untagged by digest to both registries (same digest in + # each); the tag is only created by the assemble step below. + - name: Build amd64 (push by digest) + id: amd64 uses: docker/build-push-action@v6 with: context: ./coraza-spoa platforms: linux/amd64 - push: true build-args: | PKG_CACHE=${{ steps.pkgcache.outputs.url }} - tags: | - repo.anhonesthost.net/cloud-hosting-platform/coraza-spoa:latest - ghcr.io/shadowdao/coraza-spoa:latest + outputs: type=image,"name=repo.anhonesthost.net/cloud-hosting-platform/coraza-spoa,ghcr.io/shadowdao/coraza-spoa",push-by-digest=true,name-canonical=true,push=true + + # arm64: best effort (cross-compiled, no emulation). Must never block or + # alter the amd64 tag. + - name: Build arm64 (push by digest) + id: arm64 + continue-on-error: true + timeout-minutes: 15 + uses: docker/build-push-action@v6 + with: + context: ./coraza-spoa + platforms: linux/arm64 + build-args: | + PKG_CACHE=${{ steps.pkgcache.outputs.url }} + outputs: type=image,"name=repo.anhonesthost.net/cloud-hosting-platform/coraza-spoa,ghcr.io/shadowdao/coraza-spoa",push-by-digest=true,name-canonical=true,push=true + + - name: Assemble :latest index + env: + AMD64_DIGEST: ${{ steps.amd64.outputs.digest }} + ARM64_DIGEST: ${{ steps.arm64.outputs.digest }} + ARM64_OUTCOME: ${{ steps.arm64.outcome }} + run: | + scripts/assemble-multiarch-index.sh \ + --amd64 "$AMD64_DIGEST" \ + --arm64 "$ARM64_DIGEST" --arm64-outcome "$ARM64_OUTCOME" \ + --repo repo.anhonesthost.net/cloud-hosting-platform/coraza-spoa \ + --repo ghcr.io/shadowdao/coraza-spoa \ + --tag latest diff --git a/CLAUDE.md b/CLAUDE.md index 5ed5c16..dff3ec4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **API thread-pool safety**: `python3 scripts/test-gunicorn-socket-timeouts.py` - runs REAL gunicorn (~60s); asserts a silent/half-written client cannot pin a gthread pool thread (per-connection socket timeout, `HAPROXY_MGR_CLIENT_TIMEOUT`), 2050 never-sending connections are reaped (`HAPROXY_MGR_FIRST_BYTE_TIMEOUT`), slowloris header drips are cut (`HAPROXY_MGR_HEADER_DEADLINE`, counted from enqueue), 40 reconnecting drippers cannot starve legit requests, the :8000 peer filter (`HAPROXY_MGR_API_ALLOWED_PEERS`) refuses non-allowed peers, slow legit requests are not cut off, the wedge watchdog (`HAPROXY_MGR_WEDGE_SECONDS`) replaces a fully stuck worker, and an in-process call to a stalled upstream times out. Run it after touching `haproxy_gunicorn_worker.py`, start-up.sh's gunicorn lines, or upgrading gunicorn (the worker overrides gthread internals: `enqueue_req`, `accept`, `on_client_socket_readable`, `murder_keepalived`, `handle`, `handle_request`). - **WAF body inspection**: `python3 scripts/test-waf-body-inspection.py` - offline (runs in the image build); pins `option http-buffer-request`, the `X-WHP-WAF-Body` complete/partial stamp (strip -> stamp -> SPOE -> strip), `tune.bufsize` against the 100 ms SPOE fail-open budget and the 65535-byte SPOP frame cap, and Coraza's body limits in `coraza-spoa/config.yaml`. Run it after touching the Coraza block, `tune.bufsize`, or the SPOE engine. - **Superseded-cert retirement**: covered by the same file (`TestRetirementRules`, `TestSanOverlapRetirement`). A new bundle retires any OTHER pem/lineage sharing ANY name (CN or SAN) with it - the pem moved to `cert-retired/`, the lineage's live+archive+renewal moved TOGETHER to `/etc/letsencrypt/retired//` (leaving archive/ behind makes certbot refuse the name forever), never deleted; the bundle's own `--cert-name` lineage is `live/` exactly and is never retired - unless that cert is still the only valid one for a name this edge serves (`retirement_blockers()`). `GET /api/capabilities` advertises this to WHP, which must not use whole-site re-bundling without it. Expired pems already in the crt dir are moved to `cert-retired/` by `cert_retire_expired_pems` (never the last pem, never `default_self_signed_cert.pem`). Expired lineages are never published (`cert_is_expired` in `cert-publish-lib.sh`, `leaf_is_expired()` in Python). Fixtures: `scripts/test-fixtures/san-certs/`. +- **Multi-arch index assembly (coraza-spoa)**: `python3 scripts/test-assemble-multiarch-index.py` - offline (NOT run in the image build; it also reads `.gitea/workflows/build-push-coraza.yaml`); `scripts/assemble-multiarch-index.sh` turns the by-digest amd64 + arm64 pushes into the tag(s): arm64 is attached only if THIS run's arm64 step outcome is `success` (failure => amd64-only tag, never a stale arm64), a bad amd64 digest publishes nothing. `scripts/e2e-coraza-multiarch.sh` rehearses the whole thing against throwaway local registries (see coraza-spoa/README.md "Multi-arch"). - **Default splash**: `python3 scripts/test-default-splash.py` - offline (runs in the image build); unrouted hosts (server hostname, bare IP, unknown names) are answered by `http-request return ... file /haproxy/errors/default-splash.html` BEFORE the Coraza SPOE call, never for a routed host (`routed_hosts.list`/`routed_wildcards.list`, written by `generate_config()` from the routing rows and in the rollback set), never for ACME/blocked/suspended. Run it after touching the splash block, the routing ACL templates, or the domain loops in `generate_config()`. See README "Default Splash". - **Access log receiver**: `python3 scripts/test-access-log-receiver.py` - offline (runs in the image build); one datagram = one line (PRI stripped, control chars neutralised), size cap + gzip generations, write errors drop without raising, real UNIX-socket path. Run it after touching `haproxy-log-receiver.py` or the `log` line in `hap_header.tpl`. - **Manual Testing**: Run `curl` commands against `http://localhost:8000` endpoints as shown in README.md diff --git a/coraza-spoa/Dockerfile b/coraza-spoa/Dockerfile index c944874..d9ce04a 100644 --- a/coraza-spoa/Dockerfile +++ b/coraza-spoa/Dockerfile @@ -16,8 +16,16 @@ ARG CORAZA_SPOA_VERSION=v0.7.1 # then for this image's golang:1.25), so both are mirrored at # repo.anhonesthost.net via the .gitea/workflows/mirror-base-image.yaml # weekly job. -FROM repo.anhonesthost.net/cloud-hosting-platform/golang:1.25 AS build +# +# Multi-arch (linux/amd64 + linux/arm64) WITHOUT QEMU: both Go stages are pinned +# to the BUILD host's platform (--platform=$BUILDPLATFORM, so the amd64-only +# golang mirror is enough) and the binary is CROSS-COMPILED for the target via +# GOOS/GOARCH. CGO is off (it already was), so cross-compiling is exact. Only +# the final distroless stage is per target platform (gcr.io publishes arm64). +FROM --platform=$BUILDPLATFORM repo.anhonesthost.net/cloud-hosting-platform/golang:1.25 AS build ARG CORAZA_SPOA_VERSION +ARG TARGETOS +ARG TARGETARCH # OPTIONAL internal package cache, e.g. --build-arg PKG_CACHE=https://192.168.1.40 # (CT130 pkg-cache, https only; CI sets it only after probing the cache with the # pinned CA). Unset = upstream Debian repositories and the default GOPROXY, @@ -42,14 +50,17 @@ RUN git clone --depth 1 --branch "${CORAZA_SPOA_VERSION}" \ https://github.com/corazawaf/coraza-spoa.git . \ && eval "$(/usr/local/sbin/pkg-cache-repos.sh go-env)" \ && go mod download \ - && CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/coraza-spoa . \ + && CGO_ENABLED=0 GOOS="${TARGETOS:-linux}" GOARCH="${TARGETARCH:-amd64}" go build -trimpath -ldflags='-s -w' -o /out/coraza-spoa . \ && /usr/local/sbin/pkg-cache-repos.sh restore # Catalog extractor: walks the bundled CRS at build time and emits # rules-catalog.json so WHP's UI can render rule metadata without parsing # .conf files at runtime. Uses the SAME coraza-coreruleset version pin as # the coraza-spoa binary above (drift between the two would mislabel rules). -FROM repo.anhonesthost.net/cloud-hosting-platform/golang:1.25 AS catalog +# Runs at build time, so it is built and RUN for the build platform (no +# GOOS/GOARCH); its JSON output is architecture-neutral and is copied into +# every target platform's image. +FROM --platform=$BUILDPLATFORM repo.anhonesthost.net/cloud-hosting-platform/golang:1.25 AS catalog # Same optional package cache as the build stage (module downloads only). ARG PKG_CACHE= COPY ./scripts/pkg-cache-repos.sh /usr/local/sbin/pkg-cache-repos.sh diff --git a/coraza-spoa/README.md b/coraza-spoa/README.md index 2c9935a..7479adc 100644 --- a/coraza-spoa/README.md +++ b/coraza-spoa/README.md @@ -76,3 +76,33 @@ When a legitimate request triggers a blocked rule, the audit log shows the rule 2. **Drop from the enforce list**: remove the rule's ID range from the `ctl:ruleEngine=On` overrides; it falls back to detect-only. After tuning, push the change — CI rebuilds, then `recreate coraza-spoa` on each host to apply. + +## Multi-arch (linux/amd64 + linux/arm64) + +The image is built for both platforms **without QEMU**: the two Go stages are +`FROM --platform=$BUILDPLATFORM` (the in-house `golang:1.25` mirror is amd64-only, +which is enough) and the binary is cross-compiled with +`CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH`. The catalog-extractor runs at +build time on the build platform; its JSON output is architecture-neutral. Only the +final distroless stage is per target platform. The amd64 image content is +byte-identical to the pre-multi-arch build (measured: same rootfs tree, same +`/coraza-spoa` sha256). + +CI (`.gitea/workflows/build-push-coraza.yaml`, one job on the trusted x86 runner): +amd64 and arm64 are each pushed **by digest, untagged, to both registries** +(Gitea + GHCR; the same digest lands in both), then +`scripts/assemble-multiarch-index.sh` creates `:latest` in each registry with +`docker buildx imagetools create` from `@` and `@`. +The arm64 step is `continue-on-error`; when it fails the tag is published +amd64-only, as before, and the arm64 digest is only ever taken from the current +run's step output (a previous build's arm64 is never re-attached). + +Attestations: buildx provenance is kept per platform. A by-digest push with +attestations is a small per-platform index (image + `attestation-manifest`); +`imagetools create` over two of them yields one index with all four entries +(amd64, its attestation, arm64, its attestation), each attestation still pointing +at its own platform manifest; `imagetools inspect --format '{{json .Provenance}}'` +shows both. Nothing flattens or drops them. + +Local rehearsal (no real registry is touched): `scripts/e2e-coraza-multiarch.sh` +(usage in its header). diff --git a/scripts/assemble-multiarch-index.sh b/scripts/assemble-multiarch-index.sh new file mode 100755 index 0000000..0bcdc29 --- /dev/null +++ b/scripts/assemble-multiarch-index.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Assemble the multi-arch index tag(s) from per-platform images that were +# already pushed BY DIGEST (no tag). Used by the trusted x86 publish job +# (see .gitea/workflows/build-push-coraza.yaml; template for the other WHP +# image repos, docs/arm64/IMAGE-PIPELINE-PLAN.md section 4.1/4.4). +# +# assemble-multiarch-index.sh \ +# --amd64 sha256: \ +# --arm64 sha256: --arm64-outcome success|failure|cancelled|skipped \ +# --repo [--repo ...] \ +# --tag [--tag ...] [--builder ] +# +# Rules (all enforced here so the workflow stays thin and these are testable): +# * amd64 is mandatory. A missing/malformed amd64 digest, or an amd64 digest +# that does not inspect as linux/amd64, aborts with exit 1 and publishes +# NOTHING (the same "build failed, no tag" outcome as before multi-arch). +# * arm64 is attached ONLY when its step outcome is exactly "success" AND the +# digest is well-formed AND it inspects as linux/arm64. Otherwise the tag is +# published amd64-only (warning, exit 0). The arm64 digest must come from +# THIS run's step output - the workflow never reads it from a registry tag, +# so a previous build's arm64 can never be re-attached to a newer amd64. +# * every --tag is created in every --repo from @ sources in +# that same repo (both digests were pushed to every --repo by the build). +# +# Env: IMAGETOOLS overrides the command (default: "docker buildx imagetools"); +# the test suite points it at a stub. +set -euo pipefail + +IMAGETOOLS=${IMAGETOOLS:-docker buildx imagetools} +amd64=""; arm64=""; outcome="skipped"; builder="" +repos=(); tags=() + +die() { echo "assemble-multiarch-index: $*" >&2; exit 1; } +warn() { echo "::warning::assemble-multiarch-index: $*"; } + +while [ $# -gt 0 ]; do + case "$1" in + --amd64) amd64=${2-}; shift 2 ;; + --arm64) arm64=${2-}; shift 2 ;; + --arm64-outcome) outcome=${2-}; shift 2 ;; + --repo) repos+=("${2-}"); shift 2 ;; + --tag) tags+=("${2-}"); shift 2 ;; + --builder) builder=${2-}; shift 2 ;; + *) die "unknown argument: $1" ;; + esac +done + +re='^sha256:[0-9a-f]{64}$' +[ ${#repos[@]} -gt 0 ] || die "need at least one --repo" +[ ${#tags[@]} -gt 0 ] || die "need at least one --tag" +[[ $amd64 =~ $re ]] || die "amd64 digest missing or malformed ('$amd64'); refusing to publish" + +bflag=(); [ -n "$builder" ] && bflag=(--builder "$builder") + +# arch_of : prints the image config's architecture ("" on any failure). +arch_of() { + # shellcheck disable=SC2086 + $IMAGETOOLS inspect "${bflag[@]}" "$1" --format '{{json .Image}}' 2>/dev/null \ + | python3 -I -c 'import json,sys; print(json.load(sys.stdin).get("architecture",""))' 2>/dev/null || true +} + +for r in "${repos[@]}"; do + [ "$(arch_of "$r@$amd64")" = amd64 ] || die "$r@$amd64 does not inspect as linux/amd64; refusing to publish" +done + +use_arm64=0 +if [ "$outcome" != success ]; then + warn "arm64 step outcome is '$outcome': publishing amd64-only (no arm64 digest is carried over)" +elif ! [[ $arm64 =~ $re ]]; then + warn "arm64 step reported success but its digest is missing/malformed: publishing amd64-only" +elif [ "$arm64" = "$amd64" ]; then + warn "arm64 digest equals the amd64 digest: publishing amd64-only" +else + use_arm64=1 + for r in "${repos[@]}"; do + if [ "$(arch_of "$r@$arm64")" != arm64 ]; then + warn "$r@$arm64 does not inspect as linux/arm64: publishing amd64-only" + use_arm64=0; break + fi + done +fi + +for r in "${repos[@]}"; do + targs=(); for t in "${tags[@]}"; do targs+=(-t "$r:$t"); done + srcs=("$r@$amd64"); [ "$use_arm64" = 1 ] && srcs+=("$r@$arm64") + echo "assemble-multiarch-index: $r tags=${tags[*]} sources=${#srcs[@]} ($([ "$use_arm64" = 1 ] && echo 'amd64+arm64' || echo 'amd64-only'))" + # shellcheck disable=SC2086 + $IMAGETOOLS create "${bflag[@]}" "${targs[@]}" "${srcs[@]}" +done +echo "platforms=$([ "$use_arm64" = 1 ] && echo linux/amd64,linux/arm64 || echo linux/amd64)" diff --git a/scripts/e2e-coraza-multiarch.sh b/scripts/e2e-coraza-multiarch.sh new file mode 100755 index 0000000..d8b9fdd --- /dev/null +++ b/scripts/e2e-coraza-multiarch.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# LOCAL end-to-end rehearsal of the build-push-coraza.yaml multi-arch logic +# against THROWAWAY registries. Never touches repo.anhonesthost.net / ghcr.io. +# +# docker run -d --name e2ereg1 -p 127.0.0.1:5077:5000 registry:2 +# docker run -d --name e2ereg2 -p 127.0.0.1:5078:5000 registry:2 +# REG1=127.0.0.1:5077 REG2=127.0.0.1:5078 scripts/e2e-coraza-multiarch.sh +# (then: docker rm -f e2ereg1 e2ereg2; docker buildx rm e2ebuilder) +# +# If the Docker daemon is not on this machine (remote/VM daemon), publish the +# registries on an address both sides reach (e.g. -p 172.17.0.1:5077:5000) and +# set REG1/REG2 to it; plain-HTTP is enabled for them in the builder config. +# Scenario 1: both platforms build, pushed by digest to TWO registries, +# index assembled; checks both platforms + attestations present. +# Scenario 2: arm64 forced to fail (bad upstream tag) while a STALE arm64 +# digest from scenario 1 is still known: the tag must become +# amd64-only and must not contain the stale arm64 digest. +set -euo pipefail +cd "$(dirname "$0")/.." +REG1=${REG1:?set REG1}; REG2=${REG2:?set REG2} +B=${BUILDER:-e2ebuilder} +R1=$REG1/cloud-hosting-platform/coraza-spoa +R2=$REG2/shadowdao/coraza-spoa +if ! docker buildx inspect "$B" >/dev/null 2>&1; then + cfg=$(mktemp) + printf '[registry."%s"]\n http=true\n insecure=true\n[registry."%s"]\n http=true\n insecure=true\n' "$REG1" "$REG2" > "$cfg" + docker buildx create --name "$B" --driver docker-container --driver-opt network=host --buildkitd-config "$cfg" --bootstrap >/dev/null +fi +OUT="type=image,\"name=$R1,$R2\",push-by-digest=true,name-canonical=true,push=true" +build() { # build [extra args]; prints digest + local arch=$1; shift; local meta; meta=$(mktemp) + docker buildx build --builder "$B" --platform "linux/$arch" --output "$OUT" --metadata-file "$meta" "$@" ./coraza-spoa >&2 + python3 -I -c 'import json,sys; print(json.load(open(sys.argv[1]))["containerimage.digest"])' "$meta" +} +inspect() { docker buildx imagetools inspect --builder "$B" "$@"; } + +echo "=== scenario 1: both platforms ===" +AMD=$(build amd64); ARM=$(build arm64) +echo "amd64=$AMD arm64=$ARM" +IMAGETOOLS="docker buildx imagetools" scripts/assemble-multiarch-index.sh --builder "$B" \ + --amd64 "$AMD" --arm64 "$ARM" --arm64-outcome success --repo "$R1" --repo "$R2" --tag latest +for r in "$R1" "$R2"; do echo "--- $r:latest"; inspect "$r:latest" | grep -E 'Digest:|Platform:'; done + +echo "=== scenario 2: forced arm64 failure, stale arm64 digest ($ARM) still in hand ===" +if NEWARM=$(build arm64 --build-arg CORAZA_SPOA_VERSION=v0.0.0-does-not-exist 2>/dev/null); then echo "BUG: forced failure succeeded"; exit 1; fi +echo "arm64 step failed as forced" +scripts/assemble-multiarch-index.sh --builder "$B" \ + --amd64 "$AMD" --arm64 "$ARM" --arm64-outcome failure --repo "$R1" --repo "$R2" --tag latest +for r in "$R1" "$R2"; do + echo "--- $r:latest"; inspect "$r:latest" | grep -E 'Digest:|Platform:' + if inspect "$r:latest" | grep -q 'linux/arm64'; then echo "FAIL: arm64 still attached"; exit 1; fi + if inspect "$r:latest" --raw | grep -q "$ARM"; then echo "FAIL: stale arm64 digest present"; exit 1; fi +done +echo "E2E OK" diff --git a/scripts/test-assemble-multiarch-index.py b/scripts/test-assemble-multiarch-index.py new file mode 100644 index 0000000..328f611 --- /dev/null +++ b/scripts/test-assemble-multiarch-index.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Offline tests for scripts/assemble-multiarch-index.sh (multi-arch index assembly). + +The script is the publish-side half of the multi-arch pipeline: it receives the +amd64 and (optional) arm64 digests that the build steps pushed BY DIGEST and +turns them into the tag(s). A stub stands in for `docker buildx imagetools` +(env IMAGETOOLS), records every `create` call and answers `inspect` from a +table, so no registry or Docker is needed. + +Held invariants: + - arm64 failure / cancel / skip -> amd64-only create, exit 0 (tag still published) + - a STALE arm64 digest is never attached unless this run's arm64 outcome is success + - bad/missing amd64 digest, or amd64 that is not linux/amd64 -> exit 1, NO create + - an arm64 digest that does not inspect as arm64 is dropped (amd64-only) + - every tag x every repo is created; sources are @ in that repo + +Running: python3 scripts/test-assemble-multiarch-index.py +""" +import json +import os +import stat +import subprocess +import sys +import tempfile +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +SCRIPT = os.path.join(HERE, 'assemble-multiarch-index.sh') +A = 'sha256:' + 'a' * 64 +B = 'sha256:' + 'b' * 64 +C = 'sha256:' + 'c' * 64 + +STUB = r'''#!/usr/bin/env python3 +import json, os, sys +a = sys.argv[1:] +log = os.environ['STUB_LOG'] +table = json.loads(os.environ['STUB_ARCH']) +if a[0] == 'inspect': + ref = [x for x in a[1:] if '@sha256:' in x][0] + arch = table.get(ref.split('@', 1)[1]) + if arch is None: + sys.exit(1) + print(json.dumps({'architecture': arch, 'os': 'linux'})) +elif a[0] == 'create': + open(log, 'a').write(json.dumps(a[1:]) + '\n') +else: + sys.exit(2) +''' + + +class AssembleTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.stub = os.path.join(self.tmp.name, 'stub') + with open(self.stub, 'w') as f: + f.write(STUB) + os.chmod(self.stub, os.stat(self.stub).st_mode | stat.S_IEXEC) + self.log = os.path.join(self.tmp.name, 'creates.log') + + def tearDown(self): + self.tmp.cleanup() + + def run_script(self, args, arch=None): + env = dict(os.environ, IMAGETOOLS=self.stub, STUB_LOG=self.log, + STUB_ARCH=json.dumps(arch if arch is not None else {A: 'amd64', B: 'arm64'})) + p = subprocess.run(['bash', SCRIPT] + args, env=env, capture_output=True, text=True) + creates = [] + if os.path.exists(self.log): + creates = [json.loads(l) for l in open(self.log)] + return p, creates + + BASE = ['--repo', 'r.example/o/img', '--tag', 'latest'] + + def test_both_platforms(self): + p, c = self.run_script(['--amd64', A, '--arm64', B, '--arm64-outcome', 'success'] + self.BASE) + self.assertEqual(p.returncode, 0, p.stderr) + self.assertEqual(c, [['-t', 'r.example/o/img:latest', 'r.example/o/img@' + A, 'r.example/o/img@' + B]]) + self.assertIn('platforms=linux/amd64,linux/arm64', p.stdout) + + def test_arm64_failure_is_amd64_only_even_with_a_digest(self): + # A stale digest (e.g. left over from an earlier run) must not be attached. + for outcome in ('failure', 'cancelled', 'skipped', ''): + if os.path.exists(self.log): + os.remove(self.log) + p, c = self.run_script(['--amd64', A, '--arm64', B, '--arm64-outcome', outcome] + self.BASE) + self.assertEqual(p.returncode, 0, p.stderr) + self.assertEqual(c, [['-t', 'r.example/o/img:latest', 'r.example/o/img@' + A]], outcome) + self.assertIn('::warning::', p.stdout) + self.assertIn('platforms=linux/amd64\n', p.stdout) + + def test_no_arm64_args_is_amd64_only(self): + p, c = self.run_script(['--amd64', A] + self.BASE) + self.assertEqual(p.returncode, 0, p.stderr) + self.assertEqual(c, [['-t', 'r.example/o/img:latest', 'r.example/o/img@' + A]]) + + def test_success_with_empty_or_malformed_arm64_digest(self): + for bad in ('', 'sha256:short', 'latest'): + if os.path.exists(self.log): + os.remove(self.log) + p, c = self.run_script(['--amd64', A, '--arm64', bad, '--arm64-outcome', 'success'] + self.BASE) + self.assertEqual(p.returncode, 0, p.stderr) + self.assertEqual(len(c[0]), 3, bad) # -t, ref, one source + + def test_arm64_digest_that_is_not_arm64_is_dropped(self): + p, c = self.run_script(['--amd64', A, '--arm64', C, '--arm64-outcome', 'success'] + self.BASE, + arch={A: 'amd64', C: 'amd64'}) + self.assertEqual(p.returncode, 0, p.stderr) + self.assertEqual(c, [['-t', 'r.example/o/img:latest', 'r.example/o/img@' + A]]) + + def test_arm64_equal_to_amd64_is_dropped(self): + p, c = self.run_script(['--amd64', A, '--arm64', A, '--arm64-outcome', 'success'] + self.BASE) + self.assertEqual(p.returncode, 0, p.stderr) + self.assertEqual(len(c[0]), 3) + + def test_amd64_problems_publish_nothing(self): + for amd in ('', 'nope', B): # B inspects as arm64 + p, c = self.run_script(['--amd64', amd, '--arm64', B, '--arm64-outcome', 'success'] + self.BASE) + self.assertNotEqual(p.returncode, 0, amd) + self.assertEqual(c, [], amd) + p, c = self.run_script(['--amd64', C, '--arm64-outcome', 'failure'] + self.BASE, arch={}) + self.assertNotEqual(p.returncode, 0) # not inspectable + self.assertEqual(c, []) + + def test_all_tags_in_all_repos(self): + p, c = self.run_script(['--amd64', A, '--arm64', B, '--arm64-outcome', 'success', + '--repo', 'gitea.example/o/img', '--repo', 'ghcr.io/o/img', + '--tag', 'latest', '--tag', '1.2.3', '--tag', 'abc123']) + self.assertEqual(p.returncode, 0, p.stderr) + self.assertEqual(len(c), 2) + self.assertEqual(c[0], ['-t', 'gitea.example/o/img:latest', '-t', 'gitea.example/o/img:1.2.3', + '-t', 'gitea.example/o/img:abc123', + 'gitea.example/o/img@' + A, 'gitea.example/o/img@' + B]) + self.assertEqual(c[1][-2:], ['ghcr.io/o/img@' + A, 'ghcr.io/o/img@' + B]) + + def test_requires_repo_and_tag_and_rejects_unknown_args(self): + for args in (['--amd64', A, '--tag', 't'], ['--amd64', A, '--repo', 'r'], ['--bogus', 'x']): + p, c = self.run_script(args) + self.assertNotEqual(p.returncode, 0) + self.assertEqual(c, []) + + +class WorkflowWiring(unittest.TestCase): + """The workflow must keep the properties the script relies on.""" + + def setUp(self): + self.wf = open(os.path.join(HERE, '..', '.gitea', 'workflows', 'build-push-coraza.yaml')).read() + + def test_arm64_step_is_non_fatal_and_digest_comes_from_step_output(self): + self.assertIn('continue-on-error: true', self.wf) + self.assertIn('steps.arm64.outputs.digest', self.wf) + self.assertIn('steps.arm64.outcome', self.wf) + self.assertIn('assemble-multiarch-index.sh', self.wf) + + def test_both_builds_push_by_digest_untagged(self): + self.assertEqual(self.wf.count('push-by-digest=true'), 2) + self.assertNotIn('tags:', self.wf.replace('# tags:', '')) + + def test_no_tag_is_pushed_by_a_build_step(self): + # tags exist only via imagetools create (the assemble script) + self.assertNotIn('ghcr.io/shadowdao/coraza-spoa:latest\n', self.wf.split('assemble-multiarch-index.sh')[0]) + + +if __name__ == '__main__': + unittest.main(verbosity=2) -- 2.52.0