diff --git a/.github/workflows/tags.yaml b/.github/workflows/tags.yaml index 534c9e6d7..8c123fdb1 100644 --- a/.github/workflows/tags.yaml +++ b/.github/workflows/tags.yaml @@ -120,6 +120,7 @@ jobs: env: DOCKER_CONFIG: ${{ runner.temp }}/.docker BUILDER: ${{ steps.buildx.outputs.name }} + SBOM: '1' PLATFORM: linux/arm64 CACHE_REGISTRY: iad.ocir.io/idyksih5sir9/cozystack # Read the arm64 cache build-arm64-nightly.yaml keeps warm; a release @@ -290,11 +291,14 @@ jobs: env: DOCKER_CONFIG: ${{ runner.temp }}/.docker - # Only for the matchbox rebuild below: the default docker driver cannot - # build an index, and `use: false` keeps make build on that driver. - - name: Set up Buildx for matchbox (docker-container driver) + # Every image carries an SBOM attestation, which the default docker driver + # cannot attach, and the matchbox index below needs this driver too. + # docker.io pulls go through the mirror in hack/buildkitd.toml, as the + # arm64 leg's do. `use: false` keeps plain `docker build` on the default + # driver; the builds name this one with BUILDER. + - name: Set up Buildx (docker-container driver, docker.io mirror) if: steps.check_release.outputs.release_exists == 'false' - id: matchbox_buildx + id: buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 with: driver: docker-container @@ -315,17 +319,16 @@ jobs: # The arm64 leg skips the talos package, so matchbox gets no arm64 # half to stitch. Its Dockerfile only copies files, so both arches # build here without QEMU and the stamped ref pins the index. - make -C packages/core/talos image-matchbox PLATFORM=linux/amd64,linux/arm64 BUILDER="$MATCHBOX_BUILDER" + make -C packages/core/talos image-matchbox PLATFORM=linux/amd64,linux/arm64 env: - MATCHBOX_BUILDER: ${{ steps.matchbox_buildx.outputs.name }} DOCKER_CONFIG: ${{ runner.temp }}/.docker + BUILDER: ${{ steps.buildx.outputs.name }} + SBOM: '1' # Cache manifests are registry-portable (oci-mediatypes=true) — read # them from OCIR while pushing images to GHCR. A missing :buildcache # ref 404s harmlessly into a cold build. CACHE_REGISTRY: iad.ocir.io/idyksih5sir9/cozystack - # amd64 here; build-arm64 builds arm64 natively. This job also - # builds on the default docker driver, which handles a - # multi-platform image only on the containerd image store. + # amd64 here; build-arm64 builds arm64 natively. PLATFORM: linux/amd64 # Push the release tag as :IMAGE_TAG (vX.Y.Z-rc.N) and the component # versions as :. Deliberately NO PUBLISH_FLOATING: this diff --git a/docs/agents/image-refs.md b/docs/agents/image-refs.md index 0d22e120d..9c9bb3ca6 100644 --- a/docs/agents/image-refs.md +++ b/docs/agents/image-refs.md @@ -47,6 +47,7 @@ If a package stamps a reference somewhere the two globs do not cover, add that f - **A package that stamps a reference is in the root `Makefile` `build:` list.** `build-matrix.sh`, the main build and release preparation all iterate that one list, so a package outside it is never rebuilt by any path and its pin freezes permanently. `hack/build-matrix_test.bats` computes the set of packages whose Makefile runs `docker buildx build` and fails on any the list misses. - **Every consumer of references reads all three storage shapes**, via `hack/lib/image-refs.sh`. - **Scan wider than you rewrite.** A rewrite is deliberately narrow to avoid corrupting vendored defaults, so the check that follows it must not share that blind spot. `hack/promote-rewrite-tags.sh` rewrites the enumerated files, then greps the whole tree and fails if any reference to the release candidate survives. +- **A pin can name an index that carries attestation manifests.** An image built with `SBOM=1` is pushed as an index of the image and its attestation manifest, and the ref pins that index, not a single-platform manifest. A consumer that reads a pin's manifest takes the entries with `platform.os == "linux"` as its platforms: an entry whose platform is `unknown/unknown` is an attestation, not a platform. `hack/stitch-multiarch.sh` and `hack/verify-multiarch.sh` read pins this way, and `skopeo copy --multi-arch all`, which the retag and the mirror run, copies the attestation with the image. ## What each consumer does @@ -58,9 +59,9 @@ If a package stamps a reference somewhere the two globs do not cover, add that f | `hack/promote-retag.sh` | all three shapes | copy each digest to its stable tag in the registry | | `hack/nightly-mirror.sh` | all three shapes | copy each digest to the public registry, then rewrite hosts in the same file set | | `hack/overlay-main-images.sh` | `values.yaml` + `*.tag` | overlay current-main references onto packages a PR did not rebuild | -| `hack/stitch-multiarch.sh` | all three shapes | join each amd64 image and its `-arm64` twin into one index, repin the tree on it, then fail if any old digest survives | +| `hack/stitch-multiarch.sh` | all three shapes | join each amd64 image and its `-arm64` twin into one index, each side an image or, built with `SBOM=1`, an index of the image and its attestation that the result keeps, repin the tree on it, then fail if any old digest survives | | `hack/retention-packages.sh` | all three shapes, in the published nightly tree | name the GHCR packages the retention workflow prunes (those linked to another repository are then skipped); a ref it cannot see is a package whose nightlies are never pruned | -| `hack/verify-multiarch.sh` | all three shapes, first- and third-party | fail a release unless every pinned image is an index with `linux/amd64` and `linux/arm64`, bar the repositories in `hack/multiarch-allowlist` | +| `hack/verify-multiarch.sh` | all three shapes, first- and third-party | fail a release unless every pinned image is an index with `linux/amd64` and `linux/arm64`, attestation entries not counted, bar the repositories in `hack/multiarch-allowlist` | `hack/multiarch-allowlist` is the only bypass for that gate, and it is for images that cannot exist for arm64, not for images nobody has rebuilt yet. An entry is a repository with no tag or digest, so bumping an allowed image needs no edit, and it must carry a reason after `#` on the same line: the script refuses an entry without one. Allowed refs are still printed, under their own "allowed single-arch" heading, and in tree mode an entry that matches no ref that failed the check is reported as stale without changing the exit status, so it gets removed once the image is multi-arch or gone. `--refs-file` reports no stale entries, since a list of pulled images omits everything the run did not pull. diff --git a/docs/release.md b/docs/release.md index af7ffc5ac..9e8b34cd8 100644 --- a/docs/release.md +++ b/docs/release.md @@ -329,7 +329,7 @@ So a commit on a supported `release-X.Y` line ships when a maintainer promotes t A release cut from a tree that has [`hack/sign-release-assets.sh`](../hack/sign-release-assets.sh) carries three files beside its other assets, which [`hack/upload-assets.sh`](../hack/upload-assets.sh) has that script write before it uploads anything. Releases cut before it have none of them, and neither does a maintenance line until the script is backported to it (see below). -- `cozystack-source-sbom.spdx.json` is an SPDX SBOM, made with syft, of the committed source tree (the Go modules, npm lockfiles, GitHub Actions references and other manifests syft reads in it) and of the `cozypkg` binaries in the `cozypkg-*.tar.gz` assets. It does not describe what runs in the cluster: neither the container images that `packages/` pins by digest, whose SBOMs are [#2937](https://github.com/cozystack/cozystack/issues/2937) (item A2 in [#3278](https://github.com/cozystack/cozystack/issues/3278)), nor the Talos ISO, disk, kernel and initramfs images. Its name says what it covers, so that `cozystack-sbom` stays free for an SBOM of the release as a whole. +- `cozystack-source-sbom.spdx.json` is an SPDX SBOM, made with syft, of the committed source tree (the Go modules, npm lockfiles, GitHub Actions references and other manifests syft reads in it) and of the `cozypkg` binaries in the `cozypkg-*.tar.gz` assets. It does not describe what runs in the cluster: the container images carry their own SBOMs, see [SBOM of the container images](#sbom-of-the-container-images), and the Talos installer image, ISO, disk, kernel and initramfs images have none. Its name says what it covers, so that `cozystack-sbom` stays free for an SBOM of the release as a whole. - `cozystack-checksums.txt` holds the SHA-256 of every other asset and of the SBOM. - `cozystack-checksums.txt.sigstore.json` is a Sigstore bundle with a keyless cosign signature over the checksums. Its certificate names the workflow that published the release: `tags.yaml` for a pre-release, `promote-rc.yaml` for a stable one. @@ -351,6 +351,10 @@ The script runs the same `cosign verify-blob` before it returns, so a signature `promote-rc.yaml` runs the `hack/upload-assets.sh` of the rc tree, not the dispatch ref's. A stable release is therefore signed only when its rc was tagged from a tree that has `hack/sign-release-assets.sh`, and a maintenance line starts carrying signatures once that script is backported to its `release-X.Y` branch. +### SBOM of the container images + +The container images a release pins by digest each carry an SPDX SBOM of the packages in them, which BuildKit attaches at build time as an attestation (`SBOM=1` in `tags.yaml`, scanner and flags in [`hack/common-envs.mk`](../hack/common-envs.mk)). The attestation is an entry of the image's index, so the digest the release pins names both, and promotion and the nightly mirror copy it with the image. Read it with `docker buildx imagetools inspect @ --format '{{ json .SBOM }}'`, which prints one document per platform. The attestations are not signed. A release cut before `SBOM=1` was set has none, and neither has the Talos installer image, which the imager builds and `skopeo` pushes, nor the third-party images `packages/` vendors by digest. + ## Nightly builds A nightly is an **installable copy of `main` on GHCR — not a rebuild and not a release**. [`build-main.yaml`](../.github/workflows/build-main.yaml) already builds every push to `main` into the CI registry (OCIR); the nightly promotes that build to the public release registry (GHCR) and proves it installs. diff --git a/docs/security/self-assessment.md b/docs/security/self-assessment.md index cb8360904..6b3b2e076 100644 --- a/docs/security/self-assessment.md +++ b/docs/security/self-assessment.md @@ -10,7 +10,7 @@ This document is a security self-assessment for [Cozystack](https://github.com/c | Software | | | Security Provider | No. Cozystack is a cloud platform, not a security product; security is a supporting property, not its primary function. | | Languages | Go, Helm, shell | -| SBOM | Not published at this time. Image builds can emit a CycloneDX SBOM attestation (`SBOM=1` in `hack/common-envs.mk`), but this is disabled by default; SBOM generation on release is a planned improvement. | +| SBOM | Not published at this time. Image builds can emit an SPDX SBOM attestation (`SBOM=1` in `hack/common-envs.mk`), but this is disabled by default; SBOM generation on release is a planned improvement. | ### Security links diff --git a/hack/common-envs.mk b/hack/common-envs.mk index 100e9de1d..f679120ec 100644 --- a/hack/common-envs.mk +++ b/hack/common-envs.mk @@ -66,15 +66,20 @@ PLATFORM ?= $(if $(filter 1,$(LOAD)),,$(DEFAULT_PLATFORMS)) BUILDX_EXTRA_ARGS ?= COZYSTACK_VERSION = $(patsubst v%,%,$(shell git describe --tags --match 'v*')) -# SBOM=1 attaches a CycloneDX SBOM as a buildx build attestation (--sbom=true). -# Requires the docker-container buildx driver and registry referrers/attestation -# support. Kept OFF by default until an OCIR referrers/attestation probe confirms -# support — otherwise the attestation push fails the build. See #2937. +# SBOM=1 attaches an SPDX SBOM to every image as a BuildKit attestation. The +# pushed ref then names an index of the image and its attestation manifest, not +# the image itself, which hack/stitch-multiarch.sh and hack/verify-multiarch.sh +# both handle. Attestations need the docker-container buildx driver. The scanner +# is pinned by digest like every other build input, where --sbom=true would pull +# a floating tag; bump the version and the digest together. SBOM ?= 0 +SBOM_SCANNER ?= docker.io/docker/buildkit-syft-scanner:1.12.0@sha256:ae4f3b554449e7e25548e7d8ccc029d17357348e30c6e3df01b92bc93654d6a9 +# A variable, because the commas would split the arguments of $(if) below. +SBOM_ATTEST := --attest type=sbom,generator=$(SBOM_SCANNER) BUILDX_ARGS := --provenance=false --push=$(PUSH) --load=$(LOAD) \ --label org.opencontainers.image.source=https://github.com/cozystack/cozystack \ - $(if $(filter 1,$(SBOM)),--sbom=true) \ + $(if $(filter 1,$(SBOM)),$(SBOM_ATTEST)) \ $(if $(strip $(BUILDER)),--builder=$(BUILDER)) \ $(if $(strip $(PLATFORM)),--platform=$(PLATFORM)) \ $(BUILDX_EXTRA_ARGS) diff --git a/hack/common-envs_test.bats b/hack/common-envs_test.bats index 9eab7a47b..d40ca33e9 100644 --- a/hack/common-envs_test.bats +++ b/hack/common-envs_test.bats @@ -144,6 +144,20 @@ load test_helper echo "$out" | grep -q -- '--push=0 --load=1' } +@test "SBOM=1 attaches an SPDX attestation from a scanner pinned by digest, and the default builds none" { + # An image built with an attestation is an index, not a plain manifest, which + # the stitch and the gate have to read, so it stays off unless a build asks. + # --sbom=true would pull the scanner by a floating tag. + out=$(make -n -C packages/system/cozystack-controller image IMAGE_TAG=pr-1-abc COZYSTACK_VERSION=0 BUILDER=b) + if echo "$out" | grep -q -- '--attest\|--sbom'; then echo "FAIL: an attestation without SBOM=1"; false; fi + out=$(make -n -C packages/system/cozystack-controller image IMAGE_TAG=pr-1-abc COZYSTACK_VERSION=0 BUILDER=b SBOM=1) + echo "$out" | grep -qE -- '--attest type=sbom,generator=[^ ]+@sha256:[0-9a-f]{64} ' || { echo "FAIL: no digest-pinned scanner"; false; } + if echo "$out" | grep -q -- '--sbom'; then echo "FAIL: --sbom=true pulls a floating scanner tag"; false; fi + # The environment reaches buildx as well, which is how the release workflow sets it. + out=$(SBOM=1 make -n -C packages/system/cozystack-controller image IMAGE_TAG=pr-1-abc COZYSTACK_VERSION=0 BUILDER=b) + echo "$out" | grep -q -- '--attest type=sbom,' +} + @test "talos publishes nothing to the registry with PUSH=0" { out=$(make -n -B -C packages/core/talos image-talos PUSH=0 PUBLISH_VERSIONED=1 PUBLISH_FLOATING=1 IMAGE_TAG=pr-1-abc COZYSTACK_VERSION=0) echo "$out" | grep -q 'docker://' diff --git a/hack/stitch-multiarch.sh b/hack/stitch-multiarch.sh index d52bb6cb4..79d831bc6 100755 --- a/hack/stitch-multiarch.sh +++ b/hack/stitch-multiarch.sh @@ -10,7 +10,9 @@ # image as -arm64 # # The amd64 build and the arm64 leg run on separate native runners, so each -# pushes a single-platform image. For every ref /[:]@A in +# 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 /[:]@A in # the tree, whatever tag it carries, with the : # # 1. B is the digest of :-arm64. No such image is not an error: @@ -109,10 +111,18 @@ manifest_digest() { } # image_arch prints the architecture of a single-platform image, -# or nothing for an index or a non-image artifact. A registry error returns -# non-zero, like manifest_digest. +# 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 @@ -232,10 +242,15 @@ while read -r repo a reftags <&3; do 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 --arg a "$a" \ - '(.manifests // []) as $m | any($m[]; .digest == $a) and any($m[]; .platform.architecture == "arm64")' >/dev/null; then + 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 diff --git a/hack/stitch-multiarch_test.bats b/hack/stitch-multiarch_test.bats index 730a89d64..2d1232810 100644 --- a/hack/stitch-multiarch_test.bats +++ b/hack/stitch-multiarch_test.bats @@ -76,8 +76,15 @@ while [ "$#" -gt 0 ]; do --tag) tags="$tags $2"; shift 2 ;; *) d="${1##*@}" - arch="$(jq -r .arch "$MOCK_REG/blobs/${d#sha256:}")" - entries="$entries${entries:+,}{\"digest\":\"$d\",\"platform\":{\"os\":\"linux\",\"architecture\":\"$arch\"}}" + f="$MOCK_REG/blobs/${d#sha256:}" + # A source index contributes its entries, attestation manifests included, + # as buildx does; a plain image contributes itself. + if jq -e '.manifests' "$f" >/dev/null 2>&1; then + entries="$entries${entries:+,}$(jq -r '[.manifests[] | tojson] | join(",")' "$f")" + else + arch="$(jq -r .arch "$f")" + entries="$entries${entries:+,}{\"digest\":\"$d\",\"platform\":{\"os\":\"linux\",\"architecture\":\"$arch\"}}" + fi shift ;; esac done @@ -105,6 +112,44 @@ _tag() { awk -v r="$1" -v t="$2" '$1 == r && $2 == t { d = $3 } END { printf "%s", d }' "$MOCK_REG/tags" } +# _push_sbom ... stores the index an image built with SBOM=1 +# pushes, one linux image per arch beside an attestation manifest, tags it, and +# prints its digest. +_push_sbom() { + _ps_repo="$1" + _ps_tag="$2" + shift 2 + _ps_entries="" + for _ps_arch in "$@"; do + _ps_img="{\"mediaType\":\"application/vnd.oci.image.manifest.v1+json\",\"config\":{\"mediaType\":\"application/vnd.oci.image.config.v1+json\"},\"arch\":\"$_ps_arch\",\"name\":\"$_ps_repo:$_ps_tag\"}" + _ps_hex="$(printf '%s' "$_ps_img" | sha256sum | cut -d' ' -f1)" + printf '%s' "$_ps_img" >"$MOCK_REG/blobs/$_ps_hex" + _ps_entries="$_ps_entries${_ps_entries:+,}{\"digest\":\"sha256:$_ps_hex\",\"platform\":{\"os\":\"linux\",\"architecture\":\"$_ps_arch\"}}" + done + _ps_att="{\"mediaType\":\"application/vnd.oci.image.manifest.v1+json\",\"attestation\":true,\"name\":\"$_ps_repo:$_ps_tag\"}" + _ps_hex="$(printf '%s' "$_ps_att" | sha256sum | cut -d' ' -f1)" + printf '%s' "$_ps_att" >"$MOCK_REG/blobs/$_ps_hex" + _ps_entries="$_ps_entries,{\"digest\":\"sha256:$_ps_hex\",\"platform\":{\"os\":\"unknown\",\"architecture\":\"unknown\"},\"annotations\":{\"vnd.docker.reference.type\":\"attestation-manifest\"}}" + _ps_body="{\"mediaType\":\"application/vnd.oci.image.index.v1+json\",\"manifests\":[$_ps_entries]}" + _ps_hex="$(printf '%s' "$_ps_body" | sha256sum | cut -d' ' -f1)" + printf '%s' "$_ps_body" >"$MOCK_REG/blobs/$_ps_hex" + printf '%s %s sha256:%s\n' "$_ps_repo" "$_ps_tag" "$_ps_hex" >>"$MOCK_REG/tags" + printf 'sha256:%s' "$_ps_hex" +} + +# A tree stamped by an amd64 build with SBOM=1: api pinned on the index of its +# amd64 image and attestation manifest, with the arm64 leg's twin. Sets A_api +# and B_api. +_make_sbom_world() { + mkdir -p "$1/tree/system/api" + A_api=$(_push_sbom "$REG/api" main amd64) + B_api=$(_push_sbom "$REG/api" main-arm64 arm64) + PUSHED_TAGS_LOG="$1/pushed-tags" + export PUSHED_TAGS_LOG + printf '%s/api:main\n' "$REG" >"$PUSHED_TAGS_LOG" + printf 'image: %s/api:main@%s\n' "$REG" "$A_api" >"$1/tree/system/api/values.yaml" +} + # A tree stamped by an amd64 build with IMAGE_TAG=main, one image per storage # shape and values.yaml sub-shape, each with an arm64 twin, plus the refs that # must be left alone. Sets A_ to each amd64 digest. @@ -383,24 +428,98 @@ _make_world() { [ "$(yq -r '.jobs.prepare-release.steps[] | select(.name == "Set up flux") | .with.version' "$wf")" = "$want" ] } -@test "the amd64 release job rebuilds matchbox as a two-arch index before it captures the tree" { - # matchbox has no arm64 twin to stitch, so this rebuild is the only thing - # that makes the ref the gate checks an index. It needs a builder that can - # push an index while make build stays on the default docker driver. +@test "the amd64 release job builds every image on a docker-container builder, with an SBOM, before it captures the tree" { + # An SBOM attestation needs the docker-container driver, and so does the + # matchbox rebuild as a two-arch index: matchbox has no arm64 twin to stitch, + # so that rebuild is the only thing that makes the ref the gate checks an + # index. wf=.github/workflows/tags.yaml steps=$(yq -r '.jobs.build-amd64.steps[].name' "$wf") pos() { echo "$steps" | grep -nxF "$1" | cut -d: -f1; } - bx=$(pos 'Set up Buildx for matchbox (docker-container driver)') + bx=$(pos 'Set up Buildx (docker-container driver, docker.io mirror)') build=$(pos 'Build') capture=$(pos 'Capture the stamped tree') [ -n "$bx" ] && [ -n "$build" ] && [ -n "$capture" ] [ "$bx" -lt "$build" ] && [ "$build" -lt "$capture" ] - sel='.jobs.build-amd64.steps[] | select(.id == "matchbox_buildx")' + sel='.jobs.build-amd64.steps[] | select(.id == "buildx")' [ "$(yq -r "$sel | .with.driver" "$wf")" = docker-container ] + [ "$(yq -r "$sel | .with.\"buildkitd-config\"" "$wf")" = hack/buildkitd.toml ] [ "$(yq -r "$sel | .with.use" "$wf")" = false ] - run_=$(yq -r '.jobs.build-amd64.steps[] | select(.name == "Build") | .run' "$wf") - echo "$run_" | grep -qxE '[[:space:]]*make -C packages/core/talos image-matchbox PLATFORM=linux/amd64,linux/arm64 BUILDER="\$MATCHBOX_BUILDER"' - [ "$(yq -r '.jobs.build-amd64.steps[] | select(.name == "Build") | .env.MATCHBOX_BUILDER' "$wf")" = '${{ steps.matchbox_buildx.outputs.name }}' ] + build_sel='.jobs.build-amd64.steps[] | select(.name == "Build")' + [ "$(yq -r "$build_sel | .env.BUILDER" "$wf")" = '${{ steps.buildx.outputs.name }}' ] + [ "$(yq -r "$build_sel | .env.SBOM" "$wf")" = 1 ] + run_=$(yq -r "$build_sel | .run" "$wf") + echo "$run_" | grep -qxE '[[:space:]]*make -C packages/core/talos image-matchbox PLATFORM=linux/amd64,linux/arm64' +} + +@test "the arm64 release leg attaches an SBOM on its docker-container builder" { + wf=.github/workflows/tags.yaml + sel='.jobs.build-arm64.steps[] | select(.name == "Build arm64 images")' + [ "$(yq -r "$sel | .env.SBOM" "$wf")" = 1 ] + [ "$(yq -r "$sel | .env.BUILDER" "$wf")" = '${{ steps.buildx.outputs.name }}' ] + [ "$(yq -r '.jobs.build-arm64.steps[] | select(.id == "buildx") | .with.driver' "$wf")" = docker-container ] +} + +@test "images built with an SBOM are stitched into one index that keeps both attestations" { + tmp=$(mktemp -d) + _stub_registry "$tmp/reg" + _make_sbom_world "$tmp" + t="$tmp/tree" + + PATH="$MOCK_REG/bin:$PATH" hack/stitch-multiarch.sh "$t" "$REG" main >"$tmp/out" 2>"$tmp/err" + + i="$(_tag "$REG/api" main)" + [ "$i" != "$A_api" ] + # Two linux images, the ones the two builds pushed, and both attestations. + a_child=$(jq -r '.manifests[] | select(.platform.os == "linux") | .digest' "$MOCK_REG/blobs/${A_api#sha256:}") + b_child=$(jq -r '.manifests[] | select(.platform.os == "linux") | .digest' "$MOCK_REG/blobs/${B_api#sha256:}") + jq -e --arg a "$a_child" --arg b "$b_child" ' + ([.manifests[] | select(.platform.os == "linux") | .platform.architecture] | sort) == ["amd64", "arm64"] + and ([.manifests[] | select(.platform.os == "unknown")] | length) == 2 + and any(.manifests[]; .digest == $a) and any(.manifests[]; .digest == $b)' \ + "$MOCK_REG/blobs/${i#sha256:}" >/dev/null + grep -q "api:main@$i\$" "$t/system/api/values.yaml" + if grep -rq "$A_api" "$t"; then echo "FAIL: api still pinned on its amd64 index"; false; fi + grep -q '^stitch: 1 stitched (api); 0 skipped ()$' "$tmp/out" + rm -rf "$tmp" +} + +@test "a rerun after a stitch of SBOM images repins on the existing index" { + # The stitched index lists the linux images of the two builds, not the amd64 + # index the tree pins, so the rerun has to look through the pinned one. + tmp=$(mktemp -d) + _stub_registry "$tmp/reg" + _make_sbom_world "$tmp" + cp -R "$tmp/tree" "$tmp/stamped" + PATH="$MOCK_REG/bin:$PATH" hack/stitch-multiarch.sh "$tmp/tree" "$REG" main >/dev/null 2>&1 + i="$(_tag "$REG/api" main)" + + rm -rf "$tmp/tree" + cp -R "$tmp/stamped" "$tmp/tree" + PATH="$MOCK_REG/bin:$PATH" hack/stitch-multiarch.sh "$tmp/tree" "$REG" main >"$tmp/out" 2>"$tmp/err" + + [ "$(_tag "$REG/api" main)" = "$i" ] + grep -q "api:main@$i\$" "$tmp/tree/system/api/values.yaml" + [ "$(grep -c 'imagetools create' "$MOCK_REG/log")" -eq 1 ] + grep -q '^stitch: 1 stitched (api); 0 skipped ()$' "$tmp/out" + rm -rf "$tmp" +} + +@test "an index of two linux images is skipped, not stitched" { + # A multi-arch build in one go has no twin to join. + tmp=$(mktemp -d) + _stub_registry "$tmp/reg" + _make_sbom_world "$tmp" + both=$(_push_sbom "$REG/api" main amd64 arm64) + printf 'image: %s/api:main@%s\n' "$REG" "$both" >"$tmp/tree/system/api/values.yaml" + + PATH="$MOCK_REG/bin:$PATH" hack/stitch-multiarch.sh "$tmp/tree" "$REG" main >"$tmp/out" 2>"$tmp/err" + + [ "$(_tag "$REG/api" main)" = "$both" ] + grep -q "$both" "$tmp/tree/system/api/values.yaml" + grep -q 'skipping api: main and main-arm64 are not one amd64 and one arm64 image' "$tmp/err" + if grep -q 'imagetools create' "$MOCK_REG/log"; then echo "FAIL: a skipped ref was pushed"; false; fi + rm -rf "$tmp" } @test "an arm64 tag that holds no arm64 image is skipped, not stitched" { diff --git a/hack/verify-multiarch.sh b/hack/verify-multiarch.sh index 5db960559..b11ca4f15 100755 --- a/hack/verify-multiarch.sh +++ b/hack/verify-multiarch.sh @@ -117,6 +117,8 @@ while IFS= read -r ref; do 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