Files
cozystack/hack/verify-multiarch.sh
Myasnikov Daniil ce054baa34 feat(release): attach an SBOM to every release image
A release said nothing about what is inside its container images, so a
user could not tell which packages a cluster runs. SBOM=1 had been
plumbed for it and left off.

BuildKit attaches the SBOM as an attestation, which makes the pushed
image an index of the image and its attestation manifest. The stitch took
such an index for something other than one amd64 and one arm64 image,
skipped it and still exited 0, and the multi-arch gate then failed the
release, so the stitch now reads through the index and the stitched one
keeps both attestations. The release build ran on the default docker
driver, which cannot attach attestations, so it moves to the
docker-container builder with the docker.io mirror, as the arm64 leg
already did. The scanner is pinned by digest where --sbom=true pulls a
floating tag.

The Talos installer image is built by the imager and pushed by skopeo, so
it carries no attestation.

Assisted-by: LLM
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-10-20 16:40:06 +05:00

183 lines
7.2 KiB
Bash
Executable File

#!/bin/sh
# Fail unless every image the packages tree pins is an index serving both
# linux/amd64 and linux/arm64.
#
# Usage: hack/verify-multiarch.sh [--report] [--allowlist <file>] <packages-root>
# hack/verify-multiarch.sh [--report] [--allowlist <file>] --refs-file <file>
# --report print the same list but exit 0, for a tree that is not being
# released (the nightly arm64 build runs it against main)
# --allowlist repositories allowed to stay single-arch; defaults to
# hack/multiarch-allowlist
# --refs-file check the refs listed in <file>, one per line, instead of a
# tree: what an e2e run's nodes actually pulled, which includes
# images the tree does not pin by digest. A ref without a digest
# is resolved by its tag.
#
# A release ships one ref per image to clusters of either architecture, so a
# ref that resolves to a single-platform manifest, or to an index missing one of
# the two, installs on one of them and fails to pull on the other. First- and
# third-party refs are checked alike: an arm64 node pulls both.
#
# The allowlist holds one repository per line with a mandatory `# reason`, and
# no tag or digest, so bumping an allowed image needs no edit. Allowed refs are
# still listed, under their own heading, and in tree mode an entry that
# matches no ref that failed the check is named as stale.
#
# A registry that answers a lookup with a rate limit or a 5xx is asked again,
# up to three times, VERIFY_MULTIARCH_RETRY_DELAY seconds (default 10) times
# the attempt apart; a ref still refused fails as rate-limited, never as
# single-arch.
#
# Exit: 0 all refs pass (or --report), 1 a ref failed, 2 usage or allowlist
# error. Requires skopeo, jq, and yq (mikefarah) to read a tree.
set -eu
usage() { echo "usage: verify-multiarch.sh [--report] [--allowlist <file>] <packages-root> | --refs-file <file>" >&2; exit 2; }
report=0
allowlist="$(dirname "$0")/multiarch-allowlist"
refs_file=""
while [ "$#" -gt 0 ]; do
case "$1" in
--report) report=1; shift ;;
--allowlist) [ "$#" -ge 2 ] || usage; allowlist="$2"; shift 2 ;;
--refs-file) [ "$#" -ge 2 ] || usage; refs_file="$2"; shift 2 ;;
-*) usage ;;
*) break ;;
esac
done
if [ -n "$refs_file" ]; then
[ "$#" -eq 0 ] || usage
[ -f "$refs_file" ] || { echo "refs file '$refs_file' does not exist" >&2; exit 2; }
tools="skopeo jq"
else
[ "$#" -eq 1 ] || usage
ROOT="${1%/}"
[ -d "$ROOT" ] || { echo "packages-root '$ROOT' is not a directory" >&2; exit 2; }
tools="skopeo jq yq"
fi
[ -f "$allowlist" ] || { echo "allowlist '$allowlist' does not exist" >&2; exit 2; }
for _tool in $tools; do
command -v "$_tool" >/dev/null || { echo "$_tool is required" >&2; exit 2; }
done
# shellcheck source=hack/lib/image-refs.sh
. "$(dirname "$0")/lib/image-refs.sh"
# "<repo> <reason>" per entry.
allowed="$(sed -E '/^[[:space:]]*(#|$)/d' "$allowlist" | while IFS= read -r _line; do
_repo="$(printf '%s' "$_line" | sed -E 's/[[:space:]]*#.*//')"
_why="$(printf '%s' "$_line" | sed -nE 's/^[^#]*#[[:space:]]*//p')"
if [ -z "$_why" ] || [ -z "$_repo" ]; then
echo "::error::allowlist entry '$_line' needs a '# reason'" >&2
exit 2
fi
printf '%s %s\n' "$_repo" "$_why"
done)" || exit 2
# A ref can sit inside a container argument, such as kamaji's
# --migrate-image=<ref>. The packages artifact is an OCI artifact with no
# platform, so it is left out. The enumeration also emits the bare
# "<tag>@<digest>" or "<repo>@<digest>" half of a split map next to the full
# ref; such a fragment is dropped when a ref with a host carries its digest, and
# kept otherwise, so a ref the gate cannot resolve still fails it.
if [ -n "$refs_file" ]; then
refs="$(grep -vE '^[[:space:]]*(#|$)' "$refs_file" | sort -u)" || true
[ -n "$refs" ] || { echo "no image refs in ${refs_file}" >&2; exit 2; }
else
refs="$(collect_image_refs "$ROOT" \
| sed -E 's/^--[A-Za-z0-9-]+=//' \
| grep -vE '/cozystack-packages(:[^@]*)?@' \
| sort -u \
| awk -F@ '{ ref[NR] = $0; dig[NR] = $2; if ($1 ~ /\//) full[$2] = 1 }
END { for (i = 1; i <= NR; i++) if (ref[i] ~ /^[^@]*\// || !(dig[i] in full)) print ref[i] }')" || true
[ -n "$refs" ] || { echo "no image refs found under ${ROOT}" >&2; exit 2; }
fi
bad=""
ok_single=""
used=""
nl='
'
while IFS= read -r ref; do
name="${ref%@*}"
last="${name##*/}"
repo="${name%/*}/${last%%:*}"
case "$name" in */*) ;; *) repo="${last%%:*}" ;; esac
# A registry client rejects a ref carrying both a tag and a digest, so a
# digest ref drops its tag; a tag-only ref resolves as written.
case "$ref" in
*@*) target="${repo}@${ref##*@}" ;;
*) target="$ref" ;;
esac
# A rate limit or a 5xx says nothing about the image, so that answer alone is
# retried, a bounded number of times; any other error is deterministic and
# fails at once.
err="$(mktemp)"
try=1
while :; do
if raw="$(skopeo inspect --raw "docker://${target}" 2>"$err")"; then
# An attestation manifest (SBOM=1) is an index entry with platform
# unknown/unknown, which selecting os == linux leaves out.
missing="$(printf '%s' "$raw" | jq -r '
if .manifests then
[.manifests[].platform | select(.os == "linux") | .architecture] as $a
| ["amd64", "arm64"] - $a | map("index without linux/" + .) | join(", ")
else "single-platform manifest" end')"
break
fi
if ! grep -qiE 'toomanyrequests|too many requests|status:? (429|5[0-9][0-9])|status code (429|5[0-9][0-9])' "$err"; then
missing="cannot read the manifest: $(head -n 1 "$err")"
break
fi
if [ "$try" -ge 3 ]; then
missing="rate-limited or unavailable after 3 tries, not checked: $(head -n 1 "$err")"
break
fi
sleep $((try * ${VERIFY_MULTIARCH_RETRY_DELAY:-10}))
try=$((try + 1))
done
rm -f "$err"
[ -n "$missing" ] || continue
# A ref of an allowlisted repository that failed the check, whatever the
# reason, keeps the entry from being stale; the entry excuses a platform
# verdict only, since a ref that cannot be read or stays rate-limited is no
# more usable on amd64 than on arm64.
entry="$(printf '%s\n' "$allowed" | awk -v r="$repo" '$1 == r { $1 = ""; sub(/^ /, ""); print; exit }')"
[ -z "$entry" ] || used="${used}${repo}${nl}"
why=""
case "$missing" in
"single-platform manifest"|"index without"*) why="$entry" ;;
esac
if [ -n "$why" ]; then
ok_single="${ok_single} ${ref}: ${missing} (${why})${nl}"
else
bad="${bad} ${ref}: ${missing}${nl}"
fi
done <<EOF
$refs
EOF
# Only a tree says an entry is dead. A list of pulled images leaves out every
# image the run did not pull, allowed ones included.
stale=""
if [ -z "$refs_file" ]; then
stale="$(printf '%s\n' "$allowed" | awk 'NF { print $1 }' | while IFS= read -r _repo; do
printf '%s' "$used" | grep -qxF "$_repo" || printf ' %s\n' "$_repo"
done)" || true
fi
n_bad="$(printf '%s' "$bad" | grep -c . || true)"
echo "refs that failed (${n_bad}):"
printf '%s\n' "$bad"
if [ -n "$ok_single" ]; then
echo "allowed single-arch:"
printf '%s\n' "$ok_single"
fi
if [ -n "$stale" ]; then
echo "stale allowlist entries, matching no ref that failed the check:"
printf '%s\n\n' "$stale"
fi
[ "$n_bad" -eq 0 ] || [ "$report" -eq 1 ] || exit 1