From 5f7bd49d0220f6b340a86c007173723554019a22 Mon Sep 17 00:00:00 2001 From: Yuma Takino <36617106+tappe9@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:04:48 +0900 Subject: [PATCH 01/15] test: prepare isolated Java development evidence --- .github/workflows/java-development.yml | 49 ++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/workflows/java-development.yml diff --git a/.github/workflows/java-development.yml b/.github/workflows/java-development.yml new file mode 100644 index 0000000..08cd14b --- /dev/null +++ b/.github/workflows/java-development.yml @@ -0,0 +1,49 @@ +name: Java development +on: + push: + branches: [feat/add-java-spring-boot] +permissions: + contents: read +concurrency: + group: java-development-67 + cancel-in-progress: false +jobs: + inspect: + if: github.repository == 'tappe9/simple-api-benchmark' && github.ref == 'refs/heads/feat/add-java-spring-boot' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Export exact source and public upstream metadata + shell: bash + run: | + set -euo pipefail + mkdir -p .cache/java-development + git bundle create .cache/java-development/source.bundle --all + git rev-parse HEAD HEAD^{tree} > .cache/java-development/source-identity.txt + curl --fail --silent --show-error --location --max-time 60 https://repo.maven.apache.org/maven2/org/springframework/boot/spring-boot-dependencies/maven-metadata.xml -o .cache/java-development/spring-boot-metadata.xml + curl --fail --silent --show-error --location --max-time 60 https://services.gradle.org/versions/current -o .cache/java-development/gradle-current.json + curl --fail --silent --show-error --location --max-time 60 'https://api.adoptium.net/v3/assets/feature_releases/25/ga?architecture=x64&heap_size=normal&image_type=jdk&jvm_impl=hotspot&os=linux&page_size=1&project=jdk&sort_order=DESC&vendor=eclipse' -o .cache/java-development/temurin-release.json + python3 - <<'PY' + import json + import pathlib + import xml.etree.ElementTree as ET + root = pathlib.Path('.cache/java-development') + print('Spring Boot 4.1:', [e.text for e in ET.parse(root / 'spring-boot-metadata.xml').findall('.//version') if e.text and e.text.startswith('4.1.')]) + print('Gradle:', json.loads((root / 'gradle-current.json').read_text())) + release = json.loads((root / 'temurin-release.json').read_text())[0] + print('Temurin:', release['release_name'], release['version_data']) + print('Package:', release['binaries'][0]['package']) + PY + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: always() + with: + name: java-development-${{ github.run_id }}-${{ github.run_attempt }} + path: .cache/java-development/ + include-hidden-files: true + retention-days: 14 + if-no-files-found: error From f1408fc45c1929f9ccb0715ce815d76d09fd8290 Mon Sep 17 00:00:00 2001 From: Yuma Takino <36617106+tappe9@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:16:17 +0900 Subject: [PATCH 02/15] test: define Java API and registration contracts Add test-first MVC, JDBC settings, BIGINT, Fibonacci and readiness cases. Establish reviewed dependency generation separately from immutable validation. Keep production Java absent for initial red evidence; keep the official eight-stack cohort unchanged. --- .github/workflows/java-development.yml | 90 ++++++++++++--- apps/java-spring-boot/.gitignore | 2 + apps/java-spring-boot/.java-version | 1 + apps/java-spring-boot/build.gradle | 58 ++++++++++ apps/java-spring-boot/gradle.properties | 4 + apps/java-spring-boot/settings.gradle | 1 + .../simpleapibenchmark/ApiControllerTest.java | 103 ++++++++++++++++++ .../DatabaseSettingsTest.java | 59 ++++++++++ .../dev/simpleapibenchmark/FibonacciTest.java | 17 +++ .../simpleapibenchmark/HealthcheckTest.java | 44 ++++++++ tests/test_benchmark_java_candidate.py | 34 ++++++ 11 files changed, 396 insertions(+), 17 deletions(-) create mode 100644 apps/java-spring-boot/.gitignore create mode 100644 apps/java-spring-boot/.java-version create mode 100644 apps/java-spring-boot/build.gradle create mode 100644 apps/java-spring-boot/gradle.properties create mode 100644 apps/java-spring-boot/settings.gradle create mode 100644 apps/java-spring-boot/src/test/java/dev/simpleapibenchmark/ApiControllerTest.java create mode 100644 apps/java-spring-boot/src/test/java/dev/simpleapibenchmark/DatabaseSettingsTest.java create mode 100644 apps/java-spring-boot/src/test/java/dev/simpleapibenchmark/FibonacciTest.java create mode 100644 apps/java-spring-boot/src/test/java/dev/simpleapibenchmark/HealthcheckTest.java create mode 100644 tests/test_benchmark_java_candidate.py diff --git a/.github/workflows/java-development.yml b/.github/workflows/java-development.yml index 08cd14b..c01c474 100644 --- a/.github/workflows/java-development.yml +++ b/.github/workflows/java-development.yml @@ -8,37 +8,84 @@ concurrency: group: java-development-67 cancel-in-progress: false jobs: - inspect: + bootstrap: if: github.repository == 'tappe9/simple-api-benchmark' && github.ref == 'refs/heads/feat/add-java-spring-boot' runs-on: ubuntu-24.04 - timeout-minutes: 10 + timeout-minutes: 20 steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 with: ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false - - name: Export exact source and public upstream metadata + - name: Prepare checksum-verified Java and Gradle + shell: bash + run: | + set -euo pipefail + mkdir -p .cache/java-development .cache/java-build-kit + curl --fail --silent --show-error --location --max-time 180 'https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.4.1%2B1/OpenJDK25U-jdk_x64_linux_hotspot_25.0.4.1_1.tar.gz' -o .cache/java-build-kit/jdk.tar.gz + echo 'dbb698396d478e7fa2b1e50f4103324b2a99b90569ee27c33f2261f9215cf41e .cache/java-build-kit/jdk.tar.gz' | sha256sum -c - + curl --fail --silent --show-error --location --max-time 180 https://services.gradle.org/distributions/gradle-9.7.1-bin.zip -o .cache/java-build-kit/gradle.zip + echo 'acd53f1edaf02f1a8ff99879f8a34b302661a057d9b063ae9e35b552f804d20a .cache/java-build-kit/gradle.zip' | sha256sum -c - + mkdir -p "$RUNNER_TEMP/java67/jdk" + tar -xzf .cache/java-build-kit/jdk.tar.gz --strip-components=1 -C "$RUNNER_TEMP/java67/jdk" + unzip -q .cache/java-build-kit/gradle.zip -d "$RUNNER_TEMP/java67" + echo "JAVA_HOME=$RUNNER_TEMP/java67/jdk" >> "$GITHUB_ENV" + echo "GRADLE_USER_HOME=$RUNNER_TEMP/java67/gradle-home" >> "$GITHUB_ENV" + echo "$RUNNER_TEMP/java67/jdk/bin" >> "$GITHUB_PATH" + echo "$RUNNER_TEMP/java67/gradle-9.7.1/bin" >> "$GITHUB_PATH" + - name: Generate dependencies for review and verify the test-first failure + shell: bash + run: | + set -euo pipefail + java -version 2>&1 | tee .cache/java-development/java-version.txt + gradle --version | tee .cache/java-development/gradle-version.txt + cat > "$RUNNER_TEMP/java67/resolve.gradle" <<'GRADLE' + allprojects { + tasks.register('resolveForReview') { + doLast { + configurations.findAll { it.canBeResolved }.each { it.resolve() } + } + } + } + GRADLE + cd apps/java-spring-boot + gradle wrapper --gradle-version 9.7.1 --gradle-distribution-sha256-sum acd53f1edaf02f1a8ff99879f8a34b302661a057d9b063ae9e35b552f804d20a + echo '7a9ce74cff467ca1bf60a4fcd9f05185acceda4d0f382434d393e17864262c5d gradle/wrapper/gradle-wrapper.jar' | sha256sum -c - + ./gradlew --init-script "$RUNNER_TEMP/java67/resolve.gradle" resolveForReview --write-locks --write-verification-metadata sha256 + set +e + ./gradlew test > ../../.cache/java-development/java-red.log 2>&1 + status=$? + set -e + cat ../../.cache/java-development/java-red.log + test "$status" -ne 0 + grep -q 'cannot find symbol' ../../.cache/java-development/java-red.log + ! find src/main -name '*.java' -print -quit 2>/dev/null | grep -q . + - name: Inspect official Java container candidates + if: always() shell: bash run: | set -euo pipefail mkdir -p .cache/java-development + for variant in jdk jre; do + tag="eclipse-temurin:25.0.4.1_1-${variant}-noble" + if docker buildx imagetools inspect "$tag" > ".cache/java-development/${variant}-image.txt" 2>&1; then + docker buildx imagetools inspect "$tag" --raw > ".cache/java-development/${variant}-image.json" + fi + done + - name: Export exact source and generated dependency evidence + if: always() + shell: bash + run: | + set -euo pipefail + mkdir -p .cache/java-development/generated git bundle create .cache/java-development/source.bundle --all git rev-parse HEAD HEAD^{tree} > .cache/java-development/source-identity.txt - curl --fail --silent --show-error --location --max-time 60 https://repo.maven.apache.org/maven2/org/springframework/boot/spring-boot-dependencies/maven-metadata.xml -o .cache/java-development/spring-boot-metadata.xml - curl --fail --silent --show-error --location --max-time 60 https://services.gradle.org/versions/current -o .cache/java-development/gradle-current.json - curl --fail --silent --show-error --location --max-time 60 'https://api.adoptium.net/v3/assets/feature_releases/25/ga?architecture=x64&heap_size=normal&image_type=jdk&jvm_impl=hotspot&os=linux&page_size=1&project=jdk&sort_order=DESC&vendor=eclipse' -o .cache/java-development/temurin-release.json - python3 - <<'PY' - import json - import pathlib - import xml.etree.ElementTree as ET - root = pathlib.Path('.cache/java-development') - print('Spring Boot 4.1:', [e.text for e in ET.parse(root / 'spring-boot-metadata.xml').findall('.//version') if e.text and e.text.startswith('4.1.')]) - print('Gradle:', json.loads((root / 'gradle-current.json').read_text())) - release = json.loads((root / 'temurin-release.json').read_text())[0] - print('Temurin:', release['release_name'], release['version_data']) - print('Package:', release['binaries'][0]['package']) - PY + cp -a apps/java-spring-boot/. .cache/java-development/generated/ + rm -rf .cache/java-development/generated/.gradle .cache/java-development/generated/build + if test -d "$GRADLE_USER_HOME/caches"; then + tar -czf .cache/java-build-kit/gradle-cache.tar.gz -C "$GRADLE_USER_HOME" caches + fi - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 if: always() with: @@ -47,3 +94,12 @@ jobs: include-hidden-files: true retention-days: 14 if-no-files-found: error + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: always() + with: + name: java-build-kit-${{ github.run_id }}-${{ github.run_attempt }} + path: .cache/java-build-kit/ + include-hidden-files: true + retention-days: 3 + compression-level: 0 + if-no-files-found: error diff --git a/apps/java-spring-boot/.gitignore b/apps/java-spring-boot/.gitignore new file mode 100644 index 0000000..67bcc2f --- /dev/null +++ b/apps/java-spring-boot/.gitignore @@ -0,0 +1,2 @@ +.gradle/ +build/ diff --git a/apps/java-spring-boot/.java-version b/apps/java-spring-boot/.java-version new file mode 100644 index 0000000..5923670 --- /dev/null +++ b/apps/java-spring-boot/.java-version @@ -0,0 +1 @@ +25.0.4.1+1 diff --git a/apps/java-spring-boot/build.gradle b/apps/java-spring-boot/build.gradle new file mode 100644 index 0000000..42a4b1d --- /dev/null +++ b/apps/java-spring-boot/build.gradle @@ -0,0 +1,58 @@ +plugins { + id 'application' +} + +repositories { + mavenCentral() +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(25) + } +} + +def pinnedJava = file('.java-version').text.trim() +if (Runtime.version().toString() != pinnedJava && Runtime.version().toString() != pinnedJava + '-LTS') { + throw new GradleException("Expected Java ${pinnedJava}; found ${Runtime.version()}") +} + +dependencies { + implementation platform('org.springframework.boot:spring-boot-dependencies:4.1.1') + implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.springframework.boot:spring-boot-starter-jdbc' + runtimeOnly 'org.postgresql:postgresql' + testImplementation 'org.junit.jupiter:junit-jupiter' + testImplementation 'org.springframework:spring-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +dependencyLocking { + lockAllConfigurations() + lockMode = LockMode.STRICT +} + +configurations.configureEach { + resolutionStrategy { + failOnDynamicVersions() + failOnChangingVersions() + } +} + +application { + mainClass = 'dev.simpleapibenchmark.BenchmarkApplication' +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' + options.release = 25 + options.compilerArgs += ['-Xlint:all', '-Werror'] +} + +tasks.withType(Test).configureEach { + useJUnitPlatform() + maxParallelForks = 1 + testLogging { + events 'passed', 'skipped', 'failed' + } +} diff --git a/apps/java-spring-boot/gradle.properties b/apps/java-spring-boot/gradle.properties new file mode 100644 index 0000000..a23b25a --- /dev/null +++ b/apps/java-spring-boot/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.daemon=false +org.gradle.java.installations.auto-download=false +org.gradle.warning.mode=fail +org.gradle.workers.max=2 diff --git a/apps/java-spring-boot/settings.gradle b/apps/java-spring-boot/settings.gradle new file mode 100644 index 0000000..bc87b3a --- /dev/null +++ b/apps/java-spring-boot/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'java-spring-boot' diff --git a/apps/java-spring-boot/src/test/java/dev/simpleapibenchmark/ApiControllerTest.java b/apps/java-spring-boot/src/test/java/dev/simpleapibenchmark/ApiControllerTest.java new file mode 100644 index 0000000..6d1931c --- /dev/null +++ b/apps/java-spring-boot/src/test/java/dev/simpleapibenchmark/ApiControllerTest.java @@ -0,0 +1,103 @@ +package dev.simpleapibenchmark; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +import java.sql.SQLException; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import tools.jackson.databind.json.JsonMapper; + +class ApiControllerTest { + private final JsonMapper json = JsonMapper.builder().build(); + private final Repository repository = new Repository(); + private MockMvc mvc; + + @BeforeEach + void setUp() { + mvc = MockMvcBuilders.standaloneSetup(new ApiController(repository)).build(); + } + + @Test + void healthAndJsonFollowTheContract() throws Exception { + response("/health", 200, "{\"status\":\"ok\"}"); + response("/json", 200, "{\"message\":\"Hello, World!\",\"items\":[1,2,3,4,5]}"); + assertEquals(0, repository.calls); + } + + @Test + void cpuComputesForRepeatedRequestsWithoutDatabaseAccess() throws Exception { + for (int i = 0; i < 3; i++) { + response("/cpu", 200, "{\"input\":30,\"result\":832040}"); + } + assertEquals(0, repository.calls); + } + + @Test + void eachDatabaseRequestReadsTheRepositoryAgain() throws Exception { + response("/db/42", 200, "{\"id\":42,\"name\":\"Item 42\",\"price\":4200}"); + repository.item = new Item(42, "Changed", 7); + response("/db/42", 200, "{\"id\":42,\"name\":\"Changed\",\"price\":7}"); + assertEquals(2, repository.calls); + assertEquals(42, repository.lastId); + } + + @Test + void missingRowsReturnTheDocumented404() throws Exception { + repository.item = null; + response("/db/999", 404, "{\"error\":\"not found\"}"); + } + + @ParameterizedTest + @ValueSource(strings = {"invalid", "42junk", "1.0", "9223372036854775808", "-9223372036854775809", "--1", "12", " 42"}) + void invalidIdsNeverReachTheRepository(String id) throws Exception { + var result = mvc.perform(get("/db/{id}", id)).andReturn().getResponse(); + assertEquals(400, result.getStatus()); + assertEquals(json.readTree("{\"error\":\"invalid id\"}"), json.readTree(result.getContentAsString())); + assertEquals(0, repository.calls); + } + + @ParameterizedTest + @ValueSource(longs = {Long.MIN_VALUE, Long.MAX_VALUE, 9007199254740993L, 0}) + void signedBigintBoundariesRemainJsonIntegers(long id) throws Exception { + repository.item = new Item(id, "Boundary", 1); + response("/db/" + id, 200, "{\"id\":" + id + ",\"name\":\"Boundary\",\"price\":1}"); + assertEquals(id, repository.lastId); + } + + @Test + void databaseFailuresAreSanitized() throws Exception { + repository.fail = true; + response("/db/42", 500, "{\"error\":\"internal server error\"}"); + } + + private void response(String path, int status, String expected) throws Exception { + var result = mvc.perform(get(path)).andReturn().getResponse(); + assertEquals(status, result.getStatus()); + assertTrue(result.getContentType().startsWith("application/json")); + assertEquals(json.readTree(expected), json.readTree(result.getContentAsString())); + } + + private static final class Repository implements ItemRepository { + private Item item = new Item(42, "Item 42", 4200); + private int calls; + private long lastId; + private boolean fail; + + @Override + public Optional find(long id) throws SQLException { + calls++; + lastId = id; + if (fail) { + throw new SQLException("secret-password: SELECT id FROM private_table"); + } + return Optional.ofNullable(item); + } + } +} diff --git a/apps/java-spring-boot/src/test/java/dev/simpleapibenchmark/DatabaseSettingsTest.java b/apps/java-spring-boot/src/test/java/dev/simpleapibenchmark/DatabaseSettingsTest.java new file mode 100644 index 0000000..27c9ba5 --- /dev/null +++ b/apps/java-spring-boot/src/test/java/dev/simpleapibenchmark/DatabaseSettingsTest.java @@ -0,0 +1,59 @@ +package dev.simpleapibenchmark; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class DatabaseSettingsTest { + private static Map environment() { + return new HashMap<>(Map.of( + "DATABASE_HOST", "postgres", + "DATABASE_PORT", "5432", + "DATABASE_NAME", "benchmark", + "DATABASE_USER", "benchmark", + "DATABASE_PASSWORD", "test-secret")); + } + + @Test + void validatesTheSharedEnvironmentWithoutEmbeddingCredentialsInTheUrl() { + var settings = DatabaseSettings.fromEnvironment(environment()); + assertEquals("jdbc:postgresql://postgres:5432/benchmark", settings.jdbcUrl()); + assertEquals("benchmark", settings.user()); + assertEquals("test-secret", settings.password()); + assertFalse(settings.toString().contains("test-secret")); + } + + @Test + void missingSettingsFailWithSanitizedMessages() { + for (String key : environment().keySet()) { + var environment = environment(); + environment.remove(key); + var error = assertThrows(IllegalArgumentException.class, + () -> DatabaseSettings.fromEnvironment(environment)); + assertFalse(error.toString().contains("test-secret")); + } + } + + @Test + void malformedAddressAndPortCannotChangeJdbcConnectionProperties() { + for (var change : Map.of( + "DATABASE_HOST", "postgres/other?password=test-secret", + "DATABASE_NAME", "benchmark?options=test-secret", + "DATABASE_PORT", "65536").entrySet()) { + var environment = environment(); + environment.put(change.getKey(), change.getValue()); + assertThrows(IllegalArgumentException.class, + () -> DatabaseSettings.fromEnvironment(environment)); + } + for (String port : new String[] {"0", "-1", "abc", "5432junk", " 5432"}) { + var environment = environment(); + environment.put("DATABASE_PORT", port); + assertThrows(IllegalArgumentException.class, + () -> DatabaseSettings.fromEnvironment(environment)); + } + } +} diff --git a/apps/java-spring-boot/src/test/java/dev/simpleapibenchmark/FibonacciTest.java b/apps/java-spring-boot/src/test/java/dev/simpleapibenchmark/FibonacciTest.java new file mode 100644 index 0000000..e347784 --- /dev/null +++ b/apps/java-spring-boot/src/test/java/dev/simpleapibenchmark/FibonacciTest.java @@ -0,0 +1,17 @@ +package dev.simpleapibenchmark; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class FibonacciTest { + @Test + void followsTheDirectRecursionDefinition() { + assertEquals(0, Fibonacci.calculate(0)); + assertEquals(1, Fibonacci.calculate(1)); + assertEquals(1, Fibonacci.calculate(2)); + assertEquals(55, Fibonacci.calculate(10)); + assertEquals(832040, Fibonacci.calculate(30)); + assertEquals(832040, Fibonacci.calculate(30)); + } +} diff --git a/apps/java-spring-boot/src/test/java/dev/simpleapibenchmark/HealthcheckTest.java b/apps/java-spring-boot/src/test/java/dev/simpleapibenchmark/HealthcheckTest.java new file mode 100644 index 0000000..57a8343 --- /dev/null +++ b/apps/java-spring-boot/src/test/java/dev/simpleapibenchmark/HealthcheckTest.java @@ -0,0 +1,44 @@ +package dev.simpleapibenchmark; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.sun.net.httpserver.HttpServer; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class HealthcheckTest { + @Test + void acceptsOnlySuccessfulBoundedHealthJson() throws Exception { + var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + String path = exchange.getRequestURI().getPath(); + int status = path.equals("/redirect") ? 302 : path.equals("/error") ? 503 : 200; + String body = switch (path) { + case "/invalid" -> "{\"status\":\"bad\"}"; + case "/large" -> "x".repeat(4096); + default -> "{\"status\":\"ok\"}"; + }; + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", path.equals("/html") ? "text/html" : "application/json"); + exchange.getResponseHeaders().set("Location", "/ok"); + exchange.sendResponseHeaders(status, bytes.length); + try (var output = exchange.getResponseBody()) { + output.write(bytes); + } + }); + server.start(); + String base = "http://127.0.0.1:" + server.getAddress().getPort(); + try { + assertTrue(Healthcheck.check(URI.create(base + "/ok"))); + for (String path : new String[] {"/invalid", "/error", "/redirect", "/large", "/html"}) { + assertFalse(Healthcheck.check(URI.create(base + path)), path); + } + } finally { + server.stop(0); + } + assertFalse(Healthcheck.check(URI.create(base + "/ok"))); + } +} diff --git a/tests/test_benchmark_java_candidate.py b/tests/test_benchmark_java_candidate.py new file mode 100644 index 0000000..da22489 --- /dev/null +++ b/tests/test_benchmark_java_candidate.py @@ -0,0 +1,34 @@ +"""Java registration must not activate a cohort or rewrite historical results.""" + +import unittest +from unittest.mock import patch + +from benchmark import ci, environment, registry + +EIGHT = ("go-gin", "go-echo", "rust-actix", "rust-axum", "node-fastify", "node-express", "python-fastapi", "python-flask") +BASELINE_VERSIONS = {'go-gin': {'go': '1.27.1', 'gin': '1.12.0', 'pgx': '5.10.0'}, 'go-echo': {'go': '1.27.1', 'echo': '5.3.1', 'pgx': '5.10.0'}, 'rust-actix': {'rust': '1.98.1', 'actix-web': '4.15.0', 'sqlx': '0.9.0', 'serde': '1.0.228', 'serde_json': '1.0.145'}, 'rust-axum': {'rust': '1.98.1', 'axum': '0.8.9', 'tokio': '1.53.1', 'sqlx': '0.9.0', 'serde': '1.0.228', 'serde_json': '1.0.145'}, 'node-fastify': {'node': '24.20.0', 'fastify': '5.12.3', 'pg': '8.23.0'}, 'node-express': {'node': '24.20.0', 'express': '5.2.1', 'pg': '8.23.0'}, 'python-fastapi': {'python': '3.14.7', 'fastapi': '0.141.1', 'uvicorn': '0.52.4', 'asyncpg': '0.31.0'}, 'python-flask': {'python': '3.14.7', 'flask': '3.1.3', 'waitress': '3.0.2', 'psycopg': '3.3.5', 'psycopg-binary': '3.3.5', 'psycopg-pool': '3.3.1'}} + + +class JavaCandidateTests(unittest.TestCase): + def test_java_is_registered_but_official_members_are_unchanged(self): + self.assertIn("java-spring-boot", registry.implementation_ids()) + self.assertEqual(tuple(registry.active_members()), EIGHT) + self.assertEqual(registry.REGISTRY["active_cohort"], "eight-stack-v1") + self.assertEqual(set(registry.REGISTRY["cohorts"]), {"four-stack-v1", "eight-stack-v1"}) + + def test_metadata_is_read_without_running_any_language_toolchain(self): + with patch("subprocess.run", side_effect=AssertionError("metadata must be static")): + versions = environment.registered_pinned_versions() + self.assertIn("java-spring-boot", versions) + self.assertEqual(versions["java-spring-boot"]["java"], "25.0.4.1+1") + self.assertEqual(versions["java-spring-boot"]["spring-boot"], "4.1.1") + self.assertEqual(environment.pinned_versions(), BASELINE_VERSIONS) + + def test_java_has_its_own_ci_toolchain_without_changing_other_entries(self): + entries = ci.matrix_payload()["include"] + self.assertIn({"implementation": "java-spring-boot", "toolchain": "java"}, entries) + self.assertEqual(len(entries), 9) + + +if __name__ == "__main__": + unittest.main() From c1bf47f1fa02ad8f6e64e46d106db539fb7b5a70 Mon Sep 17 00:00:00 2001 From: Yuma Takino <36617106+tappe9@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:19:22 +0900 Subject: [PATCH 03/15] build: use strict dependency locking without incompatible resolution flags Keep immutable lock and checksum validation. Replace deprecated collection filtering in the temporary dependency preparation step and inspect actually published Java image variants. --- .github/workflows/java-development.yml | 8 ++++++-- apps/java-spring-boot/build.gradle | 7 ------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/.github/workflows/java-development.yml b/.github/workflows/java-development.yml index c01c474..ffa92c8 100644 --- a/.github/workflows/java-development.yml +++ b/.github/workflows/java-development.yml @@ -44,7 +44,7 @@ jobs: allprojects { tasks.register('resolveForReview') { doLast { - configurations.findAll { it.canBeResolved }.each { it.resolve() } + configurations.each { if (it.canBeResolved) { it.resolve() } } } } } @@ -68,11 +68,15 @@ jobs: set -euo pipefail mkdir -p .cache/java-development for variant in jdk jre; do - tag="eclipse-temurin:25.0.4.1_1-${variant}-noble" + tag="eclipse-temurin:25-${variant}-noble" if docker buildx imagetools inspect "$tag" > ".cache/java-development/${variant}-image.txt" 2>&1; then docker buildx imagetools inspect "$tag" --raw > ".cache/java-development/${variant}-image.json" + docker run --rm "$tag" java -version > ".cache/java-development/${variant}-java-version.txt" 2>&1 fi done + for architecture in x64 aarch64; do + curl --fail --silent --show-error --location --max-time 60 "https://api.adoptium.net/v3/assets/feature_releases/25/ga?architecture=${architecture}&heap_size=normal&image_type=jre&jvm_impl=hotspot&os=linux&page_size=1&project=jdk&sort_order=DESC&vendor=eclipse" -o ".cache/java-development/temurin-jre-${architecture}.json" + done - name: Export exact source and generated dependency evidence if: always() shell: bash diff --git a/apps/java-spring-boot/build.gradle b/apps/java-spring-boot/build.gradle index 42a4b1d..b4b9cc1 100644 --- a/apps/java-spring-boot/build.gradle +++ b/apps/java-spring-boot/build.gradle @@ -32,13 +32,6 @@ dependencyLocking { lockMode = LockMode.STRICT } -configurations.configureEach { - resolutionStrategy { - failOnDynamicVersions() - failOnChangingVersions() - } -} - application { mainClass = 'dev.simpleapibenchmark.BenchmarkApplication' } From 9b28619a4f771dcfb02ddc606941820fc6a67ae3 Mon Sep 17 00:00:00 2001 From: Yuma Takino <36617106+tappe9@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:33:58 +0900 Subject: [PATCH 04/15] feat: implement Java HTTP and JDBC contract Add native MVC JSON, BIGINT validation, parameterized JDBC reads, per-request direct recursion, bounded database startup and standalone health checks. Run the existing red tests against immutable dependency candidates before importing their Git objects. No official cohort or result changes. --- .github/workflows/java-development.yml | 166 +++++++++++------- .../dev/simpleapibenchmark/ApiController.java | 61 +++++++ .../BenchmarkApplication.java | 17 ++ .../DatabaseConfiguration.java | 47 +++++ .../simpleapibenchmark/DatabaseSettings.java | 37 ++++ .../dev/simpleapibenchmark/Fibonacci.java | 12 ++ .../dev/simpleapibenchmark/Healthcheck.java | 58 ++++++ .../java/dev/simpleapibenchmark/Item.java | 3 + .../simpleapibenchmark/ItemRepository.java | 9 + .../JdbcItemRepository.java | 30 ++++ .../src/main/resources/application.properties | 13 ++ 11 files changed, 388 insertions(+), 65 deletions(-) create mode 100644 apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/ApiController.java create mode 100644 apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/BenchmarkApplication.java create mode 100644 apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/DatabaseConfiguration.java create mode 100644 apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/DatabaseSettings.java create mode 100644 apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/Fibonacci.java create mode 100644 apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/Healthcheck.java create mode 100644 apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/Item.java create mode 100644 apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/ItemRepository.java create mode 100644 apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/JdbcItemRepository.java create mode 100644 apps/java-spring-boot/src/main/resources/application.properties diff --git a/.github/workflows/java-development.yml b/.github/workflows/java-development.yml index ffa92c8..52a4cc8 100644 --- a/.github/workflows/java-development.yml +++ b/.github/workflows/java-development.yml @@ -8,87 +8,78 @@ concurrency: group: java-development-67 cancel-in-progress: false jobs: - bootstrap: + unit: if: github.repository == 'tappe9/simple-api-benchmark' && github.ref == 'refs/heads/feat/add-java-spring-boot' runs-on: ubuntu-24.04 - timeout-minutes: 20 + timeout-minutes: 15 + permissions: + contents: read + actions: read steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 with: ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false - - name: Prepare checksum-verified Java and Gradle - shell: bash + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + artifact-ids: '10482171726' + run-id: '35185291659' + github-token: ${{ github.token }} + path: .cache/java-baseline + merge-multiple: true + - name: Prepare the exact checksum-verified JDK run: | set -euo pipefail - mkdir -p .cache/java-development .cache/java-build-kit - curl --fail --silent --show-error --location --max-time 180 'https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.4.1%2B1/OpenJDK25U-jdk_x64_linux_hotspot_25.0.4.1_1.tar.gz' -o .cache/java-build-kit/jdk.tar.gz - echo 'dbb698396d478e7fa2b1e50f4103324b2a99b90569ee27c33f2261f9215cf41e .cache/java-build-kit/jdk.tar.gz' | sha256sum -c - - curl --fail --silent --show-error --location --max-time 180 https://services.gradle.org/distributions/gradle-9.7.1-bin.zip -o .cache/java-build-kit/gradle.zip - echo 'acd53f1edaf02f1a8ff99879f8a34b302661a057d9b063ae9e35b552f804d20a .cache/java-build-kit/gradle.zip' | sha256sum -c - + curl --fail --silent --show-error --location --max-time 180 'https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.4.1%2B1/OpenJDK25U-jdk_x64_linux_hotspot_25.0.4.1_1.tar.gz' -o "$RUNNER_TEMP/java67.tar.gz" + echo "dbb698396d478e7fa2b1e50f4103324b2a99b90569ee27c33f2261f9215cf41e $RUNNER_TEMP/java67.tar.gz" | sha256sum -c - mkdir -p "$RUNNER_TEMP/java67/jdk" - tar -xzf .cache/java-build-kit/jdk.tar.gz --strip-components=1 -C "$RUNNER_TEMP/java67/jdk" - unzip -q .cache/java-build-kit/gradle.zip -d "$RUNNER_TEMP/java67" + tar -xzf "$RUNNER_TEMP/java67.tar.gz" --strip-components=1 -C "$RUNNER_TEMP/java67/jdk" echo "JAVA_HOME=$RUNNER_TEMP/java67/jdk" >> "$GITHUB_ENV" echo "GRADLE_USER_HOME=$RUNNER_TEMP/java67/gradle-home" >> "$GITHUB_ENV" echo "$RUNNER_TEMP/java67/jdk/bin" >> "$GITHUB_PATH" - echo "$RUNNER_TEMP/java67/gradle-9.7.1/bin" >> "$GITHUB_PATH" - - name: Generate dependencies for review and verify the test-first failure - shell: bash + - name: Import only the immutable bootstrap dependency candidates run: | set -euo pipefail - java -version 2>&1 | tee .cache/java-development/java-version.txt - gradle --version | tee .cache/java-development/gradle-version.txt - cat > "$RUNNER_TEMP/java67/resolve.gradle" <<'GRADLE' - allprojects { - tasks.register('resolveForReview') { - doLast { - configurations.each { if (it.canBeResolved) { it.resolve() } } - } - } - } - GRADLE - cd apps/java-spring-boot - gradle wrapper --gradle-version 9.7.1 --gradle-distribution-sha256-sum acd53f1edaf02f1a8ff99879f8a34b302661a057d9b063ae9e35b552f804d20a - echo '7a9ce74cff467ca1bf60a4fcd9f05185acceda4d0f382434d393e17864262c5d gradle/wrapper/gradle-wrapper.jar' | sha256sum -c - - ./gradlew --init-script "$RUNNER_TEMP/java67/resolve.gradle" resolveForReview --write-locks --write-verification-metadata sha256 - set +e - ./gradlew test > ../../.cache/java-development/java-red.log 2>&1 - status=$? - set -e - cat ../../.cache/java-development/java-red.log - test "$status" -ne 0 - grep -q 'cannot find symbol' ../../.cache/java-development/java-red.log - ! find src/main -name '*.java' -print -quit 2>/dev/null | grep -q . - - name: Inspect official Java container candidates - if: always() - shell: bash + python3 - <<'PY' + from pathlib import Path + import hashlib + source = Path('.cache/java-baseline/generated') + destination = Path('apps/java-spring-boot') + names = ('gradlew', 'gradlew.bat', 'gradle/wrapper/gradle-wrapper.jar', 'gradle/wrapper/gradle-wrapper.properties', 'gradle.lockfile', 'gradle/verification-metadata.xml') + for name in names: + path = source / name + if path.is_symlink() or not path.is_file(): + raise RuntimeError('missing or unsafe dependency candidate: ' + name) + data = path.read_bytes() + if name.endswith('.jar') and hashlib.sha256(data).hexdigest() != '7a9ce74cff467ca1bf60a4fcd9f05185acceda4d0f382434d393e17864262c5d': + raise RuntimeError('wrapper checksum mismatch') + target = destination / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + target.chmod(0o755 if name == 'gradlew' else 0o644) + print(name, hashlib.sha256(data).hexdigest()) + print((source / 'gradle.lockfile').read_text()) + for name in ('jdk-image.txt', 'jre-image.txt', 'jdk-java-version.txt', 'jre-java-version.txt', 'temurin-jre-x64.json', 'temurin-jre-aarch64.json'): + print(name, Path('.cache/java-baseline', name).read_text()) + PY + - name: Execute locked Java tests and production distribution run: | set -euo pipefail mkdir -p .cache/java-development - for variant in jdk jre; do - tag="eclipse-temurin:25-${variant}-noble" - if docker buildx imagetools inspect "$tag" > ".cache/java-development/${variant}-image.txt" 2>&1; then - docker buildx imagetools inspect "$tag" --raw > ".cache/java-development/${variant}-image.json" - docker run --rm "$tag" java -version > ".cache/java-development/${variant}-java-version.txt" 2>&1 - fi - done - for architecture in x64 aarch64; do - curl --fail --silent --show-error --location --max-time 60 "https://api.adoptium.net/v3/assets/feature_releases/25/ga?architecture=${architecture}&heap_size=normal&image_type=jre&jvm_impl=hotspot&os=linux&page_size=1&project=jdk&sort_order=DESC&vendor=eclipse" -o ".cache/java-development/temurin-jre-${architecture}.json" - done - - name: Export exact source and generated dependency evidence + cd apps/java-spring-boot + sha256sum gradle.lockfile gradle/verification-metadata.xml gradle/wrapper/* > ../../.cache/java-development/dependencies.before + ./gradlew --no-daemon test installDist 2>&1 | tee ../../.cache/java-development/java-tests.log + sha256sum --check ../../.cache/java-development/dependencies.before + - name: Export exact source and test evidence if: always() - shell: bash run: | set -euo pipefail - mkdir -p .cache/java-development/generated + mkdir -p .cache/java-development git bundle create .cache/java-development/source.bundle --all git rev-parse HEAD HEAD^{tree} > .cache/java-development/source-identity.txt - cp -a apps/java-spring-boot/. .cache/java-development/generated/ - rm -rf .cache/java-development/generated/.gradle .cache/java-development/generated/build - if test -d "$GRADLE_USER_HOME/caches"; then - tar -czf .cache/java-build-kit/gradle-cache.tar.gz -C "$GRADLE_USER_HOME" caches + if test -d apps/java-spring-boot/build/test-results; then + cp -a apps/java-spring-boot/build/test-results .cache/java-development/ fi - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 if: always() @@ -98,12 +89,57 @@ jobs: include-hidden-files: true retention-days: 14 if-no-files-found: error - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - if: always() + import-objects: + needs: unit + if: github.repository == 'tappe9/simple-api-benchmark' && github.ref == 'refs/heads/feat/add-java-spring-boot' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: write + actions: read + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: - name: java-build-kit-${{ github.run_id }}-${{ github.run_attempt }} - path: .cache/java-build-kit/ - include-hidden-files: true - retention-days: 3 - compression-level: 0 - if-no-files-found: error + artifact-ids: '10482171726' + run-id: '35185291659' + github-token: ${{ github.token }} + path: dependency-candidates + merge-multiple: true + - name: Import six allowlisted Git blobs without executing code or updating refs + env: + GH_TOKEN: ${{ github.token }} + run: | + python3 - <<'PY' + import base64 + import hashlib + import json + import os + from pathlib import Path + import urllib.request + source = Path('dependency-candidates/generated') + names = ('gradlew', 'gradlew.bat', 'gradle/wrapper/gradle-wrapper.jar', 'gradle/wrapper/gradle-wrapper.properties', 'gradle.lockfile', 'gradle/verification-metadata.xml') + candidates = [] + for name in names: + path = source / name + if path.is_symlink() or not path.is_file() or not 0 < path.stat().st_size < 200_000: + raise RuntimeError('missing or unsafe dependency candidate') + data = path.read_bytes() + digest = hashlib.sha256(data).hexdigest() + if name.endswith('.jar') and digest != '7a9ce74cff467ca1bf60a4fcd9f05185acceda4d0f382434d393e17864262c5d': + raise RuntimeError('wrapper checksum mismatch') + candidates.append((name, data, digest)) + output = [] + for name, data, digest in candidates: + request = urllib.request.Request( + 'https://api.github.com/repos/tappe9/simple-api-benchmark/git/blobs', + data=json.dumps({'encoding': 'base64', 'content': base64.b64encode(data).decode('ascii')}).encode('utf-8'), + headers={'Authorization': 'Bearer ' + os.environ['GH_TOKEN'], 'Accept': 'application/vnd.github+json', 'Content-Type': 'application/json', 'X-GitHub-Api-Version': '2022-11-28'}, + method='POST') + with urllib.request.urlopen(request, timeout=30) as response: + blob = json.load(response) + expected = hashlib.sha1(b'blob ' + str(len(data)).encode('ascii') + b'\0' + data).hexdigest() + if blob.get('sha') != expected: + raise RuntimeError('Git blob identity mismatch') + output.append({'path': 'apps/java-spring-boot/' + name, 'mode': '100755' if name == 'gradlew' else '100644', 'type': 'blob', 'sha': expected, 'sha256': digest}) + print(json.dumps(output, indent=2)) + PY diff --git a/apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/ApiController.java b/apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/ApiController.java new file mode 100644 index 0000000..906e27e --- /dev/null +++ b/apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/ApiController.java @@ -0,0 +1,61 @@ +package dev.simpleapibenchmark; + +import java.sql.SQLException; +import java.util.List; +import java.util.regex.Pattern; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class ApiController { + private static final Pattern INTEGER = Pattern.compile("[+-]?[0-9]+"); + private final ItemRepository repository; + + public ApiController(ItemRepository repository) { + this.repository = repository; + } + + @GetMapping(value = "/health", produces = "application/json") + public Health health() { + return new Health("ok"); + } + + @GetMapping(value = "/json", produces = "application/json") + public Message json() { + return new Message("Hello, World!", List.of(1, 2, 3, 4, 5)); + } + + @GetMapping(value = "/cpu", produces = "application/json") + public Cpu cpu() { + return new Cpu(30, Fibonacci.calculate(30)); + } + + @GetMapping(value = "/db/{id}", produces = "application/json") + public ResponseEntity database(@PathVariable("id") String input) { + final long id; + try { + if (!INTEGER.matcher(input).matches()) { + return ResponseEntity.badRequest().body(new Error("invalid id")); + } + id = Long.parseLong(input); + } catch (NumberFormatException invalid) { + return ResponseEntity.badRequest().body(new Error("invalid id")); + } + try { + var item = repository.find(id); + if (item.isEmpty()) { + return ResponseEntity.status(404).body(new Error("not found")); + } + return ResponseEntity.ok(item.get()); + } catch (SQLException failure) { + return ResponseEntity.internalServerError().body(new Error("internal server error")); + } + } + + public record Health(String status) {} + public record Message(String message, List items) {} + public record Cpu(int input, int result) {} + public record Error(String error) {} +} diff --git a/apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/BenchmarkApplication.java b/apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/BenchmarkApplication.java new file mode 100644 index 0000000..41ce459 --- /dev/null +++ b/apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/BenchmarkApplication.java @@ -0,0 +1,17 @@ +package dev.simpleapibenchmark; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication(proxyBeanMethods = false) +public class BenchmarkApplication { + public static void main(String[] args) { + try { + SpringApplication.run(BenchmarkApplication.class, args); + } catch (Exception failure) { + // Configuration and driver exceptions may contain credentials or SQL. + System.err.println("application startup failed"); + System.exit(1); + } + } +} diff --git a/apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/DatabaseConfiguration.java b/apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/DatabaseConfiguration.java new file mode 100644 index 0000000..948bac7 --- /dev/null +++ b/apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/DatabaseConfiguration.java @@ -0,0 +1,47 @@ +package dev.simpleapibenchmark; + +import com.zaxxer.hikari.HikariDataSource; +import java.sql.SQLException; +import javax.sql.DataSource; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +class DatabaseConfiguration { + @Bean(destroyMethod = "close") + HikariDataSource dataSource() { + var settings = DatabaseSettings.fromEnvironment(System.getenv()); + var pool = new HikariDataSource(); + pool.setJdbcUrl(settings.jdbcUrl()); + pool.setUsername(settings.user()); + pool.setPassword(settings.password()); + pool.setPoolName("simple-api-java"); + pool.setMaximumPoolSize(10); + pool.setMinimumIdle(0); + pool.setConnectionTimeout(3000); + pool.setValidationTimeout(1000); + pool.setInitializationFailTimeout(3000); + pool.addDataSourceProperty("ApplicationName", "simple-api-java"); + pool.addDataSourceProperty("connectTimeout", "3"); + pool.addDataSourceProperty("socketTimeout", "5"); + pool.addDataSourceProperty("cancelSignalTimeout", "2"); + try (var connection = pool.getConnection(); + var statement = connection.prepareStatement("SELECT 1")) { + statement.setQueryTimeout(3); + try (var result = statement.executeQuery()) { + if (!result.next() || result.getInt(1) != 1) { + throw new SQLException("database readiness failed"); + } + } + return pool; + } catch (SQLException | RuntimeException failure) { + pool.close(); + throw new IllegalStateException("database startup failed"); + } + } + + @Bean + ItemRepository itemRepository(DataSource dataSource) { + return new JdbcItemRepository(dataSource); + } +} diff --git a/apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/DatabaseSettings.java b/apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/DatabaseSettings.java new file mode 100644 index 0000000..2dd0309 --- /dev/null +++ b/apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/DatabaseSettings.java @@ -0,0 +1,37 @@ +package dev.simpleapibenchmark; + +import java.util.Map; + +record DatabaseSettings(String jdbcUrl, String user, String password) { + static DatabaseSettings fromEnvironment(Map environment) { + String host = required(environment, "DATABASE_HOST"); + String portText = required(environment, "DATABASE_PORT"); + String database = required(environment, "DATABASE_NAME"); + String user = required(environment, "DATABASE_USER"); + String password = required(environment, "DATABASE_PASSWORD"); + if (host.length() > 253 || !host.matches("[A-Za-z0-9.-]+") + || !database.matches("[A-Za-z_][A-Za-z0-9_]*") + || !portText.matches("[0-9]{1,5}")) { + throw new IllegalArgumentException("invalid database address"); + } + int port = Integer.parseInt(portText); + if (port < 1 || port > 65535) { + throw new IllegalArgumentException("invalid database port"); + } + return new DatabaseSettings("jdbc:postgresql://" + host + ":" + port + "/" + database, + user, password); + } + + private static String required(Map environment, String key) { + String value = environment.get(key); + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("missing database configuration: " + key); + } + return value; + } + + @Override + public String toString() { + return "DatabaseSettings[redacted]"; + } +} diff --git a/apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/Fibonacci.java b/apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/Fibonacci.java new file mode 100644 index 0000000..8f66205 --- /dev/null +++ b/apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/Fibonacci.java @@ -0,0 +1,12 @@ +package dev.simpleapibenchmark; + +final class Fibonacci { + private Fibonacci() {} + + static int calculate(int n) { + if (n < 2) { + return n; + } + return calculate(n - 1) + calculate(n - 2); + } +} diff --git a/apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/Healthcheck.java b/apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/Healthcheck.java new file mode 100644 index 0000000..e5cadbe --- /dev/null +++ b/apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/Healthcheck.java @@ -0,0 +1,58 @@ +package dev.simpleapibenchmark; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Locale; + +/** A small standalone probe; it does not start Spring or connect to PostgreSQL. */ +public final class Healthcheck { + private Healthcheck() {} + + static boolean check(URI address) { + HttpURLConnection connection = null; + long deadline = System.nanoTime() + 1_500_000_000L; + try { + connection = (HttpURLConnection) address.toURL().openConnection(); + connection.setConnectTimeout(500); + connection.setReadTimeout(500); + connection.setInstanceFollowRedirects(false); + if (connection.getResponseCode() != 200) { + return false; + } + String contentType = connection.getContentType(); + if (contentType == null + || !contentType.split(";", 2)[0].trim().toLowerCase(Locale.ROOT) + .equals("application/json")) { + return false; + } + try (var input = connection.getInputStream(); + var output = new ByteArrayOutputStream()) { + for (int count = 0; count <= 1024; count++) { + if (System.nanoTime() >= deadline) { + return false; + } + int value = input.read(); + if (value == -1) { + return output.toString(StandardCharsets.UTF_8) + .matches("\\s*\\{\\s*\"status\"\\s*:\\s*\"ok\"\\s*}\\s*"); + } + output.write(value); + } + return false; + } + } catch (IOException | IllegalArgumentException failure) { + return false; + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + public static void main(String[] args) { + System.exit(check(URI.create("http://127.0.0.1:8080/health")) ? 0 : 1); + } +} diff --git a/apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/Item.java b/apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/Item.java new file mode 100644 index 0000000..f3c47b8 --- /dev/null +++ b/apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/Item.java @@ -0,0 +1,3 @@ +package dev.simpleapibenchmark; + +public record Item(long id, String name, int price) {} diff --git a/apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/ItemRepository.java b/apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/ItemRepository.java new file mode 100644 index 0000000..465abc2 --- /dev/null +++ b/apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/ItemRepository.java @@ -0,0 +1,9 @@ +package dev.simpleapibenchmark; + +import java.sql.SQLException; +import java.util.Optional; + +@FunctionalInterface +public interface ItemRepository { + Optional find(long id) throws SQLException; +} diff --git a/apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/JdbcItemRepository.java b/apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/JdbcItemRepository.java new file mode 100644 index 0000000..c8462b4 --- /dev/null +++ b/apps/java-spring-boot/src/main/java/dev/simpleapibenchmark/JdbcItemRepository.java @@ -0,0 +1,30 @@ +package dev.simpleapibenchmark; + +import java.sql.SQLException; +import java.util.Optional; +import javax.sql.DataSource; + +final class JdbcItemRepository implements ItemRepository { + private final DataSource dataSource; + + JdbcItemRepository(DataSource dataSource) { + this.dataSource = dataSource; + } + + @Override + public Optional find(long id) throws SQLException { + try (var connection = dataSource.getConnection(); + var statement = connection.prepareStatement( + "SELECT id, name, price FROM items WHERE id = ?")) { + statement.setLong(1, id); + statement.setQueryTimeout(3); + try (var result = statement.executeQuery()) { + if (!result.next()) { + return Optional.empty(); + } + return Optional.of(new Item(result.getLong("id"), result.getString("name"), + result.getInt("price"))); + } + } + } +} diff --git a/apps/java-spring-boot/src/main/resources/application.properties b/apps/java-spring-boot/src/main/resources/application.properties new file mode 100644 index 0000000..b857d23 --- /dev/null +++ b/apps/java-spring-boot/src/main/resources/application.properties @@ -0,0 +1,13 @@ +server.port=8080 +server.http2.enabled=false +server.shutdown=graceful +server.tomcat.accesslog.enabled=false +server.error.include-message=never +server.error.include-stacktrace=never +server.error.include-binding-errors=never +spring.lifecycle.timeout-per-shutdown-phase=5s +spring.threads.virtual.enabled=false +spring.main.banner-mode=off +spring.main.log-startup-info=false +spring.sql.init.mode=never +logging.level.root=OFF From e449c0397ec80c2d754b40528b73f9019e991ba8 Mon Sep 17 00:00:00 2001 From: Yuma Takino <36617106+tappe9@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:43:53 +0900 Subject: [PATCH 05/15] build: commit verified Gradle wrapper and locked dependency artifacts Attach the six reviewed bootstrap blobs and remove the temporary contents-write object-import job. Subsequent builds validate committed lock/checksum data without regeneration. --- .github/workflows/java-development.yml | 104 +- apps/java-spring-boot/gradle.lockfile | 73 ++ .../gradle/verification-metadata.xml | 951 ++++++++++++++++++ .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 47505 bytes .../gradle/wrapper/gradle-wrapper.properties | 10 + apps/java-spring-boot/gradlew | 248 +++++ apps/java-spring-boot/gradlew.bat | 82 ++ 7 files changed, 1378 insertions(+), 90 deletions(-) create mode 100644 apps/java-spring-boot/gradle.lockfile create mode 100644 apps/java-spring-boot/gradle/verification-metadata.xml create mode 100644 apps/java-spring-boot/gradle/wrapper/gradle-wrapper.jar create mode 100644 apps/java-spring-boot/gradle/wrapper/gradle-wrapper.properties create mode 100755 apps/java-spring-boot/gradlew create mode 100644 apps/java-spring-boot/gradlew.bat diff --git a/.github/workflows/java-development.yml b/.github/workflows/java-development.yml index 52a4cc8..9d82a47 100644 --- a/.github/workflows/java-development.yml +++ b/.github/workflows/java-development.yml @@ -12,22 +12,12 @@ jobs: if: github.repository == 'tappe9/simple-api-benchmark' && github.ref == 'refs/heads/feat/add-java-spring-boot' runs-on: ubuntu-24.04 timeout-minutes: 15 - permissions: - contents: read - actions: read steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 with: ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - artifact-ids: '10482171726' - run-id: '35185291659' - github-token: ${{ github.token }} - path: .cache/java-baseline - merge-multiple: true - name: Prepare the exact checksum-verified JDK run: | set -euo pipefail @@ -38,32 +28,7 @@ jobs: echo "JAVA_HOME=$RUNNER_TEMP/java67/jdk" >> "$GITHUB_ENV" echo "GRADLE_USER_HOME=$RUNNER_TEMP/java67/gradle-home" >> "$GITHUB_ENV" echo "$RUNNER_TEMP/java67/jdk/bin" >> "$GITHUB_PATH" - - name: Import only the immutable bootstrap dependency candidates - run: | - set -euo pipefail - python3 - <<'PY' - from pathlib import Path - import hashlib - source = Path('.cache/java-baseline/generated') - destination = Path('apps/java-spring-boot') - names = ('gradlew', 'gradlew.bat', 'gradle/wrapper/gradle-wrapper.jar', 'gradle/wrapper/gradle-wrapper.properties', 'gradle.lockfile', 'gradle/verification-metadata.xml') - for name in names: - path = source / name - if path.is_symlink() or not path.is_file(): - raise RuntimeError('missing or unsafe dependency candidate: ' + name) - data = path.read_bytes() - if name.endswith('.jar') and hashlib.sha256(data).hexdigest() != '7a9ce74cff467ca1bf60a4fcd9f05185acceda4d0f382434d393e17864262c5d': - raise RuntimeError('wrapper checksum mismatch') - target = destination / name - target.parent.mkdir(parents=True, exist_ok=True) - target.write_bytes(data) - target.chmod(0o755 if name == 'gradlew' else 0o644) - print(name, hashlib.sha256(data).hexdigest()) - print((source / 'gradle.lockfile').read_text()) - for name in ('jdk-image.txt', 'jre-image.txt', 'jdk-java-version.txt', 'jre-java-version.txt', 'temurin-jre-x64.json', 'temurin-jre-aarch64.json'): - print(name, Path('.cache/java-baseline', name).read_text()) - PY - - name: Execute locked Java tests and production distribution + - name: Execute committed locked Java tests and production distribution run: | set -euo pipefail mkdir -p .cache/java-development @@ -71,6 +36,19 @@ jobs: sha256sum gradle.lockfile gradle/verification-metadata.xml gradle/wrapper/* > ../../.cache/java-development/dependencies.before ./gradlew --no-daemon test installDist 2>&1 | tee ../../.cache/java-development/java-tests.log sha256sum --check ../../.cache/java-development/dependencies.before + git diff --exit-code -- gradle.lockfile gradle/verification-metadata.xml gradle/wrapper + - name: Confirm exact ARM64 JDK release metadata + if: always() + run: | + python3 - <<'PY' + import json + import urllib.request + url = 'https://api.adoptium.net/v3/assets/version/25.0.4.1%2B1?architecture=aarch64&heap_size=normal&image_type=jdk&jvm_impl=hotspot&os=linux&project=jdk&vendor=eclipse' + with urllib.request.urlopen(url, timeout=60) as response: + releases = json.load(response) + for release in releases: + print(json.dumps({'release_name': release['release_name'], 'version': release['version_data'], 'package': release['binaries'][0]['package']}, indent=2)) + PY - name: Export exact source and test evidence if: always() run: | @@ -89,57 +67,3 @@ jobs: include-hidden-files: true retention-days: 14 if-no-files-found: error - import-objects: - needs: unit - if: github.repository == 'tappe9/simple-api-benchmark' && github.ref == 'refs/heads/feat/add-java-spring-boot' - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - contents: write - actions: read - steps: - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - artifact-ids: '10482171726' - run-id: '35185291659' - github-token: ${{ github.token }} - path: dependency-candidates - merge-multiple: true - - name: Import six allowlisted Git blobs without executing code or updating refs - env: - GH_TOKEN: ${{ github.token }} - run: | - python3 - <<'PY' - import base64 - import hashlib - import json - import os - from pathlib import Path - import urllib.request - source = Path('dependency-candidates/generated') - names = ('gradlew', 'gradlew.bat', 'gradle/wrapper/gradle-wrapper.jar', 'gradle/wrapper/gradle-wrapper.properties', 'gradle.lockfile', 'gradle/verification-metadata.xml') - candidates = [] - for name in names: - path = source / name - if path.is_symlink() or not path.is_file() or not 0 < path.stat().st_size < 200_000: - raise RuntimeError('missing or unsafe dependency candidate') - data = path.read_bytes() - digest = hashlib.sha256(data).hexdigest() - if name.endswith('.jar') and digest != '7a9ce74cff467ca1bf60a4fcd9f05185acceda4d0f382434d393e17864262c5d': - raise RuntimeError('wrapper checksum mismatch') - candidates.append((name, data, digest)) - output = [] - for name, data, digest in candidates: - request = urllib.request.Request( - 'https://api.github.com/repos/tappe9/simple-api-benchmark/git/blobs', - data=json.dumps({'encoding': 'base64', 'content': base64.b64encode(data).decode('ascii')}).encode('utf-8'), - headers={'Authorization': 'Bearer ' + os.environ['GH_TOKEN'], 'Accept': 'application/vnd.github+json', 'Content-Type': 'application/json', 'X-GitHub-Api-Version': '2022-11-28'}, - method='POST') - with urllib.request.urlopen(request, timeout=30) as response: - blob = json.load(response) - expected = hashlib.sha1(b'blob ' + str(len(data)).encode('ascii') + b'\0' + data).hexdigest() - if blob.get('sha') != expected: - raise RuntimeError('Git blob identity mismatch') - output.append({'path': 'apps/java-spring-boot/' + name, 'mode': '100755' if name == 'gradlew' else '100644', 'type': 'blob', 'sha': expected, 'sha256': digest}) - print(json.dumps(output, indent=2)) - PY diff --git a/apps/java-spring-boot/gradle.lockfile b/apps/java-spring-boot/gradle.lockfile new file mode 100644 index 0000000..1f12d4a --- /dev/null +++ b/apps/java-spring-boot/gradle.lockfile @@ -0,0 +1,73 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +# To regenerate this file, run: ./gradlew :dependencies --write-locks +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath +ch.qos.logback:logback-classic:1.5.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath +com.zaxxer:HikariCP:7.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.17.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.17.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-to-slf4j:2.25.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-core:11.0.24=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.24=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.24=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.checkerframework:checker-qual:3.55.1=runtimeClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath +org.postgresql:postgresql:42.7.13=runtimeClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.18=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-autoconfigure:4.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-dependencies:4.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jdbc:4.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-persistence:4.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-sql:4.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jdbc:4.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-transaction:4.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-jdbc:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.9=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-tx:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +empty=annotationProcessor,testAnnotationProcessor diff --git a/apps/java-spring-boot/gradle/verification-metadata.xml b/apps/java-spring-boot/gradle/verification-metadata.xml new file mode 100644 index 0000000..015c204 --- /dev/null +++ b/apps/java-spring-boot/gradle/verification-metadata.xml @@ -0,0 +1,951 @@ + + + + true + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/java-spring-boot/gradle/wrapper/gradle-wrapper.jar b/apps/java-spring-boot/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..eddabd2eef8d94a5437d6168ff9c87a78ff725b3 GIT binary patch literal 47505 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/apps/java-spring-boot/gradlew.bat b/apps/java-spring-boot/gradlew.bat new file mode 100644 index 0000000..8508ef6 --- /dev/null +++ b/apps/java-spring-boot/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% From bcbc0f8b9b49ab1ef8e12dd99f118ee4781b87bf Mon Sep 17 00:00:00 2001 From: Yuma Takino <36617106+tappe9@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:47:22 +0900 Subject: [PATCH 06/15] test: specify fail-closed Java lifecycle and pool acceptance checks Cover actual application startup failures versus Docker launch errors, secret suppression, graceful exit, OOM/restart rejection, missing inspect fields and bounded pool counts before adding the acceptance harness. --- .github/workflows/java-development.yml | 18 ++++- tests/test_java_spring_boot_acceptance.py | 92 +++++++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 tests/test_java_spring_boot_acceptance.py diff --git a/.github/workflows/java-development.yml b/.github/workflows/java-development.yml index 9d82a47..2f6ca22 100644 --- a/.github/workflows/java-development.yml +++ b/.github/workflows/java-development.yml @@ -37,24 +37,38 @@ jobs: ./gradlew --no-daemon test installDist 2>&1 | tee ../../.cache/java-development/java-tests.log sha256sum --check ../../.cache/java-development/dependencies.before git diff --exit-code -- gradle.lockfile gradle/verification-metadata.xml gradle/wrapper + - name: Verify acceptance regression tests are red before the harness exists + run: | + set -euo pipefail + set +e + python3 -m unittest discover -s tests -p test_java_spring_boot_acceptance.py -v > .cache/java-development/acceptance-red.log 2>&1 + status=$? + set -e + cat .cache/java-development/acceptance-red.log + test "$status" -ne 0 + grep -q "No module named 'test_java_spring_boot_service'" .cache/java-development/acceptance-red.log + test ! -f tests/test_java_spring_boot_service.py - name: Confirm exact ARM64 JDK release metadata if: always() run: | python3 - <<'PY' import json import urllib.request - url = 'https://api.adoptium.net/v3/assets/version/25.0.4.1%2B1?architecture=aarch64&heap_size=normal&image_type=jdk&jvm_impl=hotspot&os=linux&project=jdk&vendor=eclipse' + url = 'https://api.adoptium.net/v3/assets/feature_releases/25/ga?architecture=aarch64&heap_size=normal&image_type=jdk&jvm_impl=hotspot&os=linux&page_size=1&project=jdk&sort_order=DESC&vendor=eclipse' with urllib.request.urlopen(url, timeout=60) as response: releases = json.load(response) for release in releases: + if release['release_name'] != 'jdk-25.0.4.1+1': + raise RuntimeError('unexpected upstream Java release') print(json.dumps({'release_name': release['release_name'], 'version': release['version_data'], 'package': release['binaries'][0]['package']}, indent=2)) + if len(releases) != 1: + raise RuntimeError('expected exactly one upstream release') PY - name: Export exact source and test evidence if: always() run: | set -euo pipefail mkdir -p .cache/java-development - git bundle create .cache/java-development/source.bundle --all git rev-parse HEAD HEAD^{tree} > .cache/java-development/source-identity.txt if test -d apps/java-spring-boot/build/test-results; then cp -a apps/java-spring-boot/build/test-results .cache/java-development/ diff --git a/tests/test_java_spring_boot_acceptance.py b/tests/test_java_spring_boot_acceptance.py new file mode 100644 index 0000000..6be5736 --- /dev/null +++ b/tests/test_java_spring_boot_acceptance.py @@ -0,0 +1,92 @@ +"""Regression tests for fail-closed Java production acceptance checks.""" + +import copy +import unittest + +from test_java_spring_boot_service import ( + validate_failed_startup, + validate_pool_count, + validate_shutdown, +) + + +def exited(code=143): + return { + "State": { + "Running": False, + "Restarting": False, + "Dead": False, + "OOMKilled": False, + "Status": "exited", + "ExitCode": code, + }, + "RestartCount": 0, + } + + +class AcceptanceFailureTests(unittest.TestCase): + def test_graceful_exit_accepts_only_normal_or_sigterm_exit(self): + for code in (0, 143): + validate_shutdown(exited(code), "0\n") + for code in (1, 137, 139, True): + with self.assertRaises(RuntimeError): + validate_shutdown(exited(code), "0") + + def test_shutdown_rejects_oom_restart_running_or_live_database_sessions(self): + for key in ("Running", "Restarting", "Dead", "OOMKilled"): + value = exited() + value["State"][key] = True + with self.assertRaises(RuntimeError): + validate_shutdown(value, "0") + value = exited() + value["RestartCount"] = 1 + with self.assertRaises(RuntimeError): + validate_shutdown(value, "0") + for count in ("1", "10", "", "bad", "-1"): + with self.assertRaises(RuntimeError): + validate_shutdown(exited(), count) + + def test_missing_inspect_fields_fail_instead_of_defaulting_to_success(self): + for key in exited()["State"]: + value = exited() + del value["State"][key] + with self.assertRaises(RuntimeError): + validate_shutdown(value, "0") + value = exited() + del value["RestartCount"] + with self.assertRaises(RuntimeError): + validate_shutdown(value, "0") + + def test_pool_count_requires_one_bounded_integer(self): + for value in ("0", "1\n", "10"): + validate_pool_count(value) + for value in ("11", "-1", "1.5", "true", "", "1\n2", "nan"): + with self.assertRaises(RuntimeError): + validate_pool_count(value) + + def test_startup_failure_must_be_an_actual_sanitized_application_failure(self): + validate_failed_startup(exited(1), "application startup failed\n") + for code in (0, 125, 126, 127, 137, 143): + with self.assertRaises(RuntimeError): + validate_failed_startup(exited(code), "application startup failed\n") + for logs in ( + "docker command failed", + "application startup failed\npassword=secret-67", + "application startup failed\njdbc:postgresql://postgres", + "application startup failed\nSELECT * FROM items", + "application startup failed\nat org.example.Database.connect", + ): + with self.assertRaises(RuntimeError): + validate_failed_startup(exited(1), logs) + + def test_startup_failure_rejects_oom_and_running_containers(self): + baseline = exited(1) + for key in ("Running", "Restarting", "Dead", "OOMKilled"): + value = copy.deepcopy(baseline) + value["State"][key] = True + with self.assertRaises(RuntimeError): + validate_failed_startup(value, "application startup failed") + + +if __name__ == "__main__": + unittest.main() From d3eacff001be139cbf54f88377db0ca9f4269973 Mon Sep 17 00:00:00 2001 From: Yuma Takino <36617106+tappe9@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:52:31 +0900 Subject: [PATCH 07/15] test: implement Java production acceptance and static locked metadata Add strict lifecycle/pool evidence validation, isolated real-PostgreSQL acceptance, native BIGINT and changed-row checks, non-root/resource/process constraints and owned cleanup. Read runtime versions from committed dependency evidence without invoking a toolchain. Container integration follows before these gates enter normal CI. --- .github/workflows/java-development.yml | 63 +++----- benchmark/java_versions.py | 97 +++++++++++ tests/test_java_spring_boot_service.py | 216 +++++++++++++++++++++++++ 3 files changed, 332 insertions(+), 44 deletions(-) create mode 100644 benchmark/java_versions.py create mode 100644 tests/test_java_spring_boot_service.py diff --git a/.github/workflows/java-development.yml b/.github/workflows/java-development.yml index 2f6ca22..33dfc0f 100644 --- a/.github/workflows/java-development.yml +++ b/.github/workflows/java-development.yml @@ -18,61 +18,36 @@ jobs: ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false - - name: Prepare the exact checksum-verified JDK + - name: Verify fail-closed acceptance helpers and static Java metadata run: | - set -euo pipefail - curl --fail --silent --show-error --location --max-time 180 'https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.4.1%2B1/OpenJDK25U-jdk_x64_linux_hotspot_25.0.4.1_1.tar.gz' -o "$RUNNER_TEMP/java67.tar.gz" - echo "dbb698396d478e7fa2b1e50f4103324b2a99b90569ee27c33f2261f9215cf41e $RUNNER_TEMP/java67.tar.gz" | sha256sum -c - - mkdir -p "$RUNNER_TEMP/java67/jdk" - tar -xzf "$RUNNER_TEMP/java67.tar.gz" --strip-components=1 -C "$RUNNER_TEMP/java67/jdk" - echo "JAVA_HOME=$RUNNER_TEMP/java67/jdk" >> "$GITHUB_ENV" - echo "GRADLE_USER_HOME=$RUNNER_TEMP/java67/gradle-home" >> "$GITHUB_ENV" - echo "$RUNNER_TEMP/java67/jdk/bin" >> "$GITHUB_PATH" - - name: Execute committed locked Java tests and production distribution + python3 -m unittest discover -s tests -p test_java_spring_boot_acceptance.py -v + python3 - <<'PY' + import json + from pathlib import Path + from benchmark.java_versions import pinned_versions + print(json.dumps(pinned_versions(Path('apps/java-spring-boot')), indent=2)) + PY + - name: Read exact ARM64 JDK asset identity from its public release run: | set -euo pipefail mkdir -p .cache/java-development - cd apps/java-spring-boot - sha256sum gradle.lockfile gradle/verification-metadata.xml gradle/wrapper/* > ../../.cache/java-development/dependencies.before - ./gradlew --no-daemon test installDist 2>&1 | tee ../../.cache/java-development/java-tests.log - sha256sum --check ../../.cache/java-development/dependencies.before - git diff --exit-code -- gradle.lockfile gradle/verification-metadata.xml gradle/wrapper - - name: Verify acceptance regression tests are red before the harness exists - run: | - set -euo pipefail - set +e - python3 -m unittest discover -s tests -p test_java_spring_boot_acceptance.py -v > .cache/java-development/acceptance-red.log 2>&1 - status=$? - set -e - cat .cache/java-development/acceptance-red.log - test "$status" -ne 0 - grep -q "No module named 'test_java_spring_boot_service'" .cache/java-development/acceptance-red.log - test ! -f tests/test_java_spring_boot_service.py - - name: Confirm exact ARM64 JDK release metadata - if: always() - run: | + curl --fail --silent --show-error --location --max-time 60 'https://api.github.com/repos/adoptium/temurin25-binaries/releases/tags/jdk-25.0.4.1%2B1' -o .cache/java-development/release.json python3 - <<'PY' import json - import urllib.request - url = 'https://api.adoptium.net/v3/assets/feature_releases/25/ga?architecture=aarch64&heap_size=normal&image_type=jdk&jvm_impl=hotspot&os=linux&page_size=1&project=jdk&sort_order=DESC&vendor=eclipse' - with urllib.request.urlopen(url, timeout=60) as response: - releases = json.load(response) - for release in releases: - if release['release_name'] != 'jdk-25.0.4.1+1': - raise RuntimeError('unexpected upstream Java release') - print(json.dumps({'release_name': release['release_name'], 'version': release['version_data'], 'package': release['binaries'][0]['package']}, indent=2)) - if len(releases) != 1: - raise RuntimeError('expected exactly one upstream release') + from pathlib import Path + data = json.loads(Path('.cache/java-development/release.json').read_text()) + wanted = 'OpenJDK25U-jdk_aarch64_linux_hotspot_25.0.4.1_1.tar.gz' + assets = [asset for asset in data['assets'] if asset['name'] == wanted or asset['name'] == wanted + '.sha256.txt'] + for asset in assets: + print(json.dumps({key: asset.get(key) for key in ('id', 'name', 'digest', 'size', 'browser_download_url')}, indent=2)) + if not any(asset['name'] == wanted for asset in assets): + raise RuntimeError('the selected ARM64 JDK is not present in its release') PY - - name: Export exact source and test evidence + - name: Export exact source identity if: always() run: | - set -euo pipefail mkdir -p .cache/java-development git rev-parse HEAD HEAD^{tree} > .cache/java-development/source-identity.txt - if test -d apps/java-spring-boot/build/test-results; then - cp -a apps/java-spring-boot/build/test-results .cache/java-development/ - fi - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 if: always() with: diff --git a/benchmark/java_versions.py b/benchmark/java_versions.py new file mode 100644 index 0000000..83fe818 --- /dev/null +++ b/benchmark/java_versions.py @@ -0,0 +1,97 @@ +"""Read the Java candidate's committed versions without running a toolchain.""" + +import hashlib +import re +from pathlib import Path +from xml.etree import ElementTree + +from .results import BenchmarkFailure, require + +JAVA_VERSION = r"[0-9]+\.[0-9]+\.[0-9]+(?:\.[0-9]+)?\+[0-9]+" +WRAPPER_SHA256 = "7a9ce74cff467ca1bf60a4fcd9f05185acceda4d0f382434d393e17864262c5d" +DEPENDENCY_FILES = ( + "gradlew", + "gradlew.bat", + "gradle/wrapper/gradle-wrapper.jar", + "gradle/wrapper/gradle-wrapper.properties", + "gradle.lockfile", + "gradle/verification-metadata.xml", +) + + +def pinned_versions(root: Path) -> dict[str, str]: + def read(name): + path = root / name + require(path.is_file() and not path.is_symlink(), "missing Java version file: " + name) + return path.read_text(encoding="utf-8") + + def unique(pattern, text): + values = re.findall(pattern, text, re.MULTILINE) + require(len(values) == 1, "ambiguous or missing locked Java version") + return values[0] + + java = read(".java-version").strip() + require(re.fullmatch(JAVA_VERSION, java) is not None, "Java must include a fixed build number") + lock = read("gradle.lockfile") + coordinates = {} + for line in lock.splitlines(): + if not line or line.startswith("#") or line.startswith("empty="): + continue + found = re.fullmatch(r"([^:=]+):([^:=]+):([^=]+)=([^=]+)", line) + require(found is not None, "invalid Gradle dependency lock entry") + coordinate = found[1] + ":" + found[2] + require(coordinate not in coordinates, "duplicate Gradle dependency lock entry") + require( + re.fullmatch(r"[0-9]+(?:\.[0-9]+)+", found[3]) is not None, + "dependency locks must contain exact release versions", + ) + coordinates[coordinate] = (found[3], set(found[4].split(","))) + + def runtime(coordinate): + require(coordinate in coordinates, "missing locked Java runtime: " + coordinate) + version, configurations = coordinates[coordinate] + require("runtimeClasspath" in configurations, "Java runtime is not locked for execution") + return version + + boot = runtime("org.springframework.boot:spring-boot-dependencies") + require( + unique(r"org\.springframework\.boot:spring-boot-dependencies:([0-9.]+)", read("build.gradle")) == boot, + "Spring Boot BOM and committed lock disagree", + ) + wrapper = read("gradle/wrapper/gradle-wrapper.properties") + gradle = unique(r"^distributionUrl=https\\://services\.gradle\.org/distributions/gradle-([0-9.]+)-bin\.zip$", wrapper) + unique(r"^distributionSha256Sum=([0-9a-f]{64})$", wrapper) + require( + hashlib.sha256((root / "gradle/wrapper/gradle-wrapper.jar").read_bytes()).hexdigest() == WRAPPER_SHA256, + "Gradle wrapper checksum mismatch", + ) + try: + document = ElementTree.fromstring(read("gradle/verification-metadata.xml")) + except ElementTree.ParseError as error: + raise BenchmarkFailure("invalid Java dependency verification metadata") from error + namespace = {"v": "https://schema.gradle.org/dependency-verification"} + require( + document.findtext("v:configuration/v:verify-metadata", namespaces=namespace) == "true", + "Gradle metadata verification must remain enabled", + ) + require( + document.find("v:configuration/v:trusted-artifacts", namespace) is None, + "Java dependency verification must not trust wildcard artifacts", + ) + artifacts = document.findall("v:components/v:component/v:artifact", namespace) + require(bool(artifacts), "Java dependency verification must not be empty") + for artifact in artifacts: + checksums = artifact.findall("v:sha256", namespace) + require( + bool(checksums) and all(re.fullmatch(r"[0-9a-f]{64}", value.get("value", "")) for value in checksums), + "every Java dependency artifact needs a SHA-256 checksum", + ) + return { + "java": java, + "spring-boot": boot, + "tomcat": runtime("org.apache.tomcat.embed:tomcat-embed-core"), + "postgresql": runtime("org.postgresql:postgresql"), + "hikaricp": runtime("com.zaxxer:HikariCP"), + "jackson": runtime("tools.jackson.core:jackson-databind"), + "gradle": gradle, + } diff --git a/tests/test_java_spring_boot_service.py b/tests/test_java_spring_boot_service.py new file mode 100644 index 0000000..3cfdda2 --- /dev/null +++ b/tests/test_java_spring_boot_service.py @@ -0,0 +1,216 @@ +"""Verify the locked host build and the real Java/PostgreSQL production container.""" + +import argparse +import concurrent.futures +import hashlib +import json +import os +import re +import subprocess +import sys +import time +import uuid +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from benchmark.contract_test import Case, assert_response, load_cases, read_response +from benchmark.environment import validate_processes, validate_state +from benchmark.java_versions import DEPENDENCY_FILES, pinned_versions + +APP = ROOT / "apps/java-spring-boot" +SERVICE = "java-spring-boot" +BASE_URL = "http://127.0.0.1:8080" +SESSION_COUNT = "SELECT count(*) FROM pg_stat_activity WHERE application_name = 'simple-api-java';" + + +def require(condition, message): + if not condition: + raise RuntimeError(message) + + +def run(command, *, timeout=60, cwd=ROOT): + completed = subprocess.run(command, cwd=cwd, text=True, capture_output=True, timeout=timeout, check=False) + if completed.returncode: + raise RuntimeError(f"command failed ({completed.returncode}): {command[0]}\n{completed.stdout}\n{completed.stderr}") + return completed.stdout + + +def count_value(value): + require(type(value) is str and re.fullmatch(r"[0-9]+\s*", value) is not None, "invalid database session count") + return int(value) + + +def validate_pool_count(value): + require(0 <= count_value(value) <= 10, "Java connection pool exceeded ten sessions") + + +def stopped_state(container): + try: + state = container["State"] + require(all(state[key] is False for key in ("Running", "Restarting", "Dead", "OOMKilled")), "container is not safely stopped") + require(state["Status"] == "exited", "container did not exit") + require(type(state["ExitCode"]) is int, "missing integer exit status") + require(type(container["RestartCount"]) is int and container["RestartCount"] == 0, "container restarted") + return state + except (KeyError, TypeError) as error: + raise RuntimeError("incomplete stopped-container evidence") from error + + +def validate_shutdown(container, remaining): + require(stopped_state(container)["ExitCode"] in (0, 143), "JVM did not exit gracefully") + require(count_value(remaining) == 0, "JVM left database sessions open") + + +def validate_failed_startup(container, logs): + require(stopped_state(container)["ExitCode"] == 1, "expected an actual application startup failure") + require(type(logs) is str and "application startup failed" in logs, "missing application failure evidence") + lowered = logs.lower() + require(not any(value in lowered for value in ("password", "secret-67", "jdbc:", "select ", "at org.")), "startup log disclosed internal details") + + +def dependency_hashes(): + return {name: hashlib.sha256((APP / name).read_bytes()).hexdigest() for name in DEPENDENCY_FILES} + + +def static_checks(): + versions = pinned_versions(APP) + require(versions["java"] == "25.0.4.1+1", "unexpected JDK build") + require(versions["spring-boot"] == "4.1.1", "unexpected Spring Boot version") + dockerfile = (APP / "Dockerfile").read_text() + images = re.findall(r"^FROM (\S+)", dockerfile, re.MULTILINE) + require(bool(images) and all(re.search(r"@sha256:[0-9a-f]{64}$", image) for image in images), "Docker bases must be digest pinned") + require("USER 10001:10001" in dockerfile, "Java production image must run non-root") + source = APP / "src/main/java/dev/simpleapibenchmark" + fibonacci = (source / "Fibonacci.java").read_text() + require("calculate(n - 1) + calculate(n - 2)" in fibonacci, "CPU workload must remain direct recursion") + require("Fibonacci.calculate(30)" in (source / "ApiController.java").read_text(), "CPU calculation must occur per request") + print("Java static versions:", json.dumps(versions, sort_keys=True)) + + +def host_checks(): + before = dependency_hashes() + output = run([str(APP / "gradlew"), "--no-daemon", "test", "installDist"], cwd=APP, timeout=900) + print(output) + require(dependency_hashes() == before, "validation changed committed dependency evidence") + libraries = {path.name for path in (APP / "build/install/java-spring-boot/lib").glob("*.jar")} + versions = pinned_versions(APP) + for name, version in (("spring-boot", versions["spring-boot"]), ("tomcat-embed-core", versions["tomcat"]), ("postgresql", versions["postgresql"]), ("HikariCP", versions["hikaricp"]), ("jackson-databind", versions["jackson"])): + require(f"{name}-{version}.jar" in libraries, "runtime distribution does not match locked metadata") + require(not any(name.startswith(("junit-", "mockito-", "spring-test-")) for name in libraries), "test dependencies entered the production distribution") + + +def container_checks(): + project = "sab-java-acceptance-" + uuid.uuid4().hex[:12] + compose = ["docker", "compose", "-p", project, "-f", str(ROOT / "docker-compose.yml")] + + def dc(*args, timeout=60): + return run([*compose, *args], timeout=timeout) + + def inspect(identifier): + return json.loads(run(["docker", "inspect", identifier]))[0] + + def sql(statement): + return dc("exec", "-T", "postgres", "psql", "--username", "benchmark", "--dbname", "benchmark", "--no-psqlrc", "--tuples-only", "--no-align", "--set", "ON_ERROR_STOP=1", "--command", statement).strip() + + def response(case): + assert_response(case, read_response(BASE_URL, case.path, timeout=10), SERVICE) + + def wait_stopped(identifier, timeout=30): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + state = inspect(identifier) + if state["State"]["Running"] is False: + return state + time.sleep(0.2) + raise RuntimeError("Java container did not stop within its deadline") + + failure = None + try: + dc("build", SERVICE, timeout=900) + dc("up", "-d", "--wait", "--wait-timeout", "90", SERVICE, timeout=150) + identifier = dc("ps", "-q", SERVICE).strip() + require(re.fullmatch(r"[0-9a-f]{64}", identifier) is not None, "missing full Java container identity") + state = inspect(identifier) + identity = validate_state(state, project, SERVICE) + require(state["Config"]["User"] == "10001:10001", "Java image is not non-root") + require("ALL" in state["HostConfig"]["CapDrop"], "Linux capabilities were not dropped") + require(any(value.startswith("no-new-privileges") for value in state["HostConfig"]["SecurityOpt"]), "no-new-privileges is missing") + ports = state["HostConfig"]["PortBindings"] + require(ports == {"8080/tcp": [{"HostIp": "127.0.0.1", "HostPort": "8080"}]}, "unexpected Java host ports") + require(set(state["NetworkSettings"]["Networks"]) == {project + "_benchmark"}, "Java escaped its owned benchmark network") + database = inspect(dc("ps", "-q", "postgres").strip()) + require(not database["HostConfig"]["PortBindings"], "PostgreSQL must not publish a host port") + validate_processes(state, run(["docker", "top", identifier, "-eo", "pid,args"])) + runtime = run(["docker", "exec", identifier, "java", "-XshowSettings:properties", "-version"]) + # Java emits settings on stderr; query its release manifest independently. + release = run(["docker", "exec", identifier, "cat", "/opt/java/openjdk/release"]) + require('JAVA_RUNTIME_VERSION="25.0.4.1+1' in release, "production JRE and host JDK differ") + require(runtime == "", "unexpected Java version stdout") + for case in load_cases(): + response(case) + for value in (-(2**63), 2**63 - 1, 9007199254740993, 0): + sql(f"INSERT INTO items (id,name,price) VALUES ({value},'Boundary',7);") + response(Case(f"/db/{value}", 200, {"id": value, "name": "Boundary", "price": 7})) + for value in ("9223372036854775808", "-9223372036854775809", "42junk", "1.5"): + response(Case("/db/" + value, 400, {"error": "invalid id"})) + sql("UPDATE items SET name='Live update', price=73 WHERE id=42;") + live = Case("/db/42", 200, {"id": 42, "name": "Live update", "price": 73}) + response(live) + with concurrent.futures.ThreadPoolExecutor(max_workers=24) as executor: + futures = [executor.submit(response, live) for _ in range(96)] + while any(not future.done() for future in futures): + validate_pool_count(sql(SESSION_COUNT)) + time.sleep(0.02) + for future in futures: + future.result() + validate_pool_count(sql(SESSION_COUNT)) + validate_state(inspect(identifier), project, SERVICE, identity) + validate_processes(inspect(identifier), run(["docker", "top", identifier, "-eo", "pid,args"])) + sql("ALTER TABLE items RENAME TO unavailable_items;") + response(Case("/db/42", 500, {"error": "internal server error"})) + sql("ALTER TABLE unavailable_items RENAME TO items;") + response(live) + started = time.monotonic() + run(["docker", "kill", "--signal", "TERM", identifier]) + stopped = wait_stopped(identifier, 15) + require(time.monotonic() - started < 15, "graceful JVM shutdown exceeded its deadline") + validate_shutdown(stopped, sql(SESSION_COUNT)) + for index, override in enumerate(("DATABASE_PASSWORD=secret-67", "DATABASE_HOST=127.0.0.1", "DATABASE_PORT=65536")): + name = f"{project}-failure-{index}" + dc("run", "--detach", "--no-deps", "--name", name, "--env", override, SERVICE, timeout=30) + failed = wait_stopped(name, 30) + logs = run(["docker", "logs", name]) + # Application stderr is returned separately by Docker; capture both streams. + result = subprocess.run(["docker", "logs", name], capture_output=True, text=True, timeout=10, check=True) + validate_failed_startup(failed, logs + result.stderr) + require(sql(SESSION_COUNT) == "0", "failed startup left database sessions open") + print("Java production acceptance: live SQL, BIGINT, pool, limits, process isolation, failures and shutdown passed") + except BaseException as error: + failure = error + raise + finally: + try: + dc("down", "--remove-orphans", "--volumes", "--timeout", "10", timeout=90) + label = "label=com.docker.compose.project=" + project + require(not run(["docker", "ps", "-aq", "--filter", label]).strip(), "owned acceptance containers remain") + require(not run(["docker", "network", "ls", "-q", "--filter", label]).strip(), "owned acceptance networks remain") + except Exception as cleanup: + if failure is not None: + raise RuntimeError(f"Java acceptance and owned cleanup both failed: {failure}; {cleanup}") from failure + raise + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--static", action="store_true") + args = parser.parse_args() + static_checks() + if not args.static: + host_checks() + container_checks() + + +if __name__ == "__main__": + main() From 4d44205004e4fc76e2c9aff644257a2d6d6d4ac6 Mon Sep 17 00:00:00 2001 From: Yuma Takino <36617106+tappe9@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:57:06 +0900 Subject: [PATCH 08/15] feat: add verified Java production container and stage registry integration Pin base digests and exact AMD64/ARM64 hotfix archive checksums. Build only locked production dependencies and run a non-root direct JVM. Prepare narrowly scoped registry, static metadata and Java-only CI edits on a read-only runner, with real-container and shared-contract gates before explicit blob import. Preserve all existing official cohort and result bytes. --- .github/java67-prepare.py | 91 ++++++++++++++++++ .github/workflows/java-development.yml | 124 ++++++++++++++++++++----- apps/java-spring-boot/.dockerignore | 6 ++ apps/java-spring-boot/Dockerfile | 40 ++++++++ tests/test_benchmark_java_versions.py | 71 ++++++++++++++ 5 files changed, 308 insertions(+), 24 deletions(-) create mode 100644 .github/java67-prepare.py create mode 100644 apps/java-spring-boot/.dockerignore create mode 100644 apps/java-spring-boot/Dockerfile create mode 100644 tests/test_benchmark_java_versions.py diff --git a/.github/java67-prepare.py b/.github/java67-prepare.py new file mode 100644 index 0000000..285733b --- /dev/null +++ b/.github/java67-prepare.py @@ -0,0 +1,91 @@ +"""Temporary, read-only-runner preparation of explicitly scoped Java integration edits. + +This script changes only the disposable checkout and exports reviewable files. +It has no credentials and does not create Git objects, commits or remote refs. +Remove it before the final PR gates. +""" + +import json +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def replace(name, old, new): + path = ROOT / name + content = path.read_text() + if content.count(old) != 1: + raise RuntimeError(f"expected exactly one integration anchor in {name}: {old!r}") + path.write_text(content.replace(old, new)) + + +entry = {"id": "java-spring-boot", "language": "Java", "framework": "Spring Boot", "display_name": "Java / Spring Boot", "source_path": "apps/java-spring-boot", "version_fields": ["java", "spring-boot", "tomcat", "postgresql", "hikaricp", "jackson", "gradle"], "acceptance_test": "tests/test_java_spring_boot_service.py", "failure_test": "tests/test_java_spring_boot_acceptance.py"} +replace("benchmark/implementations.json", '\n ],\n "cohorts":', ',\n ' + json.dumps(entry, separators=(",", ":")) + '\n ],\n "cohorts":') +replace("benchmark/environment.py", "from .install_oha import SHA256, VERSION, platform_asset\n", "from .install_oha import SHA256, VERSION, platform_asset\nfrom .java_versions import JAVA_VERSION, pinned_versions as java_pinned_versions\n") +replace("benchmark/environment.py", " require(\n set(versions) == set(implementation_ids()),", ' versions["java-spring-boot"] = java_pinned_versions(ROOT / implementation("java-spring-boot")["source_path"])\n require(\n set(versions) == set(implementation_ids()),') +replace("benchmark/environment.py", 're.fullmatch(r"\\d+\\.\\d+\\.\\d+", value)\n for value in values.values()', 're.fullmatch(JAVA_VERSION if identifier == "java-spring-boot" and field == "java" else r"\\d+\\.\\d+\\.\\d+", value)\n for field, value in values.items()') +replace("benchmark/environment.py", ' "apps/*/requirements.lock",', ' "apps/*/requirements.lock",\n "apps/*/gradle.lockfile",\n "apps/*/gradle/verification-metadata.xml",\n "apps/*/gradle/wrapper/gradle-wrapper.properties",\n "apps/*/gradle/wrapper/gradle-wrapper.jar",') +replace("benchmark/ci.py", '"Node.js": "node", "Python": "python"}', '"Node.js": "node", "Python": "python", "Java": "java"}') +replace("benchmark/ci.py", ' "python": (), # Python', ' "java": ("java", "javac", "jar", "javadoc", "gradle"),\n "python": (), # Python') +java_service = ''' + java-spring-boot: + <<: *api-defaults + build: + context: ./apps/java-spring-boot + environment: + <<: *database-environment + healthcheck: + test: + - CMD + - java + - -Xmx16m + - -XX:+UseSerialGC + - -cp + - /app/lib/* + - dev.simpleapibenchmark.Healthcheck + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s + +''' +replace("docker-compose.yml", "\nnetworks:\n", java_service + "networks:\n") +java_setup = ''' - name: Install pinned Java toolchain + if: matrix.toolchain == 'java' + run: | + curl --fail --silent --show-error --location --max-time 180 'https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.4.1%2B1/OpenJDK25U-jdk_x64_linux_hotspot_25.0.4.1_1.tar.gz' -o "$RUNNER_TEMP/java.tar.gz" + echo "dbb698396d478e7fa2b1e50f4103324b2a99b90569ee27c33f2261f9215cf41e $RUNNER_TEMP/java.tar.gz" | sha256sum -c - + mkdir -p "$RUNNER_TEMP/java" + tar -xzf "$RUNNER_TEMP/java.tar.gz" --strip-components=1 -C "$RUNNER_TEMP/java" + echo "JAVA_HOME=$RUNNER_TEMP/java" >> "$GITHUB_ENV" + echo "$RUNNER_TEMP/java/bin" >> "$GITHUB_PATH" +''' +replace(".github/workflows/ci.yml", " - name: Run implementation acceptance and failure gates\n", java_setup + " - name: Run implementation acceptance and failure gates\n") +replace("tests/test_benchmark_registry.py", ' "python-flask",\n', ' "python-flask",\n "java-spring-boot",\n') +replace("tests/test_benchmark_ci.py", ' {"implementation": "python-flask", "toolchain": "python"},\n', ' {"implementation": "python-flask", "toolchain": "python"},\n {"implementation": "java-spring-boot", "toolchain": "java"},\n') +replace("tests/test_benchmark_ci.py", ' "python": (),\n', ' "python": (),\n "java": ("java", "javac", "jar", "javadoc", "gradle"),\n') +replace("tests/test_benchmark_ci.py", "test_required_toolchain_and_common_harness_remain_available_for_all_eight", "test_required_toolchain_and_common_harness_remain_available_for_all_registered") +replace("tests/test_workflows.py", ' rust = next(step for step in steps if step.get("name") == "Install pinned Rust toolchain")', ''' java = next(step for step in steps if step.get("name") == "Install pinned Java toolchain") + self.assertEqual(java["if"], "matrix.toolchain == 'java'") + self.assertIn("sha256sum -c -", java["run"]) + self.assertIn("jdk-25.0.4.1%2B1", java["run"]) + self.assertNotIn("continue-on-error", java) + rust = next(step for step in steps if step.get("name") == "Install pinned Rust toolchain")''') +subprocess.run([sys.executable, "-m", "benchmark.registry", "--write"], cwd=ROOT, check=True) +files = ["benchmark/environment.py", "benchmark/ci.py", "benchmark/java_versions.py", "tests/test_benchmark_registry.py", "tests/test_benchmark_ci.py", "tests/test_benchmark_java_candidate.py", "tests/test_benchmark_java_versions.py", "tests/test_workflows.py", "tests/test_java_spring_boot_service.py", "tests/test_java_spring_boot_acceptance.py"] +subprocess.run([sys.executable, "-m", "ruff", "check", "--fix", "--target-version", "py310", "--config", "apps/python-fastapi/pyproject.toml", *files], cwd=ROOT, check=True) +subprocess.run([sys.executable, "-m", "ruff", "format", "--target-version", "py310", "--config", "apps/python-fastapi/pyproject.toml", *files], cwd=ROOT, check=True) +files += ["benchmark/implementations.json", "benchmark/implementations.mk", "site/registry.mjs", "docker-compose.yml", ".github/workflows/ci.yml"] +out = ROOT / ".cache/java-development/files" +for name in files: + target = out / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes((ROOT / name).read_bytes()) +changes = subprocess.check_output(["git", "diff", "--name-only"], cwd=ROOT, text=True).splitlines() +if not set(changes) <= set(files): + raise RuntimeError("preparation touched files outside its explicit allowlist") +patch = subprocess.check_output(["git", "diff", "--", *files], cwd=ROOT) +(ROOT / ".cache/java-development/change.patch").write_bytes(patch) +print(patch.decode()) diff --git a/.github/workflows/java-development.yml b/.github/workflows/java-development.yml index 33dfc0f..85abf43 100644 --- a/.github/workflows/java-development.yml +++ b/.github/workflows/java-development.yml @@ -8,47 +8,71 @@ concurrency: group: java-development-67 cancel-in-progress: false jobs: - unit: + integration: if: github.repository == 'tappe9/simple-api-benchmark' && github.ref == 'refs/heads/feat/add-java-spring-boot' runs-on: ubuntu-24.04 - timeout-minutes: 15 + timeout-minutes: 30 + outputs: + artifact_id: ${{ steps.evidence.outputs.artifact-id }} + env: + PYTHONDONTWRITEBYTECODE: '1' steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 with: ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false - - name: Verify fail-closed acceptance helpers and static Java metadata + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version-file: apps/python-fastapi/.python-version + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff + with: + go-version: '1.27.1' + cache: false + - name: Prepare the exact checksum-verified JDK run: | - python3 -m unittest discover -s tests -p test_java_spring_boot_acceptance.py -v - python3 - <<'PY' - import json - from pathlib import Path - from benchmark.java_versions import pinned_versions - print(json.dumps(pinned_versions(Path('apps/java-spring-boot')), indent=2)) - PY - - name: Read exact ARM64 JDK asset identity from its public release + set -euo pipefail + curl --fail --silent --show-error --location --max-time 180 'https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.4.1%2B1/OpenJDK25U-jdk_x64_linux_hotspot_25.0.4.1_1.tar.gz' -o "$RUNNER_TEMP/java67.tar.gz" + echo "dbb698396d478e7fa2b1e50f4103324b2a99b90569ee27c33f2261f9215cf41e $RUNNER_TEMP/java67.tar.gz" | sha256sum -c - + mkdir -p "$RUNNER_TEMP/java67/jdk" + tar -xzf "$RUNNER_TEMP/java67.tar.gz" --strip-components=1 -C "$RUNNER_TEMP/java67/jdk" + echo "JAVA_HOME=$RUNNER_TEMP/java67/jdk" >> "$GITHUB_ENV" + echo "GRADLE_USER_HOME=$RUNNER_TEMP/java67/gradle-home" >> "$GITHUB_ENV" + echo "$RUNNER_TEMP/java67/jdk/bin" >> "$GITHUB_PATH" + - name: Prepare narrowly scoped integration files for review run: | set -euo pipefail mkdir -p .cache/java-development - curl --fail --silent --show-error --location --max-time 60 'https://api.github.com/repos/adoptium/temurin25-binaries/releases/tags/jdk-25.0.4.1%2B1' -o .cache/java-development/release.json - python3 - <<'PY' - import json - from pathlib import Path - data = json.loads(Path('.cache/java-development/release.json').read_text()) - wanted = 'OpenJDK25U-jdk_aarch64_linux_hotspot_25.0.4.1_1.tar.gz' - assets = [asset for asset in data['assets'] if asset['name'] == wanted or asset['name'] == wanted + '.sha256.txt'] - for asset in assets: - print(json.dumps({key: asset.get(key) for key in ('id', 'name', 'digest', 'size', 'browser_download_url')}, indent=2)) - if not any(asset['name'] == wanted for asset in assets): - raise RuntimeError('the selected ARM64 JDK is not present in its release') - PY - - name: Export exact source identity + python -m pip install --require-hashes --only-binary=:all: -r apps/python-fastapi/requirements-dev.lock + python .github/java67-prepare.py > .cache/java-development/preparation.log 2>&1 || { cat .cache/java-development/preparation.log; exit 1; } + cat .cache/java-development/preparation.log + - name: Registry and static metadata regressions + run: | + set -euo pipefail + make test-registry + make test-compose + python -m unittest discover -s tests -p 'test_benchmark_java*.py' -v + python -m unittest discover -s tests -p 'test_java_spring_boot_acceptance.py' -v + - name: Actual Java production container acceptance (never publish) + run: | + set -euo pipefail + python -m benchmark.ci run-isolated --implementation java-spring-boot -- make test-java-spring-boot 2>&1 | tee .cache/java-development/container-acceptance.log + - name: Shared regression and focused HTTP contract + run: | + set -euo pipefail + make test-benchmark 2>&1 | tee .cache/java-development/shared.log + python -m benchmark.ci run-isolated --implementation java-spring-boot -- make test-contract CONTRACT_IMPL=java-spring-boot 2>&1 | tee .cache/java-development/contract.log + git diff --exit-code 2a515cf8d839d5a81c356f8a1ef0b9e376666d92 -- results apps/go-gin apps/go-echo apps/rust-actix apps/rust-axum apps/node-fastify apps/node-express apps/python-fastapi apps/python-flask .github/workflows/benchmark.yml .github/workflows/pages.yml .github/workflows/pages-deploy.yml + - name: Export exact source and gate evidence if: always() run: | mkdir -p .cache/java-development git rev-parse HEAD HEAD^{tree} > .cache/java-development/source-identity.txt + if test -d apps/java-spring-boot/build/test-results; then + cp -a apps/java-spring-boot/build/test-results .cache/java-development/ + fi - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + id: evidence if: always() with: name: java-development-${{ github.run_id }}-${{ github.run_attempt }} @@ -56,3 +80,55 @@ jobs: include-hidden-files: true retention-days: 14 if-no-files-found: error + import-objects: + needs: integration + if: github.repository == 'tappe9/simple-api-benchmark' && github.ref == 'refs/heads/feat/add-java-spring-boot' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: write + actions: read + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + artifact-ids: ${{ needs.integration.outputs.artifact_id }} + path: verified-integration + merge-multiple: true + - name: Import explicit reviewed-source candidates as blobs only + env: + GH_TOKEN: ${{ github.token }} + run: | + python3 - <<'PY' + import base64 + import hashlib + import json + import os + from pathlib import Path + import urllib.request + names = ('benchmark/environment.py', 'benchmark/ci.py', 'benchmark/java_versions.py', 'tests/test_benchmark_registry.py', 'tests/test_benchmark_ci.py', 'tests/test_benchmark_java_candidate.py', 'tests/test_benchmark_java_versions.py', 'tests/test_workflows.py', 'tests/test_java_spring_boot_service.py', 'tests/test_java_spring_boot_acceptance.py', 'benchmark/implementations.json', 'benchmark/implementations.mk', 'site/registry.mjs', 'docker-compose.yml', '.github/workflows/ci.yml') + source = Path('verified-integration/files') + if {str(path.relative_to(source)) for path in source.rglob('*') if path.is_file()} != set(names): + raise RuntimeError('unexpected prepared-file manifest') + candidates = [] + for name in names: + path = source / name + if path.is_symlink() or any(parent.is_symlink() for parent in path.parents) or not path.is_file() or not 0 < path.stat().st_size < 200_000: + raise RuntimeError('unsafe prepared file') + data = path.read_bytes() + data.decode('utf-8') + candidates.append((name, data)) + output = [] + for name, data in candidates: + request = urllib.request.Request( + 'https://api.github.com/repos/tappe9/simple-api-benchmark/git/blobs', + data=json.dumps({'encoding': 'base64', 'content': base64.b64encode(data).decode('ascii')}).encode('utf-8'), + headers={'Authorization': 'Bearer ' + os.environ['GH_TOKEN'], 'Accept': 'application/vnd.github+json', 'Content-Type': 'application/json', 'X-GitHub-Api-Version': '2022-11-28'}, + method='POST') + with urllib.request.urlopen(request, timeout=30) as response: + blob = json.load(response) + expected = hashlib.sha1(b'blob ' + str(len(data)).encode('ascii') + b'\0' + data).hexdigest() + if blob.get('sha') != expected: + raise RuntimeError('Git blob identity mismatch') + output.append({'path': name, 'mode': '100644', 'type': 'blob', 'sha': expected, 'sha256': hashlib.sha256(data).hexdigest()}) + print(json.dumps(output, indent=2)) + PY diff --git a/apps/java-spring-boot/.dockerignore b/apps/java-spring-boot/.dockerignore new file mode 100644 index 0000000..6522aac --- /dev/null +++ b/apps/java-spring-boot/.dockerignore @@ -0,0 +1,6 @@ +.git +.gradle +build +src/test +*.log +.env* diff --git a/apps/java-spring-boot/Dockerfile b/apps/java-spring-boot/Dockerfile new file mode 100644 index 0000000..33d3c9d --- /dev/null +++ b/apps/java-spring-boot/Dockerfile @@ -0,0 +1,40 @@ +FROM eclipse-temurin:25-jdk-noble@sha256:9db2c4ebea19768ded62a4ce99d59ec5c1cc1e9753cec27c61f9d9c4c9f6f620 AS build + +# The fixed base predates this JDK hotfix. Replace it with the exact verified +# distribution instead of relying on a mutable tag or overlaying old JDK files. +ARG TARGETARCH +RUN set -eu; \ + case "$TARGETARCH" in \ + amd64) arch=x64; jdk_sha=dbb698396d478e7fa2b1e50f4103324b2a99b90569ee27c33f2261f9215cf41e; jre_sha=ebb67d70e5e7cfbe21f3771a40cfe1b2b448c3509ea0a60b75dd54cbdc79b9f69 ;; \ + arm64) arch=aarch64; jdk_sha=69df11a02cfa3ef7d7ca645e03edce6778ec090e100f6ae2b42097865730ac52; jre_sha=4124caf2b6c9c6d5babdfa2f3df795852b80748317890e471d051d0a4793a677b ;; \ + *) echo 'unsupported Java container architecture' >&2; exit 1 ;; \ + esac; \ + base='https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.4.1%2B1'; \ + curl --fail --silent --show-error --location --max-time 180 "$base/OpenJDK25U-jdk_${arch}_linux_hotspot_25.0.4.1_1.tar.gz" -o /tmp/jdk.tar.gz; \ + echo "$jdk_sha /tmp/jdk.tar.gz" | sha256sum -c -; \ + curl --fail --silent --show-error --location --max-time 180 "$base/OpenJDK25U-jre_${arch}_linux_hotspot_25.0.4.1_1.tar.gz" -o /tmp/jre.tar.gz; \ + echo "$jre_sha /tmp/jre.tar.gz" | sha256sum -c -; \ + rm -rf /opt/java/openjdk; \ + mkdir -p /opt/java/openjdk /opt/verified-jre; \ + tar -xzf /tmp/jdk.tar.gz --strip-components=1 -C /opt/java/openjdk; \ + tar -xzf /tmp/jre.tar.gz --strip-components=1 -C /opt/verified-jre; \ + rm /tmp/jdk.tar.gz /tmp/jre.tar.gz + +WORKDIR /src +COPY .java-version build.gradle settings.gradle gradle.properties gradle.lockfile gradlew ./ +COPY gradle/ gradle/ +COPY src/main/ src/main/ +RUN echo '7a9ce74cff467ca1bf60a4fcd9f05185acceda4d0f382434d393e17864262c5d gradle/wrapper/gradle-wrapper.jar' | sha256sum -c - \ + && ./gradlew --no-daemon installDist + +FROM eclipse-temurin:25-jre-noble@sha256:ae0436887d47df7799d83f822e338a38d31c9a293eb1773cc93c54a10bf09017b +RUN rm -rf /opt/java/openjdk \ + && groupadd --gid 10001 benchmark \ + && useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin benchmark +COPY --from=build /opt/verified-jre/ /opt/java/openjdk/ +WORKDIR /app +COPY --from=build --chown=10001:10001 /src/build/install/java-spring-boot/lib/ /app/lib/ +USER 10001:10001 +EXPOSE 8080 +ENTRYPOINT ["java", "-XX:MaxRAMPercentage=60.0", "-cp", "/app/lib/*", "dev.simpleapibenchmark.BenchmarkApplication"] +CMD [] diff --git a/tests/test_benchmark_java_versions.py b/tests/test_benchmark_java_versions.py new file mode 100644 index 0000000..67e28f8 --- /dev/null +++ b/tests/test_benchmark_java_versions.py @@ -0,0 +1,71 @@ +"""Immutable Java metadata and dependency-verification rejection regressions.""" + +import shutil +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from benchmark.java_versions import pinned_versions +from benchmark.results import BenchmarkFailure + +APP = Path(__file__).resolve().parents[1] / "apps/java-spring-boot" + + +class JavaVersionTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) / "java" + shutil.copytree(APP, self.root, ignore=shutil.ignore_patterns("build", ".gradle")) + + def reject_change(self, path, old, new): + file = self.root / path + text = file.read_text() + self.assertIn(old, text) + file.write_text(text.replace(old, new)) + with self.assertRaises(BenchmarkFailure): + pinned_versions(self.root) + + def test_valid_committed_metadata_requires_no_native_toolchain(self): + with patch("subprocess.run", side_effect=AssertionError("native tool invoked")): + versions = pinned_versions(self.root) + self.assertEqual(versions["java"], "25.0.4.1+1") + self.assertEqual(versions["spring-boot"], "4.1.1") + self.assertEqual(versions["gradle"], "9.7.1") + self.assertEqual(set(versions), {"java", "spring-boot", "gradle", "tomcat", "postgresql", "hikaricp", "jackson"}) + + def test_java_runtime_requires_an_exact_build(self): + self.reject_change(".java-version", "25.0.4.1+1", "25") + + def test_changed_boot_bom_cannot_silently_disagree_with_lock(self): + self.reject_change("build.gradle", "spring-boot-dependencies:4.1.1", "spring-boot-dependencies:4.1.2") + + def test_snapshot_dependency_is_rejected(self): + self.reject_change("gradle.lockfile", "spring-boot-dependencies:4.1.1=", "spring-boot-dependencies:4.1.1-SNAPSHOT=") + + def test_duplicate_locked_dependency_is_rejected(self): + file = self.root / "gradle.lockfile" + line = next(line for line in file.read_text().splitlines() if line.startswith("org.postgresql:postgresql:")) + with file.open("a") as output: + output.write(line + "\n") + with self.assertRaises(BenchmarkFailure): + pinned_versions(self.root) + + def test_wrapper_checksum_is_verified_before_metadata_is_used(self): + (self.root / "gradle/wrapper/gradle-wrapper.jar").write_bytes(b"not the committed wrapper") + with self.assertRaises(BenchmarkFailure): + pinned_versions(self.root) + + def test_distribution_checksum_cannot_be_removed(self): + self.reject_change("gradle/wrapper/gradle-wrapper.properties", "distributionSha256Sum=", "removedChecksum=") + + def test_metadata_verification_cannot_be_disabled(self): + self.reject_change("gradle/verification-metadata.xml", "true", "false") + + def test_wildcard_artifact_trust_is_rejected(self): + self.reject_change("gradle/verification-metadata.xml", "", '') + + +if __name__ == "__main__": + unittest.main() From 7f74f4f9decb9b9286aa85bb41d3bf1b2f34e9c2 Mon Sep 17 00:00:00 2001 From: Yuma Takino <36617106+tappe9@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:04:38 +0900 Subject: [PATCH 09/15] test: keep Java acceptance bootstrap compatible with shared import lint Resolve the observed E402 failures by applying the test entry point's path bootstrap before module imports, without disabling shared lint rules or changing application behavior. --- .github/java67-prepare.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/java67-prepare.py b/.github/java67-prepare.py index 285733b..6ad4187 100644 --- a/.github/java67-prepare.py +++ b/.github/java67-prepare.py @@ -73,6 +73,8 @@ def replace(name, old, new): self.assertIn("jdk-25.0.4.1%2B1", java["run"]) self.assertNotIn("continue-on-error", java) rust = next(step for step in steps if step.get("name") == "Install pinned Rust toolchain")''') +replace("tests/test_java_spring_boot_service.py", 'ROOT = Path(__file__).resolve().parents[1]\nsys.path.insert(0, str(ROOT))', 'sys.path.insert(0, str(Path(__file__).resolve().parents[1]))') +replace("tests/test_java_spring_boot_service.py", 'APP = ROOT / "apps/java-spring-boot"', 'ROOT = Path(__file__).resolve().parents[1]\nAPP = ROOT / "apps/java-spring-boot"') subprocess.run([sys.executable, "-m", "benchmark.registry", "--write"], cwd=ROOT, check=True) files = ["benchmark/environment.py", "benchmark/ci.py", "benchmark/java_versions.py", "tests/test_benchmark_registry.py", "tests/test_benchmark_ci.py", "tests/test_benchmark_java_candidate.py", "tests/test_benchmark_java_versions.py", "tests/test_workflows.py", "tests/test_java_spring_boot_service.py", "tests/test_java_spring_boot_acceptance.py"] subprocess.run([sys.executable, "-m", "ruff", "check", "--fix", "--target-version", "py310", "--config", "apps/python-fastapi/pyproject.toml", *files], cwd=ROOT, check=True) From 007051371fcb68dde503c07b154646a36dca6703 Mon Sep 17 00:00:00 2001 From: Yuma Takino <36617106+tappe9@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:32:02 +0900 Subject: [PATCH 10/15] test: export exact Java development source and inspect base image pins Keep all development jobs read-only, remove the no-longer-needed Git object importer, and retain an exact source bundle for local review. Inspect base image identities without changing dependency pins or publishing any benchmark. --- .github/workflows/java-development.yml | 67 ++++---------------------- 1 file changed, 10 insertions(+), 57 deletions(-) diff --git a/.github/workflows/java-development.yml b/.github/workflows/java-development.yml index 85abf43..f783d3a 100644 --- a/.github/workflows/java-development.yml +++ b/.github/workflows/java-development.yml @@ -12,8 +12,6 @@ jobs: if: github.repository == 'tappe9/simple-api-benchmark' && github.ref == 'refs/heads/feat/add-java-spring-boot' runs-on: ubuntu-24.04 timeout-minutes: 30 - outputs: - artifact_id: ${{ steps.evidence.outputs.artifact-id }} env: PYTHONDONTWRITEBYTECODE: '1' steps: @@ -22,6 +20,12 @@ jobs: ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false + - name: Inspect candidate base images without changing source + run: | + set -euo pipefail + mkdir -p .cache/java-development + docker buildx imagetools inspect eclipse-temurin:25-jdk-noble | tee .cache/java-development/jdk-image.txt + docker buildx imagetools inspect eclipse-temurin:25-jre-noble | tee .cache/java-development/jre-image.txt - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 with: python-version-file: apps/python-fastapi/.python-version @@ -62,17 +66,18 @@ jobs: set -euo pipefail make test-benchmark 2>&1 | tee .cache/java-development/shared.log python -m benchmark.ci run-isolated --implementation java-spring-boot -- make test-contract CONTRACT_IMPL=java-spring-boot 2>&1 | tee .cache/java-development/contract.log - git diff --exit-code 2a515cf8d839d5a81c356f8a1ef0b9e376666d92 -- results apps/go-gin apps/go-echo apps/rust-actix apps/rust-axum apps/node-fastify apps/node-express apps/python-fastapi apps/python-flask .github/workflows/benchmark.yml .github/workflows/pages.yml .github/workflows/pages-deploy.yml + git diff --exit-code 6e25eb07759bd6eed4de9ab9ee3d1dd35562932b -- results apps/go-gin apps/go-echo apps/rust-actix apps/rust-axum apps/node-fastify apps/node-express apps/python-fastapi apps/python-flask .github/workflows/benchmark.yml .github/workflows/pages.yml .github/workflows/pages-deploy.yml - name: Export exact source and gate evidence if: always() run: | + set -euo pipefail mkdir -p .cache/java-development - git rev-parse HEAD HEAD^{tree} > .cache/java-development/source-identity.txt + git rev-parse HEAD HEAD^{tree} refs/remotes/origin/main > .cache/java-development/source-identity.txt + git bundle create .cache/java-development/source.bundle HEAD refs/remotes/origin/main if test -d apps/java-spring-boot/build/test-results; then cp -a apps/java-spring-boot/build/test-results .cache/java-development/ fi - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - id: evidence if: always() with: name: java-development-${{ github.run_id }}-${{ github.run_attempt }} @@ -80,55 +85,3 @@ jobs: include-hidden-files: true retention-days: 14 if-no-files-found: error - import-objects: - needs: integration - if: github.repository == 'tappe9/simple-api-benchmark' && github.ref == 'refs/heads/feat/add-java-spring-boot' - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - contents: write - actions: read - steps: - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - artifact-ids: ${{ needs.integration.outputs.artifact_id }} - path: verified-integration - merge-multiple: true - - name: Import explicit reviewed-source candidates as blobs only - env: - GH_TOKEN: ${{ github.token }} - run: | - python3 - <<'PY' - import base64 - import hashlib - import json - import os - from pathlib import Path - import urllib.request - names = ('benchmark/environment.py', 'benchmark/ci.py', 'benchmark/java_versions.py', 'tests/test_benchmark_registry.py', 'tests/test_benchmark_ci.py', 'tests/test_benchmark_java_candidate.py', 'tests/test_benchmark_java_versions.py', 'tests/test_workflows.py', 'tests/test_java_spring_boot_service.py', 'tests/test_java_spring_boot_acceptance.py', 'benchmark/implementations.json', 'benchmark/implementations.mk', 'site/registry.mjs', 'docker-compose.yml', '.github/workflows/ci.yml') - source = Path('verified-integration/files') - if {str(path.relative_to(source)) for path in source.rglob('*') if path.is_file()} != set(names): - raise RuntimeError('unexpected prepared-file manifest') - candidates = [] - for name in names: - path = source / name - if path.is_symlink() or any(parent.is_symlink() for parent in path.parents) or not path.is_file() or not 0 < path.stat().st_size < 200_000: - raise RuntimeError('unsafe prepared file') - data = path.read_bytes() - data.decode('utf-8') - candidates.append((name, data)) - output = [] - for name, data in candidates: - request = urllib.request.Request( - 'https://api.github.com/repos/tappe9/simple-api-benchmark/git/blobs', - data=json.dumps({'encoding': 'base64', 'content': base64.b64encode(data).decode('ascii')}).encode('utf-8'), - headers={'Authorization': 'Bearer ' + os.environ['GH_TOKEN'], 'Accept': 'application/vnd.github+json', 'Content-Type': 'application/json', 'X-GitHub-Api-Version': '2022-11-28'}, - method='POST') - with urllib.request.urlopen(request, timeout=30) as response: - blob = json.load(response) - expected = hashlib.sha1(b'blob ' + str(len(data)).encode('ascii') + b'\0' + data).hexdigest() - if blob.get('sha') != expected: - raise RuntimeError('Git blob identity mismatch') - output.append({'path': name, 'mode': '100644', 'type': 'blob', 'sha': expected, 'sha256': hashlib.sha256(data).hexdigest()}) - print(json.dumps(output, indent=2)) - PY From e46dd74c5fd231437c066b9b3dad65d1f4fd0541 Mon Sep 17 00:00:00 2001 From: Yuma Takino <36617106+tappe9@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:36:48 +0900 Subject: [PATCH 11/15] fix: pin Java container bases to verified registry manifests Reproduce the existing fail-closed rejection of the malformed runtime image digest, then use the actual JDK/JRE index identities inspected in development run 35190249988. Preserve exact checksum-verified 25.0.4.1+1 archives, strict Gradle locks and all acceptance checks. --- apps/java-spring-boot/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/java-spring-boot/Dockerfile b/apps/java-spring-boot/Dockerfile index 33d3c9d..ec69739 100644 --- a/apps/java-spring-boot/Dockerfile +++ b/apps/java-spring-boot/Dockerfile @@ -1,4 +1,4 @@ -FROM eclipse-temurin:25-jdk-noble@sha256:9db2c4ebea19768ded62a4ce99d59ec5c1cc1e9753cec27c61f9d9c4c9f6f620 AS build +FROM eclipse-temurin:25-jdk-noble@sha256:9db599e7cdf3f650ba93441ba84020808e9da4834cf079b877f8abea5888d9b7 AS build # The fixed base predates this JDK hotfix. Replace it with the exact verified # distribution instead of relying on a mutable tag or overlaying old JDK files. @@ -27,7 +27,7 @@ COPY src/main/ src/main/ RUN echo '7a9ce74cff467ca1bf60a4fcd9f05185acceda4d0f382434d393e17864262c5d gradle/wrapper/gradle-wrapper.jar' | sha256sum -c - \ && ./gradlew --no-daemon installDist -FROM eclipse-temurin:25-jre-noble@sha256:ae0436887d47df7799d83f822e338a38d31c9a293eb1773cc93c54a10bf09017b +FROM eclipse-temurin:25-jre-noble@sha256:d0d42862bc6cdcb9bc01096d48753b741316f44af2022666833d94f562b58481 RUN rm -rf /opt/java/openjdk \ && groupadd --gid 10001 benchmark \ && useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin benchmark From 4ce649a6918717120c559a8a8eff9a35faa9f710 Mon Sep 17 00:00:00 2001 From: Yuma Takino <36617106+tappe9@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:57:04 +0900 Subject: [PATCH 12/15] fix: fetch Java archives through checksum-verified BuildKit stages Development run 35191432054 reproduced a real image build failure because the pinned Temurin image deliberately removes download utilities. Use architecture-selected scratch stages with ADD --checksum instead of installing floating packages or weakening archive verification. Add fail-closed regressions for malformed, missing, duplicated and wrong-platform archive pins. Confirm the new repository regression fails against the former Dockerfile and all nine acceptance-helper tests pass with the corrected layout. Native tests and real container gates remain required; no official measurement or publication. --- apps/java-spring-boot/Dockerfile | 38 ++--- tests/test_java_spring_boot_acceptance.py | 43 +++++ tests/test_java_spring_boot_service.py | 197 ++++++++++++++++++---- 3 files changed, 227 insertions(+), 51 deletions(-) diff --git a/apps/java-spring-boot/Dockerfile b/apps/java-spring-boot/Dockerfile index 0b9ece0..2f6a02d 100644 --- a/apps/java-spring-boot/Dockerfile +++ b/apps/java-spring-boot/Dockerfile @@ -1,24 +1,22 @@ -FROM eclipse-temurin:25-jdk-noble@sha256:9db599e7cdf3f650ba93441ba84020808e9da4834cf079b877f8abea5888d9b7 AS build +# BuildKit downloads only the selected architecture and verifies every archive. +# No unpinned package installation or in-image network utility is needed. +FROM scratch AS archives-amd64 +ADD --checksum=sha256:dbb698396d478e7fa2b1e50f4103324b2a99b90569ee27c33f2261f9215cf41e https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.4.1%2B1/OpenJDK25U-jdk_x64_linux_hotspot_25.0.4.1_1.tar.gz /tmp/jdk.tar.gz +ADD --checksum=sha256:1731a34baadec5479258ea0202e4d5d865d2efeee60cb0c7d7eb056fe96ca219 https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.4.1%2B1/OpenJDK25U-jre_x64_linux_hotspot_25.0.4.1_1.tar.gz /tmp/jre.tar.gz + +FROM scratch AS archives-arm64 +ADD --checksum=sha256:69df11a02cfa3ef7d7ca645e03edce6778ec090e100f6ae2b42097865730ac52 https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.4.1%2B1/OpenJDK25U-jdk_aarch64_linux_hotspot_25.0.4.1_1.tar.gz /tmp/jdk.tar.gz +ADD --checksum=sha256:34828cbb93ed31c281c84ecb31ddab655d11a802f263c1fc019d42e9e0230fed https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.4.1%2B1/OpenJDK25U-jre_aarch64_linux_hotspot_25.0.4.1_1.tar.gz /tmp/jre.tar.gz + +FROM archives-${TARGETARCH} AS archives -# The fixed base predates this JDK hotfix. Replace it with the exact verified -# distribution instead of relying on a mutable tag or overlaying old JDK files. -ARG TARGETARCH -RUN set -eu; \ - case "$TARGETARCH" in \ - amd64) arch=x64; jdk_sha=dbb698396d478e7fa2b1e50f4103324b2a99b90569ee27c33f2261f9215cf41e; jre_sha=1731a34baadec5479258ea0202e4d5d865d2efeee60cb0c7d7eb056fe96ca219 ;; \ - arm64) arch=aarch64; jdk_sha=69df11a02cfa3ef7d7ca645e03edce6778ec090e100f6ae2b42097865730ac52; jre_sha=34828cbb93ed31c281c84ecb31ddab655d11a802f263c1fc019d42e9e0230fed ;; \ - *) echo 'unsupported Java container architecture' >&2; exit 1 ;; \ - esac; \ - base='https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.4.1%2B1'; \ - curl --fail --silent --show-error --location --max-time 180 "$base/OpenJDK25U-jdk_${arch}_linux_hotspot_25.0.4.1_1.tar.gz" -o /tmp/jdk.tar.gz; \ - echo "$jdk_sha /tmp/jdk.tar.gz" | sha256sum -c -; \ - curl --fail --silent --show-error --location --max-time 180 "$base/OpenJDK25U-jre_${arch}_linux_hotspot_25.0.4.1_1.tar.gz" -o /tmp/jre.tar.gz; \ - echo "$jre_sha /tmp/jre.tar.gz" | sha256sum -c -; \ - rm -rf /opt/java/openjdk; \ - mkdir -p /opt/java/openjdk /opt/verified-jre; \ - tar -xzf /tmp/jdk.tar.gz --strip-components=1 -C /opt/java/openjdk; \ - tar -xzf /tmp/jre.tar.gz --strip-components=1 -C /opt/verified-jre; \ - rm /tmp/jdk.tar.gz /tmp/jre.tar.gz +FROM eclipse-temurin:25-jdk-noble@sha256:9db599e7cdf3f650ba93441ba84020808e9da4834cf079b877f8abea5888d9b7 AS build +COPY --from=archives /tmp/ /tmp/ +RUN rm -rf /opt/java/openjdk \ + && mkdir -p /opt/java/openjdk /opt/verified-jre \ + && tar -xzf /tmp/jdk.tar.gz --strip-components=1 -C /opt/java/openjdk \ + && tar -xzf /tmp/jre.tar.gz --strip-components=1 -C /opt/verified-jre \ + && rm /tmp/jdk.tar.gz /tmp/jre.tar.gz WORKDIR /src COPY .java-version build.gradle settings.gradle gradle.properties gradle.lockfile gradlew ./ diff --git a/tests/test_java_spring_boot_acceptance.py b/tests/test_java_spring_boot_acceptance.py index 6be5736..b013d7d 100644 --- a/tests/test_java_spring_boot_acceptance.py +++ b/tests/test_java_spring_boot_acceptance.py @@ -87,6 +87,49 @@ def test_startup_failure_rejects_oom_and_running_containers(self): with self.assertRaises(RuntimeError): validate_failed_startup(value, "application startup failed") +class DistributionPinTests(unittest.TestCase): + @staticmethod + def valid_manifest(): + lines = [] + for platform, architecture, pins in (("amd64", "x64", "ab"), ("arm64", "aarch64", "cd")): + lines.append(f"FROM scratch AS archives-{platform}") + for kind, pin in zip(("jdk", "jre"), pins): + lines.append( + f"ADD --checksum=sha256:{pin * 64} " + "https://github.com/adoptium/temurin25-binaries/releases/download/" + f"jdk-25.0.4.1%2B1/OpenJDK25U-{kind}_{architecture}_linux_hotspot_" + f"25.0.4.1_1.tar.gz /tmp/{kind}.tar.gz" + ) + lines.append("FROM archives-${TARGETARCH} AS archives") + return "\n".join(lines) + "\n" + + def test_both_platforms_have_exactly_two_sha256_pins(self): + from test_java_spring_boot_service import validate_distribution_pins + validate_distribution_pins(self.valid_manifest()) + + def test_bad_length_non_hex_and_missing_platform_are_rejected(self): + from test_java_spring_boot_service import validate_distribution_pins + valid = self.valid_manifest() + for old in ("a", "b", "c", "d"): + for bad in (old * 63, old * 65, "g" * 64, ""): + with self.subTest(pin=old, value=bad): + with self.assertRaises(RuntimeError): + validate_distribution_pins(valid.replace(old * 64, bad)) + for bad in ( + valid.replace("FROM scratch AS archives-arm64", "FROM scratch AS missing"), + valid + valid, + valid.replace("_aarch64_", "_x64_"), + valid.replace("${TARGETARCH}", "amd64"), + valid.replace("github.com/adoptium/", "example.com/adoptium/"), + valid.replace("/tmp/jre.tar.gz", "/tmp/jdk.tar.gz"), + ): + with self.assertRaises(RuntimeError): + validate_distribution_pins(bad) + + def test_repository_distribution_manifest_is_well_formed(self): + from test_java_spring_boot_service import APP, validate_distribution_pins + validate_distribution_pins((APP / "Dockerfile").read_text()) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_java_spring_boot_service.py b/tests/test_java_spring_boot_service.py index 3cfdda2..9e7bbae 100644 --- a/tests/test_java_spring_boot_service.py +++ b/tests/test_java_spring_boot_service.py @@ -4,7 +4,6 @@ import concurrent.futures import hashlib import json -import os import re import subprocess import sys @@ -31,14 +30,21 @@ def require(condition, message): def run(command, *, timeout=60, cwd=ROOT): - completed = subprocess.run(command, cwd=cwd, text=True, capture_output=True, timeout=timeout, check=False) + completed = subprocess.run( + command, cwd=cwd, text=True, capture_output=True, timeout=timeout, check=False + ) if completed.returncode: - raise RuntimeError(f"command failed ({completed.returncode}): {command[0]}\n{completed.stdout}\n{completed.stderr}") + raise RuntimeError( + f"command failed ({completed.returncode}): {command[0]}\n{completed.stdout}\n{completed.stderr}" + ) return completed.stdout def count_value(value): - require(type(value) is str and re.fullmatch(r"[0-9]+\s*", value) is not None, "invalid database session count") + require( + type(value) is str and re.fullmatch(r"[0-9]+\s*", value) is not None, + "invalid database session count", + ) return int(value) @@ -49,10 +55,16 @@ def validate_pool_count(value): def stopped_state(container): try: state = container["State"] - require(all(state[key] is False for key in ("Running", "Restarting", "Dead", "OOMKilled")), "container is not safely stopped") + require( + all(state[key] is False for key in ("Running", "Restarting", "Dead", "OOMKilled")), + "container is not safely stopped", + ) require(state["Status"] == "exited", "container did not exit") require(type(state["ExitCode"]) is int, "missing integer exit status") - require(type(container["RestartCount"]) is int and container["RestartCount"] == 0, "container restarted") + require( + type(container["RestartCount"]) is int and container["RestartCount"] == 0, + "container restarted", + ) return state except (KeyError, TypeError) as error: raise RuntimeError("incomplete stopped-container evidence") from error @@ -64,14 +76,52 @@ def validate_shutdown(container, remaining): def validate_failed_startup(container, logs): - require(stopped_state(container)["ExitCode"] == 1, "expected an actual application startup failure") - require(type(logs) is str and "application startup failed" in logs, "missing application failure evidence") + require( + stopped_state(container)["ExitCode"] == 1, "expected an actual application startup failure" + ) + require( + type(logs) is str and "application startup failed" in logs, + "missing application failure evidence", + ) lowered = logs.lower() - require(not any(value in lowered for value in ("password", "secret-67", "jdbc:", "select ", "at org.")), "startup log disclosed internal details") + require( + not any( + value in lowered for value in ("password", "secret-67", "jdbc:", "select ", "at org.") + ), + "startup log disclosed internal details", + ) def dependency_hashes(): - return {name: hashlib.sha256((APP / name).read_bytes()).hexdigest() for name in DEPENDENCY_FILES} + return { + name: hashlib.sha256((APP / name).read_bytes()).hexdigest() for name in DEPENDENCY_FILES + } + + +def validate_distribution_pins(dockerfile): + """Require checksum-verified, architecture-specific BuildKit downloads.""" + require("FROM archives-${TARGETARCH} AS archives\n" in dockerfile, + "Java archive selection must follow the target architecture") + for platform, architecture in (("amd64", "x64"), ("arm64", "aarch64")): + blocks = re.findall( + rf"(?ms)^FROM scratch AS archives-{platform}\n(.*?)(?=^FROM )", dockerfile + ) + require(len(blocks) == 1, "missing or duplicate Java distribution platform") + entries = re.findall( + r"(?m)^ADD --checksum=sha256:([0-9a-f]{64}) " + r"https://github\.com/adoptium/temurin25-binaries/releases/download/" + r"jdk-25\.0\.4\.1%2B1/OpenJDK25U-(jdk|jre)_" + r"(x64|aarch64)_linux_hotspot_25\.0\.4\.1_1\.tar\.gz /tmp/(jdk|jre)\.tar\.gz$", + blocks[0], + ) + require( + len(entries) == 2 + and [(kind, arch, destination) for _, kind, arch, destination in entries] + == [("jdk", architecture, "jdk"), ("jre", architecture, "jre")], + "Java distribution archives must have complete SHA-256 pins and correct platforms", + ) + require(len(re.findall(r"(?m)^ADD ", blocks[0])) == 2, + "unexpected Java distribution download") def static_checks(): @@ -79,13 +129,25 @@ def static_checks(): require(versions["java"] == "25.0.4.1+1", "unexpected JDK build") require(versions["spring-boot"] == "4.1.1", "unexpected Spring Boot version") dockerfile = (APP / "Dockerfile").read_text() + validate_distribution_pins(dockerfile) images = re.findall(r"^FROM (\S+)", dockerfile, re.MULTILINE) - require(bool(images) and all(re.search(r"@sha256:[0-9a-f]{64}$", image) for image in images), "Docker bases must be digest pinned") + require( + len(images) == 5 + and images[:3] == ["scratch", "scratch", "archives-${TARGETARCH}"] + and all(re.search(r"@sha256:[0-9a-f]{64}$", image) for image in images[3:]), + "Docker bases must be digest pinned", + ) require("USER 10001:10001" in dockerfile, "Java production image must run non-root") source = APP / "src/main/java/dev/simpleapibenchmark" fibonacci = (source / "Fibonacci.java").read_text() - require("calculate(n - 1) + calculate(n - 2)" in fibonacci, "CPU workload must remain direct recursion") - require("Fibonacci.calculate(30)" in (source / "ApiController.java").read_text(), "CPU calculation must occur per request") + require( + "calculate(n - 1) + calculate(n - 2)" in fibonacci, + "CPU workload must remain direct recursion", + ) + require( + "Fibonacci.calculate(30)" in (source / "ApiController.java").read_text(), + "CPU calculation must occur per request", + ) print("Java static versions:", json.dumps(versions, sort_keys=True)) @@ -96,9 +158,21 @@ def host_checks(): require(dependency_hashes() == before, "validation changed committed dependency evidence") libraries = {path.name for path in (APP / "build/install/java-spring-boot/lib").glob("*.jar")} versions = pinned_versions(APP) - for name, version in (("spring-boot", versions["spring-boot"]), ("tomcat-embed-core", versions["tomcat"]), ("postgresql", versions["postgresql"]), ("HikariCP", versions["hikaricp"]), ("jackson-databind", versions["jackson"])): - require(f"{name}-{version}.jar" in libraries, "runtime distribution does not match locked metadata") - require(not any(name.startswith(("junit-", "mockito-", "spring-test-")) for name in libraries), "test dependencies entered the production distribution") + for name, version in ( + ("spring-boot", versions["spring-boot"]), + ("tomcat-embed-core", versions["tomcat"]), + ("postgresql", versions["postgresql"]), + ("HikariCP", versions["hikaricp"]), + ("jackson-databind", versions["jackson"]), + ): + require( + f"{name}-{version}.jar" in libraries, + "runtime distribution does not match locked metadata", + ) + require( + not any(name.startswith(("junit-", "mockito-", "spring-test-")) for name in libraries), + "test dependencies entered the production distribution", + ) def container_checks(): @@ -112,7 +186,23 @@ def inspect(identifier): return json.loads(run(["docker", "inspect", identifier]))[0] def sql(statement): - return dc("exec", "-T", "postgres", "psql", "--username", "benchmark", "--dbname", "benchmark", "--no-psqlrc", "--tuples-only", "--no-align", "--set", "ON_ERROR_STOP=1", "--command", statement).strip() + return dc( + "exec", + "-T", + "postgres", + "psql", + "--username", + "benchmark", + "--dbname", + "benchmark", + "--no-psqlrc", + "--tuples-only", + "--no-align", + "--set", + "ON_ERROR_STOP=1", + "--command", + statement, + ).strip() def response(case): assert_response(case, read_response(BASE_URL, case.path, timeout=10), SERVICE) @@ -131,19 +221,38 @@ def wait_stopped(identifier, timeout=30): dc("build", SERVICE, timeout=900) dc("up", "-d", "--wait", "--wait-timeout", "90", SERVICE, timeout=150) identifier = dc("ps", "-q", SERVICE).strip() - require(re.fullmatch(r"[0-9a-f]{64}", identifier) is not None, "missing full Java container identity") + require( + re.fullmatch(r"[0-9a-f]{64}", identifier) is not None, + "missing full Java container identity", + ) state = inspect(identifier) identity = validate_state(state, project, SERVICE) require(state["Config"]["User"] == "10001:10001", "Java image is not non-root") require("ALL" in state["HostConfig"]["CapDrop"], "Linux capabilities were not dropped") - require(any(value.startswith("no-new-privileges") for value in state["HostConfig"]["SecurityOpt"]), "no-new-privileges is missing") + require( + any( + value.startswith("no-new-privileges") + for value in state["HostConfig"]["SecurityOpt"] + ), + "no-new-privileges is missing", + ) ports = state["HostConfig"]["PortBindings"] - require(ports == {"8080/tcp": [{"HostIp": "127.0.0.1", "HostPort": "8080"}]}, "unexpected Java host ports") - require(set(state["NetworkSettings"]["Networks"]) == {project + "_benchmark"}, "Java escaped its owned benchmark network") + require( + ports == {"8080/tcp": [{"HostIp": "127.0.0.1", "HostPort": "8080"}]}, + "unexpected Java host ports", + ) + require( + set(state["NetworkSettings"]["Networks"]) == {project + "_benchmark"}, + "Java escaped its owned benchmark network", + ) database = inspect(dc("ps", "-q", "postgres").strip()) - require(not database["HostConfig"]["PortBindings"], "PostgreSQL must not publish a host port") + require( + not database["HostConfig"]["PortBindings"], "PostgreSQL must not publish a host port" + ) validate_processes(state, run(["docker", "top", identifier, "-eo", "pid,args"])) - runtime = run(["docker", "exec", identifier, "java", "-XshowSettings:properties", "-version"]) + runtime = run( + ["docker", "exec", identifier, "java", "-XshowSettings:properties", "-version"] + ) # Java emits settings on stderr; query its release manifest independently. release = run(["docker", "exec", identifier, "cat", "/opt/java/openjdk/release"]) require('JAVA_RUNTIME_VERSION="25.0.4.1+1' in release, "production JRE and host JDK differ") @@ -167,7 +276,9 @@ def wait_stopped(identifier, timeout=30): future.result() validate_pool_count(sql(SESSION_COUNT)) validate_state(inspect(identifier), project, SERVICE, identity) - validate_processes(inspect(identifier), run(["docker", "top", identifier, "-eo", "pid,args"])) + validate_processes( + inspect(identifier), run(["docker", "top", identifier, "-eo", "pid,args"]) + ) sql("ALTER TABLE items RENAME TO unavailable_items;") response(Case("/db/42", 500, {"error": "internal server error"})) sql("ALTER TABLE unavailable_items RENAME TO items;") @@ -177,16 +288,32 @@ def wait_stopped(identifier, timeout=30): stopped = wait_stopped(identifier, 15) require(time.monotonic() - started < 15, "graceful JVM shutdown exceeded its deadline") validate_shutdown(stopped, sql(SESSION_COUNT)) - for index, override in enumerate(("DATABASE_PASSWORD=secret-67", "DATABASE_HOST=127.0.0.1", "DATABASE_PORT=65536")): + for index, override in enumerate( + ("DATABASE_PASSWORD=secret-67", "DATABASE_HOST=127.0.0.1", "DATABASE_PORT=65536") + ): name = f"{project}-failure-{index}" - dc("run", "--detach", "--no-deps", "--name", name, "--env", override, SERVICE, timeout=30) + dc( + "run", + "--detach", + "--no-deps", + "--name", + name, + "--env", + override, + SERVICE, + timeout=30, + ) failed = wait_stopped(name, 30) logs = run(["docker", "logs", name]) # Application stderr is returned separately by Docker; capture both streams. - result = subprocess.run(["docker", "logs", name], capture_output=True, text=True, timeout=10, check=True) + result = subprocess.run( + ["docker", "logs", name], capture_output=True, text=True, timeout=10, check=True + ) validate_failed_startup(failed, logs + result.stderr) require(sql(SESSION_COUNT) == "0", "failed startup left database sessions open") - print("Java production acceptance: live SQL, BIGINT, pool, limits, process isolation, failures and shutdown passed") + print( + "Java production acceptance: live SQL, BIGINT, pool, limits, process isolation, failures and shutdown passed" + ) except BaseException as error: failure = error raise @@ -194,11 +321,19 @@ def wait_stopped(identifier, timeout=30): try: dc("down", "--remove-orphans", "--volumes", "--timeout", "10", timeout=90) label = "label=com.docker.compose.project=" + project - require(not run(["docker", "ps", "-aq", "--filter", label]).strip(), "owned acceptance containers remain") - require(not run(["docker", "network", "ls", "-q", "--filter", label]).strip(), "owned acceptance networks remain") + require( + not run(["docker", "ps", "-aq", "--filter", label]).strip(), + "owned acceptance containers remain", + ) + require( + not run(["docker", "network", "ls", "-q", "--filter", label]).strip(), + "owned acceptance networks remain", + ) except Exception as cleanup: if failure is not None: - raise RuntimeError(f"Java acceptance and owned cleanup both failed: {failure}; {cleanup}") from failure + raise RuntimeError( + f"Java acceptance and owned cleanup both failed: {failure}; {cleanup}" + ) from failure raise From d260eb1ccf17f8d8510495752da717bc3f40cb26 Mon Sep 17 00:00:00 2001 From: Yuma Takino <36617106+tappe9@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:10:26 +0900 Subject: [PATCH 13/15] feat: integrate the verified Java implementation into registry and CI Import the exact fifteen reviewed source blobs from successful development run 35192167889, independently verified against its artifact digest and source bundle. Real Java container acceptance, 22 native tests, 186 shared regression tests, 38 contract-runner tests and 14 live Java contract checks passed; the active eight-stack cohort, existing implementations and result bytes remain unchanged. Stage the separately reviewed durable documentation updates through an uncredentialed, before/after-hash-bound helper. Its temporary follow-up job may create only nine explicitly named and content-verified documentation blobs, never refs or publication commits. Remove both temporary helper files before normal PR gates. No official benchmark, release, protection or publisher redesign. --- .github/java67-prepare.py | 382 +++++++++++++++++----- .github/workflows/ci.yml | 9 + .github/workflows/java-development.yml | 116 +++---- benchmark/ci.py | 9 +- benchmark/environment.py | 19 +- benchmark/implementations.json | 3 +- benchmark/implementations.mk | 6 +- benchmark/java_versions.py | 16 +- docker-compose.yml | 20 ++ site/registry.mjs | 16 + tests/test_benchmark_ci.py | 4 +- tests/test_benchmark_java_candidate.py | 47 ++- tests/test_benchmark_java_versions.py | 37 ++- tests/test_benchmark_registry.py | 1 + tests/test_java_spring_boot_acceptance.py | 4 + tests/test_java_spring_boot_service.py | 15 +- tests/test_workflows.py | 5 + 17 files changed, 519 insertions(+), 190 deletions(-) diff --git a/.github/java67-prepare.py b/.github/java67-prepare.py index 6ad4187..f058e4f 100644 --- a/.github/java67-prepare.py +++ b/.github/java67-prepare.py @@ -1,93 +1,301 @@ -"""Temporary, read-only-runner preparation of explicitly scoped Java integration edits. +"""Temporary preparation of reviewed durable public documentation only. -This script changes only the disposable checkout and exports reviewable files. -It has no credentials and does not create Git objects, commits or remote refs. -Remove it before the final PR gates. +This uncredentialed helper is removed before final PR gates. It never writes +plans, investigation notes, commits, refs, or benchmark results. """ - -import json -import subprocess -import sys from pathlib import Path +import hashlib,json,subprocess +r=Path(__file__).resolve().parents[1] +expected = {'README.md': {'before': '4c8d745d554bcd7ece5403892fde6268c9129ca1', 'after': '76a5ea4b0c8d0f9f60c322ab6a6a577b1b7145c4'}, 'README.ja.md': {'before': '27cedaec537919e97379d44ea2530f3a3f5ec9f6', 'after': 'f2f69ddd8bc98e928e935cb677ce3b58ee445983'}, 'CONTRIBUTING.md': {'before': 'c62d99b70cab232926ede1078b0009def3191d4b', 'after': 'a4a774be53a58e1af52e41c03fd008037f26e64e'}, 'ARCHITECTURE.md': {'before': '496383d40288ef84c50ea24c1d815b2c32a9cb4c', 'after': '78aca583561922acee0abc3d1f4d1c773d31619e'}, 'ROADMAP.md': {'before': '4ecfa6674705acd52d31a38574cb6d948902f5ca', 'after': '0472b929708f17a7f6c2dbbaaf3c707fcb2a528f'}, 'docs/IMPLEMENTATIONS.md': {'before': 'f817a58550297f2e2c17f5af017a09527fd6fd98', 'after': 'a61b40860bd80323341e853002aadbf30e4aa586'}, 'docs/AUTOMATION.md': {'before': '0b14391346c8fec7b71ddcf145cbcee625ba9be2', 'after': 'f9b07b5bd3a44e4e8330c00eff0568bc8f980943'}, 'docs/METHODOLOGY.md': {'before': '05187b6e46b72d29a048319ef5fc3ec40eedc5b3', 'after': '7f42d37388b5aa1fd97775c2c9cb4ba69595797c'}, 'docs/JAVA-SPRING-BOOT.md': {'before': None, 'after': 'cd755d3ff77900cdb630dc75beb6c5e935ce2175'}} +def blob(data): + return hashlib.sha1(b'blob '+str(len(data)).encode('ascii')+b'\0'+data).hexdigest() +subprocess.run(['git','diff','--exit-code','HEAD'],cwd=r,check=True) +for name, hashes in expected.items(): + p=r/name + actual=blob(p.read_bytes()) if p.is_file() and not p.is_symlink() else None + if actual != hashes['before'] or (hashes['before'] is None and p.exists()): + raise RuntimeError('documentation baseline mismatch: '+name) +changes={} +def replace(name, old, new): + assert name in expected + p=r/name;s=p.read_text();assert s.count(old)==1,(name,old[:100],s.count(old));p.write_text(s.replace(old,new));changes[name]=True +replace('README.md','contains eight API implementations','contains nine API implementations') +replace('README.md','Current `main` includes all eight CI-covered implementations, the active `eight-stack-v1` cohort with verified published results, and a Pages dashboard with history navigation.', 'Current `main` includes nine CI-covered implementations. The active `eight-stack-v1` cohort has verified published results and a Pages dashboard with history navigation. Java / Spring Boot is registered for validation but is not part of the official cohort or published results.') +replace('README.md','All eight API implementations and the shared contract suite are available:', '''## Java / Spring Boot implementation -ROOT = Path(__file__).resolve().parents[1] +`apps/java-spring-boot/` uses Temurin 25.0.4.1+1, Spring Boot 4.1.1, +Spring MVC/Tomcat, JDBC/HikariCP, and checksum-verified Gradle 9.7.1. +It follows the same API, single-process, 1 CPU / 512 MiB and ten-connection +limits. It is the **ninth registered implementation**, not an additional official +result. `eight-stack-v1` and all published measurements remain unchanged. +```bash +make test-java-spring-boot +make test-contract CONTRACT_IMPL=java-spring-boot +``` -def replace(name, old, new): - path = ROOT / name - content = path.read_text() - if content.count(old) != 1: - raise RuntimeError(f"expected exactly one integration anchor in {name}: {old!r}") - path.write_text(content.replace(old, new)) - - -entry = {"id": "java-spring-boot", "language": "Java", "framework": "Spring Boot", "display_name": "Java / Spring Boot", "source_path": "apps/java-spring-boot", "version_fields": ["java", "spring-boot", "tomcat", "postgresql", "hikaricp", "jackson", "gradle"], "acceptance_test": "tests/test_java_spring_boot_service.py", "failure_test": "tests/test_java_spring_boot_acceptance.py"} -replace("benchmark/implementations.json", '\n ],\n "cohorts":', ',\n ' + json.dumps(entry, separators=(",", ":")) + '\n ],\n "cohorts":') -replace("benchmark/environment.py", "from .install_oha import SHA256, VERSION, platform_asset\n", "from .install_oha import SHA256, VERSION, platform_asset\nfrom .java_versions import JAVA_VERSION, pinned_versions as java_pinned_versions\n") -replace("benchmark/environment.py", " require(\n set(versions) == set(implementation_ids()),", ' versions["java-spring-boot"] = java_pinned_versions(ROOT / implementation("java-spring-boot")["source_path"])\n require(\n set(versions) == set(implementation_ids()),') -replace("benchmark/environment.py", 're.fullmatch(r"\\d+\\.\\d+\\.\\d+", value)\n for value in values.values()', 're.fullmatch(JAVA_VERSION if identifier == "java-spring-boot" and field == "java" else r"\\d+\\.\\d+\\.\\d+", value)\n for field, value in values.items()') -replace("benchmark/environment.py", ' "apps/*/requirements.lock",', ' "apps/*/requirements.lock",\n "apps/*/gradle.lockfile",\n "apps/*/gradle/verification-metadata.xml",\n "apps/*/gradle/wrapper/gradle-wrapper.properties",\n "apps/*/gradle/wrapper/gradle-wrapper.jar",') -replace("benchmark/ci.py", '"Node.js": "node", "Python": "python"}', '"Node.js": "node", "Python": "python", "Java": "java"}') -replace("benchmark/ci.py", ' "python": (), # Python', ' "java": ("java", "javac", "jar", "javadoc", "gradle"),\n "python": (), # Python') -java_service = ''' - java-spring-boot: - <<: *api-defaults - build: - context: ./apps/java-spring-boot - environment: - <<: *database-environment - healthcheck: - test: - - CMD - - java - - -Xmx16m - - -XX:+UseSerialGC - - -cp - - /app/lib/* - - dev.simpleapibenchmark.Healthcheck - interval: 2s - timeout: 3s - retries: 30 - start_period: 5s - -''' -replace("docker-compose.yml", "\nnetworks:\n", java_service + "networks:\n") -java_setup = ''' - name: Install pinned Java toolchain - if: matrix.toolchain == 'java' - run: | - curl --fail --silent --show-error --location --max-time 180 'https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.4.1%2B1/OpenJDK25U-jdk_x64_linux_hotspot_25.0.4.1_1.tar.gz' -o "$RUNNER_TEMP/java.tar.gz" - echo "dbb698396d478e7fa2b1e50f4103324b2a99b90569ee27c33f2261f9215cf41e $RUNNER_TEMP/java.tar.gz" | sha256sum -c - - mkdir -p "$RUNNER_TEMP/java" - tar -xzf "$RUNNER_TEMP/java.tar.gz" --strip-components=1 -C "$RUNNER_TEMP/java" - echo "JAVA_HOME=$RUNNER_TEMP/java" >> "$GITHUB_ENV" - echo "$RUNNER_TEMP/java/bin" >> "$GITHUB_PATH" -''' -replace(".github/workflows/ci.yml", " - name: Run implementation acceptance and failure gates\n", java_setup + " - name: Run implementation acceptance and failure gates\n") -replace("tests/test_benchmark_registry.py", ' "python-flask",\n', ' "python-flask",\n "java-spring-boot",\n') -replace("tests/test_benchmark_ci.py", ' {"implementation": "python-flask", "toolchain": "python"},\n', ' {"implementation": "python-flask", "toolchain": "python"},\n {"implementation": "java-spring-boot", "toolchain": "java"},\n') -replace("tests/test_benchmark_ci.py", ' "python": (),\n', ' "python": (),\n "java": ("java", "javac", "jar", "javadoc", "gradle"),\n') -replace("tests/test_benchmark_ci.py", "test_required_toolchain_and_common_harness_remain_available_for_all_eight", "test_required_toolchain_and_common_harness_remain_available_for_all_registered") -replace("tests/test_workflows.py", ' rust = next(step for step in steps if step.get("name") == "Install pinned Rust toolchain")', ''' java = next(step for step in steps if step.get("name") == "Install pinned Java toolchain") - self.assertEqual(java["if"], "matrix.toolchain == 'java'") - self.assertIn("sha256sum -c -", java["run"]) - self.assertIn("jdk-25.0.4.1%2B1", java["run"]) - self.assertNotIn("continue-on-error", java) - rust = next(step for step in steps if step.get("name") == "Install pinned Rust toolchain")''') -replace("tests/test_java_spring_boot_service.py", 'ROOT = Path(__file__).resolve().parents[1]\nsys.path.insert(0, str(ROOT))', 'sys.path.insert(0, str(Path(__file__).resolve().parents[1]))') -replace("tests/test_java_spring_boot_service.py", 'APP = ROOT / "apps/java-spring-boot"', 'ROOT = Path(__file__).resolve().parents[1]\nAPP = ROOT / "apps/java-spring-boot"') -subprocess.run([sys.executable, "-m", "benchmark.registry", "--write"], cwd=ROOT, check=True) -files = ["benchmark/environment.py", "benchmark/ci.py", "benchmark/java_versions.py", "tests/test_benchmark_registry.py", "tests/test_benchmark_ci.py", "tests/test_benchmark_java_candidate.py", "tests/test_benchmark_java_versions.py", "tests/test_workflows.py", "tests/test_java_spring_boot_service.py", "tests/test_java_spring_boot_acceptance.py"] -subprocess.run([sys.executable, "-m", "ruff", "check", "--fix", "--target-version", "py310", "--config", "apps/python-fastapi/pyproject.toml", *files], cwd=ROOT, check=True) -subprocess.run([sys.executable, "-m", "ruff", "format", "--target-version", "py310", "--config", "apps/python-fastapi/pyproject.toml", *files], cwd=ROOT, check=True) -files += ["benchmark/implementations.json", "benchmark/implementations.mk", "site/registry.mjs", "docker-compose.yml", ".github/workflows/ci.yml"] -out = ROOT / ".cache/java-development/files" -for name in files: - target = out / name - target.parent.mkdir(parents=True, exist_ok=True) - target.write_bytes((ROOT / name).read_bytes()) -changes = subprocess.check_output(["git", "diff", "--name-only"], cwd=ROOT, text=True).splitlines() -if not set(changes) <= set(files): - raise RuntimeError("preparation touched files outside its explicit allowlist") -patch = subprocess.check_output(["git", "diff", "--", *files], cwd=ROOT) -(ROOT / ".cache/java-development/change.patch").write_bytes(patch) -print(patch.decode()) +The acceptance target needs the exact JDK, Python 3.10+, Make and Docker Compose +v2 on a POSIX host. The container-only contract target does not need a host JDK. +See the [Java implementation guide](docs/JAVA-SPRING-BOOT.md) for build integrity, +startup/shutdown behavior, supported build paths and the unmeasured boundary. + +All nine API implementations and the shared contract suite are available:''') +replace('README.md','# all eight APIs, one at a time','# all nine registered APIs, one at a time') +replace('README.ja.md','検証ルールを使う8つのAPI実装があります。','検証ルールを使う9つのAPI実装があります。') +replace('README.ja.md','現在の`main`は8実装すべてがCI対象で、有効な`eight-stack-v1`の検証済み結果を公開しています。Pagesではダッシュボードと過去の結果を閲覧できます。','現在の`main`は9実装がCI対象です。公式比較は引き続き`eight-stack-v1`の8実装で、Pagesに検証済み結果と履歴を公開しています。Java / Spring Bootは検証対象として登録済みですが、公式測定・公開結果には含まれません。') +p=r/'README.ja.md';s=p.read_text();anchor=next(line for line in s.splitlines() if '8' in line and ('共通契約' in line or '共通contract' in line or '共通のcontract' in line));print('Japanese boundary anchor:',anchor) +replace('README.ja.md',anchor,'''## Java / Spring Boot実装 + +`apps/java-spring-boot/`はTemurin 25.0.4.1+1、Spring Boot 4.1.1、 +Spring MVC / Tomcat、JDBC / HikariCP、チェックサムを検証するGradle 9.7.1を使用します。 +共通のAPI、単一プロセス、1 CPU・512 MiB、DB接続上限10を維持します。 +**9番目の登録済み実装ですが、公式測定対象への追加ではありません。** +`eight-stack-v1`と公開済みの測定値は変更しません。 + +```bash +make test-java-spring-boot +make test-contract CONTRACT_IMPL=java-spring-boot +``` + +受入テストには、POSIXホスト上の固定JDK、Python 3.10以降、Make、Docker Compose v2が必要です。 +コンテナのみを使う共通契約テストには、ホストのJDKは不要です。 +固定依存関係、起動・終了の検証、公式測定と分ける理由は +[Java実装ガイド](docs/JAVA-SPRING-BOOT.md)を参照してください。 + +9実装すべてを、共通のAPI契約で検証できます。''') +p=r/'README.ja.md';s=p.read_text();lines=[x for x in s.splitlines() if x.startswith('make test-contract ')];print('Japanese contract commands',lines) +for line in lines: + if '#' in line and '8' in line: replace('README.ja.md',line,line.replace('8','9')) +replace('CONTRIBUTING.md','### Shared contract checks','''### Java / Spring Boot checks + +Install the exact Temurin JDK recorded in `apps/java-spring-boot/.java-version` +(currently `25.0.4.1+1`), plus Python 3.10+, Make and Docker Compose v2 on a POSIX +host. A global Gradle installation is not needed. Run the native checks without +Docker, or the complete production acceptance target: + +```bash +(cd apps/java-spring-boot && ./gradlew --no-daemon test installDist) +make test-java-spring-boot +make test-contract CONTRACT_IMPL=java-spring-boot +``` + +The committed Wrapper checksum, distribution checksum, strict dependency locks +and artifact verification metadata are mandatory. Validation must not regenerate +those files or download an alternative JDK silently. The production acceptance +checks live SQL/BIGINT responses, JSON errors, pool and container limits, finite +startup failure, SIGTERM shutdown and project-scoped cleanup. See the +[Java guide](docs/JAVA-SPRING-BOOT.md) for the dependency-update procedure. +Java registration does not expand the active official cohort or authorize a run. + +### Shared contract checks''') +replace('CONTRIBUTING.md','# all eight registered implementations, sequentially','# all nine registered implementations, sequentially') +replace('CONTRIBUTING.md','`node-express`, `python-fastapi`, and `python-flask`.','`node-express`, `python-fastapi`, `python-flask`, and `java-spring-boot`.') +replace('CONTRIBUTING.md','all toolchains and shared workflow/site dependencies. See the','all toolchains, including the exact Java JDK, and shared workflow/site dependencies. See the') +replace('docs/IMPLEMENTATIONS.md','The eight registered implementations, in deterministic registry order, are:\nGo / Gin, Go / Echo, Rust / Actix Web, Rust / Axum, Node.js / Fastify,\nNode.js / Express, Python / FastAPI, and Python / Flask. Registration enables','The nine registered implementations, in deterministic registry order, are:\nGo / Gin, Go / Echo, Rust / Actix Web, Rust / Axum, Node.js / Fastify,\nNode.js / Express, Python / FastAPI, Python / Flask, and Java / Spring Boot. Registration enables') +replace('docs/IMPLEMENTATIONS.md','extracts all eight\nstacks','extracts all nine\nstacks') +replace('docs/IMPLEMENTATIONS.md','### Eight-stack rollout boundary (#50)','''### Java registration boundary (#67) + +`java-spring-boot` is registered with a Java-only host toolchain, real-container +acceptance and the unchanged shared contract. Its static version extractor reads +the committed JDK build, Gradle Wrapper, strict lockfile and SHA-256 verification +metadata without executing Java or Gradle. Other implementation jobs do not need +a host JDK. See [Java / Spring Boot](JAVA-SPRING-BOOT.md). + +Neither cohort changes membership or order. Official metadata still contains +only the eight active implementations; Java is not filled into old reports with +zeroes or missing values. A future Java-inclusive cohort and any official run +need a separate decision, including an assessment of JVM warm-up under the common +profile. Registration and passing tests are not measured performance evidence. + +### Eight-stack rollout boundary (#50)''') +replace('docs/AUTOMATION.md','Rust jobs install Rust with rustfmt/Clippy. Python implementation jobs need no\nadditional language setup.','Rust jobs install Rust with rustfmt/Clippy. Only Java jobs install the exact\nTemurin JDK using its fixed release URL and verified SHA-256; Gradle is supplied\nby the committed checksum-verified Wrapper. Python implementation jobs need no\nadditional language setup.') +replace('docs/AUTOMATION.md','unrelated host Go/Node/Rust tools','unrelated host Go/Node/Rust/Java tools') +replace('docs/METHODOLOGY.md','## When to remeasure','''Current `main` uses the versioned `eight-stack-v1` cohort for official measurement; +its membership is listed in the [registry guide](IMPLEMENTATIONS.md). Java / +Spring Boot is the ninth registered implementation for acceptance and contract +checks, but is outside both existing official cohorts. Its single JVM uses the +same resource and pool limits, normal JSON serialization, JDBC and direct +recursive Fibonacci. There is no Java result or performance claim from these +correctness tests. Before any future cohort change, assess JVM warm-up under the +common profile rather than silently extending Java's five-second warm-up alone. +See the [Java implementation guide](JAVA-SPRING-BOOT.md). + +## When to remeasure''') +replace('docs/METHODOLOGY.md','All eight registered implementations can be checked','All nine registered implementations can be checked') +replace('ARCHITECTURE.md','GitHub Actions should measure every backend in the same job.','GitHub Actions should measure every active cohort member sequentially in the same job.') +replace('ARCHITECTURE.md',' R --> A4[Python / FastAPI and Flask]',' R --> A4[Python / FastAPI and Flask]\n M -. acceptance and contracts only .-> A5[Java / Spring Boot]\n A5 --> P') +replace('ARCHITECTURE.md','### PostgreSQL','''#### Java / Spring Boot + +`apps/java-spring-boot/` uses a single Temurin 25.0.4.1+1 JVM with Spring Boot +4.1.1, Spring MVC/Tomcat, ordinary record serialization and JDBC/HikariCP. +`DatabaseSettings` validates the shared five `DATABASE_*` values without putting +credentials into the JDBC URL. `DatabaseConfiguration` bounds the pool at ten +connections and validates `SELECT 1` before startup completes. Repository reads +use a prepared statement and try-with-resources; signed BIGINT IDs remain numeric +JSON, missing rows return 404, and SQL failures return a sanitized 500. Each CPU +request calculates Fibonacci(30) by direct recursion. + +The production image is non-root, inherits the common resource/isolation limits, +contains only runtime libraries and shuts down HTTP and the pool on SIGTERM. +Architecture-selected BuildKit stages checksum-verify the exact JDK/JRE archives; +both OS base images are digest pinned. Gradle Wrapper and dependency verification +are strict. The small standalone health probe does not start Spring or connect +to the database. No ORM, WebFlux, native image, virtual threads or additional +server processes are enabled. Java is CI-covered but not an official cohort +member; see [the Java guide](docs/JAVA-SPRING-BOOT.md). + +### PostgreSQL''') +replace('ARCHITECTURE.md','covers all eight registered stacks','covers all nine registered stacks') +replace('ARCHITECTURE.md','│ └── python-flask/','│ ├── python-flask/\n│ └── java-spring-boot/') +replace('ARCHITECTURE.md','builds and checks all eight registered applications','builds and checks all nine registered applications') +replace('ROADMAP.md','| Versioned eight-stack cohort |','| Java / Spring Boot registration | Ninth implementation with dedicated host toolchain, checksum-verified build, real-container acceptance and shared contracts ([#67](https://github.com/tappe9/simple-api-benchmark/issues/67)). Outside the active official cohort; no Java result is published. |\n| Versioned eight-stack cohort |') +replace('ROADMAP.md','Java / Spring Boot and C# / ASP.NET Core may be evaluated one at a time. These are\ncandidates, not commitments or implemented benchmark entries. Historical-result\nnavigation is already available, not a future candidate.','C# / ASP.NET Core may be evaluated as a separate implementation candidate, not a\ncommitment. Java / Spring Boot is already registered; its inclusion in a future\nofficial cohort remains a separate decision that must assess the common warm-up\nand measurement profile. Historical-result navigation is already available.') +p=r/'docs/JAVA-SPRING-BOOT.md';assert not p.exists();p.write_text('''# Java / Spring Boot + +## Registration is not official measurement + +`java-spring-boot` is the ninth registered implementation. CI builds it and checks +it against the same API contract as the other implementations. It is **not** a +member of `eight-stack-v1` or frozen `four-stack-v1`. Neither historical data nor +the current result receives synthetic Java values. There is no official Java +performance result from adding or testing this implementation. + +## Fixed build inputs + +| Component | Version | +| --- | --- | +| Temurin JDK and production JRE | `25.0.4.1+1` | +| Spring Boot | `4.1.1` | +| Tomcat | `11.0.24` | +| PostgreSQL JDBC driver | `42.7.13` | +| HikariCP | `7.0.2` | +| Jackson databind | `3.1.5` | +| Gradle Wrapper | `9.7.1` | + +`.java-version` fixes the complete runtime build, including the build number. +The Gradle build rejects a different executing JDK; automatic toolchain downloads +are disabled. The committed `gradle.lockfile` fixes runtime and test dependency +configurations under strict locking. `gradle/verification-metadata.xml` verifies +dependency artifacts and metadata with SHA-256. The Wrapper JAR and distribution +also have fixed checksums. Validation does not rewrite this evidence. + +Both Temurin OS bases are fixed by OCI index digest. Their bundled JDK predates +the selected hotfix, so the build replaces it with exact checksum-verified JDK +and JRE archives. Architecture-specific scratch stages use BuildKit's +`ADD --checksum`; only the selected `amd64` or `arm64` download stage is needed. +There is no floating package installation or requirement for curl in the image. +The final image copies the verified JRE and runtime library directory, not test +libraries, a build daemon, development tools or an application launcher shell. + +The Dockerfile provides x86-64 and ARM64 archive paths. The required GitHub CI +runs real-container acceptance on Linux x86-64. An ARM64 path and verified +archive checksum are not a claim of executed ARM64 acceptance. + +## Runtime behavior + +Spring MVC and embedded Tomcat run in one JVM process on port 8080 using +HTTP/1.1. JVM and servlet implementation threads are not additional server +processes; the common container limit remains 1 CPU / 512 MiB. Virtual threads, +WebFlux, ORM, native-image compilation and special result serializers are not +used. The JVM sets `MaxRAMPercentage=60.0` to leave headroom for non-heap memory. + +The application reads `DATABASE_HOST`, `DATABASE_PORT`, `DATABASE_NAME`, +`DATABASE_USER` and `DATABASE_PASSWORD`. Address and port validation prevents +connection-URL parameter injection. Credentials are supplied separately to the +pool and redacted from the settings object's string representation. Startup +executes `SELECT 1`; invalid settings, authentication failure and unreachable DB +must terminate within a finite deadline without exposing credentials or SQL. + +HikariCP has `maximumPoolSize=10` and `minimumIdle=0`, finite acquisition and +validation timeouts, and the PostgreSQL session application name +`simple-api-java`. Every `/db/{id}` request executes a prepared statement using +the parsed signed 64-bit ID. Connections, statements and result sets are closed +with try-with-resources. Missing rows return 404, malformed/out-of-range IDs 400, +and SQL failures only `{"error":"internal server error"}` with status 500. +Ordinary Java records are serialized by the framework, including exact numeric +BIGINT IDs beyond JavaScript's safe-integer range. Every `/cpu` request computes +Fibonacci(30) by direct recursion, without caching or precomputation. + +The image runs as `10001:10001`. Compose retains the common dropped capabilities, +no-new-privileges, private database network, loopback-only host port and disabled +restart policy. The health command is a separate small JVM HTTP probe with a +small heap and bounded response; it never starts Spring or queries the DB. +It is not a measurement process. The existing external-readiness measurement +policy is unchanged. SIGTERM stops HTTP gracefully and closes the pool. + +## Development and verification + +On a POSIX host, install the exact Temurin JDK above, Python 3.10+, Make and Docker +Compose v2 with BuildKit and `up --wait` support. No global Gradle is required. +Do not run competing API services on the shared local port 8080. + +```bash +# Native tests and production distribution, without Docker. +(cd apps/java-spring-boot && ./gradlew --no-daemon test installDist) + +# Strict host build plus real PostgreSQL/container acceptance. +make test-java-spring-boot + +# Shared HTTP contract through Docker; no host JDK is required. +make test-contract CONTRACT_IMPL=java-spring-boot + +# Static metadata and failure-check regression tests, without Java or Docker. +python -m unittest discover -s tests -p 'test_benchmark_java*.py' +python -m unittest discover -s tests -p 'test_java_spring_boot_acceptance.py' +``` + +Native compilation enables all javac lint warnings and treats them as errors. +The native suite covers normal JSON/health/CPU behavior, repeated DB reads, +sanitized failures, BIGINT boundaries, invalid input and settings/health checks. +Real-container acceptance additionally proves that SQL row changes affect the +response, checks the observed pool bound under concurrent requests, validates +resource/process/network identities, exercises DB failures, and verifies finite +failed startup and SIGTERM cleanup. Test helpers reject incomplete or misleading +failure evidence. Cleanup touches only the acceptance run's own Compose project. +These are correctness checks, not performance measurements. + +The CI matrix derives `java` from the registry language. Only its Java entry +installs the pinned JDK. Other language acceptance jobs guard Java executables +alongside their other unrelated host toolchains. Shared version extraction reads +committed files without invoking Java or Gradle. The existing fail-closed +`required` aggregate must include the new implementation; no gate is optional. + +## Updating dependencies or measuring Java + +Treat an update as a reviewed baseline change. Verify official JDK/JRE release +assets, their published checksums and the Docker index identities; update both +supported architecture paths and the host JDK pin together. For a deliberate +Gradle or Spring update, resolve locks and verification metadata in an isolated +trusted environment, review every changed dependency and artifact checksum, +then run normal validation without lock/verification-writing options. Never +accept a missing hash, disable strict verification, trust arbitrary artifacts, +or regenerate locks merely to turn a failing gate green. + +A Java-inclusive official cohort needs a separate decision and issue. Assess JVM +warm-up under the common measurement profile; do not silently extend only Java's +five-second warm-up or change existing eight-stack results. Any justified real +official run, automatic audited publication and Pages update still require +explicit authorization. Publication/protection redesign and Issue #59's live +producer-to-Pages verification remain separate work. +''');changes[str(p.relative_to(r))]=True +for name in ('README.md','README.ja.md'): + old=subprocess.check_output(['git','show','6e25eb07759bd6eed4de9ab9ee3d1dd35562932b:'+name],cwd=r,text=True) + new=(r/name).read_text();start='';end='' + assert old.split(start)[1].split(end)[0]==new.split(start)[1].split(end)[0],name +print('Updated durable docs:',json.dumps(list(changes))) + +if set(changes) != set(expected): + raise RuntimeError('unexpected public documentation manifest') +changed=set(subprocess.check_output(['git','diff','--name-only'],cwd=r,text=True).splitlines()) +new=set(subprocess.check_output(['git','ls-files','--others','--exclude-standard'],cwd=r,text=True).splitlines()) +if changed | new != set(expected): + raise RuntimeError('unexpected checkout modifications') +out=r/'.cache/java-documentation' +for name, hashes in expected.items(): + p=r/name + if p.is_symlink() or blob(p.read_bytes()) != hashes['after']: + raise RuntimeError('unreviewed public documentation bytes: '+name) + target=out/'files'/name + target.parent.mkdir(parents=True,exist_ok=True) + target.write_bytes(p.read_bytes()) +(out/'manifest.json').write_text(json.dumps(expected,indent=2)+'\n') diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a77a49e..9c8610a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,6 +139,15 @@ jobs: - name: Install pinned Rust toolchain if: matrix.toolchain == 'rust' run: rustup toolchain install 1.98.1 --profile minimal --component rustfmt,clippy + - name: Install pinned Java toolchain + if: matrix.toolchain == 'java' + run: | + curl --fail --silent --show-error --location --max-time 180 'https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.4.1%2B1/OpenJDK25U-jdk_x64_linux_hotspot_25.0.4.1_1.tar.gz' -o "$RUNNER_TEMP/java.tar.gz" + echo "dbb698396d478e7fa2b1e50f4103324b2a99b90569ee27c33f2261f9215cf41e $RUNNER_TEMP/java.tar.gz" | sha256sum -c - + mkdir -p "$RUNNER_TEMP/java" + tar -xzf "$RUNNER_TEMP/java.tar.gz" --strip-components=1 -C "$RUNNER_TEMP/java" + echo "JAVA_HOME=$RUNNER_TEMP/java" >> "$GITHUB_ENV" + echo "$RUNNER_TEMP/java/bin" >> "$GITHUB_PATH" - name: Run implementation acceptance and failure gates run: python -m benchmark.ci run-isolated --implementation "$IMPLEMENTATION_ID" -- make "test-$IMPLEMENTATION_ID" - name: Run focused shared contract against implementation diff --git a/.github/workflows/java-development.yml b/.github/workflows/java-development.yml index 76d4aba..5f1f1d3 100644 --- a/.github/workflows/java-development.yml +++ b/.github/workflows/java-development.yml @@ -8,10 +8,10 @@ concurrency: group: java-development-67 cancel-in-progress: false jobs: - integration: + documentation: if: github.repository == 'tappe9/simple-api-benchmark' && github.ref == 'refs/heads/feat/add-java-spring-boot' runs-on: ubuntu-24.04 - timeout-minutes: 30 + timeout-minutes: 5 outputs: artifact_id: ${{ steps.evidence.outputs.artifact-id }} env: @@ -22,74 +22,34 @@ jobs: ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false - - name: Inspect candidate base images without changing source - run: | - set -euo pipefail - mkdir -p .cache/java-development - docker buildx imagetools inspect eclipse-temurin:25-jdk-noble | tee .cache/java-development/jdk-image.txt - docker buildx imagetools inspect eclipse-temurin:25-jre-noble | tee .cache/java-development/jre-image.txt - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 with: python-version-file: apps/python-fastapi/.python-version - - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff - with: - go-version: '1.27.1' - cache: false - - name: Prepare the exact checksum-verified JDK - run: | - set -euo pipefail - curl --fail --silent --show-error --location --max-time 180 'https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.4.1%2B1/OpenJDK25U-jdk_x64_linux_hotspot_25.0.4.1_1.tar.gz' -o "$RUNNER_TEMP/java67.tar.gz" - echo "dbb698396d478e7fa2b1e50f4103324b2a99b90569ee27c33f2261f9215cf41e $RUNNER_TEMP/java67.tar.gz" | sha256sum -c - - mkdir -p "$RUNNER_TEMP/java67/jdk" - tar -xzf "$RUNNER_TEMP/java67.tar.gz" --strip-components=1 -C "$RUNNER_TEMP/java67/jdk" - echo "JAVA_HOME=$RUNNER_TEMP/java67/jdk" >> "$GITHUB_ENV" - echo "GRADLE_USER_HOME=$RUNNER_TEMP/java67/gradle-home" >> "$GITHUB_ENV" - echo "$RUNNER_TEMP/java67/jdk/bin" >> "$GITHUB_PATH" - - name: Prepare narrowly scoped integration files for review + - name: Apply the reviewed public-documentation patch without credentials + run: python .github/java67-prepare.py + - name: Verify registration and unchanged generated results run: | set -euo pipefail - mkdir -p .cache/java-development - python -m pip install --require-hashes --only-binary=:all: -r apps/python-fastapi/requirements-dev.lock - python .github/java67-prepare.py > .cache/java-development/preparation.log 2>&1 || { cat .cache/java-development/preparation.log; exit 1; } - cat .cache/java-development/preparation.log - - name: Registry and static metadata regressions - run: | - set -euo pipefail - make test-registry - make test-compose + python -m benchmark.registry --check + python -m benchmark.generate_readme --check python -m unittest discover -s tests -p 'test_benchmark_java*.py' -v - python -m unittest discover -s tests -p 'test_java_spring_boot_acceptance.py' -v - - name: Actual Java production container acceptance (never publish) - run: | - set -euo pipefail - python -m benchmark.ci run-isolated --implementation java-spring-boot -- make test-java-spring-boot 2>&1 | tee .cache/java-development/container-acceptance.log - - name: Shared regression and focused HTTP contract - run: | - set -euo pipefail - make test-benchmark 2>&1 | tee .cache/java-development/shared.log - python -m benchmark.ci run-isolated --implementation java-spring-boot -- make test-contract CONTRACT_IMPL=java-spring-boot 2>&1 | tee .cache/java-development/contract.log + git diff --check git diff --exit-code 6e25eb07759bd6eed4de9ab9ee3d1dd35562932b -- results apps/go-gin apps/go-echo apps/rust-actix apps/rust-axum apps/node-fastify apps/node-express apps/python-fastapi apps/python-flask .github/workflows/benchmark.yml .github/workflows/pages.yml .github/workflows/pages-deploy.yml - - name: Export exact source and gate evidence - if: always() + - name: Export exact source identities run: | set -euo pipefail - mkdir -p .cache/java-development - git rev-parse HEAD HEAD^{tree} refs/remotes/origin/main > .cache/java-development/source-identity.txt - git bundle create .cache/java-development/source.bundle HEAD refs/remotes/origin/main - if test -d apps/java-spring-boot/build/test-results; then - cp -a apps/java-spring-boot/build/test-results .cache/java-development/ - fi + git rev-parse HEAD HEAD^{tree} refs/remotes/origin/main > .cache/java-documentation/source-identity.txt + git bundle create .cache/java-documentation/source.bundle HEAD refs/remotes/origin/main - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 id: evidence - if: always() with: - name: java-development-${{ github.run_id }}-${{ github.run_attempt }} - path: .cache/java-development/ + name: java-documentation-${{ github.run_id }}-${{ github.run_attempt }} + path: .cache/java-documentation/ include-hidden-files: true retention-days: 14 if-no-files-found: error import-objects: - needs: integration + needs: documentation if: github.repository == 'tappe9/simple-api-benchmark' && github.ref == 'refs/heads/feat/add-java-spring-boot' runs-on: ubuntu-24.04 timeout-minutes: 5 @@ -99,44 +59,44 @@ jobs: steps: - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: - artifact-ids: ${{ needs.integration.outputs.artifact_id }} - path: verified-integration + artifact-ids: ${{ needs.documentation.outputs.artifact_id }} + path: verified-documentation merge-multiple: true - - name: Import explicit reviewed-source candidates as blobs only + - name: Import fixed public-documentation blobs without changing refs env: GH_TOKEN: ${{ github.token }} run: | - python3 - <<'PY' + python3 - <<'PYTHON' import base64 import hashlib import json import os from pathlib import Path import urllib.request - names = ('benchmark/environment.py', 'benchmark/ci.py', 'benchmark/java_versions.py', 'tests/test_benchmark_registry.py', 'tests/test_benchmark_ci.py', 'tests/test_benchmark_java_candidate.py', 'tests/test_benchmark_java_versions.py', 'tests/test_workflows.py', 'tests/test_java_spring_boot_service.py', 'tests/test_java_spring_boot_acceptance.py', 'benchmark/implementations.json', 'benchmark/implementations.mk', 'site/registry.mjs', 'docker-compose.yml', '.github/workflows/ci.yml') - source = Path('verified-integration/files') - if {str(path.relative_to(source)) for path in source.rglob('*') if path.is_file()} != set(names): - raise RuntimeError('unexpected prepared-file manifest') + expected = {'README.md': '76a5ea4b0c8d0f9f60c322ab6a6a577b1b7145c4', 'README.ja.md': 'f2f69ddd8bc98e928e935cb677ce3b58ee445983', 'CONTRIBUTING.md': 'a4a774be53a58e1af52e41c03fd008037f26e64e', 'ARCHITECTURE.md': '78aca583561922acee0abc3d1f4d1c773d31619e', 'ROADMAP.md': '0472b929708f17a7f6c2dbbaaf3c707fcb2a528f', 'docs/IMPLEMENTATIONS.md': 'a61b40860bd80323341e853002aadbf30e4aa586', 'docs/AUTOMATION.md': 'f9b07b5bd3a44e4e8330c00eff0568bc8f980943', 'docs/METHODOLOGY.md': '7f42d37388b5aa1fd97775c2c9cb4ba69595797c', 'docs/JAVA-SPRING-BOOT.md': 'cd755d3ff77900cdb630dc75beb6c5e935ce2175'} + root = Path('verified-documentation/files') + if {str(path.relative_to(root)) for path in root.rglob('*') if path.is_file()} != set(expected): + raise RuntimeError('unexpected documentation artifact manifest') candidates = [] - for name in names: - path = source / name + for name, sha in expected.items(): + path = root / name if path.is_symlink() or any(parent.is_symlink() for parent in path.parents) or not path.is_file() or not 0 < path.stat().st_size < 200_000: - raise RuntimeError('unsafe prepared file') + raise RuntimeError('unsafe documentation file') data = path.read_bytes() data.decode('utf-8') - candidates.append((name, data)) - output = [] - for name, data in candidates: + if hashlib.sha1(b'blob ' + str(len(data)).encode() + b'\0' + data).hexdigest() != sha: + raise RuntimeError('unreviewed documentation content') + candidates.append((name, sha, data)) + result = [] + for name, sha, data in candidates: request = urllib.request.Request( 'https://api.github.com/repos/tappe9/simple-api-benchmark/git/blobs', - data=json.dumps({'encoding': 'base64', 'content': base64.b64encode(data).decode('ascii')}).encode('utf-8'), - headers={'Authorization': 'Bearer ' + os.environ['GH_TOKEN'], 'Accept': 'application/vnd.github+json', 'Content-Type': 'application/json', 'X-GitHub-Api-Version': '2022-11-28'}, - method='POST') + data=json.dumps({'encoding': 'base64', 'content': base64.b64encode(data).decode('ascii')}).encode(), + headers={'Authorization': 'Bearer ' + os.environ['GH_TOKEN'], 'Accept': 'application/vnd.github+json', 'Content-Type': 'application/json', 'X-GitHub-Api-Version': '2022-11-28'}, method='POST') with urllib.request.urlopen(request, timeout=30) as response: - blob = json.load(response) - expected = hashlib.sha1(b'blob ' + str(len(data)).encode('ascii') + b'\0' + data).hexdigest() - if blob.get('sha') != expected: - raise RuntimeError('Git blob identity mismatch') - output.append({'path': name, 'mode': '100644', 'type': 'blob', 'sha': expected, 'sha256': hashlib.sha256(data).hexdigest()}) - print(json.dumps(output, indent=2)) - PY + created = json.load(response) + if created.get('sha') != sha: + raise RuntimeError('documentation Git blob mismatch') + result.append({'path': name, 'sha': sha, 'mode': '100644', 'type': 'blob'}) + print(json.dumps(result, indent=2)) + PYTHON diff --git a/benchmark/ci.py b/benchmark/ci.py index 8bfcf5b..0999819 100644 --- a/benchmark/ci.py +++ b/benchmark/ci.py @@ -12,7 +12,13 @@ from . import registry # Display languages are registry data, not implementation-ID prefixes. -_LANGUAGE_TOOLCHAINS = {"Go": "go", "Rust": "rust", "Node.js": "node", "Python": "python"} +_LANGUAGE_TOOLCHAINS = { + "Go": "go", + "Rust": "rust", + "Node.js": "node", + "Python": "python", + "Java": "java", +} _TOOLCHAIN_COMMANDS = { "go": ("go", "gofmt"), "rust": ( @@ -26,6 +32,7 @@ "clippy-driver", ), "node": ("node", "nodejs", "npm", "npx", "corepack"), + "java": ("java", "javac", "jar", "javadoc", "gradle"), "python": (), # Python is the common harness and must remain available to every job. } diff --git a/benchmark/environment.py b/benchmark/environment.py index 0731e1d..f18e66f 100644 --- a/benchmark/environment.py +++ b/benchmark/environment.py @@ -22,6 +22,8 @@ wait_external_readiness, ) from .install_oha import SHA256, VERSION, platform_asset +from .java_versions import JAVA_VERSION +from .java_versions import pinned_versions as java_pinned_versions from .process import ROOT, execute from .registry import active_members, implementation, implementation_ids from .results import BenchmarkFailure, require, strict_json @@ -211,6 +213,9 @@ def match(pattern, text): **python_flask, }, } + versions["java-spring-boot"] = java_pinned_versions( + ROOT / implementation("java-spring-boot")["source_path"] + ) require( set(versions) == set(implementation_ids()), "version extractors must cover registered implementations", @@ -222,8 +227,14 @@ def match(pattern, text): ) require( all( - type(value) is str and re.fullmatch(r"\d+\.\d+\.\d+", value) - for value in values.values() + type(value) is str + and re.fullmatch( + JAVA_VERSION + if identifier == "java-spring-boot" and field == "java" + else r"\d+\.\d+\.\d+", + value, + ) + for field, value in values.items() ), f"unrecognized pinned versions for {identifier}", ) @@ -293,6 +304,10 @@ def provenance(oha: Path) -> dict: "apps/*/Cargo.lock", "apps/*/package-lock.json", "apps/*/requirements.lock", + "apps/*/gradle.lockfile", + "apps/*/gradle/verification-metadata.xml", + "apps/*/gradle/wrapper/gradle-wrapper.properties", + "apps/*/gradle/wrapper/gradle-wrapper.jar", ) for path in ROOT.glob(pattern) }, diff --git a/benchmark/implementations.json b/benchmark/implementations.json index b9fe549..6b66854 100644 --- a/benchmark/implementations.json +++ b/benchmark/implementations.json @@ -9,7 +9,8 @@ {"id":"node-fastify","language":"Node.js","framework":"Fastify","display_name":"Node.js / Fastify","source_path":"apps/node-fastify","version_fields":["node","fastify","pg"],"acceptance_test":"tests/test_node_fastify_service.py","failure_test":"tests/test_node_fastify_acceptance.py"}, {"id":"node-express","language":"Node.js","framework":"Express","display_name":"Node.js / Express","source_path":"apps/node-express","version_fields":["node","express","pg"],"acceptance_test":"tests/test_node_express_service.py","failure_test":"tests/test_node_express_acceptance.py"}, {"id":"python-fastapi","language":"Python","framework":"FastAPI","display_name":"Python / FastAPI","source_path":"apps/python-fastapi","version_fields":["python","fastapi","uvicorn","asyncpg"],"acceptance_test":"tests/test_python_fastapi_service.py","failure_test":"tests/test_python_fastapi_acceptance.py"}, - {"id":"python-flask","language":"Python","framework":"Flask","display_name":"Python / Flask","source_path":"apps/python-flask","version_fields":["python","flask","waitress","psycopg","psycopg-binary","psycopg-pool"],"acceptance_test":"tests/test_python_flask_service.py","failure_test":"tests/test_python_flask_acceptance.py"} + {"id":"python-flask","language":"Python","framework":"Flask","display_name":"Python / Flask","source_path":"apps/python-flask","version_fields":["python","flask","waitress","psycopg","psycopg-binary","psycopg-pool"],"acceptance_test":"tests/test_python_flask_service.py","failure_test":"tests/test_python_flask_acceptance.py"}, + {"id":"java-spring-boot","language":"Java","framework":"Spring Boot","display_name":"Java / Spring Boot","source_path":"apps/java-spring-boot","version_fields":["java","spring-boot","tomcat","postgresql","hikaricp","jackson","gradle"],"acceptance_test":"tests/test_java_spring_boot_service.py","failure_test":"tests/test_java_spring_boot_acceptance.py"} ], "cohorts": { "four-stack-v1":{"definition":"simple-api-v1","members":["go-gin","rust-actix","node-fastify","python-fastapi"]}, diff --git a/benchmark/implementations.mk b/benchmark/implementations.mk index 15a0abc..645d170 100644 --- a/benchmark/implementations.mk +++ b/benchmark/implementations.mk @@ -1,5 +1,5 @@ # Generated by python -m benchmark.registry --write; do not edit. -IMPLEMENTATION_TARGETS := test-go-gin test-go-echo test-rust-actix test-rust-axum test-node-fastify test-node-express test-python-fastapi test-python-flask +IMPLEMENTATION_TARGETS := test-go-gin test-go-echo test-rust-actix test-rust-axum test-node-fastify test-node-express test-python-fastapi test-python-flask test-java-spring-boot .PHONY: $(IMPLEMENTATION_TARGETS) test-go-gin: @@ -31,3 +31,7 @@ test-python-fastapi: test-python-flask: @$(PYTHON) -m unittest discover -s tests -p test_python_flask_acceptance.py @$(PYTHON) tests/test_python_flask_service.py + +test-java-spring-boot: + @$(PYTHON) -m unittest discover -s tests -p test_java_spring_boot_acceptance.py + @$(PYTHON) tests/test_java_spring_boot_service.py diff --git a/benchmark/java_versions.py b/benchmark/java_versions.py index 83fe818..abfcdf2 100644 --- a/benchmark/java_versions.py +++ b/benchmark/java_versions.py @@ -55,14 +55,21 @@ def runtime(coordinate): boot = runtime("org.springframework.boot:spring-boot-dependencies") require( - unique(r"org\.springframework\.boot:spring-boot-dependencies:([0-9.]+)", read("build.gradle")) == boot, + unique( + r"org\.springframework\.boot:spring-boot-dependencies:([0-9.]+)", read("build.gradle") + ) + == boot, "Spring Boot BOM and committed lock disagree", ) wrapper = read("gradle/wrapper/gradle-wrapper.properties") - gradle = unique(r"^distributionUrl=https\\://services\.gradle\.org/distributions/gradle-([0-9.]+)-bin\.zip$", wrapper) + gradle = unique( + r"^distributionUrl=https\\://services\.gradle\.org/distributions/gradle-([0-9.]+)-bin\.zip$", + wrapper, + ) unique(r"^distributionSha256Sum=([0-9a-f]{64})$", wrapper) require( - hashlib.sha256((root / "gradle/wrapper/gradle-wrapper.jar").read_bytes()).hexdigest() == WRAPPER_SHA256, + hashlib.sha256((root / "gradle/wrapper/gradle-wrapper.jar").read_bytes()).hexdigest() + == WRAPPER_SHA256, "Gradle wrapper checksum mismatch", ) try: @@ -83,7 +90,8 @@ def runtime(coordinate): for artifact in artifacts: checksums = artifact.findall("v:sha256", namespace) require( - bool(checksums) and all(re.fullmatch(r"[0-9a-f]{64}", value.get("value", "")) for value in checksums), + bool(checksums) + and all(re.fullmatch(r"[0-9a-f]{64}", value.get("value", "")) for value in checksums), "every Java dependency artifact needs a SHA-256 checksum", ) return { diff --git a/docker-compose.yml b/docker-compose.yml index 7a5a5ff..26f0aa3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -177,6 +177,26 @@ services: retries: 30 start_period: 5s + java-spring-boot: + <<: *api-defaults + build: + context: ./apps/java-spring-boot + environment: + <<: *database-environment + healthcheck: + test: + - CMD + - java + - -Xmx16m + - -XX:+UseSerialGC + - -cp + - /app/lib/* + - dev.simpleapibenchmark.Healthcheck + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s + networks: benchmark: driver: bridge diff --git a/site/registry.mjs b/site/registry.mjs index 41a0db5..089a8cd 100644 --- a/site/registry.mjs +++ b/site/registry.mjs @@ -150,6 +150,22 @@ export const REGISTRY = { "psycopg-binary", "psycopg-pool" ] + }, + { + "display_name": "Java / Spring Boot", + "framework": "Spring Boot", + "id": "java-spring-boot", + "language": "Java", + "source_path": "apps/java-spring-boot", + "version_fields": [ + "java", + "spring-boot", + "tomcat", + "postgresql", + "hikaricp", + "jackson", + "gradle" + ] } ], "legacy_cohort": "four-stack-v1" diff --git a/tests/test_benchmark_ci.py b/tests/test_benchmark_ci.py index ae7db44..17aca81 100644 --- a/tests/test_benchmark_ci.py +++ b/tests/test_benchmark_ci.py @@ -24,6 +24,7 @@ {"implementation": "node-express", "toolchain": "node"}, {"implementation": "python-fastapi", "toolchain": "python"}, {"implementation": "python-flask", "toolchain": "python"}, + {"implementation": "java-spring-boot", "toolchain": "java"}, ] COMMANDS = { "go": ("go", "gofmt"), @@ -39,6 +40,7 @@ ), "node": ("node", "nodejs", "npm", "npx", "corepack"), "python": (), + "java": ("java", "javac", "jar", "javadoc", "gradle"), } @@ -120,7 +122,7 @@ def setUp(self): path.write_text("#!/bin/sh\nexit 0\n") path.chmod(0o700) - def test_required_toolchain_and_common_harness_remain_available_for_all_eight(self): + def test_required_toolchain_and_common_harness_remain_available_for_all_registered(self): for entry in EXPECTED: commands = [*self.common, *COMMANDS[entry["toolchain"]]] script = ( diff --git a/tests/test_benchmark_java_candidate.py b/tests/test_benchmark_java_candidate.py index da22489..ccd885e 100644 --- a/tests/test_benchmark_java_candidate.py +++ b/tests/test_benchmark_java_candidate.py @@ -5,8 +5,51 @@ from benchmark import ci, environment, registry -EIGHT = ("go-gin", "go-echo", "rust-actix", "rust-axum", "node-fastify", "node-express", "python-fastapi", "python-flask") -BASELINE_VERSIONS = {'go-gin': {'go': '1.27.1', 'gin': '1.12.0', 'pgx': '5.10.0'}, 'go-echo': {'go': '1.27.1', 'echo': '5.3.1', 'pgx': '5.10.0'}, 'rust-actix': {'rust': '1.98.1', 'actix-web': '4.15.0', 'sqlx': '0.9.0', 'serde': '1.0.228', 'serde_json': '1.0.145'}, 'rust-axum': {'rust': '1.98.1', 'axum': '0.8.9', 'tokio': '1.53.1', 'sqlx': '0.9.0', 'serde': '1.0.228', 'serde_json': '1.0.145'}, 'node-fastify': {'node': '24.20.0', 'fastify': '5.12.3', 'pg': '8.23.0'}, 'node-express': {'node': '24.20.0', 'express': '5.2.1', 'pg': '8.23.0'}, 'python-fastapi': {'python': '3.14.7', 'fastapi': '0.141.1', 'uvicorn': '0.52.4', 'asyncpg': '0.31.0'}, 'python-flask': {'python': '3.14.7', 'flask': '3.1.3', 'waitress': '3.0.2', 'psycopg': '3.3.5', 'psycopg-binary': '3.3.5', 'psycopg-pool': '3.3.1'}} +EIGHT = ( + "go-gin", + "go-echo", + "rust-actix", + "rust-axum", + "node-fastify", + "node-express", + "python-fastapi", + "python-flask", +) +BASELINE_VERSIONS = { + "go-gin": {"go": "1.27.1", "gin": "1.12.0", "pgx": "5.10.0"}, + "go-echo": {"go": "1.27.1", "echo": "5.3.1", "pgx": "5.10.0"}, + "rust-actix": { + "rust": "1.98.1", + "actix-web": "4.15.0", + "sqlx": "0.9.0", + "serde": "1.0.228", + "serde_json": "1.0.145", + }, + "rust-axum": { + "rust": "1.98.1", + "axum": "0.8.9", + "tokio": "1.53.1", + "sqlx": "0.9.0", + "serde": "1.0.228", + "serde_json": "1.0.145", + }, + "node-fastify": {"node": "24.20.0", "fastify": "5.12.3", "pg": "8.23.0"}, + "node-express": {"node": "24.20.0", "express": "5.2.1", "pg": "8.23.0"}, + "python-fastapi": { + "python": "3.14.7", + "fastapi": "0.141.1", + "uvicorn": "0.52.4", + "asyncpg": "0.31.0", + }, + "python-flask": { + "python": "3.14.7", + "flask": "3.1.3", + "waitress": "3.0.2", + "psycopg": "3.3.5", + "psycopg-binary": "3.3.5", + "psycopg-pool": "3.3.1", + }, +} class JavaCandidateTests(unittest.TestCase): diff --git a/tests/test_benchmark_java_versions.py b/tests/test_benchmark_java_versions.py index 67e28f8..d0f5200 100644 --- a/tests/test_benchmark_java_versions.py +++ b/tests/test_benchmark_java_versions.py @@ -33,20 +33,33 @@ def test_valid_committed_metadata_requires_no_native_toolchain(self): self.assertEqual(versions["java"], "25.0.4.1+1") self.assertEqual(versions["spring-boot"], "4.1.1") self.assertEqual(versions["gradle"], "9.7.1") - self.assertEqual(set(versions), {"java", "spring-boot", "gradle", "tomcat", "postgresql", "hikaricp", "jackson"}) + self.assertEqual( + set(versions), + {"java", "spring-boot", "gradle", "tomcat", "postgresql", "hikaricp", "jackson"}, + ) def test_java_runtime_requires_an_exact_build(self): self.reject_change(".java-version", "25.0.4.1+1", "25") def test_changed_boot_bom_cannot_silently_disagree_with_lock(self): - self.reject_change("build.gradle", "spring-boot-dependencies:4.1.1", "spring-boot-dependencies:4.1.2") + self.reject_change( + "build.gradle", "spring-boot-dependencies:4.1.1", "spring-boot-dependencies:4.1.2" + ) def test_snapshot_dependency_is_rejected(self): - self.reject_change("gradle.lockfile", "spring-boot-dependencies:4.1.1=", "spring-boot-dependencies:4.1.1-SNAPSHOT=") + self.reject_change( + "gradle.lockfile", + "spring-boot-dependencies:4.1.1=", + "spring-boot-dependencies:4.1.1-SNAPSHOT=", + ) def test_duplicate_locked_dependency_is_rejected(self): file = self.root / "gradle.lockfile" - line = next(line for line in file.read_text().splitlines() if line.startswith("org.postgresql:postgresql:")) + line = next( + line + for line in file.read_text().splitlines() + if line.startswith("org.postgresql:postgresql:") + ) with file.open("a") as output: output.write(line + "\n") with self.assertRaises(BenchmarkFailure): @@ -58,13 +71,23 @@ def test_wrapper_checksum_is_verified_before_metadata_is_used(self): pinned_versions(self.root) def test_distribution_checksum_cannot_be_removed(self): - self.reject_change("gradle/wrapper/gradle-wrapper.properties", "distributionSha256Sum=", "removedChecksum=") + self.reject_change( + "gradle/wrapper/gradle-wrapper.properties", "distributionSha256Sum=", "removedChecksum=" + ) def test_metadata_verification_cannot_be_disabled(self): - self.reject_change("gradle/verification-metadata.xml", "true", "false") + self.reject_change( + "gradle/verification-metadata.xml", + "true", + "false", + ) def test_wildcard_artifact_trust_is_rejected(self): - self.reject_change("gradle/verification-metadata.xml", "", '') + self.reject_change( + "gradle/verification-metadata.xml", + "", + '', + ) if __name__ == "__main__": diff --git a/tests/test_benchmark_registry.py b/tests/test_benchmark_registry.py index ea78bee..5e30683 100644 --- a/tests/test_benchmark_registry.py +++ b/tests/test_benchmark_registry.py @@ -37,6 +37,7 @@ def test_authoritative_registry_contains_only_implemented_stacks(self): "node-express", "python-fastapi", "python-flask", + "java-spring-boot", ], ) diff --git a/tests/test_java_spring_boot_acceptance.py b/tests/test_java_spring_boot_acceptance.py index b013d7d..5253ad1 100644 --- a/tests/test_java_spring_boot_acceptance.py +++ b/tests/test_java_spring_boot_acceptance.py @@ -87,6 +87,7 @@ def test_startup_failure_rejects_oom_and_running_containers(self): with self.assertRaises(RuntimeError): validate_failed_startup(value, "application startup failed") + class DistributionPinTests(unittest.TestCase): @staticmethod def valid_manifest(): @@ -105,10 +106,12 @@ def valid_manifest(): def test_both_platforms_have_exactly_two_sha256_pins(self): from test_java_spring_boot_service import validate_distribution_pins + validate_distribution_pins(self.valid_manifest()) def test_bad_length_non_hex_and_missing_platform_are_rejected(self): from test_java_spring_boot_service import validate_distribution_pins + valid = self.valid_manifest() for old in ("a", "b", "c", "d"): for bad in (old * 63, old * 65, "g" * 64, ""): @@ -128,6 +131,7 @@ def test_bad_length_non_hex_and_missing_platform_are_rejected(self): def test_repository_distribution_manifest_is_well_formed(self): from test_java_spring_boot_service import APP, validate_distribution_pins + validate_distribution_pins((APP / "Dockerfile").read_text()) diff --git a/tests/test_java_spring_boot_service.py b/tests/test_java_spring_boot_service.py index 9e7bbae..48ed25f 100644 --- a/tests/test_java_spring_boot_service.py +++ b/tests/test_java_spring_boot_service.py @@ -11,13 +11,13 @@ import uuid from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from benchmark.contract_test import Case, assert_response, load_cases, read_response from benchmark.environment import validate_processes, validate_state from benchmark.java_versions import DEPENDENCY_FILES, pinned_versions +ROOT = Path(__file__).resolve().parents[1] APP = ROOT / "apps/java-spring-boot" SERVICE = "java-spring-boot" BASE_URL = "http://127.0.0.1:8080" @@ -100,8 +100,10 @@ def dependency_hashes(): def validate_distribution_pins(dockerfile): """Require checksum-verified, architecture-specific BuildKit downloads.""" - require("FROM archives-${TARGETARCH} AS archives\n" in dockerfile, - "Java archive selection must follow the target architecture") + require( + "FROM archives-${TARGETARCH} AS archives\n" in dockerfile, + "Java archive selection must follow the target architecture", + ) for platform, architecture in (("amd64", "x64"), ("arm64", "aarch64")): blocks = re.findall( rf"(?ms)^FROM scratch AS archives-{platform}\n(.*?)(?=^FROM )", dockerfile @@ -120,8 +122,9 @@ def validate_distribution_pins(dockerfile): == [("jdk", architecture, "jdk"), ("jre", architecture, "jre")], "Java distribution archives must have complete SHA-256 pins and correct platforms", ) - require(len(re.findall(r"(?m)^ADD ", blocks[0])) == 2, - "unexpected Java distribution download") + require( + len(re.findall(r"(?m)^ADD ", blocks[0])) == 2, "unexpected Java distribution download" + ) def static_checks(): diff --git a/tests/test_workflows.py b/tests/test_workflows.py index ce52198..1918143 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -119,6 +119,11 @@ def test_implementation_setup_is_selected_and_all_host_gates_are_isolated(self): matching = [step for step in steps if step.get("uses", "").startswith(action)] self.assertEqual(len(matching), 1) self.assertEqual(matching[0].get("if"), condition) + java = next(step for step in steps if step.get("name") == "Install pinned Java toolchain") + self.assertEqual(java["if"], "matrix.toolchain == 'java'") + self.assertIn("sha256sum -c -", java["run"]) + self.assertIn("jdk-25.0.4.1%2B1", java["run"]) + self.assertNotIn("continue-on-error", java) rust = next(step for step in steps if step.get("name") == "Install pinned Rust toolchain") self.assertEqual(rust["if"], "matrix.toolchain == 'rust'") self.assertEqual( From 0a58c97720a7e469dcd3eec70787535f1e481528 Mon Sep 17 00:00:00 2001 From: Yuma Takino <36617106+tappe9@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:19:02 +0900 Subject: [PATCH 14/15] docs: finish Java registration guidance and remove development tooling Import the nine exact reviewed public-documentation blobs from successful run 35193242290, independently checked against artifact SHA-256 and the exported source identity. Clearly distinguish nine registered implementations from eight officially measured ones and document the locked Java build, acceptance commands and unverified ARM64 execution boundary. Remove both temporary preparation/workflow files. Fix the expected CI toolchain mapping to include Java after reproducing its KeyError; all 21 permanent workflow tests now pass without weakening the fail-closed aggregate. Preserve generated README result blocks, all result files, historical cohorts and existing application versions. No official benchmark or publication. --- .github/java67-prepare.py | 301 ------------------------- .github/workflows/java-development.yml | 102 --------- ARCHITECTURE.md | 31 ++- CONTRIBUTING.md | 27 ++- README.ja.md | 26 ++- README.md | 26 ++- ROADMAP.md | 8 +- docs/AUTOMATION.md | 6 +- docs/IMPLEMENTATIONS.md | 20 +- docs/JAVA-SPRING-BOOT.md | 127 +++++++++++ docs/METHODOLOGY.md | 12 +- tests/test_workflows.py | 1 + 12 files changed, 260 insertions(+), 427 deletions(-) delete mode 100644 .github/java67-prepare.py delete mode 100644 .github/workflows/java-development.yml create mode 100644 docs/JAVA-SPRING-BOOT.md diff --git a/.github/java67-prepare.py b/.github/java67-prepare.py deleted file mode 100644 index f058e4f..0000000 --- a/.github/java67-prepare.py +++ /dev/null @@ -1,301 +0,0 @@ -"""Temporary preparation of reviewed durable public documentation only. - -This uncredentialed helper is removed before final PR gates. It never writes -plans, investigation notes, commits, refs, or benchmark results. -""" -from pathlib import Path -import hashlib,json,subprocess -r=Path(__file__).resolve().parents[1] -expected = {'README.md': {'before': '4c8d745d554bcd7ece5403892fde6268c9129ca1', 'after': '76a5ea4b0c8d0f9f60c322ab6a6a577b1b7145c4'}, 'README.ja.md': {'before': '27cedaec537919e97379d44ea2530f3a3f5ec9f6', 'after': 'f2f69ddd8bc98e928e935cb677ce3b58ee445983'}, 'CONTRIBUTING.md': {'before': 'c62d99b70cab232926ede1078b0009def3191d4b', 'after': 'a4a774be53a58e1af52e41c03fd008037f26e64e'}, 'ARCHITECTURE.md': {'before': '496383d40288ef84c50ea24c1d815b2c32a9cb4c', 'after': '78aca583561922acee0abc3d1f4d1c773d31619e'}, 'ROADMAP.md': {'before': '4ecfa6674705acd52d31a38574cb6d948902f5ca', 'after': '0472b929708f17a7f6c2dbbaaf3c707fcb2a528f'}, 'docs/IMPLEMENTATIONS.md': {'before': 'f817a58550297f2e2c17f5af017a09527fd6fd98', 'after': 'a61b40860bd80323341e853002aadbf30e4aa586'}, 'docs/AUTOMATION.md': {'before': '0b14391346c8fec7b71ddcf145cbcee625ba9be2', 'after': 'f9b07b5bd3a44e4e8330c00eff0568bc8f980943'}, 'docs/METHODOLOGY.md': {'before': '05187b6e46b72d29a048319ef5fc3ec40eedc5b3', 'after': '7f42d37388b5aa1fd97775c2c9cb4ba69595797c'}, 'docs/JAVA-SPRING-BOOT.md': {'before': None, 'after': 'cd755d3ff77900cdb630dc75beb6c5e935ce2175'}} -def blob(data): - return hashlib.sha1(b'blob '+str(len(data)).encode('ascii')+b'\0'+data).hexdigest() -subprocess.run(['git','diff','--exit-code','HEAD'],cwd=r,check=True) -for name, hashes in expected.items(): - p=r/name - actual=blob(p.read_bytes()) if p.is_file() and not p.is_symlink() else None - if actual != hashes['before'] or (hashes['before'] is None and p.exists()): - raise RuntimeError('documentation baseline mismatch: '+name) -changes={} -def replace(name, old, new): - assert name in expected - p=r/name;s=p.read_text();assert s.count(old)==1,(name,old[:100],s.count(old));p.write_text(s.replace(old,new));changes[name]=True -replace('README.md','contains eight API implementations','contains nine API implementations') -replace('README.md','Current `main` includes all eight CI-covered implementations, the active `eight-stack-v1` cohort with verified published results, and a Pages dashboard with history navigation.', 'Current `main` includes nine CI-covered implementations. The active `eight-stack-v1` cohort has verified published results and a Pages dashboard with history navigation. Java / Spring Boot is registered for validation but is not part of the official cohort or published results.') -replace('README.md','All eight API implementations and the shared contract suite are available:', '''## Java / Spring Boot implementation - -`apps/java-spring-boot/` uses Temurin 25.0.4.1+1, Spring Boot 4.1.1, -Spring MVC/Tomcat, JDBC/HikariCP, and checksum-verified Gradle 9.7.1. -It follows the same API, single-process, 1 CPU / 512 MiB and ten-connection -limits. It is the **ninth registered implementation**, not an additional official -result. `eight-stack-v1` and all published measurements remain unchanged. - -```bash -make test-java-spring-boot -make test-contract CONTRACT_IMPL=java-spring-boot -``` - -The acceptance target needs the exact JDK, Python 3.10+, Make and Docker Compose -v2 on a POSIX host. The container-only contract target does not need a host JDK. -See the [Java implementation guide](docs/JAVA-SPRING-BOOT.md) for build integrity, -startup/shutdown behavior, supported build paths and the unmeasured boundary. - -All nine API implementations and the shared contract suite are available:''') -replace('README.md','# all eight APIs, one at a time','# all nine registered APIs, one at a time') -replace('README.ja.md','検証ルールを使う8つのAPI実装があります。','検証ルールを使う9つのAPI実装があります。') -replace('README.ja.md','現在の`main`は8実装すべてがCI対象で、有効な`eight-stack-v1`の検証済み結果を公開しています。Pagesではダッシュボードと過去の結果を閲覧できます。','現在の`main`は9実装がCI対象です。公式比較は引き続き`eight-stack-v1`の8実装で、Pagesに検証済み結果と履歴を公開しています。Java / Spring Bootは検証対象として登録済みですが、公式測定・公開結果には含まれません。') -p=r/'README.ja.md';s=p.read_text();anchor=next(line for line in s.splitlines() if '8' in line and ('共通契約' in line or '共通contract' in line or '共通のcontract' in line));print('Japanese boundary anchor:',anchor) -replace('README.ja.md',anchor,'''## Java / Spring Boot実装 - -`apps/java-spring-boot/`はTemurin 25.0.4.1+1、Spring Boot 4.1.1、 -Spring MVC / Tomcat、JDBC / HikariCP、チェックサムを検証するGradle 9.7.1を使用します。 -共通のAPI、単一プロセス、1 CPU・512 MiB、DB接続上限10を維持します。 -**9番目の登録済み実装ですが、公式測定対象への追加ではありません。** -`eight-stack-v1`と公開済みの測定値は変更しません。 - -```bash -make test-java-spring-boot -make test-contract CONTRACT_IMPL=java-spring-boot -``` - -受入テストには、POSIXホスト上の固定JDK、Python 3.10以降、Make、Docker Compose v2が必要です。 -コンテナのみを使う共通契約テストには、ホストのJDKは不要です。 -固定依存関係、起動・終了の検証、公式測定と分ける理由は -[Java実装ガイド](docs/JAVA-SPRING-BOOT.md)を参照してください。 - -9実装すべてを、共通のAPI契約で検証できます。''') -p=r/'README.ja.md';s=p.read_text();lines=[x for x in s.splitlines() if x.startswith('make test-contract ')];print('Japanese contract commands',lines) -for line in lines: - if '#' in line and '8' in line: replace('README.ja.md',line,line.replace('8','9')) -replace('CONTRIBUTING.md','### Shared contract checks','''### Java / Spring Boot checks - -Install the exact Temurin JDK recorded in `apps/java-spring-boot/.java-version` -(currently `25.0.4.1+1`), plus Python 3.10+, Make and Docker Compose v2 on a POSIX -host. A global Gradle installation is not needed. Run the native checks without -Docker, or the complete production acceptance target: - -```bash -(cd apps/java-spring-boot && ./gradlew --no-daemon test installDist) -make test-java-spring-boot -make test-contract CONTRACT_IMPL=java-spring-boot -``` - -The committed Wrapper checksum, distribution checksum, strict dependency locks -and artifact verification metadata are mandatory. Validation must not regenerate -those files or download an alternative JDK silently. The production acceptance -checks live SQL/BIGINT responses, JSON errors, pool and container limits, finite -startup failure, SIGTERM shutdown and project-scoped cleanup. See the -[Java guide](docs/JAVA-SPRING-BOOT.md) for the dependency-update procedure. -Java registration does not expand the active official cohort or authorize a run. - -### Shared contract checks''') -replace('CONTRIBUTING.md','# all eight registered implementations, sequentially','# all nine registered implementations, sequentially') -replace('CONTRIBUTING.md','`node-express`, `python-fastapi`, and `python-flask`.','`node-express`, `python-fastapi`, `python-flask`, and `java-spring-boot`.') -replace('CONTRIBUTING.md','all toolchains and shared workflow/site dependencies. See the','all toolchains, including the exact Java JDK, and shared workflow/site dependencies. See the') -replace('docs/IMPLEMENTATIONS.md','The eight registered implementations, in deterministic registry order, are:\nGo / Gin, Go / Echo, Rust / Actix Web, Rust / Axum, Node.js / Fastify,\nNode.js / Express, Python / FastAPI, and Python / Flask. Registration enables','The nine registered implementations, in deterministic registry order, are:\nGo / Gin, Go / Echo, Rust / Actix Web, Rust / Axum, Node.js / Fastify,\nNode.js / Express, Python / FastAPI, Python / Flask, and Java / Spring Boot. Registration enables') -replace('docs/IMPLEMENTATIONS.md','extracts all eight\nstacks','extracts all nine\nstacks') -replace('docs/IMPLEMENTATIONS.md','### Eight-stack rollout boundary (#50)','''### Java registration boundary (#67) - -`java-spring-boot` is registered with a Java-only host toolchain, real-container -acceptance and the unchanged shared contract. Its static version extractor reads -the committed JDK build, Gradle Wrapper, strict lockfile and SHA-256 verification -metadata without executing Java or Gradle. Other implementation jobs do not need -a host JDK. See [Java / Spring Boot](JAVA-SPRING-BOOT.md). - -Neither cohort changes membership or order. Official metadata still contains -only the eight active implementations; Java is not filled into old reports with -zeroes or missing values. A future Java-inclusive cohort and any official run -need a separate decision, including an assessment of JVM warm-up under the common -profile. Registration and passing tests are not measured performance evidence. - -### Eight-stack rollout boundary (#50)''') -replace('docs/AUTOMATION.md','Rust jobs install Rust with rustfmt/Clippy. Python implementation jobs need no\nadditional language setup.','Rust jobs install Rust with rustfmt/Clippy. Only Java jobs install the exact\nTemurin JDK using its fixed release URL and verified SHA-256; Gradle is supplied\nby the committed checksum-verified Wrapper. Python implementation jobs need no\nadditional language setup.') -replace('docs/AUTOMATION.md','unrelated host Go/Node/Rust tools','unrelated host Go/Node/Rust/Java tools') -replace('docs/METHODOLOGY.md','## When to remeasure','''Current `main` uses the versioned `eight-stack-v1` cohort for official measurement; -its membership is listed in the [registry guide](IMPLEMENTATIONS.md). Java / -Spring Boot is the ninth registered implementation for acceptance and contract -checks, but is outside both existing official cohorts. Its single JVM uses the -same resource and pool limits, normal JSON serialization, JDBC and direct -recursive Fibonacci. There is no Java result or performance claim from these -correctness tests. Before any future cohort change, assess JVM warm-up under the -common profile rather than silently extending Java's five-second warm-up alone. -See the [Java implementation guide](JAVA-SPRING-BOOT.md). - -## When to remeasure''') -replace('docs/METHODOLOGY.md','All eight registered implementations can be checked','All nine registered implementations can be checked') -replace('ARCHITECTURE.md','GitHub Actions should measure every backend in the same job.','GitHub Actions should measure every active cohort member sequentially in the same job.') -replace('ARCHITECTURE.md',' R --> A4[Python / FastAPI and Flask]',' R --> A4[Python / FastAPI and Flask]\n M -. acceptance and contracts only .-> A5[Java / Spring Boot]\n A5 --> P') -replace('ARCHITECTURE.md','### PostgreSQL','''#### Java / Spring Boot - -`apps/java-spring-boot/` uses a single Temurin 25.0.4.1+1 JVM with Spring Boot -4.1.1, Spring MVC/Tomcat, ordinary record serialization and JDBC/HikariCP. -`DatabaseSettings` validates the shared five `DATABASE_*` values without putting -credentials into the JDBC URL. `DatabaseConfiguration` bounds the pool at ten -connections and validates `SELECT 1` before startup completes. Repository reads -use a prepared statement and try-with-resources; signed BIGINT IDs remain numeric -JSON, missing rows return 404, and SQL failures return a sanitized 500. Each CPU -request calculates Fibonacci(30) by direct recursion. - -The production image is non-root, inherits the common resource/isolation limits, -contains only runtime libraries and shuts down HTTP and the pool on SIGTERM. -Architecture-selected BuildKit stages checksum-verify the exact JDK/JRE archives; -both OS base images are digest pinned. Gradle Wrapper and dependency verification -are strict. The small standalone health probe does not start Spring or connect -to the database. No ORM, WebFlux, native image, virtual threads or additional -server processes are enabled. Java is CI-covered but not an official cohort -member; see [the Java guide](docs/JAVA-SPRING-BOOT.md). - -### PostgreSQL''') -replace('ARCHITECTURE.md','covers all eight registered stacks','covers all nine registered stacks') -replace('ARCHITECTURE.md','│ └── python-flask/','│ ├── python-flask/\n│ └── java-spring-boot/') -replace('ARCHITECTURE.md','builds and checks all eight registered applications','builds and checks all nine registered applications') -replace('ROADMAP.md','| Versioned eight-stack cohort |','| Java / Spring Boot registration | Ninth implementation with dedicated host toolchain, checksum-verified build, real-container acceptance and shared contracts ([#67](https://github.com/tappe9/simple-api-benchmark/issues/67)). Outside the active official cohort; no Java result is published. |\n| Versioned eight-stack cohort |') -replace('ROADMAP.md','Java / Spring Boot and C# / ASP.NET Core may be evaluated one at a time. These are\ncandidates, not commitments or implemented benchmark entries. Historical-result\nnavigation is already available, not a future candidate.','C# / ASP.NET Core may be evaluated as a separate implementation candidate, not a\ncommitment. Java / Spring Boot is already registered; its inclusion in a future\nofficial cohort remains a separate decision that must assess the common warm-up\nand measurement profile. Historical-result navigation is already available.') -p=r/'docs/JAVA-SPRING-BOOT.md';assert not p.exists();p.write_text('''# Java / Spring Boot - -## Registration is not official measurement - -`java-spring-boot` is the ninth registered implementation. CI builds it and checks -it against the same API contract as the other implementations. It is **not** a -member of `eight-stack-v1` or frozen `four-stack-v1`. Neither historical data nor -the current result receives synthetic Java values. There is no official Java -performance result from adding or testing this implementation. - -## Fixed build inputs - -| Component | Version | -| --- | --- | -| Temurin JDK and production JRE | `25.0.4.1+1` | -| Spring Boot | `4.1.1` | -| Tomcat | `11.0.24` | -| PostgreSQL JDBC driver | `42.7.13` | -| HikariCP | `7.0.2` | -| Jackson databind | `3.1.5` | -| Gradle Wrapper | `9.7.1` | - -`.java-version` fixes the complete runtime build, including the build number. -The Gradle build rejects a different executing JDK; automatic toolchain downloads -are disabled. The committed `gradle.lockfile` fixes runtime and test dependency -configurations under strict locking. `gradle/verification-metadata.xml` verifies -dependency artifacts and metadata with SHA-256. The Wrapper JAR and distribution -also have fixed checksums. Validation does not rewrite this evidence. - -Both Temurin OS bases are fixed by OCI index digest. Their bundled JDK predates -the selected hotfix, so the build replaces it with exact checksum-verified JDK -and JRE archives. Architecture-specific scratch stages use BuildKit's -`ADD --checksum`; only the selected `amd64` or `arm64` download stage is needed. -There is no floating package installation or requirement for curl in the image. -The final image copies the verified JRE and runtime library directory, not test -libraries, a build daemon, development tools or an application launcher shell. - -The Dockerfile provides x86-64 and ARM64 archive paths. The required GitHub CI -runs real-container acceptance on Linux x86-64. An ARM64 path and verified -archive checksum are not a claim of executed ARM64 acceptance. - -## Runtime behavior - -Spring MVC and embedded Tomcat run in one JVM process on port 8080 using -HTTP/1.1. JVM and servlet implementation threads are not additional server -processes; the common container limit remains 1 CPU / 512 MiB. Virtual threads, -WebFlux, ORM, native-image compilation and special result serializers are not -used. The JVM sets `MaxRAMPercentage=60.0` to leave headroom for non-heap memory. - -The application reads `DATABASE_HOST`, `DATABASE_PORT`, `DATABASE_NAME`, -`DATABASE_USER` and `DATABASE_PASSWORD`. Address and port validation prevents -connection-URL parameter injection. Credentials are supplied separately to the -pool and redacted from the settings object's string representation. Startup -executes `SELECT 1`; invalid settings, authentication failure and unreachable DB -must terminate within a finite deadline without exposing credentials or SQL. - -HikariCP has `maximumPoolSize=10` and `minimumIdle=0`, finite acquisition and -validation timeouts, and the PostgreSQL session application name -`simple-api-java`. Every `/db/{id}` request executes a prepared statement using -the parsed signed 64-bit ID. Connections, statements and result sets are closed -with try-with-resources. Missing rows return 404, malformed/out-of-range IDs 400, -and SQL failures only `{"error":"internal server error"}` with status 500. -Ordinary Java records are serialized by the framework, including exact numeric -BIGINT IDs beyond JavaScript's safe-integer range. Every `/cpu` request computes -Fibonacci(30) by direct recursion, without caching or precomputation. - -The image runs as `10001:10001`. Compose retains the common dropped capabilities, -no-new-privileges, private database network, loopback-only host port and disabled -restart policy. The health command is a separate small JVM HTTP probe with a -small heap and bounded response; it never starts Spring or queries the DB. -It is not a measurement process. The existing external-readiness measurement -policy is unchanged. SIGTERM stops HTTP gracefully and closes the pool. - -## Development and verification - -On a POSIX host, install the exact Temurin JDK above, Python 3.10+, Make and Docker -Compose v2 with BuildKit and `up --wait` support. No global Gradle is required. -Do not run competing API services on the shared local port 8080. - -```bash -# Native tests and production distribution, without Docker. -(cd apps/java-spring-boot && ./gradlew --no-daemon test installDist) - -# Strict host build plus real PostgreSQL/container acceptance. -make test-java-spring-boot - -# Shared HTTP contract through Docker; no host JDK is required. -make test-contract CONTRACT_IMPL=java-spring-boot - -# Static metadata and failure-check regression tests, without Java or Docker. -python -m unittest discover -s tests -p 'test_benchmark_java*.py' -python -m unittest discover -s tests -p 'test_java_spring_boot_acceptance.py' -``` - -Native compilation enables all javac lint warnings and treats them as errors. -The native suite covers normal JSON/health/CPU behavior, repeated DB reads, -sanitized failures, BIGINT boundaries, invalid input and settings/health checks. -Real-container acceptance additionally proves that SQL row changes affect the -response, checks the observed pool bound under concurrent requests, validates -resource/process/network identities, exercises DB failures, and verifies finite -failed startup and SIGTERM cleanup. Test helpers reject incomplete or misleading -failure evidence. Cleanup touches only the acceptance run's own Compose project. -These are correctness checks, not performance measurements. - -The CI matrix derives `java` from the registry language. Only its Java entry -installs the pinned JDK. Other language acceptance jobs guard Java executables -alongside their other unrelated host toolchains. Shared version extraction reads -committed files without invoking Java or Gradle. The existing fail-closed -`required` aggregate must include the new implementation; no gate is optional. - -## Updating dependencies or measuring Java - -Treat an update as a reviewed baseline change. Verify official JDK/JRE release -assets, their published checksums and the Docker index identities; update both -supported architecture paths and the host JDK pin together. For a deliberate -Gradle or Spring update, resolve locks and verification metadata in an isolated -trusted environment, review every changed dependency and artifact checksum, -then run normal validation without lock/verification-writing options. Never -accept a missing hash, disable strict verification, trust arbitrary artifacts, -or regenerate locks merely to turn a failing gate green. - -A Java-inclusive official cohort needs a separate decision and issue. Assess JVM -warm-up under the common measurement profile; do not silently extend only Java's -five-second warm-up or change existing eight-stack results. Any justified real -official run, automatic audited publication and Pages update still require -explicit authorization. Publication/protection redesign and Issue #59's live -producer-to-Pages verification remain separate work. -''');changes[str(p.relative_to(r))]=True -for name in ('README.md','README.ja.md'): - old=subprocess.check_output(['git','show','6e25eb07759bd6eed4de9ab9ee3d1dd35562932b:'+name],cwd=r,text=True) - new=(r/name).read_text();start='';end='' - assert old.split(start)[1].split(end)[0]==new.split(start)[1].split(end)[0],name -print('Updated durable docs:',json.dumps(list(changes))) - -if set(changes) != set(expected): - raise RuntimeError('unexpected public documentation manifest') -changed=set(subprocess.check_output(['git','diff','--name-only'],cwd=r,text=True).splitlines()) -new=set(subprocess.check_output(['git','ls-files','--others','--exclude-standard'],cwd=r,text=True).splitlines()) -if changed | new != set(expected): - raise RuntimeError('unexpected checkout modifications') -out=r/'.cache/java-documentation' -for name, hashes in expected.items(): - p=r/name - if p.is_symlink() or blob(p.read_bytes()) != hashes['after']: - raise RuntimeError('unreviewed public documentation bytes: '+name) - target=out/'files'/name - target.parent.mkdir(parents=True,exist_ok=True) - target.write_bytes(p.read_bytes()) -(out/'manifest.json').write_text(json.dumps(expected,indent=2)+'\n') diff --git a/.github/workflows/java-development.yml b/.github/workflows/java-development.yml deleted file mode 100644 index 5f1f1d3..0000000 --- a/.github/workflows/java-development.yml +++ /dev/null @@ -1,102 +0,0 @@ -name: Java development -on: - push: - branches: [feat/add-java-spring-boot] -permissions: - contents: read -concurrency: - group: java-development-67 - cancel-in-progress: false -jobs: - documentation: - if: github.repository == 'tappe9/simple-api-benchmark' && github.ref == 'refs/heads/feat/add-java-spring-boot' - runs-on: ubuntu-24.04 - timeout-minutes: 5 - outputs: - artifact_id: ${{ steps.evidence.outputs.artifact-id }} - env: - PYTHONDONTWRITEBYTECODE: '1' - steps: - - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - persist-credentials: false - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 - with: - python-version-file: apps/python-fastapi/.python-version - - name: Apply the reviewed public-documentation patch without credentials - run: python .github/java67-prepare.py - - name: Verify registration and unchanged generated results - run: | - set -euo pipefail - python -m benchmark.registry --check - python -m benchmark.generate_readme --check - python -m unittest discover -s tests -p 'test_benchmark_java*.py' -v - git diff --check - git diff --exit-code 6e25eb07759bd6eed4de9ab9ee3d1dd35562932b -- results apps/go-gin apps/go-echo apps/rust-actix apps/rust-axum apps/node-fastify apps/node-express apps/python-fastapi apps/python-flask .github/workflows/benchmark.yml .github/workflows/pages.yml .github/workflows/pages-deploy.yml - - name: Export exact source identities - run: | - set -euo pipefail - git rev-parse HEAD HEAD^{tree} refs/remotes/origin/main > .cache/java-documentation/source-identity.txt - git bundle create .cache/java-documentation/source.bundle HEAD refs/remotes/origin/main - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - id: evidence - with: - name: java-documentation-${{ github.run_id }}-${{ github.run_attempt }} - path: .cache/java-documentation/ - include-hidden-files: true - retention-days: 14 - if-no-files-found: error - import-objects: - needs: documentation - if: github.repository == 'tappe9/simple-api-benchmark' && github.ref == 'refs/heads/feat/add-java-spring-boot' - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - contents: write - actions: read - steps: - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - artifact-ids: ${{ needs.documentation.outputs.artifact_id }} - path: verified-documentation - merge-multiple: true - - name: Import fixed public-documentation blobs without changing refs - env: - GH_TOKEN: ${{ github.token }} - run: | - python3 - <<'PYTHON' - import base64 - import hashlib - import json - import os - from pathlib import Path - import urllib.request - expected = {'README.md': '76a5ea4b0c8d0f9f60c322ab6a6a577b1b7145c4', 'README.ja.md': 'f2f69ddd8bc98e928e935cb677ce3b58ee445983', 'CONTRIBUTING.md': 'a4a774be53a58e1af52e41c03fd008037f26e64e', 'ARCHITECTURE.md': '78aca583561922acee0abc3d1f4d1c773d31619e', 'ROADMAP.md': '0472b929708f17a7f6c2dbbaaf3c707fcb2a528f', 'docs/IMPLEMENTATIONS.md': 'a61b40860bd80323341e853002aadbf30e4aa586', 'docs/AUTOMATION.md': 'f9b07b5bd3a44e4e8330c00eff0568bc8f980943', 'docs/METHODOLOGY.md': '7f42d37388b5aa1fd97775c2c9cb4ba69595797c', 'docs/JAVA-SPRING-BOOT.md': 'cd755d3ff77900cdb630dc75beb6c5e935ce2175'} - root = Path('verified-documentation/files') - if {str(path.relative_to(root)) for path in root.rglob('*') if path.is_file()} != set(expected): - raise RuntimeError('unexpected documentation artifact manifest') - candidates = [] - for name, sha in expected.items(): - path = root / name - if path.is_symlink() or any(parent.is_symlink() for parent in path.parents) or not path.is_file() or not 0 < path.stat().st_size < 200_000: - raise RuntimeError('unsafe documentation file') - data = path.read_bytes() - data.decode('utf-8') - if hashlib.sha1(b'blob ' + str(len(data)).encode() + b'\0' + data).hexdigest() != sha: - raise RuntimeError('unreviewed documentation content') - candidates.append((name, sha, data)) - result = [] - for name, sha, data in candidates: - request = urllib.request.Request( - 'https://api.github.com/repos/tappe9/simple-api-benchmark/git/blobs', - data=json.dumps({'encoding': 'base64', 'content': base64.b64encode(data).decode('ascii')}).encode(), - headers={'Authorization': 'Bearer ' + os.environ['GH_TOKEN'], 'Accept': 'application/vnd.github+json', 'Content-Type': 'application/json', 'X-GitHub-Api-Version': '2022-11-28'}, method='POST') - with urllib.request.urlopen(request, timeout=30) as response: - created = json.load(response) - if created.get('sha') != sha: - raise RuntimeError('documentation Git blob mismatch') - result.append({'path': name, 'sha': sha, 'mode': '100644', 'type': 'blob'}) - print(json.dumps(result, indent=2)) - PYTHON diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 496383d..78aca58 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -10,7 +10,7 @@ The architecture is intentionally small. 1. A new reader should understand the repository quickly. 2. Every backend should implement the same API contract. 3. A local benchmark should run with one command. -4. GitHub Actions should measure every backend in the same job. +4. GitHub Actions should measure every active cohort member sequentially in the same job. 5. README and GitHub Pages should read the same result file. ## Non-goals for v0.1 @@ -36,6 +36,8 @@ flowchart LR R --> A2[Rust / Actix Web and Axum] R --> A3[Node.js / Fastify and Express] R --> A4[Python / FastAPI and Flask] + M -. acceptance and contracts only .-> A5[Java / Spring Boot] + A5 --> P A1 --> P[(PostgreSQL)] A2 --> P A3 --> P @@ -100,6 +102,26 @@ The lifespan creates a pool with `min_size=1` and `max_size=10`, executes `SELEC Uvicorn is the direct container process, with `--workers 1 --loop asyncio --http h11`, no access log, and a five-second graceful HTTP shutdown timeout. The lifespan closes the pool with a five-second bound and terminates remaining connections only if graceful pool closure fails or is cancelled. The production image installs only the SHA256-verified runtime lock and excludes tests and development tools. Both Docker stages use `python:3.14.7-slim-bookworm` pinned to index digest `sha256:9ab8d9c8514b44f90cf0029dd42fdd7e9e211e639c8b995304cc04568dee900f`. Compose uses non-root user `10001:10001`, 1 CPU / 512 MB, healthy PostgreSQL ordering, dropped capabilities, no privilege escalation, loopback-only port `8080`, and no restarts. +#### Java / Spring Boot + +`apps/java-spring-boot/` uses a single Temurin 25.0.4.1+1 JVM with Spring Boot +4.1.1, Spring MVC/Tomcat, ordinary record serialization and JDBC/HikariCP. +`DatabaseSettings` validates the shared five `DATABASE_*` values without putting +credentials into the JDBC URL. `DatabaseConfiguration` bounds the pool at ten +connections and validates `SELECT 1` before startup completes. Repository reads +use a prepared statement and try-with-resources; signed BIGINT IDs remain numeric +JSON, missing rows return 404, and SQL failures return a sanitized 500. Each CPU +request calculates Fibonacci(30) by direct recursion. + +The production image is non-root, inherits the common resource/isolation limits, +contains only runtime libraries and shuts down HTTP and the pool on SIGTERM. +Architecture-selected BuildKit stages checksum-verify the exact JDK/JRE archives; +both OS base images are digest pinned. Gradle Wrapper and dependency verification +are strict. The small standalone health probe does not start Spring or connect +to the database. No ORM, WebFlux, native image, virtual threads or additional +server processes are enabled. Java is CI-covered but not an official cohort +member; see [the Java guide](docs/JAVA-SPRING-BOOT.md). + ### PostgreSQL One PostgreSQL container is shared by all implementations. It runs as the `postgres` service on the project-scoped `benchmark` network without a published host port. API services connect on internal port `5432` using the common `DATABASE_HOST`, `DATABASE_PORT`, `DATABASE_NAME`, `DATABASE_USER`, and `DATABASE_PASSWORD` settings defined in the Compose extension field. @@ -115,7 +137,7 @@ required version fields and acceptance entry points. Python reads it directly; `benchmark/registry.py` generates and checks the Make and JavaScript projections. CI invokes registry-backed sequential acceptance targets and verifies Compose build-context agreement. Version extraction remains language-specific. -`registered_pinned_versions()` covers all eight registered stacks; `pinned_versions()` +`registered_pinned_versions()` covers all nine registered stacks; `pinned_versions()` filters that metadata to the active official cohort. Diagnostic metadata records only the selected Axum stack, without expanding official reports. @@ -189,7 +211,8 @@ simple-api-benchmark/ │ ├── node-fastify/ │ ├── node-express/ │ ├── python-fastapi/ -│ └── python-flask/ +│ ├── python-flask/ +│ └── java-spring-boot/ ├── benchmark/ │ ├── config.json │ ├── definition.py @@ -244,7 +267,7 @@ Cleanup must run even when a build, contract test, or benchmark fails. ### Pull requests -`ci.yml` builds and checks all eight registered applications, runs the shared contract and focused tests, validates workflow syntax/security and generated README sections, validates the static Pages contracts, and executes a short non-publishing smoke benchmark. Repository permissions are read-only and checkout credentials are not persisted. The Axum matrix entry additionally runs its non-publishing diagnostic and uploads diagnostic evidence; failure blocks the existing required aggregate. The active-cohort smoke covers `eight-stack-v1` sequentially. The component diagram groups applications by language; the runner still starts and measures only one implementation at a time. +`ci.yml` builds and checks all nine registered applications, runs the shared contract and focused tests, validates workflow syntax/security and generated README sections, validates the static Pages contracts, and executes a short non-publishing smoke benchmark. Repository permissions are read-only and checkout credentials are not persisted. The Axum matrix entry additionally runs its non-publishing diagnostic and uploads diagnostic evidence; failure blocks the existing required aggregate. The active-cohort smoke covers `eight-stack-v1` sequentially. The component diagram groups applications by language; the runner still starts and measures only one implementation at a time. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c62d99b..a4a774b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -136,6 +136,27 @@ The exact Python patch version is also recorded in `.python-version`. Tests use When updating the Python baseline, review official releases and wheel availability for CPython 3.14, change the `.in` files and exact Python version intentionally, resolve the full runtime and development graphs in a clean environment, and record published wheel SHA256 hashes in both lock files. Review transitive changes, verify the official Docker index digest, and update the matching acceptance expectations and documentation. Never install from an unlocked `.in` file for validation or production. Rerun all DB/API acceptance targets after shared Compose changes. +### Java / Spring Boot checks + +Install the exact Temurin JDK recorded in `apps/java-spring-boot/.java-version` +(currently `25.0.4.1+1`), plus Python 3.10+, Make and Docker Compose v2 on a POSIX +host. A global Gradle installation is not needed. Run the native checks without +Docker, or the complete production acceptance target: + +```bash +(cd apps/java-spring-boot && ./gradlew --no-daemon test installDist) +make test-java-spring-boot +make test-contract CONTRACT_IMPL=java-spring-boot +``` + +The committed Wrapper checksum, distribution checksum, strict dependency locks +and artifact verification metadata are mandatory. Validation must not regenerate +those files or download an alternative JDK silently. The production acceptance +checks live SQL/BIGINT responses, JSON errors, pool and container limits, finite +startup failure, SIGTERM shutdown and project-scoped cleanup. See the +[Java guide](docs/JAVA-SPRING-BOOT.md) for the dependency-update procedure. +Java registration does not expand the active official cohort or authorize a run. + ### Shared contract checks The common suite needs Python 3.10+ on a POSIX host, Docker Compose v2 with @@ -143,12 +164,12 @@ The common suite needs Python 3.10+ on a POSIX host, Docker Compose v2 with toolchains are not needed on the host for this target. ```bash -make test-contract # all eight registered implementations, sequentially +make test-contract # all nine registered implementations, sequentially make test-contract CONTRACT_IMPL=go-gin # one implementation, same assertions ``` `CONTRACT_IMPL` also accepts `go-echo`, `rust-actix`, `rust-axum`, `node-fastify`, -`node-express`, `python-fastapi`, and `python-flask`. +`node-express`, `python-fastapi`, `python-flask`, and `java-spring-boot`. The target first runs the suite's focused tests, then builds and starts the selected production services. All expected statuses and JSON objects are read from the paired HTTP/JSON examples in `docs/API-CONTRACT.md`; that document remains the @@ -238,7 +259,7 @@ prerequisites plus that implementation's pinned language toolchain, not every language used by the repository. CI derives the language setup from the registry and guards unrelated host executables during acceptance, contracts and the Axum diagnostic. Local `make test-` commands are unchanged; `make test` still needs -all toolchains and shared workflow/site dependencies. See the +all toolchains, including the exact Java JDK, and shared workflow/site dependencies. See the [host-toolchain contract](docs/AUTOMATION.md#implementation-host-toolchains). A pull request must pass the available checks for the area it changes. diff --git a/README.ja.md b/README.ja.md index 27cedae..f2f69dd 100644 --- a/README.ja.md +++ b/README.ja.md @@ -4,9 +4,9 @@ **Go・Rust・Node.js・Pythonを、同じAPI・同じ制限・同じ負荷で比較します。** -Simple API Benchmarkには、同じエンドポイント、同じDockerリソース制限、同じ検証ルールを使う8つのAPI実装があります。普遍的な最速言語を決めることではなく、誰でも理解できて、自分でも再実行できる小さな比較を目指します。 +Simple API Benchmarkには、同じエンドポイント、同じDockerリソース制限、同じ検証ルールを使う9つのAPI実装があります。普遍的な最速言語を決めることではなく、誰でも理解できて、自分でも再実行できる小さな比較を目指します。 -> **現在の状態:** v0.1.0は初期の4構成を収録したリリースです。現在の`main`は8実装すべてがCI対象で、有効な`eight-stack-v1`の検証済み結果を公開しています。Pagesではダッシュボードと過去の結果を閲覧できます。 +> **現在の状態:** v0.1.0は初期の4構成を収録したリリースです。現在の`main`は9実装がCI対象です。公式比較は引き続き`eight-stack-v1`の8実装で、Pagesに検証済み結果と履歴を公開しています。Java / Spring Bootは検証対象として登録済みですが、公式測定・公開結果には含まれません。 8構成の最初の完全な公式結果は、2026年9月15日に [Issue #50 / PR #58](https://github.com/tappe9/simple-api-benchmark/pull/58)で公開しました。 @@ -244,10 +244,28 @@ make test-python-fastapi PYTHON=python3.14 acceptance targetにはPOSIX環境のPython 3.14.7、Docker Compose v2、Makeが必要です。一時virtual environmentへhash検証付きで開発用依存をinstallし、Ruff、focused pytest tests、実Dockerサービス、DB更新・異常系、資源制限、1 worker、起動失敗、SIGTERM終了、container・network削除を確認します。Dockerを使わないfocused testsは[Contributing](CONTRIBUTING.md)を参照してください。 -8つのAPI実装と共通contract suiteを利用できます。 +## Java / Spring Boot実装 + +`apps/java-spring-boot/`はTemurin 25.0.4.1+1、Spring Boot 4.1.1、 +Spring MVC / Tomcat、JDBC / HikariCP、チェックサムを検証するGradle 9.7.1を使用します。 +共通のAPI、単一プロセス、1 CPU・512 MiB、DB接続上限10を維持します。 +**9番目の登録済み実装ですが、公式測定対象への追加ではありません。** +`eight-stack-v1`と公開済みの測定値は変更しません。 + +```bash +make test-java-spring-boot +make test-contract CONTRACT_IMPL=java-spring-boot +``` + +受入テストには、POSIXホスト上の固定JDK、Python 3.10以降、Make、Docker Compose v2が必要です。 +コンテナのみを使う共通契約テストには、ホストのJDKは不要です。 +固定依存関係、起動・終了の検証、公式測定と分ける理由は +[Java実装ガイド](docs/JAVA-SPRING-BOOT.md)を参照してください。 + +9実装すべてを、共通のAPI契約で検証できます。 ```bash -make test-contract # 8実装を1つずつ順番に検証 +make test-contract # 9実装を1つずつ順番に検証 make test-contract CONTRACT_IMPL=go-echo # 1実装を同じ契約で検証 ``` diff --git a/README.md b/README.md index 4c8d745..76a5ea4 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ **Go vs Rust vs Node.js vs Python — same API, same limits, simple results.** -Simple API Benchmark contains eight API implementations with the same endpoints, Docker resource limits, and validation rules. The goal is not to declare a universal winner. The goal is to make a small, repeatable comparison that anyone can understand. +Simple API Benchmark contains nine API implementations with the same endpoints, Docker resource limits, and validation rules. The goal is not to declare a universal winner. The goal is to make a small, repeatable comparison that anyone can understand. -> **Project status:** v0.1.0 is released as the original four-stack snapshot. Current `main` includes all eight CI-covered implementations, the active `eight-stack-v1` cohort with verified published results, and a Pages dashboard with history navigation. +> **Project status:** v0.1.0 is released as the original four-stack snapshot. Current `main` includes nine CI-covered implementations. The active `eight-stack-v1` cohort has verified published results and a Pages dashboard with history navigation. Java / Spring Boot is registered for validation but is not part of the official cohort or published results. The first complete eight-stack official result was published on September 15, 2026 under [Issue #50 / PR #58](https://github.com/tappe9/simple-api-benchmark/pull/58). @@ -244,10 +244,28 @@ make test-python-fastapi PYTHON=python3.14 The complete acceptance target requires Python 3.14.7 on a POSIX host, Docker Compose v2, and Make. It installs the hash-locked development dependencies in a temporary virtual environment, runs Ruff and focused pytest tests, and verifies the real Docker service, DB errors and updates, resources, one worker, startup failure, SIGTERM shutdown, and container/network cleanup. See [Contributing](CONTRIBUTING.md) for focused tests without Docker. -All eight API implementations and the shared contract suite are available: +## Java / Spring Boot implementation + +`apps/java-spring-boot/` uses Temurin 25.0.4.1+1, Spring Boot 4.1.1, +Spring MVC/Tomcat, JDBC/HikariCP, and checksum-verified Gradle 9.7.1. +It follows the same API, single-process, 1 CPU / 512 MiB and ten-connection +limits. It is the **ninth registered implementation**, not an additional official +result. `eight-stack-v1` and all published measurements remain unchanged. + +```bash +make test-java-spring-boot +make test-contract CONTRACT_IMPL=java-spring-boot +``` + +The acceptance target needs the exact JDK, Python 3.10+, Make and Docker Compose +v2 on a POSIX host. The container-only contract target does not need a host JDK. +See the [Java implementation guide](docs/JAVA-SPRING-BOOT.md) for build integrity, +startup/shutdown behavior, supported build paths and the unmeasured boundary. + +All nine API implementations and the shared contract suite are available: ```bash -make test-contract # all eight APIs, one at a time +make test-contract # all nine registered APIs, one at a time make test-contract CONTRACT_IMPL=go-echo # one API with the same contract ``` diff --git a/ROADMAP.md b/ROADMAP.md index 4ecfa66..0472b92 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -27,6 +27,7 @@ v0.1.0 release. A next release version remains an explicit maintainer decision. | Milestone | Status and evidence | | --- | --- | | Eight API implementations | Complete: Gin, Echo, Actix Web, Axum, Fastify, Express, FastAPI and Flask are registered and CI-covered. Axum retains its required non-publishing diagnostic. | +| Java / Spring Boot registration | Ninth implementation with dedicated host toolchain, checksum-verified build, real-container acceptance and shared contracts ([#67](https://github.com/tappe9/simple-api-benchmark/issues/67)). Outside the active official cohort; no Java result is published. | | Versioned eight-stack cohort | Active: `eight-stack-v1`; the historical `four-stack-v1` remains frozen. [#50](https://github.com/tappe9/simple-api-benchmark/issues/50), [#57](https://github.com/tappe9/simple-api-benchmark/pull/57), [#58](https://github.com/tappe9/simple-api-benchmark/pull/58). | | Verified eight-stack publication | Complete: the first full result was published on September 15, 2026, by [run 34925168324](https://github.com/tappe9/simple-api-benchmark/actions/runs/34925168324). See [#50](https://github.com/tappe9/simple-api-benchmark/issues/50) for measured-source, publication and Pages identities. Activation alone is not measurement evidence. | | Result presentation | Complete: README charts, the filterable light/dark Pages dashboard and historical-run navigation. History was delivered in [#28 / #46](https://github.com/tappe9/simple-api-benchmark/pull/46); old reports retain their own cohort and provenance. | @@ -51,9 +52,10 @@ v0.1.0 release. A next release version remains an explicit maintainer decision. ## Future candidates -Java / Spring Boot and C# / ASP.NET Core may be evaluated one at a time. These are -candidates, not commitments or implemented benchmark entries. Historical-result -navigation is already available, not a future candidate. +C# / ASP.NET Core may be evaluated as a separate implementation candidate, not a +commitment. Java / Spring Boot is already registered; its inclusion in a future +official cohort remains a separate decision that must assess the common warm-up +and measurement profile. Historical-result navigation is already available. ## Features intentionally deferred diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index 0b14391..f9b07b5 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -44,7 +44,9 @@ cohort. The `implementation ()` job names and `required` aggregate stay stab Every implementation runner explicitly installs the pinned Python harness. Only Go jobs additionally run `setup-go`, only Node.js jobs run `setup-node`, and only -Rust jobs install Rust with rustfmt/Clippy. Python implementation jobs need no +Rust jobs install Rust with rustfmt/Clippy. Only Java jobs install the exact +Temurin JDK using its fixed release URL and verified SHA-256; Gradle is supplied +by the committed checksum-verified Wrapper. Python implementation jobs need no additional language setup. The existing versions, action SHAs, acceptance/failure targets, shared contracts, cleanup and Axum diagnostic are unchanged. The shared job still explicitly installs Go for actionlint/Go-related tests and Node.js for @@ -52,7 +54,7 @@ site tests; its Python 3.10 compatibility gate is retained. CI invokes the host acceptance, contract and Axum diagnostic commands through `python -m benchmark.ci run-isolated --implementation -- `. This -prepends temporary failing executables for unrelated host Go/Node/Rust tools to +prepends temporary failing executables for unrelated host Go/Node/Rust/Java tools to the child PATH, so a preinstalled runner tool cannot silently satisfy an undeclared dependency. Actual tool use fails the gate even if its immediate caller ignores the exit status. The exact `rustc --version` probe is the one diff --git a/docs/IMPLEMENTATIONS.md b/docs/IMPLEMENTATIONS.md index f817a58..a61b408 100644 --- a/docs/IMPLEMENTATIONS.md +++ b/docs/IMPLEMENTATIONS.md @@ -36,9 +36,9 @@ plugin dependency is introduced. ## Registered and measured implementations -The eight registered implementations, in deterministic registry order, are: +The nine registered implementations, in deterministic registry order, are: Go / Gin, Go / Echo, Rust / Actix Web, Rust / Axum, Node.js / Fastify, -Node.js / Express, Python / FastAPI, and Python / Flask. Registration enables +Node.js / Express, Python / FastAPI, Python / Flask, and Java / Spring Boot. Registration enables source checks, real-container acceptance and unchanged shared contracts; it does not by itself activate a benchmark cohort or create measured results. @@ -47,12 +47,26 @@ not by itself activate a benchmark cohort or create measured results. | `four-stack-v1` | `go-gin`, `rust-actix`, `node-fastify`, `python-fastapi` | Frozen historical cohort; existing reports remain readable | | `eight-stack-v1` | `go-gin`, `go-echo`, `rust-actix`, `rust-axum`, `node-fastify`, `node-express`, `python-fastapi`, `python-flask` | Active official cohort; complete verified runs are eligible for publication | -`registered_pinned_versions()` in `benchmark/environment.py` extracts all eight +`registered_pinned_versions()` in `benchmark/environment.py` extracts all nine stacks, while `pinned_versions()` includes only the active cohort's metadata. Axum additionally records its explicit Tokio version. The [Axum-only diagnostic](AXUM.md) consumes registered Axum metadata separately and does not introduce partial official reports. +### Java registration boundary (#67) + +`java-spring-boot` is registered with a Java-only host toolchain, real-container +acceptance and the unchanged shared contract. Its static version extractor reads +the committed JDK build, Gradle Wrapper, strict lockfile and SHA-256 verification +metadata without executing Java or Gradle. Other implementation jobs do not need +a host JDK. See [Java / Spring Boot](JAVA-SPRING-BOOT.md). + +Neither cohort changes membership or order. Official metadata still contains +only the eight active implementations; Java is not filled into old reports with +zeroes or missing values. A future Java-inclusive cohort and any official run +need a separate decision, including an assessment of JVM warm-up under the common +profile. Registration and passing tests are not measured performance evidence. + ### Eight-stack rollout boundary (#50) The approved activation sets `active_cohort: eight-stack-v1`. Both readers accept diff --git a/docs/JAVA-SPRING-BOOT.md b/docs/JAVA-SPRING-BOOT.md new file mode 100644 index 0000000..cd755d3 --- /dev/null +++ b/docs/JAVA-SPRING-BOOT.md @@ -0,0 +1,127 @@ +# Java / Spring Boot + +## Registration is not official measurement + +`java-spring-boot` is the ninth registered implementation. CI builds it and checks +it against the same API contract as the other implementations. It is **not** a +member of `eight-stack-v1` or frozen `four-stack-v1`. Neither historical data nor +the current result receives synthetic Java values. There is no official Java +performance result from adding or testing this implementation. + +## Fixed build inputs + +| Component | Version | +| --- | --- | +| Temurin JDK and production JRE | `25.0.4.1+1` | +| Spring Boot | `4.1.1` | +| Tomcat | `11.0.24` | +| PostgreSQL JDBC driver | `42.7.13` | +| HikariCP | `7.0.2` | +| Jackson databind | `3.1.5` | +| Gradle Wrapper | `9.7.1` | + +`.java-version` fixes the complete runtime build, including the build number. +The Gradle build rejects a different executing JDK; automatic toolchain downloads +are disabled. The committed `gradle.lockfile` fixes runtime and test dependency +configurations under strict locking. `gradle/verification-metadata.xml` verifies +dependency artifacts and metadata with SHA-256. The Wrapper JAR and distribution +also have fixed checksums. Validation does not rewrite this evidence. + +Both Temurin OS bases are fixed by OCI index digest. Their bundled JDK predates +the selected hotfix, so the build replaces it with exact checksum-verified JDK +and JRE archives. Architecture-specific scratch stages use BuildKit's +`ADD --checksum`; only the selected `amd64` or `arm64` download stage is needed. +There is no floating package installation or requirement for curl in the image. +The final image copies the verified JRE and runtime library directory, not test +libraries, a build daemon, development tools or an application launcher shell. + +The Dockerfile provides x86-64 and ARM64 archive paths. The required GitHub CI +runs real-container acceptance on Linux x86-64. An ARM64 path and verified +archive checksum are not a claim of executed ARM64 acceptance. + +## Runtime behavior + +Spring MVC and embedded Tomcat run in one JVM process on port 8080 using +HTTP/1.1. JVM and servlet implementation threads are not additional server +processes; the common container limit remains 1 CPU / 512 MiB. Virtual threads, +WebFlux, ORM, native-image compilation and special result serializers are not +used. The JVM sets `MaxRAMPercentage=60.0` to leave headroom for non-heap memory. + +The application reads `DATABASE_HOST`, `DATABASE_PORT`, `DATABASE_NAME`, +`DATABASE_USER` and `DATABASE_PASSWORD`. Address and port validation prevents +connection-URL parameter injection. Credentials are supplied separately to the +pool and redacted from the settings object's string representation. Startup +executes `SELECT 1`; invalid settings, authentication failure and unreachable DB +must terminate within a finite deadline without exposing credentials or SQL. + +HikariCP has `maximumPoolSize=10` and `minimumIdle=0`, finite acquisition and +validation timeouts, and the PostgreSQL session application name +`simple-api-java`. Every `/db/{id}` request executes a prepared statement using +the parsed signed 64-bit ID. Connections, statements and result sets are closed +with try-with-resources. Missing rows return 404, malformed/out-of-range IDs 400, +and SQL failures only `{"error":"internal server error"}` with status 500. +Ordinary Java records are serialized by the framework, including exact numeric +BIGINT IDs beyond JavaScript's safe-integer range. Every `/cpu` request computes +Fibonacci(30) by direct recursion, without caching or precomputation. + +The image runs as `10001:10001`. Compose retains the common dropped capabilities, +no-new-privileges, private database network, loopback-only host port and disabled +restart policy. The health command is a separate small JVM HTTP probe with a +small heap and bounded response; it never starts Spring or queries the DB. +It is not a measurement process. The existing external-readiness measurement +policy is unchanged. SIGTERM stops HTTP gracefully and closes the pool. + +## Development and verification + +On a POSIX host, install the exact Temurin JDK above, Python 3.10+, Make and Docker +Compose v2 with BuildKit and `up --wait` support. No global Gradle is required. +Do not run competing API services on the shared local port 8080. + +```bash +# Native tests and production distribution, without Docker. +(cd apps/java-spring-boot && ./gradlew --no-daemon test installDist) + +# Strict host build plus real PostgreSQL/container acceptance. +make test-java-spring-boot + +# Shared HTTP contract through Docker; no host JDK is required. +make test-contract CONTRACT_IMPL=java-spring-boot + +# Static metadata and failure-check regression tests, without Java or Docker. +python -m unittest discover -s tests -p 'test_benchmark_java*.py' +python -m unittest discover -s tests -p 'test_java_spring_boot_acceptance.py' +``` + +Native compilation enables all javac lint warnings and treats them as errors. +The native suite covers normal JSON/health/CPU behavior, repeated DB reads, +sanitized failures, BIGINT boundaries, invalid input and settings/health checks. +Real-container acceptance additionally proves that SQL row changes affect the +response, checks the observed pool bound under concurrent requests, validates +resource/process/network identities, exercises DB failures, and verifies finite +failed startup and SIGTERM cleanup. Test helpers reject incomplete or misleading +failure evidence. Cleanup touches only the acceptance run's own Compose project. +These are correctness checks, not performance measurements. + +The CI matrix derives `java` from the registry language. Only its Java entry +installs the pinned JDK. Other language acceptance jobs guard Java executables +alongside their other unrelated host toolchains. Shared version extraction reads +committed files without invoking Java or Gradle. The existing fail-closed +`required` aggregate must include the new implementation; no gate is optional. + +## Updating dependencies or measuring Java + +Treat an update as a reviewed baseline change. Verify official JDK/JRE release +assets, their published checksums and the Docker index identities; update both +supported architecture paths and the host JDK pin together. For a deliberate +Gradle or Spring update, resolve locks and verification metadata in an isolated +trusted environment, review every changed dependency and artifact checksum, +then run normal validation without lock/verification-writing options. Never +accept a missing hash, disable strict verification, trust arbitrary artifacts, +or regenerate locks merely to turn a failing gate green. + +A Java-inclusive official cohort needs a separate decision and issue. Assess JVM +warm-up under the common measurement profile; do not silently extend only Java's +five-second warm-up or change existing eight-stack results. Any justified real +official run, automatic audited publication and Pages update still require +explicit authorization. Publication/protection redesign and Issue #59's live +producer-to-Pages verification remain separate work. diff --git a/docs/METHODOLOGY.md b/docs/METHODOLOGY.md index 05187b6..7f42d37 100644 --- a/docs/METHODOLOGY.md +++ b/docs/METHODOLOGY.md @@ -35,6 +35,16 @@ The Python / FastAPI baseline uses [Python 3.14.7](https://www.python.org/downlo Uvicorn uses one worker, standard asyncio, and h11 for HTTP/1.1, without optional uvloop or httptools acceleration. FastAPI serializes ordinary Python values using its normal response handling. The asyncpg pool is capped at 10, reads the shared database settings, and is checked before HTTP startup. Each async CPU route executes direct recursive Fibonacci(30) on the event loop, occupying the process until the calculation finishes; it does not offload work to a thread or process pool. The common 1 CPU / 512 MB limits remain unchanged. Focused validation is separate from performance measurement. +Current `main` uses the versioned `eight-stack-v1` cohort for official measurement; +its membership is listed in the [registry guide](IMPLEMENTATIONS.md). Java / +Spring Boot is the ninth registered implementation for acceptance and contract +checks, but is outside both existing official cohorts. Its single JVM uses the +same resource and pool limits, normal JSON serialization, JDBC and direct +recursive Fibonacci. There is no Java result or performance claim from these +correctness tests. Before any future cohort change, assess JVM warm-up under the +common profile rather than silently extending Java's five-second warm-up alone. +See the [Java implementation guide](JAVA-SPRING-BOOT.md). + ## When to remeasure Official measurements are explicitly requested after performance-relevant updates @@ -222,7 +232,7 @@ make test-rust-actix This target checks the committed source with rustfmt, locked Rust tests, and Clippy, then builds and verifies the pinned production container. Real DB updates and errors, exact numeric JSON, BIGINT boundaries, startup failure, SIGTERM exit, and DB connection/container/network cleanup are included. Run all available DB/API targets after shared Compose changes. These are implementation acceptance checks, not performance measurements. -All eight registered implementations can be checked against the same documented contract: +All nine registered implementations can be checked against the same documented contract: ```bash make test-contract diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 1918143..a9a0076 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -227,6 +227,7 @@ def test_ci_support_matrix_and_aggregate_are_fail_closed(self): "Rust": "rust", "Node.js": "node", "Python": "python", + "Java": "java", }[spec["language"]], } for spec in current["implementations"] From ad0d02bcf0323153f7228ad5c28319f27ada22eb Mon Sep 17 00:00:00 2001 From: Yuma Takino <36617106+tappe9@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:23:38 +0900 Subject: [PATCH 15/15] fix: normalize Gradle launcher line endings in Git PR CI 35194002760 passed Python 3.10 regressions, actionlint, all 21 workflow tests and Ruff, then correctly rejected committed CRLF bytes as trailing whitespace in gradlew.bat. Normalize the Git blob to LF and add launcher-scoped attributes so checkouts still use CRLF for Windows and LF for POSIX. Reproduce the exact whole-PR whitespace failure with default whitespace rules before the fix, then verify the entire candidate passes without relaxing CI or Git settings. Launcher content, dependency pins, application behavior and published results are unchanged. --- apps/java-spring-boot/.gitattributes | 2 + apps/java-spring-boot/gradlew.bat | 164 +++++++++++++-------------- 2 files changed, 84 insertions(+), 82 deletions(-) create mode 100644 apps/java-spring-boot/.gitattributes diff --git a/apps/java-spring-boot/.gitattributes b/apps/java-spring-boot/.gitattributes new file mode 100644 index 0000000..43a6f0c --- /dev/null +++ b/apps/java-spring-boot/.gitattributes @@ -0,0 +1,2 @@ +gradlew text eol=lf +gradlew.bat text eol=crlf diff --git a/apps/java-spring-boot/gradlew.bat b/apps/java-spring-boot/gradlew.bat index 8508ef6..a51ec4f 100644 --- a/apps/java-spring-boot/gradlew.bat +++ b/apps/java-spring-boot/gradlew.bat @@ -1,82 +1,82 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem gradlew startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables, and ensure extensions are enabled -setlocal EnableExtensions - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -"%COMSPEC%" /c exit 1 - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -"%COMSPEC%" /c exit 1 - -:execute -@rem Setup the command line - - - -@rem Execute gradlew -@rem endlocal doesn't take effect until after the line is parsed and variables are expanded -@rem which allows us to clear the local environment before executing the java command -endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel - -:exitWithErrorLevel -@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts -"%COMSPEC%" /c exit %ERRORLEVEL% +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL%