diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index dd17dacd..530f70a9 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -89,7 +89,7 @@ jobs: run: go mod download && go mod vendor && CTR_CMD=podman make build-ansible-cross-build-linux-arm64 - name: Verify native ARM image - run: test "$(podman image inspect --format '{{.Architecture}}' quay.io/fetchit/fetchit-ansible-arm:latest)" = arm64 + run: test "$(podman image inspect --format '{{.Os}}/{{.Architecture}}' quay.io/fetchit/fetchit-ansible-arm:latest)" = linux/arm64 - name: Export image run: podman save -o /tmp/ansible-arm.tar quay.io/fetchit/fetchit-ansible-arm:latest @@ -169,7 +169,7 @@ jobs: run: go mod download && go mod vendor && CTR_CMD=podman make build-systemd-cross-build-linux-arm64 - name: Verify native ARM image - run: test "$(podman image inspect --format '{{.Architecture}}' quay.io/fetchit/fetchit-systemd-arm:latest)" = arm64 + run: test "$(podman image inspect --format '{{.Os}}/{{.Architecture}}' quay.io/fetchit/fetchit-systemd-arm:latest)" = linux/arm64 - name: Export image run: podman save -o /tmp/systemd-arm.tar quay.io/fetchit/fetchit-systemd-arm:latest @@ -1682,7 +1682,7 @@ jobs: run: go mod download && go mod vendor && CTR_CMD=podman make build-containerized-cross-build-linux-arm64 - name: Verify native ARM image - run: test "$(podman image inspect --format '{{.Architecture}}' quay.io/fetchit/fetchit-arm:latest)" = arm64 + run: test "$(podman image inspect --format '{{.Os}}/{{.Architecture}}' quay.io/fetchit/fetchit-arm:latest)" = linux/arm64 - name: Export image run: podman save -o /tmp/fetchit-arm.tar quay.io/fetchit/fetchit-arm:latest @@ -1719,9 +1719,6 @@ jobs: - name: Load the arm image run: podman load -i /tmp/fetchit-arm.tar - - name: create manifest - run: podman manifest create quay.io/fetchit/fetchit:latest - - name: Login to Quay if: (github.event_name == 'push' || github.event_name == 'schedule') && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) uses: docker/login-action@v3 @@ -1730,19 +1727,10 @@ jobs: username: ${{ secrets.REGISTRY_USER }} password: ${{ secrets.REGISTRY_PASSWORD }} - - name: Assemble and verify the tested platform images + - name: Verify the tested Linux platforms run: | - test "$(podman image inspect --format '{{.Architecture}}' quay.io/fetchit/fetchit-amd:latest)" = amd64 - test "$(podman image inspect --format '{{.Architecture}}' quay.io/fetchit/fetchit-arm:latest)" = arm64 - podman manifest add quay.io/fetchit/fetchit:latest quay.io/fetchit/fetchit-arm:latest - podman manifest add quay.io/fetchit/fetchit:latest quay.io/fetchit/fetchit-amd:latest - podman manifest inspect quay.io/fetchit/fetchit:latest > /tmp/platform-index.json - python3 - <<'PYTHON' - import json - with open('/tmp/platform-index.json') as source: - platforms = {(m['platform']['os'], m['platform']['architecture']) for m in json.load(source)['manifests']} - assert platforms == {('linux', 'amd64'), ('linux', 'arm64')}, platforms - PYTHON + test "$(podman image inspect --format '{{.Os}}/{{.Architecture}}' quay.io/fetchit/fetchit-amd:latest)" = linux/amd64 + test "$(podman image inspect --format '{{.Os}}/{{.Architecture}}' quay.io/fetchit/fetchit-arm:latest)" = linux/arm64 - name: Publish tested architecture tags and platform index if: (github.event_name == 'push' || github.event_name == 'schedule') && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) @@ -1834,22 +1822,10 @@ jobs: username: ${{ secrets.REGISTRY_USER }} password: ${{ secrets.REGISTRY_PASSWORD }} - - name: create manifest - run: podman manifest create quay.io/fetchit/fetchit-ansible:latest - - - name: Assemble and verify the tested platform images + - name: Verify the tested Linux platforms run: | - test "$(podman image inspect --format '{{.Architecture}}' quay.io/fetchit/fetchit-ansible-amd:latest)" = amd64 - test "$(podman image inspect --format '{{.Architecture}}' quay.io/fetchit/fetchit-ansible-arm:latest)" = arm64 - podman manifest add quay.io/fetchit/fetchit-ansible:latest quay.io/fetchit/fetchit-ansible-arm:latest - podman manifest add quay.io/fetchit/fetchit-ansible:latest quay.io/fetchit/fetchit-ansible-amd:latest - podman manifest inspect quay.io/fetchit/fetchit-ansible:latest > /tmp/platform-index.json - python3 - <<'PYTHON' - import json - with open('/tmp/platform-index.json') as source: - platforms = {(m['platform']['os'], m['platform']['architecture']) for m in json.load(source)['manifests']} - assert platforms == {('linux', 'amd64'), ('linux', 'arm64')}, platforms - PYTHON + test "$(podman image inspect --format '{{.Os}}/{{.Architecture}}' quay.io/fetchit/fetchit-ansible-amd:latest)" = linux/amd64 + test "$(podman image inspect --format '{{.Os}}/{{.Architecture}}' quay.io/fetchit/fetchit-ansible-arm:latest)" = linux/arm64 - name: Publish tested architecture tags and platform index if: (github.event_name == 'push' || github.event_name == 'schedule') && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) @@ -1891,22 +1867,10 @@ jobs: username: ${{ secrets.REGISTRY_USER }} password: ${{ secrets.REGISTRY_PASSWORD }} - - name: create manifest - run: podman manifest create quay.io/fetchit/fetchit-systemd:latest - - - name: Assemble and verify the tested platform images + - name: Verify the tested Linux platforms run: | - test "$(podman image inspect --format '{{.Architecture}}' quay.io/fetchit/fetchit-systemd-amd:latest)" = amd64 - test "$(podman image inspect --format '{{.Architecture}}' quay.io/fetchit/fetchit-systemd-arm:latest)" = arm64 - podman manifest add quay.io/fetchit/fetchit-systemd:latest quay.io/fetchit/fetchit-systemd-arm:latest - podman manifest add quay.io/fetchit/fetchit-systemd:latest quay.io/fetchit/fetchit-systemd-amd:latest - podman manifest inspect quay.io/fetchit/fetchit-systemd:latest > /tmp/platform-index.json - python3 - <<'PYTHON' - import json - with open('/tmp/platform-index.json') as source: - platforms = {(m['platform']['os'], m['platform']['architecture']) for m in json.load(source)['manifests']} - assert platforms == {('linux', 'amd64'), ('linux', 'arm64')}, platforms - PYTHON + test "$(podman image inspect --format '{{.Os}}/{{.Architecture}}' quay.io/fetchit/fetchit-systemd-amd:latest)" = linux/amd64 + test "$(podman image inspect --format '{{.Os}}/{{.Architecture}}' quay.io/fetchit/fetchit-systemd-arm:latest)" = linux/arm64 - name: Publish tested architecture tags and platform index if: (github.event_name == 'push' || github.event_name == 'schedule') && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) diff --git a/.github/workflows/platform-publish.yml b/.github/workflows/platform-publish.yml index ca4477fd..c87f003e 100644 --- a/.github/workflows/platform-publish.yml +++ b/.github/workflows/platform-publish.yml @@ -13,5 +13,7 @@ jobs: steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: ./.github/actions/setup-podman + - name: Test publishing failure handling + run: bash tests/integration/publishing/test-publish-failures.sh - name: Test archive import and platform publishing with a local registry run: bash tests/integration/publishing/test-platform-publish.sh diff --git a/docs/documentation.rst b/docs/documentation.rst index 25314814..de062e76 100644 --- a/docs/documentation.rst +++ b/docs/documentation.rst @@ -105,3 +105,31 @@ the same rootful store. For a local build, use: sudo podman build -t quay.io/fetchit/fetchit-sample-app:latest \ -f examples/sample-app/Containerfile examples/sample-app + +Platform index verification +--------------------------- + +The publisher stages amd64 and arm64 child manifests in the destination +repository and publishes one index referencing their immutable digests. +Verification reads the index using the digest returned by the push. A local +manifest with the same name as a public ``latest`` tag can shadow registry +inspection in Podman; it must not be used as evidence of published content. +The regression in ``tests/integration/publishing/test-platform-publish.sh`` +creates a conflicting local manifest under the destination's ``latest`` name, +then verifies that registry clients can pull both amd64 and arm64 from the +shared tag. ``tests/integration/publishing/test-publish-failures.sh`` checks +missing and invalid digest output and rejects non-Linux images before a push. + +For an existing local index whose child manifests have already been pushed to +``quay.io/fetchit/fetchit``, the equivalent digest-based inspection is: + +.. code-block:: bash + + podman manifest push --all=false --format=docker --digestfile index.digest \ + localhost/reviewed-index docker://quay.io/fetchit/fetchit:latest + index_digest=$(/dev/null 2>&1 || true; rm -rf "$scratch"; exit "$publish_status"' EXIT -test "$(podman image inspect --format '{{.Architecture}}' "$amd_image")" = amd64 -test "$(podman image inspect --format '{{.Architecture}}' "$arm_image")" = arm64 +test "$(podman image inspect --format '{{.Os}}/{{.Architecture}}' "$amd_image")" = linux/amd64 +test "$(podman image inspect --format '{{.Os}}/{{.Architecture}}' "$arm_image")" = linux/arm64 # A push may convert the archive's manifest and update its storage metadata. Do # not reuse an index assembled from those local instances before the pushes. # Retain legacy architecture repositories for existing consumers. @@ -39,8 +39,8 @@ podman push "${options[@]}" --format=v2s2 "$arm_image" "docker://$repository-arm # Registries require every index child manifest in the index's own repository. podman push "${options[@]}" --format=v2s2 --digestfile "$scratch/amd.digest" "$amd_image" "docker://$repository:amd64" podman push "${options[@]}" --format=v2s2 --digestfile "$scratch/arm.digest" "$arm_image" "docker://$repository:arm64" -amd_digest=$(cat "$scratch/amd.digest") -arm_digest=$(cat "$scratch/arm.digest") +amd_digest=$(<"$scratch/amd.digest") +arm_digest=$(<"$scratch/arm.digest") for digest in "$amd_digest" "$arm_digest"; do [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo 'Invalid pushed image digest' >&2; exit 1; } done @@ -58,11 +58,20 @@ podman manifest add "${options[@]}" "$manifest" "docker://$repository@$arm_diges # can recompress layers and replace the exact digests pinned above. Publish only # the index with --all=false; Podman 5 defaults --all to true. Keep both pinned # child digests unchanged. Child publication and index publication are separate. -if ! podman manifest push --all=false "${options[@]}" --format=docker "$manifest" "docker://$destination"; then +push_args=( + --all=false "${options[@]}" --format=docker + --digestfile "$scratch/index.digest" "$manifest" "docker://$destination" +) +if ! podman manifest push "${push_args[@]}"; then echo "Failed to publish platform index: $destination" >&2 exit 1 fi -podman manifest inspect "${options[@]}" "$destination" > "$scratch/index.json" +[[ -s "$scratch/index.digest" ]] || { echo 'Missing or empty pushed index digest' >&2; exit 1; } +index_digest=$(<"$scratch/index.digest") +[[ "$index_digest" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo 'Invalid pushed index digest' >&2; exit 1; } +# A local list named DESTINATION shadows the remote tag in manifest inspect. +# Inspect the immutable index returned by this push, not a possibly stale local tag. +podman manifest inspect "${options[@]}" "$repository@$index_digest" > "$scratch/index.json" python3 - "$scratch/index.json" "$amd_digest" "$arm_digest" <<'PYTHON' import json import sys diff --git a/tests/integration/publishing/test-platform-publish.sh b/tests/integration/publishing/test-platform-publish.sh index 2ed7646e..8c935005 100644 --- a/tests/integration/publishing/test-platform-publish.sh +++ b/tests/integration/publishing/test-platform-publish.sh @@ -35,8 +35,14 @@ expect_failure bash scripts/publish-platform-index.sh 127.0.0.1:5000/test:latest expect_failure bash scripts/publish-platform-index.sh 127.0.0.1:5000/test:latest localhost/publish-missing:amd64 localhost/publish-source:arm64 # Seed an incorrect old architecture tag: the script must replace it. podman push --tls-verify=false localhost/publish-source:arm64 docker://127.0.0.1:5000/fetchit-test-amd:latest +# Match the production failure: a stale local list has the destination tag. +# Give it only the wrong architecture so inspecting that local tag cannot pass. +podman manifest create 127.0.0.1:5000/fetchit-test:latest +podman manifest add 127.0.0.1:5000/fetchit-test:latest localhost/publish-source:arm64 FETCHIT_REGISTRY_TLS_VERIFY=false bash scripts/publish-platform-index.sh \ 127.0.0.1:5000/fetchit-test:latest localhost/publish-source:amd64 localhost/publish-source:arm64 +# Remove the intentionally stale local tag before the independent remote checks. +podman manifest rm 127.0.0.1:5000/fetchit-test:latest # Check both architecture tags before deliberately moving one. for arch_tag in amd arm; do podman manifest inspect --tls-verify=false "127.0.0.1:5000/fetchit-test-$arch_tag:latest" > "/tmp/$arch_tag-tag.json" diff --git a/tests/integration/publishing/test-publish-failures.sh b/tests/integration/publishing/test-publish-failures.sh new file mode 100644 index 00000000..69faf878 --- /dev/null +++ b/tests/integration/publishing/test-publish-failures.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Fault injection exercises failure paths without publishing to a real registry. +set -euo pipefail +fixture=$(mktemp -d) +trap 'rm -rf "$fixture"' EXIT +mkdir "$fixture/bin" +cat > "$fixture/bin/podman" <<'PYTHON' +#!/usr/bin/env python3 +import json +import os +from pathlib import Path +import sys + +args = sys.argv[1:] +root = Path(os.environ['FETCHIT_FAULT_DIRECTORY']) +case = os.environ['FETCHIT_FAULT_CASE'] +with (root / 'calls').open('a') as calls: + calls.write(json.dumps(args) + '\n') +if args[:2] == ['image', 'inspect']: + architecture = 'amd64' if args[-1] == 'fixture-amd' else 'arm64' + print(('windows' if case == 'non-linux' else 'linux') + '/' + architecture) +elif args[0] == 'push': + if '--digestfile' in args: + path = Path(args[args.index('--digestfile') + 1]) + path.write_text('sha256:' + ('a' if path.name == 'amd.digest' else 'b') * 64) +elif args[:2] == ['manifest', 'push']: + path = Path(args[args.index('--digestfile') + 1]) + if case == 'missing': + pass + elif case == 'empty': + path.write_text('') + elif case == 'malformed': + path.write_text('not-a-digest') + elif case == 'unsupported': + path.write_text('sha512:' + 'c' * 128) + else: + path.write_text('sha256:' + 'c' * 64) +elif args[:2] == ['manifest', 'inspect']: + if args[-1].endswith('@sha256:' + 'c' * 64): + (root / 'index-inspected').touch() + print(json.dumps({'manifests': [ + {'platform': {'os': 'linux', 'architecture': architecture}, 'digest': 'sha256:' + digit * 64} + for architecture, digit in [('amd64', 'a'), ('arm64', 'b')] + ]})) + else: + print('{}') +elif args[:2] not in (['manifest', 'create'], ['manifest', 'add'], ['manifest', 'rm']): + raise SystemExit('Unexpected fake Podman command: ' + repr(args)) +PYTHON +chmod +x "$fixture/bin/podman" +export PATH="$fixture/bin:$PATH" +for case in missing empty malformed unsupported non-linux success; do + directory="$fixture/$case" + mkdir "$directory" + status=0 + FETCHIT_FAULT_CASE="$case" FETCHIT_FAULT_DIRECTORY="$directory" \ + bash scripts/publish-platform-index.sh example.invalid/test:latest fixture-amd fixture-arm \ + > "$directory/output" 2>&1 || status=$? + if [[ "$case" == success ]]; then + test "$status" = 0 + test -f "$directory/index-inspected" + else + test "$status" != 0 + test ! -e "$directory/index-inspected" + case "$case" in + missing|empty) grep -q 'Missing or empty pushed index digest' "$directory/output" ;; + malformed|unsupported) grep -q 'Invalid pushed index digest' "$directory/output" ;; + esac + fi + python3 - "$directory/calls" "$case" <<'PYTHON' +import json +import sys +calls = [json.loads(line) for line in open(sys.argv[1])] +inspections = [call for call in calls if call[:2] == ['manifest', 'inspect']] +if sys.argv[2] == 'non-linux': + assert not any(call[0] == 'push' or call[:2] == ['manifest', 'push'] for call in calls), calls + assert not inspections, inspections +else: + # Only the two child preflights may run when the index digest is invalid. + assert len(inspections) == (3 if sys.argv[2] == 'success' else 2), inspections +PYTHON + echo "Publishing fault case passed: $case" +done