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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0185ukXjXyosSkunDsdWz3mb
This commit is contained in:
1 parent
50c58c4e9e
commit
a476bb5352
7 files changed
+399
-12
No files matched your search
Executable
+90
@@ -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:<digest> \
|
||||
# --arm64 sha256:<digest> --arm64-outcome success|failure|cancelled|skipped \
|
||||
# --repo <registry/path> [--repo <other registry/path> ...] \
|
||||
# --tag <tag> [--tag <tag> ...] [--builder <buildx 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 <repo>@<digest> 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 <ref>: 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)"
|
||||
Executable
+54
@@ -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 <arch> [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"
|
||||
@@ -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 <repo>@<digest> 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)
|
||||
Reference in new issue
Block a user