From 67e6b17658d17f78b5410625c6e512260cde5ce7 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:25:42 +0200 Subject: [PATCH 1/7] fix(image): retire the hard-coded version literal on the JFR Dockerfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The profiling image pinned org.opencontainers.image.version to the literal 0.1.0-SNAPSHOT, so the label started lying the moment the project version moved. Replace it with the distroless sibling's treatment: an ARG APP_VERSION=dev declared between FROM and the LABEL block, emitted as ${APP_VERSION} in the position the old literal occupied. The jfr Compose overlay re-specifies only context and dockerfile, so it inherits the base service's build.args.APP_VERSION — no pom or Compose edit is required. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CWMcYKQuv3uo5RiNZEin5w --- api-sheriff/src/main/docker/Dockerfile.native.jfr | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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" From fdd9667c163dc7373b00e2cf5a0397f742e0b603 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:34:04 +0200 Subject: [PATCH 2/7] feat(image): label the published image with its release-tag commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The published image carried no org.opencontainers.image.revision, so an operator holding a digest could recover the source commit only through the mutable sha- tag. Add ARG APP_REVISION=dev beside APP_VERSION on the distroless Dockerfile and emit the revision label from it. release.yml performs no docker build of its own — the harness compose build is the one image build in the lane — so the Compose service's build.args is the only channel reaching the ARG. Plumb APP_REVISION through it and feed it the release-tag commit already resolved by the `commit` step. The smoke step gains a revision leg symmetric with the landed version leg: it reads the label off the pulled digest and fails the release when it differs from the release-tag commit, so a released image whose labels lie does not ship. The operator guide documents the label under "Which tag to use", stating plainly that it is a convenience and not a security control — provenance authority remains the Cosign certificate. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CWMcYKQuv3uo5RiNZEin5w --- .github/workflows/release.yml | 14 ++++++++-- api-sheriff/src/main/docker/Dockerfile.native | 9 ++++-- doc/user/container-image.adoc | 28 +++++++++++++++++++ integration-tests/docker-compose.yml | 8 ++++-- 4 files changed, 52 insertions(+), 7 deletions(-) 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/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 From 47fde03049a79dfd0c5866461803990a20d1f369 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:45:28 +0200 Subject: [PATCH 3/7] test(image): assert both OCI provenance labels on the built image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing read the version or revision label back off the image, so the whole arg chain — Compose build.args to Dockerfile ARG to LABEL — could break with every step still succeeding and the release lane still going green. ImageMetadataIT reads both labels off the built api-sheriff:distroless image with the same `docker image inspect` the release smoke step performs, never off Dockerfile text: a declaration that reads correctly proves nothing about what arrived. It resolves the expected values exactly as the Compose ${VAR:-dev} passthrough does, and carries a negative control — a label the image does not carry must read back empty — so the positive legs cannot pass on a helper that returns a constant. ImageLabelActivationWiringTest pins the one hop the image cannot show: a fast no-Docker surefire guard that the api-sheriff service declares both args in the ${VAR:-dev} form. That hop is load-bearing because release.yml runs no docker build of its own. It carries the sibling wiring tests' vacuity guard. The jfr profile excludes ImageMetadataIT: that lane tags api-sheriff:jfr, so the IT would inspect an image the profile never builds. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CWMcYKQuv3uo5RiNZEin5w --- integration-tests/pom.xml | 8 + .../ImageLabelActivationWiringTest.java | 125 +++++++++++++ .../gateway/integration/ImageMetadataIT.java | 166 ++++++++++++++++++ 3 files changed, 299 insertions(+) create mode 100644 integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/ImageLabelActivationWiringTest.java create mode 100644 integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/ImageMetadataIT.java diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml index ead2a536..4b6baaa5 100644 --- a/integration-tests/pom.xml +++ b/integration-tests/pom.xml @@ -420,6 +420,14 @@ **/integration/**/*IT.java + + + **/ImageMetadataIT.java + ${test.https.port} org.jboss.logmanager.LogManager 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..7c3959dd --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/ImageLabelActivationWiringTest.java @@ -0,0 +1,125 @@ +/* + * 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 java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +/** + * 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. + *

+ * It parses the committed descriptor 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 build args carrying the OCI provenance label values into the Dockerfile. */ + private static final List LABEL_ARGS = List.of("APP_VERSION", "APP_REVISION"); + + @Test + @DisplayName("the api-sheriff build declares every provenance-label arg in the ${VAR:-dev} form") + void imageBuildDeclaresEveryProvenanceLabelArg() throws IOException { + Map args = buildArgs(IMAGE_SERVICE); + + // 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 : LABEL_ARGS) { + 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"); + } + } + + /** + * The declared {@code build.args} map of one Compose service. + * + * @param service the Compose service name + * @return the declared build args, never {@code null} + * @throws IOException when the descriptor cannot be read + */ + @SuppressWarnings("unchecked") + private static Map buildArgs(String service) throws IOException { + Object node = composeServices().get(service); + assertNotNull(node, "docker-compose.yml must declare the '" + service + "' service"); + Map serviceMap = (Map) node; + Object build = serviceMap.get("build"); + assertInstanceOf(Map.class, build, "the '" + service + "' service must declare a build section"); + Object args = ((Map) build).get("args"); + assertInstanceOf(Map.class, args, + "the '" + 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; + } + + @SuppressWarnings("unchecked") + private static Map composeServices() throws IOException { + Map doc = loadYaml(MODULE.resolve("docker-compose.yml")); + Object services = doc.get("services"); + assertInstanceOf(Map.class, services, "docker-compose.yml must declare services"); + return (Map) services; + } + + @SuppressWarnings("unchecked") + private static Map loadYaml(Path path) throws IOException { + try (InputStream in = Files.newInputStream(path)) { + return new Yaml().loadAs(in, Map.class); + } + } +} 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..5818ced7 --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/ImageMetadataIT.java @@ -0,0 +1,166 @@ +/* + * 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.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; + +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. */ + 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"; + + /** The Compose passthrough default for both args ({@code ${APP_VERSION:-dev}}). */ + private static final String DEFAULT_VALUE = "dev"; + + private static final long INSPECT_TIMEOUT_SECONDS = 30L; + + @Test + @DisplayName("the version label carries the version the build was given") + void versionLabelCarriesTheBuildVersion() { + String expected = passthrough("APP_VERSION"); + + String actual = inspectLabel(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(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(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."); + } + + /** + * 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 + */ + private static String passthrough(String variable) { + String value = System.getenv(variable); + return value == null || value.isBlank() ? DEFAULT_VALUE : value; + } + + /** + * Reads one label off the built image via {@code docker image inspect}, the same read the release + * lane's smoke step performs on the published digest. + * + * @param label the OCI label key + * @return the label value, stripped; empty when the image does not carry the label + */ + private static String inspectLabel(String label) { + ProcessBuilder builder = new ProcessBuilder( + "docker", "image", "inspect", + "--format", "{{index .Config.Labels \"" + label + "\"}}", + IMAGE); + builder.redirectErrorStream(true); + try { + Process process = builder.start(); + String output; + try (InputStream in = process.getInputStream()) { + output = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + assertTrue(process.waitFor(INSPECT_TIMEOUT_SECONDS, TimeUnit.SECONDS), + () -> "docker image inspect on " + IMAGE + " did not complete within " + + INSPECT_TIMEOUT_SECONDS + "s"); + 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); + } + } +} From 0f7732b76606ceb2fa28c7cb4392208936d4ad82 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:53:06 +0200 Subject: [PATCH 4/7] style(image): apply the gate's OpenRewrite normalization to the new tests The pre-commit gate rewrites sources while exiting 0, so the two files added by the previous commit came back modified: Process.waitFor now takes the Duration overload, the SnakeYAML import moves to the sibling wiring tests' ordering, and the wiring test's throws clause widens. Committing the rewrite is what makes the next gate run idempotent instead of dirtying the tree again. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CWMcYKQuv3uo5RiNZEin5w --- .../gateway/integration/ImageLabelActivationWiringTest.java | 4 ++-- .../cuioss/sheriff/gateway/integration/ImageMetadataIT.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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 index 7c3959dd..d47fa283 100644 --- 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 @@ -26,10 +26,10 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; +import org.yaml.snakeyaml.Yaml; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.yaml.snakeyaml.Yaml; /** * Fast, no-Docker surefire guard that the committed Compose descriptor actually @@ -67,7 +67,7 @@ class ImageLabelActivationWiringTest { @Test @DisplayName("the api-sheriff build declares every provenance-label arg in the ${VAR:-dev} form") - void imageBuildDeclaresEveryProvenanceLabelArg() throws IOException { + void imageBuildDeclaresEveryProvenanceLabelArg() throws Exception { Map args = buildArgs(IMAGE_SERVICE); // The vacuity guard: an empty map would satisfy the per-key loop below by never running it. 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 index 5818ced7..2574fbe0 100644 --- 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 @@ -23,7 +23,7 @@ import java.io.InputStream; import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; -import java.util.concurrent.TimeUnit; +import java.time.Duration; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -150,7 +150,7 @@ private static String inspectLabel(String label) { try (InputStream in = process.getInputStream()) { output = new String(in.readAllBytes(), StandardCharsets.UTF_8); } - assertTrue(process.waitFor(INSPECT_TIMEOUT_SECONDS, TimeUnit.SECONDS), + assertTrue(process.waitFor(Duration.ofSeconds(INSPECT_TIMEOUT_SECONDS)), () -> "docker image inspect on " + IMAGE + " did not complete within " + INSPECT_TIMEOUT_SECONDS + "s"); assertEquals(0, process.exitValue(), () -> "docker image inspect on " + IMAGE From 63ac3b0027cd2accc5bb7bd56037a25c18346811 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:37:39 +0200 Subject: [PATCH 5/7] chore(simplify): collapse accidental complexity in the image-metadata tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the three-deep single-caller helper ladder in ImageLabelActivationWiringTest into one no-arg helper, narrow the test's throws clause to IOException, and make ImageMetadataIT's IMAGE constant private. The negative-control leg and the vacuity guard are deliberately kept — they are the anti-vacuity controls the deliverable exists to provide. Co-Authored-By: Claude --- .../ImageLabelActivationWiringTest.java | 50 +++++++------------ .../gateway/integration/ImageMetadataIT.java | 2 +- 2 files changed, 18 insertions(+), 34 deletions(-) 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 index d47fa283..710c4a8c 100644 --- 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 @@ -26,10 +26,10 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; -import org.yaml.snakeyaml.Yaml; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; /** * Fast, no-Docker surefire guard that the committed Compose descriptor actually @@ -67,8 +67,8 @@ class ImageLabelActivationWiringTest { @Test @DisplayName("the api-sheriff build declares every provenance-label arg in the ${VAR:-dev} form") - void imageBuildDeclaresEveryProvenanceLabelArg() throws Exception { - Map args = buildArgs(IMAGE_SERVICE); + void imageBuildDeclaresEveryProvenanceLabelArg() throws IOException { + Map args = declaredBuildArgs(); // The vacuity guard: an empty map would satisfy the per-key loop below by never running it. assertFalse(args.isEmpty(), @@ -87,39 +87,23 @@ void imageBuildDeclaresEveryProvenanceLabelArg() throws Exception { } } - /** - * The declared {@code build.args} map of one Compose service. - * - * @param service the Compose service name - * @return the declared build args, never {@code null} - * @throws IOException when the descriptor cannot be read - */ @SuppressWarnings("unchecked") - private static Map buildArgs(String service) throws IOException { - Object node = composeServices().get(service); - assertNotNull(node, "docker-compose.yml must declare the '" + service + "' service"); - Map serviceMap = (Map) node; - Object build = serviceMap.get("build"); - assertInstanceOf(Map.class, build, "the '" + service + "' service must declare a build section"); + 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 '" + 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"); + "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; } - - @SuppressWarnings("unchecked") - private static Map composeServices() throws IOException { - Map doc = loadYaml(MODULE.resolve("docker-compose.yml")); - Object services = doc.get("services"); - assertInstanceOf(Map.class, services, "docker-compose.yml must declare services"); - return (Map) services; - } - - @SuppressWarnings("unchecked") - private static Map loadYaml(Path path) throws IOException { - try (InputStream in = Files.newInputStream(path)) { - return new Yaml().loadAs(in, Map.class); - } - } } 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 index 2574fbe0..7c099184 100644 --- 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 @@ -62,7 +62,7 @@ class ImageMetadataIT { /** The image the integration-test harness builds and the release lane publishes. */ - static final String IMAGE = "api-sheriff:distroless"; + 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"; From 4e89a06705e2fe439ea465517751d0cd796a685b Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:49:07 +0200 Subject: [PATCH 6/7] style(image): apply the gate's OpenRewrite normalization to the wiring test The pre-push quality gate's OpenRewrite pass reverted two of the simplify sweep's edits on ImageLabelActivationWiringTest: it restored the `throws Exception` signature and moved the SnakeYAML import back above the JUnit block. The gate is the authority on both, so its output is taken rather than re-applied against it. Re-verified: the gate is idempotent on the resulting tree. Co-Authored-By: Claude --- .../gateway/integration/ImageLabelActivationWiringTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 710c4a8c..7f5d5a43 100644 --- 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 @@ -26,10 +26,10 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; +import org.yaml.snakeyaml.Yaml; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.yaml.snakeyaml.Yaml; /** * Fast, no-Docker surefire guard that the committed Compose descriptor actually @@ -67,7 +67,7 @@ class ImageLabelActivationWiringTest { @Test @DisplayName("the api-sheriff build declares every provenance-label arg in the ${VAR:-dev} form") - void imageBuildDeclaresEveryProvenanceLabelArg() throws IOException { + void imageBuildDeclaresEveryProvenanceLabelArg() throws Exception { Map args = declaredBuildArgs(); // The vacuity guard: an empty map would satisfy the per-key loop below by never running it. From f312bf4a27d2855dc57882fd3cea043254e88b02 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:39:01 +0200 Subject: [PATCH 7/7] fix(image): address PR review and make the JFR lane able to run at all Three CodeRabbit findings on #199, plus the lane fix the third one exposed. The timeout guard in the label reader was dead code: readAllBytes() ran before waitFor(), so it blocked until EOF and the timeout could only be evaluated once the process had already exited. Reordered to wait -> destroyForcibly on expiry -> read, and moved into a shared ImageLabelInspector so the ordering is stated once rather than duplicated into a copy that could silently regress. ImageLabelActivationWiringTest no longer hardcodes the arg set it checks; it derives it from Dockerfile.native at run time (LABEL-interpolated variables intersected with declared ARGs), which is what makes it catch growth. Verified in both directions: adding an APP_CREATED arg+label to the Dockerfile failed the test with the intended message, and the control was reverted. The pom comment justifying the JFR exclusion claimed no automated run builds that image. That is false about the very profile it sat in, so the justification is replaced and ImageMetadataJfrIT now asserts the JFR image's version label plus an absent-label control. It records why there is no revision leg: Dockerfile.native.jfr declares no APP_REVISION. Running that IT required fixing the JFR lane, which could not start at all. docker-compose.jfr.yml bind-mounts ./target/jfr-recordings at /tmp/jfr-output; whichever compose command touched the service first created that directory as root, and the uid-1001 container then could not write the recording, so the gateway died at startup. The new prepare-jfr-output-dir.sh pre-creates it 1777 -- sticky, so the world write is not also a world delete of the recording -- and is called from both entry points that compose the overlay. ImageMetadataJfrIT passes (2/2). MtlsHandshakeIT fails 2/3 in this lane, which the fix unmasks rather than causes: the same tests pass 3/3 under -Pintegration-tests on the identical tree. Filed as #201. Co-Authored-By: Claude --- integration-tests/pom.xml | 31 ++++- .../scripts/prepare-jfr-output-dir.sh | 26 +++++ .../scripts/start-integration-container.sh | 8 ++ .../ImageLabelActivationWiringTest.java | 84 +++++++++++++- .../integration/ImageLabelInspector.java | 107 ++++++++++++++++++ .../gateway/integration/ImageMetadataIT.java | 64 +---------- .../integration/ImageMetadataJfrIT.java | 97 ++++++++++++++++ 7 files changed, 351 insertions(+), 66 deletions(-) create mode 100755 integration-tests/scripts/prepare-jfr-output-dir.sh create mode 100644 integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/ImageLabelInspector.java create mode 100644 integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/ImageMetadataJfrIT.java diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml index 4b6baaa5..36f7220c 100644 --- a/integration-tests/pom.xml +++ b/integration-tests/pom.xml @@ -238,6 +238,14 @@ **/integration/**/*IT.java + + + **/ImageMetadataJfrIT.java + ${test.https.port} + own version label IS machine-asserted, by ImageMetadataJfrIT, + which runs in this lane against the image the docker-build-jfr + execution below builds. --> **/ImageMetadataIT.java @@ -455,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 index 7f5d5a43..843ce40a 100644 --- 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 @@ -19,13 +19,17 @@ 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.List; +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; @@ -49,7 +53,19 @@ * 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. *

- * It parses the committed descriptor only — it starts no container and reaches no network. + * 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 @@ -62,20 +78,43 @@ class ImageLabelActivationWiringTest { /** The service whose build supplies the published image. */ private static final String IMAGE_SERVICE = "api-sheriff"; - /** The build args carrying the OCI provenance label values into the Dockerfile. */ - private static final List LABEL_ARGS = List.of("APP_VERSION", "APP_REVISION"); + /** + * 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 : LABEL_ARGS) { + 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" @@ -87,6 +126,41 @@ void imageBuildDeclaresEveryProvenanceLabelArg() throws Exception { } } + /** + * 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; 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 index 7c099184..16966c02 100644 --- 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 @@ -15,16 +15,12 @@ */ 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 java.io.IOException; -import java.io.InputStream; -import java.io.UncheckedIOException; -import java.nio.charset.StandardCharsets; -import java.time.Duration; - import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -74,17 +70,12 @@ class ImageMetadataIT { */ private static final String ABSENT_LABEL = "de.cuioss.sheriff.no-such-label"; - /** The Compose passthrough default for both args ({@code ${APP_VERSION:-dev}}). */ - private static final String DEFAULT_VALUE = "dev"; - - private static final long INSPECT_TIMEOUT_SECONDS = 30L; - @Test @DisplayName("the version label carries the version the build was given") void versionLabelCarriesTheBuildVersion() { String expected = passthrough("APP_VERSION"); - String actual = inspectLabel(VERSION_LABEL); + 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"); @@ -98,7 +89,7 @@ void versionLabelCarriesTheBuildVersion() { void revisionLabelCarriesTheBuildRevision() { String expected = passthrough("APP_REVISION"); - String actual = inspectLabel(REVISION_LABEL); + 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." @@ -112,55 +103,10 @@ void revisionLabelCarriesTheBuildRevision() { @Test @DisplayName("a label the image does not carry reads back empty — the inspect helper discriminates") void absentLabelReadsBackEmpty() { - String actual = inspectLabel(ABSENT_LABEL); + 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."); } - - /** - * 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 - */ - private static String passthrough(String variable) { - String value = System.getenv(variable); - return value == null || value.isBlank() ? DEFAULT_VALUE : value; - } - - /** - * Reads one label off the built image via {@code docker image inspect}, the same read the release - * lane's smoke step performs on the published digest. - * - * @param label the OCI label key - * @return the label value, stripped; empty when the image does not carry the label - */ - private static String inspectLabel(String label) { - ProcessBuilder builder = new ProcessBuilder( - "docker", "image", "inspect", - "--format", "{{index .Config.Labels \"" + label + "\"}}", - IMAGE); - builder.redirectErrorStream(true); - try { - Process process = builder.start(); - String output; - try (InputStream in = process.getInputStream()) { - output = new String(in.readAllBytes(), StandardCharsets.UTF_8); - } - assertTrue(process.waitFor(Duration.ofSeconds(INSPECT_TIMEOUT_SECONDS)), - () -> "docker image inspect on " + IMAGE + " did not complete within " - + INSPECT_TIMEOUT_SECONDS + "s"); - 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/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."); + } +}