Files
cozystack/hack/stitch-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

293 lines
13 KiB
Bash
Executable File

#!/bin/sh
# Join the amd64 and arm64 builds of every first-party image into one
# multi-arch index, and repin the packages tree on the index digests.
#
# Usage: hack/stitch-multiarch.sh <packages-root> <registry> <image-tag>
# <packages-root> a tree stamped by the amd64 build (packages/, or an
# extracted artifact with the same <group>/<pkg> layout)
# <registry> the registry both builds pushed to
# <image-tag> the amd64 build's IMAGE_TAG; the arm64 build pushed each
# image as <image-tag>-arm64
#
# The amd64 build and the arm64 leg run on separate native runners, so each
# pushes a single-platform image. Built with SBOM=1 that image is an index of
# itself and its attestation manifest, and the index made below carries both
# attestations. For every ref <registry>/<repo>[:<t>]@A in
# the tree, whatever tag <t> it carries, with <tag> the <image-tag>:
#
# 1. B is the digest of <repo>:<tag>-arm64. No such image is not an error:
# matchbox is built for both arches by the amd64 job and the e2e
# sandbox for amd64 only, so neither has an arm64 twin; the ref is
# skipped and named in the summary.
# 2. Every tag this build pushed for <repo> is moved to an index of A and B
# if it points at A, so none is left naming the amd64 half alone: <tag>,
# the tag <t> the ref carries, and every <repo>:<tag> line in the file
# PUSHED_TAGS_LOG names. image-tags in hack/common-envs.mk writes that
# log when the variable is set and PUSH=1, and it is how the component
# version pushed beside <image-tag> under PUBLISH_VERSIONED=1 is found,
# since most refs name only <image-tag>. These are the only tags read;
# a tag of another build that sits on the same bytes is not this build's
# to move.
# PUSHED_TAGS_LOG set to a file that does not exist fails the run.
# 3. A is replaced with the index digest in the files hack/lib/image-refs.sh
# enumerates, and nowhere else.
#
# When <tag> already points at an index of A and an arm64 image, as it does on
# a rerun of a stitch that failed partway, that index is used as is and copied
# to the build's other tags still left on A. <tag> pointing anywhere else means
# another build moved it and the tree no longer matches the registry, so the
# run fails.
#
# A registry read answered with a rate limit or a 5xx is retried, three tries
# in all, STITCH_RETRY_DELAY seconds (default 10) times the attempt apart; any
# other registry error fails the run at once.
#
# The rewrite is as narrow as the enumeration, so the check after it is not:
# any sha256:A left under the tree outside charts/, gzip payloads included,
# fails the run. A ref whose digest also sits in a file the rewrite does not
# reach is skipped before anything is pushed, since rewriting only some of its
# copies would split one image across two digests. The one gzip it rewrites is
# declared in GZIP_COPIES below; see docs/agents/image-refs.md.
#
# Requires: skopeo, docker buildx, jq, sha256sum, gzip, yq (mikefarah), and a
# login to <registry> that both skopeo and docker read (REGISTRY_AUTH_FILE for
# skopeo).
set -eu
ROOT="${1:?usage: stitch-multiarch.sh <packages-root> <registry> <image-tag>}"
REGISTRY="${2:?usage: stitch-multiarch.sh <packages-root> <registry> <image-tag>}"
IMAGE_TAG="${3:?usage: stitch-multiarch.sh <packages-root> <registry> <image-tag>}"
ROOT="${ROOT%/}"
[ -d "$ROOT" ] || { echo "packages-root '$ROOT' is not a directory" >&2; exit 1; }
for _tool in skopeo docker jq sha256sum gzip yq; do
command -v "$_tool" >/dev/null || { echo "$_tool is required" >&2; exit 1; }
done
if [ -n "${PUSHED_TAGS_LOG:-}" ] && [ ! -f "$PUSHED_TAGS_LOG" ]; then
echo "PUSHED_TAGS_LOG '$PUSHED_TAGS_LOG' does not exist; the build recorded no tags" >&2
exit 1
fi
# shellcheck source=hack/lib/image-refs.sh
. "$(dirname "$0")/lib/image-refs.sh"
# sk runs skopeo and asks again on a rate limit or a 5xx, which say nothing
# about the image; any other failure, or one that outlasts the tries, is
# returned with skopeo's message.
sk() {
_sk_err="$(mktemp)"
_sk_try=1
while :; do
if skopeo "$@" 2>"$_sk_err"; then rm -f "$_sk_err"; return 0; fi
if [ "$_sk_try" -ge 3 ] \
|| ! grep -qiE 'toomanyrequests|too many requests|status:? (429|5[0-9][0-9])|status code (429|5[0-9][0-9])' "$_sk_err"; then
cat "$_sk_err" >&2
rm -f "$_sk_err"
return 1
fi
sleep $((_sk_try * ${STITCH_RETRY_DELAY:-10}))
_sk_try=$((_sk_try + 1))
done
}
# manifest_digest <ref> prints the digest of <ref>'s raw manifest, or nothing
# when the registry says it does not exist. Any other failure returns non-zero:
# a rate limit read as "no arm64 image" would quietly ship amd64 only.
manifest_digest() {
_md_out="$(mktemp)"
_md_err="$(mktemp)"
if sk inspect --raw "docker://$1" >"$_md_out" 2>"$_md_err" && [ -s "$_md_out" ]; then
printf 'sha256:%s' "$(sha256sum <"$_md_out" | cut -d' ' -f1)"
rm -f "$_md_out" "$_md_err"
return 0
fi
if grep -qiE 'manifest unknown|not found|404' "$_md_err"; then
rm -f "$_md_out" "$_md_err"
return 0
fi
echo "::error::cannot read the manifest of $1: $(tr '\n' ' ' <"$_md_err")" >&2
rm -f "$_md_out" "$_md_err"
return 1
}
# image_arch <repo@digest> prints the architecture of a single-platform image,
# or nothing for an index of several platforms or a non-image artifact. An
# image built with SBOM=1 is an index of one linux image and its attestation
# manifest, and counts as that image. A registry error returns non-zero, like
# manifest_digest.
image_arch() {
_ia_raw="$(sk inspect --raw "docker://$1")" || return 1
_ia_plat="$(printf '%s' "$_ia_raw" | jq -r '
if .manifests then
[.manifests[] | select(.platform.os == "linux") | .platform.architecture]
| if length == 1 then .[0] else empty end
else empty end')"
if [ -n "$_ia_plat" ]; then printf '%s\n' "$_ia_plat"; return 0; fi
printf '%s' "$_ia_raw" | jq -r '.config.mediaType // empty' \
| grep -qE 'image\.config|container\.image' || return 0
_ia_cfg="$(sk inspect --config "docker://$1")" || return 1
printf '%s' "$_ia_cfg" | jq -r '.architecture // empty'
}
# Every lookup below is assigned before it is compared: `set -e` aborts on a
# failed command substitution only in a plain assignment, and inside a test a
# registry error would read as a mismatch and turn into a skip.
skipped=""
skipped_n=0
skip() {
skipped="${skipped}${skipped:+, }$1"
skipped_n=$((skipped_n + 1))
echo "::warning::stitch: skipping $1: $2" >&2
}
# "<gzip>=<plain file>" pairs, root-relative. The kamaji provider's chart ships
# its components only as files/components.gz, which the package's image target
# builds from control-plane-components.yaml with `gzip -nc`. Both copies are
# rewritten, the gzip by decompressing, rewriting and recompressing with -n,
# which is byte for byte what that target writes as long as both run GNU gzip,
# as CI does (Apple's gzip compresses differently). The list is not in
# image-refs.sh: the mirror's host rewrite there cannot reach inside a gzip, and
# declaring only the plain copy to it would split the two.
GZIP_COPIES="system/capi-providers-cpprovider/files/components.gz=system/capi-providers-cpprovider/files/control-plane-components.yaml"
ref_files="$(image_ref_files "$ROOT")"
gz_files=""
for _pair in $GZIP_COPIES; do
[ -f "$ROOT/${_pair%%=*}" ] || continue
gz_files="${gz_files}${gz_files:+
}${ROOT}/${_pair%%=*}"
ref_files="${ref_files}
${ROOT}/${_pair#*=}"
done
# pinned_in <digest> lists every file outside charts/ that pins it, looking
# inside gzips too: grep -I skips them as binary.
pinned_in() {
grep -rIl --exclude-dir=charts "$1" "$ROOT" || true
find "$ROOT" -name charts -prune -o -type f -name '*.gz' -print | while IFS= read -r _gz; do
if gzip -dc "$_gz" 2>/dev/null | grep -q "$1"; then printf '%s\n' "$_gz"; fi
done
}
reg_re="$(printf '%s' "$REGISTRY" | sed -e 's/[].[^$*/\\]/\\&/g')"
# "<repo> <digest> <tags>" per owned image, <tags> the comma-joined tags its
# refs carry, or "-". Every build pushed the image under <image-tag>; a
# component-versioned ref names its component version, pushed beside it. The
# packages artifact is not an image; the caller republishes it from the
# rewritten tree. grep -o drops anything around the ref, such as a
# --migrate-image= prefix.
pairs="$(collect_image_refs "$ROOT" \
| grep -oE "${reg_re}/[^@[:space:]\"']+@sha256:[0-9a-f]{64}" \
| while IFS= read -r _ref; do
_name="${_ref%@*}"
_last="${_name##*/}"
_t=-
if [ "${_last#*:}" != "$_last" ]; then _t="${_last#*:}"; fi
printf '%s/%s %s %s\n' "${_name%/*}" "${_last%%:*}" "${_ref##*@}" "$_t"
done \
| grep -v "^${reg_re}/cozystack-packages " \
| sort -u \
| awk '{ k = $1 " " $2; if (!(k in t)) { order[++n] = k; t[k] = "" } if ($3 != "-") t[k] = t[k] "," $3 }
END { for (i = 1; i <= n; i++) { v = t[order[i]]; sub(/^,/, "", v); print order[i], (v == "" ? "-" : v) } }')" || true
[ -n "$pairs" ] || { echo "no ${REGISTRY}/ refs found under ${ROOT}; is this a tree the build stamped?" >&2; exit 1; }
tag="$IMAGE_TAG"
stitched=""
stitched_n=0
stitched_digests=""
# tags_at_a sets $move to a --tag flag for each tag this build pushed for
# $repo that still points at $a.
tags_at_a() {
move=""
logged=""
if [ -n "${PUSHED_TAGS_LOG:-}" ]; then
logged="$(awk -v r="$repo" 'index($0, r ":") == 1 { t = substr($0, length(r) + 2); if (t !~ /[\/:]/) print t }' "$PUSHED_TAGS_LOG")"
fi
for t in $tag $(printf '%s' "$reftags" | tr ',' ' ') $logged; do
[ "$t" != - ] || continue
case "$move " in *" --tag ${repo}:${t} "*) continue ;; esac
d="$(manifest_digest "${repo}:${t}")"
if [ "$d" = "$a" ]; then move="${move} --tag ${repo}:${t}"; fi
done
}
# fd 3, so a registry client that reads stdin cannot swallow the list.
while read -r repo a reftags <&3; do
name="${repo##*/}"
b="$(manifest_digest "${repo}:${tag}-arm64")"
if [ -z "$b" ]; then skip "$name" "no ${tag}-arm64 image"; continue; fi
elsewhere="$(pinned_in "$a" | grep -vxF "${ref_files}${gz_files:+
}${gz_files}" || true)"
if [ -n "$elsewhere" ]; then
skip "$name" "digest also pinned in $(echo "$elsewhere" | tr '\n' ' ')which the rewrite does not reach"; continue
fi
cur="$(manifest_digest "${repo}:${tag}")"
if [ "$cur" = "$a" ]; then
arch_a="$(image_arch "${repo}@${a}")"
arch_b="$(image_arch "${repo}@${b}")"
if [ "$arch_a" != amd64 ] || [ "$arch_b" != arm64 ]; then
skip "$name" "${tag} and ${tag}-arm64 are not one amd64 and one arm64 image"; continue
fi
tags_at_a
[ -n "$move" ] || { echo "::error::no tag of ${repo} left to move to the index" >&2; exit 1; }
# $move is a list of --tag words; tags cannot contain whitespace.
# shellcheck disable=SC2086
docker buildx imagetools create $move "${repo}@${a}" "${repo}@${b}"
index="$(manifest_digest "${repo}:${tag}")"
[ -n "$index" ] && [ "$index" != "$a" ] || { echo "::error::${repo}:${tag} did not move to an index" >&2; exit 1; }
else
[ -n "$cur" ] || { echo "::error::${repo}:${tag} does not exist, though the tree pins it" >&2; exit 1; }
raw="$(sk inspect --raw "docker://${repo}@${cur}")"
# An A built with SBOM=1 is itself an index, and the stitched one lists its
# linux image rather than A.
a_raw="$(sk inspect --raw "docker://${repo}@${a}")"
a_members="$(printf '%s' "$a_raw" | jq -c --arg a "$a" \
'if .manifests then [.manifests[] | select(.platform.os == "linux") | .digest] else [$a] end')"
# Its arm64 half need not be this run's B: a rerun rebuilds the arm64
# image, which need not reproduce it byte for byte.
if ! printf '%s' "$raw" | jq -e --argjson ms "$a_members" \
'(.manifests // []) as $m | ($ms | length) > 0 and all($ms[]; . as $d | any($m[]; .digest == $d)) and any($m[]; .platform.architecture == "arm64")' >/dev/null; then
echo "::error::${repo}:${tag} points at ${cur}, which is neither the digest the tree pins (${a}) nor an index of it and an arm64 image" >&2
exit 1
fi
index="$cur"
# An attempt that died inside imagetools create may have moved <tag> and
# not the others; copy the index to any still left on A.
tags_at_a
# shellcheck disable=SC2086
if [ -n "$move" ]; then docker buildx imagetools create $move "${repo}@${index}"; fi
fi
for f in $ref_files; do
grep -q "$a" "$f" || continue
sed "s|${a}|${index}|g" "$f" >"$f.stitch"
cat "$f.stitch" >"$f"
rm -f "$f.stitch"
done
for f in $gz_files; do
gzip -dc "$f" | grep -q "$a" || continue
gzip -dc "$f" | sed "s|${a}|${index}|g" | gzip -n >"$f.stitch"
cat "$f.stitch" >"$f"
rm -f "$f.stitch"
done
stitched="${stitched}${stitched:+, }${name}"
stitched_n=$((stitched_n + 1))
stitched_digests="${stitched_digests} ${a}"
done 3<<EOF
$pairs
EOF
for a in $stitched_digests; do
left="$(pinned_in "$a")"
if [ -n "$left" ]; then
echo "::error::${a} is still pinned after the rewrite, in: $(echo "$left" | tr '\n' ' ')" >&2
exit 1
fi
done
echo "stitch: ${stitched_n} stitched (${stitched}); ${skipped_n} skipped (${skipped})"