diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a9943c9b..cb2800bf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -212,11 +212,15 @@ jobs: # so the harness always builds the image. Letting that build be authoritative is what makes # "push exactly what was tested" true BY CONSTRUCTION — there is only one build, so nothing can # drift. Do NOT add a second `docker build` here, and do NOT add a skip-guard to - # integration-tests/pom.xml. APP_VERSION reaches the image label through the Compose service's - # build.args (see integration-tests/docker-compose.yml). + # integration-tests/pom.xml. APP_VERSION and APP_REVISION reach the image labels through the + # Compose service's build.args (see integration-tests/docker-compose.yml) — that is the only + # channel, because this lane performs no `docker build` of its own. APP_REVISION carries the + # release-tag commit resolved above, so org.opencontainers.image.revision names the very commit + # the `sha-` tag names. - name: Run the integration-test suite (builds the image) env: APP_VERSION: ${{ steps.config.outputs.current-version }} + APP_REVISION: ${{ steps.commit.outputs.sha }} run: | ./mvnw --no-transfer-progress verify -Pintegration-tests -pl integration-tests -am @@ -374,6 +378,12 @@ jobs: exit 1 fi + PULLED_REVISION="$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$REF")" + if [ "$PULLED_REVISION" != "$TAG_SHA" ]; then + echo "::error::Published image label org.opencontainers.image.revision='${PULLED_REVISION}' but the release-tag commit is '${TAG_SHA}'." + exit 1 + fi + CERTS="${GITHUB_WORKSPACE}/integration-tests/src/main/docker/certificates" CONFIG="${GITHUB_WORKSPACE}/integration-tests/src/main/docker/sheriff-config" docker run -d --name api-sheriff-smoke -p 19000:9000 \ diff --git a/api-sheriff/src/main/docker/Dockerfile.native b/api-sheriff/src/main/docker/Dockerfile.native index be18e9bd..450b8d40 100644 --- a/api-sheriff/src/main/docker/Dockerfile.native +++ b/api-sheriff/src/main/docker/Dockerfile.native @@ -8,14 +8,17 @@ # quarkus.management.tls-configuration-name=plain-management. FROM quay.io/quarkus/quarkus-distroless-image:2.0@sha256:58c30907e6d4417251c90963e1d14e6918a0baa6f8a7d046497584972dc2a378 -# Supplied by the build (docker compose build.args.APP_VERSION). The `dev` default is deliberate and -# honest: a locally or PR-built image is never published, and a version-shaped default would lie the -# moment the project version moved. The release lane exports the released version into this arg. +# Both supplied by the build (docker compose build.args.APP_VERSION / build.args.APP_REVISION). The +# `dev` defaults are deliberate and honest: a locally or PR-built image is never published, and a +# version- or sha-shaped default would lie the moment the project version or the commit moved. The +# release lane exports the released version and the release-tag commit sha into these args. ARG APP_VERSION=dev +ARG APP_REVISION=dev LABEL org.opencontainers.image.title="API Sheriff" LABEL org.opencontainers.image.description="Security-focused API Gateway — Quarkus native executable" LABEL org.opencontainers.image.version="${APP_VERSION}" +LABEL org.opencontainers.image.revision="${APP_REVISION}" LABEL org.opencontainers.image.vendor="CUIoss" LABEL org.opencontainers.image.licenses="Apache-2.0" LABEL org.opencontainers.image.source="https://github.com/cuioss/API-Sheriff" diff --git a/api-sheriff/src/main/docker/Dockerfile.native.jfr b/api-sheriff/src/main/docker/Dockerfile.native.jfr index 37256820..a703425c 100644 --- a/api-sheriff/src/main/docker/Dockerfile.native.jfr +++ b/api-sheriff/src/main/docker/Dockerfile.native.jfr @@ -3,9 +3,15 @@ # No baked-in certificates — mount TLS certs at runtime via volumes or secrets FROM quay.io/quarkus/ubi9-quarkus-micro-image:2.0@sha256:a26d1276dee47e331805a2d89a0faa16dbfcf4ade3f7669a4dae16a752861677 +# Supplied by the build (docker compose build.args.APP_VERSION); the jfr Compose overlay re-specifies +# only context and dockerfile, so it inherits that arg from the base service. The `dev` default is +# deliberate and honest: this profiling image is never published, and a version-shaped default would +# start lying the moment the project version moved. +ARG APP_VERSION=dev + LABEL org.opencontainers.image.title="API Sheriff - JFR" LABEL org.opencontainers.image.description="Security-focused API Gateway — Quarkus native executable with JFR profiling" -LABEL org.opencontainers.image.version="0.1.0-SNAPSHOT" +LABEL org.opencontainers.image.version="${APP_VERSION}" LABEL org.opencontainers.image.vendor="CUIoss" LABEL org.opencontainers.image.licenses="Apache-2.0" LABEL org.opencontainers.image.source="https://github.com/cuioss/API-Sheriff" diff --git a/doc/user/container-image.adoc b/doc/user/container-image.adoc index 85d67307..8864bf6e 100644 --- a/doc/user/container-image.adoc +++ b/doc/user/container-image.adoc @@ -68,6 +68,34 @@ for you but works for a maintainer, this is the reason -- the field to check is `visibility`, and only the literal value `public` serves anonymous consumers. ==== +=== Reading the source commit off the image + +The published image carries the same commit in an OCI label, so you can recover it from an image you +already hold without knowing which tag it was pulled under: + +[source,bash] +---- +docker image inspect \ + --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' \ + ghcr.io/cuioss/api-sheriff@sha256:... +---- + +The value is the full 40-character SHA of the release-tag commit -- the same commit +`ghcr.io/cuioss/api-sheriff:sha-` names. `org.opencontainers.image.version` sits beside it +and carries the released version. The release lane reads both off the *pulled* image and fails the +release when either disagrees with what it just published, so a released image whose labels lie does +not ship. + +*This is a convenience, not a security control.* An image label is ordinary image metadata: anyone +who can build an image can write any value into it, and reading one back proves nothing about where +the image came from. Provenance authority remains the Cosign certificate -- verify it as described +under <<_verify_the_signature_before_deploying,Verify the signature before deploying>> and treat the +label as a lookup aid once that verification has passed. + +A locally or PR-built image carries `dev` in both labels. That default is deliberate: such an image +is never published, and a version- or SHA-shaped placeholder would read as a claim about a release +that does not exist. + == Pull and run The image is distroless: it carries no shell and no package manager, and it runs as `nonroot`. It diff --git a/integration-tests/docker-compose.yml b/integration-tests/docker-compose.yml index 359a4238..96f672f5 100644 --- a/integration-tests/docker-compose.yml +++ b/integration-tests/docker-compose.yml @@ -170,10 +170,14 @@ services: dockerfile: src/main/docker/Dockerfile.native # The harness build is the ONE image build in this project — the release lane runs this very # compose build and pushes exactly the digest it produced — so this is the single supply point - # for the image's org.opencontainers.image.version label. The release lane exports - # APP_VERSION= into the Maven step's environment; locally it stays `dev`. + # for the image's org.opencontainers.image.version and .revision labels. release.yml performs + # no docker build of its own, which makes these build.args the ONLY channel reaching the + # Dockerfile ARGs. The release lane exports APP_VERSION= and + # APP_REVISION= into the Maven step's environment; locally both stay + # `dev`. args: APP_VERSION: ${APP_VERSION:-dev} + APP_REVISION: ${APP_REVISION:-dev} cache_from: - quay.io/quarkus/quarkus-distroless-image:2.0 - quay.io/quarkus/ubi9-quarkus-micro-image:2.0 diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml index ead2a536..36f7220c 100644 --- a/integration-tests/pom.xml +++ b/integration-tests/pom.xml @@ -238,6 +238,14 @@ **/integration/**/*IT.java + + + **/ImageMetadataJfrIT.java + ${test.https.port} + + **/ImageMetadataIT.java + ${test.https.port} org.jboss.logmanager.LogManager @@ -447,6 +464,24 @@ ${project.basedir} + + + prepare-jfr-output-dir + pre-integration-test + + exec + + + ./scripts/prepare-jfr-output-dir.sh + ${project.basedir} + + ${project.basedir} + + + docker-build-jfr diff --git a/integration-tests/scripts/prepare-jfr-output-dir.sh b/integration-tests/scripts/prepare-jfr-output-dir.sh new file mode 100755 index 00000000..3a2ba551 --- /dev/null +++ b/integration-tests/scripts/prepare-jfr-output-dir.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Prepare the host directory that docker-compose.jfr.yml bind-mounts at /tmp/jfr-output. +# +# The JFR image runs as uid 1001, and the Dockerfile's own `chmod 777 /tmp/jfr-output` does NOT +# survive the mount — a bind mount shadows the image's directory with the host's, ownership +# included. Whichever `docker compose` command touches the service first creates the missing host +# directory as root:root 0755, after which uid 1001 cannot write and the gateway dies at startup +# with "Could not start recording, not able to write to file /tmp/jfr-output/api-sheriff-profile.jfr". +# That is a startup failure, not a degraded profile: the JFR lane never comes up at all. +# +# So this must run before ANY compose command that resolves the jfr overlay. There are two such +# entry points and each is genuinely the first toucher on its own path, which is why both call +# this one script rather than open-coding it: +# - the `jfr` Maven profile, whose `docker-build-jfr` execution composes the overlay to build; +# - start-integration-container.sh, for a lane that composes the overlay without that execution +# having run (the benchmark lane reuses an already-present api-sheriff:jfr image). +# +# Mode 1777, not 0777, for the same reason the /logs mount uses it: the sticky bit keeps the world +# write from also being a world DELETE of the recording this lane exists to produce. +set -euo pipefail + +JFR_TARGET_DIR="${1:?usage: prepare-jfr-output-dir.sh }/target/jfr-recordings" + +mkdir -p "${JFR_TARGET_DIR}" +chmod 1777 "${JFR_TARGET_DIR}" +echo "📁 JFR recordings will be written to: ${JFR_TARGET_DIR}/api-sheriff-profile.jfr" diff --git a/integration-tests/scripts/start-integration-container.sh b/integration-tests/scripts/start-integration-container.sh index 1011a0d4..a2c3a248 100755 --- a/integration-tests/scripts/start-integration-container.sh +++ b/integration-tests/scripts/start-integration-container.sh @@ -94,6 +94,14 @@ mkdir -p "${LOG_TARGET_DIR}" chmod 1777 "${LOG_TARGET_DIR}" echo "📁 Quarkus logs will be written to: ${LOG_TARGET_DIR}/quarkus.log" +# The JFR overlay bind-mounts ./target/jfr-recordings at /tmp/jfr-output, and that host directory +# must exist and be container-writable before the first compose command resolves the overlay — +# see scripts/prepare-jfr-output-dir.sh for why, and for why both this script and the `jfr` Maven +# profile call it. Only the JFR overlay needs it, so it runs only when that overlay is composed. +if [[ "$IMAGE_TYPE" == "jfr" ]]; then + ./scripts/prepare-jfr-output-dir.sh "${PROJECT_DIR}" +fi + # Discover every host-side probe target from the resolved Compose model, BEFORE anything is started. # # The service set, each service's published management port, and the scheme its management interface diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/ImageLabelActivationWiringTest.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/ImageLabelActivationWiringTest.java new file mode 100644 index 00000000..843ce40a --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/ImageLabelActivationWiringTest.java @@ -0,0 +1,183 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.yaml.snakeyaml.Yaml; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Fast, no-Docker surefire guard that the committed Compose descriptor actually + * activates the build args the image's OCI provenance labels are emitted from. + *

+ * This pins a hop the image itself cannot show. {@code ImageMetadataIT} reads the labels off the built + * image and proves the values arrived, but it can only run where a native image has been built and + * Docker is available. This test covers the one link in the chain that is pure declaration: the + * release lane performs no {@code docker build} of its own — the harness Compose build is the + * single image build — so the {@code api-sheriff} service's {@code build.args} is the ONLY channel + * reaching the Dockerfile's {@code ARG APP_VERSION} / {@code ARG APP_REVISION}. Drop an entry here and + * the Dockerfile's declared default silently wins: the released image would label itself {@code dev} + * while every step still succeeds. + *

+ * The asserted form is the passthrough literal {@code ${VAR:-dev}} rather than merely "the key is + * present". The default half is load-bearing in both directions: it is what keeps a local or PR build + * honestly labelled {@code dev}, and it is what {@code ImageMetadataIT} resolves its expected values + * against, so the two tests agree on one rule instead of two. + *

+ * The arg set is derived from {@code Dockerfile.native} at run time, never mirrored in + * a constant here. A hardcoded list cannot notice the one change that matters — growth. Add + * {@code ARG APP_CREATED} plus {@code LABEL org.opencontainers.image.created="${APP_CREATED}"} to the + * Dockerfile and forget the Compose {@code build.args} entry, and a mirrored list would still pass + * green while the published image silently took the Dockerfile's {@code dev} default. Deriving the + * expectation from the file that defines it makes that omission a failure instead. + *

+ * The asserted direction is "every label-backed {@code ARG} is forwarded". The converse — a + * {@code build.args} entry no label consumes — is deliberately not asserted: it is inert, whereas the + * missing direction is the silent-{@code dev} defect this guard exists for. + *

+ * It parses the committed descriptor and the committed Dockerfile only — it starts no container and + * reaches no network. + * + * @author API Sheriff Team + * @since 1.0 + */ +class ImageLabelActivationWiringTest { + + /** The module base directory (surefire runs with the module root as the working directory). */ + private static final Path MODULE = Path.of(System.getProperty("user.dir")); + + /** The service whose build supplies the published image. */ + private static final String IMAGE_SERVICE = "api-sheriff"; + + /** + * The Dockerfile that DEFINES the provenance-label arg set. The expected set is derived from this + * file at run time rather than mirrored in a constant here — see the class Javadoc. + */ + private static final Path DOCKERFILE = + MODULE.resolve("../api-sheriff/src/main/docker/Dockerfile.native").normalize(); + + /** Matches {@code ARG NAME} / {@code ARG NAME=default}, capturing the declared arg name. */ + private static final Pattern ARG_DECLARATION = + Pattern.compile("^\\s*ARG\\s+([A-Za-z_][A-Za-z0-9_]*)"); + + /** Matches a {@code LABEL} instruction line. */ + private static final Pattern LABEL_DECLARATION = Pattern.compile("^\\s*LABEL\\s"); + + /** Matches a {@code ${VAR}} reference, capturing the variable name. */ + private static final Pattern VARIABLE_REFERENCE = + Pattern.compile("\\$\\{([A-Za-z_][A-Za-z0-9_]*)[^}]*}"); + + @Test + @DisplayName("the api-sheriff build declares every provenance-label arg in the ${VAR:-dev} form") + void imageBuildDeclaresEveryProvenanceLabelArg() throws Exception { + Set labelArgs = labelBackedArgs(); + Map args = declaredBuildArgs(); + + // Vacuity guard on the DERIVED side: a parser that found nothing would make the loop below + // assert nothing at all, and the test would pass while checking no wiring whatsoever. + assertFalse(labelArgs.isEmpty(), + () -> "no label-backed ARG was derived from " + DOCKERFILE + " — either the parser broke" + + " or the Dockerfile moved. Until this resolves, the passthrough wiring below is" + + " unchecked, so this is a failure rather than a pass."); + + // The vacuity guard: an empty map would satisfy the per-key loop below by never running it. + assertFalse(args.isEmpty(), + "the '" + IMAGE_SERVICE + "' service must declare build.args — without them the" + + " Dockerfile's `dev` defaults win and the published image labels itself `dev`"); + + for (String arg : labelArgs) { + Object declared = args.get(arg); + assertNotNull(declared, () -> "the '" + IMAGE_SERVICE + "' service must declare build.args." + + arg + ": the release lane runs no docker build of its own, so build.args is the" + + " only channel reaching the Dockerfile ARG"); + assertEquals("${" + arg + ":-dev}", String.valueOf(declared), + () -> "build.args." + arg + " must use the ${" + arg + ":-dev} passthrough form so an" + + " unset or empty variable falls back to `dev` — a local or PR image must" + + " never carry a release-shaped value"); + } + } + + /** + * Derives the provenance-label arg set from {@link #DOCKERFILE}: the variables its {@code LABEL} + * instructions interpolate, intersected with the {@code ARG}s it declares. + *

+ * The intersection is what makes the set mean "label-backed build arg" rather than merely "some + * variable a label mentions" — a {@code LABEL} referencing an undeclared variable is not something + * Compose can forward, and an {@code ARG} no label consumes is not part of the provenance chain. + * + * @return the derived arg names, in declaration order + * @throws IOException when the Dockerfile cannot be read + */ + private static Set labelBackedArgs() throws IOException { + assertTrue(Files.isRegularFile(DOCKERFILE), + () -> "cannot derive the provenance-label arg set: " + DOCKERFILE + " does not exist." + + " This test resolves it relative to the module root (" + MODULE + "); if the" + + " Dockerfile moved, point DOCKERFILE at its new location rather than falling" + + " back to a hardcoded arg list."); + + Set declaredArgs = new LinkedHashSet<>(); + Set referencedByLabels = new LinkedHashSet<>(); + for (String line : Files.readAllLines(DOCKERFILE)) { + Matcher arg = ARG_DECLARATION.matcher(line); + if (arg.find()) { + declaredArgs.add(arg.group(1)); + } else if (LABEL_DECLARATION.matcher(line).find()) { + Matcher reference = VARIABLE_REFERENCE.matcher(line); + while (reference.find()) { + referencedByLabels.add(reference.group(1)); + } + } + } + referencedByLabels.retainAll(declaredArgs); + return referencedByLabels; + } + + @SuppressWarnings("unchecked") + private static Map declaredBuildArgs() throws IOException { + Map doc; + try (InputStream in = Files.newInputStream(MODULE.resolve("docker-compose.yml"))) { + doc = new Yaml().loadAs(in, Map.class); + } + Object services = doc.get("services"); + assertInstanceOf(Map.class, services, "docker-compose.yml must declare services"); + Object service = ((Map) services).get(IMAGE_SERVICE); + assertNotNull(service, "docker-compose.yml must declare the '" + IMAGE_SERVICE + "' service"); + Object build = ((Map) service).get("build"); + assertInstanceOf(Map.class, build, + "the '" + IMAGE_SERVICE + "' service must declare a build section"); + Object args = ((Map) build).get("args"); + assertInstanceOf(Map.class, args, + "the '" + IMAGE_SERVICE + "' service build.args must be a mapping, not a list — the mapping" + + " form is what lets a value carry the ${VAR:-dev} passthrough default"); + return (Map) args; + } +} diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/ImageLabelInspector.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/ImageLabelInspector.java new file mode 100644 index 00000000..4003b24b --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/ImageLabelInspector.java @@ -0,0 +1,107 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +/** + * Reads a single OCI label off a built image, shared by the per-image metadata ITs. + *

+ * The read is the same one {@code .github/workflows/release.yml} performs on the pulled digest in its + * smoke step: {@code docker image inspect --format} with a Go template indexing {@code .Config.Labels} + * by the label key. + *

+ * It lives here rather than in one IT because two lanes assert against two different images — the + * distroless image the default integration-test lane builds and the JFR image the {@code jfr} lane + * builds. Sharing the reader keeps the subprocess handling (below) stated once; duplicating it would + * let one copy silently regress. + * + * @author API Sheriff Team + * @since 1.0 + */ +final class ImageLabelInspector { + + /** The Compose passthrough default for the provenance args ({@code ${APP_VERSION:-dev}}). */ + static final String DEFAULT_VALUE = "dev"; + + private static final long INSPECT_TIMEOUT_SECONDS = 30L; + + private ImageLabelInspector() { + // utility + } + + /** + * Resolves a build variable the way the Compose {@code ${VAR:-dev}} passthrough does: an unset + * or empty value falls back to {@code dev}. + * + * @param variable the environment variable name + * @return the resolved value, never empty + */ + static String passthrough(String variable) { + String value = System.getenv(variable); + return value == null || value.isBlank() ? DEFAULT_VALUE : value; + } + + /** + * Reads one label off a built image. + *

+ * The process is waited on before its output is read, and that order is load-bearing. + * Reading first would make the timeout unreachable: {@code readAllBytes()} blocks until EOF, and + * EOF on the merged stream arrives only when the child closes stdout — so an unresponsive daemon + * would hang in the read and never reach the guard that exists to catch exactly that. Waiting + * first is safe here because the output is a single label value (or a short daemon error), orders + * of magnitude below the pipe buffer that would otherwise deadlock a read-after-wait; that bound + * is also why no reader thread is warranted. + * + * @param image the image reference to inspect + * @param label the OCI label key + * @return the label value, stripped; empty when the image does not carry the label + */ + static String inspectLabel(String image, String label) { + ProcessBuilder builder = new ProcessBuilder( + "docker", "image", "inspect", + "--format", "{{index .Config.Labels \"" + label + "\"}}", + image); + builder.redirectErrorStream(true); + try { + Process process = builder.start(); + if (!process.waitFor(Duration.ofSeconds(INSPECT_TIMEOUT_SECONDS))) { + process.destroyForcibly(); + fail(() -> "docker image inspect on " + image + " did not complete within " + + INSPECT_TIMEOUT_SECONDS + "s"); + } + String output; + try (InputStream in = process.getInputStream()) { + output = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + assertEquals(0, process.exitValue(), () -> "docker image inspect on " + image + + " failed — is the image built? Output: " + output.strip()); + return output.strip(); + } catch (IOException e) { + throw new UncheckedIOException("cannot run docker image inspect on " + image, e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted while inspecting " + image, e); + } + } +} diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/ImageMetadataIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/ImageMetadataIT.java new file mode 100644 index 00000000..16966c02 --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/ImageMetadataIT.java @@ -0,0 +1,112 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.integration; + +import static de.cuioss.sheriff.gateway.integration.ImageLabelInspector.inspectLabel; +import static de.cuioss.sheriff.gateway.integration.ImageLabelInspector.passthrough; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Reads the OCI provenance labels off the built image and fails when either one is + * dishonest. + *

+ * The assertion target is deliberately the image, never the Dockerfile text. A Dockerfile can declare + * {@code LABEL org.opencontainers.image.version="${APP_VERSION}"} perfectly while the value never + * arrives — the build arg is not plumbed, the Compose service does not forward it, the release lane + * does not export it — and every one of those breakages leaves the source reading correctly. Only the + * built artifact can answer what the label actually says. + *

+ * The read is the same one {@code .github/workflows/release.yml} performs on the pulled digest in its + * smoke step: {@code docker image inspect --format} with a Go template indexing + * {@code .Config.Labels} by the label key. This IT runs it at PR time against the locally built + * {@value #IMAGE}, so a break in the arg chain surfaces on the pull request rather than during a + * release. + *

+ * The expected values are resolved exactly as the Compose passthrough resolves them — + * {@code ${APP_VERSION:-dev}} and {@code ${APP_REVISION:-dev}}, where an unset or empty + * variable falls back to {@code dev}. On a local or PR run both are {@code dev}; in the release lane + * they are the released version and the release-tag commit. + *

+ * The third test is a negative control. Both positive legs assert a specific non-empty value, and a + * helper that returned the empty string for everything would fail them — but a helper that returned + * some constant non-empty string would not be caught by them alone. Asserting that a label the image + * does not carry reads back empty proves the helper discriminates between a present and an absent + * label, so the positive legs are reading real metadata. + * + * @author API Sheriff Team + * @since 1.0 + */ +@DisplayName("Container image OCI metadata") +class ImageMetadataIT { + + /** The image the integration-test harness builds and the release lane publishes. */ + private static final String IMAGE = "api-sheriff:distroless"; + + private static final String VERSION_LABEL = "org.opencontainers.image.version"; + private static final String REVISION_LABEL = "org.opencontainers.image.revision"; + + /** + * A label no image in this project declares. Fixed rather than generated: the control is only + * meaningful while the name is guaranteed absent, and a generated name could collide with a label + * the base image carries. + */ + private static final String ABSENT_LABEL = "de.cuioss.sheriff.no-such-label"; + + @Test + @DisplayName("the version label carries the version the build was given") + void versionLabelCarriesTheBuildVersion() { + String expected = passthrough("APP_VERSION"); + + String actual = inspectLabel(IMAGE, VERSION_LABEL); + + assertFalse(actual.isEmpty(), () -> IMAGE + " carries no " + VERSION_LABEL + + " — the label is absent or the APP_VERSION build arg never reached the Dockerfile"); + assertEquals(expected, actual, () -> VERSION_LABEL + " on " + IMAGE + " is '" + actual + + "' but the build was given APP_VERSION='" + expected + + "'. The image is stating a version it was not built at."); + } + + @Test + @DisplayName("the revision label carries the commit the build was given") + void revisionLabelCarriesTheBuildRevision() { + String expected = passthrough("APP_REVISION"); + + String actual = inspectLabel(IMAGE, REVISION_LABEL); + + assertFalse(actual.isEmpty(), () -> IMAGE + " carries no " + REVISION_LABEL + + " — the label is absent or the APP_REVISION build arg never reached the Dockerfile." + + " Check the api-sheriff service's build.args in docker-compose.yml: the release lane" + + " runs no docker build of its own, so build.args is the only channel to the ARG."); + assertEquals(expected, actual, () -> REVISION_LABEL + " on " + IMAGE + " is '" + actual + + "' but the build was given APP_REVISION='" + expected + + "'. The image is naming a commit it was not built from."); + } + + @Test + @DisplayName("a label the image does not carry reads back empty — the inspect helper discriminates") + void absentLabelReadsBackEmpty() { + String actual = inspectLabel(IMAGE, ABSENT_LABEL); + + assertTrue(actual.isEmpty(), () -> "inspecting the absent label " + ABSENT_LABEL + " on " + IMAGE + + " returned '" + actual + "' rather than an empty result. The helper is not reading the" + + " label map, so the version and revision assertions above prove nothing."); + } +} diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/ImageMetadataJfrIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/ImageMetadataJfrIT.java new file mode 100644 index 00000000..a28b047c --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/ImageMetadataJfrIT.java @@ -0,0 +1,97 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.integration; + +import static de.cuioss.sheriff.gateway.integration.ImageLabelInspector.inspectLabel; +import static de.cuioss.sheriff.gateway.integration.ImageLabelInspector.passthrough; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The JFR image's counterpart to {@link ImageMetadataIT}: reads the version label off the + * built {@value #IMAGE} and fails when it is dishonest. + *

+ * This runs in the {@code jfr} Maven profile, the lane that actually builds this image — its + * {@code docker-build-jfr} execution runs + * {@code docker compose -f docker-compose.yml -f docker-compose.jfr.yml build api-sheriff} at + * {@code pre-integration-test}. The overlay re-specifies only {@code image}, {@code build.context} and + * {@code build.dockerfile}, so it INHERITS {@code build.args.APP_VERSION} from the base service by + * mapping merge, and that arg reaches {@code Dockerfile.native.jfr}'s {@code ARG APP_VERSION}. The + * whole chain is therefore machine-assertable here, and this test is what asserts it — the + * {@code jfr} profile excludes {@link ImageMetadataIT} only because that one is pinned to the + * distroless tag this lane never produces, not because the JFR image's labels go unchecked. + *

+ * The asserted set is deliberately narrower than {@link ImageMetadataIT}'s. + * {@code Dockerfile.native.jfr} declares {@code ARG APP_VERSION} and the matching version label and + * nothing for revision — no {@code APP_REVISION} arg, no + * {@code org.opencontainers.image.revision} label — because this profiling image is never published + * and carries no release provenance. Asserting a revision leg here would assert a label the image is + * not meant to have. + *

+ * The second test is the same negative control {@link ImageMetadataIT} carries, for the same reason: a + * helper returning a constant non-empty string would satisfy the positive leg alone, so proving it + * reads back empty for a label the image does not carry is what makes the positive leg evidence. + * + * @author API Sheriff Team + * @since 1.0 + */ +@DisplayName("JFR container image OCI metadata") +class ImageMetadataJfrIT { + + /** The image the {@code jfr} profile builds and tags. */ + private static final String IMAGE = "api-sheriff:jfr"; + + private static final String VERSION_LABEL = "org.opencontainers.image.version"; + + /** + * A label no image in this project declares. Fixed rather than generated: the control is only + * meaningful while the name is guaranteed absent, and a generated name could collide with a label + * the base image carries. + */ + private static final String ABSENT_LABEL = "de.cuioss.sheriff.no-such-label"; + + @Test + @DisplayName("the JFR image's version label carries the version the build was given") + void versionLabelCarriesTheBuildVersion() { + String expected = passthrough("APP_VERSION"); + + String actual = inspectLabel(IMAGE, VERSION_LABEL); + + assertFalse(actual.isEmpty(), () -> IMAGE + " carries no " + VERSION_LABEL + + " — the label is absent or the APP_VERSION build arg never reached" + + " Dockerfile.native.jfr. The jfr Compose overlay re-specifies only image, context and" + + " dockerfile, so it inherits build.args from the api-sheriff service in" + + " docker-compose.yml; a build.args entry removed there silently disarms this image" + + " too."); + assertEquals(expected, actual, () -> VERSION_LABEL + " on " + IMAGE + " is '" + actual + + "' but the build was given APP_VERSION='" + expected + + "'. The image is stating a version it was not built at."); + } + + @Test + @DisplayName("a label the JFR image does not carry reads back empty — the inspect helper discriminates") + void absentLabelReadsBackEmpty() { + String actual = inspectLabel(IMAGE, ABSENT_LABEL); + + assertTrue(actual.isEmpty(), () -> "inspecting the absent label " + ABSENT_LABEL + " on " + IMAGE + + " returned '" + actual + "' rather than an empty result. The helper is not reading the" + + " label map, so the version assertion above proves nothing."); + } +}