diff --git a/.gitea/workflows/build-push.yaml b/.gitea/workflows/build-push.yaml index e39a64f..8f76e58 100644 --- a/.gitea/workflows/build-push.yaml +++ b/.gitea/workflows/build-push.yaml @@ -59,12 +59,23 @@ jobs: scripts/tests/lsphp-info-probe.test.sh \ scripts/lsphp-repo-version.sh \ scripts/tests/lsphp-repo-version.test.sh \ + scripts/image-label-amd64.sh \ + scripts/php-emergency-guard.sh \ + scripts/index-platforms.sh \ + scripts/assemble-multiarch-index.sh \ + scripts/tests/image-label-amd64.test.sh \ + scripts/tests/index-platforms.test.sh \ scripts/tests/lsphp-abi-gate.test.sh \ .gitea/scripts/resolve-base-image.sh - name: lsphp repo-version lookup test run: ./scripts/tests/lsphp-repo-version.test.sh + - name: multi-arch guard tests (index-safe label read, fail-closed, assemble rules) + run: | + ./scripts/tests/image-label-amd64.test.sh + ./scripts/tests/index-platforms.test.sh + - name: lsphp probe regression test run: ./scripts/tests/lsphp-info-probe.test.sh @@ -445,7 +456,10 @@ jobs: # :phpNN points at right now. While LiteSpeed still lags, a normal build # would silently put the VULNERABLE packaged lsphp back. Refuse instead; # the job goes green again by itself once the LiteSpeed repo catches up. - # Fail-open if the tag cannot be inspected (first build, registry blip). + # Reads the labels of the linux/amd64 image behind the tag (index-safe: + # works for single-platform and multi-arch tags) and FAILS CLOSED if they + # cannot be read; only a tag that does not exist yet (first build) passes + # unchecked. scripts/php-emergency-guard.sh, tests in scripts/tests/. - name: Do not roll back a newer php.net emergency image env: PHPVER: ${{ matrix.phpver }} @@ -453,14 +467,7 @@ jobs: run: | set -euo pipefail ref="repo.anhonesthost.net/cloud-hosting-platform/cac-litespeed:php${PHPVER}" - src=$(docker buildx imagetools inspect "$ref" --format '{{ index .Image.Config.Labels "org.whp.lsphp-source" }}' 2>/dev/null || true) - cur=$(docker buildx imagetools inspect "$ref" --format '{{ index .Image.Config.Labels "org.whp.php-version" }}' 2>/dev/null || true) - new="$NEW_PHP" - echo "published $ref: source=${src:-?} php=${cur:-?}; this build: packaged $new" - if [ "$src" = "php.net" ] && [ "$cur" != "$new" ] && [ "$(printf '%s\n%s\n' "$cur" "$new" | sort -V | tail -n1)" = "$cur" ]; then - echo "::error::$ref is a php.net emergency build of PHP $cur; LiteSpeed still ships $new. Not overwriting it with an older PHP. Re-run lsphp-source-emergency.yaml for a newer fix, or wait for LiteSpeed." - exit 1 - fi + ./scripts/php-emergency-guard.sh "$ref" "$NEW_PHP" - name: Build and Push LiteSpeed Image uses: docker/build-push-action@v6 @@ -605,7 +612,10 @@ jobs: # :phpNN points at right now. While LiteSpeed still lags, a normal build # would silently put the VULNERABLE packaged lsphp back. Refuse instead; # the job goes green again by itself once the LiteSpeed repo catches up. - # Fail-open if the tag cannot be inspected (first build, registry blip). + # Reads the labels of the linux/amd64 image behind the tag (index-safe: + # works for single-platform and multi-arch tags) and FAILS CLOSED if they + # cannot be read; only a tag that does not exist yet (first build) passes + # unchecked. scripts/php-emergency-guard.sh, tests in scripts/tests/. - name: Do not roll back a newer php.net emergency image env: PHPVER: ${{ matrix.phpver }} @@ -613,14 +623,7 @@ jobs: run: | set -euo pipefail ref="repo.anhonesthost.net/cloud-hosting-platform/cac-lsphp:php${PHPVER}" - src=$(docker buildx imagetools inspect "$ref" --format '{{ index .Image.Config.Labels "org.whp.lsphp-source" }}' 2>/dev/null || true) - cur=$(docker buildx imagetools inspect "$ref" --format '{{ index .Image.Config.Labels "org.whp.php-version" }}' 2>/dev/null || true) - new="$NEW_PHP" - echo "published $ref: source=${src:-?} php=${cur:-?}; this build: packaged $new" - if [ "$src" = "php.net" ] && [ "$cur" != "$new" ] && [ "$(printf '%s\n%s\n' "$cur" "$new" | sort -V | tail -n1)" = "$cur" ]; then - echo "::error::$ref is a php.net emergency build of PHP $cur; LiteSpeed still ships $new. Not overwriting it with an older PHP. Re-run lsphp-source-emergency.yaml for a newer fix, or wait for LiteSpeed." - exit 1 - fi + ./scripts/php-emergency-guard.sh "$ref" "$NEW_PHP" - name: Build and Push lsphp Image uses: docker/build-push-action@v6 diff --git a/.gitea/workflows/lsphp-source-emergency.yaml b/.gitea/workflows/lsphp-source-emergency.yaml index a5d2e22..e74b4a0 100644 --- a/.gitea/workflows/lsphp-source-emergency.yaml +++ b/.gitea/workflows/lsphp-source-emergency.yaml @@ -15,6 +15,16 @@ run-name: EMERGENCY lsphp php.net build by ${{ gitea.actor }} ## ## Tags: always :phpNN-phpnet-X.Y.Z (immutable, for staging / rollback) ## and :phpNN only when push_production_tags = true. +## +## Multi-arch safety (docs/arm64/IMAGE-PIPELINE-PLAN.md 2.2 #6, 4.4): each +## platform is pushed BY DIGEST and the tags are assembled afterwards with +## scripts/assemble-multiarch-index.sh (copy of haproxy-manager-base's), so a +## tag is never overwritten with a bare single-platform manifest by accident. +## arm64 is built from the SAME php.net source in THIS run (continue-on-error); +## a previous run's arm64 is never re-attached. If a production tag is +## currently a multi-arch index and arm64 was not built successfully, the +## production tag is REFUSED unless allow_amd64_only = true (an explicit +## security-over-parity decision); the immutable tag is still created. on: workflow_dispatch: inputs: @@ -32,6 +42,16 @@ on: required: true default: false type: boolean + build_arm64: + description: "Also build linux/arm64 (QEMU, slow, best effort). Needed to keep arm64 in a multi-arch :phpNN." + required: false + default: false + type: boolean + allow_amd64_only: + description: "Allow replacing a multi-arch production tag with amd64-only when arm64 is not built/failed (ARM hosts then cannot pull it)." + required: false + default: false + type: boolean jobs: Build-lsphp-from-source: @@ -49,6 +69,8 @@ jobs: IN_VERSIONS: ${{ inputs.php_versions }} IN_IMAGES: ${{ inputs.images }} IN_PROD: ${{ format('{0}', inputs.push_production_tags) }} + IN_ARM: ${{ format('{0}', inputs.build_arm64) }} + IN_AMD_ONLY: ${{ format('{0}', inputs.allow_amd64_only) }} PHPVER: ${{ matrix.phpver }} IMG: ${{ matrix.image }} run: | @@ -60,7 +82,11 @@ jobs: [[ "$pair" =~ ^(8[2-5])=([0-9]+\.[0-9]+\.[0-9]+)$ ]] || { echo "::error::bad php_versions entry '$pair' (want NN=X.Y.Z, NN in 82..85)"; exit 1; } [ "${BASH_REMATCH[1]}" = "$PHPVER" ] && v="${BASH_REMATCH[2]}" done + case "$IN_ARM" in true|false) ;; *) echo "::error::build_arm64 must be true|false, got '$IN_ARM'"; exit 1 ;; esac + case "$IN_AMD_ONLY" in true|false) ;; *) echo "::error::allow_amd64_only must be true|false, got '$IN_AMD_ONLY'"; exit 1 ;; esac echo "prod=$IN_PROD" >> "$GITHUB_OUTPUT" + echo "arm=$IN_ARM" >> "$GITHUB_OUTPUT" + echo "amd_only=$IN_AMD_ONLY" >> "$GITHUB_OUTPUT" if [ -z "$v" ] || { [ "$IN_IMAGES" != both ] && [ "$IN_IMAGES" != "$IMG" ]; }; then echo "not requested: php${PHPVER} / ${IMG}"; echo "skip=1" >> "$GITHUB_OUTPUT"; exit 0 fi @@ -111,6 +137,12 @@ jobs: if: steps.t.outputs.skip == '0' uses: docker/setup-buildx-action@v3 + - name: Set up QEMU (arm64) + if: steps.t.outputs.skip == '0' && steps.t.outputs.arm == 'true' + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + - name: Login to Gitea if: steps.t.outputs.skip == '0' uses: docker/login-action@v3 @@ -119,13 +151,15 @@ jobs: username: ${{ secrets.CI_USER }} password: ${{ secrets.CI_TOKEN }} - - name: Build and push (php.net lsphp) + # amd64: required. Pushed untagged by digest; tags are made below. + - name: Build amd64 (push by digest) if: steps.t.outputs.skip == '0' + id: amd64 uses: docker/build-push-action@v6 with: file: ${{ steps.t.outputs.dockerfile }} platforms: linux/amd64 - push: true + outputs: type=image,name=repo.anhonesthost.net/cloud-hosting-platform/${{ steps.t.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true build-args: | PHPVER=${{ matrix.phpver }} OLS_VERSION=1.8.4 @@ -135,7 +169,74 @@ jobs: PHP_SRC_VERSION=${{ steps.t.outputs.version }} PHP_SRC_SHA256=${{ steps.v.outputs.sha }} LSPHP_SOURCE=php.net - tags: | - repo.anhonesthost.net/cloud-hosting-platform/${{ steps.t.outputs.image_name }}:php${{ matrix.phpver }}-phpnet-${{ steps.t.outputs.version }} - ${{ steps.t.outputs.prod == 'true' && format('repo.anhonesthost.net/cloud-hosting-platform/{0}:php{1}', steps.t.outputs.image_name, matrix.phpver) || '' }} - ${{ steps.t.outputs.prod == 'true' && matrix.phpver == 85 && format('repo.anhonesthost.net/cloud-hosting-platform/{0}:latest', steps.t.outputs.image_name) || '' }} + + # arm64: best effort, same php.net source and version, never blocks amd64. + - name: Build arm64 (push by digest) + if: steps.t.outputs.skip == '0' && steps.t.outputs.arm == 'true' + id: arm64 + continue-on-error: true + timeout-minutes: 120 + uses: docker/build-push-action@v6 + with: + file: ${{ steps.t.outputs.dockerfile }} + platforms: linux/arm64 + outputs: type=image,name=repo.anhonesthost.net/cloud-hosting-platform/${{ steps.t.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true + build-args: | + PHPVER=${{ matrix.phpver }} + OLS_VERSION=1.8.4 + CACHE_BUST=${{ steps.v.outputs.bust }} + EXPECT_LSPHP_PKG=${{ steps.v.outputs.pkg }} + EXPECT_PHP_VERSION=${{ steps.v.outputs.pkgphp }} + PHP_SRC_VERSION=${{ steps.t.outputs.version }} + PHP_SRC_SHA256=${{ steps.v.outputs.sha }} + LSPHP_SOURCE=php.net + + # Immutable staging/rollback tag: always created (amd64, + arm64 if built). + - name: Assemble immutable tag + if: steps.t.outputs.skip == '0' + env: + AMD64_DIGEST: ${{ steps.amd64.outputs.digest }} + ARM64_DIGEST: ${{ steps.arm64.outputs.digest }} + ARM64_OUTCOME: ${{ steps.arm64.outcome }} + REPO: repo.anhonesthost.net/cloud-hosting-platform/${{ steps.t.outputs.image_name }} + TAG: php${{ matrix.phpver }}-phpnet-${{ steps.t.outputs.version }} + run: | + ./scripts/assemble-multiarch-index.sh \ + --amd64 "$AMD64_DIGEST" \ + --arm64 "$ARM64_DIGEST" --arm64-outcome "${ARM64_OUTCOME:-skipped}" \ + --repo "$REPO" --tag "$TAG" + + # Production tags: never silently drop arm64 from a multi-arch tag. + - name: Assemble production tags + if: steps.t.outputs.skip == '0' && steps.t.outputs.prod == 'true' + env: + AMD64_DIGEST: ${{ steps.amd64.outputs.digest }} + ARM64_DIGEST: ${{ steps.arm64.outputs.digest }} + ARM64_OUTCOME: ${{ steps.arm64.outcome }} + ALLOW_AMD_ONLY: ${{ steps.t.outputs.amd_only }} + REPO: repo.anhonesthost.net/cloud-hosting-platform/${{ steps.t.outputs.image_name }} + PHPVER: ${{ matrix.phpver }} + run: | + set -euo pipefail + tags=(--tag "php${PHPVER}") + [ "$PHPVER" = 85 ] && tags+=(--tag latest) + if [ "${ARM64_OUTCOME:-skipped}" != success ] && [ "$ALLOW_AMD_ONLY" != true ]; then + for t in "php${PHPVER}" $([ "$PHPVER" = 85 ] && echo latest); do + rc=0; info=$(./scripts/index-platforms.sh "$REPO:$t") || rc=$? + case "$rc" in + 0) ;; + 3) info="not found" ;; + *) echo "::error::cannot tell whether $REPO:$t is multi-arch; refusing to overwrite it (fail closed)"; exit 1 ;; + esac + echo "$REPO:$t currently: $info" + case "$info" in + *linux/arm64*) + echo "::error::$REPO:$t is multi-arch (has arm64) but arm64 was not built successfully in this run (outcome=${ARM64_OUTCOME:-skipped}). Publishing amd64-only would drop arm64. Re-run with build_arm64=true, or set allow_amd64_only=true to accept that. The immutable tag was still created." + exit 1 ;; + esac + done + fi + ./scripts/assemble-multiarch-index.sh \ + --amd64 "$AMD64_DIGEST" \ + --arm64 "$ARM64_DIGEST" --arm64-outcome "${ARM64_OUTCOME:-skipped}" \ + --repo "$REPO" "${tags[@]}" diff --git a/scripts/assemble-multiarch-index.sh b/scripts/assemble-multiarch-index.sh new file mode 100755 index 0000000..8da295c --- /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 +# (copied verbatim from haproxy-manager-base PR #17; see its 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/image-label-amd64.sh b/scripts/image-label-amd64.sh new file mode 100755 index 0000000..628fed1 --- /dev/null +++ b/scripts/image-label-amd64.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +## image-label-amd64.sh REF LABEL [LABEL...] +## +## Prints the value of each LABEL (one per line, empty line if the label is +## absent) from the *linux/amd64* image behind REF, whether REF is a plain +## single-platform manifest, an OCI image index (attestation children are +## ignored) or a Docker manifest list. +## +## Why: `imagetools inspect REF --format '{{ index .Image.Config.Labels ... }}'` +## is only meaningful for a single-platform image; on a multi-arch index `.Image` +## is platform-keyed / unavailable depending on the buildx version, and the old +## callers swallowed the error (fail-open). This resolves the amd64 child +## digest from the raw index, then inspects that child directly. +## +## Exit: 0 labels printed; 3 REF does not exist in the registry (not found); +## 1 anything else (network/auth error, malformed manifest, no amd64 +## image, config is not amd64, ...) with a clear message on stderr. +## Env: IMAGETOOLS overrides "docker buildx imagetools" (tests use a stub). +set -euo pipefail +IMAGETOOLS=${IMAGETOOLS:-docker buildx imagetools} +die() { echo "image-label-amd64: $*" >&2; exit 1; } +[ $# -ge 2 ] || die "usage: image-label-amd64.sh REF LABEL [LABEL...]" +ref=$1; shift + +# Run an imagetools command; map "tag/manifest does not exist" to exit 3. +it() { + local out err rc=0 + err=$(mktemp); trap 'rm -f "$err"' RETURN + # shellcheck disable=SC2086 + out=$($IMAGETOOLS "$@" 2>"$err") || rc=$? + if [ "$rc" -ne 0 ]; then + if grep -qiE 'not found|manifest unknown|name unknown|: 404' "$err"; then + echo "image-label-amd64: $ref: not found ($(head -c 300 "$err" | tr '\n' ' '))" >&2 + return 3 + fi + echo "image-label-amd64: imagetools $1 failed (rc=$rc): $(head -c 300 "$err" | tr '\n' ' ')" >&2 + return 1 + fi + printf '%s' "$out" +} + +rc=0; raw=$(it inspect --raw "$ref") || rc=$? +[ "$rc" -eq 0 ] || exit "$rc" + +# Classify and, for an index, pick the single linux/amd64 child digest. +rc=0 +verdict=$(printf '%s' "$raw" | python3 -I -c ' +import json, sys +try: + d = json.load(sys.stdin) +except Exception as e: + print("ERR not JSON: %s" % e); sys.exit(0) +if not isinstance(d, dict): + print("ERR manifest is not a JSON object"); sys.exit(0) +mt = d.get("mediaType", "") +if isinstance(d.get("manifests"), list): + kids = [m for m in d["manifests"] if isinstance(m, dict) + and isinstance(m.get("platform"), dict) + and m["platform"].get("os") == "linux" + and m["platform"].get("architecture") == "amd64" + and "vnd.docker.reference.type" not in (m.get("annotations") or {})] + digs = [m.get("digest", "") for m in kids] + if len(digs) != 1: + print("ERR index has %d linux/amd64 images (need exactly 1)" % len(digs)); sys.exit(0) + if not digs[0].startswith("sha256:") or len(digs[0]) != 71: + print("ERR malformed amd64 child digest %r" % digs[0]); sys.exit(0) + print("INDEX " + digs[0]) +elif isinstance(d.get("config"), dict) and isinstance(d.get("layers"), list): + print("SINGLE") +else: + print("ERR neither an image index nor an image manifest (mediaType=%r)" % mt) +') || rc=$? +[ "$rc" -eq 0 ] || die "could not parse manifest of $ref" +case "$verdict" in + ERR*) die "$ref: ${verdict#ERR }" ;; + SINGLE) target=$ref ;; + INDEX\ *) + base=${ref%%@*} + last=${base##*/} + [[ $last == *:* ]] && base=${base%:"${last#*:}"} + target="$base@${verdict#INDEX }" ;; + *) die "$ref: unexpected parser output" ;; +esac + +rc=0; cfg=$(it inspect "$target" --format '{{json .Image}}') || rc=$? +[ "$rc" -eq 0 ] || { [ "$rc" -eq 3 ] && die "$target: not found although listed by the index"; exit "$rc"; } + +printf '%s' "$cfg" | python3 -I -c ' +import json, sys +try: + c = json.load(sys.stdin) +except Exception as e: + sys.stderr.write("image-label-amd64: image config is not JSON: %s\n" % e); sys.exit(1) +if not isinstance(c, dict) or c.get("architecture") != "amd64": + sys.stderr.write("image-label-amd64: image config is not linux/amd64 (architecture=%r)\n" % (c.get("architecture") if isinstance(c, dict) else None)); sys.exit(1) +cfg = c.get("config") or {} +labels = cfg.get("Labels") or {} +if not isinstance(labels, dict): + sys.stderr.write("image-label-amd64: Labels is not an object\n"); sys.exit(1) +for name in sys.argv[1:]: + v = labels.get(name, "") + print(v if isinstance(v, str) else "") +' "$@" diff --git a/scripts/index-platforms.sh b/scripts/index-platforms.sh new file mode 100755 index 0000000..bc360b5 --- /dev/null +++ b/scripts/index-platforms.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +## index-platforms.sh REF +## Prints "index ..." (real platforms only, attestation children +## excluded) when REF is an image index / manifest list, or "single" when it is +## a plain single-platform manifest. Exit 3 = REF not found, 1 = any other +## failure (fail closed). Used by lsphp-source-emergency.yaml to refuse to +## replace a multi-arch tag with an amd64-only one. +## Env: IMAGETOOLS overrides "docker buildx imagetools" (tests use a stub). +set -euo pipefail +IMAGETOOLS=${IMAGETOOLS:-docker buildx imagetools} +ref=${1:?usage: index-platforms.sh REF} +err=$(mktemp); trap 'rm -f "$err"' EXIT +rc=0 +# shellcheck disable=SC2086 +raw=$($IMAGETOOLS inspect --raw "$ref" 2>"$err") || rc=$? +if [ "$rc" -ne 0 ]; then + if grep -qiE 'not found|manifest unknown|name unknown|: 404' "$err"; then echo "index-platforms: $ref not found" >&2; exit 3; fi + echo "index-platforms: imagetools failed: $(head -c 300 "$err" | tr '\n' ' ')" >&2; exit 1 +fi +printf '%s' "$raw" | python3 -I -c ' +import json, sys +try: + d = json.load(sys.stdin) +except Exception as e: + sys.stderr.write("index-platforms: not JSON: %s\n" % e); sys.exit(1) +if isinstance(d, dict) and isinstance(d.get("manifests"), list): + ps = [] + for m in d["manifests"]: + p = m.get("platform") if isinstance(m, dict) else None + if not isinstance(p, dict) or "vnd.docker.reference.type" in (m.get("annotations") or {}): + continue + if p.get("os") in (None, "", "unknown") or p.get("architecture") in (None, "", "unknown"): + continue + ps.append("%s/%s" % (p["os"], p["architecture"])) + print("index " + " ".join(sorted(set(ps)))) +elif isinstance(d, dict) and isinstance(d.get("config"), dict): + print("single") +else: + sys.stderr.write("index-platforms: neither index nor image manifest\n"); sys.exit(1) +' diff --git a/scripts/php-emergency-guard.sh b/scripts/php-emergency-guard.sh new file mode 100755 index 0000000..94b35af --- /dev/null +++ b/scripts/php-emergency-guard.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +## php-emergency-guard.sh REF NEW_PHP +## +## "Do not roll back a newer php.net emergency image." REF is the published +## :phpNN tag a normal build is about to overwrite; NEW_PHP is the PHP version +## the normal build packages. Exit 0 = go ahead, 1 = refuse (or cannot tell). +## +## Decision (same predicate as the old inline step, evaluated on the amd64 +## image only, so it is correct for a single-platform image AND a multi-arch +## index): +## REF does not exist yet (registry "not found") -> proceed (nothing to protect) +## labels read, source != php.net -> proceed +## labels read, source = php.net, cur == new -> proceed +## labels read, source = php.net, cur > new (sort -V) -> REFUSE +## labels read, source = php.net, cur < new -> proceed +## labels absent (older image without them) -> proceed (as before) +## ANYTHING ELSE (registry/auth/network error, malformed manifest, index +## without exactly one linux/amd64 image, non-amd64 config) -> REFUSE +## (FAIL CLOSED; the old step failed open and silently disabled the guard). +## Env: LABEL_SCRIPT (tests), IMAGETOOLS passed through. +set -euo pipefail +ref=${1:?usage: php-emergency-guard.sh REF NEW_PHP} +new=${2-} +here=$(cd "$(dirname "$0")" && pwd) +labeler=${LABEL_SCRIPT:-$here/image-label-amd64.sh} + +rc=0 +out=$("$labeler" "$ref" org.whp.lsphp-source org.whp.php-version) || rc=$? +if [ "$rc" -eq 3 ]; then + echo "published $ref: not found (first build); this build: packaged $new. Nothing to protect." + exit 0 +fi +if [ "$rc" -ne 0 ]; then + echo "::error::cannot read the php.net-emergency labels of the amd64 image behind $ref (rc=$rc). Refusing to overwrite it blind (fail closed). Retry the run; if it persists check registry access and the manifest." + exit 1 +fi +src=$(printf '%s\n' "$out" | sed -n 1p) +cur=$(printf '%s\n' "$out" | sed -n 2p) +echo "published $ref (amd64): source=${src:-?} php=${cur:-?}; this build: packaged $new" +if [ "$src" = "php.net" ] && [ "$cur" != "$new" ] && [ "$(printf '%s\n%s\n' "$cur" "$new" | sort -V | tail -n1)" = "$cur" ]; then + echo "::error::$ref is a php.net emergency build of PHP $cur; LiteSpeed still ships $new. Not overwriting it with an older PHP. Re-run lsphp-source-emergency.yaml for a newer fix, or wait for LiteSpeed." + exit 1 +fi +exit 0 diff --git a/scripts/tests/image-label-amd64.test.sh b/scripts/tests/image-label-amd64.test.sh new file mode 100755 index 0000000..4e2a2db --- /dev/null +++ b/scripts/tests/image-label-amd64.test.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +## image-label-amd64.test.sh — fixtures for scripts/image-label-amd64.sh and +## scripts/php-emergency-guard.sh, driven through a stub IMAGETOOLS. +## Proves: index-safe (two-platform OCI index WITH attestation children, Docker +## manifest list, single-platform manifest), fail-closed on malformed/ambiguous +## input, "not found" = proceed, and that for single-platform images the guard +## makes the SAME decision as the old inline step on every (src,cur,new) case. +set -euo pipefail +HERE=$(cd "$(dirname "$0")/.." && pwd) +T=$(mktemp -d); trap 'rm -rf "$T"' EXIT +mkdir -p "$T/raw" "$T/cfg" "$T/err" +fail=0 +chk() { if [ "$2" = "$3" ]; then echo "ok $1"; else echo "FAIL $1: got '$2' want '$3'"; fail=1; fi; } + +# ---- stub: inspect --raw REF / inspect REF --format ... ; fixtures keyed by ref +key() { printf '%s' "$1" | tr '/:@' '___'; } +cat >"$T/it" <<'STUB' +#!/usr/bin/env bash +# args: inspect --raw REF | inspect REF --format F +d=$FIXDIR +[ "$1" = inspect ] || exit 2 +if [ "$2" = --raw ]; then ref=$3; kind=raw; else ref=$2; kind=cfg; fi +k=$(printf '%s' "$ref" | tr '/:@' '___') +if [ -f "$d/err/$k" ]; then cat "$d/err/$k" >&2; exit 1; fi +[ -f "$d/$kind/$k" ] || { echo "ERROR: $ref: not found" >&2; exit 1; } +cat "$d/$kind/$k" +STUB +chmod +x "$T/it" +export FIXDIR=$T IMAGETOOLS="$T/it" + +A=sha256:$(printf 'a%.0s' {1..64}); B=sha256:$(printf 'b%.0s' {1..64}) +C=sha256:$(printf 'c%.0s' {1..64}); D=sha256:$(printf 'd%.0s' {1..64}) +REPO=reg.example/org/img +put() { printf '%s' "$3" >"$T/$1/$(key "$2")"; } +cfg() { # arch src php -> image config json + printf '{"architecture":"%s","os":"linux","config":{"Labels":{"org.whp.lsphp-source":"%s","org.whp.php-version":"%s"}}}' "$1" "$2" "$3"; } + +# 1. OCI index: amd64 + arm64 + two attestation children (unknown/unknown) +put raw $REPO:php85 '{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[ + {"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"'$A'","platform":{"architecture":"amd64","os":"linux"}}, + {"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"'$C'","platform":{"architecture":"unknown","os":"unknown"},"annotations":{"vnd.docker.reference.type":"attestation-manifest","vnd.docker.reference.digest":"'$A'"}}, + {"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"'$B'","platform":{"architecture":"arm64","os":"linux"}}, + {"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"'$D'","platform":{"architecture":"unknown","os":"unknown"},"annotations":{"vnd.docker.reference.type":"attestation-manifest","vnd.docker.reference.digest":"'$B'"}}]}' +put cfg $REPO@$A "$(cfg amd64 php.net 8.5.12)" +put cfg $REPO@$B "$(cfg arm64 php.net 8.5.99)" # must never be read +printf '%s\n%s\n' php.net 8.5.12 >"$T/want" +chk "index+attestations: amd64 labels" "$("$HERE/image-label-amd64.sh" $REPO:php85 org.whp.lsphp-source org.whp.php-version)" "$(cat "$T/want")" + +# 2. Docker manifest list (no attestations) +put raw $REPO:ml '{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.list.v2+json","manifests":[ + {"mediaType":"application/vnd.docker.distribution.manifest.v2+json","digest":"'$B'","platform":{"architecture":"arm64","os":"linux","variant":"v8"}}, + {"mediaType":"application/vnd.docker.distribution.manifest.v2+json","digest":"'$A'","platform":{"architecture":"amd64","os":"linux"}}]}' +chk "docker manifest list" "$("$HERE/image-label-amd64.sh" $REPO:ml org.whp.php-version)" "8.5.12" + +# 3. Single-platform manifest (today's amd64-only images); ref with digest too +put raw $REPO:single '{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"digest":"'$A'"},"layers":[]}' +put cfg $REPO:single "$(cfg amd64 litespeed 8.5.1)" +chk "single-platform manifest" "$("$HERE/image-label-amd64.sh" $REPO:single org.whp.lsphp-source org.whp.php-version | tr '\n' ,)" "litespeed,8.5.1," +chk "absent label prints empty" "$("$HERE/image-label-amd64.sh" $REPO:single org.whp.nope)" "" +put raw $REPO:nolabels '{"mediaType":"x","config":{},"layers":[]}' +put cfg $REPO:nolabels '{"architecture":"amd64","config":{}}' +chk "config without Labels" "$("$HERE/image-label-amd64.sh" $REPO:nolabels org.whp.php-version)" "" + +# 4. failures: each must exit non-zero (1) with nothing on stdout +bad() { # name ref + local out rc=0; out=$("$HERE/image-label-amd64.sh" "$2" org.whp.php-version 2>/dev/null) || rc=$? + chk "$1 -> rc $3" "$rc" "$3"; chk "$1 -> no stdout" "$out" "" +} +bad "tag not found" $REPO:nosuch 3 +put raw $REPO:garbage 'this is not json'; bad "malformed JSON" $REPO:garbage 1 +put raw $REPO:arr '[1,2]'; bad "JSON not an object" $REPO:arr 1 +put raw $REPO:unk '{"foo":"bar"}'; bad "neither index nor manifest" $REPO:unk 1 +put raw $REPO:armonly '{"manifests":[{"digest":"'$B'","platform":{"architecture":"arm64","os":"linux"}}]}'; bad "index without amd64" $REPO:armonly 1 +put raw $REPO:two '{"manifests":[{"digest":"'$A'","platform":{"architecture":"amd64","os":"linux"}},{"digest":"'$D'","platform":{"architecture":"amd64","os":"linux"}}]}'; bad "ambiguous amd64" $REPO:two 1 +put raw $REPO:baddig '{"manifests":[{"digest":"sha256:short","platform":{"architecture":"amd64","os":"linux"}}]}'; bad "malformed child digest" $REPO:baddig 1 +put raw $REPO:badcfg '{"manifests":[{"digest":"'$D'","platform":{"architecture":"amd64","os":"linux"}}]}' +put cfg $REPO@$D 'not json'; bad "malformed config" $REPO:badcfg 1 +put raw $REPO:wrongarch '{"manifests":[{"digest":"'$C'","platform":{"architecture":"amd64","os":"linux"}}]}' +put cfg $REPO@$C "$(cfg arm64 php.net 8.5.12)"; bad "amd64 slot with arm64 config" $REPO:wrongarch 1 +printf 'ERROR: denied: requested access to the resource is denied\n' >"$T/err/$(key $REPO:denied)"; bad "auth/network error is NOT 'not found'" $REPO:denied 1 +put raw $REPO:orphan '{"manifests":[{"digest":"'$B'","platform":{"architecture":"amd64","os":"linux"}}]}'; bad "child listed but missing" $REPO:orphan 1 + +# 5. Guard == old inline logic on single-platform fixtures, all combos +old() { # src cur new -> proceed|refuse (verbatim predicate of the old step) + local src=$1 cur=$2 new=$3 + if [ "$src" = "php.net" ] && [ "$cur" != "$new" ] && [ "$(printf '%s\n%s\n' "$cur" "$new" | sort -V | tail -n1)" = "$cur" ]; then echo refuse; else echo proceed; fi; } +n=0 +for src in php.net litespeed ""; do for cur in "" 8.5.11 8.5.12 8.5.13 8.4.99 8.10.0; do for new in 8.5.12 8.5.9 8.6.0; do + put raw $REPO:g '{"mediaType":"x","config":{},"layers":[]}' + put cfg $REPO:g "$(cfg amd64 "$src" "$cur")" + rc=0; "$HERE/php-emergency-guard.sh" $REPO:g "$new" >/dev/null 2>&1 || rc=$? + got=proceed; [ "$rc" -ne 0 ] && got=refuse + w=$(old "$src" "$cur" "$new"); n=$((n+1)) + [ "$got" = "$w" ] || { echo "FAIL guard vs old: src='$src' cur='$cur' new='$new' got=$got old=$w"; fail=1; } +done; done; done +echo "ok guard == old inline logic on $n single-platform combos" + +# 6. Guard on a multi-arch index: refuses the rollback (old logic could not even read it) +rc=0; "$HERE/php-emergency-guard.sh" $REPO:php85 8.5.9 >/dev/null 2>&1 || rc=$?; chk "index: emergency 8.5.12 vs packaged 8.5.9 refused" "$rc" 1 +rc=0; "$HERE/php-emergency-guard.sh" $REPO:php85 8.6.0 >/dev/null 2>&1 || rc=$?; chk "index: packaged newer proceeds" "$rc" 0 +# 7. fail closed vs first build +rc=0; "$HERE/php-emergency-guard.sh" $REPO:nosuch 8.5.9 >/dev/null 2>&1 || rc=$?; chk "tag absent: proceed (first build)" "$rc" 0 +for t in garbage denied armonly orphan wrongarch; do + rc=0; "$HERE/php-emergency-guard.sh" $REPO:$t 8.5.9 >/dev/null 2>&1 || rc=$?; chk "unreadable ($t): FAIL CLOSED" "$rc" 1 +done +[ "$fail" -eq 0 ] && echo "ALL PASS" || { echo "FAILURES"; exit 1; } diff --git a/scripts/tests/index-platforms.test.sh b/scripts/tests/index-platforms.test.sh new file mode 100755 index 0000000..0487908 --- /dev/null +++ b/scripts/tests/index-platforms.test.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +## index-platforms.test.sh — scripts/index-platforms.sh and +## scripts/assemble-multiarch-index.sh (copy of haproxy-manager-base's) against a stub IMAGETOOLS. +set -euo pipefail +HERE=$(cd "$(dirname "$0")/.." && pwd) +T=$(mktemp -d); trap 'rm -rf "$T"' EXIT +fail=0 +chk() { if [ "$2" = "$3" ]; then echo "ok $1"; else echo "FAIL $1: got '$2' want '$3'"; fail=1; fi; } +A=sha256:$(printf 'a%.0s' {1..64}); B=sha256:$(printf 'b%.0s' {1..64}); C=sha256:$(printf 'c%.0s' {1..64}) +cat >"$T/it" <<'STUB' +#!/usr/bin/env bash +# index-platforms mode: serves $FIX/ for "inspect --raw REF". +# assemble mode (STUB_LOG set): "inspect REF --format .." -> arch from $ARCHES (ref=arch lines); +# "create ..." -> appended to $STUB_LOG. +if [ -n "${STUB_LOG:-}" ]; then + if [ "$1" = create ]; then echo "create $*" >>"$STUB_LOG"; exit 0; fi + ref=$2; a=$(grep -F "$ref=" "$ARCHES" | head -1 | cut -d= -f2); [ -n "$a" ] || exit 1 + printf '{"architecture":"%s"}' "$a"; exit 0 +fi +[ "$2" = --raw ] || exit 2 +k=$(printf '%s' "$3" | tr '/:@' '___') +if [ -f "$FIX/err_$k" ]; then cat "$FIX/err_$k" >&2; exit 1; fi +[ -f "$FIX/$k" ] || { echo "ERROR: $3: not found" >&2; exit 1; } +cat "$FIX/$k" +STUB +chmod +x "$T/it"; export IMAGETOOLS="$T/it" FIX=$T +w() { printf '%s' "$2" >"$T/$(printf '%s' "$1" | tr '/:@' '___')"; } +w r/i:idx '{"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"digest":"'$A'","platform":{"os":"linux","architecture":"amd64"}},{"digest":"'$B'","platform":{"os":"linux","architecture":"arm64"}},{"digest":"'$C'","platform":{"os":"unknown","architecture":"unknown"},"annotations":{"vnd.docker.reference.type":"attestation-manifest"}}]}' +w r/i:amd '{"manifests":[{"digest":"'$A'","platform":{"os":"linux","architecture":"amd64"}},{"digest":"'$C'","platform":{"os":"unknown","architecture":"unknown"},"annotations":{"vnd.docker.reference.type":"attestation-manifest"}}]}' +w r/i:one '{"config":{"digest":"'$A'"},"layers":[]}' +w r/i:bad 'garbage' +printf 'ERROR: denied\n' >"$T/err_r_i_den" +chk "two-platform index" "$("$HERE/index-platforms.sh" r/i:idx)" "index linux/amd64 linux/arm64" +chk "amd64 index with attestation" "$("$HERE/index-platforms.sh" r/i:amd)" "index linux/amd64" +chk "single manifest" "$("$HERE/index-platforms.sh" r/i:one)" "single" +for pair in "nosuch 3" "bad 1" "den 1"; do set -- $pair + rc=0; "$HERE/index-platforms.sh" r/i:$1 >/dev/null 2>&1 || rc=$?; chk "$1 -> rc $2" "$rc" "$2"; done + +# ---- assemble-multiarch-index.sh (arm64 attach rules) +export STUB_LOG=$T/log ARCHES=$T/arches +R=reg/x +printf '%s\n' "$R@$A=amd64" "$R@$B=arm64" >"$ARCHES" +run() { : >"$STUB_LOG"; "$HERE/assemble-multiarch-index.sh" "$@" >/dev/null 2>&1; echo $?; } +chk "both ok -> rc 0" "$(run --amd64 $A --arm64 $B --arm64-outcome success --repo $R --tag t1 --tag t2)" 0 +chk "both ok -> 1 create with 2 sources, 2 tags" "$(grep -c "create create -t $R:t1 -t $R:t2 $R@$A $R@$B\$" "$STUB_LOG")" 1 +chk "arm64 failed (digest present, stale) -> amd64 only" "$(run --amd64 $A --arm64 $B --arm64-outcome failure --repo $R --tag t1)" 0 +chk " ... create has no arm64 digest" "$(grep -c "$B" "$STUB_LOG" || true)" 0 +run --amd64 $A --repo $R --tag t1 >/dev/null +chk "arm64 skipped -> amd64 only" "$(grep -c "$B" "$STUB_LOG" || true)" "0" +chk "malformed amd64 digest -> refuse" "$(run --amd64 nope --repo $R --tag t1)" 1 +chk " ... nothing created" "$(grep -c create "$STUB_LOG" || true)" 0 +printf '%s\n' "$R@$A=arm64" >"$ARCHES" +chk "amd64 digest that is not amd64 -> refuse" "$(run --amd64 $A --repo $R --tag t1)" 1 +[ "$fail" -eq 0 ] && echo "ALL PASS" || { echo FAILURES; exit 1; }