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/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/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/.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/.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/Dockerfile b/apps/java-spring-boot/Dockerfile new file mode 100644 index 0000000..2f6a02d --- /dev/null +++ b/apps/java-spring-boot/Dockerfile @@ -0,0 +1,38 @@ +# 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 + +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 ./ +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:d0d42862bc6cdcb9bc01096d48753b741316f44af2022666833d94f562b58481 +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/apps/java-spring-boot/build.gradle b/apps/java-spring-boot/build.gradle new file mode 100644 index 0000000..b4b9cc1 --- /dev/null +++ b/apps/java-spring-boot/build.gradle @@ -0,0 +1,51 @@ +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 +} + +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.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.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/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 0000000..eddabd2 Binary files /dev/null and b/apps/java-spring-boot/gradle/wrapper/gradle-wrapper.jar differ diff --git a/apps/java-spring-boot/gradle/wrapper/gradle-wrapper.properties b/apps/java-spring-boot/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..760c459 --- /dev/null +++ b/apps/java-spring-boot/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,10 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=acd53f1edaf02f1a8ff99879f8a34b302661a057d9b063ae9e35b552f804d20a +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/apps/java-spring-boot/gradlew b/apps/java-spring-boot/gradlew new file mode 100755 index 0000000..249efbb --- /dev/null +++ b/apps/java-spring-boot/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + 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..a51ec4f --- /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% 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/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 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/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 new file mode 100644 index 0000000..abfcdf2 --- /dev/null +++ b/benchmark/java_versions.py @@ -0,0 +1,105 @@ +"""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/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/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/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 new file mode 100644 index 0000000..ccd885e --- /dev/null +++ b/tests/test_benchmark_java_candidate.py @@ -0,0 +1,77 @@ +"""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() diff --git a/tests/test_benchmark_java_versions.py b/tests/test_benchmark_java_versions.py new file mode 100644 index 0000000..d0f5200 --- /dev/null +++ b/tests/test_benchmark_java_versions.py @@ -0,0 +1,94 @@ +"""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() 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 new file mode 100644 index 0000000..5253ad1 --- /dev/null +++ b/tests/test_java_spring_boot_acceptance.py @@ -0,0 +1,139 @@ +"""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") + + +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 new file mode 100644 index 0000000..48ed25f --- /dev/null +++ b/tests/test_java_spring_boot_service.py @@ -0,0 +1,354 @@ +"""Verify the locked host build and the real Java/PostgreSQL production container.""" + +import argparse +import concurrent.futures +import hashlib +import json +import re +import subprocess +import sys +import time +import uuid +from pathlib import Path + +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" +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 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(): + 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() + validate_distribution_pins(dockerfile) + images = re.findall(r"^FROM (\S+)", dockerfile, re.MULTILINE) + 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", + ) + 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() diff --git a/tests/test_workflows.py b/tests/test_workflows.py index ce52198..a9a0076 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( @@ -222,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"]