diff --git a/.bazelrc b/.bazelrc index 00c83f8665..efbe585ace 100644 --- a/.bazelrc +++ b/.bazelrc @@ -71,10 +71,11 @@ build --nostamp # Java: build and run against a hermetic remotejdk 21 regardless of the host # JDK, so Java service builds are reproducible on any developer machine and in # CI. Language version 21 is the current Spring Boot 3.x baseline. -build --java_language_version=21 -build --java_runtime_version=remotejdk_21 -build --tool_java_language_version=21 -build --tool_java_runtime_version=remotejdk_21 +build --java_language_version=25 +build --java_runtime_version=remotejdk_25 +build --tool_java_language_version=25 +build --tool_java_runtime_version=remotejdk_25 +build --java_header_compilation=false test --test_output=errors # Bazel's sandbox strips HOME, but a lot of Go code (cobra/viper config, diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 00121bea68..05cfb1aaf9 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -9,6 +9,7 @@ on: paths: - "**/*.go" - "**/*.rs" + - "**/*.java" - "**/go.mod" - "**/go.sum" - "**/Cargo.toml" @@ -19,6 +20,7 @@ on: paths: - "**/*.go" - "**/*.rs" + - "**/*.java" - "**/go.mod" - "**/go.sum" - "**/Cargo.toml" @@ -63,31 +65,33 @@ jobs: shell: bash run: | set -euo pipefail - go=false; rust=false + go=false; rust=false; java=false files="" if [ "${{ github.event_name }}" != "pull_request" ]; then # Pushes to main and the weekly schedule scan everything, Go included. - go=true; rust=true + go=true; rust=true; java=true else # Never silently skip: if the changed-file lookup fails, scan both # languages instead of treating an empty result as "no code changed". if ! files="$(gh api --paginate "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" --jq '.[].filename')"; then echo "::warning::changed-file lookup failed; scanning all languages" - go=true; rust=true; files="" + go=true; rust=true; java=true; files="" fi # gh api filenames are repo-relative with no leading slash, so anchor # manifests with (^|/) to match root and nested files alike. if printf '%s\n' "$files" | grep -qE '\.rs$|(^|/)Cargo\.(toml|lock)$'; then rust=true; fi + if printf '%s\n' "$files" | grep -qE '\.java$'; then java=true; fi # A change to the scan config itself re-scans the PR-time languages. - if printf '%s\n' "$files" | grep -qE '(^|/)\.github/workflows/codeql\.yml$'; then rust=true; fi + if printf '%s\n' "$files" | grep -qE '(^|/)\.github/workflows/codeql\.yml$'; then rust=true; java=true; fi fi inc="" if [ "$go" = true ]; then inc="${inc}{\"language\":\"go\",\"build-mode\":\"autobuild\"},"; fi if [ "$rust" = true ]; then inc="${inc}{\"language\":\"rust\",\"build-mode\":\"none\"},"; fi + if [ "$java" = true ]; then inc="${inc}{\"language\":\"java-kotlin\",\"build-mode\":\"none\"},"; fi inc="${inc%,}" echo "matrix={\"include\":[${inc}]}" >> "$GITHUB_OUTPUT" if [ -n "$inc" ]; then echo "any=true" >> "$GITHUB_OUTPUT"; else echo "any=false" >> "$GITHUB_OUTPUT"; fi - echo "selected languages: go=$go rust=$rust" + echo "selected languages: go=$go rust=$rust java=$java" analyze: name: CodeQL (${{ matrix.language }}) diff --git a/BUILD.bazel b/BUILD.bazel index 06648822f4..ce3c98a737 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -20,6 +20,18 @@ exports_files( visibility = ["//src/libraries/go/lib:__pkg__"], ) +# Root module identity + third-party lock + NOTICE index, consumed by the +# nv-boot-parent NOTICE generation/verification tooling now that its Maven +# closure is owned by the root module. +exports_files( + [ + "MODULE.bazel", + "NOTICE", + "maven_install.json", + ], + visibility = ["//src/libraries/java/nv-boot-parent:__subpackages__"], +) + # Phase 1 scope: only the native Go subtrees listed in go.work.bazel are # managed by Bazel. # Everything else is excluded so Gazelle does not descend into vendored diff --git a/MODULE.bazel b/MODULE.bazel index c6fd0bf343..31322b8bca 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -159,21 +159,81 @@ use_repo( # Internal services that depend on nv-boot add the internal Artifactory virtual # repo here; see rules/java/README.md. # ============================================================================ -bazel_dep(name = "rules_java", version = "8.14.0") +bazel_dep(name = "rules_java", version = "9.3.0") bazel_dep(name = "rules_jvm_external", version = "7.0") bazel_dep(name = "contrib_rules_jvm", version = "0.33.0") maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") maven.install( - name = "maven", + name = "nv_third_party_deps", artifacts = [ - "org.springframework.boot:spring-boot-starter-web:3.3.5", - "org.springframework.boot:spring-boot-starter-test:3.3.5", - "org.junit.jupiter:junit-jupiter-api:5.11.3", - "org.junit.jupiter:junit-jupiter-engine:5.11.3", - "org.junit.platform:junit-platform-launcher:1.11.3", - "org.junit.platform:junit-platform-reporting:1.11.3", + # nv-boot-parent third-party closure (Spring Boot 4 / Spring Cloud / + # OTel / Cassandra / Testcontainers). Root coordinates only: BOM-managed + # starters/modules, parent-owned explicit pins, and build/test tools. + "at.yawk.lz4:lz4-java:1.10.3", + "com.github.ben-manes.caffeine:guava", + "com.github.java-json-tools:json-patch:1.13", + "commons-codec:commons-codec", + "io.cloudevents:cloudevents-core:4.1.1", + "io.cloudevents:cloudevents-json-jackson:4.1.1", + "io.micrometer:micrometer-tracing-bridge-otel", + "io.nats:jnats:2.23.0", + "io.opentelemetry:opentelemetry-exporter-otlp", + "io.opentelemetry:opentelemetry-sdk-testing", + "jakarta.servlet:jakarta.servlet-api", + "org.apache.cassandra:java-driver-metrics-micrometer", + "org.apache.commons:commons-lang3:3.20.0", + "org.bouncycastle:bcprov-jdk18on:1.84", + "org.jacoco:org.jacoco.agent:jar:runtime:0.8.14", + "org.jacoco:org.jacoco.cli:0.8.14", + "org.junit.platform:junit-platform-console-standalone:6.0.3", + "org.ow2.asm:asm-analysis:9.9", + "org.ow2.asm:asm-util:9.9", + "org.projectlombok:lombok", + "org.springdoc:springdoc-openapi-starter-webflux-api:3.0.3", + "org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.3", + "org.springframework.boot:spring-boot-micrometer-tracing-opentelemetry", + "org.springframework.boot:spring-boot-restclient", + "org.springframework.boot:spring-boot-starter", + "org.springframework.boot:spring-boot-starter-actuator", + "org.springframework.boot:spring-boot-starter-actuator-test", + "org.springframework.boot:spring-boot-starter-data-cassandra", + "org.springframework.boot:spring-boot-starter-data-cassandra-test", + "org.springframework.boot:spring-boot-starter-jackson", + "org.springframework.boot:spring-boot-starter-security-oauth2-client", + "org.springframework.boot:spring-boot-starter-security-oauth2-resource-server", + "org.springframework.boot:spring-boot-starter-security-oauth2-resource-server-test", + "org.springframework.boot:spring-boot-starter-test", + "org.springframework.boot:spring-boot-starter-validation", + "org.springframework.boot:spring-boot-starter-webflux", + "org.springframework.boot:spring-boot-starter-webflux-test", + "org.springframework.boot:spring-boot-starter-webmvc-test", + "org.springframework.boot:spring-boot-webclient", + "org.springframework.cloud:spring-cloud-commons", + "org.springframework.cloud:spring-cloud-context", + "org.springframework.cloud:spring-cloud-starter-bootstrap", + "org.springframework.security:spring-security-test", + "org.testcontainers:testcontainers-cassandra", + "org.testcontainers:testcontainers-junit-jupiter", + "org.wiremock:wiremock-standalone:3.13.2", + "software.amazon.awssdk:regions:2.40.1", + "tools.jackson.module:jackson-module-blackbird", + # examples/java-spring-boot-service (versions from the Spring Boot BOM). + "org.springframework.boot:spring-boot-starter-web", + "org.junit.jupiter:junit-jupiter-api", + "org.junit.jupiter:junit-jupiter-engine", + "org.junit.platform:junit-platform-launcher", + "org.junit.platform:junit-platform-reporting", ], + boms = [ + "net.javacrumbs.shedlock:shedlock-bom:7.7.0", + "org.springframework.boot:spring-boot-dependencies:4.0.7", + "org.springframework.cloud:spring-cloud-dependencies:2025.1.2", + "org.testcontainers:testcontainers-bom:2.0.5", + ], + fail_on_missing_checksum = True, + fetch_sources = True, + known_contributing_modules = ["protobuf"], lock_file = "//:maven_install.json", repositories = [ # Public Central mirrors only. This is the public GitHub mirror; its @@ -185,5 +245,6 @@ maven.install( "https://maven-central.storage-download.googleapis.com/maven2", "https://repo.maven.apache.org/maven2", ], + resolver = "maven", ) -use_repo(maven, "maven") +use_repo(maven, "nv_third_party_deps") diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index fb2e21a310..d2ebf15e37 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -197,7 +197,6 @@ "https://bcr.bazel.build/modules/rules_java/7.4.0/MODULE.bazel": "a592852f8a3dd539e82ee6542013bf2cadfc4c6946be8941e189d224500a8934", "https://bcr.bazel.build/modules/rules_java/7.6.1/MODULE.bazel": "2f14b7e8a1aa2f67ae92bc69d1ec0fa8d9f827c4e17ff5e5f02e91caa3b2d0fe", "https://bcr.bazel.build/modules/rules_java/8.13.0/MODULE.bazel": "0444ebf737d144cf2bb2ccb368e7f1cce735264285f2a3711785827c1686625e", - "https://bcr.bazel.build/modules/rules_java/8.14.0/MODULE.bazel": "717717ed40cc69994596a45aec6ea78135ea434b8402fb91b009b9151dd65615", "https://bcr.bazel.build/modules/rules_java/8.15.2/MODULE.bazel": "5cc6698c822b2f9ef90ca5558599851bed8c3b13f1f8eb140d9bfec638d2acb4", "https://bcr.bazel.build/modules/rules_java/8.3.2/MODULE.bazel": "7336d5511ad5af0b8615fdc7477535a2e4e723a357b6713af439fe8cf0195017", "https://bcr.bazel.build/modules/rules_java/8.5.1/MODULE.bazel": "d8a9e38cc5228881f7055a6079f6f7821a073df3744d441978e7a43e20226939", diff --git a/NOTICE b/NOTICE index 5d5cc13e5b..d332b19a78 100644 --- a/NOTICE +++ b/NOTICE @@ -472,3 +472,4 @@ The following third-party licenses are included in this repository: src/libraries/go/lib/vendor/sigs.k8s.io/randfill/NOTICE src/libraries/go/lib/vendor/sigs.k8s.io/structured-merge-diff/v6/LICENSE src/libraries/go/lib/vendor/sigs.k8s.io/yaml/LICENSE + src/libraries/java/nv-boot-parent/NOTICE diff --git a/examples/java-spring-boot-service/BUILD.bazel b/examples/java-spring-boot-service/BUILD.bazel index 8a7325e797..e82da8bac0 100644 --- a/examples/java-spring-boot-service/BUILD.bazel +++ b/examples/java-spring-boot-service/BUILD.bazel @@ -31,10 +31,10 @@ nvcf_java_library( # the concrete artifacts below are the ones the code imports directly, so # the strict-deps header compiler sees them on the direct classpath. deps = [ - "@maven//:org_springframework_boot_spring_boot", - "@maven//:org_springframework_boot_spring_boot_autoconfigure", - "@maven//:org_springframework_boot_spring_boot_starter_web", - "@maven//:org_springframework_spring_web", + "@nv_third_party_deps//:org_springframework_boot_spring_boot", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_autoconfigure", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_web", + "@nv_third_party_deps//:org_springframework_spring_web", ], ) @@ -54,13 +54,13 @@ java_junit5_test( srcs = ["src/test/java/com/nvidia/nvcf/example/HelloControllerTest.java"], test_class = "com.nvidia.nvcf.example.HelloControllerTest", runtime_deps = [ - "@maven//:org_junit_jupiter_junit_jupiter_engine", - "@maven//:org_junit_platform_junit_platform_launcher", - "@maven//:org_junit_platform_junit_platform_reporting", + "@nv_third_party_deps//:org_junit_jupiter_junit_jupiter_engine", + "@nv_third_party_deps//:org_junit_platform_junit_platform_launcher", + "@nv_third_party_deps//:org_junit_platform_junit_platform_reporting", ], deps = [ ":example_lib", - "@maven//:org_junit_jupiter_junit_jupiter_api", + "@nv_third_party_deps//:org_junit_jupiter_junit_jupiter_api", ], ) @@ -73,16 +73,17 @@ java_junit5_test( srcs = ["src/test/java/com/nvidia/nvcf/example/HelloControllerWebTest.java"], test_class = "com.nvidia.nvcf.example.HelloControllerWebTest", runtime_deps = [ - "@maven//:org_junit_jupiter_junit_jupiter_engine", - "@maven//:org_junit_platform_junit_platform_launcher", - "@maven//:org_junit_platform_junit_platform_reporting", + "@nv_third_party_deps//:org_junit_jupiter_junit_jupiter_engine", + "@nv_third_party_deps//:org_junit_platform_junit_platform_launcher", + "@nv_third_party_deps//:org_junit_platform_junit_platform_reporting", ], deps = [ ":example_lib", - "@maven//:org_junit_jupiter_junit_jupiter_api", - "@maven//:org_springframework_boot_spring_boot_test", - "@maven//:org_springframework_boot_spring_boot_test_autoconfigure", - "@maven//:org_springframework_spring_beans", - "@maven//:org_springframework_spring_test", + "@nv_third_party_deps//:org_junit_jupiter_junit_jupiter_api", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_test", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_test_autoconfigure", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_webmvc_test", + "@nv_third_party_deps//:org_springframework_spring_beans", + "@nv_third_party_deps//:org_springframework_spring_test", ], ) diff --git a/examples/java-spring-boot-service/src/test/java/com/nvidia/nvcf/example/HelloControllerWebTest.java b/examples/java-spring-boot-service/src/test/java/com/nvidia/nvcf/example/HelloControllerWebTest.java index 9a5365a2ca..fbad345780 100644 --- a/examples/java-spring-boot-service/src/test/java/com/nvidia/nvcf/example/HelloControllerWebTest.java +++ b/examples/java-spring-boot-service/src/test/java/com/nvidia/nvcf/example/HelloControllerWebTest.java @@ -21,7 +21,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.web.servlet.MockMvc; diff --git a/maven_install.json b/maven_install.json index dff2ba41dd..01d08969de 100755 --- a/maven_install.json +++ b/maven_install.json @@ -1,175 +1,867 @@ { "__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL", "__INPUT_ARTIFACTS_HASH": { - "com.google.code.findbugs:jsr305": 495355163, - "com.google.code.gson:gson": 597770368, - "com.google.errorprone:error_prone_annotations": -1035138750, - "com.google.guava:guava": 1943808618, - "com.google.j2objc:j2objc-annotations": 2003271689, - "org.junit.jupiter:junit-jupiter-api": -105585452, - "org.junit.jupiter:junit-jupiter-engine": 766413368, - "org.junit.platform:junit-platform-launcher": 375891854, - "org.junit.platform:junit-platform-reporting": 1058066492, - "org.springframework.boot:spring-boot-starter-test": -233051667, - "org.springframework.boot:spring-boot-starter-web": -1903240525, - "repositories": -1624298853 + "at.yawk.lz4:lz4-java": 1725015399, + "com.github.ben-manes.caffeine:guava": 1054685015, + "com.github.java-json-tools:json-patch": -1031005245, + "commons-codec:commons-codec": -1269462382, + "io.cloudevents:cloudevents-core": -2103567774, + "io.cloudevents:cloudevents-json-jackson": -1197487309, + "io.micrometer:micrometer-tracing-bridge-otel": -754588172, + "io.nats:jnats": 572014237, + "io.opentelemetry:opentelemetry-exporter-otlp": -12806658, + "io.opentelemetry:opentelemetry-sdk-testing": -32635354, + "jakarta.servlet:jakarta.servlet-api": 2120044853, + "net.javacrumbs.shedlock:shedlock-bom": -1406345450, + "org.apache.cassandra:java-driver-metrics-micrometer": -465267397, + "org.apache.commons:commons-lang3": -278168457, + "org.bouncycastle:bcprov-jdk18on": -1405390253, + "org.jacoco:org.jacoco.agent": -2069397525, + "org.jacoco:org.jacoco.cli": -1856875155, + "org.junit.jupiter:junit-jupiter-api": 194920406, + "org.junit.jupiter:junit-jupiter-engine": -1886863302, + "org.junit.platform:junit-platform-console-standalone": -1481831078, + "org.junit.platform:junit-platform-launcher": -354104948, + "org.junit.platform:junit-platform-reporting": 1896713402, + "org.ow2.asm:asm-analysis": -1027574299, + "org.ow2.asm:asm-util": -307204853, + "org.projectlombok:lombok": -2073039513, + "org.springdoc:springdoc-openapi-starter-webflux-api": 1102747448, + "org.springdoc:springdoc-openapi-starter-webmvc-api": 655186513, + "org.springframework.boot:spring-boot-dependencies": 70164638, + "org.springframework.boot:spring-boot-micrometer-tracing-opentelemetry": 238152322, + "org.springframework.boot:spring-boot-restclient": 525086533, + "org.springframework.boot:spring-boot-starter": 1358725161, + "org.springframework.boot:spring-boot-starter-actuator": -1082017891, + "org.springframework.boot:spring-boot-starter-actuator-test": 536870722, + "org.springframework.boot:spring-boot-starter-data-cassandra": 699136521, + "org.springframework.boot:spring-boot-starter-data-cassandra-test": -667262890, + "org.springframework.boot:spring-boot-starter-jackson": -1899095121, + "org.springframework.boot:spring-boot-starter-security-oauth2-client": -1248590284, + "org.springframework.boot:spring-boot-starter-security-oauth2-resource-server": 185843737, + "org.springframework.boot:spring-boot-starter-security-oauth2-resource-server-test": -1173625786, + "org.springframework.boot:spring-boot-starter-test": -1561809354, + "org.springframework.boot:spring-boot-starter-validation": 113118493, + "org.springframework.boot:spring-boot-starter-web": -1905796688, + "org.springframework.boot:spring-boot-starter-webflux": 1788530073, + "org.springframework.boot:spring-boot-starter-webflux-test": 5326534, + "org.springframework.boot:spring-boot-starter-webmvc-test": -2111356331, + "org.springframework.boot:spring-boot-webclient": -1835278023, + "org.springframework.cloud:spring-cloud-commons": -673836000, + "org.springframework.cloud:spring-cloud-context": -1724959431, + "org.springframework.cloud:spring-cloud-dependencies": 46341733, + "org.springframework.cloud:spring-cloud-starter-bootstrap": -213001704, + "org.springframework.security:spring-security-test": 1317137564, + "org.testcontainers:testcontainers-bom": 483223872, + "org.testcontainers:testcontainers-cassandra": -1027104267, + "org.testcontainers:testcontainers-junit-jupiter": -918230293, + "org.wiremock:wiremock-standalone": -1242936487, + "repositories": -1624298853, + "software.amazon.awssdk:regions": -1274787064, + "tools.jackson.module:jackson-module-blackbird": -423541381 }, "__RESOLVED_ARTIFACTS_HASH": { - "ch.qos.logback:logback-classic": -1018418103, - "ch.qos.logback:logback-core": -1421174237, - "com.fasterxml.jackson.core:jackson-annotations": -2000234115, - "com.fasterxml.jackson.core:jackson-core": -1275400772, - "com.fasterxml.jackson.core:jackson-databind": -316385142, - "com.fasterxml.jackson.datatype:jackson-datatype-jdk8": 744011743, - "com.fasterxml.jackson.datatype:jackson-datatype-jsr310": -1638034836, - "com.fasterxml.jackson.module:jackson-module-parameter-names": -1293037747, - "com.google.code.findbugs:jsr305": -1038659426, - "com.google.code.gson:gson": 1618556651, - "com.google.errorprone:error_prone_annotations": 2046467894, - "com.google.guava:failureaccess": -268223546, - "com.google.guava:guava": 1001756507, + "aopalliance:aopalliance": -1763688673, + "aopalliance:aopalliance:jar:sources": 758113234, + "args4j:args4j": -572028113, + "args4j:args4j:jar:sources": 74047526, + "at.yawk.lz4:lz4-java": -1985362494, + "at.yawk.lz4:lz4-java:jar:sources": -2141970549, + "ch.qos.logback:logback-classic": -619930806, + "ch.qos.logback:logback-classic:jar:sources": -390128445, + "ch.qos.logback:logback-core": -1554021729, + "ch.qos.logback:logback-core:jar:sources": 77538609, + "com.datastax.cassandra:cassandra-driver-core": -681774303, + "com.datastax.cassandra:cassandra-driver-core:jar:sources": -2045236197, + "com.datastax.oss:native-protocol": 447263174, + "com.datastax.oss:native-protocol:jar:sources": 396473389, + "com.fasterxml.jackson.core:jackson-annotations": 1407322119, + "com.fasterxml.jackson.core:jackson-annotations:jar:sources": -1408285999, + "com.fasterxml.jackson.core:jackson-core": -1715692416, + "com.fasterxml.jackson.core:jackson-core:jar:sources": 1404881423, + "com.fasterxml.jackson.core:jackson-databind": -1922910990, + "com.fasterxml.jackson.core:jackson-databind:jar:sources": -802304801, + "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml": 2129087476, + "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:jar:sources": -175522790, + "com.fasterxml.jackson.datatype:jackson-datatype-jsr310": 1940996847, + "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:jar:sources": 1241536710, + "com.fasterxml:classmate": -1468141616, + "com.fasterxml:classmate:jar:sources": -179492269, + "com.github.ben-manes.caffeine:caffeine": 96455941, + "com.github.ben-manes.caffeine:caffeine:jar:sources": -967473790, + "com.github.ben-manes.caffeine:guava": 1187631417, + "com.github.ben-manes.caffeine:guava:jar:sources": 1956210603, + "com.github.docker-java:docker-java-api": 1857041788, + "com.github.docker-java:docker-java-api:jar:sources": 300128277, + "com.github.docker-java:docker-java-transport": 689281477, + "com.github.docker-java:docker-java-transport-zerodep": -1848983763, + "com.github.docker-java:docker-java-transport-zerodep:jar:sources": 1882881505, + "com.github.docker-java:docker-java-transport:jar:sources": 864522470, + "com.github.java-json-tools:btf": -1747700664, + "com.github.java-json-tools:btf:jar:sources": 1539011915, + "com.github.java-json-tools:jackson-coreutils": 2047023685, + "com.github.java-json-tools:jackson-coreutils:jar:sources": 323093438, + "com.github.java-json-tools:json-patch": -399792119, + "com.github.java-json-tools:json-patch:jar:sources": -1320375757, + "com.github.java-json-tools:msg-simple": 986302814, + "com.github.java-json-tools:msg-simple:jar:sources": 1013495835, + "com.github.jnr:jffi": 1952487985, + "com.github.jnr:jffi:jar:native": 1426144838, + "com.github.jnr:jffi:jar:sources": -252446498, + "com.github.jnr:jnr-constants": 1522135714, + "com.github.jnr:jnr-constants:jar:sources": -1920781516, + "com.github.jnr:jnr-ffi": 1663045836, + "com.github.jnr:jnr-ffi:jar:sources": -150631029, + "com.github.jnr:jnr-posix": 1594911544, + "com.github.jnr:jnr-posix:jar:sources": -992203730, + "com.github.jnr:jnr-x86asm": 1097235222, + "com.github.jnr:jnr-x86asm:jar:sources": -1784079536, + "com.github.stephenc.jcip:jcip-annotations": -121928928, + "com.github.stephenc.jcip:jcip-annotations:jar:sources": -2051941468, + "com.google.code.findbugs:jsr305": 1838181273, + "com.google.errorprone:error_prone_annotations": -1266099896, + "com.google.errorprone:error_prone_annotations:jar:sources": 936701586, + "com.google.guava:failureaccess": -56291903, + "com.google.guava:failureaccess:jar:sources": -820168004, + "com.google.guava:guava": -1132194940, + "com.google.guava:guava:jar:sources": -1505508770, "com.google.guava:listenablefuture": 1588902908, - "com.google.j2objc:j2objc-annotations": -596695707, - "com.jayway.jsonpath:json-path": 706600598, + "com.google.j2objc:j2objc-annotations": -2074422376, + "com.google.j2objc:j2objc-annotations:jar:sources": -597974451, + "com.jayway.jsonpath:json-path": 626712679, + "com.jayway.jsonpath:json-path:jar:sources": -343427302, + "com.nimbusds:content-type": -444977683, + "com.nimbusds:content-type:jar:sources": -634646164, + "com.nimbusds:lang-tag": -694347345, + "com.nimbusds:lang-tag:jar:sources": 266930896, + "com.nimbusds:nimbus-jose-jwt": -286981492, + "com.nimbusds:nimbus-jose-jwt:jar:sources": 24935039, + "com.nimbusds:oauth2-oidc-sdk": -498816278, + "com.nimbusds:oauth2-oidc-sdk:jar:sources": 1276926642, + "com.squareup.okhttp3:okhttp-jvm": -314031254, + "com.squareup.okhttp3:okhttp-jvm:jar:sources": 447067750, + "com.squareup.okio:okio-jvm": -391120506, + "com.squareup.okio:okio-jvm:jar:sources": 1375453359, + "com.typesafe:config": 96906638, + "com.typesafe:config:jar:sources": -892423283, "com.vaadin.external.google:android-json": -1531950165, - "io.micrometer:micrometer-commons": -1940313310, - "io.micrometer:micrometer-observation": 828644728, - "jakarta.activation:jakarta.activation-api": 465828473, - "jakarta.annotation:jakarta.annotation-api": -1453679667, - "jakarta.xml.bind:jakarta.xml.bind-api": 2078159777, - "net.bytebuddy:byte-buddy": 1586811304, - "net.bytebuddy:byte-buddy-agent": 1999301185, - "net.minidev:accessors-smart": 1890269763, - "net.minidev:json-smart": -874751734, - "org.apache.logging.log4j:log4j-api": 1626891041, - "org.apache.logging.log4j:log4j-to-slf4j": 1775056992, - "org.apache.tomcat.embed:tomcat-embed-core": 1558450636, - "org.apache.tomcat.embed:tomcat-embed-el": -1832850731, - "org.apache.tomcat.embed:tomcat-embed-websocket": 1114335911, + "com.vaadin.external.google:android-json:jar:sources": -116078240, + "commons-codec:commons-codec": -835030550, + "commons-codec:commons-codec:jar:sources": 990790236, + "commons-io:commons-io": -1021273518, + "commons-io:commons-io:jar:sources": 2066085027, + "commons-logging:commons-logging": 1061992981, + "commons-logging:commons-logging:jar:sources": 1867783947, + "io.cloudevents:cloudevents-api": -617548735, + "io.cloudevents:cloudevents-api:jar:sources": 924762007, + "io.cloudevents:cloudevents-core": -688560325, + "io.cloudevents:cloudevents-core:jar:sources": 223693387, + "io.cloudevents:cloudevents-json-jackson": -807987873, + "io.cloudevents:cloudevents-json-jackson:jar:sources": -166751855, + "io.dropwizard.metrics:metrics-core": 1029463962, + "io.dropwizard.metrics:metrics-core:jar:sources": 1607397396, + "io.micrometer:context-propagation": -1130727419, + "io.micrometer:context-propagation:jar:sources": -1135393170, + "io.micrometer:micrometer-commons": 326693391, + "io.micrometer:micrometer-commons:jar:sources": -57818626, + "io.micrometer:micrometer-core": 829567043, + "io.micrometer:micrometer-core:jar:sources": 265175165, + "io.micrometer:micrometer-jakarta9": -15933884, + "io.micrometer:micrometer-jakarta9:jar:sources": 1275065742, + "io.micrometer:micrometer-observation": -319705914, + "io.micrometer:micrometer-observation-test": -1818068529, + "io.micrometer:micrometer-observation-test:jar:sources": 117373145, + "io.micrometer:micrometer-observation:jar:sources": 1279306785, + "io.micrometer:micrometer-tracing": -109714346, + "io.micrometer:micrometer-tracing-bridge-otel": 975863312, + "io.micrometer:micrometer-tracing-bridge-otel:jar:sources": 1300289348, + "io.micrometer:micrometer-tracing:jar:sources": 266031561, + "io.nats:jnats": 1273546329, + "io.nats:jnats:jar:sources": 194791155, + "io.netty:netty-buffer": -1740802869, + "io.netty:netty-buffer:jar:sources": 622468392, + "io.netty:netty-codec-base": -1042304993, + "io.netty:netty-codec-base:jar:sources": -612797714, + "io.netty:netty-codec-classes-quic": -702276906, + "io.netty:netty-codec-classes-quic:jar:sources": 1548159851, + "io.netty:netty-codec-compression": -789209831, + "io.netty:netty-codec-compression:jar:sources": -1402960067, + "io.netty:netty-codec-dns": 81169397, + "io.netty:netty-codec-dns:jar:sources": -1086447458, + "io.netty:netty-codec-http": -95764323, + "io.netty:netty-codec-http2": 544878123, + "io.netty:netty-codec-http2:jar:sources": -83820759, + "io.netty:netty-codec-http3": -194972071, + "io.netty:netty-codec-http3:jar:sources": 1699772273, + "io.netty:netty-codec-http:jar:sources": -116842177, + "io.netty:netty-codec-native-quic:jar:linux-aarch_64": -2075260519, + "io.netty:netty-codec-native-quic:jar:linux-x86_64": -1982796133, + "io.netty:netty-codec-native-quic:jar:osx-aarch_64": -632727283, + "io.netty:netty-codec-native-quic:jar:osx-x86_64": 1253945631, + "io.netty:netty-codec-native-quic:jar:sources": -986328940, + "io.netty:netty-codec-native-quic:jar:windows-x86_64": -115110822, + "io.netty:netty-codec-socks": 336734625, + "io.netty:netty-codec-socks:jar:sources": -1241946572, + "io.netty:netty-common": -1557278455, + "io.netty:netty-common:jar:sources": -467395659, + "io.netty:netty-handler": -1757690436, + "io.netty:netty-handler-proxy": -892232184, + "io.netty:netty-handler-proxy:jar:sources": -216040607, + "io.netty:netty-handler:jar:sources": -1379156289, + "io.netty:netty-resolver": -1571627366, + "io.netty:netty-resolver-dns": 954988644, + "io.netty:netty-resolver-dns-classes-macos": -441663783, + "io.netty:netty-resolver-dns-classes-macos:jar:sources": 776580055, + "io.netty:netty-resolver-dns-native-macos:jar:osx-x86_64": -354533951, + "io.netty:netty-resolver-dns-native-macos:jar:sources": -120739665, + "io.netty:netty-resolver-dns:jar:sources": 1652413066, + "io.netty:netty-resolver:jar:sources": 2084403340, + "io.netty:netty-transport": 44555455, + "io.netty:netty-transport-classes-epoll": -588470325, + "io.netty:netty-transport-classes-epoll:jar:sources": -1697662320, + "io.netty:netty-transport-native-epoll:jar:linux-x86_64": -563523863, + "io.netty:netty-transport-native-epoll:jar:sources": -1040259378, + "io.netty:netty-transport-native-unix-common": -166041147, + "io.netty:netty-transport-native-unix-common:jar:sources": -1674017507, + "io.netty:netty-transport:jar:sources": -820889350, + "io.opentelemetry.semconv:opentelemetry-semconv": 1195624836, + "io.opentelemetry.semconv:opentelemetry-semconv:jar:sources": -893534237, + "io.opentelemetry:opentelemetry-api": 885391408, + "io.opentelemetry:opentelemetry-api:jar:sources": -1404426315, + "io.opentelemetry:opentelemetry-common": 676580228, + "io.opentelemetry:opentelemetry-common:jar:sources": 1797051654, + "io.opentelemetry:opentelemetry-context": 747746024, + "io.opentelemetry:opentelemetry-context:jar:sources": 560193252, + "io.opentelemetry:opentelemetry-exporter-common": 42591217, + "io.opentelemetry:opentelemetry-exporter-common:jar:sources": 2032641450, + "io.opentelemetry:opentelemetry-exporter-otlp": 1346623356, + "io.opentelemetry:opentelemetry-exporter-otlp-common": -2043450521, + "io.opentelemetry:opentelemetry-exporter-otlp-common:jar:sources": -1909225648, + "io.opentelemetry:opentelemetry-exporter-otlp:jar:sources": -1536215787, + "io.opentelemetry:opentelemetry-exporter-sender-okhttp": -2026614746, + "io.opentelemetry:opentelemetry-exporter-sender-okhttp:jar:sources": -1387239258, + "io.opentelemetry:opentelemetry-extension-trace-propagators": -480191373, + "io.opentelemetry:opentelemetry-extension-trace-propagators:jar:sources": 517135536, + "io.opentelemetry:opentelemetry-sdk": -369820209, + "io.opentelemetry:opentelemetry-sdk-common": -1049102876, + "io.opentelemetry:opentelemetry-sdk-common:jar:sources": -1004050981, + "io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi": -928104061, + "io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:jar:sources": -1626691242, + "io.opentelemetry:opentelemetry-sdk-logs": -1310291573, + "io.opentelemetry:opentelemetry-sdk-logs:jar:sources": 1572252989, + "io.opentelemetry:opentelemetry-sdk-metrics": -2038069517, + "io.opentelemetry:opentelemetry-sdk-metrics:jar:sources": -323022280, + "io.opentelemetry:opentelemetry-sdk-testing": 19519171, + "io.opentelemetry:opentelemetry-sdk-testing:jar:sources": -578292790, + "io.opentelemetry:opentelemetry-sdk-trace": 2083919234, + "io.opentelemetry:opentelemetry-sdk-trace:jar:sources": 818630467, + "io.opentelemetry:opentelemetry-sdk:jar:sources": -1478160490, + "io.projectreactor.netty:reactor-netty-core": -1310988391, + "io.projectreactor.netty:reactor-netty-core:jar:sources": 160926198, + "io.projectreactor.netty:reactor-netty-http": -1289714469, + "io.projectreactor.netty:reactor-netty-http:jar:sources": 1066415166, + "io.projectreactor:reactor-core": -745004757, + "io.projectreactor:reactor-core:jar:sources": -717353230, + "io.projectreactor:reactor-test": 947112823, + "io.projectreactor:reactor-test:jar:sources": 383025302, + "io.swagger.core.v3:swagger-annotations-jakarta": -842428034, + "io.swagger.core.v3:swagger-annotations-jakarta:jar:sources": 133934665, + "io.swagger.core.v3:swagger-core-jakarta": -439612282, + "io.swagger.core.v3:swagger-core-jakarta:jar:sources": 449241668, + "io.swagger.core.v3:swagger-models-jakarta": -1439553498, + "io.swagger.core.v3:swagger-models-jakarta:jar:sources": 2092063988, + "jakarta.activation:jakarta.activation-api": -1560267684, + "jakarta.activation:jakarta.activation-api:jar:sources": 759160467, + "jakarta.annotation:jakarta.annotation-api": -1904975463, + "jakarta.annotation:jakarta.annotation-api:jar:sources": -247204066, + "jakarta.servlet:jakarta.servlet-api": 972614879, + "jakarta.servlet:jakarta.servlet-api:jar:sources": 2070183472, + "jakarta.validation:jakarta.validation-api": 666686261, + "jakarta.validation:jakarta.validation-api:jar:sources": -131796339, + "jakarta.xml.bind:jakarta.xml.bind-api": 1157993223, + "jakarta.xml.bind:jakarta.xml.bind-api:jar:sources": -945798747, + "net.bytebuddy:byte-buddy": 383637760, + "net.bytebuddy:byte-buddy-agent": -1380713096, + "net.bytebuddy:byte-buddy-agent:jar:sources": 564051985, + "net.bytebuddy:byte-buddy:jar:sources": -1360611642, + "net.java.dev.jna:jna": -1951542637, + "net.java.dev.jna:jna:jar:sources": -545183654, + "net.minidev:accessors-smart": -325667575, + "net.minidev:accessors-smart:jar:sources": -124254155, + "net.minidev:json-smart": 1673421716, + "net.minidev:json-smart:jar:sources": -1084786431, + "org.apache.cassandra:java-driver-core": 42196324, + "org.apache.cassandra:java-driver-core:jar:sources": -1508814165, + "org.apache.cassandra:java-driver-guava-shaded": 568990261, + "org.apache.cassandra:java-driver-guava-shaded:jar:sources": 1291502230, + "org.apache.cassandra:java-driver-metrics-micrometer": -93817708, + "org.apache.cassandra:java-driver-metrics-micrometer:jar:sources": -2019023443, + "org.apache.cassandra:java-driver-query-builder": 303143232, + "org.apache.cassandra:java-driver-query-builder:jar:sources": -1665027563, + "org.apache.commons:commons-compress": -134181577, + "org.apache.commons:commons-compress:jar:sources": -1845261624, + "org.apache.commons:commons-lang3": 759645435, + "org.apache.commons:commons-lang3:jar:sources": 1890991939, + "org.apache.logging.log4j:log4j-api": 191755139, + "org.apache.logging.log4j:log4j-api:jar:sources": 1226633672, + "org.apache.logging.log4j:log4j-to-slf4j": 357996538, + "org.apache.logging.log4j:log4j-to-slf4j:jar:sources": -1847454236, + "org.apache.tomcat.embed:tomcat-embed-core": -2079786590, + "org.apache.tomcat.embed:tomcat-embed-core:jar:sources": -745705587, + "org.apache.tomcat.embed:tomcat-embed-el": -727131551, + "org.apache.tomcat.embed:tomcat-embed-el:jar:sources": 1938415424, + "org.apache.tomcat.embed:tomcat-embed-websocket": 1876889190, + "org.apache.tomcat.embed:tomcat-embed-websocket:jar:sources": -1809736441, "org.apiguardian:apiguardian-api": -1579303244, - "org.assertj:assertj-core": -523804586, - "org.awaitility:awaitility": 1242763459, - "org.checkerframework:checker-qual": -2018582244, - "org.hamcrest:hamcrest": 702898642, - "org.junit.jupiter:junit-jupiter": 2045573480, - "org.junit.jupiter:junit-jupiter-api": -533393235, - "org.junit.jupiter:junit-jupiter-engine": 836453505, - "org.junit.jupiter:junit-jupiter-params": 161696702, - "org.junit.platform:junit-platform-commons": -1288602100, - "org.junit.platform:junit-platform-engine": 239613782, - "org.junit.platform:junit-platform-launcher": -1502133002, - "org.junit.platform:junit-platform-reporting": 153470184, - "org.mockito:mockito-core": 225402327, - "org.mockito:mockito-junit-jupiter": -270503493, + "org.apiguardian:apiguardian-api:jar:sources": 768152577, + "org.assertj:assertj-core": -536770136, + "org.assertj:assertj-core:jar:sources": -1826278818, + "org.awaitility:awaitility": 755971515, + "org.awaitility:awaitility:jar:sources": -1322650242, + "org.bouncycastle:bcprov-jdk18on": -709136978, + "org.bouncycastle:bcprov-jdk18on:jar:sources": 106463766, + "org.bouncycastle:bcprov-lts8on": 973431866, + "org.bouncycastle:bcprov-lts8on:jar:sources": -196135755, + "org.hamcrest:hamcrest": 1116842741, + "org.hamcrest:hamcrest:jar:sources": -996443755, + "org.hdrhistogram:HdrHistogram": 1379183334, + "org.hdrhistogram:HdrHistogram:jar:sources": -1235434218, + "org.hibernate.validator:hibernate-validator": 1976561513, + "org.hibernate.validator:hibernate-validator:jar:sources": 367698734, + "org.jacoco:org.jacoco.agent:jar:runtime": -111724801, + "org.jacoco:org.jacoco.agent:jar:sources": 1432778515, + "org.jacoco:org.jacoco.cli": 2021870981, + "org.jacoco:org.jacoco.cli:jar:sources": 47843733, + "org.jacoco:org.jacoco.core": 579589309, + "org.jacoco:org.jacoco.core:jar:sources": -458393909, + "org.jacoco:org.jacoco.report": -1287135579, + "org.jacoco:org.jacoco.report:jar:sources": -2068401168, + "org.jboss.logging:jboss-logging": -2136063667, + "org.jboss.logging:jboss-logging:jar:sources": 2066441551, + "org.jetbrains.kotlin:kotlin-stdlib": -570435334, + "org.jetbrains.kotlin:kotlin-stdlib:jar:sources": 1404576391, + "org.jetbrains:annotations": 643765179, + "org.jetbrains:annotations:jar:sources": 1009912224, + "org.jspecify:jspecify": -1402924792, + "org.jspecify:jspecify:jar:sources": -841008091, + "org.junit.jupiter:junit-jupiter": -313198921, + "org.junit.jupiter:junit-jupiter-api": 80944698, + "org.junit.jupiter:junit-jupiter-api:jar:sources": -1650654944, + "org.junit.jupiter:junit-jupiter-engine": 730848020, + "org.junit.jupiter:junit-jupiter-engine:jar:sources": 1805473715, + "org.junit.jupiter:junit-jupiter-params": -1923494954, + "org.junit.jupiter:junit-jupiter-params:jar:sources": 1561682205, + "org.junit.jupiter:junit-jupiter:jar:sources": 1809716175, + "org.junit.platform:junit-platform-commons": -1304725543, + "org.junit.platform:junit-platform-commons:jar:sources": -139331649, + "org.junit.platform:junit-platform-console-standalone": -406221538, + "org.junit.platform:junit-platform-console-standalone:jar:sources": -103511349, + "org.junit.platform:junit-platform-engine": -748595950, + "org.junit.platform:junit-platform-engine:jar:sources": -191078126, + "org.junit.platform:junit-platform-launcher": 1722101087, + "org.junit.platform:junit-platform-launcher:jar:sources": -717285440, + "org.junit.platform:junit-platform-reporting": 1847654060, + "org.junit.platform:junit-platform-reporting:jar:sources": -1771745730, + "org.latencyutils:LatencyUtils": 1082471286, + "org.latencyutils:LatencyUtils:jar:sources": 1894864087, + "org.mockito:mockito-core": 734095861, + "org.mockito:mockito-core:jar:sources": 1757924088, + "org.mockito:mockito-junit-jupiter": -501680015, + "org.mockito:mockito-junit-jupiter:jar:sources": -1281757309, "org.objenesis:objenesis": 1083875484, + "org.objenesis:objenesis:jar:sources": 703772823, + "org.opentest4j.reporting:open-test-reporting-tooling-spi": -1639379357, + "org.opentest4j.reporting:open-test-reporting-tooling-spi:jar:sources": 1632689314, "org.opentest4j:opentest4j": 793813175, - "org.ow2.asm:asm": 544578843, + "org.opentest4j:opentest4j:jar:sources": 1210936723, + "org.ow2.asm:asm": 716467505, + "org.ow2.asm:asm-analysis": 129370658, + "org.ow2.asm:asm-analysis:jar:sources": -2126326860, + "org.ow2.asm:asm-commons": 530868933, + "org.ow2.asm:asm-commons:jar:sources": 1248498766, + "org.ow2.asm:asm-tree": 369430530, + "org.ow2.asm:asm-tree:jar:sources": -1850601298, + "org.ow2.asm:asm-util": -803635337, + "org.ow2.asm:asm-util:jar:sources": 51483494, + "org.ow2.asm:asm:jar:sources": -947428423, + "org.projectlombok:lombok": -1095750717, + "org.projectlombok:lombok:jar:sources": 1834083797, + "org.reactivestreams:reactive-streams": -1996658890, + "org.reactivestreams:reactive-streams:jar:sources": -258070571, + "org.rnorth.duct-tape:duct-tape": 615461963, + "org.rnorth.duct-tape:duct-tape:jar:sources": 427419407, "org.skyscreamer:jsonassert": -1571197746, - "org.slf4j:jul-to-slf4j": 512125258, - "org.slf4j:slf4j-api": -873710932, - "org.springframework.boot:spring-boot": 2137763157, - "org.springframework.boot:spring-boot-autoconfigure": 415760723, - "org.springframework.boot:spring-boot-starter": 266575010, - "org.springframework.boot:spring-boot-starter-json": 962579605, - "org.springframework.boot:spring-boot-starter-logging": 1391779870, - "org.springframework.boot:spring-boot-starter-test": 421659865, - "org.springframework.boot:spring-boot-starter-tomcat": -614445828, - "org.springframework.boot:spring-boot-starter-web": -1714873577, - "org.springframework.boot:spring-boot-test": -1002553735, - "org.springframework.boot:spring-boot-test-autoconfigure": -1144428323, - "org.springframework:spring-aop": -1474506834, - "org.springframework:spring-beans": 948279984, - "org.springframework:spring-context": -383408204, - "org.springframework:spring-core": 1538637463, - "org.springframework:spring-expression": -1818575898, - "org.springframework:spring-jcl": -1887331608, - "org.springframework:spring-test": 736991253, - "org.springframework:spring-web": -155220165, - "org.springframework:spring-webmvc": 1323051250, - "org.xmlunit:xmlunit-core": 1871441717, - "org.yaml:snakeyaml": 2128641932 - }, - "conflict_resolution": { - "com.google.errorprone:error_prone_annotations:2.5.1": "com.google.errorprone:error_prone_annotations:2.18.0" + "org.skyscreamer:jsonassert:jar:sources": -392658057, + "org.slf4j:jul-to-slf4j": -911724984, + "org.slf4j:jul-to-slf4j:jar:sources": -662175280, + "org.slf4j:slf4j-api": -1249720338, + "org.slf4j:slf4j-api:jar:sources": -297247278, + "org.springdoc:springdoc-openapi-starter-common": -50810541, + "org.springdoc:springdoc-openapi-starter-common:jar:sources": 776689800, + "org.springdoc:springdoc-openapi-starter-webflux-api": -2128144311, + "org.springdoc:springdoc-openapi-starter-webflux-api:jar:sources": -1775632648, + "org.springdoc:springdoc-openapi-starter-webmvc-api": 479427887, + "org.springdoc:springdoc-openapi-starter-webmvc-api:jar:sources": -1201450538, + "org.springframework.boot:spring-boot": -1519545366, + "org.springframework.boot:spring-boot-actuator": 1868004981, + "org.springframework.boot:spring-boot-actuator-autoconfigure": 437017470, + "org.springframework.boot:spring-boot-actuator-autoconfigure:jar:sources": 1090885133, + "org.springframework.boot:spring-boot-actuator:jar:sources": 1545596535, + "org.springframework.boot:spring-boot-autoconfigure": -491644458, + "org.springframework.boot:spring-boot-autoconfigure:jar:sources": 562322138, + "org.springframework.boot:spring-boot-cassandra": 1880514148, + "org.springframework.boot:spring-boot-cassandra:jar:sources": -285158244, + "org.springframework.boot:spring-boot-data-cassandra": -1136414714, + "org.springframework.boot:spring-boot-data-cassandra-test": 1687171774, + "org.springframework.boot:spring-boot-data-cassandra-test:jar:sources": -601584645, + "org.springframework.boot:spring-boot-data-cassandra:jar:sources": 73983427, + "org.springframework.boot:spring-boot-data-commons": 1096447250, + "org.springframework.boot:spring-boot-data-commons:jar:sources": 867332098, + "org.springframework.boot:spring-boot-health": 988376788, + "org.springframework.boot:spring-boot-health:jar:sources": 1156692002, + "org.springframework.boot:spring-boot-http-client": -294534102, + "org.springframework.boot:spring-boot-http-client:jar:sources": 881974750, + "org.springframework.boot:spring-boot-http-codec": 434302862, + "org.springframework.boot:spring-boot-http-codec:jar:sources": 585983495, + "org.springframework.boot:spring-boot-http-converter": -1456188332, + "org.springframework.boot:spring-boot-http-converter:jar:sources": 678862764, + "org.springframework.boot:spring-boot-jackson": -886310726, + "org.springframework.boot:spring-boot-jackson:jar:sources": -928970153, + "org.springframework.boot:spring-boot-micrometer-metrics": -971815116, + "org.springframework.boot:spring-boot-micrometer-metrics-test": 1059861465, + "org.springframework.boot:spring-boot-micrometer-metrics-test:jar:sources": 539702709, + "org.springframework.boot:spring-boot-micrometer-metrics:jar:sources": -1326033951, + "org.springframework.boot:spring-boot-micrometer-observation": -515345138, + "org.springframework.boot:spring-boot-micrometer-observation:jar:sources": -1374159176, + "org.springframework.boot:spring-boot-micrometer-tracing": -1769177987, + "org.springframework.boot:spring-boot-micrometer-tracing-opentelemetry": 686601187, + "org.springframework.boot:spring-boot-micrometer-tracing-opentelemetry:jar:sources": 2058843242, + "org.springframework.boot:spring-boot-micrometer-tracing:jar:sources": -531678236, + "org.springframework.boot:spring-boot-netty": -1868279944, + "org.springframework.boot:spring-boot-netty:jar:sources": 2079852054, + "org.springframework.boot:spring-boot-opentelemetry": -1423093456, + "org.springframework.boot:spring-boot-opentelemetry:jar:sources": 1840051882, + "org.springframework.boot:spring-boot-persistence": -1485790991, + "org.springframework.boot:spring-boot-persistence:jar:sources": -1027448649, + "org.springframework.boot:spring-boot-reactor": 1298372962, + "org.springframework.boot:spring-boot-reactor-netty": 1434335970, + "org.springframework.boot:spring-boot-reactor-netty:jar:sources": -732886702, + "org.springframework.boot:spring-boot-reactor:jar:sources": -1373689055, + "org.springframework.boot:spring-boot-restclient": -67067992, + "org.springframework.boot:spring-boot-restclient:jar:sources": -1554622109, + "org.springframework.boot:spring-boot-resttestclient": 1078416021, + "org.springframework.boot:spring-boot-resttestclient:jar:sources": 1128597445, + "org.springframework.boot:spring-boot-security": -528218864, + "org.springframework.boot:spring-boot-security-oauth2-client": 1972725629, + "org.springframework.boot:spring-boot-security-oauth2-client:jar:sources": -1211778370, + "org.springframework.boot:spring-boot-security-oauth2-resource-server": -1021125134, + "org.springframework.boot:spring-boot-security-oauth2-resource-server:jar:sources": 1301519418, + "org.springframework.boot:spring-boot-security-test": -1810341741, + "org.springframework.boot:spring-boot-security-test:jar:sources": -311791400, + "org.springframework.boot:spring-boot-security:jar:sources": -1644906067, + "org.springframework.boot:spring-boot-servlet": -1040246830, + "org.springframework.boot:spring-boot-servlet:jar:sources": 1162002660, + "org.springframework.boot:spring-boot-starter": 191814674, + "org.springframework.boot:spring-boot-starter-actuator": 1761935967, + "org.springframework.boot:spring-boot-starter-actuator-test": 910069034, + "org.springframework.boot:spring-boot-starter-actuator-test:jar:sources": 314973026, + "org.springframework.boot:spring-boot-starter-actuator:jar:sources": -2079433447, + "org.springframework.boot:spring-boot-starter-data-cassandra": -1305411192, + "org.springframework.boot:spring-boot-starter-data-cassandra-test": 1049224325, + "org.springframework.boot:spring-boot-starter-data-cassandra-test:jar:sources": 464889083, + "org.springframework.boot:spring-boot-starter-data-cassandra:jar:sources": 62860247, + "org.springframework.boot:spring-boot-starter-jackson": 211326145, + "org.springframework.boot:spring-boot-starter-jackson-test": 538761659, + "org.springframework.boot:spring-boot-starter-jackson-test:jar:sources": 1211585483, + "org.springframework.boot:spring-boot-starter-jackson:jar:sources": 1689728463, + "org.springframework.boot:spring-boot-starter-logging": 51215582, + "org.springframework.boot:spring-boot-starter-logging:jar:sources": 1271111015, + "org.springframework.boot:spring-boot-starter-micrometer-metrics": 546085162, + "org.springframework.boot:spring-boot-starter-micrometer-metrics-test": -835747019, + "org.springframework.boot:spring-boot-starter-micrometer-metrics-test:jar:sources": 1830581000, + "org.springframework.boot:spring-boot-starter-micrometer-metrics:jar:sources": -1130818080, + "org.springframework.boot:spring-boot-starter-reactor-netty": 812139334, + "org.springframework.boot:spring-boot-starter-reactor-netty:jar:sources": 1164956430, + "org.springframework.boot:spring-boot-starter-security": 1058973423, + "org.springframework.boot:spring-boot-starter-security-oauth2-client": -1355057519, + "org.springframework.boot:spring-boot-starter-security-oauth2-client:jar:sources": 1707609356, + "org.springframework.boot:spring-boot-starter-security-oauth2-resource-server": -265511875, + "org.springframework.boot:spring-boot-starter-security-oauth2-resource-server-test": -17384836, + "org.springframework.boot:spring-boot-starter-security-oauth2-resource-server-test:jar:sources": -746940353, + "org.springframework.boot:spring-boot-starter-security-oauth2-resource-server:jar:sources": 2136275088, + "org.springframework.boot:spring-boot-starter-security-test": 1690721902, + "org.springframework.boot:spring-boot-starter-security-test:jar:sources": 468207576, + "org.springframework.boot:spring-boot-starter-security:jar:sources": -1720617031, + "org.springframework.boot:spring-boot-starter-test": -1087542039, + "org.springframework.boot:spring-boot-starter-test:jar:sources": -480189945, + "org.springframework.boot:spring-boot-starter-tomcat": -521361670, + "org.springframework.boot:spring-boot-starter-tomcat-runtime": -1912812722, + "org.springframework.boot:spring-boot-starter-tomcat-runtime:jar:sources": 1011838045, + "org.springframework.boot:spring-boot-starter-tomcat:jar:sources": 1319064174, + "org.springframework.boot:spring-boot-starter-validation": -321633530, + "org.springframework.boot:spring-boot-starter-validation:jar:sources": -1002865328, + "org.springframework.boot:spring-boot-starter-web": 1122240419, + "org.springframework.boot:spring-boot-starter-web:jar:sources": -768709610, + "org.springframework.boot:spring-boot-starter-webflux": -818164168, + "org.springframework.boot:spring-boot-starter-webflux-test": -415179163, + "org.springframework.boot:spring-boot-starter-webflux-test:jar:sources": -1654084055, + "org.springframework.boot:spring-boot-starter-webflux:jar:sources": 1008425882, + "org.springframework.boot:spring-boot-starter-webmvc": 170962824, + "org.springframework.boot:spring-boot-starter-webmvc-test": -1343848488, + "org.springframework.boot:spring-boot-starter-webmvc-test:jar:sources": -1751111514, + "org.springframework.boot:spring-boot-starter-webmvc:jar:sources": -2120956724, + "org.springframework.boot:spring-boot-starter:jar:sources": 1138639700, + "org.springframework.boot:spring-boot-test": -927444958, + "org.springframework.boot:spring-boot-test-autoconfigure": -669950838, + "org.springframework.boot:spring-boot-test-autoconfigure:jar:sources": 392165449, + "org.springframework.boot:spring-boot-test:jar:sources": -1764708648, + "org.springframework.boot:spring-boot-tomcat": -1113349252, + "org.springframework.boot:spring-boot-tomcat:jar:sources": -1529683395, + "org.springframework.boot:spring-boot-validation": 1270368638, + "org.springframework.boot:spring-boot-validation:jar:sources": -1684576067, + "org.springframework.boot:spring-boot-web-server": 285708094, + "org.springframework.boot:spring-boot-web-server:jar:sources": 990724443, + "org.springframework.boot:spring-boot-webclient": -507647, + "org.springframework.boot:spring-boot-webclient:jar:sources": 71538120, + "org.springframework.boot:spring-boot-webflux": -231291542, + "org.springframework.boot:spring-boot-webflux-test": 1600114323, + "org.springframework.boot:spring-boot-webflux-test:jar:sources": 1452173744, + "org.springframework.boot:spring-boot-webflux:jar:sources": -1118813321, + "org.springframework.boot:spring-boot-webmvc": -2104103221, + "org.springframework.boot:spring-boot-webmvc-test": 749235095, + "org.springframework.boot:spring-boot-webmvc-test:jar:sources": 444837917, + "org.springframework.boot:spring-boot-webmvc:jar:sources": -1895028417, + "org.springframework.boot:spring-boot-webtestclient": -705991472, + "org.springframework.boot:spring-boot-webtestclient:jar:sources": -542316343, + "org.springframework.boot:spring-boot:jar:sources": 580880564, + "org.springframework.cloud:spring-cloud-commons": -788453967, + "org.springframework.cloud:spring-cloud-commons:jar:sources": 1320616004, + "org.springframework.cloud:spring-cloud-context": 1118197933, + "org.springframework.cloud:spring-cloud-context:jar:sources": -1413797918, + "org.springframework.cloud:spring-cloud-starter": -1812063683, + "org.springframework.cloud:spring-cloud-starter-bootstrap": -1263482746, + "org.springframework.cloud:spring-cloud-starter-bootstrap:jar:sources": 1965832056, + "org.springframework.data:spring-data-cassandra": -1548120966, + "org.springframework.data:spring-data-cassandra:jar:sources": -1628107854, + "org.springframework.data:spring-data-commons": 1312199320, + "org.springframework.data:spring-data-commons:jar:sources": 544548950, + "org.springframework.security:spring-security-config": 2001744589, + "org.springframework.security:spring-security-config:jar:sources": -756968726, + "org.springframework.security:spring-security-core": -1251894794, + "org.springframework.security:spring-security-core:jar:sources": 1285888871, + "org.springframework.security:spring-security-crypto": 424824057, + "org.springframework.security:spring-security-crypto:jar:sources": 1862561396, + "org.springframework.security:spring-security-oauth2-client": -1975050683, + "org.springframework.security:spring-security-oauth2-client:jar:sources": 1833974239, + "org.springframework.security:spring-security-oauth2-core": -1658804453, + "org.springframework.security:spring-security-oauth2-core:jar:sources": -948845785, + "org.springframework.security:spring-security-oauth2-jose": 502613895, + "org.springframework.security:spring-security-oauth2-jose:jar:sources": 17294361, + "org.springframework.security:spring-security-oauth2-resource-server": -1894007153, + "org.springframework.security:spring-security-oauth2-resource-server:jar:sources": -1508178760, + "org.springframework.security:spring-security-test": 1672796517, + "org.springframework.security:spring-security-test:jar:sources": -1326480212, + "org.springframework.security:spring-security-web": -1696304083, + "org.springframework.security:spring-security-web:jar:sources": 1443816202, + "org.springframework:spring-aop": -819786825, + "org.springframework:spring-aop:jar:sources": 1924976574, + "org.springframework:spring-beans": -698130853, + "org.springframework:spring-beans:jar:sources": -2147408778, + "org.springframework:spring-context": -846077202, + "org.springframework:spring-context:jar:sources": -1263444176, + "org.springframework:spring-core": -253727183, + "org.springframework:spring-core:jar:sources": -173347576, + "org.springframework:spring-expression": 1724609785, + "org.springframework:spring-expression:jar:sources": 1135225455, + "org.springframework:spring-test": -279979944, + "org.springframework:spring-test:jar:sources": -6017836, + "org.springframework:spring-tx": 1899606770, + "org.springframework:spring-tx:jar:sources": -1007028336, + "org.springframework:spring-web": 2084009704, + "org.springframework:spring-web:jar:sources": 1608360284, + "org.springframework:spring-webflux": 1763806581, + "org.springframework:spring-webflux:jar:sources": -1419709374, + "org.springframework:spring-webmvc": 56813816, + "org.springframework:spring-webmvc:jar:sources": -837106767, + "org.testcontainers:testcontainers": 450183679, + "org.testcontainers:testcontainers-cassandra": -1886187917, + "org.testcontainers:testcontainers-cassandra:jar:sources": 2024290088, + "org.testcontainers:testcontainers-database-commons": -1213526598, + "org.testcontainers:testcontainers-database-commons:jar:sources": 315333060, + "org.testcontainers:testcontainers-junit-jupiter": -1827576744, + "org.testcontainers:testcontainers-junit-jupiter:jar:sources": 975697823, + "org.testcontainers:testcontainers:jar:sources": 76092129, + "org.wiremock:wiremock-standalone": -1817681233, + "org.wiremock:wiremock-standalone:jar:sources": 695361099, + "org.xmlunit:xmlunit-core": 1938864481, + "org.xmlunit:xmlunit-core:jar:sources": -54376142, + "org.yaml:snakeyaml": -1432706414, + "org.yaml:snakeyaml:jar:sources": 393768628, + "software.amazon.awssdk:annotations": -647669452, + "software.amazon.awssdk:annotations:jar:sources": -277384386, + "software.amazon.awssdk:checksums": 573213413, + "software.amazon.awssdk:checksums-spi": -720493267, + "software.amazon.awssdk:checksums-spi:jar:sources": -650776626, + "software.amazon.awssdk:checksums:jar:sources": -241565759, + "software.amazon.awssdk:endpoints-spi": 1412926322, + "software.amazon.awssdk:endpoints-spi:jar:sources": -1092637607, + "software.amazon.awssdk:http-auth-aws": -589182304, + "software.amazon.awssdk:http-auth-aws:jar:sources": -993506252, + "software.amazon.awssdk:http-auth-spi": -45686548, + "software.amazon.awssdk:http-auth-spi:jar:sources": -1486999869, + "software.amazon.awssdk:http-client-spi": 1386281563, + "software.amazon.awssdk:http-client-spi:jar:sources": 541891925, + "software.amazon.awssdk:identity-spi": -969758372, + "software.amazon.awssdk:identity-spi:jar:sources": -1418587191, + "software.amazon.awssdk:json-utils": -1666742251, + "software.amazon.awssdk:json-utils:jar:sources": 1618084806, + "software.amazon.awssdk:metrics-spi": -500500368, + "software.amazon.awssdk:metrics-spi:jar:sources": -1496041869, + "software.amazon.awssdk:profiles": 1556987661, + "software.amazon.awssdk:profiles:jar:sources": 517790158, + "software.amazon.awssdk:regions": 1394259783, + "software.amazon.awssdk:regions:jar:sources": 927040140, + "software.amazon.awssdk:retries": 1273159411, + "software.amazon.awssdk:retries-spi": 1857446587, + "software.amazon.awssdk:retries-spi:jar:sources": -974151310, + "software.amazon.awssdk:retries:jar:sources": 1826499536, + "software.amazon.awssdk:sdk-core": 1641186658, + "software.amazon.awssdk:sdk-core:jar:sources": -769872481, + "software.amazon.awssdk:third-party-jackson-core": -1062653941, + "software.amazon.awssdk:third-party-jackson-core:jar:sources": -230379012, + "software.amazon.awssdk:utils": 1497168994, + "software.amazon.awssdk:utils:jar:sources": 249477790, + "tools.jackson.core:jackson-core": -1258054011, + "tools.jackson.core:jackson-core:jar:sources": -1689479769, + "tools.jackson.core:jackson-databind": 1443518747, + "tools.jackson.core:jackson-databind:jar:sources": -871567409, + "tools.jackson.module:jackson-module-blackbird": 1038981586, + "tools.jackson.module:jackson-module-blackbird:jar:sources": -9825245 }, "artifacts": { + "aopalliance:aopalliance": { + "shasums": { + "jar": "0addec670fedcd3f113c5c8091d783280d23f75e3acb841b61a9cdb079376a08", + "sources": "e6ef91d439ada9045f419c77543ebe0416c3cdfc5b063448343417a3e4a72123" + }, + "version": "1.0" + }, + "args4j:args4j": { + "shasums": { + "jar": "11b029a602e787e2bc08eb3b77eda1a4f5e8b263d22e3c5d6220cd5c51f30b18", + "sources": "ec1eb6aa4859b9b4fd9da4d58efb28b2eb629a4c14919e9078054879540243b7" + }, + "version": "2.0.28" + }, + "at.yawk.lz4:lz4-java": { + "shasums": { + "jar": "49753ae8a9b7dc3ce48cb2989cc6605e43eb8269748ad3466251836ec4cd02a8", + "sources": "3b9a0590c53a4f4e45b204695af0e480dbd4f3a589913c6a36f7c2c3d31b0eaa" + }, + "version": "1.10.3" + }, "ch.qos.logback:logback-classic": { "shasums": { - "jar": "e223cce321ccb435d23e1dadfc6ceeccd862e2dabb1a2a5675318426f7fa1978" + "jar": "b65e05076a5c1aadb659b4fe4bc5fee31cb26cd70390292eb03e4a7a24cff10f", + "sources": "c2e39cb4d6d9b8c2343c6da2469e21e1d6aef2dde16c2227762c084d549ad0a0" }, - "version": "1.5.11" + "version": "1.5.34" }, "ch.qos.logback:logback-core": { "shasums": { - "jar": "e0f242aa3c4411ce8c7ec304a5afeaa75224680890f5f813153ef807cbb9542e" + "jar": "42eda264c0c650c2bec59e66151a88b708a8663dc1b49d788202d53e78b8caae", + "sources": "6a9f217ef206caf2880810c505e057fd2bb90a6024013906815e9513c6e488c5" + }, + "version": "1.5.34" + }, + "com.datastax.cassandra:cassandra-driver-core": { + "shasums": { + "jar": "09c4d54009e942e3afb8755c5fd51abac8ba721e407b61dcb22f435d1a33c1a1", + "sources": "b0e1d20dd052986f1cc1511fee039801ebb1ae3474a14f0708bedf9b6278083d" + }, + "version": "3.10.0" + }, + "com.datastax.oss:native-protocol": { + "shasums": { + "jar": "190dc40f3c63d6d803c48f90d457e06c65e6c5d955e47d4735dc9954a6743655", + "sources": "6029f3fd12ebe066826642d42f0efb63108b051577828458b66a8637847e88c9" }, - "version": "1.5.11" + "version": "1.5.2" }, "com.fasterxml.jackson.core:jackson-annotations": { "shasums": { - "jar": "873a606e23507969f9bbbea939d5e19274a88775ea5a169ba7e2d795aa5156e1" + "jar": "53ca085f4a150f703f49e1aabd935bd03b43e1ea3d55d135438292af22cef56b", + "sources": "71fe6323d91b16d5d1007fd1e1533fa06bb369abde74f68a42a68c0f8f061a8b" }, - "version": "2.17.2" + "version": "2.21" }, "com.fasterxml.jackson.core:jackson-core": { "shasums": { - "jar": "721a189241dab0525d9e858e5cb604d3ecc0ede081e2de77d6f34fa5779a5b46" + "jar": "4b40a06396f239f8de2da57419adde6e94e5edc18a2171d471ea05eeed4e5c2d", + "sources": "90ccada55626ce4f00a81bde235af0a942ec2bc4c701fcc86a93af1be9b3e08d" }, - "version": "2.17.2" + "version": "2.21.4" }, "com.fasterxml.jackson.core:jackson-databind": { "shasums": { - "jar": "c04993f33c0f845342653784f14f38373d005280e6359db5f808701cfae73c0c" + "jar": "3888e9e69ab66fbacaacc9aea0e9ffbf15368288e4aca468b024dba11c09fbf9", + "sources": "23188e78e912c9866367bf038fb7f729e79f1c2724174ca66e0a00915de70e61" }, - "version": "2.17.2" + "version": "2.21.4" }, - "com.fasterxml.jackson.datatype:jackson-datatype-jdk8": { + "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml": { "shasums": { - "jar": "aaa98d3edabf50426bd822fad1442fbdada6e470969326cbcab5c2798f1738d9" + "jar": "055eb4c008ab12f0cb63e93a6454463d032fde0fca85943d69f8dc7469489e4b", + "sources": "164cef68956eb2797e421dd6cf74bfd648581965a8df2389c260bb363d1f3baa" }, - "version": "2.17.2" + "version": "2.21.4" }, "com.fasterxml.jackson.datatype:jackson-datatype-jsr310": { "shasums": { - "jar": "9b80024a9822e70b07f6bb13824c76c137c1064a1b5eb518374ab141870fdbcc" + "jar": "d1ac4b98b70304e56448423589fde5e775b100889643ad1ead62cc7811633684", + "sources": "884a8812af289bb56f38f09a4b3b7b73b4a16dbc184ae735fbe6c4aa1089161f" }, - "version": "2.17.2" + "version": "2.21.4" }, - "com.fasterxml.jackson.module:jackson-module-parameter-names": { + "com.fasterxml:classmate": { "shasums": { - "jar": "1ee5e2f3593d44726b900868c6f64d1a50636a5683d6d40427f5984a61de53c0" + "jar": "75fbda45456f123fb6e2028a6189442d8d0730b357adce4c0a6d7e789f70669b", + "sources": "b0690a771dc54865c3d0ad9ab6abb319633b6b88849900a28f719e792c15fc68" }, - "version": "2.17.2" + "version": "1.7.3" }, - "com.google.code.findbugs:jsr305": { + "com.github.ben-manes.caffeine:caffeine": { + "shasums": { + "jar": "9d9d2cfd681fd9272ded3d27c9930db12f89f732345975aa113ebc223bbf1224", + "sources": "5079e1b327d79e2fc8ae3f5927587e59c968603e63785fb33479acd8b732211d" + }, + "version": "3.2.4" + }, + "com.github.ben-manes.caffeine:guava": { + "shasums": { + "jar": "7819335459a43b3d2d501bd19b6cf880041a1ad47a9a118d018d89c432357358", + "sources": "2de42779d0ee1807262065382a9302715b2d8daa186f9b39aca1043c10681ab5" + }, + "version": "3.2.4" + }, + "com.github.docker-java:docker-java-api": { + "shasums": { + "jar": "dad153d484b1f4ef009e2fdbad27e07aeb3191122da52b8985507ac504300081", + "sources": "57c9b5bc37d48c256a2a0de556095158f363b10eec58052e58f299c542cf9391" + }, + "version": "3.7.1" + }, + "com.github.docker-java:docker-java-transport": { + "shasums": { + "jar": "d15eec8034bf0f92c2a48ca9172691804048115c96dc853272f9486fa2695c3c", + "sources": "131ed62714d94125f89a3c3ad966e517c8ad48fbbc1905b08bcdafc0d6e9de45" + }, + "version": "3.7.1" + }, + "com.github.docker-java:docker-java-transport-zerodep": { + "shasums": { + "jar": "b89bdb1754160323597f9ea32a7fe7a4a3aa8f5b3b43b88e8d71fff3b267ab21", + "sources": "f4d1457f9e2e151d19713e46cf2b49796887d5a9744664a15a4ce07c76660883" + }, + "version": "3.7.1" + }, + "com.github.java-json-tools:btf": { + "shasums": { + "jar": "67c3e462eb50807f4e0a5f4dee304bbf17cd986a42ee5eb0b2f4c9bf64d130d9", + "sources": "97f8bfb9a8876534bf2832a5be4b913b695d72c6ff6f9c8c6719bd38fd4aeb73" + }, + "version": "1.3" + }, + "com.github.java-json-tools:jackson-coreutils": { + "shasums": { + "jar": "16b3aabd3a9eb25655dda433e35f9bd9c7c1aa7991427702f5f11f000813dbb0", + "sources": "6f39b6beed5b000702ade7014be2ca21f895a0b70ab6c199f6ca5bebc1807080" + }, + "version": "2.0" + }, + "com.github.java-json-tools:json-patch": { + "shasums": { + "jar": "1f794d256965b53ef37e70b55505e2ed00ddc0184d44e2e8e1fdce5a3cacc7de", + "sources": "f4ba54ca57611123fe972f05537d44d4b61fd8ed6f71541b3ca37e09a6e3e318" + }, + "version": "1.13" + }, + "com.github.java-json-tools:msg-simple": { + "shasums": { + "jar": "bef4111b993a5b3e6148d8f585621cceac2a1889cdbc34448b11632e0d8a9a8f", + "sources": "eeb0ecd504611cec75f261a6d282bb8b80214e473ef235481c8067b6b121f1cd" + }, + "version": "1.2" + }, + "com.github.jnr:jffi": { + "shasums": { + "jar": "7a616bb7dc6e10531a28a098078f8184df9b008d5231bdc5f1c131839385335f", + "native": "ef78953e3dbf47fab94469190bc2a6d601566a21d4651f73c822bad1c02b64fe", + "sources": "45ad89d2774e9d03de89905cf990d49d5821ce8012a841faddf23dca02538d72" + }, + "version": "1.2.16" + }, + "com.github.jnr:jnr-constants": { "shasums": { - "jar": "766ad2a0783f2687962c8ad74ceecc38a28b9f72a2d085ee438b7813e928d0c7" + "jar": "a617b0d8463d3ea36435bd1611113dedb3749157afd2269908ab306c992aefed", + "sources": "9406718df04cd893a94933213b370d99c613d94d80e23119e2cf8dc51394ea12" }, - "version": "3.0.2" + "version": "0.10.3" }, - "com.google.code.gson:gson": { + "com.github.jnr:jnr-ffi": { "shasums": { - "jar": "d3999291855de495c94c743761b8ab5176cfeabe281a5ab0d8e8d45326fd703e" + "jar": "2ed1bedf59935cd3cc0964bac5cd91638b2e966a82041fe0a6c85f52279c9b34", + "sources": "61842708c7e617ae2ca3a389931142f506f854104392fa6f7aaac6f51c93cf58" }, - "version": "2.8.9" + "version": "2.1.7" + }, + "com.github.jnr:jnr-posix": { + "shasums": { + "jar": "c38ecfccd24e5f21f17a62e45d5bd454842c5db17ed42b01b868f9206d0e99e7", + "sources": "abfff56a7628d223ba86c3ccb3bcb5101aeefdeedbe58c3c52b1917a92d7e332" + }, + "version": "3.1.15" + }, + "com.github.jnr:jnr-x86asm": { + "shasums": { + "jar": "39f3675b910e6e9b93825f8284bec9f4ad3044cd20a6f7c8ff9e2f8695ebf21e", + "sources": "3c983efd496f95ea5382ca014f96613786826136e0ce13d5c1cbc3097ea92ca0" + }, + "version": "1.0.2" + }, + "com.github.stephenc.jcip:jcip-annotations": { + "shasums": { + "jar": "4fccff8382aafc589962c4edb262f6aa595e34f1e11e61057d1c6a96e8fc7323", + "sources": "d60bb3bf4e03a5e405f9b16f4c2625de86089d6ce4f999bcc2548dcac090ae19" + }, + "version": "1.0-1" + }, + "com.google.code.findbugs:jsr305": { + "shasums": { + "jar": "1e7f53fa5b8b5c807e986ba335665da03f18d660802d8bf061823089d1bee468" + }, + "version": "2.0.1" }, "com.google.errorprone:error_prone_annotations": { "shasums": { - "jar": "9e6814cb71816988a4fd1b07a993a8f21bb7058d522c162b1de849e19bea54ae" + "jar": "3b1003e51b8ae56fdbd7c71073e81d1683b97e6c4dff5a9151164d59b769d13c", + "sources": "69d2de7f69033ff914ba06f0858adb96ac7b1436959f04fca7de5805c834b281" }, - "version": "2.18.0" + "version": "2.49.0" }, "com.google.guava:failureaccess": { "shasums": { - "jar": "a171ee4c734dd2da837e4b16be9df4661afab72a41adaf31eb84dfdaf936ca26" + "jar": "cbfc3906b19b8f55dd7cfd6dfe0aa4532e834250d7f080bd8d211a3e246b59cb", + "sources": "6fef4dfd2eb9f961655f2a3c4ea87c023618d9fcbfb6b104c17862e5afe66b97" }, - "version": "1.0.1" + "version": "1.0.3" }, "com.google.guava:guava": { "shasums": { - "jar": "bd7fa227591fb8509677d0d1122cf95158f3b8a9f45653f58281d879f6dc48c5" + "jar": "dc573e1fca4fd5454f4a5fd3d7da2df03002876a4175bafc14a95980dd7713b3", + "sources": "eff31867dd63a92d63ca856127a424a6836418d8bfa044162cac20430abd3500" }, - "version": "32.0.1-jre" + "version": "33.6.0-jre" }, "com.google.guava:listenablefuture": { "shasums": { @@ -179,395 +871,2197 @@ }, "com.google.j2objc:j2objc-annotations": { "shasums": { - "jar": "f02a95fa1a5e95edb3ed859fd0fb7df709d121a35290eff8b74dce2ab7f4d6ed" + "jar": "84d3a150518485f8140ea99b8a985656749629f6433c92b80c75b36aba3b099b", + "sources": "295938307f4016b3f128f7347101b236ada1394808104519c9e93cd61b64602b" }, - "version": "2.8" + "version": "3.1" }, "com.jayway.jsonpath:json-path": { "shasums": { - "jar": "11a9ee6f88bb31f1450108d1cf6441377dec84aca075eb6bb2343be157575bea" + "jar": "890daa95dd3892d34d9fabc27cd5153656e6f369358625c88f4dc7b79cbd6c5a", + "sources": "357f1c52217497c4251fae715ba8ef76ae310b1aae77ea319260bf4c6ad61440" + }, + "version": "2.10.0" + }, + "com.nimbusds:content-type": { + "shasums": { + "jar": "60349793e006fba96b532cb0c21e10e969fe0db8d87f91c3b9eaf82ba2998895", + "sources": "9a154c802659594d8745a0e364bd2e6b418e010268467cd222354445dd77d626" }, - "version": "2.9.0" + "version": "2.3" + }, + "com.nimbusds:lang-tag": { + "shasums": { + "jar": "e8c1c594e2425bdbea2d860de55c69b69fc5d59454452449a0f0913c2a5b8a31", + "sources": "8d37da312db366d702bd7254df6bceca0a7814e18da9c32db9cc1d8ca038c468" + }, + "version": "1.7" + }, + "com.nimbusds:nimbus-jose-jwt": { + "shasums": { + "jar": "6f13f3480ddc53d820d276f582f54843cff1daf5c6e35e534947e9de7723b46b", + "sources": "b3fd3b569013246fb9a1ac6b43ff3e3033065bce16de92ecf4c971d14875eb30" + }, + "version": "10.4" + }, + "com.nimbusds:oauth2-oidc-sdk": { + "shasums": { + "jar": "648494b00da090a4df23aa6a05e4dc03f6e8603b29dd807a4491f33d586f7f78", + "sources": "e4de2ef818fbb57aa3731b65e2f5edc1a40dc1e3f6e6541306ca9f45490aed2a" + }, + "version": "11.26.1" + }, + "com.squareup.okhttp3:okhttp-jvm": { + "shasums": { + "jar": "9632c08567dbb21c569b13d793107834c8580d44e4eea74b2eae0722f0506179", + "sources": "9fa32be62c7a46ffe70cdb1bf7e195f5ee3fc885999379292cb636b5aa311704" + }, + "version": "5.2.1" + }, + "com.squareup.okio:okio-jvm": { + "shasums": { + "jar": "31f48e6463ec587d6d262d042c91da00659c983b6ad20d5982bf31e85222693c", + "sources": "b29b5f4ad1adfd7cbfb3d388f7bf29d1702f0b85be1b6c61b0185ce7e2b2e6dc" + }, + "version": "3.16.1" + }, + "com.typesafe:config": { + "shasums": { + "jar": "4c0aa7e223c75c8840c41fc183d4cd3118140a1ee503e3e08ce66ed2794c948f", + "sources": "89af318a607f7e2b2691ed1ef4b4890bd37ea17d6986b0aec50dd4d5f889520c" + }, + "version": "1.4.1" }, "com.vaadin.external.google:android-json": { "shasums": { - "jar": "dfb7bae2f404cfe0b72b4d23944698cb716b7665171812a0a4d0f5926c0fac79" + "jar": "dfb7bae2f404cfe0b72b4d23944698cb716b7665171812a0a4d0f5926c0fac79", + "sources": "54c781eea645c450cbbc4a5a1b5a474745465452cec1354cb567b781ea6622c3" }, "version": "0.0.20131108.vaadin1" }, + "commons-codec:commons-codec": { + "shasums": { + "jar": "5c3881e4f556855e9c532927ee0c9dfde94cc66760d5805c031a59887070af5f", + "sources": "b0462142585d45fc15bc8091b7b02f1e3a85c83595068659548c82cac9cdc7a2" + }, + "version": "1.19.0" + }, + "commons-io:commons-io": { + "shasums": { + "jar": "df90bba0fe3cb586b7f164e78fe8f8f4da3f2dd5c27fa645f888100ccc25dd72", + "sources": "7a87277538cce40da6389a7163a4d9458bc7a9c39937a329881b91d144be8e0d" + }, + "version": "2.20.0" + }, + "commons-logging:commons-logging": { + "shasums": { + "jar": "f8ead8943401081dea0aa824b5b1ba40a0e4ed297a572a0f02258150a0b62357", + "sources": "6e821e03cfc64e509cc162d428af84697b6c4188eb14c0b137c75b69649976ef" + }, + "version": "1.3.6" + }, + "io.cloudevents:cloudevents-api": { + "shasums": { + "jar": "95751500be617f0c795ea3cd7c370fa712459da500fbd22bb8b7f69fcba46e97", + "sources": "962306ae4d9c9aaea5958a3e5e837c895bc683098c2da7f2fa8e972dfea82ebd" + }, + "version": "4.1.1" + }, + "io.cloudevents:cloudevents-core": { + "shasums": { + "jar": "25c3756d184eebf20aceadbd2357cd48516938ca458a1b77a3ebdc09f8dfc592", + "sources": "869ae88ca9b7f7e62af3f34ae6269e7c816b58d08ef371040530164e2aaea02a" + }, + "version": "4.1.1" + }, + "io.cloudevents:cloudevents-json-jackson": { + "shasums": { + "jar": "28aa328c61ae1783cecb430f7b51880e365ea8678628eca416e12654eafbb66d", + "sources": "b4ee1b7db2dfa50101a9fa7a3beef610b6931546be1bc5a61ae28cc929473406" + }, + "version": "4.1.1" + }, + "io.dropwizard.metrics:metrics-core": { + "shasums": { + "jar": "5c6f685e41664d10c70c65837cba9e58d39ff3896811e3b5707a934b11c85ad0", + "sources": "773164a026ea78df51d14608def3a746a2270f630e9a2f5f6f99d6e155ad5bcf" + }, + "version": "3.2.2" + }, + "io.micrometer:context-propagation": { + "shasums": { + "jar": "5b69e2100640879ccf4b20673ced86de560b8e217b55542f85a53d3563b72aaa", + "sources": "719639b819c3ecc76f2b3d4da4b17ca7a8ed6c73b93a770c69aa9d86276f8bf2" + }, + "version": "1.2.1" + }, "io.micrometer:micrometer-commons": { "shasums": { - "jar": "2f04ae7c3c33d61693b49e989dafb0ab8417bbb3a87cc64afa4161265e37e9d6" + "jar": "45aff76226830db257f4bc39a5bcff83d633e572fee9dc4e45cfa12af9a0a49a", + "sources": "0e4a336c7bca50bfa6cbea274ea681e42d86bfdac1038af3041301c8454f76ff" + }, + "version": "1.16.6" + }, + "io.micrometer:micrometer-core": { + "shasums": { + "jar": "1957ef2deaffbc1fb98cebfd0f5b3109ecf19c994d7e5378598c20747ba8d68b", + "sources": "f85d566a76d98891cdecd54c106b7ad7f8948cddad3b393debe341ecfba18bce" + }, + "version": "1.16.6" + }, + "io.micrometer:micrometer-jakarta9": { + "shasums": { + "jar": "987722add6461c5a4c796be11e7452e8813fba341f2cc067b1d283eef2bd4f40", + "sources": "83fa1c6356611b3381d6309a6159b2739c9c29446c8b52183b2111fb13ce5301" }, - "version": "1.12.11" + "version": "1.16.6" }, "io.micrometer:micrometer-observation": { "shasums": { - "jar": "2bfe290b8bb006047978d7aaf2545b3428c8b6261ddd9a0cdedb9d432fbcfc1c" + "jar": "4c0826d5e7c8522a8e111470b4e77f77b0a10f520eb5502ddd143fdbcedb2340", + "sources": "ff5525482df358e7502d4c8680fb654ec700e3b01341149d870bd6cfd8ce04c0" }, - "version": "1.12.11" + "version": "1.16.6" }, - "jakarta.activation:jakarta.activation-api": { + "io.micrometer:micrometer-observation-test": { "shasums": { - "jar": "01b176d718a169263e78290691fc479977186bcc6b333487325084d6586f4627" + "jar": "e69bf8df81458f4ad6f6027a7310d75c8ca8d11df2e95d44dd4616f8637069ad", + "sources": "1ebcdbf4e14e6a4dcec4ad188bc4227ebd5ce0a3f361f5f0ed6df2bebb10cfb2" }, - "version": "2.1.3" + "version": "1.16.6" }, - "jakarta.annotation:jakarta.annotation-api": { + "io.micrometer:micrometer-tracing": { "shasums": { - "jar": "5f65fdaf424eee2b55e1d882ba9bb376be93fb09b37b808be6e22e8851c909fe" + "jar": "b335096393e4d070dfda4fb30808f8488d31d43811b93b2d7496703c5f1315ef", + "sources": "a089a2bcc462d6ced28d7f67b01a9f54baf6c52984cd13f3ed713f2fda0810c6" }, - "version": "2.1.1" + "version": "1.6.6" }, - "jakarta.xml.bind:jakarta.xml.bind-api": { + "io.micrometer:micrometer-tracing-bridge-otel": { "shasums": { - "jar": "0d6bcfe47763e85047acf7c398336dc84ff85ebcad0a7cb6f3b9d3e981245406" + "jar": "581b6908d46ed2df6b9bb8167df4c44deddc5c2567c430c73bca66326138f661", + "sources": "2aa7115e2f0562e3c50149e77d6fc41340b20d5111b737ea443e0b8bf53a75b3" }, - "version": "4.0.2" + "version": "1.6.6" }, - "net.bytebuddy:byte-buddy": { + "io.nats:jnats": { "shasums": { - "jar": "970636134d61c183b19f8f58fa631e30d2f2abca344b37848a393cac7863dd70" + "jar": "a5705531ff8fc96657d99d2fc4436178c04463df6a216ba6b5528a5d5228f240", + "sources": "40d6500a369ad57822074fa2a838367e4818ac61eba5c038c95b54772c4ef96f" }, - "version": "1.14.12" + "version": "2.23.0" }, - "net.bytebuddy:byte-buddy-agent": { + "io.netty:netty-buffer": { "shasums": { - "jar": "2b309a9300092e0b696f7c471fd51d9969001df784c8ab9f07997437d757ad6d" + "jar": "1361fd9c9ba85b9831cf54a1b2e45ddc3ce34a768931726c099d3f5ef0efe4a3", + "sources": "13259b636c4a91c0f34a8536d621178fba302f9ba61f3e4aaec3a17ca291dc2a" }, - "version": "1.14.12" + "version": "4.2.15.Final" }, - "net.minidev:accessors-smart": { + "io.netty:netty-codec-base": { + "shasums": { + "jar": "2c6d39d7270628b8cfc3166fbd7a93d595958e59c461bc32ddb383c8c91bf811", + "sources": "029bea433dfc710c640338954c7403cc7ac31cc5bf4c192904c837b0ebda5fb2" + }, + "version": "4.2.15.Final" + }, + "io.netty:netty-codec-classes-quic": { + "shasums": { + "jar": "ec1d5da906ea0139741cd8c570907c399df5995e48a2b07fe3155272e0be21a6", + "sources": "edfbbece45f21e8f214c0f08f33666ef09ae4580322ffa27020633b5fdee6250" + }, + "version": "4.2.15.Final" + }, + "io.netty:netty-codec-compression": { + "shasums": { + "jar": "4adaa5cdd4d2e9b50b23e4c3eff42e73de6eb40848718b3aa84cd5b454da6ce8", + "sources": "58d61aa4f781657f955a196f17e28fcd9b02e21658b996571e4604a2dadbd8af" + }, + "version": "4.2.15.Final" + }, + "io.netty:netty-codec-dns": { + "shasums": { + "jar": "c5e2c05b4faeba3cf374cc6af29d6f4a7e969d7ca222be958e0800c79a02ee4e", + "sources": "f10017539ab2816efee10b70b47b262c04b881fca685327769d576dd8b222b47" + }, + "version": "4.2.15.Final" + }, + "io.netty:netty-codec-http": { + "shasums": { + "jar": "76ae57e87af37b3c4107140f50b9244b1456163a6a225510838f7c5a4458ec22", + "sources": "76b25b4884e7bec78cd310415b068204e67da5a87162774abfad9a0e6c7a0e78" + }, + "version": "4.2.15.Final" + }, + "io.netty:netty-codec-http2": { + "shasums": { + "jar": "4a9b052bd815c765d9c05e27b32abbd32a2c7c3e3364ccb992ce71da1a26bc0d", + "sources": "01f8ad714e5abb989b0e782da9ec467068821299cd17d80c2db833a6472507f6" + }, + "version": "4.2.15.Final" + }, + "io.netty:netty-codec-http3": { + "shasums": { + "jar": "f2f96b7557317c1378dd8d84b4d9b1865db285c5e665b1220ea519e9f868929d", + "sources": "3a35802b80f536b5693d53d6dfbf4102e94edd0e7b26b3b8d8ab400a12d0f9e0" + }, + "version": "4.2.15.Final" + }, + "io.netty:netty-codec-native-quic": { + "shasums": { + "linux-aarch_64": "59a8e59aa9d3a886cd7fae30fb3d3a18ebee40fa9dc08b83eb88bee58cf46774", + "linux-x86_64": "850ed59fac6679b5e205bf53187b45dbaf2d105ee5f669440645d2acd89524ec", + "osx-aarch_64": "ce18ffeccef21adb3caca5534061c921ef750662f29028fdde62d65196d63a9d", + "osx-x86_64": "10999923bf08ff5e3a75106bd8dbc5b5e4d06b5e614f8c135981ecb4377c944a", + "sources": "d9772d6d7d0eb6fdf82ccaeb9558f88b5600c6f0696a724f5e067b67bdc049f3", + "windows-x86_64": "40adba6df605302bf98a4a462bb002c4697f60958cea7cfec38f0fab6331eb7a" + }, + "version": "4.2.15.Final" + }, + "io.netty:netty-codec-socks": { + "shasums": { + "jar": "4230d43481241e49382a83da2fa9d5980c5702668fc5aa5a980346ce03d30828", + "sources": "f126a6f94589b56b1c056a5dbbda4e76e4e749cec1591a3e878e4d6854144a43" + }, + "version": "4.2.15.Final" + }, + "io.netty:netty-common": { + "shasums": { + "jar": "78206aa7f6d197caa926291408c01889b6b910ca0f74017d3fcbdaccf9562959", + "sources": "de720a128534ba1072b354c863d671ab1999900a8fddaadc4a68ee74c2e33fd6" + }, + "version": "4.2.15.Final" + }, + "io.netty:netty-handler": { + "shasums": { + "jar": "99b59e4bea72220d2aed52df8baf37d97431b06ade0b135226cdf73168975eab", + "sources": "68306697d15d870e7bb29ab9ce725d121ac3170428e8a5d7a33767c5fb0ffa0e" + }, + "version": "4.2.15.Final" + }, + "io.netty:netty-handler-proxy": { + "shasums": { + "jar": "a06aaaaa1a2e6eb26824e53566bfd020ba930a9390d57a1f0bbf57316e357373", + "sources": "91fa7fc64aaa39982b30cc19974f80021090c91023d1d5f6a8e4179aa3a5056b" + }, + "version": "4.2.15.Final" + }, + "io.netty:netty-resolver": { + "shasums": { + "jar": "24318497f2a3a645964fed418f48879ba11365cab8e1f8d66c47fa7da15ef19a", + "sources": "c87b295c43265b34c749331e12ee667f7d43329338618bee2a08027b839220d2" + }, + "version": "4.2.15.Final" + }, + "io.netty:netty-resolver-dns": { + "shasums": { + "jar": "2e8dd2d861775483d689329ab2546045647c8f8bf5ae3ed2dd48eefbb748952d", + "sources": "4db838a89ec84479476e71956d59c67a957779dc5f9781f1a9964f9a6b607c29" + }, + "version": "4.2.15.Final" + }, + "io.netty:netty-resolver-dns-classes-macos": { + "shasums": { + "jar": "dc91d81e0f6c8987ed34f6ca005b4b97cc6032f933a49f7500940e846ade7c43", + "sources": "00067617cc0927d804091590f9ea5041e39c4f907357e3c969c45078fe10be3d" + }, + "version": "4.2.15.Final" + }, + "io.netty:netty-resolver-dns-native-macos": { + "shasums": { + "osx-x86_64": "a1115fb0fd88fe50209fbd645cbfe09e41fe2d264bb1854dc545f44a1bd020cf", + "sources": "87a85f239a73c9a4e7dde8ca31e0a8dea711b3b5e3cff9df18b8c07f31f5dc50" + }, + "version": "4.2.15.Final" + }, + "io.netty:netty-transport": { + "shasums": { + "jar": "9fb671e96651066cf1a28dad3f1382c7c99df5b8326d4a214d9a375b311f8dc1", + "sources": "e17c0a4c7324a93fa01f00bc6ddd2bf2f6eb94702ad8de7af777dec87f89eaf8" + }, + "version": "4.2.15.Final" + }, + "io.netty:netty-transport-classes-epoll": { + "shasums": { + "jar": "be509407fd71a4b83378567c613420db544d659bb522df7b253a1dbe8ca297fe", + "sources": "bb095191a070714f769bff1dd0e5d2496305d10bd566bdc16dbca5773df48b4e" + }, + "version": "4.2.15.Final" + }, + "io.netty:netty-transport-native-epoll": { + "shasums": { + "linux-x86_64": "5440e38f5ca2858fee8d7898e65291e05b665736c9e8a14c832e707511f5dba6", + "sources": "d48e8de50b0994842bec4e65725a7f8e9734144dd4f08b78ffe5396a70ab06b6" + }, + "version": "4.2.15.Final" + }, + "io.netty:netty-transport-native-unix-common": { + "shasums": { + "jar": "b9ea9fc5ad5eba04e99afdff75b2eb97b5ddfb2ad772e2f2ef5d8f277f3cbd76", + "sources": "2b82c54d1b2b9908ec0104664edf58ded4848ae5b34266ee1cf90707efc420ab" + }, + "version": "4.2.15.Final" + }, + "io.opentelemetry.semconv:opentelemetry-semconv": { + "shasums": { + "jar": "693ad6f04f29b4b593a04adef5f575d28b3a91ea3449ab5b1e1e2e5c6efc6cdc", + "sources": "186f9e009d914ebe31f5994d42ff67c5dba8e0893569a4f0f0eeb7958b956d4f" + }, + "version": "1.37.0" + }, + "io.opentelemetry:opentelemetry-api": { + "shasums": { + "jar": "387b4bf98631fc2ede9470879a8ff28dd8c5cb2d3dcf5b6ef77f5ee2bdb7b4f1", + "sources": "3abc57abcaaff4521a5fb2b89593031da5feca24aa54816ec58a8d08ab71fd4a" + }, + "version": "1.55.0" + }, + "io.opentelemetry:opentelemetry-common": { + "shasums": { + "jar": "fca14bb87309d1193347d179b91c63045fa05a856f1fbeec6ef61c4a7f81b227", + "sources": "4219708637e60172c347e48b677b71f5ad97ab8e4850cfda2f85ed59da74e250" + }, + "version": "1.55.0" + }, + "io.opentelemetry:opentelemetry-context": { + "shasums": { + "jar": "f34a4718b70446ec462703ced5cc2c9ad4c9b7c69dcb41d0f032ba51c625a3dd", + "sources": "e0b63b17ccb407f6208a89a180ad48c6add5cca05d29047e858a6ebe8442b3fb" + }, + "version": "1.55.0" + }, + "io.opentelemetry:opentelemetry-exporter-common": { + "shasums": { + "jar": "3b4faaae49dd87bdefae13528d069a271f8476e2a64420e32f4e959a8d09c132", + "sources": "b795169dce773a549f6f3ce2202886ad93622306f2991bd5771547e3c649a2dc" + }, + "version": "1.55.0" + }, + "io.opentelemetry:opentelemetry-exporter-otlp": { + "shasums": { + "jar": "cf42ce6e7cc6755182149f269447f6677f69c0a31e101464594dbcf62fe874c3", + "sources": "a94ebfb492577503d49d43423328f5d0042206f309a18407386a4ab5375bcc82" + }, + "version": "1.55.0" + }, + "io.opentelemetry:opentelemetry-exporter-otlp-common": { + "shasums": { + "jar": "7ddfa417d88a5ff8626dad9ebe0c8c59776491478ab1f72aedfacd75b984931f", + "sources": "7b36e1080bbb2c4dbc7ce009393c568e3d968328f0e5891beb1e45ebd894337a" + }, + "version": "1.55.0" + }, + "io.opentelemetry:opentelemetry-exporter-sender-okhttp": { + "shasums": { + "jar": "e9cd97f59b18a89772ececbebccd773740eec2cd3ff67a4542eff73738acd60f", + "sources": "55f2f24814971e7b476db3713ec82f29285b679fbcaa434c481863f503e2a6e2" + }, + "version": "1.55.0" + }, + "io.opentelemetry:opentelemetry-extension-trace-propagators": { + "shasums": { + "jar": "27861a2b49b3acf3166f489426808032c3addd9c082999f74a110eb7ac6985ce", + "sources": "784afdd2a2a6fca26f8bf237b8aae7f6bfcb44c65d141901a110b4ff769bee0c" + }, + "version": "1.55.0" + }, + "io.opentelemetry:opentelemetry-sdk": { + "shasums": { + "jar": "d63231ea6fd33e0457c776a9aa8ae7ba778379b03119228ec56a5c8c16f9480e", + "sources": "3685819f7d9efb6761d35ecf5b6053dfc1d0161835da744b0fdb2c4d89c87a42" + }, + "version": "1.55.0" + }, + "io.opentelemetry:opentelemetry-sdk-common": { + "shasums": { + "jar": "e28bb83d0ae5a760ba95952daf820180ee7defb0c5783c9d21f9c00ada80020e", + "sources": "cb9423298f9c42ac2c568af5271d335e468d7730b21d7ca6c246c16c6eca25f7" + }, + "version": "1.55.0" + }, + "io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi": { + "shasums": { + "jar": "3ee1f647238bb77df7b7bde47bc87a35a7807b3c5bb389ca81b0ba16c77824ff", + "sources": "71760407f00ba82b762335ff2c3470daf3d5ff1c6a798eb9ae52a24c7772f418" + }, + "version": "1.55.0" + }, + "io.opentelemetry:opentelemetry-sdk-logs": { + "shasums": { + "jar": "d917bb833899057c7cbdbcc30290e816071b90e52c965693aaad4cc1b32ecc11", + "sources": "edb0008c29edf69671865945a1c47d1944c1f498ef7e895f7b21b916fea2f14c" + }, + "version": "1.55.0" + }, + "io.opentelemetry:opentelemetry-sdk-metrics": { + "shasums": { + "jar": "6219d69c3abdd6113bee35df94826f48e84b742041f5124b5857a2b801f74573", + "sources": "5f9c8dab78339e402caa7f7aa4616b6b5c8e2d50201335c73e23bd21aba147fd" + }, + "version": "1.55.0" + }, + "io.opentelemetry:opentelemetry-sdk-testing": { + "shasums": { + "jar": "9e257ac45834e5d184abdc238b5ee146a74a259d940c57722999ef400cdc2232", + "sources": "b40c580007e26d721d406e0b85385e87f6e2e8c36bea55adfd8837d77f57ccee" + }, + "version": "1.55.0" + }, + "io.opentelemetry:opentelemetry-sdk-trace": { + "shasums": { + "jar": "e593660b9fad8bfdb5e981ccd392aa030f788d577c55f8bac3b6c4c6dae509bb", + "sources": "2f374e9b5f2e7e33157bd6fc97eb5c629d0dac0f22a975075b52714916dfc6c5" + }, + "version": "1.55.0" + }, + "io.projectreactor.netty:reactor-netty-core": { + "shasums": { + "jar": "b8aaddd6025af42c2d810a76dc5b9a7717f0fde84beb93d6d4a778dac25fc287", + "sources": "061136ccc1bc3bed6938cea77a7343dc47de275aad4851b3407fb7ac15854121" + }, + "version": "1.3.6" + }, + "io.projectreactor.netty:reactor-netty-http": { + "shasums": { + "jar": "0ffdcaafe718327ca4687dd41c21318a267e93cea7b43f42db6f3fb3175256a5", + "sources": "712c12723e57d68e909ce9221cb12e04be105435ca518d13f63075af81cbaa44" + }, + "version": "1.3.6" + }, + "io.projectreactor:reactor-core": { + "shasums": { + "jar": "ffc646b225465efce55d3ea350f1bcd1d27d4a56e912323e316dd8e6d04bff11", + "sources": "d6180835f642a1f6d8b330fac98876488994187a89c3df48b369f37344ea6845" + }, + "version": "3.8.6" + }, + "io.projectreactor:reactor-test": { + "shasums": { + "jar": "27acda356a59c19bc3db033f1ed4cff9a6469da6caf424a4004ffaabbfe24d2a", + "sources": "882b7654b056b77f721a03a804e4830030f072a2f4afb92638d6925568d6d930" + }, + "version": "3.8.6" + }, + "io.swagger.core.v3:swagger-annotations-jakarta": { + "shasums": { + "jar": "9b30b319f1c31993e6128d22e12652be9a688589e30c67819ed84e95b96e7f88", + "sources": "b9324b72ab6c498fad2d766f5074b1b24be0cf2c1e55f6c937b4b8345ca90bf9" + }, + "version": "2.2.47" + }, + "io.swagger.core.v3:swagger-core-jakarta": { + "shasums": { + "jar": "e63b78c1e5b049e6670ca9bb43c3f35cf362ecc08ac59a783994a6a732a7d7eb", + "sources": "55b6e465caf14b019ebe4ab9d886915afd1a4796c6c790ffdd4789b36997f556" + }, + "version": "2.2.47" + }, + "io.swagger.core.v3:swagger-models-jakarta": { + "shasums": { + "jar": "15d10f4f7eac1e02a8ff940b430c634fc9dfe08368a54dffa3216cc2dc811aa2", + "sources": "4770b07fcb362a2c1b289fd255d5bf9ec11619bc0cbfcfc07e49599f24fef753" + }, + "version": "2.2.47" + }, + "jakarta.activation:jakarta.activation-api": { + "shasums": { + "jar": "c9db52100ce6c8aac95cc39075f95720d2e561b11f8051b81c121ad4effd7004", + "sources": "2aa5a3ba55059b778a3b467269404d7ac3b9485ed4a23ad26d2e63aa769ec35f" + }, + "version": "2.1.4" + }, + "jakarta.annotation:jakarta.annotation-api": { + "shasums": { + "jar": "b01f55552284cfb149411e64eabca75e942d26d2e1786b32914250e4330afaa2", + "sources": "142dfd2343429df2aac3e1cfacfacc21c8393112c0e0280d3628648d9b612470" + }, + "version": "3.0.0" + }, + "jakarta.servlet:jakarta.servlet-api": { + "shasums": { + "jar": "8a31f465f3593bf2351531a5c952014eb839da96a605b5825b93dd54714c48c4", + "sources": "6eb958543e0548bb93d2519e40224d13c8003b10cc615b5652bfd9899350bfb4" + }, + "version": "6.1.0" + }, + "jakarta.validation:jakarta.validation-api": { + "shasums": { + "jar": "63ce00156388c365f3ac1be71fcfaf114682fc0c452020b5df6e7ec236e142ab", + "sources": "941cc2028fe8f5e6b912954bca360ed347ace453aca4f8e2fd58dc3845a0c792" + }, + "version": "3.1.1" + }, + "jakarta.xml.bind:jakarta.xml.bind-api": { + "shasums": { + "jar": "5e489b6c874c4119e003ff1403db523ee3a8959ec499f3de29e77245efccf216", + "sources": "5bcf811e6719582ab2be21c84bc48f963ba377dfe1dae5ecb2673c1efa00e422" + }, + "version": "4.0.5" + }, + "net.bytebuddy:byte-buddy": { + "shasums": { + "jar": "2b5ddc8c1f4234bdb7cb45338a8e10a13e0e3ca473e91d5d821d681127ea8ba1", + "sources": "5211140f46c380a5e9630a86b2c07b396a9c374128fa603ca1d321b3c7da7449" + }, + "version": "1.17.8" + }, + "net.bytebuddy:byte-buddy-agent": { + "shasums": { + "jar": "5b17113e66e77ca6f8af07ff367c216df964a980f0e9f11e3b1aa793253aa64f", + "sources": "a205437d772e3edaf4a7a1a709e9d59c666e8a05218d0e000deb5651c52de2d2" + }, + "version": "1.17.8" + }, + "net.java.dev.jna:jna": { + "shasums": { + "jar": "260c4b1e22b1db9e110ee441c4f13ce115f841fa48c41d78750986214b395557", + "sources": "0b9224e215b3c6a464959e3f994ddd64c14d46fb4014facd6afa1cc18e469466" + }, + "version": "5.18.1" + }, + "net.minidev:accessors-smart": { + "shasums": { + "jar": "222c9f547bb20a99fc486403a398352d1306fb671b38abd7ecab6401df170e61", + "sources": "10880e44ed732de27ae424a9698a041c398102098b5b1bae3cc597ec62dac43e" + }, + "version": "2.6.0" + }, + "net.minidev:json-smart": { + "shasums": { + "jar": "1ae4b561458afb540be8ec5c6dbb4f2e715a319a7ae64854998aaf924770d61b", + "sources": "a4af3f3773286fe3f76f94d38d977611fd2493685f589466b76dd25cf70b400c" + }, + "version": "2.6.0" + }, + "org.apache.cassandra:java-driver-core": { + "shasums": { + "jar": "6276a8e25c8821eeaa5d2d67c50b81615d551dd7ec943302689996f6bdb2add1", + "sources": "80a95f2d4d61091a8f54bf76f2d26269ee7b8ac82e261314108d7ad83c083838" + }, + "version": "4.19.3" + }, + "org.apache.cassandra:java-driver-guava-shaded": { + "shasums": { + "jar": "fa5e6dfb61a987e69cd8559464145b6ceb6610d28760b0d8cff17eba052c8264", + "sources": "9415d1dd6132671cefc43eb5ac560f34d5e4a1b73ac25310fbfc4584a8033ec3" + }, + "version": "4.19.3" + }, + "org.apache.cassandra:java-driver-metrics-micrometer": { + "shasums": { + "jar": "4f61ed8dabc978f7c9bfdae578915480b6242468feea1c73571be7d832ee3d0a", + "sources": "6214d824dc8e72fd6221165b1497f4c46099027b7ad220ce17f44a8f3f58807f" + }, + "version": "4.19.3" + }, + "org.apache.cassandra:java-driver-query-builder": { + "shasums": { + "jar": "b6e4c84dff2448aaa67f5e3e867b1bf284ca6ad0c15701a4d4d7283384d8df12", + "sources": "dd63fa1af436d1b01155fdca72563338007e265fd8c05c3a8b2e80e76b2d4aa8" + }, + "version": "4.19.3" + }, + "org.apache.commons:commons-compress": { + "shasums": { + "jar": "e1522945218456f3649a39bc4afd70ce4bd466221519dba7d378f2141a4642ca", + "sources": "6de9de4559f12bba6d41789c72f6a2a424514f2d2a3f7f49e2a3c52414db9632" + }, + "version": "1.28.0" + }, + "org.apache.commons:commons-lang3": { + "shasums": { + "jar": "69e5c9fa35da7a51a5fd2099dfe56a2d8d32cf233e2f6d770e796146440263f4", + "sources": "eec245e820ec2800a1780cf756aefb427c1c6170e06902e67ac15b6910ce6335" + }, + "version": "3.20.0" + }, + "org.apache.logging.log4j:log4j-api": { + "shasums": { + "jar": "c4b642a7f047275215de117e0e3847eb2c7711d84a0aa7433e7b3c096daf341d", + "sources": "b86680bcf8ffa25897b6114cae508bb8e6ecac8081a0fc8985e0c00e27d4f4ec" + }, + "version": "2.25.4" + }, + "org.apache.logging.log4j:log4j-to-slf4j": { + "shasums": { + "jar": "d7b78fc0aaaa5e8ada388b29d718b0ab187e512965bed0b259bb4ab299f13db2", + "sources": "ca9159da173bf88fb621adf745fe1d313ba4074f4c66576ae70f5dd99eab89c9" + }, + "version": "2.25.4" + }, + "org.apache.tomcat.embed:tomcat-embed-core": { + "shasums": { + "jar": "78cd7cd7c104b6b87142c1b0bd902e1ce005b0245c3cefa8a06759148947200b", + "sources": "0bfbdc27e60d4db5b83e0f51193bae5f4bd02ac270fd78b06945e219fc473359" + }, + "version": "11.0.22" + }, + "org.apache.tomcat.embed:tomcat-embed-el": { + "shasums": { + "jar": "1b34c33b858c141df36c501b4d809e68036c406bca3671a86facae297917c7de", + "sources": "da3724004575f5c8fa7e45649f2900ec53e7ecfb502b6ce227ca9cf86b36a156" + }, + "version": "11.0.22" + }, + "org.apache.tomcat.embed:tomcat-embed-websocket": { + "shasums": { + "jar": "210e0c7ab194a76cc7283df0be365276091b042369dae125fb477828ba67e922", + "sources": "711f09af528ac5af172c664244bcba4748eac00811ef7c6b52ddd6836b5a4a28" + }, + "version": "11.0.22" + }, + "org.apiguardian:apiguardian-api": { + "shasums": { + "jar": "b509448ac506d607319f182537f0b35d71007582ec741832a1f111e5b5b70b38", + "sources": "277a7a4315412817beb6655b324dc7276621e95ebff00b8bf65e17a27b685e2d" + }, + "version": "1.1.2" + }, + "org.assertj:assertj-core": { + "shasums": { + "jar": "c4a445426c3c2861666863b842cc4ec7bbb1c4226fefd370b6d2fe83d6c4ff0f", + "sources": "5ba6de05730cf76021001f8437f35db4cb5b513465d4ace8c3a6fcd68d9a19ee" + }, + "version": "3.27.7" + }, + "org.awaitility:awaitility": { + "shasums": { + "jar": "ee58568ea5945dcf988551501655183dc184e23e45a8e013fdfd9036194e6f7b", + "sources": "92d209bd0135b04ca6bb7689c6b921819896ec2d517a5357760f598faafee46c" + }, + "version": "4.3.0" + }, + "org.bouncycastle:bcprov-jdk18on": { + "shasums": { + "jar": "64d6c5a6121fcd927152dd182cbed39afe0fda641a970d9bcc0c9cb1858b2731", + "sources": "e5f04550f7740e588edcbd1654c59277cd7ee8725d8b674e44f7f8f4b9c5674a" + }, + "version": "1.84" + }, + "org.bouncycastle:bcprov-lts8on": { + "shasums": { + "jar": "492049b928f8baab535af0185bbab8734d14e1c7648ae2a2037d58486cafb676", + "sources": "d4447a4f412b328f3e43595ab99603dd38b08ee1db9f1ea341e6158eeb86a70d" + }, + "version": "2.73.8" + }, + "org.hamcrest:hamcrest": { + "shasums": { + "jar": "5d66b6a4a680755cb6ed7cb104fa7835ef644667586ff0737adeb977c39ecdbc", + "sources": "7a4050b1898f7e1aa395cf2be78fb6683f9e2766fcb8e1507926b204fa24d1bf" + }, + "version": "3.0" + }, + "org.hdrhistogram:HdrHistogram": { + "shasums": { + "jar": "22d1d4316c4ec13a68b559e98c8256d69071593731da96136640f864fa14fad8", + "sources": "d3933c83a764994930f4477d4199539eaf413b42e32127ec2b68c61d711ac1a9" + }, + "version": "2.2.2" + }, + "org.hibernate.validator:hibernate-validator": { + "shasums": { + "jar": "25f40118fa4c50f8522d090d25d52d5a38953b0ccd1250835f052e7bd3164ce0", + "sources": "db6a3d49eceaae0a880de8749cd7f7e8928c18458b44fac84633c3ee8db7ac0d" + }, + "version": "9.0.1.Final" + }, + "org.jacoco:org.jacoco.agent": { + "shasums": { + "runtime": "3fb76eea65f81bd9415202bab34b6571728841dff1ab8e6bbe81adc2e299face", + "sources": "8a643b749deb255d7a42c445c3053c9ec263e27103becdaaf88cedda44357255" + }, + "version": "0.8.14" + }, + "org.jacoco:org.jacoco.cli": { + "shasums": { + "jar": "12d5d78351c638efeea71ac840f6a5cf7bcff89001ddb05eb89f956d1079a2c6", + "sources": "52f715f15b890960f70f4ae8fbdd4bca70eba59c0283b37af2d79ad9215d2e37" + }, + "version": "0.8.14" + }, + "org.jacoco:org.jacoco.core": { + "shasums": { + "jar": "28abbf0eea5a08e4f24097f2fbac663ca17c341c25c3a04d90d6cd325943c995", + "sources": "1550fd5081ecd2c2ad053994c23d91a4cef6dcbed4c4dac95130e7d75fa9cd3c" + }, + "version": "0.8.14" + }, + "org.jacoco:org.jacoco.report": { + "shasums": { + "jar": "a3e2026060ab8b8d5c650706406234bb4c033dfd5376afeb8b1666e8ed27c453", + "sources": "80ac2fac212d6a4583b7f025dc7b602f384a9dfcdbccd0e9cb78c415e9f5ffc6" + }, + "version": "0.8.14" + }, + "org.jboss.logging:jboss-logging": { + "shasums": { + "jar": "7c12ee575508f81e22b1db9334b969d0ec54ef0fd1dcba24eeab1a44235fc366", + "sources": "e8a2b9aaac82b0082aa040e6bdf6cd7f225cdbc75d2bd1a79fa7b17007ec1b15" + }, + "version": "3.6.3.Final" + }, + "org.jetbrains.kotlin:kotlin-stdlib": { + "shasums": { + "jar": "6558a3d233da56a20934b32159f9db5f86ed5816ef098f78a2c223dc6abb79dd", + "sources": "664f515359444a92267a13266101431a630f99d6b8d04407a92b1a0e558d33ee" + }, + "version": "2.2.21" + }, + "org.jetbrains:annotations": { + "shasums": { + "jar": "195fb0da046d55bb042e91543484cf1da68b02bb7afbfe031f229e45ac84b3f2", + "sources": "b2c0d02e0a32c56d359e99634e7d769f9b1a8cd6e25061995abad1c1baf86f56" + }, + "version": "17.0.0" + }, + "org.jspecify:jspecify": { + "shasums": { + "jar": "1fad6e6be7557781e4d33729d49ae1cdc8fdda6fe477bb0cc68ce351eafdfbab", + "sources": "adf0898191d55937fb3192ba971826f4f294292c4a960740f3c27310e7b70296" + }, + "version": "1.0.0" + }, + "org.junit.jupiter:junit-jupiter": { + "shasums": { + "jar": "784b65815f479a0c99a9d3a573b142e2a525efb6025d97f751b19e72f90aeda3", + "sources": "402f86a4fdce930bb68b592f2ed5a9d57dc4d1b2747aa2d66c0b3cb7a2adceeb" + }, + "version": "6.0.3" + }, + "org.junit.jupiter:junit-jupiter-api": { + "shasums": { + "jar": "d655d7e6f0c7ae07f10a2f3bbaaebb6d30e9b26204a068ad9e9b3950aa28792c", + "sources": "8266d8da51d0c7d6a2ed4895a3cb76e0ec22194907f24364f2a4f3efd05f00a5" + }, + "version": "6.0.3" + }, + "org.junit.jupiter:junit-jupiter-engine": { + "shasums": { + "jar": "1e2fab61ad27ea08fc7c70dd9677cf8c6d1ae5434d42dcfdd633b12c7e7c04d0", + "sources": "35706a9edf1b580bf22d6eca9d6a9165f478f75b0108840ad6f31481cd662d6c" + }, + "version": "6.0.3" + }, + "org.junit.jupiter:junit-jupiter-params": { + "shasums": { + "jar": "cf2947e2302b9f8c8a059259a277881c1cadae8fbc2514c16a925cfeb7beb2e5", + "sources": "f0dcf217100b06b98cd36c2e74fb919c74e3024faad868bac9eba6ff00c7c263" + }, + "version": "6.0.3" + }, + "org.junit.platform:junit-platform-commons": { + "shasums": { + "jar": "39f262d09c3d52719fe0b77f080e90a3695e285d779a41b232e17963ae5da200", + "sources": "06cbe4a4bbd79339c3817983510cf3adc3d6a88a8a556c80c5b84eeb1ebd13dc" + }, + "version": "6.0.3" + }, + "org.junit.platform:junit-platform-console-standalone": { + "shasums": { + "jar": "3ba0d6150af79214a1411f9ea2fbef864eef68b68c89a17f672c0b89bff9d3a2", + "sources": "c4e79459727c6fe6afbddcc04daa67767fb73d7044d29846dd4ab90de1d0fa0c" + }, + "version": "6.0.3" + }, + "org.junit.platform:junit-platform-engine": { + "shasums": { + "jar": "491e9e4f745f161b8a8e4186a1a7c6a450ea12c70930c9aedae427215301d947", + "sources": "89a893566a97557dbbcb0706d9b5565c4e7a402050841753dc527f3e9139c9ac" + }, + "version": "6.0.3" + }, + "org.junit.platform:junit-platform-launcher": { + "shasums": { + "jar": "315608372e4dc44bca0ccb3ae8a07ecc206b3367033fa05748a03ccd563f1301", + "sources": "4fcbdb81cc882c0c3e73f742878db5884d3d2f86facc08369ca953e32796d144" + }, + "version": "6.0.3" + }, + "org.junit.platform:junit-platform-reporting": { + "shasums": { + "jar": "f19c5be871c37ebed493f3152b67a909f89a44dff67c2db8016dfe34167d5730", + "sources": "bbdda2b9c002dbd1b05cfec144bab91e8cca46ed8d58525f51b547f3a0a84a7d" + }, + "version": "6.0.3" + }, + "org.latencyutils:LatencyUtils": { + "shasums": { + "jar": "a32a9ffa06b2f4e01c5360f8f9df7bc5d9454a5d373cd8f361347fa5a57165ec", + "sources": "717e271b5d67c190afba092795d79bba496434256aca7151cf6a02f83564e724" + }, + "version": "2.0.3" + }, + "org.mockito:mockito-core": { + "shasums": { + "jar": "d1a96d252128d3a4247cfd8a2e76412efa3cc103977be17933c942117a24f374", + "sources": "dcde489c1db6449c162f05105ace4c172850d3dc7f6633bb4f54d47a16a0fd1b" + }, + "version": "5.20.0" + }, + "org.mockito:mockito-junit-jupiter": { + "shasums": { + "jar": "fd6c703c2b00b914f3adbc27b18077a708f3d6992f19242c444e737c6cce024e", + "sources": "3c199479a2319db4395fd9fd209946ecc4530f48fe5cdeed7677c285bf70fbb5" + }, + "version": "5.20.0" + }, + "org.objenesis:objenesis": { + "shasums": { + "jar": "02dfd0b0439a5591e35b708ed2f5474eb0948f53abf74637e959b8e4ef69bfeb", + "sources": "d06164f8ca002c8ef193cef2d682822014dd330505616af93a3fb64226fc131d" + }, + "version": "3.3" + }, + "org.opentest4j.reporting:open-test-reporting-tooling-spi": { + "shasums": { + "jar": "04ac4ecfcaf60abe0e6d5b18e8306320aec7cd9cbf15e59eeac54fa9faa16902", + "sources": "efe9672e74111726c0ff85a2508a0bc78be611a7cbe460560dc7cd429ee0a4ce" + }, + "version": "0.2.4" + }, + "org.opentest4j:opentest4j": { + "shasums": { + "jar": "48e2df636cab6563ced64dcdff8abb2355627cb236ef0bf37598682ddf742f1b", + "sources": "724a24e3a68267d5ebac9411389a15638a71e50c62448ffa58f59c34d5c1ebb2" + }, + "version": "1.3.0" + }, + "org.ow2.asm:asm": { + "shasums": { + "jar": "03d99a74ad1ee5c71334ef67437f4ef4fe3488caa7c96d8645abc73c8e2017d4", + "sources": "e37000a2a0bc9f0bef373714ad7dde4082212351847b74618d483057a4ae186c" + }, + "version": "9.9" + }, + "org.ow2.asm:asm-analysis": { + "shasums": { + "jar": "6a15d28e8bd29ba4fd5bca4baf9b50e8fba2d7b51fbf78cfa0c875a7214c678b", + "sources": "ff731d401ea2407759ea19b4b025800d32495a51a912f2553d987cddda424773" + }, + "version": "9.9" + }, + "org.ow2.asm:asm-commons": { + "shasums": { + "jar": "db2f6f26150bbe7c126606b4a1151836bcc22a1e05a423b3585698bece995ff8", + "sources": "218bbb648e24578a385cb6b6a21ceff222a2a8f8b2d5f6a256a8099dd336dc76" + }, + "version": "9.9" + }, + "org.ow2.asm:asm-tree": { + "shasums": { + "jar": "42178f3775c9c63f9e5e1446747d29b4eca4d91bd6e75e5c43cfa372a47d38c6", + "sources": "9d1fe261fa1d29904ca9dbc76878396e76bc225191676a8c16ad2669a205321a" + }, + "version": "9.9" + }, + "org.ow2.asm:asm-util": { + "shasums": { + "jar": "3842e13cfe324ee9ab7cdc4914be9943541ead397c17e26daf0b8a755bede717", + "sources": "e518a00b1d004832e72c6448351c4865971ac95a7cfe78fb0315d76acb393a46" + }, + "version": "9.9" + }, + "org.projectlombok:lombok": { + "shasums": { + "jar": "01f7b1a015e33e2b62d5f5f37053306357ab1415fd181fcba7794f5d198c1126", + "sources": "5b78c305a65fbe257d57878bff6530e2b79e1a2cd660f45dcb7d7f8f5e56a483" + }, + "version": "1.18.46" + }, + "org.reactivestreams:reactive-streams": { + "shasums": { + "jar": "f75ca597789b3dac58f61857b9ac2e1034a68fa672db35055a8fb4509e325f28", + "sources": "5a7a36ae9536698c434ebe119feb374d721210fee68eb821a37ef3859b64b708" + }, + "version": "1.0.4" + }, + "org.rnorth.duct-tape:duct-tape": { + "shasums": { + "jar": "31cef12ddec979d1f86d7cf708c41a17da523d05c685fd6642e9d0b2addb7240", + "sources": "b385fd2c2b435c313b3f02988d351503230c9631bfb432261cbd8ce9765d2a26" + }, + "version": "1.0.8" + }, + "org.skyscreamer:jsonassert": { + "shasums": { + "jar": "719095c07d4203961320da593441d8b3b643c18eb1d81aa98ea933bb7eb351ba", + "sources": "a825c29f8cc40f85ea4e7a431a55d4278a785c34acdeef4cd1be7367f70ea6bb" + }, + "version": "1.5.3" + }, + "org.slf4j:jul-to-slf4j": { + "shasums": { + "jar": "cbb7d1aaaa9e871eb1a06594abd911bf97027152976edf1edc315be75239204e", + "sources": "b6dd2138f3e83d876bf07206d09f2b48ca655bc36a8e80b0c2a32ad19ab8f1d5" + }, + "version": "2.0.18" + }, + "org.slf4j:slf4j-api": { + "shasums": { + "jar": "44508fd1576500688c790b190acdd16fec4f8c79a3e0b900afd70503cf055f55", + "sources": "192e007cf7f2be41d40574e44521fc0b7ce55e01f13dbe0fa8707c8ae3329075" + }, + "version": "2.0.18" + }, + "org.springdoc:springdoc-openapi-starter-common": { + "shasums": { + "jar": "a0b5a6a5384b4a36718fd7a69efbd48311fd5f404c96ce286a6c285c5d0038e6", + "sources": "19a2612f56ea7d867c70cdae5e8fdf9d2b7a3cbdcba257c1d9ddfe88e48a74b0" + }, + "version": "3.0.3" + }, + "org.springdoc:springdoc-openapi-starter-webflux-api": { + "shasums": { + "jar": "a58f130aa60c47001e0d7b6b5e611edbbf187f58aa7d697dd720ac220f57b384", + "sources": "90968a39c963a144ee30248146dff38eb568edf57109ccddb843d6a92c1ef756" + }, + "version": "3.0.3" + }, + "org.springdoc:springdoc-openapi-starter-webmvc-api": { + "shasums": { + "jar": "c7e0221797240037eaf20c834f2d03fabec3f26051bd7052be8bf169f2c02876", + "sources": "e0fad37b01fefa5b52aec1cc09101017a397ae93f7ba8dace72ed29d1f2a3106" + }, + "version": "3.0.3" + }, + "org.springframework.boot:spring-boot": { + "shasums": { + "jar": "edb984d0bc80d209bed271e6d334fbfbfcf11cc4c1ef5059adc31c5bde7b1d7c", + "sources": "850ec7c91b0ff92c46797e0adcf1f47369d64a0cf4a8427d01b4834df129d0d4" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-actuator": { + "shasums": { + "jar": "edfedbcc5d33c8b0a8d0156dcc8cbf6234a33dc861001c7ec8ef63af19197b7e", + "sources": "d74a19a07e1eaf70bfe00b33332c3d61d82621e887b1a8af3b4dbb05051ca197" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-actuator-autoconfigure": { + "shasums": { + "jar": "59b188a313fda88bbe6cba613dd13aa00d1cc49135b11120ae5d451f72d49c00", + "sources": "c4aa275f1a4c97020d56d96cdb5f1b9a0b8157da06b62313c55e2d861e683c59" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-autoconfigure": { + "shasums": { + "jar": "1e8a953294fe76727965970de31e3bb08fba2b65e3ca0ee9b8bca492d0818d10", + "sources": "477e9834d7982b080b485c7baeb27ea399fca324d0442dc2f694a473b6a394fd" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-cassandra": { + "shasums": { + "jar": "8a69a41383b36eed9b58b462c698cf7ec825ea0ac3b131ce7c2f92419dbc3bc7", + "sources": "e580260c129670d80a96c84c2241d5cbad3d9f9a183d3af201b2b5acab96e3cd" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-data-cassandra": { + "shasums": { + "jar": "f17d6ed4fe4825abb68ec4743a3248d9e456c94e099e2653c392ee75cef4b8a6", + "sources": "d2fcf562626d79bc13f71284c2a9b820975ce0bd35e9e9daeef8deb05fb14e53" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-data-cassandra-test": { + "shasums": { + "jar": "5ad17d2ffcd040eae3f425e7588e2b2604461b47ac7801c5ccfb5179e6725832", + "sources": "73ca527b7a6d67b0e8a20865d91f0bb15092ef3415c7ebefe475bc14894440da" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-data-commons": { + "shasums": { + "jar": "991f28c95c3b5687d41429ec1da258d6ebdf33c1778dab2057361df9983dc6c7", + "sources": "bbfa0f3571ee162c21597e7fc7a925a5919e1267ebb55eb5b3698c2832de9ec0" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-health": { + "shasums": { + "jar": "e1c81e0e8d89ba7d25a3522d52159a4bcfa9bf7ee4e0d53cef61244e75a244bb", + "sources": "0d664e89d03dffde004f79e9aa5a7482128d0358c284edb0b5c2895eb25a43b7" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-http-client": { + "shasums": { + "jar": "7881d09b33f278b39fd1018d0cb7d91b73b72406d2350351166511b1c3a76c6b", + "sources": "17ee08d609a1d4d1d64198b9cf0cf79920974400460022b4b97117d22b7ea73a" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-http-codec": { + "shasums": { + "jar": "dd3e79f5bf872e34eca182f3a6f050278263562da042a1a0bc338f5d70d4ab60", + "sources": "4f2231f549e7353f3095b58995520bcb073d4c155aadc839f3f66d1b6543059e" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-http-converter": { + "shasums": { + "jar": "9f620c36cb98103ef664a9fccad4173ae20120d1abc790b795130be4e02dbb90", + "sources": "9f4378bcb3caa6696d8fe340c519bd673287f2c046098607cbe079ba9aa98874" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-jackson": { + "shasums": { + "jar": "0cdbadd10bcc3b23d32dffd419caceda4a4ed6b3da0262617f543732e9105ece", + "sources": "efe6e9f38861e94fbf36045bb23d23076407d26fed5752efb852ad2accaea3bb" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-micrometer-metrics": { + "shasums": { + "jar": "1cd20b112104d244fdc96b44b3a8193e0916686a819a6be93127c1c7eefc1c9d", + "sources": "b61f894a893ef3f3e0b978e7430f08c6b3580c19ac963bbbf8901a28b2d18f6b" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-micrometer-metrics-test": { + "shasums": { + "jar": "124e88e2d8ef0331653bb9d2c849143e210e97d3ded4e8cbdb0bd68c05ea2e65", + "sources": "b17d0251cee5454db0d3e573a8c23d6b0055de534fa49b75c18bc0af6ea9f100" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-micrometer-observation": { + "shasums": { + "jar": "aced85ab6f7a2a8b15d8bd52c2b75dfe47c9890dc08d39e24b2e7b78f79b6ac2", + "sources": "8e159976cbcf123f3333793f47e88c71932ff6f2f785db8c69831bfbcf75bc3b" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-micrometer-tracing": { + "shasums": { + "jar": "dfb60c9422c80957a020e8751317bc3515f3ed56d5d92da685bfece6c0ba780a", + "sources": "c0d8f28ffbd343b00586195ee8fd34d2441562c67f01cc100578f62145e51846" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-micrometer-tracing-opentelemetry": { + "shasums": { + "jar": "3463de0849ee471c99726b8c9a334cde69a96c78c496aa452db9d9f34b53603e", + "sources": "01b024f9530bce7dabf3592a3f50fe8063c1f9f5ae6d982edf73ba85410b3cd6" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-netty": { + "shasums": { + "jar": "d5526be25b050ad5e4dd7a56095b78357bf73bbe447d900588fe7f895873ff3f", + "sources": "34b0173a82c5b35275a773b213b20591c4047b87e3f0b6dc9c1d0736b5084591" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-opentelemetry": { + "shasums": { + "jar": "42ce3e301fb94f6d15efbed5bae1c3ea9f8fe8da93afa1ac4aacf1819c1d2cd4", + "sources": "f07c50fd5e2dc2b27fbcf08f2d7ad1b8663b8611788b6bf1f4b66fe5c5dbee5b" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-persistence": { + "shasums": { + "jar": "201cd088c5fddbcff7ece343f42eee0cabbab64083d339f35e5eb4edbcdbd4bb", + "sources": "863fc339ae51cc5b5287cac067ff5527cdff2ec8da85358d8bf61fa9a460568c" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-reactor": { + "shasums": { + "jar": "abb8ac783f1a3e2de68f2ececfb22ef4ba47c74089ce377a1476f3ccf5c35539", + "sources": "0753ac2c9596f010acdac06e9bc69d3d5a35dd9abec4e44c1de600dbb7577c13" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-reactor-netty": { + "shasums": { + "jar": "5679a1518fece25f608b6abdb165bae59c4ba1aa76833700fb6cf35d528575a1", + "sources": "35281bbd1465a0a22c1f6a2188c64e2efc8535f2e30807687c77ec15197b58ca" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-restclient": { + "shasums": { + "jar": "4438e7cbe09fe2d1aa40da4112cbc2a7f0067046dbfd153e79122be6e143c82a", + "sources": "87f4a654821800a30f5d41ac5320877cd96cf436a60ac2dd541369b159a4eff2" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-resttestclient": { + "shasums": { + "jar": "976fada4921b1817e1ced3c0da93a709d85638b519a9e63c3075e76c1e3c0593", + "sources": "ad2de8d55af3be0eba0e59259eb6427fd83eca7fdd134454a0c9cde2cde4c9bc" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-security": { + "shasums": { + "jar": "fef4d4952d7c4f86112d4a27cb98cf1d71001373f9d8f88fbf0af9d908fdaa3c", + "sources": "edc45113d48d5b3f7e3ef6f722ffb905a2b0bd5791e79879b591cfe74972178e" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-security-oauth2-client": { + "shasums": { + "jar": "37a6474b11b65f39b444971572e66553512992f4434280365cedcb396b8afbec", + "sources": "dcf7f976f5e8216498d3415508598fa6e8da7248a9ae3056f94fc3abfb76c65f" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-security-oauth2-resource-server": { + "shasums": { + "jar": "d33c17f53bab6054589d94bbc36f8ed017fb0c1cd353c9b8a85359b72d4d25e8", + "sources": "2d598d95730fca23bc1ab8d08af3cad04c6cd69e5d4f728d7ce25f3f5e410953" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-security-test": { + "shasums": { + "jar": "7e34b14edd632dc3cd911931d3e3d74de40d863aa1a1849be4b217a293b14004", + "sources": "ad5a8312d621e12e659b198ded8b8c1b1a2066b34940443856e6302bd8e885b1" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-servlet": { + "shasums": { + "jar": "fb1af769617daa2808a82921d98ca94efde8cb20fcefc55e71b2e0bf93de1423", + "sources": "172a6736933d75bdeeee725078769e27d8142a826e811940c595aea815913512" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-starter": { + "shasums": { + "jar": "d70f5f300c9e7af18813799bffce38974398f6429c578c825822a32560c4b202", + "sources": "d70f5f300c9e7af18813799bffce38974398f6429c578c825822a32560c4b202" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-starter-actuator": { + "shasums": { + "jar": "576d445c6d0b7920f098822f45d74c2239f363d06508f8acf2a181f0b69c2cb9", + "sources": "576d445c6d0b7920f098822f45d74c2239f363d06508f8acf2a181f0b69c2cb9" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-starter-actuator-test": { + "shasums": { + "jar": "9f87aeefd9049094dd1fd64d96632bee353f871e0999c7c0531d69a2dcf7a945", + "sources": "9f87aeefd9049094dd1fd64d96632bee353f871e0999c7c0531d69a2dcf7a945" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-starter-data-cassandra": { + "shasums": { + "jar": "32abc561b414e781bc1998e1a6cd1167f3eff2247e6bb2b1b08a347bb3054acb", + "sources": "32abc561b414e781bc1998e1a6cd1167f3eff2247e6bb2b1b08a347bb3054acb" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-starter-data-cassandra-test": { + "shasums": { + "jar": "aacd60f7f5cab180a23dbd87fd5b53d524c2c89ce42635d5eaa4331075dec548", + "sources": "aacd60f7f5cab180a23dbd87fd5b53d524c2c89ce42635d5eaa4331075dec548" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-starter-jackson": { + "shasums": { + "jar": "bd042218bf291c4ee821d7ec3e86f7f747223d32cd87066aa5e3730a2dd5c3b6", + "sources": "bd042218bf291c4ee821d7ec3e86f7f747223d32cd87066aa5e3730a2dd5c3b6" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-starter-jackson-test": { + "shasums": { + "jar": "72ad611e32e370446bb2de890da4f084def1d354f4526522f0ae1182265b545a", + "sources": "72ad611e32e370446bb2de890da4f084def1d354f4526522f0ae1182265b545a" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-starter-logging": { + "shasums": { + "jar": "b5f1d01cd179ffc24b125a6ce971854b7b141c4e19e9c050a0b82d514983b1f6", + "sources": "b5f1d01cd179ffc24b125a6ce971854b7b141c4e19e9c050a0b82d514983b1f6" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-starter-micrometer-metrics": { + "shasums": { + "jar": "a5d302b2d6e9a8e1989455888aad2f69349f4c0b43b7ef41c8063c4bb92dec32", + "sources": "a5d302b2d6e9a8e1989455888aad2f69349f4c0b43b7ef41c8063c4bb92dec32" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-starter-micrometer-metrics-test": { + "shasums": { + "jar": "dd6d8969ce0e7736d29c5ce1dbdf9d9d89ac0c7ecbae41485abeb5b3efe87546", + "sources": "dd6d8969ce0e7736d29c5ce1dbdf9d9d89ac0c7ecbae41485abeb5b3efe87546" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-starter-reactor-netty": { + "shasums": { + "jar": "ceebf772bae1c9492fce1c766fdc18a7a17388f02f639a6ba20824b8a3dd848c", + "sources": "ceebf772bae1c9492fce1c766fdc18a7a17388f02f639a6ba20824b8a3dd848c" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-starter-security": { + "shasums": { + "jar": "04e806b5737be0d399c85d356b060599684df6fc4f56cc08b85a563ae1a066dd", + "sources": "04e806b5737be0d399c85d356b060599684df6fc4f56cc08b85a563ae1a066dd" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-starter-security-oauth2-client": { + "shasums": { + "jar": "1afd7f96736afc19451ad2a4f001e54af93d9d9d788802df9f4e536860560aca", + "sources": "1afd7f96736afc19451ad2a4f001e54af93d9d9d788802df9f4e536860560aca" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-starter-security-oauth2-resource-server": { + "shasums": { + "jar": "72dbb14dc0b21f43cee18ae6caa6bf7b0c4efe4bac11702e34529280865ecd83", + "sources": "72dbb14dc0b21f43cee18ae6caa6bf7b0c4efe4bac11702e34529280865ecd83" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-starter-security-oauth2-resource-server-test": { + "shasums": { + "jar": "8e13abf7c7754171c83c8236b796ef3d6e8d841a3ddafd84dfec0e5615b77e75", + "sources": "8e13abf7c7754171c83c8236b796ef3d6e8d841a3ddafd84dfec0e5615b77e75" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-starter-security-test": { + "shasums": { + "jar": "a7320624975022865468e255b8e0c414f76d380eef7793faeca87f98496998ad", + "sources": "a7320624975022865468e255b8e0c414f76d380eef7793faeca87f98496998ad" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-starter-test": { + "shasums": { + "jar": "e4844ae2375d569711af218763620993ea434b4836d3463cf26d9f6b6e589340", + "sources": "e4844ae2375d569711af218763620993ea434b4836d3463cf26d9f6b6e589340" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-starter-tomcat": { + "shasums": { + "jar": "f4a81ce616baa9c715be0dc0735210d770aa67301f92d3b11942f780056fc7ad", + "sources": "f4a81ce616baa9c715be0dc0735210d770aa67301f92d3b11942f780056fc7ad" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-starter-tomcat-runtime": { + "shasums": { + "jar": "8d0c7cbd6bd43d47e664a9127f70ceddab952828797f20e2b0b93848796d82f2", + "sources": "8d0c7cbd6bd43d47e664a9127f70ceddab952828797f20e2b0b93848796d82f2" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-starter-validation": { + "shasums": { + "jar": "bc449f60bb483374e7358afcda3fccb6e01906e158c7e1e00d9035277a6545b7", + "sources": "bc449f60bb483374e7358afcda3fccb6e01906e158c7e1e00d9035277a6545b7" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-starter-web": { + "shasums": { + "jar": "1ac009b240450c089df7ef1e5cc9b63df292bea22393ed5c7deca42c85944e3c", + "sources": "1ac009b240450c089df7ef1e5cc9b63df292bea22393ed5c7deca42c85944e3c" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-starter-webflux": { + "shasums": { + "jar": "dd99ab79bd18c6358183b146c3eb35730652dd36d2225d5795405efa7137b1a0", + "sources": "dd99ab79bd18c6358183b146c3eb35730652dd36d2225d5795405efa7137b1a0" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-starter-webflux-test": { + "shasums": { + "jar": "78c866953b611065c85b8f069b18d330c1f2051db932fc3a3e8213b852b7f8eb", + "sources": "78c866953b611065c85b8f069b18d330c1f2051db932fc3a3e8213b852b7f8eb" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-starter-webmvc": { + "shasums": { + "jar": "b70e0d9e1bc667a3a9ad509f98a6e9ac443af8eab5143f8c77a38aec0a014a2c", + "sources": "b70e0d9e1bc667a3a9ad509f98a6e9ac443af8eab5143f8c77a38aec0a014a2c" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-starter-webmvc-test": { + "shasums": { + "jar": "d11fd7bbd613040f88ddba503c486970dd282abb92f12158bc9655ee533e9f31", + "sources": "d11fd7bbd613040f88ddba503c486970dd282abb92f12158bc9655ee533e9f31" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-test": { + "shasums": { + "jar": "8879b4a336972eb498ca6606266e6ecdae911964c0a480d3f4bf37a6c2455b33", + "sources": "4dc67a1a332d75fa3dcf517f4b6dc66ed146793307aa9d23c0ebb5f4b399850a" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-test-autoconfigure": { + "shasums": { + "jar": "99ca7ab8be0dde10fa6c03e1026969619e826fcc914b6bc0d09090e75ba17cb4", + "sources": "f94cf34c24539cc3752fae1a0bf182282086452e9c83cfae5241574f20951eb3" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-tomcat": { + "shasums": { + "jar": "ad7c6527319f5cac0b5638a28820847f64d21282147fb98daaaa1f515ee17323", + "sources": "2388d2eaeb9d413d3d7525983fe14030d3b48bc0cca1530c8c7e9dc73e05e8b5" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-validation": { + "shasums": { + "jar": "15161d6eda4589a24ea30d1fd466bfe3843861c3b55963d49a26bfc0bf2ebbb2", + "sources": "52beb1b2b90adb6cec5bf8523e534e56fed771a7f0fe3d42191a4d3b8a5d3873" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-web-server": { + "shasums": { + "jar": "26a13c73ee85e7a851b73eb2d683ed4d3b7a4ca581b608dfd627bd4551997cf2", + "sources": "41e1dddf6e1be5fc35c4849f51639c097e7a8eb9c6627a780791a1d9e430c311" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-webclient": { + "shasums": { + "jar": "119d82faa651b35964eab73ea3d962b1c7434792647cbf82c9dde44785749671", + "sources": "bf3dd5bf566482bc32c0c75548eb9c6a598b4d26c66677cfb84fca4cd21ccf81" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-webflux": { + "shasums": { + "jar": "7eca957fe0ad8ee60642d58e6a8e652ebaf64198b72f9ccfa959bea056fb7091", + "sources": "fc7dcb5570215a40c78fdab85c60876b740be714cb479d03b3bd39b2c7b26e5f" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-webflux-test": { + "shasums": { + "jar": "3ed73416ab316d0247d9c2efbb922db1247f03d114fada897f70f9d59408c839", + "sources": "5164cd1c633380d2f2616855a2773f099f15aebc2ad70c04a942e673be12be6b" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-webmvc": { + "shasums": { + "jar": "95250948bda9a0a1e1f2ad3f413bd23e36dfedefb89fe59d309d447c6c4c5010", + "sources": "26a04367e170092463c619c886f4f8b688644eecc4a6c91c18d17c242a8f4211" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-webmvc-test": { + "shasums": { + "jar": "06180d3ee7f9019880b0528cc93164f8f038e35007bd99a40fbe52892d96a955", + "sources": "4c5b614a96d50a44fce7c3269b82f26fed6d3d04933140ecf52db5e4d32e2480" + }, + "version": "4.0.7" + }, + "org.springframework.boot:spring-boot-webtestclient": { + "shasums": { + "jar": "7015636f7e41b69d80fd903acdcb1554f2b062551c1ce353e625ea4a11f52903", + "sources": "b1175e4937c93d493bbae0a2c7e2cee13cd60a6b7dd859fb6e662091f986e28f" + }, + "version": "4.0.7" + }, + "org.springframework.cloud:spring-cloud-commons": { + "shasums": { + "jar": "6973e75b09d7ccb98566e4e980f600cd98ffcdc76d19fd822e93c7c2166ffa49", + "sources": "fd39f42ebda40c120a450256bbca653d250ff105ea640a530680c1251ced5ca3" + }, + "version": "5.0.2" + }, + "org.springframework.cloud:spring-cloud-context": { + "shasums": { + "jar": "8972e4677e511dc518280d13a16d1a3e6e3ed4ee91e643d3f0c66676f6078f75", + "sources": "3765a79f4a43fe01a1298b09cd8ee51f0e9692971555478fd157bdf456a70c7b" + }, + "version": "5.0.2" + }, + "org.springframework.cloud:spring-cloud-starter": { + "shasums": { + "jar": "7568b8ced4069bc055af52b5455b206092401a44dcbd4f89d6fa00f89df1cc18" + }, + "version": "5.0.2" + }, + "org.springframework.cloud:spring-cloud-starter-bootstrap": { "shasums": { - "jar": "2796ae857d0c7be4bc3580daa4d3828d555212355f4c83d38dd0af0742b3c812" + "jar": "366aa9da9bd1e8222bed83fa698649b16ba4b2a976a028d565b95afe610735df", + "sources": "46bb02036834b9db0cfe086ca2b4e9581319029e9dff2b420ddf3eb8094f3d62" }, - "version": "2.5.1" + "version": "5.0.2" }, - "net.minidev:json-smart": { + "org.springframework.data:spring-data-cassandra": { "shasums": { - "jar": "86c0c189581b79b57b0719f443a724e9f628ffbb9eef645cf79194f5973a1001" + "jar": "d9e4f2656df238d5561d02d0521036f52518b91d0ef840fd8f25d113b4325610", + "sources": "35340468867c3c37447606312f1d2e2ab18b5123885967a364743f77f196bdb7" }, - "version": "2.5.1" + "version": "5.0.6" }, - "org.apache.logging.log4j:log4j-api": { + "org.springframework.data:spring-data-commons": { "shasums": { - "jar": "92ec1fd36ab3bc09de6198d2d7c0914685c0f7127ea931acc32fd2ecdd82ea89" + "jar": "626151b9c33ab98ebec7f1a2e9746cfe1aa428b0de513f9bb90383533ab8ba6d", + "sources": "8d08a88e7b6761bfb84a79fadde38bafaeeed90ddf4595ba9cbf71c25ed7e2dd" }, - "version": "2.23.1" + "version": "4.0.6" }, - "org.apache.logging.log4j:log4j-to-slf4j": { + "org.springframework.security:spring-security-config": { "shasums": { - "jar": "7937a84055156910234e3b42868f55e68ff4b7becbb6ffd10146f72f5bf54dd5" + "jar": "7aeafd876d48d8b5660356953ed7a607d2d09e8f2b9844acba3fcbc32beb63cc", + "sources": "aeb9131e5cd38ea7ebeb0d348c90da837c807f8b68ae3c0fc3152a46682f16ee" }, - "version": "2.23.1" + "version": "7.0.6" }, - "org.apache.tomcat.embed:tomcat-embed-core": { + "org.springframework.security:spring-security-core": { "shasums": { - "jar": "7f87f59ca35642cdda2f33bb9ecebe9754071b4d836c555966db8ca7f20c242b" + "jar": "f9e1aebb39e051f6f92be029a6fca31e2977a06d417aa51504bfb298bc2a7c69", + "sources": "73d208a0dd021cad8cbbb85a71c8efc66df0161f49e91cd9d877a55def6bc9d0" }, - "version": "10.1.31" + "version": "7.0.6" }, - "org.apache.tomcat.embed:tomcat-embed-el": { + "org.springframework.security:spring-security-crypto": { "shasums": { - "jar": "0e120a96d333acf0e58fb4b74584e85fe41358225440cc4c2dee958f738d07b9" + "jar": "baf9f76bc5b15d090a82841fd5713fa11b75f91fc86de27029d972ce85d3d2e2", + "sources": "bb183230f96711625bdc1d0fc45398917062c2b6b605f94f8dbf0de6a7a1f862" }, - "version": "10.1.31" + "version": "7.0.6" }, - "org.apache.tomcat.embed:tomcat-embed-websocket": { + "org.springframework.security:spring-security-oauth2-client": { "shasums": { - "jar": "62c837ea5f7fd1ab92c6a349fced8812971edcb87683a111fd54fe6d5268a59c" + "jar": "2c50bff8f60f08a46bef837dca50d1270f0dba64a60f551c0b96b33a1f608487", + "sources": "47549b16e028266bf4836b4bf00e9111a46143ae486aaed1ff0370c56ddbe82a" }, - "version": "10.1.31" + "version": "7.0.6" }, - "org.apiguardian:apiguardian-api": { + "org.springframework.security:spring-security-oauth2-core": { "shasums": { - "jar": "b509448ac506d607319f182537f0b35d71007582ec741832a1f111e5b5b70b38" + "jar": "01cc03b6f30e5b526e85fc276b32af25f34b119f601bc17ba3cbe023f45b0271", + "sources": "68bc19e0b85c108766d92bc4a9e9948a641d65d23d1d93cca7f724998665f88f" }, - "version": "1.1.2" + "version": "7.0.6" }, - "org.assertj:assertj-core": { + "org.springframework.security:spring-security-oauth2-jose": { "shasums": { - "jar": "7fbdffa1996d43cc08e2576e01008b07e57bbad2b4741aa6c3ab73ce8511130e" + "jar": "c8cd9a8aefc42a02e34166ff02f3f7def2f6028f555932f04f163dea729bdff9", + "sources": "1a33db514d2ce33222aae9bb3ba41227112de08986aac4e58be857417e7332ea" }, - "version": "3.25.3" + "version": "7.0.6" }, - "org.awaitility:awaitility": { + "org.springframework.security:spring-security-oauth2-resource-server": { "shasums": { - "jar": "e831fee30ed401e2208f1bd5a0ef9add538d9cb1103c0e43216691b647bde9c6" + "jar": "4c53ad5b6a06b9f6272212b5a0a71d003b4ba01dd16de691fd63ddf37554f098", + "sources": "97d33151cd02afcded341ab7ad65f1bcca4798789f5626a5d948809ba529373a" }, - "version": "4.2.2" + "version": "7.0.6" }, - "org.checkerframework:checker-qual": { + "org.springframework.security:spring-security-test": { "shasums": { - "jar": "e316255bbfcd9fe50d165314b85abb2b33cb2a66a93c491db648e498a82c2de1" + "jar": "4b65c38c7fcf372793cdf6e57651be1332ec241fff9481b15ed8f3ea2207382c", + "sources": "61886c39c6853ebafa91718c1bc44ebaad6f834dcb8d0524e509135033c9498c" }, - "version": "3.33.0" + "version": "7.0.6" }, - "org.hamcrest:hamcrest": { + "org.springframework.security:spring-security-web": { "shasums": { - "jar": "5e62846a89f05cd78cd9c1a553f340d002458380c320455dd1f8fc5497a8a1c1" + "jar": "6ef060f97271218c0bff56273dc2edb8e2e001a253a0291278d5fdafa5d70573", + "sources": "33298fd169429984bb6cebf9f4a6176ae9eee047e8b5a3ab6b3d752526db03d9" }, - "version": "2.2" + "version": "7.0.6" }, - "org.junit.jupiter:junit-jupiter": { + "org.springframework:spring-aop": { "shasums": { - "jar": "308c6f9796a17ebe3b5cc7dbdff7b45d916076a40a6fbd6fa53cddcb306f335a" + "jar": "1178f039e087884174e2affc46e484f4a8bd7f2a4e011d33dd9137709f740f80", + "sources": "580610d9e5e2418ea48f64850673ed1d1fdc2d9c8e5cf6e9cfaece1e61b70522" }, - "version": "5.10.5" + "version": "7.0.8" }, - "org.junit.jupiter:junit-jupiter-api": { + "org.springframework:spring-beans": { "shasums": { - "jar": "5d8147a60f49453973e250ed68701b7ff055964fe2462fc2cb1ec1d6d44889ba" + "jar": "6ec2e361a8872a71d8b1ff66f1bcb8cfa29fcc437931998919da7cecfb59b45b", + "sources": "d75bdcfe142b1576f72356748292cad5c21872a52be2c7f2920a9795bb3c52a5" }, - "version": "5.11.3" + "version": "7.0.8" }, - "org.junit.jupiter:junit-jupiter-engine": { + "org.springframework:spring-context": { "shasums": { - "jar": "e62420c99f7c0d59a2159a2ef63e61877e9c80bd722c03ca8bf3bdcea050a589" + "jar": "1eb7d552414ebac00e30ab3e809138d810785f6d2c4271db77cdf0181f308f19", + "sources": "a28b30679b51bcf8aac7f06cb564a379cc2d6c4891b2e2dfc738ddd750c6278e" }, - "version": "5.11.3" + "version": "7.0.8" }, - "org.junit.jupiter:junit-jupiter-params": { + "org.springframework:spring-core": { "shasums": { - "jar": "9f00bf572c3e7f196091234e08e21d32facc47ad2a446bf444dc2720334baaf4" + "jar": "726ba2a5130833644bdf267a55ff26e1f52e8dcc9aa1ffa06904ca9c14619f25", + "sources": "3cbdaed9f2b1eea10d8ac27148ac5e6407d2861fa2d373253a09a1220a232919" }, - "version": "5.10.5" + "version": "7.0.8" }, - "org.junit.platform:junit-platform-commons": { + "org.springframework:spring-expression": { "shasums": { - "jar": "be262964b0b6b48de977c61d4f931df8cf61e80e750cc3f3a0a39cdd21c1008c" + "jar": "3c97c38ab59c77ee886e08ccf8096f6bb58a1245f68dfed7a40e93f41c435f9a", + "sources": "35517249c504d1d4550186808665587c8bb5be953cb6cbbb3856804194e790e9" }, - "version": "1.11.3" + "version": "7.0.8" }, - "org.junit.platform:junit-platform-engine": { + "org.springframework:spring-test": { "shasums": { - "jar": "0043f72f611664735da8dc9a308bf12ecd2236b05339351c4741edb4d8fab0da" + "jar": "6ffb7796075a204915edc4bb9e54dc420183cecb520d753004f660f153763c8e", + "sources": "7bc670e2e1c9cc902da5557a857dde0a4d8089c267ccfcc1bb554cbf2a8c5a05" }, - "version": "1.11.3" + "version": "7.0.8" }, - "org.junit.platform:junit-platform-launcher": { + "org.springframework:spring-tx": { "shasums": { - "jar": "b4727459201b0011beb0742bd807421a1fc8426b116193031ed87825bc2d4f04" + "jar": "57e8fdb6de949e7ec2617683fec03af0253bdbdf4bf3d2651a926a94413df283", + "sources": "7887541d74a7221ca92e21d1a2ecc13e8f98767b1603b3b6bb81d7bbd5aede48" }, - "version": "1.11.3" + "version": "7.0.8" }, - "org.junit.platform:junit-platform-reporting": { + "org.springframework:spring-web": { "shasums": { - "jar": "b8e19dbebcae7d1ff30b9d767047fbf3694027c33dfa423b371693b7f6679ed1" + "jar": "4d4ed7ecb0453d25d735ea27d025ea36b003c3d29cb7d006bedd6d5188a2f5c0", + "sources": "281cbbd965844cdf2e08396924eacd8a369cd38f56fdfb9519c7492217d8b411" }, - "version": "1.11.3" + "version": "7.0.8" }, - "org.mockito:mockito-core": { + "org.springframework:spring-webflux": { "shasums": { - "jar": "f076c96b1f49b8d9bc42e46b0969aaf5684c40c8b5b679d400e5d880073a0e00" + "jar": "3e48db9c30d3768ac14898cfad4bad21f4cfc6a8979a12071b75e725143e5eb9", + "sources": "9b00f6ad4a63f3c636e455f3e733d0f75cf5dcdd8225820a1498469b707652ba" }, - "version": "5.11.0" + "version": "7.0.8" }, - "org.mockito:mockito-junit-jupiter": { + "org.springframework:spring-webmvc": { "shasums": { - "jar": "a30ea4fe0484e54f64cdc15269a6c6ff1bd89bc26a0d41e4c27cb91cb78dc548" + "jar": "48f7e1e2d0d46e98ed3fa30d5a64cb1f7ed2aa339a82edcd87289ed8ff216f04", + "sources": "6dcbc3dc4d5222121f83d1eb0d30807e389771b8d233be38b8e413b92b88a106" }, - "version": "5.11.0" + "version": "7.0.8" }, - "org.objenesis:objenesis": { + "org.testcontainers:testcontainers": { "shasums": { - "jar": "02dfd0b0439a5591e35b708ed2f5474eb0948f53abf74637e959b8e4ef69bfeb" + "jar": "0466f481343d5f350a91274cd7bf984308cbaf90d706247fd1cf4b1a8010c2e1", + "sources": "b4dfdb7d0f8dadc6bfa6d817df703b5c6881b83b6d0452148fd9a00434387e24" }, - "version": "3.3" + "version": "2.0.5" }, - "org.opentest4j:opentest4j": { + "org.testcontainers:testcontainers-cassandra": { "shasums": { - "jar": "48e2df636cab6563ced64dcdff8abb2355627cb236ef0bf37598682ddf742f1b" + "jar": "c6ce343636e40330da6ffa317ca4a56bf09affd3e272445d0176498497218cbe", + "sources": "4b2324b4336a0e6d928467abef387ded2b9014ab1353f85cf715f87d1dbb5be3" }, - "version": "1.3.0" + "version": "2.0.5" }, - "org.ow2.asm:asm": { + "org.testcontainers:testcontainers-database-commons": { "shasums": { - "jar": "3c6fac2424db3d4a853b669f4e3d1d9c3c552235e19a319673f887083c2303a1" + "jar": "0ebbc62bdcfb315cb840a63fbaa148b455927666d9034b5e5c00009d84c755b0", + "sources": "017efc160847bf209b3fdfa3561fa6eb1a5b5b5b82d2214f965caabe9c2cf9ab" }, - "version": "9.6" + "version": "2.0.5" }, - "org.skyscreamer:jsonassert": { + "org.testcontainers:testcontainers-junit-jupiter": { "shasums": { - "jar": "719095c07d4203961320da593441d8b3b643c18eb1d81aa98ea933bb7eb351ba" + "jar": "d66eb7f257a85833a8cc973e3814d740967d40a7db1a0de0040653c6ed236748", + "sources": "0dd4463ca900cbce90894cdaafb18fd146ea50b92ee0ee72a9c3e1e48b53a8e6" }, - "version": "1.5.3" + "version": "2.0.5" }, - "org.slf4j:jul-to-slf4j": { + "org.wiremock:wiremock-standalone": { "shasums": { - "jar": "0f2ec396ea29c9a440890d1f09fdb82fdd574b47b298435764235451c193861d" + "jar": "d097b19bd483c5038479b13a5c71e9faf8f2f5106584f0c120a7770ab0bdb367", + "sources": "76cda98377991ed09088773fe7127f9787e14f8bd41bcd285f94cbd7cd95546f" }, - "version": "2.0.16" + "version": "3.13.2" }, - "org.slf4j:slf4j-api": { + "org.xmlunit:xmlunit-core": { "shasums": { - "jar": "a12578dde1ba00bd9b816d388a0b879928d00bab3c83c240f7013bf4196c579a" + "jar": "208e0cee82aedd9183937e4b9ae44b83884179f724a706bef5795477acfcca91", + "sources": "bb8dba65e8bc1ade346e4bfd98d7f620f51ae8b9034e9758cd6f3031838e79fd" }, - "version": "2.0.16" + "version": "2.10.4" }, - "org.springframework.boot:spring-boot": { + "org.yaml:snakeyaml": { "shasums": { - "jar": "6a4a5c8a5b58c209705881e487b49445679ab69c858623fef700f634e24eb9c2" + "jar": "e6682acf1ace77508ef13649cbf4f8d09d2cf5457bdb61d25ffb6ac0233d78dd", + "sources": "7a7d307ca9fe1663219a60045a8e5a113a69331566f9ebbe1d3b12c6781909ac" }, - "version": "3.3.5" + "version": "2.5" }, - "org.springframework.boot:spring-boot-autoconfigure": { + "software.amazon.awssdk:annotations": { "shasums": { - "jar": "4e9d0d98414d92cc1844f84b88d58a0c968fde51e7333be48dffe424bab77844" + "jar": "fa3ba3b1b635dfc4d0b20f3910b325f8b70419d1fdb56b487d2e1c423c85e041", + "sources": "2c15cad3cec83ce99c86b9f135d24f42429c5c9a4d032351adfc42330cde8b97" }, - "version": "3.3.5" + "version": "2.40.1" }, - "org.springframework.boot:spring-boot-starter": { + "software.amazon.awssdk:checksums": { "shasums": { - "jar": "b93286578561c145c254b6223d401d715de2fa4a68e93cfddef4ec1485e78fe5" + "jar": "6770fdd0d970c1869cd869d784cc20963a4a6b2bee09f125da0504ba2c9e818d", + "sources": "3a5de1a7994ab25bef0eda898645ba6f02ab584bd3d904f21014bfd9d7cb98b5" }, - "version": "3.3.5" + "version": "2.40.1" }, - "org.springframework.boot:spring-boot-starter-json": { + "software.amazon.awssdk:checksums-spi": { "shasums": { - "jar": "a2db1ba5bec8e99c54ec22fb7615557c02c82e26de46d6f6ec20c4ad30e25f27" + "jar": "1f9d68cd2f5ec133757e37b794ab06de015d1b35f4c1452b43ab8b612318a8c3", + "sources": "d17d8d92268f9c6773aa6b72564774dc394ae2fb2149bed26152385a9b6751ff" }, - "version": "3.3.5" + "version": "2.40.1" }, - "org.springframework.boot:spring-boot-starter-logging": { + "software.amazon.awssdk:endpoints-spi": { "shasums": { - "jar": "c8099e083f51280529657736d7a186c377e9f7e458363d8f5d0028d3166fac28" + "jar": "05a48200f243664fa9ca8f05cf29725cbd059a9a6f3cdd55bac44a8fc12903de", + "sources": "24644eb6a36a38df7a31e81fb9fcd106298f188215b6e2ec178732711fc4b0ba" }, - "version": "3.3.5" + "version": "2.40.1" }, - "org.springframework.boot:spring-boot-starter-test": { + "software.amazon.awssdk:http-auth-aws": { "shasums": { - "jar": "e5b371a336b90071100b652a87ea288aca9fced844f993498a03b96559917df8" + "jar": "9b182f3697acc111b213524126f857f844fed5fdf95f734470d16b96097fcd3a", + "sources": "6de3c527d996a46a4e14e268fc13a32b314383a3b2976e7820720c9ddff483a5" }, - "version": "3.3.5" + "version": "2.40.1" }, - "org.springframework.boot:spring-boot-starter-tomcat": { + "software.amazon.awssdk:http-auth-spi": { "shasums": { - "jar": "4a949fb40ecc2445c3d26275411c292a4b931e1b6c3ebcd9e83e1d6c309c6a25" + "jar": "d0cd6f8dd1ef77a990c4b0ae45132707490f886307d9ee446ae1eae93a80ae5a", + "sources": "6bd36a3bfbce87b5550d73ad7ff6d0950dc6aec1baf071b0c47bd02cd98daa3d" }, - "version": "3.3.5" + "version": "2.40.1" }, - "org.springframework.boot:spring-boot-starter-web": { + "software.amazon.awssdk:http-client-spi": { "shasums": { - "jar": "ae05f9cb1abee7d296df9b66ce147ad8c8d9a70850bc8d17dcf8af5d5a1e31b1" + "jar": "87c6fc3ec6c79e088ebfcc818963715bc0a5e103f5612bd4b6d5c49720bb06e3", + "sources": "d6667c353cc231efee460dae061be2b654d3319ffb66d16804da30f5a8475d84" }, - "version": "3.3.5" + "version": "2.40.1" }, - "org.springframework.boot:spring-boot-test": { + "software.amazon.awssdk:identity-spi": { "shasums": { - "jar": "2de2baaf1192e463d9baacb85299d2bb7a8604ffc28dd97ab272ab074425b1bb" + "jar": "b4693f3832b3669850a11f29608a2a3d6e0150ab1528d01cad2a7a32848db591", + "sources": "56ab003dea16ded5f70c1a3af840433c6f5f8fd33435500535e7b888cdf8c923" }, - "version": "3.3.5" + "version": "2.40.1" }, - "org.springframework.boot:spring-boot-test-autoconfigure": { + "software.amazon.awssdk:json-utils": { "shasums": { - "jar": "a4c8b0a5095e7a79fe690cf68863ee462dfc8569da775c85bb4820dcaf0083f5" + "jar": "f565187ae223ab13a4b959f213933540475652269c414b0c82f33cab45977a74", + "sources": "3fbd4a882a672587b93b9cdd2ee7c2c1faa0f5a848509d3d3d7931ef714d6d95" }, - "version": "3.3.5" + "version": "2.40.1" }, - "org.springframework:spring-aop": { + "software.amazon.awssdk:metrics-spi": { "shasums": { - "jar": "15596939a6dbcca5812dee1ab9a8b36f69536bfd80672798927d5f1ac0d384e5" + "jar": "033b5d20384c9b4ac98a17eba0656711738da94a9d33a292b414aa0905177471", + "sources": "373fbebacc328596dda847d8183f22b9e6abef85f7c6287d7c71615cab3d4939" }, - "version": "6.1.14" + "version": "2.40.1" }, - "org.springframework:spring-beans": { + "software.amazon.awssdk:profiles": { "shasums": { - "jar": "6cad84b2a35a33a85a313a19445a43e5432b68e4f0bbf6c2bfc4a885a93dd727" + "jar": "602cac04531cf13d4c06d8d70d7b5b0e109517c73a4c91473058886fbe574cc1", + "sources": "e498cf7ef147b8fc789a448554561b5706daae1062a5e23bc4870149b42d8002" }, - "version": "6.1.14" + "version": "2.40.1" }, - "org.springframework:spring-context": { + "software.amazon.awssdk:regions": { "shasums": { - "jar": "da76b53f6a20f09b38052a300435f3245780d30deb46ec5dd75314cda06fd365" + "jar": "6b1c788306fd7a4fac6bc788a8f5d2300be13049197adacfca072d1b69eb9ddb", + "sources": "1bae6ad3cb124a6850fbede87c9dcd72f7c7eab1f570c9ae71a98f3597c4b59f" }, - "version": "6.1.14" + "version": "2.40.1" }, - "org.springframework:spring-core": { + "software.amazon.awssdk:retries": { "shasums": { - "jar": "e15a1179fc9642ffed13ca55e2863e2da524ccd1083b7c6f1b5cfd5733f3b2c5" + "jar": "198f5f0202fe8e861f3ad2fa8ecb70d0a8180dd568f4d136c898f824b3cd0dd3", + "sources": "2305b334eceb894749d281a3f48be9198e9305f09cb3b40b948a5c661c20c41a" }, - "version": "6.1.14" + "version": "2.40.1" }, - "org.springframework:spring-expression": { + "software.amazon.awssdk:retries-spi": { "shasums": { - "jar": "69ed6b052397a929a0e4c452f63f2c9a3d22e046d8b80d878190205ddb259518" + "jar": "af1c67e4212fffe7d215b41fb74e6994c36a0499234e325954c38db6b14e0e28", + "sources": "c8e5f28924cc8e0cb017a2a25c370a0d0ed9223cfb0051c6a1b0c769a5a46e0b" }, - "version": "6.1.14" + "version": "2.40.1" }, - "org.springframework:spring-jcl": { + "software.amazon.awssdk:sdk-core": { "shasums": { - "jar": "9975c462bacee7a0c1aa79e55aa4faed02bd28dc78d1252404da87f5f7fb4cb1" + "jar": "6c6b1a49b18538060d1f9a797998ca8d589910b274b345bbf0a61c4540e41f27", + "sources": "560156fe956a48cd171077edc2ccb1bcfe2f93c09e3ce62667018228b6144450" }, - "version": "6.1.14" + "version": "2.40.1" }, - "org.springframework:spring-test": { + "software.amazon.awssdk:third-party-jackson-core": { "shasums": { - "jar": "48ba71b67835816c63bbb5ed3ab182fc837d6b2988f35ee8061346501481d928" + "jar": "9939a77038dd0d53675a44fc06afb9b34805cef8fc7b3641d2e861740c1655fc", + "sources": "92ce15d5817f97d935c595b780135666d4674eaf3aeb94ae13b1e4e553f93e42" }, - "version": "6.1.14" + "version": "2.40.1" }, - "org.springframework:spring-web": { + "software.amazon.awssdk:utils": { "shasums": { - "jar": "8fbbd95e27361aa9f656bee18d32bad062b4210d584909893cbc83dd9c64f472" + "jar": "40f19e6ce26acc6a78204aa493833f578e2245cc0bc9749cc9ba431bb8f855e1", + "sources": "2ddd7c56fbc36d7c4e0c84739d68388faff130f51855ce2b80466c8784cf1b25" }, - "version": "6.1.14" + "version": "2.40.1" }, - "org.springframework:spring-webmvc": { + "tools.jackson.core:jackson-core": { "shasums": { - "jar": "9f096f7530ed385348d269dd9ad642dff47f50cbc758f17d69484952cdd0cbc0" + "jar": "3bda1cd6eff0a8d47bdfcaeae7c2bd5311d6c8ed494ef5f3e51029bb44aa9bdf", + "sources": "5aa481c1c120505fc1d204510ec201a40c2bb0bb6bdea24560e0b5fb66484e60" }, - "version": "6.1.14" + "version": "3.1.4" }, - "org.xmlunit:xmlunit-core": { + "tools.jackson.core:jackson-databind": { "shasums": { - "jar": "7e70f23d4f75e05f0ee79f0f6b9e13b6cf51d34f36c5fc3a6b839429dde1efef" + "jar": "14034bfdf392b6ebec1b4bb6c1de29d604f0aa97251259a19d5f19af8719bb20", + "sources": "109baa39968ff6fdcc4389dd4249f04d2edcab7387421ea17bfb51bf18e24189" }, - "version": "2.9.1" + "version": "3.1.4" }, - "org.yaml:snakeyaml": { + "tools.jackson.module:jackson-module-blackbird": { "shasums": { - "jar": "1467931448a0817696ae2805b7b8b20bfb082652bf9c4efaed528930dc49389b" + "jar": "d93aef324acdbffeb4e943e75ed0772f43101930b70fe37219147e5d3b84b3cd", + "sources": "5c7f6900e8b888c6c3edad9b13cd4df0d11f5bd8307064bb79881cb4edd389ef" }, - "version": "2.2" + "version": "3.1.4" } }, + "conflict_resolution": { + "com.github.jnr:jnr-ffi:2.2.11": "com.github.jnr:jnr-ffi:2.1.7", + "com.github.jnr:jnr-posix:3.0.44": "com.github.jnr:jnr-posix:3.1.15", + "com.google.code.findbugs:jsr305:3.0.2": "com.google.code.findbugs:jsr305:2.0.1", + "com.google.errorprone:error_prone_annotations:2.47.0": "com.google.errorprone:error_prone_annotations:2.49.0", + "com.google.guava:guava:19.0": "com.google.guava:guava:33.6.0-jre", + "org.jetbrains:annotations:13.0": "org.jetbrains:annotations:17.0.0", + "org.ow2.asm:asm-commons:5.0.3": "org.ow2.asm:asm-commons:9.9", + "org.ow2.asm:asm-tree:5.0.3": "org.ow2.asm:asm-tree:9.9", + "org.ow2.asm:asm:5.0.3": "org.ow2.asm:asm:9.9", + "org.ow2.asm:asm:9.7.1": "org.ow2.asm:asm:9.9" + }, "dependencies": { "ch.qos.logback:logback-classic": [ "ch.qos.logback:logback-core", "org.slf4j:slf4j-api" ], + "com.datastax.cassandra:cassandra-driver-core": [ + "com.fasterxml.jackson.core:jackson-databind", + "com.github.jnr:jnr-ffi", + "com.github.jnr:jnr-posix", + "com.google.guava:guava", + "io.dropwizard.metrics:metrics-core", + "io.netty:netty-handler", + "org.slf4j:slf4j-api" + ], "com.fasterxml.jackson.core:jackson-databind": [ "com.fasterxml.jackson.core:jackson-annotations", "com.fasterxml.jackson.core:jackson-core" ], - "com.fasterxml.jackson.datatype:jackson-datatype-jdk8": [ + "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml": [ "com.fasterxml.jackson.core:jackson-core", - "com.fasterxml.jackson.core:jackson-databind" + "com.fasterxml.jackson.core:jackson-databind", + "org.yaml:snakeyaml" ], "com.fasterxml.jackson.datatype:jackson-datatype-jsr310": [ "com.fasterxml.jackson.core:jackson-annotations", "com.fasterxml.jackson.core:jackson-core", "com.fasterxml.jackson.core:jackson-databind" ], - "com.fasterxml.jackson.module:jackson-module-parameter-names": [ - "com.fasterxml.jackson.core:jackson-core", - "com.fasterxml.jackson.core:jackson-databind" + "com.github.ben-manes.caffeine:caffeine": [ + "com.google.errorprone:error_prone_annotations", + "org.jspecify:jspecify" + ], + "com.github.ben-manes.caffeine:guava": [ + "com.github.ben-manes.caffeine:caffeine", + "com.google.guava:guava" + ], + "com.github.docker-java:docker-java-api": [ + "com.fasterxml.jackson.core:jackson-annotations", + "org.slf4j:slf4j-api" + ], + "com.github.docker-java:docker-java-transport-zerodep": [ + "com.github.docker-java:docker-java-transport", + "net.java.dev.jna:jna", + "org.slf4j:slf4j-api" + ], + "com.github.java-json-tools:btf": [ + "com.google.code.findbugs:jsr305" + ], + "com.github.java-json-tools:jackson-coreutils": [ + "com.fasterxml.jackson.core:jackson-databind", + "com.github.java-json-tools:msg-simple", + "com.google.code.findbugs:jsr305" + ], + "com.github.java-json-tools:json-patch": [ + "com.fasterxml.jackson.core:jackson-databind", + "com.github.java-json-tools:jackson-coreutils", + "com.github.java-json-tools:msg-simple" + ], + "com.github.java-json-tools:msg-simple": [ + "com.github.java-json-tools:btf", + "com.google.code.findbugs:jsr305" + ], + "com.github.jnr:jnr-ffi": [ + "com.github.jnr:jffi", + "com.github.jnr:jffi:jar:native", + "com.github.jnr:jnr-x86asm", + "org.ow2.asm:asm", + "org.ow2.asm:asm-analysis", + "org.ow2.asm:asm-commons", + "org.ow2.asm:asm-tree", + "org.ow2.asm:asm-util" + ], + "com.github.jnr:jnr-posix": [ + "com.github.jnr:jnr-constants", + "com.github.jnr:jnr-ffi" ], "com.google.guava:guava": [ - "com.google.code.findbugs:jsr305", "com.google.errorprone:error_prone_annotations", "com.google.guava:failureaccess", "com.google.guava:listenablefuture", "com.google.j2objc:j2objc-annotations", - "org.checkerframework:checker-qual" + "org.jspecify:jspecify" ], "com.jayway.jsonpath:json-path": [ "net.minidev:json-smart", "org.slf4j:slf4j-api" ], + "com.nimbusds:oauth2-oidc-sdk": [ + "com.github.stephenc.jcip:jcip-annotations", + "com.nimbusds:content-type", + "com.nimbusds:lang-tag", + "com.nimbusds:nimbus-jose-jwt", + "net.minidev:json-smart" + ], + "com.squareup.okhttp3:okhttp-jvm": [ + "com.squareup.okio:okio-jvm", + "org.jetbrains.kotlin:kotlin-stdlib" + ], + "com.squareup.okio:okio-jvm": [ + "org.jetbrains.kotlin:kotlin-stdlib" + ], + "io.cloudevents:cloudevents-core": [ + "io.cloudevents:cloudevents-api" + ], + "io.cloudevents:cloudevents-json-jackson": [ + "com.fasterxml.jackson.core:jackson-core", + "com.fasterxml.jackson.core:jackson-databind", + "io.cloudevents:cloudevents-core" + ], + "io.dropwizard.metrics:metrics-core": [ + "org.slf4j:slf4j-api" + ], + "io.micrometer:context-propagation": [ + "org.jspecify:jspecify" + ], + "io.micrometer:micrometer-commons": [ + "org.jspecify:jspecify" + ], + "io.micrometer:micrometer-core": [ + "io.micrometer:micrometer-commons", + "io.micrometer:micrometer-observation", + "org.hdrhistogram:HdrHistogram", + "org.jspecify:jspecify", + "org.latencyutils:LatencyUtils" + ], + "io.micrometer:micrometer-jakarta9": [ + "io.micrometer:micrometer-commons", + "io.micrometer:micrometer-core", + "io.micrometer:micrometer-observation", + "org.jspecify:jspecify" + ], "io.micrometer:micrometer-observation": [ - "io.micrometer:micrometer-commons" + "io.micrometer:micrometer-commons", + "org.jspecify:jspecify" + ], + "io.micrometer:micrometer-observation-test": [ + "io.micrometer:micrometer-observation", + "org.assertj:assertj-core", + "org.jspecify:jspecify", + "org.junit.jupiter:junit-jupiter", + "org.mockito:mockito-core" + ], + "io.micrometer:micrometer-tracing": [ + "aopalliance:aopalliance", + "io.micrometer:context-propagation", + "io.micrometer:micrometer-observation", + "org.jspecify:jspecify" + ], + "io.micrometer:micrometer-tracing-bridge-otel": [ + "io.micrometer:micrometer-observation", + "io.micrometer:micrometer-tracing", + "io.opentelemetry.semconv:opentelemetry-semconv", + "io.opentelemetry:opentelemetry-api", + "io.opentelemetry:opentelemetry-extension-trace-propagators", + "io.opentelemetry:opentelemetry-sdk", + "io.opentelemetry:opentelemetry-sdk-common", + "io.opentelemetry:opentelemetry-sdk-trace", + "org.jspecify:jspecify", + "org.slf4j:slf4j-api" + ], + "io.nats:jnats": [ + "org.bouncycastle:bcprov-lts8on", + "org.jspecify:jspecify" + ], + "io.netty:netty-buffer": [ + "io.netty:netty-common" + ], + "io.netty:netty-codec-base": [ + "io.netty:netty-buffer", + "io.netty:netty-common", + "io.netty:netty-transport" + ], + "io.netty:netty-codec-compression": [ + "io.netty:netty-buffer", + "io.netty:netty-codec-base", + "io.netty:netty-common", + "io.netty:netty-transport" + ], + "io.netty:netty-codec-dns": [ + "io.netty:netty-buffer", + "io.netty:netty-codec-base", + "io.netty:netty-common", + "io.netty:netty-transport" + ], + "io.netty:netty-codec-http": [ + "io.netty:netty-buffer", + "io.netty:netty-codec-base", + "io.netty:netty-codec-compression", + "io.netty:netty-common", + "io.netty:netty-handler", + "io.netty:netty-transport" + ], + "io.netty:netty-codec-http2": [ + "io.netty:netty-buffer", + "io.netty:netty-codec-base", + "io.netty:netty-codec-http", + "io.netty:netty-common", + "io.netty:netty-handler", + "io.netty:netty-transport" + ], + "io.netty:netty-codec-http3": [ + "io.netty:netty-buffer", + "io.netty:netty-codec-base", + "io.netty:netty-codec-classes-quic", + "io.netty:netty-codec-compression", + "io.netty:netty-codec-http", + "io.netty:netty-codec-native-quic:jar:linux-aarch_64", + "io.netty:netty-codec-native-quic:jar:linux-x86_64", + "io.netty:netty-codec-native-quic:jar:osx-aarch_64", + "io.netty:netty-codec-native-quic:jar:osx-x86_64", + "io.netty:netty-codec-native-quic:jar:windows-x86_64", + "io.netty:netty-common", + "io.netty:netty-handler", + "io.netty:netty-resolver", + "io.netty:netty-transport", + "io.netty:netty-transport-native-unix-common" + ], + "io.netty:netty-codec-native-quic:jar:linux-aarch_64": [ + "io.netty:netty-codec-classes-quic" + ], + "io.netty:netty-codec-native-quic:jar:linux-x86_64": [ + "io.netty:netty-codec-classes-quic" + ], + "io.netty:netty-codec-native-quic:jar:osx-aarch_64": [ + "io.netty:netty-codec-classes-quic" + ], + "io.netty:netty-codec-native-quic:jar:osx-x86_64": [ + "io.netty:netty-codec-classes-quic" + ], + "io.netty:netty-codec-native-quic:jar:windows-x86_64": [ + "io.netty:netty-codec-classes-quic" + ], + "io.netty:netty-codec-socks": [ + "io.netty:netty-buffer", + "io.netty:netty-codec-base", + "io.netty:netty-common", + "io.netty:netty-transport" + ], + "io.netty:netty-handler": [ + "io.netty:netty-buffer", + "io.netty:netty-codec-base", + "io.netty:netty-common", + "io.netty:netty-resolver", + "io.netty:netty-transport", + "io.netty:netty-transport-native-unix-common" + ], + "io.netty:netty-handler-proxy": [ + "io.netty:netty-buffer", + "io.netty:netty-codec-base", + "io.netty:netty-codec-http", + "io.netty:netty-codec-socks", + "io.netty:netty-common", + "io.netty:netty-handler", + "io.netty:netty-transport" + ], + "io.netty:netty-resolver": [ + "io.netty:netty-common" + ], + "io.netty:netty-resolver-dns": [ + "io.netty:netty-buffer", + "io.netty:netty-codec-base", + "io.netty:netty-codec-dns", + "io.netty:netty-common", + "io.netty:netty-handler", + "io.netty:netty-resolver", + "io.netty:netty-transport" + ], + "io.netty:netty-resolver-dns-classes-macos": [ + "io.netty:netty-common", + "io.netty:netty-resolver-dns", + "io.netty:netty-transport-native-unix-common" + ], + "io.netty:netty-resolver-dns-native-macos:jar:osx-x86_64": [ + "io.netty:netty-resolver-dns-classes-macos" + ], + "io.netty:netty-transport": [ + "io.netty:netty-buffer", + "io.netty:netty-common", + "io.netty:netty-resolver" + ], + "io.netty:netty-transport-classes-epoll": [ + "io.netty:netty-buffer", + "io.netty:netty-common", + "io.netty:netty-transport", + "io.netty:netty-transport-native-unix-common" + ], + "io.netty:netty-transport-native-epoll:jar:linux-x86_64": [ + "io.netty:netty-buffer", + "io.netty:netty-common", + "io.netty:netty-transport", + "io.netty:netty-transport-classes-epoll", + "io.netty:netty-transport-native-unix-common" + ], + "io.netty:netty-transport-native-unix-common": [ + "io.netty:netty-buffer", + "io.netty:netty-common", + "io.netty:netty-transport" + ], + "io.opentelemetry:opentelemetry-api": [ + "io.opentelemetry:opentelemetry-context" + ], + "io.opentelemetry:opentelemetry-context": [ + "io.opentelemetry:opentelemetry-common" + ], + "io.opentelemetry:opentelemetry-exporter-common": [ + "io.opentelemetry:opentelemetry-api", + "io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi" + ], + "io.opentelemetry:opentelemetry-exporter-otlp": [ + "io.opentelemetry:opentelemetry-exporter-otlp-common", + "io.opentelemetry:opentelemetry-exporter-sender-okhttp", + "io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi", + "io.opentelemetry:opentelemetry-sdk-logs", + "io.opentelemetry:opentelemetry-sdk-metrics", + "io.opentelemetry:opentelemetry-sdk-trace" + ], + "io.opentelemetry:opentelemetry-exporter-otlp-common": [ + "io.opentelemetry:opentelemetry-exporter-common" + ], + "io.opentelemetry:opentelemetry-exporter-sender-okhttp": [ + "com.squareup.okhttp3:okhttp-jvm", + "io.opentelemetry:opentelemetry-exporter-common", + "io.opentelemetry:opentelemetry-sdk-common" + ], + "io.opentelemetry:opentelemetry-extension-trace-propagators": [ + "io.opentelemetry:opentelemetry-api" + ], + "io.opentelemetry:opentelemetry-sdk": [ + "io.opentelemetry:opentelemetry-api", + "io.opentelemetry:opentelemetry-sdk-common", + "io.opentelemetry:opentelemetry-sdk-logs", + "io.opentelemetry:opentelemetry-sdk-metrics", + "io.opentelemetry:opentelemetry-sdk-trace" + ], + "io.opentelemetry:opentelemetry-sdk-common": [ + "io.opentelemetry:opentelemetry-api" + ], + "io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi": [ + "io.opentelemetry:opentelemetry-sdk" + ], + "io.opentelemetry:opentelemetry-sdk-logs": [ + "io.opentelemetry:opentelemetry-api", + "io.opentelemetry:opentelemetry-sdk-common" + ], + "io.opentelemetry:opentelemetry-sdk-metrics": [ + "io.opentelemetry:opentelemetry-api", + "io.opentelemetry:opentelemetry-sdk-common" + ], + "io.opentelemetry:opentelemetry-sdk-testing": [ + "io.opentelemetry:opentelemetry-api", + "io.opentelemetry:opentelemetry-sdk" + ], + "io.opentelemetry:opentelemetry-sdk-trace": [ + "io.opentelemetry:opentelemetry-api", + "io.opentelemetry:opentelemetry-sdk-common" + ], + "io.projectreactor.netty:reactor-netty-core": [ + "io.netty:netty-handler", + "io.netty:netty-handler-proxy", + "io.netty:netty-resolver-dns", + "io.netty:netty-resolver-dns-native-macos:jar:osx-x86_64", + "io.netty:netty-transport-native-epoll:jar:linux-x86_64", + "io.projectreactor:reactor-core", + "org.jspecify:jspecify" + ], + "io.projectreactor.netty:reactor-netty-http": [ + "io.netty:netty-codec-http", + "io.netty:netty-codec-http2", + "io.netty:netty-codec-http3", + "io.netty:netty-resolver-dns", + "io.netty:netty-resolver-dns-native-macos:jar:osx-x86_64", + "io.netty:netty-transport-native-epoll:jar:linux-x86_64", + "io.projectreactor.netty:reactor-netty-core", + "io.projectreactor:reactor-core", + "org.jspecify:jspecify" + ], + "io.projectreactor:reactor-core": [ + "org.jspecify:jspecify", + "org.reactivestreams:reactive-streams" + ], + "io.projectreactor:reactor-test": [ + "io.projectreactor:reactor-core", + "org.jspecify:jspecify" + ], + "io.swagger.core.v3:swagger-core-jakarta": [ + "com.fasterxml.jackson.core:jackson-annotations", + "com.fasterxml.jackson.core:jackson-databind", + "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml", + "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", + "io.swagger.core.v3:swagger-annotations-jakarta", + "io.swagger.core.v3:swagger-models-jakarta", + "jakarta.validation:jakarta.validation-api", + "jakarta.xml.bind:jakarta.xml.bind-api", + "org.apache.commons:commons-lang3", + "org.slf4j:slf4j-api", + "org.yaml:snakeyaml" + ], + "io.swagger.core.v3:swagger-models-jakarta": [ + "com.fasterxml.jackson.core:jackson-annotations" ], "jakarta.xml.bind:jakarta.xml.bind-api": [ "jakarta.activation:jakarta.activation-api" @@ -578,6 +3072,30 @@ "net.minidev:json-smart": [ "net.minidev:accessors-smart" ], + "org.apache.cassandra:java-driver-core": [ + "com.datastax.oss:native-protocol", + "com.fasterxml.jackson.core:jackson-core", + "com.fasterxml.jackson.core:jackson-databind", + "com.github.jnr:jnr-posix", + "com.typesafe:config", + "io.netty:netty-handler", + "org.apache.cassandra:java-driver-guava-shaded", + "org.reactivestreams:reactive-streams", + "org.slf4j:slf4j-api" + ], + "org.apache.cassandra:java-driver-metrics-micrometer": [ + "io.micrometer:micrometer-core", + "org.apache.cassandra:java-driver-core" + ], + "org.apache.cassandra:java-driver-query-builder": [ + "org.apache.cassandra:java-driver-core", + "org.apache.cassandra:java-driver-guava-shaded" + ], + "org.apache.commons:commons-compress": [ + "commons-codec:commons-codec", + "commons-io:commons-io", + "org.apache.commons:commons-lang3" + ], "org.apache.logging.log4j:log4j-to-slf4j": [ "org.apache.logging.log4j:log4j-api", "org.slf4j:slf4j-api" @@ -591,6 +3109,27 @@ "org.awaitility:awaitility": [ "org.hamcrest:hamcrest" ], + "org.hibernate.validator:hibernate-validator": [ + "com.fasterxml:classmate", + "jakarta.validation:jakarta.validation-api", + "org.jboss.logging:jboss-logging" + ], + "org.jacoco:org.jacoco.cli": [ + "args4j:args4j", + "org.jacoco:org.jacoco.core", + "org.jacoco:org.jacoco.report" + ], + "org.jacoco:org.jacoco.core": [ + "org.ow2.asm:asm", + "org.ow2.asm:asm-commons", + "org.ow2.asm:asm-tree" + ], + "org.jacoco:org.jacoco.report": [ + "org.jacoco:org.jacoco.core" + ], + "org.jetbrains.kotlin:kotlin-stdlib": [ + "org.jetbrains:annotations" + ], "org.junit.jupiter:junit-jupiter": [ "org.junit.jupiter:junit-jupiter-api", "org.junit.jupiter:junit-jupiter-engine", @@ -598,33 +3137,41 @@ ], "org.junit.jupiter:junit-jupiter-api": [ "org.apiguardian:apiguardian-api", + "org.jspecify:jspecify", "org.junit.platform:junit-platform-commons", "org.opentest4j:opentest4j" ], "org.junit.jupiter:junit-jupiter-engine": [ "org.apiguardian:apiguardian-api", + "org.jspecify:jspecify", "org.junit.jupiter:junit-jupiter-api", "org.junit.platform:junit-platform-engine" ], "org.junit.jupiter:junit-jupiter-params": [ "org.apiguardian:apiguardian-api", + "org.jspecify:jspecify", "org.junit.jupiter:junit-jupiter-api" ], "org.junit.platform:junit-platform-commons": [ - "org.apiguardian:apiguardian-api" + "org.apiguardian:apiguardian-api", + "org.jspecify:jspecify" ], "org.junit.platform:junit-platform-engine": [ "org.apiguardian:apiguardian-api", + "org.jspecify:jspecify", "org.junit.platform:junit-platform-commons", "org.opentest4j:opentest4j" ], "org.junit.platform:junit-platform-launcher": [ "org.apiguardian:apiguardian-api", + "org.jspecify:jspecify", "org.junit.platform:junit-platform-engine" ], "org.junit.platform:junit-platform-reporting": [ "org.apiguardian:apiguardian-api", - "org.junit.platform:junit-platform-launcher" + "org.jspecify:jspecify", + "org.junit.platform:junit-platform-launcher", + "org.opentest4j.reporting:open-test-reporting-tooling-spi" ], "org.mockito:mockito-core": [ "net.bytebuddy:byte-buddy", @@ -635,40 +3182,268 @@ "org.junit.jupiter:junit-jupiter-api", "org.mockito:mockito-core" ], + "org.opentest4j.reporting:open-test-reporting-tooling-spi": [ + "org.apiguardian:apiguardian-api" + ], + "org.ow2.asm:asm-analysis": [ + "org.ow2.asm:asm-tree" + ], + "org.ow2.asm:asm-commons": [ + "org.ow2.asm:asm", + "org.ow2.asm:asm-tree" + ], + "org.ow2.asm:asm-tree": [ + "org.ow2.asm:asm" + ], + "org.ow2.asm:asm-util": [ + "org.ow2.asm:asm", + "org.ow2.asm:asm-analysis", + "org.ow2.asm:asm-tree" + ], + "org.rnorth.duct-tape:duct-tape": [ + "org.jetbrains:annotations" + ], "org.skyscreamer:jsonassert": [ "com.vaadin.external.google:android-json" ], "org.slf4j:jul-to-slf4j": [ "org.slf4j:slf4j-api" ], - "org.springframework.boot:spring-boot": [ - "org.springframework:spring-context", - "org.springframework:spring-core" + "org.springdoc:springdoc-openapi-starter-common": [ + "io.swagger.core.v3:swagger-core-jakarta", + "org.springframework.boot:spring-boot-autoconfigure", + "org.springframework.boot:spring-boot-jackson", + "org.springframework.boot:spring-boot-starter", + "org.springframework.boot:spring-boot-validation" + ], + "org.springdoc:springdoc-openapi-starter-webflux-api": [ + "org.springdoc:springdoc-openapi-starter-common", + "org.springframework.boot:spring-boot-web-server", + "org.springframework.boot:spring-boot-webflux" + ], + "org.springdoc:springdoc-openapi-starter-webmvc-api": [ + "org.springdoc:springdoc-openapi-starter-common", + "org.springframework.boot:spring-boot-web-server", + "org.springframework.boot:spring-boot-webmvc" + ], + "org.springframework.boot:spring-boot": [ + "org.springframework:spring-context", + "org.springframework:spring-core" + ], + "org.springframework.boot:spring-boot-actuator": [ + "org.springframework.boot:spring-boot" + ], + "org.springframework.boot:spring-boot-actuator-autoconfigure": [ + "org.springframework.boot:spring-boot-actuator", + "org.springframework.boot:spring-boot-autoconfigure" + ], + "org.springframework.boot:spring-boot-autoconfigure": [ + "org.springframework.boot:spring-boot" + ], + "org.springframework.boot:spring-boot-cassandra": [ + "org.apache.cassandra:java-driver-core", + "org.springframework.boot:spring-boot" + ], + "org.springframework.boot:spring-boot-data-cassandra": [ + "org.springframework.boot:spring-boot-cassandra", + "org.springframework.boot:spring-boot-data-commons", + "org.springframework.data:spring-data-cassandra" + ], + "org.springframework.boot:spring-boot-data-cassandra-test": [ + "org.springframework.boot:spring-boot-data-cassandra", + "org.springframework.boot:spring-boot-test-autoconfigure" + ], + "org.springframework.boot:spring-boot-data-commons": [ + "org.springframework.boot:spring-boot", + "org.springframework.boot:spring-boot-persistence", + "org.springframework.data:spring-data-commons" + ], + "org.springframework.boot:spring-boot-health": [ + "org.springframework.boot:spring-boot" + ], + "org.springframework.boot:spring-boot-http-client": [ + "org.springframework.boot:spring-boot", + "org.springframework.boot:spring-boot-http-converter", + "org.springframework:spring-web" + ], + "org.springframework.boot:spring-boot-http-codec": [ + "org.springframework.boot:spring-boot", + "org.springframework:spring-web" + ], + "org.springframework.boot:spring-boot-http-converter": [ + "org.springframework.boot:spring-boot", + "org.springframework:spring-web" + ], + "org.springframework.boot:spring-boot-jackson": [ + "org.springframework.boot:spring-boot", + "tools.jackson.core:jackson-databind" + ], + "org.springframework.boot:spring-boot-micrometer-metrics": [ + "io.micrometer:micrometer-core", + "org.springframework.boot:spring-boot", + "org.springframework.boot:spring-boot-micrometer-observation" + ], + "org.springframework.boot:spring-boot-micrometer-metrics-test": [ + "io.micrometer:micrometer-observation-test", + "org.springframework.boot:spring-boot-micrometer-metrics", + "org.springframework.boot:spring-boot-test-autoconfigure" + ], + "org.springframework.boot:spring-boot-micrometer-observation": [ + "io.micrometer:micrometer-observation", + "org.springframework.boot:spring-boot" + ], + "org.springframework.boot:spring-boot-micrometer-tracing": [ + "io.micrometer:micrometer-tracing", + "org.springframework.boot:spring-boot", + "org.springframework.boot:spring-boot-micrometer-observation" + ], + "org.springframework.boot:spring-boot-micrometer-tracing-opentelemetry": [ + "io.micrometer:micrometer-tracing", + "org.springframework.boot:spring-boot", + "org.springframework.boot:spring-boot-micrometer-observation", + "org.springframework.boot:spring-boot-micrometer-tracing", + "org.springframework.boot:spring-boot-opentelemetry" + ], + "org.springframework.boot:spring-boot-netty": [ + "io.netty:netty-common", + "org.springframework.boot:spring-boot" + ], + "org.springframework.boot:spring-boot-opentelemetry": [ + "io.opentelemetry:opentelemetry-api", + "io.opentelemetry:opentelemetry-sdk", + "org.springframework.boot:spring-boot" + ], + "org.springframework.boot:spring-boot-persistence": [ + "org.springframework.boot:spring-boot", + "org.springframework:spring-tx" + ], + "org.springframework.boot:spring-boot-reactor": [ + "io.projectreactor:reactor-core", + "org.springframework.boot:spring-boot" + ], + "org.springframework.boot:spring-boot-reactor-netty": [ + "io.projectreactor.netty:reactor-netty-http", + "org.springframework.boot:spring-boot-netty", + "org.springframework.boot:spring-boot-web-server", + "org.springframework:spring-web" + ], + "org.springframework.boot:spring-boot-restclient": [ + "org.springframework.boot:spring-boot", + "org.springframework.boot:spring-boot-http-client", + "org.springframework.boot:spring-boot-http-converter" + ], + "org.springframework.boot:spring-boot-resttestclient": [ + "org.springframework.boot:spring-boot-http-converter", + "org.springframework.boot:spring-boot-test", + "org.springframework:spring-web" + ], + "org.springframework.boot:spring-boot-security": [ + "org.springframework.boot:spring-boot", + "org.springframework.security:spring-security-config", + "org.springframework.security:spring-security-web" + ], + "org.springframework.boot:spring-boot-security-oauth2-client": [ + "org.springframework.boot:spring-boot", + "org.springframework.boot:spring-boot-security", + "org.springframework.security:spring-security-oauth2-client" + ], + "org.springframework.boot:spring-boot-security-oauth2-resource-server": [ + "org.springframework.boot:spring-boot", + "org.springframework.boot:spring-boot-security", + "org.springframework.security:spring-security-oauth2-jose", + "org.springframework.security:spring-security-oauth2-resource-server" + ], + "org.springframework.boot:spring-boot-security-test": [ + "org.springframework.boot:spring-boot-security", + "org.springframework.boot:spring-boot-test-autoconfigure", + "org.springframework.security:spring-security-test" ], - "org.springframework.boot:spring-boot-autoconfigure": [ - "org.springframework.boot:spring-boot" + "org.springframework.boot:spring-boot-servlet": [ + "org.springframework.boot:spring-boot", + "org.springframework:spring-web" ], "org.springframework.boot:spring-boot-starter": [ "jakarta.annotation:jakarta.annotation-api", - "org.springframework.boot:spring-boot", "org.springframework.boot:spring-boot-autoconfigure", "org.springframework.boot:spring-boot-starter-logging", - "org.springframework:spring-core", "org.yaml:snakeyaml" ], - "org.springframework.boot:spring-boot-starter-json": [ - "com.fasterxml.jackson.core:jackson-databind", - "com.fasterxml.jackson.datatype:jackson-datatype-jdk8", - "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", - "com.fasterxml.jackson.module:jackson-module-parameter-names", + "org.springframework.boot:spring-boot-starter-actuator": [ + "io.micrometer:micrometer-jakarta9", + "io.micrometer:micrometer-observation", + "org.springframework.boot:spring-boot-actuator-autoconfigure", + "org.springframework.boot:spring-boot-health", "org.springframework.boot:spring-boot-starter", - "org.springframework:spring-web" + "org.springframework.boot:spring-boot-starter-micrometer-metrics" + ], + "org.springframework.boot:spring-boot-starter-actuator-test": [ + "org.springframework.boot:spring-boot-starter-actuator", + "org.springframework.boot:spring-boot-starter-micrometer-metrics-test", + "org.springframework.boot:spring-boot-starter-test" + ], + "org.springframework.boot:spring-boot-starter-data-cassandra": [ + "org.springframework.boot:spring-boot-cassandra", + "org.springframework.boot:spring-boot-data-cassandra", + "org.springframework.boot:spring-boot-starter" + ], + "org.springframework.boot:spring-boot-starter-data-cassandra-test": [ + "org.springframework.boot:spring-boot-data-cassandra-test", + "org.springframework.boot:spring-boot-starter-data-cassandra", + "org.springframework.boot:spring-boot-starter-test" + ], + "org.springframework.boot:spring-boot-starter-jackson": [ + "org.springframework.boot:spring-boot-jackson", + "org.springframework.boot:spring-boot-starter" + ], + "org.springframework.boot:spring-boot-starter-jackson-test": [ + "org.springframework.boot:spring-boot-starter-jackson", + "org.springframework.boot:spring-boot-starter-test" ], "org.springframework.boot:spring-boot-starter-logging": [ "ch.qos.logback:logback-classic", "org.apache.logging.log4j:log4j-to-slf4j", "org.slf4j:jul-to-slf4j" ], + "org.springframework.boot:spring-boot-starter-micrometer-metrics": [ + "org.springframework.boot:spring-boot-micrometer-metrics", + "org.springframework.boot:spring-boot-starter" + ], + "org.springframework.boot:spring-boot-starter-micrometer-metrics-test": [ + "org.springframework.boot:spring-boot-micrometer-metrics-test", + "org.springframework.boot:spring-boot-starter-micrometer-metrics", + "org.springframework.boot:spring-boot-starter-test" + ], + "org.springframework.boot:spring-boot-starter-reactor-netty": [ + "org.springframework.boot:spring-boot-reactor", + "org.springframework.boot:spring-boot-reactor-netty", + "org.springframework.boot:spring-boot-starter" + ], + "org.springframework.boot:spring-boot-starter-security": [ + "org.springframework.boot:spring-boot-security", + "org.springframework.boot:spring-boot-starter", + "org.springframework:spring-aop" + ], + "org.springframework.boot:spring-boot-starter-security-oauth2-client": [ + "org.springframework.boot:spring-boot-security-oauth2-client", + "org.springframework.boot:spring-boot-starter", + "org.springframework.boot:spring-boot-starter-security", + "org.springframework.security:spring-security-oauth2-jose" + ], + "org.springframework.boot:spring-boot-starter-security-oauth2-resource-server": [ + "org.springframework.boot:spring-boot-security-oauth2-resource-server", + "org.springframework.boot:spring-boot-starter", + "org.springframework.boot:spring-boot-starter-security" + ], + "org.springframework.boot:spring-boot-starter-security-oauth2-resource-server-test": [ + "org.springframework.boot:spring-boot-starter-security-oauth2-resource-server", + "org.springframework.boot:spring-boot-starter-security-test", + "org.springframework.boot:spring-boot-starter-test" + ], + "org.springframework.boot:spring-boot-starter-security-test": [ + "org.springframework.boot:spring-boot-security-test", + "org.springframework.boot:spring-boot-starter-security", + "org.springframework.boot:spring-boot-starter-test" + ], "org.springframework.boot:spring-boot-starter-test": [ "com.jayway.jsonpath:json-path", "jakarta.xml.bind:jakarta.xml.bind-api", @@ -688,27 +3463,198 @@ "org.xmlunit:xmlunit-core" ], "org.springframework.boot:spring-boot-starter-tomcat": [ + "org.springframework.boot:spring-boot-starter", + "org.springframework.boot:spring-boot-starter-tomcat-runtime", + "org.springframework.boot:spring-boot-tomcat" + ], + "org.springframework.boot:spring-boot-starter-tomcat-runtime": [ "jakarta.annotation:jakarta.annotation-api", "org.apache.tomcat.embed:tomcat-embed-core", "org.apache.tomcat.embed:tomcat-embed-el", - "org.apache.tomcat.embed:tomcat-embed-websocket" + "org.apache.tomcat.embed:tomcat-embed-websocket", + "org.springframework.boot:spring-boot-tomcat", + "org.springframework.boot:spring-boot-web-server" + ], + "org.springframework.boot:spring-boot-starter-validation": [ + "org.springframework.boot:spring-boot-starter", + "org.springframework.boot:spring-boot-validation" ], "org.springframework.boot:spring-boot-starter-web": [ + "org.springframework.boot:spring-boot-http-converter", + "org.springframework.boot:spring-boot-starter-jackson", + "org.springframework.boot:spring-boot-starter-tomcat", + "org.springframework.boot:spring-boot-webmvc" + ], + "org.springframework.boot:spring-boot-starter-webflux": [ + "org.springframework.boot:spring-boot-reactor", + "org.springframework.boot:spring-boot-starter", + "org.springframework.boot:spring-boot-starter-jackson", + "org.springframework.boot:spring-boot-starter-reactor-netty", + "org.springframework.boot:spring-boot-webflux" + ], + "org.springframework.boot:spring-boot-starter-webflux-test": [ + "io.projectreactor:reactor-test", + "org.springframework.boot:spring-boot-starter-jackson-test", + "org.springframework.boot:spring-boot-starter-test", + "org.springframework.boot:spring-boot-starter-webflux", + "org.springframework.boot:spring-boot-webflux-test", + "org.springframework.boot:spring-boot-webtestclient" + ], + "org.springframework.boot:spring-boot-starter-webmvc": [ + "org.springframework.boot:spring-boot-http-converter", "org.springframework.boot:spring-boot-starter", - "org.springframework.boot:spring-boot-starter-json", + "org.springframework.boot:spring-boot-starter-jackson", "org.springframework.boot:spring-boot-starter-tomcat", - "org.springframework:spring-web", - "org.springframework:spring-webmvc" + "org.springframework.boot:spring-boot-webmvc" + ], + "org.springframework.boot:spring-boot-starter-webmvc-test": [ + "org.springframework.boot:spring-boot-resttestclient", + "org.springframework.boot:spring-boot-starter-jackson-test", + "org.springframework.boot:spring-boot-starter-test", + "org.springframework.boot:spring-boot-starter-webmvc", + "org.springframework.boot:spring-boot-webmvc-test" ], "org.springframework.boot:spring-boot-test": [ "org.springframework.boot:spring-boot", "org.springframework:spring-test" ], "org.springframework.boot:spring-boot-test-autoconfigure": [ - "org.springframework.boot:spring-boot", - "org.springframework.boot:spring-boot-autoconfigure", "org.springframework.boot:spring-boot-test" ], + "org.springframework.boot:spring-boot-tomcat": [ + "jakarta.annotation:jakarta.annotation-api", + "org.apache.tomcat.embed:tomcat-embed-core", + "org.springframework.boot:spring-boot-web-server" + ], + "org.springframework.boot:spring-boot-validation": [ + "org.apache.tomcat.embed:tomcat-embed-el", + "org.hibernate.validator:hibernate-validator", + "org.springframework.boot:spring-boot" + ], + "org.springframework.boot:spring-boot-web-server": [ + "org.springframework.boot:spring-boot", + "org.springframework:spring-web" + ], + "org.springframework.boot:spring-boot-webclient": [ + "org.springframework.boot:spring-boot", + "org.springframework.boot:spring-boot-http-client", + "org.springframework.boot:spring-boot-http-codec", + "org.springframework:spring-webflux" + ], + "org.springframework.boot:spring-boot-webflux": [ + "org.springframework.boot:spring-boot", + "org.springframework.boot:spring-boot-http-codec", + "org.springframework.boot:spring-boot-web-server", + "org.springframework:spring-webflux" + ], + "org.springframework.boot:spring-boot-webflux-test": [ + "org.springframework.boot:spring-boot-test-autoconfigure", + "org.springframework.boot:spring-boot-webflux", + "org.springframework.boot:spring-boot-webtestclient" + ], + "org.springframework.boot:spring-boot-webmvc": [ + "org.springframework.boot:spring-boot-http-converter", + "org.springframework.boot:spring-boot-servlet", + "org.springframework:spring-web", + "org.springframework:spring-webmvc" + ], + "org.springframework.boot:spring-boot-webmvc-test": [ + "org.springframework.boot:spring-boot-http-converter", + "org.springframework.boot:spring-boot-test-autoconfigure", + "org.springframework.boot:spring-boot-web-server", + "org.springframework.boot:spring-boot-webmvc" + ], + "org.springframework.boot:spring-boot-webtestclient": [ + "org.springframework.boot:spring-boot-http-codec", + "org.springframework.boot:spring-boot-test", + "org.springframework:spring-webflux" + ], + "org.springframework.cloud:spring-cloud-commons": [ + "org.springframework.security:spring-security-crypto" + ], + "org.springframework.cloud:spring-cloud-context": [ + "org.springframework.security:spring-security-crypto" + ], + "org.springframework.cloud:spring-cloud-starter": [ + "org.bouncycastle:bcprov-jdk18on", + "org.springframework.boot:spring-boot-starter", + "org.springframework.cloud:spring-cloud-commons", + "org.springframework.cloud:spring-cloud-context" + ], + "org.springframework.cloud:spring-cloud-starter-bootstrap": [ + "org.springframework.cloud:spring-cloud-starter" + ], + "org.springframework.data:spring-data-cassandra": [ + "org.apache.cassandra:java-driver-core", + "org.apache.cassandra:java-driver-query-builder", + "org.slf4j:slf4j-api", + "org.springframework.data:spring-data-commons", + "org.springframework:spring-beans", + "org.springframework:spring-context", + "org.springframework:spring-core", + "org.springframework:spring-expression", + "org.springframework:spring-tx" + ], + "org.springframework.data:spring-data-commons": [ + "org.slf4j:slf4j-api", + "org.springframework:spring-beans", + "org.springframework:spring-core" + ], + "org.springframework.security:spring-security-config": [ + "org.springframework.security:spring-security-core", + "org.springframework:spring-aop", + "org.springframework:spring-beans", + "org.springframework:spring-context", + "org.springframework:spring-core" + ], + "org.springframework.security:spring-security-core": [ + "io.micrometer:micrometer-observation", + "org.springframework.security:spring-security-crypto", + "org.springframework:spring-aop", + "org.springframework:spring-beans", + "org.springframework:spring-context", + "org.springframework:spring-core", + "org.springframework:spring-expression" + ], + "org.springframework.security:spring-security-oauth2-client": [ + "com.nimbusds:oauth2-oidc-sdk", + "org.springframework.security:spring-security-core", + "org.springframework.security:spring-security-oauth2-core", + "org.springframework.security:spring-security-web", + "org.springframework:spring-core" + ], + "org.springframework.security:spring-security-oauth2-core": [ + "org.springframework.security:spring-security-core", + "org.springframework:spring-core", + "org.springframework:spring-web" + ], + "org.springframework.security:spring-security-oauth2-jose": [ + "com.nimbusds:nimbus-jose-jwt", + "org.springframework.security:spring-security-core", + "org.springframework.security:spring-security-oauth2-core", + "org.springframework:spring-core" + ], + "org.springframework.security:spring-security-oauth2-resource-server": [ + "org.springframework.security:spring-security-core", + "org.springframework.security:spring-security-oauth2-core", + "org.springframework.security:spring-security-web", + "org.springframework:spring-core" + ], + "org.springframework.security:spring-security-test": [ + "org.springframework.security:spring-security-core", + "org.springframework.security:spring-security-web", + "org.springframework:spring-core", + "org.springframework:spring-test" + ], + "org.springframework.security:spring-security-web": [ + "org.springframework.security:spring-security-core", + "org.springframework:spring-aop", + "org.springframework:spring-beans", + "org.springframework:spring-context", + "org.springframework:spring-core", + "org.springframework:spring-expression", + "org.springframework:spring-web" + ], "org.springframework:spring-aop": [ "org.springframework:spring-beans", "org.springframework:spring-core" @@ -724,7 +3670,8 @@ "org.springframework:spring-expression" ], "org.springframework:spring-core": [ - "org.springframework:spring-jcl" + "commons-logging:commons-logging", + "org.jspecify:jspecify" ], "org.springframework:spring-expression": [ "org.springframework:spring-core" @@ -732,11 +3679,21 @@ "org.springframework:spring-test": [ "org.springframework:spring-core" ], + "org.springframework:spring-tx": [ + "org.springframework:spring-beans", + "org.springframework:spring-core" + ], "org.springframework:spring-web": [ "io.micrometer:micrometer-observation", "org.springframework:spring-beans", "org.springframework:spring-core" ], + "org.springframework:spring-webflux": [ + "io.projectreactor:reactor-core", + "org.springframework:spring-beans", + "org.springframework:spring-core", + "org.springframework:spring-web" + ], "org.springframework:spring-webmvc": [ "org.springframework:spring-aop", "org.springframework:spring-beans", @@ -745,11 +3702,139 @@ "org.springframework:spring-expression", "org.springframework:spring-web" ], + "org.testcontainers:testcontainers": [ + "com.github.docker-java:docker-java-api", + "com.github.docker-java:docker-java-transport-zerodep", + "org.apache.commons:commons-compress", + "org.rnorth.duct-tape:duct-tape", + "org.slf4j:slf4j-api" + ], + "org.testcontainers:testcontainers-cassandra": [ + "com.datastax.cassandra:cassandra-driver-core", + "org.testcontainers:testcontainers-database-commons" + ], + "org.testcontainers:testcontainers-database-commons": [ + "org.testcontainers:testcontainers" + ], + "org.testcontainers:testcontainers-junit-jupiter": [ + "org.testcontainers:testcontainers" + ], "org.xmlunit:xmlunit-core": [ "jakarta.xml.bind:jakarta.xml.bind-api" + ], + "software.amazon.awssdk:checksums": [ + "software.amazon.awssdk:annotations", + "software.amazon.awssdk:checksums-spi", + "software.amazon.awssdk:utils" + ], + "software.amazon.awssdk:checksums-spi": [ + "software.amazon.awssdk:annotations" + ], + "software.amazon.awssdk:endpoints-spi": [ + "software.amazon.awssdk:annotations" + ], + "software.amazon.awssdk:http-auth-aws": [ + "software.amazon.awssdk:annotations", + "software.amazon.awssdk:checksums", + "software.amazon.awssdk:checksums-spi", + "software.amazon.awssdk:http-auth-spi", + "software.amazon.awssdk:http-client-spi", + "software.amazon.awssdk:identity-spi", + "software.amazon.awssdk:utils" + ], + "software.amazon.awssdk:http-auth-spi": [ + "org.reactivestreams:reactive-streams", + "software.amazon.awssdk:annotations", + "software.amazon.awssdk:checksums-spi", + "software.amazon.awssdk:http-client-spi", + "software.amazon.awssdk:identity-spi", + "software.amazon.awssdk:utils" + ], + "software.amazon.awssdk:http-client-spi": [ + "org.reactivestreams:reactive-streams", + "software.amazon.awssdk:annotations", + "software.amazon.awssdk:metrics-spi", + "software.amazon.awssdk:utils" + ], + "software.amazon.awssdk:identity-spi": [ + "software.amazon.awssdk:annotations", + "software.amazon.awssdk:utils" + ], + "software.amazon.awssdk:json-utils": [ + "software.amazon.awssdk:annotations", + "software.amazon.awssdk:third-party-jackson-core", + "software.amazon.awssdk:utils" + ], + "software.amazon.awssdk:metrics-spi": [ + "software.amazon.awssdk:annotations", + "software.amazon.awssdk:utils" + ], + "software.amazon.awssdk:profiles": [ + "software.amazon.awssdk:annotations", + "software.amazon.awssdk:utils" + ], + "software.amazon.awssdk:regions": [ + "org.slf4j:slf4j-api", + "software.amazon.awssdk:annotations", + "software.amazon.awssdk:json-utils", + "software.amazon.awssdk:profiles", + "software.amazon.awssdk:sdk-core", + "software.amazon.awssdk:utils" + ], + "software.amazon.awssdk:retries": [ + "software.amazon.awssdk:annotations", + "software.amazon.awssdk:retries-spi", + "software.amazon.awssdk:utils" + ], + "software.amazon.awssdk:retries-spi": [ + "software.amazon.awssdk:annotations", + "software.amazon.awssdk:utils" + ], + "software.amazon.awssdk:sdk-core": [ + "org.reactivestreams:reactive-streams", + "org.slf4j:slf4j-api", + "software.amazon.awssdk:annotations", + "software.amazon.awssdk:checksums", + "software.amazon.awssdk:checksums-spi", + "software.amazon.awssdk:endpoints-spi", + "software.amazon.awssdk:http-auth-aws", + "software.amazon.awssdk:http-auth-spi", + "software.amazon.awssdk:http-client-spi", + "software.amazon.awssdk:identity-spi", + "software.amazon.awssdk:metrics-spi", + "software.amazon.awssdk:profiles", + "software.amazon.awssdk:retries", + "software.amazon.awssdk:retries-spi", + "software.amazon.awssdk:utils" + ], + "software.amazon.awssdk:utils": [ + "org.reactivestreams:reactive-streams", + "org.slf4j:slf4j-api", + "software.amazon.awssdk:annotations" + ], + "tools.jackson.core:jackson-databind": [ + "com.fasterxml.jackson.core:jackson-annotations", + "tools.jackson.core:jackson-core" + ], + "tools.jackson.module:jackson-module-blackbird": [ + "tools.jackson.core:jackson-core", + "tools.jackson.core:jackson-databind" ] }, "packages": { + "aopalliance:aopalliance": [ + "org.aopalliance.aop", + "org.aopalliance.intercept" + ], + "args4j:args4j": [ + "org.kohsuke.args4j", + "org.kohsuke.args4j.spi" + ], + "at.yawk.lz4:lz4-java": [ + "net.jpountz.lz4", + "net.jpountz.util", + "net.jpountz.xxhash" + ], "ch.qos.logback:logback-classic": [ "ch.qos.logback.classic", "ch.qos.logback.classic.boolex", @@ -792,8 +3877,6 @@ "ch.qos.logback.core.joran.action", "ch.qos.logback.core.joran.conditional", "ch.qos.logback.core.joran.event", - "ch.qos.logback.core.joran.event.stax", - "ch.qos.logback.core.joran.node", "ch.qos.logback.core.joran.sanity", "ch.qos.logback.core.joran.spi", "ch.qos.logback.core.joran.util", @@ -805,7 +3888,6 @@ "ch.qos.logback.core.model.processor.conditional", "ch.qos.logback.core.model.util", "ch.qos.logback.core.net", - "ch.qos.logback.core.net.server", "ch.qos.logback.core.net.ssl", "ch.qos.logback.core.pattern", "ch.qos.logback.core.pattern.color", @@ -823,6 +3905,29 @@ "ch.qos.logback.core.testUtil", "ch.qos.logback.core.util" ], + "com.datastax.cassandra:cassandra-driver-core": [ + "com.datastax.driver.core", + "com.datastax.driver.core.exceptions", + "com.datastax.driver.core.policies", + "com.datastax.driver.core.querybuilder", + "com.datastax.driver.core.schemabuilder", + "com.datastax.driver.core.utils" + ], + "com.datastax.oss:native-protocol": [ + "com.datastax.dse.protocol.internal", + "com.datastax.dse.protocol.internal.request", + "com.datastax.dse.protocol.internal.request.query", + "com.datastax.dse.protocol.internal.response.result", + "com.datastax.oss.protocol.internal", + "com.datastax.oss.protocol.internal.request", + "com.datastax.oss.protocol.internal.request.query", + "com.datastax.oss.protocol.internal.response", + "com.datastax.oss.protocol.internal.response.error", + "com.datastax.oss.protocol.internal.response.event", + "com.datastax.oss.protocol.internal.response.result", + "com.datastax.oss.protocol.internal.util", + "com.datastax.oss.protocol.internal.util.collection" + ], "com.fasterxml.jackson.core:jackson-annotations": [ "com.fasterxml.jackson.annotation" ], @@ -833,8 +3938,10 @@ "com.fasterxml.jackson.core.exc", "com.fasterxml.jackson.core.filter", "com.fasterxml.jackson.core.format", + "com.fasterxml.jackson.core.internal.shaded.fdp.v2_21_4", + "com.fasterxml.jackson.core.internal.shaded.fdp.v2_21_4.bte", + "com.fasterxml.jackson.core.internal.shaded.fdp.v2_21_4.chr", "com.fasterxml.jackson.core.io", - "com.fasterxml.jackson.core.io.doubleparser", "com.fasterxml.jackson.core.io.schubfach", "com.fasterxml.jackson.core.json", "com.fasterxml.jackson.core.json.async", @@ -867,8 +3974,10 @@ "com.fasterxml.jackson.databind.util", "com.fasterxml.jackson.databind.util.internal" ], - "com.fasterxml.jackson.datatype:jackson-datatype-jdk8": [ - "com.fasterxml.jackson.datatype.jdk8" + "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml": [ + "com.fasterxml.jackson.dataformat.yaml", + "com.fasterxml.jackson.dataformat.yaml.snakeyaml.error", + "com.fasterxml.jackson.dataformat.yaml.util" ], "com.fasterxml.jackson.datatype:jackson-datatype-jsr310": [ "com.fasterxml.jackson.datatype.jsr310", @@ -878,25 +3987,204 @@ "com.fasterxml.jackson.datatype.jsr310.ser.key", "com.fasterxml.jackson.datatype.jsr310.util" ], - "com.fasterxml.jackson.module:jackson-module-parameter-names": [ - "com.fasterxml.jackson.module.paramnames" + "com.fasterxml:classmate": [ + "com.fasterxml.classmate", + "com.fasterxml.classmate.members", + "com.fasterxml.classmate.types", + "com.fasterxml.classmate.util" + ], + "com.github.ben-manes.caffeine:caffeine": [ + "com.github.benmanes.caffeine.cache", + "com.github.benmanes.caffeine.cache.stats" + ], + "com.github.ben-manes.caffeine:guava": [ + "com.github.benmanes.caffeine.guava" + ], + "com.github.docker-java:docker-java-api": [ + "com.github.dockerjava.api", + "com.github.dockerjava.api.async", + "com.github.dockerjava.api.command", + "com.github.dockerjava.api.exception", + "com.github.dockerjava.api.model" + ], + "com.github.docker-java:docker-java-transport": [ + "com.github.dockerjava.transport" + ], + "com.github.docker-java:docker-java-transport-zerodep": [ + "com.github.dockerjava.zerodep", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.async", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.async.methods", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.auth", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.classic", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.classic.methods", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.config", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.cookie", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.entity", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.entity.mime", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.impl", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.impl.async", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.impl.auth", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.impl.classic", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.impl.compat", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.impl.cookie", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.impl.io", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.impl.nio", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.impl.routing", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.io", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.nio", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.protocol", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.psl", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.routing", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.socket", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.ssl", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.utils", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.validator", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.annotation", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.concurrent", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.function", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http.config", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http.impl", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http.impl.bootstrap", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http.impl.io", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http.impl.nio", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http.impl.routing", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http.io", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http.io.entity", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http.io.ssl", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http.io.support", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http.message", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http.nio", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http.nio.command", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http.nio.entity", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http.nio.ssl", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http.nio.support", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http.nio.support.classic", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http.protocol", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http.ssl", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http.support", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http2", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http2.config", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http2.frame", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http2.hpack", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http2.impl", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http2.impl.io", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http2.impl.nio", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http2.impl.nio.bootstrap", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http2.nio", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http2.nio.command", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http2.nio.pool", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http2.nio.support", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http2.protocol", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.http2.ssl", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.io", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.net", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.pool", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.reactor", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.reactor.ssl", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.ssl", + "com.github.dockerjava.zerodep.shaded.org.apache.hc.core5.util" + ], + "com.github.java-json-tools:btf": [ + "com.github.fge" + ], + "com.github.java-json-tools:jackson-coreutils": [ + "com.github.fge.jackson", + "com.github.fge.jackson.jsonpointer" + ], + "com.github.java-json-tools:json-patch": [ + "com.github.fge.jsonpatch", + "com.github.fge.jsonpatch.diff", + "com.github.fge.jsonpatch.mergepatch" + ], + "com.github.java-json-tools:msg-simple": [ + "com.github.fge.msgsimple", + "com.github.fge.msgsimple.bundle", + "com.github.fge.msgsimple.load", + "com.github.fge.msgsimple.locale", + "com.github.fge.msgsimple.provider", + "com.github.fge.msgsimple.source" + ], + "com.github.jnr:jffi": [ + "com.kenai.jffi", + "com.kenai.jffi.internal" + ], + "com.github.jnr:jnr-constants": [ + "jnr.constants", + "jnr.constants.platform", + "jnr.constants.platform.aix", + "jnr.constants.platform.darwin", + "jnr.constants.platform.dragonflybsd", + "jnr.constants.platform.fake", + "jnr.constants.platform.freebsd", + "jnr.constants.platform.freebsd.aarch64", + "jnr.constants.platform.linux", + "jnr.constants.platform.linux.aarch64", + "jnr.constants.platform.linux.loongarch64", + "jnr.constants.platform.linux.mips64el", + "jnr.constants.platform.linux.powerpc64", + "jnr.constants.platform.linux.s390x", + "jnr.constants.platform.openbsd", + "jnr.constants.platform.solaris", + "jnr.constants.platform.windows" + ], + "com.github.jnr:jnr-ffi": [ + "jnr.ffi", + "jnr.ffi.annotations", + "jnr.ffi.byref", + "jnr.ffi.mapper", + "jnr.ffi.provider", + "jnr.ffi.provider.converters", + "jnr.ffi.provider.jffi", + "jnr.ffi.provider.jffi.platform.aarch64.linux", + "jnr.ffi.provider.jffi.platform.arm.linux", + "jnr.ffi.provider.jffi.platform.i386.darwin", + "jnr.ffi.provider.jffi.platform.i386.freebsd", + "jnr.ffi.provider.jffi.platform.i386.linux", + "jnr.ffi.provider.jffi.platform.i386.openbsd", + "jnr.ffi.provider.jffi.platform.i386.solaris", + "jnr.ffi.provider.jffi.platform.i386.windows", + "jnr.ffi.provider.jffi.platform.mips.linux", + "jnr.ffi.provider.jffi.platform.mipsel.linux", + "jnr.ffi.provider.jffi.platform.ppc.aix", + "jnr.ffi.provider.jffi.platform.ppc.darwin", + "jnr.ffi.provider.jffi.platform.ppc.linux", + "jnr.ffi.provider.jffi.platform.ppc64.linux", + "jnr.ffi.provider.jffi.platform.ppc64le.linux", + "jnr.ffi.provider.jffi.platform.s390.linux", + "jnr.ffi.provider.jffi.platform.s390x.linux", + "jnr.ffi.provider.jffi.platform.sparc.solaris", + "jnr.ffi.provider.jffi.platform.sparcv9.linux", + "jnr.ffi.provider.jffi.platform.sparcv9.solaris", + "jnr.ffi.provider.jffi.platform.x86_64.darwin", + "jnr.ffi.provider.jffi.platform.x86_64.freebsd", + "jnr.ffi.provider.jffi.platform.x86_64.linux", + "jnr.ffi.provider.jffi.platform.x86_64.openbsd", + "jnr.ffi.provider.jffi.platform.x86_64.solaris", + "jnr.ffi.provider.jffi.platform.x86_64.windows", + "jnr.ffi.types", + "jnr.ffi.util", + "jnr.ffi.util.ref", + "jnr.ffi.util.ref.internal" + ], + "com.github.jnr:jnr-posix": [ + "jnr.posix", + "jnr.posix.util", + "jnr.posix.windows" + ], + "com.github.jnr:jnr-x86asm": [ + "com.kenai.jnr.x86asm", + "jnr.x86asm" + ], + "com.github.stephenc.jcip:jcip-annotations": [ + "net.jcip.annotations" ], "com.google.code.findbugs:jsr305": [ "javax.annotation", "javax.annotation.concurrent", "javax.annotation.meta" ], - "com.google.code.gson:gson": [ - "com.google.gson", - "com.google.gson.annotations", - "com.google.gson.internal", - "com.google.gson.internal.bind", - "com.google.gson.internal.bind.util", - "com.google.gson.internal.reflect", - "com.google.gson.internal.sql", - "com.google.gson.reflect", - "com.google.gson.stream" - ], "com.google.errorprone:error_prone_annotations": [ "com.google.errorprone.annotations", "com.google.errorprone.annotations.concurrent" @@ -942,17 +4230,254 @@ "com.jayway.jsonpath.spi.json", "com.jayway.jsonpath.spi.mapper" ], + "com.nimbusds:content-type": [ + "com.nimbusds.common.contenttype" + ], + "com.nimbusds:lang-tag": [ + "com.nimbusds.langtag" + ], + "com.nimbusds:nimbus-jose-jwt": [ + "com.nimbusds.jose", + "com.nimbusds.jose.crypto", + "com.nimbusds.jose.crypto.bc", + "com.nimbusds.jose.crypto.factories", + "com.nimbusds.jose.crypto.impl", + "com.nimbusds.jose.crypto.opts", + "com.nimbusds.jose.crypto.utils", + "com.nimbusds.jose.jca", + "com.nimbusds.jose.jwk", + "com.nimbusds.jose.jwk.gen", + "com.nimbusds.jose.jwk.source", + "com.nimbusds.jose.mint", + "com.nimbusds.jose.proc", + "com.nimbusds.jose.produce", + "com.nimbusds.jose.shaded.gson", + "com.nimbusds.jose.shaded.gson.annotations", + "com.nimbusds.jose.shaded.gson.internal", + "com.nimbusds.jose.shaded.gson.internal.bind", + "com.nimbusds.jose.shaded.gson.internal.bind.util", + "com.nimbusds.jose.shaded.gson.internal.reflect", + "com.nimbusds.jose.shaded.gson.internal.sql", + "com.nimbusds.jose.shaded.gson.reflect", + "com.nimbusds.jose.shaded.gson.stream", + "com.nimbusds.jose.shaded.jcip", + "com.nimbusds.jose.util", + "com.nimbusds.jose.util.cache", + "com.nimbusds.jose.util.events", + "com.nimbusds.jose.util.health", + "com.nimbusds.jwt", + "com.nimbusds.jwt.proc", + "com.nimbusds.jwt.util" + ], + "com.nimbusds:oauth2-oidc-sdk": [ + "com.nimbusds.oauth2.sdk", + "com.nimbusds.oauth2.sdk.as", + "com.nimbusds.oauth2.sdk.assertions", + "com.nimbusds.oauth2.sdk.assertions.jwt", + "com.nimbusds.oauth2.sdk.assertions.saml2", + "com.nimbusds.oauth2.sdk.auth", + "com.nimbusds.oauth2.sdk.auth.verifier", + "com.nimbusds.oauth2.sdk.ciba", + "com.nimbusds.oauth2.sdk.client", + "com.nimbusds.oauth2.sdk.cnf", + "com.nimbusds.oauth2.sdk.device", + "com.nimbusds.oauth2.sdk.dpop", + "com.nimbusds.oauth2.sdk.dpop.verifiers", + "com.nimbusds.oauth2.sdk.http", + "com.nimbusds.oauth2.sdk.id", + "com.nimbusds.oauth2.sdk.jarm", + "com.nimbusds.oauth2.sdk.jose", + "com.nimbusds.oauth2.sdk.pkce", + "com.nimbusds.oauth2.sdk.rar", + "com.nimbusds.oauth2.sdk.token", + "com.nimbusds.oauth2.sdk.tokenexchange", + "com.nimbusds.oauth2.sdk.util", + "com.nimbusds.oauth2.sdk.util.date", + "com.nimbusds.oauth2.sdk.util.singleuse", + "com.nimbusds.oauth2.sdk.util.tls", + "com.nimbusds.openid.connect.sdk", + "com.nimbusds.openid.connect.sdk.assurance", + "com.nimbusds.openid.connect.sdk.assurance.claims", + "com.nimbusds.openid.connect.sdk.assurance.evidences", + "com.nimbusds.openid.connect.sdk.assurance.evidences.attachment", + "com.nimbusds.openid.connect.sdk.assurance.request", + "com.nimbusds.openid.connect.sdk.claims", + "com.nimbusds.openid.connect.sdk.federation", + "com.nimbusds.openid.connect.sdk.federation.api", + "com.nimbusds.openid.connect.sdk.federation.config", + "com.nimbusds.openid.connect.sdk.federation.entities", + "com.nimbusds.openid.connect.sdk.federation.policy", + "com.nimbusds.openid.connect.sdk.federation.policy.language", + "com.nimbusds.openid.connect.sdk.federation.policy.operations", + "com.nimbusds.openid.connect.sdk.federation.registration", + "com.nimbusds.openid.connect.sdk.federation.trust", + "com.nimbusds.openid.connect.sdk.federation.trust.constraints", + "com.nimbusds.openid.connect.sdk.federation.trust.marks", + "com.nimbusds.openid.connect.sdk.federation.utils", + "com.nimbusds.openid.connect.sdk.id", + "com.nimbusds.openid.connect.sdk.nativesso", + "com.nimbusds.openid.connect.sdk.op", + "com.nimbusds.openid.connect.sdk.rp", + "com.nimbusds.openid.connect.sdk.rp.statement", + "com.nimbusds.openid.connect.sdk.token", + "com.nimbusds.openid.connect.sdk.validators", + "com.nimbusds.secevent.sdk.claims" + ], + "com.squareup.okhttp3:okhttp-jvm": [ + "okhttp3", + "okhttp3.internal", + "okhttp3.internal.authenticator", + "okhttp3.internal.cache", + "okhttp3.internal.cache2", + "okhttp3.internal.concurrent", + "okhttp3.internal.connection", + "okhttp3.internal.graal", + "okhttp3.internal.http", + "okhttp3.internal.http1", + "okhttp3.internal.http2", + "okhttp3.internal.http2.flowcontrol", + "okhttp3.internal.idn", + "okhttp3.internal.platform", + "okhttp3.internal.proxy", + "okhttp3.internal.publicsuffix", + "okhttp3.internal.tls", + "okhttp3.internal.url", + "okhttp3.internal.ws" + ], + "com.squareup.okio:okio-jvm": [ + "okio", + "okio.internal" + ], + "com.typesafe:config": [ + "com.typesafe.config", + "com.typesafe.config.impl", + "com.typesafe.config.parser" + ], "com.vaadin.external.google:android-json": [ "org.json" ], + "commons-codec:commons-codec": [ + "org.apache.commons.codec", + "org.apache.commons.codec.binary", + "org.apache.commons.codec.cli", + "org.apache.commons.codec.digest", + "org.apache.commons.codec.language", + "org.apache.commons.codec.language.bm", + "org.apache.commons.codec.net" + ], + "commons-io:commons-io": [ + "org.apache.commons.io", + "org.apache.commons.io.build", + "org.apache.commons.io.channels", + "org.apache.commons.io.charset", + "org.apache.commons.io.comparator", + "org.apache.commons.io.file", + "org.apache.commons.io.file.attribute", + "org.apache.commons.io.file.spi", + "org.apache.commons.io.filefilter", + "org.apache.commons.io.function", + "org.apache.commons.io.input", + "org.apache.commons.io.input.buffer", + "org.apache.commons.io.monitor", + "org.apache.commons.io.output", + "org.apache.commons.io.serialization" + ], + "commons-logging:commons-logging": [ + "org.apache.commons.logging", + "org.apache.commons.logging.impl" + ], + "io.cloudevents:cloudevents-api": [ + "io.cloudevents", + "io.cloudevents.lang", + "io.cloudevents.rw", + "io.cloudevents.types" + ], + "io.cloudevents:cloudevents-core": [ + "io.cloudevents.core", + "io.cloudevents.core.builder", + "io.cloudevents.core.data", + "io.cloudevents.core.extensions", + "io.cloudevents.core.extensions.impl", + "io.cloudevents.core.format", + "io.cloudevents.core.impl", + "io.cloudevents.core.message", + "io.cloudevents.core.message.impl", + "io.cloudevents.core.provider", + "io.cloudevents.core.v03", + "io.cloudevents.core.v1", + "io.cloudevents.core.validator" + ], + "io.cloudevents:cloudevents-json-jackson": [ + "io.cloudevents.jackson" + ], + "io.dropwizard.metrics:metrics-core": [ + "com.codahale.metrics" + ], + "io.micrometer:context-propagation": [ + "io.micrometer.context", + "io.micrometer.context.integration" + ], "io.micrometer:micrometer-commons": [ "io.micrometer.common", "io.micrometer.common.annotation", "io.micrometer.common.docs", "io.micrometer.common.lang", + "io.micrometer.common.lang.internal", "io.micrometer.common.util", "io.micrometer.common.util.internal.logging" ], + "io.micrometer:micrometer-core": [ + "io.micrometer.core.annotation", + "io.micrometer.core.aop", + "io.micrometer.core.instrument", + "io.micrometer.core.instrument.binder", + "io.micrometer.core.instrument.binder.cache", + "io.micrometer.core.instrument.binder.commonspool2", + "io.micrometer.core.instrument.binder.db", + "io.micrometer.core.instrument.binder.grpc", + "io.micrometer.core.instrument.binder.http", + "io.micrometer.core.instrument.binder.httpcomponents", + "io.micrometer.core.instrument.binder.httpcomponents.hc5", + "io.micrometer.core.instrument.binder.hystrix", + "io.micrometer.core.instrument.binder.jersey.server", + "io.micrometer.core.instrument.binder.jetty", + "io.micrometer.core.instrument.binder.jpa", + "io.micrometer.core.instrument.binder.jvm", + "io.micrometer.core.instrument.binder.jvm.convention", + "io.micrometer.core.instrument.binder.jvm.convention.micrometer", + "io.micrometer.core.instrument.binder.jvm.convention.otel", + "io.micrometer.core.instrument.binder.kafka", + "io.micrometer.core.instrument.binder.logging", + "io.micrometer.core.instrument.binder.mongodb", + "io.micrometer.core.instrument.binder.netty4", + "io.micrometer.core.instrument.binder.okhttp3", + "io.micrometer.core.instrument.binder.system", + "io.micrometer.core.instrument.binder.tomcat", + "io.micrometer.core.instrument.composite", + "io.micrometer.core.instrument.config", + "io.micrometer.core.instrument.config.validate", + "io.micrometer.core.instrument.cumulative", + "io.micrometer.core.instrument.distribution", + "io.micrometer.core.instrument.distribution.pause", + "io.micrometer.core.instrument.docs", + "io.micrometer.core.instrument.dropwizard", + "io.micrometer.core.instrument.internal", + "io.micrometer.core.instrument.kotlin", + "io.micrometer.core.instrument.logging", + "io.micrometer.core.instrument.noop", + "io.micrometer.core.instrument.observation", + "io.micrometer.core.instrument.push", + "io.micrometer.core.instrument.search", + "io.micrometer.core.instrument.simple", + "io.micrometer.core.instrument.step", + "io.micrometer.core.instrument.util", + "io.micrometer.core.ipc.http", + "io.micrometer.core.util.internal.logging" + ], + "io.micrometer:micrometer-jakarta9": [ + "io.micrometer.jakarta9.instrument.jms", + "io.micrometer.jakarta9.instrument.mail" + ], "io.micrometer:micrometer-observation": [ "io.micrometer.observation", "io.micrometer.observation.annotation", @@ -961,6 +4486,352 @@ "io.micrometer.observation.docs", "io.micrometer.observation.transport" ], + "io.micrometer:micrometer-observation-test": [ + "io.micrometer.observation.tck" + ], + "io.micrometer:micrometer-tracing": [ + "io.micrometer.tracing", + "io.micrometer.tracing.annotation", + "io.micrometer.tracing.contextpropagation", + "io.micrometer.tracing.contextpropagation.reactor", + "io.micrometer.tracing.docs", + "io.micrometer.tracing.exporter", + "io.micrometer.tracing.handler", + "io.micrometer.tracing.internal", + "io.micrometer.tracing.propagation" + ], + "io.micrometer:micrometer-tracing-bridge-otel": [ + "io.micrometer.tracing.otel", + "io.micrometer.tracing.otel.bridge", + "io.micrometer.tracing.otel.propagation" + ], + "io.nats:jnats": [ + "io.nats.client", + "io.nats.client.api", + "io.nats.client.impl", + "io.nats.client.support", + "io.nats.service" + ], + "io.netty:netty-buffer": [ + "io.netty.buffer", + "io.netty.buffer.search" + ], + "io.netty:netty-codec-base": [ + "io.netty.handler.codec", + "io.netty.handler.codec.base64", + "io.netty.handler.codec.bytes", + "io.netty.handler.codec.json", + "io.netty.handler.codec.serialization", + "io.netty.handler.codec.string" + ], + "io.netty:netty-codec-classes-quic": [ + "io.netty.handler.codec.quic" + ], + "io.netty:netty-codec-compression": [ + "io.netty.handler.codec.compression" + ], + "io.netty:netty-codec-dns": [ + "io.netty.handler.codec.dns" + ], + "io.netty:netty-codec-http": [ + "io.netty.handler.codec.http", + "io.netty.handler.codec.http.cookie", + "io.netty.handler.codec.http.cors", + "io.netty.handler.codec.http.multipart", + "io.netty.handler.codec.http.websocketx", + "io.netty.handler.codec.http.websocketx.extensions", + "io.netty.handler.codec.http.websocketx.extensions.compression", + "io.netty.handler.codec.rtsp", + "io.netty.handler.codec.spdy" + ], + "io.netty:netty-codec-http2": [ + "io.netty.handler.codec.http2" + ], + "io.netty:netty-codec-http3": [ + "io.netty.handler.codec.http3" + ], + "io.netty:netty-codec-socks": [ + "io.netty.handler.codec.socks", + "io.netty.handler.codec.socksx", + "io.netty.handler.codec.socksx.v4", + "io.netty.handler.codec.socksx.v5" + ], + "io.netty:netty-common": [ + "io.netty.util", + "io.netty.util.collection", + "io.netty.util.concurrent", + "io.netty.util.internal", + "io.netty.util.internal.logging", + "io.netty.util.internal.shaded.org.jctools.counters", + "io.netty.util.internal.shaded.org.jctools.maps", + "io.netty.util.internal.shaded.org.jctools.queues", + "io.netty.util.internal.shaded.org.jctools.queues.atomic", + "io.netty.util.internal.shaded.org.jctools.queues.atomic.unpadded", + "io.netty.util.internal.shaded.org.jctools.queues.unpadded", + "io.netty.util.internal.shaded.org.jctools.util", + "io.netty.util.internal.svm" + ], + "io.netty:netty-handler": [ + "io.netty.handler.address", + "io.netty.handler.flow", + "io.netty.handler.flush", + "io.netty.handler.ipfilter", + "io.netty.handler.logging", + "io.netty.handler.pcap", + "io.netty.handler.ssl", + "io.netty.handler.ssl.util", + "io.netty.handler.stream", + "io.netty.handler.timeout", + "io.netty.handler.traffic" + ], + "io.netty:netty-handler-proxy": [ + "io.netty.handler.proxy" + ], + "io.netty:netty-resolver": [ + "io.netty.resolver" + ], + "io.netty:netty-resolver-dns": [ + "io.netty.resolver.dns" + ], + "io.netty:netty-resolver-dns-classes-macos": [ + "io.netty.resolver.dns.macos" + ], + "io.netty:netty-transport": [ + "io.netty.bootstrap", + "io.netty.channel", + "io.netty.channel.embedded", + "io.netty.channel.group", + "io.netty.channel.internal", + "io.netty.channel.local", + "io.netty.channel.nio", + "io.netty.channel.oio", + "io.netty.channel.pool", + "io.netty.channel.socket", + "io.netty.channel.socket.nio", + "io.netty.channel.socket.oio" + ], + "io.netty:netty-transport-classes-epoll": [ + "io.netty.channel.epoll" + ], + "io.netty:netty-transport-native-unix-common": [ + "io.netty.channel.unix" + ], + "io.opentelemetry.semconv:opentelemetry-semconv": [ + "io.opentelemetry.semconv" + ], + "io.opentelemetry:opentelemetry-api": [ + "io.opentelemetry.api", + "io.opentelemetry.api.baggage", + "io.opentelemetry.api.baggage.propagation", + "io.opentelemetry.api.common", + "io.opentelemetry.api.internal", + "io.opentelemetry.api.logs", + "io.opentelemetry.api.metrics", + "io.opentelemetry.api.trace", + "io.opentelemetry.api.trace.propagation", + "io.opentelemetry.api.trace.propagation.internal" + ], + "io.opentelemetry:opentelemetry-common": [ + "io.opentelemetry.common" + ], + "io.opentelemetry:opentelemetry-context": [ + "io.opentelemetry.context", + "io.opentelemetry.context.internal.shaded", + "io.opentelemetry.context.propagation", + "io.opentelemetry.context.propagation.internal" + ], + "io.opentelemetry:opentelemetry-exporter-common": [ + "io.opentelemetry.exporter.internal", + "io.opentelemetry.exporter.internal.compression", + "io.opentelemetry.exporter.internal.grpc", + "io.opentelemetry.exporter.internal.http", + "io.opentelemetry.exporter.internal.marshal", + "io.opentelemetry.exporter.internal.metrics" + ], + "io.opentelemetry:opentelemetry-exporter-otlp": [ + "io.opentelemetry.exporter.otlp.all.internal", + "io.opentelemetry.exporter.otlp.http.logs", + "io.opentelemetry.exporter.otlp.http.metrics", + "io.opentelemetry.exporter.otlp.http.trace", + "io.opentelemetry.exporter.otlp.internal", + "io.opentelemetry.exporter.otlp.logs", + "io.opentelemetry.exporter.otlp.metrics", + "io.opentelemetry.exporter.otlp.trace" + ], + "io.opentelemetry:opentelemetry-exporter-otlp-common": [ + "io.opentelemetry.exporter.internal.otlp", + "io.opentelemetry.exporter.internal.otlp.logs", + "io.opentelemetry.exporter.internal.otlp.metrics", + "io.opentelemetry.exporter.internal.otlp.traces", + "io.opentelemetry.proto.collector.logs.v1.internal", + "io.opentelemetry.proto.collector.metrics.v1.internal", + "io.opentelemetry.proto.collector.profiles.v1development.internal", + "io.opentelemetry.proto.collector.trace.v1.internal", + "io.opentelemetry.proto.common.v1.internal", + "io.opentelemetry.proto.logs.v1.internal", + "io.opentelemetry.proto.metrics.v1.internal", + "io.opentelemetry.proto.profiles.v1development.internal", + "io.opentelemetry.proto.resource.v1.internal", + "io.opentelemetry.proto.trace.v1.internal" + ], + "io.opentelemetry:opentelemetry-exporter-sender-okhttp": [ + "io.opentelemetry.exporter.sender.okhttp.internal" + ], + "io.opentelemetry:opentelemetry-extension-trace-propagators": [ + "io.opentelemetry.extension.trace.propagation", + "io.opentelemetry.extension.trace.propagation.internal" + ], + "io.opentelemetry:opentelemetry-sdk": [ + "io.opentelemetry.sdk" + ], + "io.opentelemetry:opentelemetry-sdk-common": [ + "io.opentelemetry.sdk.common", + "io.opentelemetry.sdk.common.export", + "io.opentelemetry.sdk.common.internal", + "io.opentelemetry.sdk.internal", + "io.opentelemetry.sdk.resources" + ], + "io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi": [ + "io.opentelemetry.sdk.autoconfigure.spi", + "io.opentelemetry.sdk.autoconfigure.spi.internal", + "io.opentelemetry.sdk.autoconfigure.spi.logs", + "io.opentelemetry.sdk.autoconfigure.spi.metrics", + "io.opentelemetry.sdk.autoconfigure.spi.traces" + ], + "io.opentelemetry:opentelemetry-sdk-logs": [ + "io.opentelemetry.sdk.logs", + "io.opentelemetry.sdk.logs.data", + "io.opentelemetry.sdk.logs.data.internal", + "io.opentelemetry.sdk.logs.export", + "io.opentelemetry.sdk.logs.internal" + ], + "io.opentelemetry:opentelemetry-sdk-metrics": [ + "io.opentelemetry.sdk.metrics", + "io.opentelemetry.sdk.metrics.data", + "io.opentelemetry.sdk.metrics.export", + "io.opentelemetry.sdk.metrics.internal", + "io.opentelemetry.sdk.metrics.internal.aggregator", + "io.opentelemetry.sdk.metrics.internal.concurrent", + "io.opentelemetry.sdk.metrics.internal.data", + "io.opentelemetry.sdk.metrics.internal.debug", + "io.opentelemetry.sdk.metrics.internal.descriptor", + "io.opentelemetry.sdk.metrics.internal.exemplar", + "io.opentelemetry.sdk.metrics.internal.export", + "io.opentelemetry.sdk.metrics.internal.state", + "io.opentelemetry.sdk.metrics.internal.view" + ], + "io.opentelemetry:opentelemetry-sdk-testing": [ + "io.opentelemetry.sdk.testing.assertj", + "io.opentelemetry.sdk.testing.context", + "io.opentelemetry.sdk.testing.exporter", + "io.opentelemetry.sdk.testing.junit4", + "io.opentelemetry.sdk.testing.junit5", + "io.opentelemetry.sdk.testing.logs", + "io.opentelemetry.sdk.testing.logs.internal", + "io.opentelemetry.sdk.testing.metrics", + "io.opentelemetry.sdk.testing.time", + "io.opentelemetry.sdk.testing.trace" + ], + "io.opentelemetry:opentelemetry-sdk-trace": [ + "io.opentelemetry.internal.shaded.jctools.counters", + "io.opentelemetry.internal.shaded.jctools.maps", + "io.opentelemetry.internal.shaded.jctools.queues", + "io.opentelemetry.internal.shaded.jctools.queues.atomic", + "io.opentelemetry.internal.shaded.jctools.queues.atomic.unpadded", + "io.opentelemetry.internal.shaded.jctools.queues.unpadded", + "io.opentelemetry.internal.shaded.jctools.util", + "io.opentelemetry.sdk.trace", + "io.opentelemetry.sdk.trace.data", + "io.opentelemetry.sdk.trace.export", + "io.opentelemetry.sdk.trace.internal", + "io.opentelemetry.sdk.trace.samplers" + ], + "io.projectreactor.netty:reactor-netty-core": [ + "reactor.netty", + "reactor.netty.channel", + "reactor.netty.contextpropagation", + "reactor.netty.internal.shaded.reactor.pool", + "reactor.netty.internal.shaded.reactor.pool.decorators", + "reactor.netty.internal.shaded.reactor.pool.introspection", + "reactor.netty.internal.util", + "reactor.netty.observability", + "reactor.netty.resources", + "reactor.netty.tcp", + "reactor.netty.transport", + "reactor.netty.transport.logging", + "reactor.netty.udp" + ], + "io.projectreactor.netty:reactor-netty-http": [ + "reactor.netty.http", + "reactor.netty.http.client", + "reactor.netty.http.internal", + "reactor.netty.http.logging", + "reactor.netty.http.observability", + "reactor.netty.http.server", + "reactor.netty.http.server.compression", + "reactor.netty.http.server.logging", + "reactor.netty.http.server.logging.error", + "reactor.netty.http.websocket" + ], + "io.projectreactor:reactor-core": [ + "reactor.adapter", + "reactor.core", + "reactor.core.observability", + "reactor.core.publisher", + "reactor.core.scheduler", + "reactor.util", + "reactor.util.annotation", + "reactor.util.concurrent", + "reactor.util.context", + "reactor.util.function", + "reactor.util.repeat", + "reactor.util.retry" + ], + "io.projectreactor:reactor-test": [ + "reactor.test", + "reactor.test.publisher", + "reactor.test.scheduler", + "reactor.test.subscriber", + "reactor.test.util" + ], + "io.swagger.core.v3:swagger-annotations-jakarta": [ + "io.swagger.v3.oas.annotations", + "io.swagger.v3.oas.annotations.callbacks", + "io.swagger.v3.oas.annotations.enums", + "io.swagger.v3.oas.annotations.extensions", + "io.swagger.v3.oas.annotations.headers", + "io.swagger.v3.oas.annotations.info", + "io.swagger.v3.oas.annotations.links", + "io.swagger.v3.oas.annotations.media", + "io.swagger.v3.oas.annotations.parameters", + "io.swagger.v3.oas.annotations.responses", + "io.swagger.v3.oas.annotations.security", + "io.swagger.v3.oas.annotations.servers", + "io.swagger.v3.oas.annotations.tags" + ], + "io.swagger.core.v3:swagger-core-jakarta": [ + "io.swagger.v3.core.converter", + "io.swagger.v3.core.filter", + "io.swagger.v3.core.jackson", + "io.swagger.v3.core.jackson.mixin", + "io.swagger.v3.core.model", + "io.swagger.v3.core.util" + ], + "io.swagger.core.v3:swagger-models-jakarta": [ + "io.swagger.v3.oas.models", + "io.swagger.v3.oas.models.annotations", + "io.swagger.v3.oas.models.callbacks", + "io.swagger.v3.oas.models.examples", + "io.swagger.v3.oas.models.headers", + "io.swagger.v3.oas.models.info", + "io.swagger.v3.oas.models.links", + "io.swagger.v3.oas.models.media", + "io.swagger.v3.oas.models.parameters", + "io.swagger.v3.oas.models.responses", + "io.swagger.v3.oas.models.security", + "io.swagger.v3.oas.models.servers", + "io.swagger.v3.oas.models.tags" + ], "jakarta.activation:jakarta.activation-api": [ "jakarta.activation", "jakarta.activation.spi" @@ -970,6 +4841,23 @@ "jakarta.annotation.security", "jakarta.annotation.sql" ], + "jakarta.servlet:jakarta.servlet-api": [ + "jakarta.servlet", + "jakarta.servlet.annotation", + "jakarta.servlet.descriptor", + "jakarta.servlet.http" + ], + "jakarta.validation:jakarta.validation-api": [ + "jakarta.validation", + "jakarta.validation.bootstrap", + "jakarta.validation.constraints", + "jakarta.validation.constraintvalidation", + "jakarta.validation.executable", + "jakarta.validation.groups", + "jakarta.validation.metadata", + "jakarta.validation.spi", + "jakarta.validation.valueextraction" + ], "jakarta.xml.bind:jakarta.xml.bind-api": [ "jakarta.xml.bind", "jakarta.xml.bind.annotation", @@ -1010,6 +4898,7 @@ "net.bytebuddy.jar.asm", "net.bytebuddy.jar.asm.commons", "net.bytebuddy.jar.asm.signature", + "net.bytebuddy.jar.asmjdkbridge", "net.bytebuddy.matcher", "net.bytebuddy.pool", "net.bytebuddy.utility", @@ -1022,6 +4911,12 @@ "net.bytebuddy.agent", "net.bytebuddy.agent.utility.nullability" ], + "net.java.dev.jna:jna": [ + "com.sun.jna", + "com.sun.jna.internal", + "com.sun.jna.ptr", + "com.sun.jna.win32" + ], "net.minidev:accessors-smart": [ "net.minidev.asm", "net.minidev.asm.ex" @@ -1033,11 +4928,247 @@ "net.minidev.json.reader", "net.minidev.json.writer" ], + "org.apache.cassandra:java-driver-core": [ + "com.datastax.dse.driver.api.core", + "com.datastax.dse.driver.api.core.auth", + "com.datastax.dse.driver.api.core.config", + "com.datastax.dse.driver.api.core.cql.continuous", + "com.datastax.dse.driver.api.core.cql.continuous.reactive", + "com.datastax.dse.driver.api.core.cql.reactive", + "com.datastax.dse.driver.api.core.data.geometry", + "com.datastax.dse.driver.api.core.data.time", + "com.datastax.dse.driver.api.core.graph", + "com.datastax.dse.driver.api.core.graph.predicates", + "com.datastax.dse.driver.api.core.graph.reactive", + "com.datastax.dse.driver.api.core.metadata", + "com.datastax.dse.driver.api.core.metadata.schema", + "com.datastax.dse.driver.api.core.metrics", + "com.datastax.dse.driver.api.core.servererrors", + "com.datastax.dse.driver.api.core.type", + "com.datastax.dse.driver.api.core.type.codec", + "com.datastax.dse.driver.internal.core", + "com.datastax.dse.driver.internal.core.auth", + "com.datastax.dse.driver.internal.core.cql", + "com.datastax.dse.driver.internal.core.cql.continuous", + "com.datastax.dse.driver.internal.core.cql.continuous.reactive", + "com.datastax.dse.driver.internal.core.cql.reactive", + "com.datastax.dse.driver.internal.core.data.geometry", + "com.datastax.dse.driver.internal.core.graph", + "com.datastax.dse.driver.internal.core.graph.binary", + "com.datastax.dse.driver.internal.core.graph.binary.buffer", + "com.datastax.dse.driver.internal.core.graph.reactive", + "com.datastax.dse.driver.internal.core.insights", + "com.datastax.dse.driver.internal.core.insights.configuration", + "com.datastax.dse.driver.internal.core.insights.exceptions", + "com.datastax.dse.driver.internal.core.insights.schema", + "com.datastax.dse.driver.internal.core.loadbalancing", + "com.datastax.dse.driver.internal.core.metadata.schema", + "com.datastax.dse.driver.internal.core.metadata.schema.parsing", + "com.datastax.dse.driver.internal.core.protocol", + "com.datastax.dse.driver.internal.core.search", + "com.datastax.dse.driver.internal.core.session", + "com.datastax.dse.driver.internal.core.type.codec", + "com.datastax.dse.driver.internal.core.type.codec.geometry", + "com.datastax.dse.driver.internal.core.type.codec.time", + "com.datastax.dse.driver.internal.core.util.concurrent", + "com.datastax.oss.driver.api.core", + "com.datastax.oss.driver.api.core.addresstranslation", + "com.datastax.oss.driver.api.core.auth", + "com.datastax.oss.driver.api.core.config", + "com.datastax.oss.driver.api.core.connection", + "com.datastax.oss.driver.api.core.context", + "com.datastax.oss.driver.api.core.cql", + "com.datastax.oss.driver.api.core.data", + "com.datastax.oss.driver.api.core.detach", + "com.datastax.oss.driver.api.core.loadbalancing", + "com.datastax.oss.driver.api.core.metadata", + "com.datastax.oss.driver.api.core.metadata.schema", + "com.datastax.oss.driver.api.core.metadata.token", + "com.datastax.oss.driver.api.core.metrics", + "com.datastax.oss.driver.api.core.paging", + "com.datastax.oss.driver.api.core.retry", + "com.datastax.oss.driver.api.core.servererrors", + "com.datastax.oss.driver.api.core.session", + "com.datastax.oss.driver.api.core.session.throttling", + "com.datastax.oss.driver.api.core.specex", + "com.datastax.oss.driver.api.core.ssl", + "com.datastax.oss.driver.api.core.time", + "com.datastax.oss.driver.api.core.tracker", + "com.datastax.oss.driver.api.core.type", + "com.datastax.oss.driver.api.core.type.codec", + "com.datastax.oss.driver.api.core.type.codec.registry", + "com.datastax.oss.driver.api.core.type.reflect", + "com.datastax.oss.driver.api.core.uuid", + "com.datastax.oss.driver.internal.core", + "com.datastax.oss.driver.internal.core.addresstranslation", + "com.datastax.oss.driver.internal.core.adminrequest", + "com.datastax.oss.driver.internal.core.auth", + "com.datastax.oss.driver.internal.core.channel", + "com.datastax.oss.driver.internal.core.config", + "com.datastax.oss.driver.internal.core.config.cloud", + "com.datastax.oss.driver.internal.core.config.composite", + "com.datastax.oss.driver.internal.core.config.map", + "com.datastax.oss.driver.internal.core.config.typesafe", + "com.datastax.oss.driver.internal.core.connection", + "com.datastax.oss.driver.internal.core.context", + "com.datastax.oss.driver.internal.core.control", + "com.datastax.oss.driver.internal.core.cql", + "com.datastax.oss.driver.internal.core.data", + "com.datastax.oss.driver.internal.core.loadbalancing", + "com.datastax.oss.driver.internal.core.loadbalancing.helper", + "com.datastax.oss.driver.internal.core.loadbalancing.nodeset", + "com.datastax.oss.driver.internal.core.metadata", + "com.datastax.oss.driver.internal.core.metadata.schema", + "com.datastax.oss.driver.internal.core.metadata.schema.events", + "com.datastax.oss.driver.internal.core.metadata.schema.parsing", + "com.datastax.oss.driver.internal.core.metadata.schema.queries", + "com.datastax.oss.driver.internal.core.metadata.schema.refresh", + "com.datastax.oss.driver.internal.core.metadata.token", + "com.datastax.oss.driver.internal.core.metrics", + "com.datastax.oss.driver.internal.core.os", + "com.datastax.oss.driver.internal.core.pool", + "com.datastax.oss.driver.internal.core.protocol", + "com.datastax.oss.driver.internal.core.retry", + "com.datastax.oss.driver.internal.core.servererrors", + "com.datastax.oss.driver.internal.core.session", + "com.datastax.oss.driver.internal.core.session.throttling", + "com.datastax.oss.driver.internal.core.specex", + "com.datastax.oss.driver.internal.core.ssl", + "com.datastax.oss.driver.internal.core.time", + "com.datastax.oss.driver.internal.core.tracker", + "com.datastax.oss.driver.internal.core.type", + "com.datastax.oss.driver.internal.core.type.codec", + "com.datastax.oss.driver.internal.core.type.codec.extras", + "com.datastax.oss.driver.internal.core.type.codec.extras.array", + "com.datastax.oss.driver.internal.core.type.codec.extras.enums", + "com.datastax.oss.driver.internal.core.type.codec.extras.json", + "com.datastax.oss.driver.internal.core.type.codec.extras.time", + "com.datastax.oss.driver.internal.core.type.codec.extras.vector", + "com.datastax.oss.driver.internal.core.type.codec.registry", + "com.datastax.oss.driver.internal.core.type.util", + "com.datastax.oss.driver.internal.core.util", + "com.datastax.oss.driver.internal.core.util.collection", + "com.datastax.oss.driver.internal.core.util.concurrent" + ], + "org.apache.cassandra:java-driver-guava-shaded": [ + "com.datastax.oss.driver.shaded.guava.common.annotations", + "com.datastax.oss.driver.shaded.guava.common.base", + "com.datastax.oss.driver.shaded.guava.common.base.internal", + "com.datastax.oss.driver.shaded.guava.common.cache", + "com.datastax.oss.driver.shaded.guava.common.collect", + "com.datastax.oss.driver.shaded.guava.common.escape", + "com.datastax.oss.driver.shaded.guava.common.eventbus", + "com.datastax.oss.driver.shaded.guava.common.graph", + "com.datastax.oss.driver.shaded.guava.common.hash", + "com.datastax.oss.driver.shaded.guava.common.html", + "com.datastax.oss.driver.shaded.guava.common.io", + "com.datastax.oss.driver.shaded.guava.common.math", + "com.datastax.oss.driver.shaded.guava.common.net", + "com.datastax.oss.driver.shaded.guava.common.primitives", + "com.datastax.oss.driver.shaded.guava.common.reflect", + "com.datastax.oss.driver.shaded.guava.common.util.concurrent", + "com.datastax.oss.driver.shaded.guava.common.util.concurrent.internal", + "com.datastax.oss.driver.shaded.guava.common.xml", + "com.datastax.oss.driver.shaded.guava.j2objc.annotations", + "com.datastax.oss.driver.shaded.guava.thirdparty.publicsuffix" + ], + "org.apache.cassandra:java-driver-metrics-micrometer": [ + "com.datastax.oss.driver.internal.metrics.micrometer" + ], + "org.apache.cassandra:java-driver-query-builder": [ + "com.datastax.dse.driver.api.querybuilder", + "com.datastax.dse.driver.api.querybuilder.schema", + "com.datastax.dse.driver.internal.querybuilder.schema", + "com.datastax.oss.driver.api.querybuilder", + "com.datastax.oss.driver.api.querybuilder.condition", + "com.datastax.oss.driver.api.querybuilder.delete", + "com.datastax.oss.driver.api.querybuilder.insert", + "com.datastax.oss.driver.api.querybuilder.relation", + "com.datastax.oss.driver.api.querybuilder.schema", + "com.datastax.oss.driver.api.querybuilder.schema.compaction", + "com.datastax.oss.driver.api.querybuilder.select", + "com.datastax.oss.driver.api.querybuilder.term", + "com.datastax.oss.driver.api.querybuilder.truncate", + "com.datastax.oss.driver.api.querybuilder.update", + "com.datastax.oss.driver.internal.querybuilder", + "com.datastax.oss.driver.internal.querybuilder.condition", + "com.datastax.oss.driver.internal.querybuilder.delete", + "com.datastax.oss.driver.internal.querybuilder.insert", + "com.datastax.oss.driver.internal.querybuilder.lhs", + "com.datastax.oss.driver.internal.querybuilder.relation", + "com.datastax.oss.driver.internal.querybuilder.schema", + "com.datastax.oss.driver.internal.querybuilder.schema.compaction", + "com.datastax.oss.driver.internal.querybuilder.select", + "com.datastax.oss.driver.internal.querybuilder.term", + "com.datastax.oss.driver.internal.querybuilder.truncate", + "com.datastax.oss.driver.internal.querybuilder.update" + ], + "org.apache.commons:commons-compress": [ + "org.apache.commons.compress", + "org.apache.commons.compress.archivers", + "org.apache.commons.compress.archivers.ar", + "org.apache.commons.compress.archivers.arj", + "org.apache.commons.compress.archivers.cpio", + "org.apache.commons.compress.archivers.dump", + "org.apache.commons.compress.archivers.examples", + "org.apache.commons.compress.archivers.jar", + "org.apache.commons.compress.archivers.sevenz", + "org.apache.commons.compress.archivers.tar", + "org.apache.commons.compress.archivers.zip", + "org.apache.commons.compress.changes", + "org.apache.commons.compress.compressors", + "org.apache.commons.compress.compressors.brotli", + "org.apache.commons.compress.compressors.bzip2", + "org.apache.commons.compress.compressors.deflate", + "org.apache.commons.compress.compressors.deflate64", + "org.apache.commons.compress.compressors.gzip", + "org.apache.commons.compress.compressors.lz4", + "org.apache.commons.compress.compressors.lz77support", + "org.apache.commons.compress.compressors.lzma", + "org.apache.commons.compress.compressors.lzw", + "org.apache.commons.compress.compressors.pack200", + "org.apache.commons.compress.compressors.snappy", + "org.apache.commons.compress.compressors.xz", + "org.apache.commons.compress.compressors.z", + "org.apache.commons.compress.compressors.zstandard", + "org.apache.commons.compress.harmony", + "org.apache.commons.compress.harmony.archive.internal.nls", + "org.apache.commons.compress.harmony.pack200", + "org.apache.commons.compress.harmony.unpack200", + "org.apache.commons.compress.harmony.unpack200.bytecode", + "org.apache.commons.compress.harmony.unpack200.bytecode.forms", + "org.apache.commons.compress.java.util.jar", + "org.apache.commons.compress.parallel", + "org.apache.commons.compress.utils" + ], + "org.apache.commons:commons-lang3": [ + "org.apache.commons.lang3", + "org.apache.commons.lang3.arch", + "org.apache.commons.lang3.builder", + "org.apache.commons.lang3.compare", + "org.apache.commons.lang3.concurrent", + "org.apache.commons.lang3.concurrent.locks", + "org.apache.commons.lang3.event", + "org.apache.commons.lang3.exception", + "org.apache.commons.lang3.function", + "org.apache.commons.lang3.math", + "org.apache.commons.lang3.mutable", + "org.apache.commons.lang3.reflect", + "org.apache.commons.lang3.stream", + "org.apache.commons.lang3.text", + "org.apache.commons.lang3.text.translate", + "org.apache.commons.lang3.time", + "org.apache.commons.lang3.tuple", + "org.apache.commons.lang3.util" + ], "org.apache.logging.log4j:log4j-api": [ "org.apache.logging.log4j", "org.apache.logging.log4j.internal", + "org.apache.logging.log4j.internal.annotation", + "org.apache.logging.log4j.internal.map", "org.apache.logging.log4j.message", "org.apache.logging.log4j.simple", + "org.apache.logging.log4j.simple.internal", "org.apache.logging.log4j.spi", "org.apache.logging.log4j.status", "org.apache.logging.log4j.util", @@ -1089,7 +5220,6 @@ "org.apache.juli.logging", "org.apache.naming", "org.apache.naming.factory", - "org.apache.naming.factory.webservices", "org.apache.naming.java", "org.apache.tomcat", "org.apache.tomcat.jni", @@ -1097,9 +5227,9 @@ "org.apache.tomcat.util.bcel", "org.apache.tomcat.util.bcel.classfile", "org.apache.tomcat.util.buf", - "org.apache.tomcat.util.codec.binary", "org.apache.tomcat.util.collections", "org.apache.tomcat.util.compat", + "org.apache.tomcat.util.concurrent", "org.apache.tomcat.util.descriptor", "org.apache.tomcat.util.descriptor.tagplugin", "org.apache.tomcat.util.descriptor.web", @@ -1145,6 +5275,7 @@ "org.apiguardian.api" ], "org.assertj:assertj-core": [ + "org.assertj.core.annotation", "org.assertj.core.annotations", "org.assertj.core.api", "org.assertj.core.api.exception", @@ -1165,6 +5296,7 @@ "org.assertj.core.extractor", "org.assertj.core.groups", "org.assertj.core.internal", + "org.assertj.core.internal.annotation", "org.assertj.core.matcher", "org.assertj.core.presentation", "org.assertj.core.util", @@ -1183,37 +5315,347 @@ "org.awaitility.reflect.exception", "org.awaitility.spi" ], - "org.checkerframework:checker-qual": [ - "org.checkerframework.checker.builder.qual", - "org.checkerframework.checker.calledmethods.qual", - "org.checkerframework.checker.compilermsgs.qual", - "org.checkerframework.checker.fenum.qual", - "org.checkerframework.checker.formatter.qual", - "org.checkerframework.checker.guieffect.qual", - "org.checkerframework.checker.i18n.qual", - "org.checkerframework.checker.i18nformatter.qual", - "org.checkerframework.checker.index.qual", - "org.checkerframework.checker.initialization.qual", - "org.checkerframework.checker.interning.qual", - "org.checkerframework.checker.lock.qual", - "org.checkerframework.checker.mustcall.qual", - "org.checkerframework.checker.nullness.qual", - "org.checkerframework.checker.optional.qual", - "org.checkerframework.checker.propkey.qual", - "org.checkerframework.checker.regex.qual", - "org.checkerframework.checker.signature.qual", - "org.checkerframework.checker.signedness.qual", - "org.checkerframework.checker.tainting.qual", - "org.checkerframework.checker.units.qual", - "org.checkerframework.common.aliasing.qual", - "org.checkerframework.common.initializedfields.qual", - "org.checkerframework.common.reflection.qual", - "org.checkerframework.common.returnsreceiver.qual", - "org.checkerframework.common.subtyping.qual", - "org.checkerframework.common.util.report.qual", - "org.checkerframework.common.value.qual", - "org.checkerframework.dataflow.qual", - "org.checkerframework.framework.qual" + "org.bouncycastle:bcprov-jdk18on": [ + "org.bouncycastle", + "org.bouncycastle.asn1", + "org.bouncycastle.asn1.anssi", + "org.bouncycastle.asn1.bc", + "org.bouncycastle.asn1.cryptopro", + "org.bouncycastle.asn1.gm", + "org.bouncycastle.asn1.nist", + "org.bouncycastle.asn1.ocsp", + "org.bouncycastle.asn1.pkcs", + "org.bouncycastle.asn1.sec", + "org.bouncycastle.asn1.teletrust", + "org.bouncycastle.asn1.ua", + "org.bouncycastle.asn1.util", + "org.bouncycastle.asn1.x500", + "org.bouncycastle.asn1.x500.style", + "org.bouncycastle.asn1.x509", + "org.bouncycastle.asn1.x509.qualified", + "org.bouncycastle.asn1.x509.sigi", + "org.bouncycastle.asn1.x9", + "org.bouncycastle.crypto", + "org.bouncycastle.crypto.agreement", + "org.bouncycastle.crypto.agreement.ecjpake", + "org.bouncycastle.crypto.agreement.jpake", + "org.bouncycastle.crypto.agreement.kdf", + "org.bouncycastle.crypto.agreement.srp", + "org.bouncycastle.crypto.commitments", + "org.bouncycastle.crypto.constraints", + "org.bouncycastle.crypto.digests", + "org.bouncycastle.crypto.ec", + "org.bouncycastle.crypto.encodings", + "org.bouncycastle.crypto.engines", + "org.bouncycastle.crypto.examples", + "org.bouncycastle.crypto.fpe", + "org.bouncycastle.crypto.generators", + "org.bouncycastle.crypto.hash2curve", + "org.bouncycastle.crypto.hash2curve.data", + "org.bouncycastle.crypto.hash2curve.impl", + "org.bouncycastle.crypto.hpke", + "org.bouncycastle.crypto.io", + "org.bouncycastle.crypto.kems", + "org.bouncycastle.crypto.kems.mlkem", + "org.bouncycastle.crypto.macs", + "org.bouncycastle.crypto.modes", + "org.bouncycastle.crypto.modes.gcm", + "org.bouncycastle.crypto.modes.kgcm", + "org.bouncycastle.crypto.paddings", + "org.bouncycastle.crypto.params", + "org.bouncycastle.crypto.parsers", + "org.bouncycastle.crypto.prng", + "org.bouncycastle.crypto.prng.drbg", + "org.bouncycastle.crypto.signers", + "org.bouncycastle.crypto.signers.mldsa", + "org.bouncycastle.crypto.signers.slhdsa", + "org.bouncycastle.crypto.threshold", + "org.bouncycastle.crypto.tls", + "org.bouncycastle.crypto.util", + "org.bouncycastle.i18n", + "org.bouncycastle.i18n.filter", + "org.bouncycastle.iana", + "org.bouncycastle.internal.asn1.bsi", + "org.bouncycastle.internal.asn1.cms", + "org.bouncycastle.internal.asn1.cryptlib", + "org.bouncycastle.internal.asn1.eac", + "org.bouncycastle.internal.asn1.edec", + "org.bouncycastle.internal.asn1.gnu", + "org.bouncycastle.internal.asn1.iana", + "org.bouncycastle.internal.asn1.isara", + "org.bouncycastle.internal.asn1.isismtt", + "org.bouncycastle.internal.asn1.iso", + "org.bouncycastle.internal.asn1.kisa", + "org.bouncycastle.internal.asn1.microsoft", + "org.bouncycastle.internal.asn1.misc", + "org.bouncycastle.internal.asn1.nsri", + "org.bouncycastle.internal.asn1.ntt", + "org.bouncycastle.internal.asn1.oiw", + "org.bouncycastle.internal.asn1.rosstandart", + "org.bouncycastle.jcajce", + "org.bouncycastle.jcajce.interfaces", + "org.bouncycastle.jcajce.io", + "org.bouncycastle.jcajce.provider.asymmetric", + "org.bouncycastle.jcajce.provider.asymmetric.compositesignatures", + "org.bouncycastle.jcajce.provider.asymmetric.dh", + "org.bouncycastle.jcajce.provider.asymmetric.dsa", + "org.bouncycastle.jcajce.provider.asymmetric.dstu", + "org.bouncycastle.jcajce.provider.asymmetric.ec", + "org.bouncycastle.jcajce.provider.asymmetric.ecgost", + "org.bouncycastle.jcajce.provider.asymmetric.ecgost12", + "org.bouncycastle.jcajce.provider.asymmetric.edec", + "org.bouncycastle.jcajce.provider.asymmetric.elgamal", + "org.bouncycastle.jcajce.provider.asymmetric.gost", + "org.bouncycastle.jcajce.provider.asymmetric.ies", + "org.bouncycastle.jcajce.provider.asymmetric.mldsa", + "org.bouncycastle.jcajce.provider.asymmetric.mlkem", + "org.bouncycastle.jcajce.provider.asymmetric.rsa", + "org.bouncycastle.jcajce.provider.asymmetric.slhdsa", + "org.bouncycastle.jcajce.provider.asymmetric.util", + "org.bouncycastle.jcajce.provider.asymmetric.x509", + "org.bouncycastle.jcajce.provider.config", + "org.bouncycastle.jcajce.provider.digest", + "org.bouncycastle.jcajce.provider.drbg", + "org.bouncycastle.jcajce.provider.kdf", + "org.bouncycastle.jcajce.provider.kdf.hkdf", + "org.bouncycastle.jcajce.provider.kdf.pbkdf2", + "org.bouncycastle.jcajce.provider.kdf.scrypt", + "org.bouncycastle.jcajce.provider.keystore", + "org.bouncycastle.jcajce.provider.keystore.bc", + "org.bouncycastle.jcajce.provider.keystore.bcfks", + "org.bouncycastle.jcajce.provider.keystore.pkcs12", + "org.bouncycastle.jcajce.provider.keystore.util", + "org.bouncycastle.jcajce.provider.symmetric", + "org.bouncycastle.jcajce.provider.symmetric.util", + "org.bouncycastle.jcajce.provider.util", + "org.bouncycastle.jcajce.spec", + "org.bouncycastle.jcajce.util", + "org.bouncycastle.jce", + "org.bouncycastle.jce.exception", + "org.bouncycastle.jce.interfaces", + "org.bouncycastle.jce.netscape", + "org.bouncycastle.jce.provider", + "org.bouncycastle.jce.spec", + "org.bouncycastle.ldap", + "org.bouncycastle.math", + "org.bouncycastle.math.ec", + "org.bouncycastle.math.ec.custom.djb", + "org.bouncycastle.math.ec.custom.gm", + "org.bouncycastle.math.ec.custom.sec", + "org.bouncycastle.math.ec.endo", + "org.bouncycastle.math.ec.rfc7748", + "org.bouncycastle.math.ec.rfc8032", + "org.bouncycastle.math.ec.tools", + "org.bouncycastle.math.field", + "org.bouncycastle.math.raw", + "org.bouncycastle.pqc.asn1", + "org.bouncycastle.pqc.crypto", + "org.bouncycastle.pqc.crypto.cmce", + "org.bouncycastle.pqc.crypto.crystals.dilithium", + "org.bouncycastle.pqc.crypto.falcon", + "org.bouncycastle.pqc.crypto.frodo", + "org.bouncycastle.pqc.crypto.hqc", + "org.bouncycastle.pqc.crypto.lms", + "org.bouncycastle.pqc.crypto.mayo", + "org.bouncycastle.pqc.crypto.mldsa", + "org.bouncycastle.pqc.crypto.mlkem", + "org.bouncycastle.pqc.crypto.newhope", + "org.bouncycastle.pqc.crypto.ntru", + "org.bouncycastle.pqc.crypto.ntruplus", + "org.bouncycastle.pqc.crypto.ntruprime", + "org.bouncycastle.pqc.crypto.saber", + "org.bouncycastle.pqc.crypto.slhdsa", + "org.bouncycastle.pqc.crypto.snova", + "org.bouncycastle.pqc.crypto.sphincs", + "org.bouncycastle.pqc.crypto.util", + "org.bouncycastle.pqc.crypto.xmss", + "org.bouncycastle.pqc.crypto.xwing", + "org.bouncycastle.pqc.jcajce.interfaces", + "org.bouncycastle.pqc.jcajce.provider", + "org.bouncycastle.pqc.jcajce.provider.bike", + "org.bouncycastle.pqc.jcajce.provider.cmce", + "org.bouncycastle.pqc.jcajce.provider.dilithium", + "org.bouncycastle.pqc.jcajce.provider.falcon", + "org.bouncycastle.pqc.jcajce.provider.frodo", + "org.bouncycastle.pqc.jcajce.provider.hqc", + "org.bouncycastle.pqc.jcajce.provider.kyber", + "org.bouncycastle.pqc.jcajce.provider.lms", + "org.bouncycastle.pqc.jcajce.provider.mayo", + "org.bouncycastle.pqc.jcajce.provider.newhope", + "org.bouncycastle.pqc.jcajce.provider.ntru", + "org.bouncycastle.pqc.jcajce.provider.ntruplus", + "org.bouncycastle.pqc.jcajce.provider.ntruprime", + "org.bouncycastle.pqc.jcajce.provider.picnic", + "org.bouncycastle.pqc.jcajce.provider.saber", + "org.bouncycastle.pqc.jcajce.provider.snova", + "org.bouncycastle.pqc.jcajce.provider.sphincs", + "org.bouncycastle.pqc.jcajce.provider.sphincsplus", + "org.bouncycastle.pqc.jcajce.provider.util", + "org.bouncycastle.pqc.jcajce.provider.xmss", + "org.bouncycastle.pqc.jcajce.spec", + "org.bouncycastle.pqc.legacy.bike", + "org.bouncycastle.pqc.legacy.picnic", + "org.bouncycastle.pqc.legacy.rainbow", + "org.bouncycastle.pqc.legacy.sphincsplus", + "org.bouncycastle.pqc.math.ntru", + "org.bouncycastle.pqc.math.ntru.parameters", + "org.bouncycastle.util", + "org.bouncycastle.util.encoders", + "org.bouncycastle.util.io", + "org.bouncycastle.util.io.pem", + "org.bouncycastle.util.test", + "org.bouncycastle.x509", + "org.bouncycastle.x509.extension", + "org.bouncycastle.x509.util" + ], + "org.bouncycastle:bcprov-lts8on": [ + "org.bouncycastle", + "org.bouncycastle.asn1", + "org.bouncycastle.asn1.anssi", + "org.bouncycastle.asn1.bc", + "org.bouncycastle.asn1.cryptlib", + "org.bouncycastle.asn1.cryptopro", + "org.bouncycastle.asn1.edec", + "org.bouncycastle.asn1.gm", + "org.bouncycastle.asn1.gnu", + "org.bouncycastle.asn1.iana", + "org.bouncycastle.asn1.isara", + "org.bouncycastle.asn1.iso", + "org.bouncycastle.asn1.kisa", + "org.bouncycastle.asn1.microsoft", + "org.bouncycastle.asn1.misc", + "org.bouncycastle.asn1.mozilla", + "org.bouncycastle.asn1.nist", + "org.bouncycastle.asn1.nsri", + "org.bouncycastle.asn1.ntt", + "org.bouncycastle.asn1.ocsp", + "org.bouncycastle.asn1.oiw", + "org.bouncycastle.asn1.pkcs", + "org.bouncycastle.asn1.rosstandart", + "org.bouncycastle.asn1.sec", + "org.bouncycastle.asn1.teletrust", + "org.bouncycastle.asn1.ua", + "org.bouncycastle.asn1.util", + "org.bouncycastle.asn1.x500", + "org.bouncycastle.asn1.x500.style", + "org.bouncycastle.asn1.x509", + "org.bouncycastle.asn1.x509.qualified", + "org.bouncycastle.asn1.x509.sigi", + "org.bouncycastle.asn1.x9", + "org.bouncycastle.crypto", + "org.bouncycastle.crypto.agreement", + "org.bouncycastle.crypto.agreement.ecjpake", + "org.bouncycastle.crypto.agreement.jpake", + "org.bouncycastle.crypto.agreement.kdf", + "org.bouncycastle.crypto.agreement.srp", + "org.bouncycastle.crypto.commitments", + "org.bouncycastle.crypto.constraints", + "org.bouncycastle.crypto.digests", + "org.bouncycastle.crypto.ec", + "org.bouncycastle.crypto.encodings", + "org.bouncycastle.crypto.engines", + "org.bouncycastle.crypto.fpe", + "org.bouncycastle.crypto.generators", + "org.bouncycastle.crypto.hpke", + "org.bouncycastle.crypto.io", + "org.bouncycastle.crypto.kems", + "org.bouncycastle.crypto.macs", + "org.bouncycastle.crypto.modes", + "org.bouncycastle.crypto.modes.gcm", + "org.bouncycastle.crypto.modes.kgcm", + "org.bouncycastle.crypto.paddings", + "org.bouncycastle.crypto.params", + "org.bouncycastle.crypto.parsers", + "org.bouncycastle.crypto.prng", + "org.bouncycastle.crypto.prng.drbg", + "org.bouncycastle.crypto.signers", + "org.bouncycastle.crypto.tls", + "org.bouncycastle.crypto.util", + "org.bouncycastle.iana", + "org.bouncycastle.internal.asn1.bsi", + "org.bouncycastle.internal.asn1.cms", + "org.bouncycastle.internal.asn1.cryptlib", + "org.bouncycastle.internal.asn1.eac", + "org.bouncycastle.internal.asn1.edec", + "org.bouncycastle.internal.asn1.gnu", + "org.bouncycastle.internal.asn1.iana", + "org.bouncycastle.internal.asn1.isara", + "org.bouncycastle.internal.asn1.isismtt", + "org.bouncycastle.internal.asn1.iso", + "org.bouncycastle.internal.asn1.kisa", + "org.bouncycastle.internal.asn1.microsoft", + "org.bouncycastle.internal.asn1.misc", + "org.bouncycastle.internal.asn1.nsri", + "org.bouncycastle.internal.asn1.ntt", + "org.bouncycastle.internal.asn1.oiw", + "org.bouncycastle.internal.asn1.rosstandart", + "org.bouncycastle.jcajce", + "org.bouncycastle.jcajce.interfaces", + "org.bouncycastle.jcajce.io", + "org.bouncycastle.jcajce.provider.asymmetric", + "org.bouncycastle.jcajce.provider.asymmetric.compositesignatures", + "org.bouncycastle.jcajce.provider.asymmetric.dh", + "org.bouncycastle.jcajce.provider.asymmetric.dsa", + "org.bouncycastle.jcajce.provider.asymmetric.dstu", + "org.bouncycastle.jcajce.provider.asymmetric.ec", + "org.bouncycastle.jcajce.provider.asymmetric.ecgost", + "org.bouncycastle.jcajce.provider.asymmetric.ecgost12", + "org.bouncycastle.jcajce.provider.asymmetric.edec", + "org.bouncycastle.jcajce.provider.asymmetric.elgamal", + "org.bouncycastle.jcajce.provider.asymmetric.gost", + "org.bouncycastle.jcajce.provider.asymmetric.ies", + "org.bouncycastle.jcajce.provider.asymmetric.mldsa", + "org.bouncycastle.jcajce.provider.asymmetric.mlkem", + "org.bouncycastle.jcajce.provider.asymmetric.rsa", + "org.bouncycastle.jcajce.provider.asymmetric.slhdsa", + "org.bouncycastle.jcajce.provider.asymmetric.util", + "org.bouncycastle.jcajce.provider.asymmetric.x509", + "org.bouncycastle.jcajce.provider.config", + "org.bouncycastle.jcajce.provider.digest", + "org.bouncycastle.jcajce.provider.drbg", + "org.bouncycastle.jcajce.provider.keystore", + "org.bouncycastle.jcajce.provider.keystore.bc", + "org.bouncycastle.jcajce.provider.keystore.bcfks", + "org.bouncycastle.jcajce.provider.keystore.pkcs12", + "org.bouncycastle.jcajce.provider.keystore.util", + "org.bouncycastle.jcajce.provider.symmetric", + "org.bouncycastle.jcajce.provider.symmetric.util", + "org.bouncycastle.jcajce.provider.util", + "org.bouncycastle.jcajce.spec", + "org.bouncycastle.jcajce.util", + "org.bouncycastle.jce", + "org.bouncycastle.jce.exception", + "org.bouncycastle.jce.interfaces", + "org.bouncycastle.jce.netscape", + "org.bouncycastle.jce.provider", + "org.bouncycastle.jce.spec", + "org.bouncycastle.math", + "org.bouncycastle.math.ec", + "org.bouncycastle.math.ec.custom.djb", + "org.bouncycastle.math.ec.custom.gm", + "org.bouncycastle.math.ec.custom.sec", + "org.bouncycastle.math.ec.endo", + "org.bouncycastle.math.ec.rfc7748", + "org.bouncycastle.math.ec.rfc8032", + "org.bouncycastle.math.ec.tools", + "org.bouncycastle.math.field", + "org.bouncycastle.math.raw", + "org.bouncycastle.pqc.crypto", + "org.bouncycastle.pqc.crypto.lms", + "org.bouncycastle.pqc.crypto.mldsa", + "org.bouncycastle.pqc.crypto.mlkem", + "org.bouncycastle.pqc.crypto.slhdsa", + "org.bouncycastle.pqc.crypto.util", + "org.bouncycastle.pqc.jcajce.interfaces", + "org.bouncycastle.pqc.jcajce.provider.lms", + "org.bouncycastle.pqc.jcajce.provider.util", + "org.bouncycastle.pqc.jcajce.spec", + "org.bouncycastle.util", + "org.bouncycastle.util.dispose", + "org.bouncycastle.util.encoders", + "org.bouncycastle.util.io", + "org.bouncycastle.util.io.pem", + "org.bouncycastle.util.test" ], "org.hamcrest:hamcrest": [ "org.hamcrest", @@ -1228,6 +5670,212 @@ "org.hamcrest.text", "org.hamcrest.xml" ], + "org.hdrhistogram:HdrHistogram": [ + "org.HdrHistogram", + "org.HdrHistogram.packedarray" + ], + "org.hibernate.validator:hibernate-validator": [ + "org.hibernate.validator", + "org.hibernate.validator.cfg", + "org.hibernate.validator.cfg.context", + "org.hibernate.validator.cfg.defs", + "org.hibernate.validator.cfg.defs.br", + "org.hibernate.validator.cfg.defs.kor", + "org.hibernate.validator.cfg.defs.pl", + "org.hibernate.validator.cfg.defs.ru", + "org.hibernate.validator.constraints", + "org.hibernate.validator.constraints.br", + "org.hibernate.validator.constraints.kor", + "org.hibernate.validator.constraints.pl", + "org.hibernate.validator.constraints.ru", + "org.hibernate.validator.constraints.time", + "org.hibernate.validator.constraintvalidation", + "org.hibernate.validator.constraintvalidation.spi", + "org.hibernate.validator.constraintvalidators", + "org.hibernate.validator.engine", + "org.hibernate.validator.group", + "org.hibernate.validator.internal", + "org.hibernate.validator.internal.cfg", + "org.hibernate.validator.internal.cfg.context", + "org.hibernate.validator.internal.constraintvalidators", + "org.hibernate.validator.internal.constraintvalidators.bv", + "org.hibernate.validator.internal.constraintvalidators.bv.money", + "org.hibernate.validator.internal.constraintvalidators.bv.notempty", + "org.hibernate.validator.internal.constraintvalidators.bv.number", + "org.hibernate.validator.internal.constraintvalidators.bv.number.bound", + "org.hibernate.validator.internal.constraintvalidators.bv.number.bound.decimal", + "org.hibernate.validator.internal.constraintvalidators.bv.number.sign", + "org.hibernate.validator.internal.constraintvalidators.bv.size", + "org.hibernate.validator.internal.constraintvalidators.bv.time", + "org.hibernate.validator.internal.constraintvalidators.bv.time.future", + "org.hibernate.validator.internal.constraintvalidators.bv.time.futureorpresent", + "org.hibernate.validator.internal.constraintvalidators.bv.time.past", + "org.hibernate.validator.internal.constraintvalidators.bv.time.pastorpresent", + "org.hibernate.validator.internal.constraintvalidators.hv", + "org.hibernate.validator.internal.constraintvalidators.hv.br", + "org.hibernate.validator.internal.constraintvalidators.hv.kor", + "org.hibernate.validator.internal.constraintvalidators.hv.pl", + "org.hibernate.validator.internal.constraintvalidators.hv.ru", + "org.hibernate.validator.internal.constraintvalidators.hv.time", + "org.hibernate.validator.internal.engine", + "org.hibernate.validator.internal.engine.constraintdefinition", + "org.hibernate.validator.internal.engine.constraintvalidation", + "org.hibernate.validator.internal.engine.groups", + "org.hibernate.validator.internal.engine.messageinterpolation", + "org.hibernate.validator.internal.engine.messageinterpolation.el", + "org.hibernate.validator.internal.engine.messageinterpolation.parser", + "org.hibernate.validator.internal.engine.messageinterpolation.util", + "org.hibernate.validator.internal.engine.path", + "org.hibernate.validator.internal.engine.resolver", + "org.hibernate.validator.internal.engine.scripting", + "org.hibernate.validator.internal.engine.validationcontext", + "org.hibernate.validator.internal.engine.valuecontext", + "org.hibernate.validator.internal.engine.valueextraction", + "org.hibernate.validator.internal.metadata", + "org.hibernate.validator.internal.metadata.aggregated", + "org.hibernate.validator.internal.metadata.aggregated.rule", + "org.hibernate.validator.internal.metadata.core", + "org.hibernate.validator.internal.metadata.descriptor", + "org.hibernate.validator.internal.metadata.facets", + "org.hibernate.validator.internal.metadata.location", + "org.hibernate.validator.internal.metadata.provider", + "org.hibernate.validator.internal.metadata.raw", + "org.hibernate.validator.internal.properties", + "org.hibernate.validator.internal.properties.javabean", + "org.hibernate.validator.internal.util", + "org.hibernate.validator.internal.util.actions", + "org.hibernate.validator.internal.util.annotation", + "org.hibernate.validator.internal.util.classhierarchy", + "org.hibernate.validator.internal.util.logging", + "org.hibernate.validator.internal.util.logging.formatter", + "org.hibernate.validator.internal.util.stereotypes", + "org.hibernate.validator.internal.xml", + "org.hibernate.validator.internal.xml.config", + "org.hibernate.validator.internal.xml.mapping", + "org.hibernate.validator.messageinterpolation", + "org.hibernate.validator.metadata", + "org.hibernate.validator.parameternameprovider", + "org.hibernate.validator.path", + "org.hibernate.validator.resourceloading", + "org.hibernate.validator.spi.cfg", + "org.hibernate.validator.spi.group", + "org.hibernate.validator.spi.messageinterpolation", + "org.hibernate.validator.spi.nodenameprovider", + "org.hibernate.validator.spi.properties", + "org.hibernate.validator.spi.resourceloading", + "org.hibernate.validator.spi.scripting" + ], + "org.jacoco:org.jacoco.agent:jar:runtime": [ + "com.vladium.emma.rt", + "org.jacoco.agent.rt", + "org.jacoco.agent.rt.internal_29a6edd", + "org.jacoco.agent.rt.internal_29a6edd.asm", + "org.jacoco.agent.rt.internal_29a6edd.asm.commons", + "org.jacoco.agent.rt.internal_29a6edd.asm.tree", + "org.jacoco.agent.rt.internal_29a6edd.core", + "org.jacoco.agent.rt.internal_29a6edd.core.analysis", + "org.jacoco.agent.rt.internal_29a6edd.core.data", + "org.jacoco.agent.rt.internal_29a6edd.core.instr", + "org.jacoco.agent.rt.internal_29a6edd.core.internal", + "org.jacoco.agent.rt.internal_29a6edd.core.internal.analysis", + "org.jacoco.agent.rt.internal_29a6edd.core.internal.analysis.filter", + "org.jacoco.agent.rt.internal_29a6edd.core.internal.data", + "org.jacoco.agent.rt.internal_29a6edd.core.internal.flow", + "org.jacoco.agent.rt.internal_29a6edd.core.internal.instr", + "org.jacoco.agent.rt.internal_29a6edd.core.runtime", + "org.jacoco.agent.rt.internal_29a6edd.core.tools", + "org.jacoco.agent.rt.internal_29a6edd.output" + ], + "org.jacoco:org.jacoco.cli": [ + "org.jacoco.cli.internal", + "org.jacoco.cli.internal.commands" + ], + "org.jacoco:org.jacoco.core": [ + "org.jacoco.core", + "org.jacoco.core.analysis", + "org.jacoco.core.data", + "org.jacoco.core.instr", + "org.jacoco.core.internal", + "org.jacoco.core.internal.analysis", + "org.jacoco.core.internal.analysis.filter", + "org.jacoco.core.internal.data", + "org.jacoco.core.internal.flow", + "org.jacoco.core.internal.instr", + "org.jacoco.core.runtime", + "org.jacoco.core.tools" + ], + "org.jacoco:org.jacoco.report": [ + "org.jacoco.report", + "org.jacoco.report.check", + "org.jacoco.report.csv", + "org.jacoco.report.html", + "org.jacoco.report.internal", + "org.jacoco.report.internal.html", + "org.jacoco.report.internal.html.index", + "org.jacoco.report.internal.html.page", + "org.jacoco.report.internal.html.resources", + "org.jacoco.report.internal.html.table", + "org.jacoco.report.internal.xml", + "org.jacoco.report.xml" + ], + "org.jboss.logging:jboss-logging": [ + "org.jboss.logging" + ], + "org.jetbrains.kotlin:kotlin-stdlib": [ + "kotlin", + "kotlin.annotation", + "kotlin.collections", + "kotlin.collections.builders", + "kotlin.collections.jdk8", + "kotlin.collections.unsigned", + "kotlin.comparisons", + "kotlin.concurrent", + "kotlin.concurrent.atomics", + "kotlin.concurrent.internal", + "kotlin.contracts", + "kotlin.coroutines", + "kotlin.coroutines.cancellation", + "kotlin.coroutines.intrinsics", + "kotlin.coroutines.jvm.internal", + "kotlin.enums", + "kotlin.experimental", + "kotlin.internal", + "kotlin.internal.jdk7", + "kotlin.internal.jdk8", + "kotlin.io", + "kotlin.io.encoding", + "kotlin.io.path", + "kotlin.jdk7", + "kotlin.js", + "kotlin.jvm", + "kotlin.jvm.functions", + "kotlin.jvm.internal", + "kotlin.jvm.internal.markers", + "kotlin.jvm.internal.unsafe", + "kotlin.jvm.jdk8", + "kotlin.jvm.optionals", + "kotlin.math", + "kotlin.properties", + "kotlin.random", + "kotlin.random.jdk8", + "kotlin.ranges", + "kotlin.reflect", + "kotlin.sequences", + "kotlin.streams.jdk8", + "kotlin.system", + "kotlin.text", + "kotlin.text.jdk8", + "kotlin.time", + "kotlin.time.jdk8", + "kotlin.uuid" + ], + "org.jetbrains:annotations": [ + "org.intellij.lang.annotations", + "org.jetbrains.annotations" + ], + "org.jspecify:jspecify": [ + "org.jspecify.annotations" + ], "org.junit.jupiter:junit-jupiter-api": [ "org.junit.jupiter.api", "org.junit.jupiter.api.condition", @@ -1235,7 +5883,8 @@ "org.junit.jupiter.api.extension.support", "org.junit.jupiter.api.function", "org.junit.jupiter.api.io", - "org.junit.jupiter.api.parallel" + "org.junit.jupiter.api.parallel", + "org.junit.jupiter.api.util" ], "org.junit.jupiter:junit-jupiter-engine": [ "org.junit.jupiter.engine", @@ -1252,33 +5901,132 @@ "org.junit.jupiter.params.aggregator", "org.junit.jupiter.params.converter", "org.junit.jupiter.params.provider", - "org.junit.jupiter.params.shadow.com.univocity.parsers.annotations", - "org.junit.jupiter.params.shadow.com.univocity.parsers.annotations.helpers", - "org.junit.jupiter.params.shadow.com.univocity.parsers.common", - "org.junit.jupiter.params.shadow.com.univocity.parsers.common.beans", - "org.junit.jupiter.params.shadow.com.univocity.parsers.common.fields", - "org.junit.jupiter.params.shadow.com.univocity.parsers.common.input", - "org.junit.jupiter.params.shadow.com.univocity.parsers.common.input.concurrent", - "org.junit.jupiter.params.shadow.com.univocity.parsers.common.iterators", - "org.junit.jupiter.params.shadow.com.univocity.parsers.common.processor", - "org.junit.jupiter.params.shadow.com.univocity.parsers.common.processor.core", - "org.junit.jupiter.params.shadow.com.univocity.parsers.common.record", - "org.junit.jupiter.params.shadow.com.univocity.parsers.common.routine", - "org.junit.jupiter.params.shadow.com.univocity.parsers.conversions", - "org.junit.jupiter.params.shadow.com.univocity.parsers.csv", - "org.junit.jupiter.params.shadow.com.univocity.parsers.fixed", - "org.junit.jupiter.params.shadow.com.univocity.parsers.tsv", + "org.junit.jupiter.params.shadow.de.siegmar.fastcsv.reader", + "org.junit.jupiter.params.shadow.de.siegmar.fastcsv.util", + "org.junit.jupiter.params.shadow.de.siegmar.fastcsv.writer", "org.junit.jupiter.params.support" ], "org.junit.platform:junit-platform-commons": [ "org.junit.platform.commons", "org.junit.platform.commons.annotation", "org.junit.platform.commons.function", + "org.junit.platform.commons.io", "org.junit.platform.commons.logging", "org.junit.platform.commons.support", "org.junit.platform.commons.support.conversion", + "org.junit.platform.commons.support.scanning", "org.junit.platform.commons.util" ], + "org.junit.platform:junit-platform-console-standalone": [ + "junit.extensions", + "junit.framework", + "junit.runner", + "junit.textui", + "org.apiguardian.api", + "org.hamcrest", + "org.hamcrest.core", + "org.hamcrest.internal", + "org.junit", + "org.junit.experimental", + "org.junit.experimental.categories", + "org.junit.experimental.max", + "org.junit.experimental.results", + "org.junit.experimental.runners", + "org.junit.experimental.theories", + "org.junit.experimental.theories.internal", + "org.junit.experimental.theories.suppliers", + "org.junit.function", + "org.junit.internal", + "org.junit.internal.builders", + "org.junit.internal.management", + "org.junit.internal.matchers", + "org.junit.internal.requests", + "org.junit.internal.runners", + "org.junit.internal.runners.model", + "org.junit.internal.runners.rules", + "org.junit.internal.runners.statements", + "org.junit.jupiter.api", + "org.junit.jupiter.api.condition", + "org.junit.jupiter.api.extension", + "org.junit.jupiter.api.extension.support", + "org.junit.jupiter.api.function", + "org.junit.jupiter.api.io", + "org.junit.jupiter.api.parallel", + "org.junit.jupiter.api.util", + "org.junit.jupiter.engine", + "org.junit.jupiter.engine.config", + "org.junit.jupiter.engine.descriptor", + "org.junit.jupiter.engine.discovery", + "org.junit.jupiter.engine.discovery.predicates", + "org.junit.jupiter.engine.execution", + "org.junit.jupiter.engine.extension", + "org.junit.jupiter.engine.support", + "org.junit.jupiter.params", + "org.junit.jupiter.params.aggregator", + "org.junit.jupiter.params.converter", + "org.junit.jupiter.params.provider", + "org.junit.jupiter.params.shadow.de.siegmar.fastcsv.reader", + "org.junit.jupiter.params.shadow.de.siegmar.fastcsv.util", + "org.junit.jupiter.params.shadow.de.siegmar.fastcsv.writer", + "org.junit.jupiter.params.support", + "org.junit.matchers", + "org.junit.platform.commons", + "org.junit.platform.commons.annotation", + "org.junit.platform.commons.function", + "org.junit.platform.commons.io", + "org.junit.platform.commons.logging", + "org.junit.platform.commons.support", + "org.junit.platform.commons.support.conversion", + "org.junit.platform.commons.support.scanning", + "org.junit.platform.commons.util", + "org.junit.platform.console", + "org.junit.platform.console.command", + "org.junit.platform.console.options", + "org.junit.platform.console.output", + "org.junit.platform.console.shadow.picocli", + "org.junit.platform.engine", + "org.junit.platform.engine.discovery", + "org.junit.platform.engine.reporting", + "org.junit.platform.engine.support.config", + "org.junit.platform.engine.support.descriptor", + "org.junit.platform.engine.support.discovery", + "org.junit.platform.engine.support.hierarchical", + "org.junit.platform.engine.support.store", + "org.junit.platform.launcher", + "org.junit.platform.launcher.core", + "org.junit.platform.launcher.jfr", + "org.junit.platform.launcher.listeners", + "org.junit.platform.launcher.listeners.discovery", + "org.junit.platform.launcher.listeners.session", + "org.junit.platform.launcher.tagexpression", + "org.junit.platform.reporting", + "org.junit.platform.reporting.legacy", + "org.junit.platform.reporting.legacy.xml", + "org.junit.platform.reporting.open.xml", + "org.junit.platform.reporting.shadow.org.opentest4j.reporting.events.api", + "org.junit.platform.reporting.shadow.org.opentest4j.reporting.events.core", + "org.junit.platform.reporting.shadow.org.opentest4j.reporting.events.git", + "org.junit.platform.reporting.shadow.org.opentest4j.reporting.events.java", + "org.junit.platform.reporting.shadow.org.opentest4j.reporting.events.root", + "org.junit.platform.reporting.shadow.org.opentest4j.reporting.schema", + "org.junit.platform.suite.api", + "org.junit.platform.suite.engine", + "org.junit.rules", + "org.junit.runner", + "org.junit.runner.manipulation", + "org.junit.runner.notification", + "org.junit.runners", + "org.junit.runners.model", + "org.junit.runners.parameterized", + "org.junit.validator", + "org.junit.vintage.engine", + "org.junit.vintage.engine.descriptor", + "org.junit.vintage.engine.discovery", + "org.junit.vintage.engine.execution", + "org.junit.vintage.engine.support", + "org.opentest4j", + "org.opentest4j.reporting.tooling.spi.htmlreport" + ], "org.junit.platform:junit-platform-engine": [ "org.junit.platform.engine", "org.junit.platform.engine.discovery", @@ -1286,31 +6034,35 @@ "org.junit.platform.engine.support.config", "org.junit.platform.engine.support.descriptor", "org.junit.platform.engine.support.discovery", - "org.junit.platform.engine.support.filter", "org.junit.platform.engine.support.hierarchical", "org.junit.platform.engine.support.store" ], "org.junit.platform:junit-platform-launcher": [ "org.junit.platform.launcher", "org.junit.platform.launcher.core", + "org.junit.platform.launcher.jfr", "org.junit.platform.launcher.listeners", "org.junit.platform.launcher.listeners.discovery", "org.junit.platform.launcher.listeners.session", "org.junit.platform.launcher.tagexpression" ], "org.junit.platform:junit-platform-reporting": [ + "org.junit.platform.reporting", "org.junit.platform.reporting.legacy", "org.junit.platform.reporting.legacy.xml", "org.junit.platform.reporting.open.xml", "org.junit.platform.reporting.shadow.org.opentest4j.reporting.events.api", "org.junit.platform.reporting.shadow.org.opentest4j.reporting.events.core", + "org.junit.platform.reporting.shadow.org.opentest4j.reporting.events.git", "org.junit.platform.reporting.shadow.org.opentest4j.reporting.events.java", "org.junit.platform.reporting.shadow.org.opentest4j.reporting.events.root", "org.junit.platform.reporting.shadow.org.opentest4j.reporting.schema" ], + "org.latencyutils:LatencyUtils": [ + "org.LatencyUtils" + ], "org.mockito:mockito-core": [ "org.mockito", - "org.mockito.codegen", "org.mockito.configuration", "org.mockito.creation.instance", "org.mockito.exceptions.base", @@ -1328,6 +6080,8 @@ "org.mockito.internal.configuration.plugins", "org.mockito.internal.creation", "org.mockito.internal.creation.bytebuddy", + "org.mockito.internal.creation.bytebuddy.access", + "org.mockito.internal.creation.bytebuddy.codegen", "org.mockito.internal.creation.instance", "org.mockito.internal.creation.proxy", "org.mockito.internal.creation.settings", @@ -1390,6 +6144,9 @@ "org.objenesis.instantiator.util", "org.objenesis.strategy" ], + "org.opentest4j.reporting:open-test-reporting-tooling-spi": [ + "org.opentest4j.reporting.tooling.spi.htmlreport" + ], "org.opentest4j:opentest4j": [ "org.opentest4j" ], @@ -1397,6 +6154,43 @@ "org.objectweb.asm", "org.objectweb.asm.signature" ], + "org.ow2.asm:asm-analysis": [ + "org.objectweb.asm.tree.analysis" + ], + "org.ow2.asm:asm-commons": [ + "org.objectweb.asm.commons" + ], + "org.ow2.asm:asm-tree": [ + "org.objectweb.asm.tree" + ], + "org.ow2.asm:asm-util": [ + "org.objectweb.asm.util" + ], + "org.projectlombok:lombok": [ + "lombok", + "lombok.delombok.ant", + "lombok.experimental", + "lombok.extern.apachecommons", + "lombok.extern.flogger", + "lombok.extern.jackson", + "lombok.extern.java", + "lombok.extern.jbosslog", + "lombok.extern.log4j", + "lombok.extern.slf4j", + "lombok.javac.apt", + "lombok.launch" + ], + "org.reactivestreams:reactive-streams": [ + "org.reactivestreams" + ], + "org.rnorth.duct-tape:duct-tape": [ + "org.rnorth.ducttape", + "org.rnorth.ducttape.circuitbreakers", + "org.rnorth.ducttape.inconsistents", + "org.rnorth.ducttape.ratelimits", + "org.rnorth.ducttape.timeouts", + "org.rnorth.ducttape.unreliables" + ], "org.skyscreamer:jsonassert": [ "org.json", "org.skyscreamer.jsonassert", @@ -1411,11 +6205,75 @@ "org.slf4j.helpers", "org.slf4j.spi" ], + "org.springdoc:springdoc-openapi-starter-common": [ + "org.springdoc.api", + "org.springdoc.core.annotations", + "org.springdoc.core.conditions", + "org.springdoc.core.configuration", + "org.springdoc.core.configuration.hints", + "org.springdoc.core.configuration.oauth2", + "org.springdoc.core.configurer", + "org.springdoc.core.converters", + "org.springdoc.core.converters.models", + "org.springdoc.core.customizers", + "org.springdoc.core.data", + "org.springdoc.core.discoverer", + "org.springdoc.core.events", + "org.springdoc.core.extractor", + "org.springdoc.core.filters", + "org.springdoc.core.fn", + "org.springdoc.core.fn.builders.apiresponse", + "org.springdoc.core.fn.builders.arrayschema", + "org.springdoc.core.fn.builders.content", + "org.springdoc.core.fn.builders.discriminatormapping", + "org.springdoc.core.fn.builders.encoding", + "org.springdoc.core.fn.builders.exampleobject", + "org.springdoc.core.fn.builders.extension", + "org.springdoc.core.fn.builders.extensionproperty", + "org.springdoc.core.fn.builders.externaldocumentation", + "org.springdoc.core.fn.builders.header", + "org.springdoc.core.fn.builders.link", + "org.springdoc.core.fn.builders.linkparameter", + "org.springdoc.core.fn.builders.operation", + "org.springdoc.core.fn.builders.parameter", + "org.springdoc.core.fn.builders.requestbody", + "org.springdoc.core.fn.builders.schema", + "org.springdoc.core.fn.builders.securityrequirement", + "org.springdoc.core.fn.builders.server", + "org.springdoc.core.fn.builders.servervariable", + "org.springdoc.core.mixins", + "org.springdoc.core.models", + "org.springdoc.core.properties", + "org.springdoc.core.providers", + "org.springdoc.core.service", + "org.springdoc.core.utils", + "org.springdoc.core.versions", + "org.springdoc.scalar", + "org.springdoc.ui" + ], + "org.springdoc:springdoc-openapi-starter-webflux-api": [ + "org.springdoc.webflux.api", + "org.springdoc.webflux.core.configuration", + "org.springdoc.webflux.core.configuration.hints", + "org.springdoc.webflux.core.fn", + "org.springdoc.webflux.core.providers", + "org.springdoc.webflux.core.service", + "org.springdoc.webflux.core.visitor" + ], + "org.springdoc:springdoc-openapi-starter-webmvc-api": [ + "org.springdoc.webmvc.api", + "org.springdoc.webmvc.core.configuration", + "org.springdoc.webmvc.core.configuration.hints", + "org.springdoc.webmvc.core.fn", + "org.springdoc.webmvc.core.providers", + "org.springdoc.webmvc.core.service" + ], "org.springframework.boot:spring-boot": [ "org.springframework.boot", "org.springframework.boot.admin", "org.springframework.boot.ansi", "org.springframework.boot.availability", + "org.springframework.boot.bootstrap", "org.springframework.boot.builder", "org.springframework.boot.cloud", "org.springframework.boot.context", @@ -1433,230 +6291,729 @@ "org.springframework.boot.diagnostics", "org.springframework.boot.diagnostics.analyzer", "org.springframework.boot.env", - "org.springframework.boot.flyway", "org.springframework.boot.info", "org.springframework.boot.io", - "org.springframework.boot.jackson", - "org.springframework.boot.jdbc", - "org.springframework.boot.jdbc.init", - "org.springframework.boot.jdbc.metadata", - "org.springframework.boot.jms", - "org.springframework.boot.jooq", "org.springframework.boot.json", - "org.springframework.boot.liquibase", "org.springframework.boot.logging", "org.springframework.boot.logging.java", "org.springframework.boot.logging.log4j2", "org.springframework.boot.logging.logback", + "org.springframework.boot.logging.structured", "org.springframework.boot.origin", - "org.springframework.boot.orm.jpa", - "org.springframework.boot.orm.jpa.hibernate", - "org.springframework.boot.r2dbc", - "org.springframework.boot.r2dbc.init", - "org.springframework.boot.reactor", - "org.springframework.boot.rsocket.context", - "org.springframework.boot.rsocket.messaging", - "org.springframework.boot.rsocket.netty", - "org.springframework.boot.rsocket.server", - "org.springframework.boot.security.reactive", - "org.springframework.boot.security.servlet", - "org.springframework.boot.sql.init", - "org.springframework.boot.sql.init.dependency", + "org.springframework.boot.retry", "org.springframework.boot.ssl", "org.springframework.boot.ssl.jks", "org.springframework.boot.ssl.pem", + "org.springframework.boot.support", "org.springframework.boot.system", "org.springframework.boot.task", - "org.springframework.boot.type.classreading", + "org.springframework.boot.thread", "org.springframework.boot.util", "org.springframework.boot.validation", "org.springframework.boot.validation.beanvalidation", - "org.springframework.boot.web.client", - "org.springframework.boot.web.codec", - "org.springframework.boot.web.context", - "org.springframework.boot.web.embedded.jetty", - "org.springframework.boot.web.embedded.netty", - "org.springframework.boot.web.embedded.tomcat", - "org.springframework.boot.web.embedded.undertow", + "org.springframework.boot.web.context.reactive", + "org.springframework.boot.web.context.servlet", "org.springframework.boot.web.error", - "org.springframework.boot.web.reactive.context", - "org.springframework.boot.web.reactive.error", - "org.springframework.boot.web.reactive.filter", - "org.springframework.boot.web.reactive.function.client", - "org.springframework.boot.web.reactive.result.view", - "org.springframework.boot.web.reactive.server", - "org.springframework.boot.web.server", "org.springframework.boot.web.servlet", - "org.springframework.boot.web.servlet.context", - "org.springframework.boot.web.servlet.error", - "org.springframework.boot.web.servlet.filter", - "org.springframework.boot.web.servlet.server", - "org.springframework.boot.web.servlet.support", - "org.springframework.boot.web.servlet.view", - "org.springframework.boot.webservices.client" + "org.springframework.boot.web.servlet.support" + ], + "org.springframework.boot:spring-boot-actuator": [ + "org.springframework.boot.actuate.audit", + "org.springframework.boot.actuate.audit.listener", + "org.springframework.boot.actuate.beans", + "org.springframework.boot.actuate.context", + "org.springframework.boot.actuate.context.properties", + "org.springframework.boot.actuate.endpoint", + "org.springframework.boot.actuate.endpoint.annotation", + "org.springframework.boot.actuate.endpoint.invoke", + "org.springframework.boot.actuate.endpoint.invoke.convert", + "org.springframework.boot.actuate.endpoint.invoke.reflect", + "org.springframework.boot.actuate.endpoint.invoker.cache", + "org.springframework.boot.actuate.endpoint.jackson", + "org.springframework.boot.actuate.endpoint.jmx", + "org.springframework.boot.actuate.endpoint.jmx.annotation", + "org.springframework.boot.actuate.endpoint.web", + "org.springframework.boot.actuate.endpoint.web.annotation", + "org.springframework.boot.actuate.env", + "org.springframework.boot.actuate.info", + "org.springframework.boot.actuate.logging", + "org.springframework.boot.actuate.management", + "org.springframework.boot.actuate.sbom", + "org.springframework.boot.actuate.scheduling", + "org.springframework.boot.actuate.security", + "org.springframework.boot.actuate.startup", + "org.springframework.boot.actuate.web.exchanges", + "org.springframework.boot.actuate.web.mappings" + ], + "org.springframework.boot:spring-boot-actuator-autoconfigure": [ + "org.springframework.boot.actuate.autoconfigure", + "org.springframework.boot.actuate.autoconfigure.audit", + "org.springframework.boot.actuate.autoconfigure.beans", + "org.springframework.boot.actuate.autoconfigure.condition", + "org.springframework.boot.actuate.autoconfigure.context", + "org.springframework.boot.actuate.autoconfigure.context.properties", + "org.springframework.boot.actuate.autoconfigure.endpoint", + "org.springframework.boot.actuate.autoconfigure.endpoint.condition", + "org.springframework.boot.actuate.autoconfigure.endpoint.expose", + "org.springframework.boot.actuate.autoconfigure.endpoint.jackson", + "org.springframework.boot.actuate.autoconfigure.endpoint.jmx", + "org.springframework.boot.actuate.autoconfigure.endpoint.web", + "org.springframework.boot.actuate.autoconfigure.env", + "org.springframework.boot.actuate.autoconfigure.info", + "org.springframework.boot.actuate.autoconfigure.logging", + "org.springframework.boot.actuate.autoconfigure.management", + "org.springframework.boot.actuate.autoconfigure.sbom", + "org.springframework.boot.actuate.autoconfigure.scheduling", + "org.springframework.boot.actuate.autoconfigure.startup", + "org.springframework.boot.actuate.autoconfigure.web", + "org.springframework.boot.actuate.autoconfigure.web.exchanges", + "org.springframework.boot.actuate.autoconfigure.web.mappings", + "org.springframework.boot.actuate.autoconfigure.web.server" ], "org.springframework.boot:spring-boot-autoconfigure": [ "org.springframework.boot.autoconfigure", "org.springframework.boot.autoconfigure.admin", - "org.springframework.boot.autoconfigure.amqp", "org.springframework.boot.autoconfigure.aop", "org.springframework.boot.autoconfigure.availability", - "org.springframework.boot.autoconfigure.batch", "org.springframework.boot.autoconfigure.cache", - "org.springframework.boot.autoconfigure.cassandra", - "org.springframework.boot.autoconfigure.codec", "org.springframework.boot.autoconfigure.condition", + "org.springframework.boot.autoconfigure.container", "org.springframework.boot.autoconfigure.context", - "org.springframework.boot.autoconfigure.couchbase", - "org.springframework.boot.autoconfigure.dao", "org.springframework.boot.autoconfigure.data", - "org.springframework.boot.autoconfigure.data.cassandra", - "org.springframework.boot.autoconfigure.data.couchbase", - "org.springframework.boot.autoconfigure.data.elasticsearch", - "org.springframework.boot.autoconfigure.data.jdbc", - "org.springframework.boot.autoconfigure.data.jpa", - "org.springframework.boot.autoconfigure.data.ldap", - "org.springframework.boot.autoconfigure.data.mongo", - "org.springframework.boot.autoconfigure.data.neo4j", - "org.springframework.boot.autoconfigure.data.r2dbc", - "org.springframework.boot.autoconfigure.data.redis", - "org.springframework.boot.autoconfigure.data.rest", - "org.springframework.boot.autoconfigure.data.web", "org.springframework.boot.autoconfigure.diagnostics.analyzer", - "org.springframework.boot.autoconfigure.domain", - "org.springframework.boot.autoconfigure.elasticsearch", - "org.springframework.boot.autoconfigure.flyway", - "org.springframework.boot.autoconfigure.freemarker", - "org.springframework.boot.autoconfigure.graphql", - "org.springframework.boot.autoconfigure.graphql.data", - "org.springframework.boot.autoconfigure.graphql.reactive", - "org.springframework.boot.autoconfigure.graphql.rsocket", - "org.springframework.boot.autoconfigure.graphql.security", - "org.springframework.boot.autoconfigure.graphql.servlet", - "org.springframework.boot.autoconfigure.groovy.template", - "org.springframework.boot.autoconfigure.gson", - "org.springframework.boot.autoconfigure.h2", - "org.springframework.boot.autoconfigure.hateoas", - "org.springframework.boot.autoconfigure.hazelcast", - "org.springframework.boot.autoconfigure.http", - "org.springframework.boot.autoconfigure.http.codec", - "org.springframework.boot.autoconfigure.influx", "org.springframework.boot.autoconfigure.info", - "org.springframework.boot.autoconfigure.integration", - "org.springframework.boot.autoconfigure.jackson", - "org.springframework.boot.autoconfigure.jdbc", - "org.springframework.boot.autoconfigure.jdbc.metadata", - "org.springframework.boot.autoconfigure.jersey", - "org.springframework.boot.autoconfigure.jms", - "org.springframework.boot.autoconfigure.jms.activemq", - "org.springframework.boot.autoconfigure.jms.artemis", "org.springframework.boot.autoconfigure.jmx", - "org.springframework.boot.autoconfigure.jooq", - "org.springframework.boot.autoconfigure.jsonb", - "org.springframework.boot.autoconfigure.kafka", - "org.springframework.boot.autoconfigure.ldap", - "org.springframework.boot.autoconfigure.ldap.embedded", - "org.springframework.boot.autoconfigure.liquibase", "org.springframework.boot.autoconfigure.logging", - "org.springframework.boot.autoconfigure.mail", - "org.springframework.boot.autoconfigure.mongo", - "org.springframework.boot.autoconfigure.mustache", - "org.springframework.boot.autoconfigure.neo4j", - "org.springframework.boot.autoconfigure.netty", - "org.springframework.boot.autoconfigure.orm.jpa", - "org.springframework.boot.autoconfigure.pulsar", - "org.springframework.boot.autoconfigure.quartz", - "org.springframework.boot.autoconfigure.r2dbc", - "org.springframework.boot.autoconfigure.reactor", - "org.springframework.boot.autoconfigure.reactor.netty", - "org.springframework.boot.autoconfigure.rsocket", - "org.springframework.boot.autoconfigure.security", - "org.springframework.boot.autoconfigure.security.oauth2.client", - "org.springframework.boot.autoconfigure.security.oauth2.client.reactive", - "org.springframework.boot.autoconfigure.security.oauth2.client.servlet", - "org.springframework.boot.autoconfigure.security.oauth2.resource", - "org.springframework.boot.autoconfigure.security.oauth2.resource.reactive", - "org.springframework.boot.autoconfigure.security.oauth2.resource.servlet", - "org.springframework.boot.autoconfigure.security.oauth2.server.servlet", - "org.springframework.boot.autoconfigure.security.reactive", - "org.springframework.boot.autoconfigure.security.rsocket", - "org.springframework.boot.autoconfigure.security.saml2", - "org.springframework.boot.autoconfigure.security.servlet", - "org.springframework.boot.autoconfigure.sendgrid", + "org.springframework.boot.autoconfigure.preinitialize", "org.springframework.boot.autoconfigure.service.connection", - "org.springframework.boot.autoconfigure.session", - "org.springframework.boot.autoconfigure.sql.init", "org.springframework.boot.autoconfigure.ssl", "org.springframework.boot.autoconfigure.task", "org.springframework.boot.autoconfigure.template", - "org.springframework.boot.autoconfigure.thread", - "org.springframework.boot.autoconfigure.thymeleaf", - "org.springframework.boot.autoconfigure.transaction", - "org.springframework.boot.autoconfigure.transaction.jta", - "org.springframework.boot.autoconfigure.validation", "org.springframework.boot.autoconfigure.web", - "org.springframework.boot.autoconfigure.web.client", - "org.springframework.boot.autoconfigure.web.embedded", - "org.springframework.boot.autoconfigure.web.format", - "org.springframework.boot.autoconfigure.web.reactive", - "org.springframework.boot.autoconfigure.web.reactive.error", - "org.springframework.boot.autoconfigure.web.reactive.function.client", - "org.springframework.boot.autoconfigure.web.servlet", - "org.springframework.boot.autoconfigure.web.servlet.error", - "org.springframework.boot.autoconfigure.webservices", - "org.springframework.boot.autoconfigure.webservices.client", - "org.springframework.boot.autoconfigure.websocket.reactive", - "org.springframework.boot.autoconfigure.websocket.servlet" + "org.springframework.boot.autoconfigure.web.format" + ], + "org.springframework.boot:spring-boot-cassandra": [ + "org.springframework.boot.cassandra.autoconfigure", + "org.springframework.boot.cassandra.autoconfigure.health", + "org.springframework.boot.cassandra.docker.compose", + "org.springframework.boot.cassandra.health", + "org.springframework.boot.cassandra.testcontainers" + ], + "org.springframework.boot:spring-boot-data-cassandra": [ + "org.springframework.boot.data.cassandra.autoconfigure" + ], + "org.springframework.boot:spring-boot-data-cassandra-test": [ + "org.springframework.boot.data.cassandra.test.autoconfigure" + ], + "org.springframework.boot:spring-boot-data-commons": [ + "org.springframework.boot.data.autoconfigure.metrics", + "org.springframework.boot.data.autoconfigure.web", + "org.springframework.boot.data.metrics" + ], + "org.springframework.boot:spring-boot-health": [ + "org.springframework.boot.health.actuate.endpoint", + "org.springframework.boot.health.application", + "org.springframework.boot.health.autoconfigure.actuate.endpoint", + "org.springframework.boot.health.autoconfigure.application", + "org.springframework.boot.health.autoconfigure.contributor", + "org.springframework.boot.health.autoconfigure.registry", + "org.springframework.boot.health.contributor", + "org.springframework.boot.health.registry" + ], + "org.springframework.boot:spring-boot-http-client": [ + "org.springframework.boot.http.client", + "org.springframework.boot.http.client.autoconfigure", + "org.springframework.boot.http.client.autoconfigure.imperative", + "org.springframework.boot.http.client.autoconfigure.metrics", + "org.springframework.boot.http.client.autoconfigure.reactive", + "org.springframework.boot.http.client.autoconfigure.service", + "org.springframework.boot.http.client.reactive" + ], + "org.springframework.boot:spring-boot-http-codec": [ + "org.springframework.boot.http.codec", + "org.springframework.boot.http.codec.autoconfigure" + ], + "org.springframework.boot:spring-boot-http-converter": [ + "org.springframework.boot.http.converter.autoconfigure" + ], + "org.springframework.boot:spring-boot-jackson": [ + "org.springframework.boot.jackson", + "org.springframework.boot.jackson.autoconfigure" + ], + "org.springframework.boot:spring-boot-micrometer-metrics": [ + "org.springframework.boot.micrometer.metrics", + "org.springframework.boot.micrometer.metrics.actuate.endpoint", + "org.springframework.boot.micrometer.metrics.autoconfigure", + "org.springframework.boot.micrometer.metrics.autoconfigure.export", + "org.springframework.boot.micrometer.metrics.autoconfigure.export.appoptics", + "org.springframework.boot.micrometer.metrics.autoconfigure.export.atlas", + "org.springframework.boot.micrometer.metrics.autoconfigure.export.datadog", + "org.springframework.boot.micrometer.metrics.autoconfigure.export.dynatrace", + "org.springframework.boot.micrometer.metrics.autoconfigure.export.elastic", + "org.springframework.boot.micrometer.metrics.autoconfigure.export.ganglia", + "org.springframework.boot.micrometer.metrics.autoconfigure.export.graphite", + "org.springframework.boot.micrometer.metrics.autoconfigure.export.humio", + "org.springframework.boot.micrometer.metrics.autoconfigure.export.influx", + "org.springframework.boot.micrometer.metrics.autoconfigure.export.jmx", + "org.springframework.boot.micrometer.metrics.autoconfigure.export.kairos", + "org.springframework.boot.micrometer.metrics.autoconfigure.export.newrelic", + "org.springframework.boot.micrometer.metrics.autoconfigure.export.otlp", + "org.springframework.boot.micrometer.metrics.autoconfigure.export.prometheus", + "org.springframework.boot.micrometer.metrics.autoconfigure.export.properties", + "org.springframework.boot.micrometer.metrics.autoconfigure.export.simple", + "org.springframework.boot.micrometer.metrics.autoconfigure.export.stackdriver", + "org.springframework.boot.micrometer.metrics.autoconfigure.export.statsd", + "org.springframework.boot.micrometer.metrics.autoconfigure.jvm", + "org.springframework.boot.micrometer.metrics.autoconfigure.logging.log4j2", + "org.springframework.boot.micrometer.metrics.autoconfigure.logging.logback", + "org.springframework.boot.micrometer.metrics.autoconfigure.ssl", + "org.springframework.boot.micrometer.metrics.autoconfigure.startup", + "org.springframework.boot.micrometer.metrics.autoconfigure.system", + "org.springframework.boot.micrometer.metrics.autoconfigure.task", + "org.springframework.boot.micrometer.metrics.docker.compose.otlp", + "org.springframework.boot.micrometer.metrics.export.prometheus", + "org.springframework.boot.micrometer.metrics.export.prometheus.endpoint", + "org.springframework.boot.micrometer.metrics.startup", + "org.springframework.boot.micrometer.metrics.system", + "org.springframework.boot.micrometer.metrics.testcontainers.otlp" + ], + "org.springframework.boot:spring-boot-micrometer-metrics-test": [ + "org.springframework.boot.micrometer.metrics.test.autoconfigure" + ], + "org.springframework.boot:spring-boot-micrometer-observation": [ + "org.springframework.boot.micrometer.observation.autoconfigure" + ], + "org.springframework.boot:spring-boot-micrometer-tracing": [ + "org.springframework.boot.micrometer.tracing.autoconfigure", + "org.springframework.boot.micrometer.tracing.autoconfigure.prometheus" + ], + "org.springframework.boot:spring-boot-micrometer-tracing-opentelemetry": [ + "org.springframework.boot.micrometer.tracing.opentelemetry.autoconfigure", + "org.springframework.boot.micrometer.tracing.opentelemetry.autoconfigure.otlp", + "org.springframework.boot.micrometer.tracing.opentelemetry.autoconfigure.zipkin", + "org.springframework.boot.micrometer.tracing.opentelemetry.docker.compose.otlp", + "org.springframework.boot.micrometer.tracing.opentelemetry.testcontainers.otlp" + ], + "org.springframework.boot:spring-boot-netty": [ + "org.springframework.boot.netty.autoconfigure" + ], + "org.springframework.boot:spring-boot-opentelemetry": [ + "org.springframework.boot.opentelemetry.autoconfigure", + "org.springframework.boot.opentelemetry.autoconfigure.logging", + "org.springframework.boot.opentelemetry.autoconfigure.logging.otlp", + "org.springframework.boot.opentelemetry.docker.compose", + "org.springframework.boot.opentelemetry.testcontainers" + ], + "org.springframework.boot:spring-boot-persistence": [ + "org.springframework.boot.persistence.autoconfigure" + ], + "org.springframework.boot:spring-boot-reactor": [ + "org.springframework.boot.reactor", + "org.springframework.boot.reactor.autoconfigure" + ], + "org.springframework.boot:spring-boot-reactor-netty": [ + "org.springframework.boot.reactor.netty", + "org.springframework.boot.reactor.netty.autoconfigure", + "org.springframework.boot.reactor.netty.autoconfigure.actuate.web.server" + ], + "org.springframework.boot:spring-boot-restclient": [ + "org.springframework.boot.restclient", + "org.springframework.boot.restclient.autoconfigure", + "org.springframework.boot.restclient.autoconfigure.service", + "org.springframework.boot.restclient.observation" + ], + "org.springframework.boot:spring-boot-resttestclient": [ + "org.springframework.boot.resttestclient", + "org.springframework.boot.resttestclient.autoconfigure" + ], + "org.springframework.boot:spring-boot-security": [ + "org.springframework.boot.security.autoconfigure", + "org.springframework.boot.security.autoconfigure.actuate.web.reactive", + "org.springframework.boot.security.autoconfigure.actuate.web.servlet", + "org.springframework.boot.security.autoconfigure.rsocket", + "org.springframework.boot.security.autoconfigure.web", + "org.springframework.boot.security.autoconfigure.web.reactive", + "org.springframework.boot.security.autoconfigure.web.servlet", + "org.springframework.boot.security.web.reactive", + "org.springframework.boot.security.web.servlet" + ], + "org.springframework.boot:spring-boot-security-oauth2-client": [ + "org.springframework.boot.security.oauth2.client.autoconfigure", + "org.springframework.boot.security.oauth2.client.autoconfigure.reactive", + "org.springframework.boot.security.oauth2.client.autoconfigure.servlet" + ], + "org.springframework.boot:spring-boot-security-oauth2-resource-server": [ + "org.springframework.boot.security.oauth2.server.resource.autoconfigure", + "org.springframework.boot.security.oauth2.server.resource.autoconfigure.reactive", + "org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet" + ], + "org.springframework.boot:spring-boot-security-test": [ + "org.springframework.boot.security.test.autoconfigure.webflux", + "org.springframework.boot.security.test.autoconfigure.webmvc" + ], + "org.springframework.boot:spring-boot-servlet": [ + "org.springframework.boot.servlet", + "org.springframework.boot.servlet.actuate.web.exchanges", + "org.springframework.boot.servlet.actuate.web.mappings", + "org.springframework.boot.servlet.autoconfigure", + "org.springframework.boot.servlet.autoconfigure.actuate.web", + "org.springframework.boot.servlet.autoconfigure.actuate.web.exchanges", + "org.springframework.boot.servlet.autoconfigure.actuate.web.mappings", + "org.springframework.boot.servlet.filter" ], "org.springframework.boot:spring-boot-test": [ "org.springframework.boot.test.context", "org.springframework.boot.test.context.assertj", "org.springframework.boot.test.context.filter", + "org.springframework.boot.test.context.filter.annotation", "org.springframework.boot.test.context.runner", - "org.springframework.boot.test.graphql.tester", + "org.springframework.boot.test.http.client", + "org.springframework.boot.test.http.server", "org.springframework.boot.test.json", - "org.springframework.boot.test.mock.mockito", "org.springframework.boot.test.mock.web", - "org.springframework.boot.test.rsocket.server", "org.springframework.boot.test.system", "org.springframework.boot.test.util", - "org.springframework.boot.test.web", - "org.springframework.boot.test.web.client", "org.springframework.boot.test.web.htmlunit", - "org.springframework.boot.test.web.htmlunit.webdriver", - "org.springframework.boot.test.web.reactive.server", - "org.springframework.boot.test.web.reactor.netty", "org.springframework.boot.test.web.server" ], "org.springframework.boot:spring-boot-test-autoconfigure": [ "org.springframework.boot.test.autoconfigure", - "org.springframework.boot.test.autoconfigure.actuate.observability", - "org.springframework.boot.test.autoconfigure.core", - "org.springframework.boot.test.autoconfigure.data.cassandra", - "org.springframework.boot.test.autoconfigure.data.couchbase", - "org.springframework.boot.test.autoconfigure.data.elasticsearch", - "org.springframework.boot.test.autoconfigure.data.jdbc", - "org.springframework.boot.test.autoconfigure.data.ldap", - "org.springframework.boot.test.autoconfigure.data.mongo", - "org.springframework.boot.test.autoconfigure.data.neo4j", - "org.springframework.boot.test.autoconfigure.data.r2dbc", - "org.springframework.boot.test.autoconfigure.data.redis", - "org.springframework.boot.test.autoconfigure.filter", - "org.springframework.boot.test.autoconfigure.graphql", - "org.springframework.boot.test.autoconfigure.graphql.tester", "org.springframework.boot.test.autoconfigure.jdbc", - "org.springframework.boot.test.autoconfigure.jooq", - "org.springframework.boot.test.autoconfigure.json", - "org.springframework.boot.test.autoconfigure.orm.jpa", - "org.springframework.boot.test.autoconfigure.properties", - "org.springframework.boot.test.autoconfigure.restdocs", - "org.springframework.boot.test.autoconfigure.web.client", - "org.springframework.boot.test.autoconfigure.web.reactive", - "org.springframework.boot.test.autoconfigure.web.servlet", - "org.springframework.boot.test.autoconfigure.webservices.client", - "org.springframework.boot.test.autoconfigure.webservices.server" + "org.springframework.boot.test.autoconfigure.json" + ], + "org.springframework.boot:spring-boot-tomcat": [ + "org.springframework.boot.tomcat", + "org.springframework.boot.tomcat.autoconfigure", + "org.springframework.boot.tomcat.autoconfigure.actuate.web.server", + "org.springframework.boot.tomcat.autoconfigure.metrics", + "org.springframework.boot.tomcat.autoconfigure.reactive", + "org.springframework.boot.tomcat.autoconfigure.servlet", + "org.springframework.boot.tomcat.metrics", + "org.springframework.boot.tomcat.reactive", + "org.springframework.boot.tomcat.servlet" + ], + "org.springframework.boot:spring-boot-validation": [ + "org.springframework.boot.validation.autoconfigure" + ], + "org.springframework.boot:spring-boot-web-server": [ + "org.springframework.boot.web.server", + "org.springframework.boot.web.server.autoconfigure", + "org.springframework.boot.web.server.autoconfigure.reactive", + "org.springframework.boot.web.server.autoconfigure.servlet", + "org.springframework.boot.web.server.context", + "org.springframework.boot.web.server.reactive", + "org.springframework.boot.web.server.reactive.context", + "org.springframework.boot.web.server.servlet", + "org.springframework.boot.web.server.servlet.context" + ], + "org.springframework.boot:spring-boot-webclient": [ + "org.springframework.boot.webclient", + "org.springframework.boot.webclient.autoconfigure", + "org.springframework.boot.webclient.autoconfigure.service", + "org.springframework.boot.webclient.observation" + ], + "org.springframework.boot:spring-boot-webflux": [ + "org.springframework.boot.webflux", + "org.springframework.boot.webflux.actuate.endpoint.web", + "org.springframework.boot.webflux.actuate.web.exchanges", + "org.springframework.boot.webflux.actuate.web.mappings", + "org.springframework.boot.webflux.autoconfigure", + "org.springframework.boot.webflux.autoconfigure.actuate.endpoint.web", + "org.springframework.boot.webflux.autoconfigure.actuate.web", + "org.springframework.boot.webflux.autoconfigure.actuate.web.exchanges", + "org.springframework.boot.webflux.autoconfigure.actuate.web.mappings", + "org.springframework.boot.webflux.autoconfigure.error", + "org.springframework.boot.webflux.error", + "org.springframework.boot.webflux.filter" + ], + "org.springframework.boot:spring-boot-webflux-test": [ + "org.springframework.boot.webflux.test.autoconfigure" + ], + "org.springframework.boot:spring-boot-webmvc": [ + "org.springframework.boot.webmvc", + "org.springframework.boot.webmvc.actuate.endpoint.web", + "org.springframework.boot.webmvc.actuate.web.mappings", + "org.springframework.boot.webmvc.autoconfigure", + "org.springframework.boot.webmvc.autoconfigure.actuate.endpoint.web", + "org.springframework.boot.webmvc.autoconfigure.actuate.web", + "org.springframework.boot.webmvc.autoconfigure.actuate.web.mappings", + "org.springframework.boot.webmvc.autoconfigure.error", + "org.springframework.boot.webmvc.error" + ], + "org.springframework.boot:spring-boot-webmvc-test": [ + "org.springframework.boot.webmvc.test.autoconfigure" + ], + "org.springframework.boot:spring-boot-webtestclient": [ + "org.springframework.boot.webtestclient.autoconfigure" + ], + "org.springframework.cloud:spring-cloud-commons": [ + "org.springframework.cloud.client", + "org.springframework.cloud.client.actuator", + "org.springframework.cloud.client.circuitbreaker", + "org.springframework.cloud.client.circuitbreaker.httpservice", + "org.springframework.cloud.client.circuitbreaker.observation", + "org.springframework.cloud.client.discovery", + "org.springframework.cloud.client.discovery.composite", + "org.springframework.cloud.client.discovery.composite.reactive", + "org.springframework.cloud.client.discovery.event", + "org.springframework.cloud.client.discovery.health", + "org.springframework.cloud.client.discovery.health.reactive", + "org.springframework.cloud.client.discovery.simple", + "org.springframework.cloud.client.discovery.simple.reactive", + "org.springframework.cloud.client.hypermedia", + "org.springframework.cloud.client.loadbalancer", + "org.springframework.cloud.client.loadbalancer.reactive", + "org.springframework.cloud.client.serviceregistry", + "org.springframework.cloud.client.serviceregistry.endpoint", + "org.springframework.cloud.commons", + "org.springframework.cloud.commons.config", + "org.springframework.cloud.commons.publisher", + "org.springframework.cloud.commons.security", + "org.springframework.cloud.commons.util", + "org.springframework.cloud.configuration" + ], + "org.springframework.cloud:spring-cloud-context": [ + "org.springframework.cloud.autoconfigure", + "org.springframework.cloud.bootstrap", + "org.springframework.cloud.bootstrap.config", + "org.springframework.cloud.bootstrap.encrypt", + "org.springframework.cloud.bootstrap.support", + "org.springframework.cloud.context", + "org.springframework.cloud.context.config", + "org.springframework.cloud.context.config.annotation", + "org.springframework.cloud.context.encrypt", + "org.springframework.cloud.context.environment", + "org.springframework.cloud.context.named", + "org.springframework.cloud.context.properties", + "org.springframework.cloud.context.refresh", + "org.springframework.cloud.context.restart", + "org.springframework.cloud.context.scope", + "org.springframework.cloud.context.scope.refresh", + "org.springframework.cloud.context.scope.thread", + "org.springframework.cloud.endpoint", + "org.springframework.cloud.endpoint.event", + "org.springframework.cloud.env", + "org.springframework.cloud.health", + "org.springframework.cloud.logging", + "org.springframework.cloud.util", + "org.springframework.cloud.util.random" + ], + "org.springframework.cloud:spring-cloud-starter-bootstrap": [ + "org.springframework.cloud.bootstrap.marker" + ], + "org.springframework.data:spring-data-cassandra": [ + "org.springframework.data.cassandra", + "org.springframework.data.cassandra.aot", + "org.springframework.data.cassandra.config", + "org.springframework.data.cassandra.core", + "org.springframework.data.cassandra.core.convert", + "org.springframework.data.cassandra.core.cql", + "org.springframework.data.cassandra.core.cql.converter", + "org.springframework.data.cassandra.core.cql.generator", + "org.springframework.data.cassandra.core.cql.keyspace", + "org.springframework.data.cassandra.core.cql.session", + "org.springframework.data.cassandra.core.cql.session.init", + "org.springframework.data.cassandra.core.cql.session.lookup", + "org.springframework.data.cassandra.core.cql.util", + "org.springframework.data.cassandra.core.mapping", + "org.springframework.data.cassandra.core.mapping.event", + "org.springframework.data.cassandra.core.query", + "org.springframework.data.cassandra.observability", + "org.springframework.data.cassandra.repository", + "org.springframework.data.cassandra.repository.aot", + "org.springframework.data.cassandra.repository.cdi", + "org.springframework.data.cassandra.repository.config", + "org.springframework.data.cassandra.repository.query", + "org.springframework.data.cassandra.repository.support", + "org.springframework.data.cassandra.util" + ], + "org.springframework.data:spring-data-commons": [ + "org.springframework.data.annotation", + "org.springframework.data.aot", + "org.springframework.data.auditing", + "org.springframework.data.auditing.config", + "org.springframework.data.config", + "org.springframework.data.convert", + "org.springframework.data.core", + "org.springframework.data.crossstore", + "org.springframework.data.domain", + "org.springframework.data.domain.jaxb", + "org.springframework.data.expression", + "org.springframework.data.geo", + "org.springframework.data.geo.format", + "org.springframework.data.history", + "org.springframework.data.javapoet", + "org.springframework.data.mapping", + "org.springframework.data.mapping.callback", + "org.springframework.data.mapping.context", + "org.springframework.data.mapping.model", + "org.springframework.data.projection", + "org.springframework.data.querydsl", + "org.springframework.data.querydsl.aot", + "org.springframework.data.querydsl.binding", + "org.springframework.data.repository", + "org.springframework.data.repository.aot.generate", + "org.springframework.data.repository.aot.hint", + "org.springframework.data.repository.cdi", + "org.springframework.data.repository.config", + "org.springframework.data.repository.core", + "org.springframework.data.repository.core.support", + "org.springframework.data.repository.history", + "org.springframework.data.repository.history.support", + "org.springframework.data.repository.init", + "org.springframework.data.repository.kotlin", + "org.springframework.data.repository.query", + "org.springframework.data.repository.query.parser", + "org.springframework.data.repository.reactive", + "org.springframework.data.repository.support", + "org.springframework.data.repository.util", + "org.springframework.data.spel", + "org.springframework.data.spel.spi", + "org.springframework.data.support", + "org.springframework.data.transaction", + "org.springframework.data.util", + "org.springframework.data.web", + "org.springframework.data.web.aot", + "org.springframework.data.web.config", + "org.springframework.data.web.querydsl" + ], + "org.springframework.security:spring-security-config": [ + "org.springframework.security.config", + "org.springframework.security.config.annotation", + "org.springframework.security.config.annotation.authentication", + "org.springframework.security.config.annotation.authentication.builders", + "org.springframework.security.config.annotation.authentication.configuration", + "org.springframework.security.config.annotation.authentication.configurers.ldap", + "org.springframework.security.config.annotation.authentication.configurers.provisioning", + "org.springframework.security.config.annotation.authentication.configurers.userdetails", + "org.springframework.security.config.annotation.authorization", + "org.springframework.security.config.annotation.configuration", + "org.springframework.security.config.annotation.method.configuration", + "org.springframework.security.config.annotation.rsocket", + "org.springframework.security.config.annotation.web", + "org.springframework.security.config.annotation.web.builders", + "org.springframework.security.config.annotation.web.configuration", + "org.springframework.security.config.annotation.web.configurers", + "org.springframework.security.config.annotation.web.configurers.oauth2.client", + "org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization", + "org.springframework.security.config.annotation.web.configurers.oauth2.server.resource", + "org.springframework.security.config.annotation.web.configurers.ott", + "org.springframework.security.config.annotation.web.configurers.saml2", + "org.springframework.security.config.annotation.web.headers", + "org.springframework.security.config.annotation.web.oauth2.client", + "org.springframework.security.config.annotation.web.oauth2.login", + "org.springframework.security.config.annotation.web.oauth2.resourceserver", + "org.springframework.security.config.annotation.web.reactive", + "org.springframework.security.config.annotation.web.saml2", + "org.springframework.security.config.annotation.web.servlet.configuration", + "org.springframework.security.config.annotation.web.session", + "org.springframework.security.config.annotation.web.socket", + "org.springframework.security.config.aot.hint", + "org.springframework.security.config.authentication", + "org.springframework.security.config.core", + "org.springframework.security.config.core.userdetails", + "org.springframework.security.config.crypto", + "org.springframework.security.config.debug", + "org.springframework.security.config.http", + "org.springframework.security.config.ldap", + "org.springframework.security.config.method", + "org.springframework.security.config.oauth2.client", + "org.springframework.security.config.observation", + "org.springframework.security.config.provisioning", + "org.springframework.security.config.saml2", + "org.springframework.security.config.web", + "org.springframework.security.config.web.messaging", + "org.springframework.security.config.web.server", + "org.springframework.security.config.websocket" + ], + "org.springframework.security:spring-security-core": [ + "org.springframework.security.access", + "org.springframework.security.access.annotation", + "org.springframework.security.access.expression", + "org.springframework.security.access.expression.method", + "org.springframework.security.access.hierarchicalroles", + "org.springframework.security.access.prepost", + "org.springframework.security.aot.hint", + "org.springframework.security.authentication", + "org.springframework.security.authentication.dao", + "org.springframework.security.authentication.event", + "org.springframework.security.authentication.jaas", + "org.springframework.security.authentication.jaas.event", + "org.springframework.security.authentication.jaas.memory", + "org.springframework.security.authentication.ott", + "org.springframework.security.authentication.ott.reactive", + "org.springframework.security.authentication.password", + "org.springframework.security.authorization", + "org.springframework.security.authorization.event", + "org.springframework.security.authorization.method", + "org.springframework.security.concurrent", + "org.springframework.security.context", + "org.springframework.security.converter", + "org.springframework.security.core", + "org.springframework.security.core.annotation", + "org.springframework.security.core.authority", + "org.springframework.security.core.authority.mapping", + "org.springframework.security.core.context", + "org.springframework.security.core.parameters", + "org.springframework.security.core.session", + "org.springframework.security.core.token", + "org.springframework.security.core.userdetails", + "org.springframework.security.core.userdetails.cache", + "org.springframework.security.core.userdetails.jdbc", + "org.springframework.security.core.userdetails.memory", + "org.springframework.security.jackson", + "org.springframework.security.jackson2", + "org.springframework.security.provisioning", + "org.springframework.security.scheduling", + "org.springframework.security.task", + "org.springframework.security.util" + ], + "org.springframework.security:spring-security-crypto": [ + "org.springframework.security.crypto.argon2", + "org.springframework.security.crypto.bcrypt", + "org.springframework.security.crypto.codec", + "org.springframework.security.crypto.encrypt", + "org.springframework.security.crypto.factory", + "org.springframework.security.crypto.keygen", + "org.springframework.security.crypto.password", + "org.springframework.security.crypto.password4j", + "org.springframework.security.crypto.scrypt", + "org.springframework.security.crypto.util" + ], + "org.springframework.security:spring-security-oauth2-client": [ + "org.springframework.security.oauth2.client", + "org.springframework.security.oauth2.client.annotation", + "org.springframework.security.oauth2.client.aot.hint", + "org.springframework.security.oauth2.client.authentication", + "org.springframework.security.oauth2.client.endpoint", + "org.springframework.security.oauth2.client.event", + "org.springframework.security.oauth2.client.http", + "org.springframework.security.oauth2.client.jackson", + "org.springframework.security.oauth2.client.jackson2", + "org.springframework.security.oauth2.client.oidc.authentication", + "org.springframework.security.oauth2.client.oidc.authentication.event", + "org.springframework.security.oauth2.client.oidc.authentication.logout", + "org.springframework.security.oauth2.client.oidc.server.session", + "org.springframework.security.oauth2.client.oidc.session", + "org.springframework.security.oauth2.client.oidc.userinfo", + "org.springframework.security.oauth2.client.oidc.web.logout", + "org.springframework.security.oauth2.client.oidc.web.server.logout", + "org.springframework.security.oauth2.client.registration", + "org.springframework.security.oauth2.client.userinfo", + "org.springframework.security.oauth2.client.web", + "org.springframework.security.oauth2.client.web.client", + "org.springframework.security.oauth2.client.web.client.support", + "org.springframework.security.oauth2.client.web.method.annotation", + "org.springframework.security.oauth2.client.web.reactive.function.client", + "org.springframework.security.oauth2.client.web.reactive.function.client.support", + "org.springframework.security.oauth2.client.web.reactive.result.method.annotation", + "org.springframework.security.oauth2.client.web.server", + "org.springframework.security.oauth2.client.web.server.authentication" + ], + "org.springframework.security:spring-security-oauth2-core": [ + "org.springframework.security.oauth2.core", + "org.springframework.security.oauth2.core.authorization", + "org.springframework.security.oauth2.core.converter", + "org.springframework.security.oauth2.core.endpoint", + "org.springframework.security.oauth2.core.http.converter", + "org.springframework.security.oauth2.core.oidc", + "org.springframework.security.oauth2.core.oidc.endpoint", + "org.springframework.security.oauth2.core.oidc.user", + "org.springframework.security.oauth2.core.user", + "org.springframework.security.oauth2.core.web.reactive.function" + ], + "org.springframework.security:spring-security-oauth2-jose": [ + "org.springframework.security.oauth2.jose", + "org.springframework.security.oauth2.jose.jws", + "org.springframework.security.oauth2.jwt" + ], + "org.springframework.security:spring-security-oauth2-resource-server": [ + "org.springframework.security.oauth2.server.resource", + "org.springframework.security.oauth2.server.resource.authentication", + "org.springframework.security.oauth2.server.resource.introspection", + "org.springframework.security.oauth2.server.resource.web", + "org.springframework.security.oauth2.server.resource.web.access", + "org.springframework.security.oauth2.server.resource.web.access.server", + "org.springframework.security.oauth2.server.resource.web.authentication", + "org.springframework.security.oauth2.server.resource.web.reactive.function.client", + "org.springframework.security.oauth2.server.resource.web.server", + "org.springframework.security.oauth2.server.resource.web.server.authentication" + ], + "org.springframework.security:spring-security-test": [ + "org.springframework.security.test.aot.hint", + "org.springframework.security.test.context", + "org.springframework.security.test.context.annotation", + "org.springframework.security.test.context.support", + "org.springframework.security.test.web.reactive.server", + "org.springframework.security.test.web.servlet.request", + "org.springframework.security.test.web.servlet.response", + "org.springframework.security.test.web.servlet.setup", + "org.springframework.security.test.web.support" + ], + "org.springframework.security:spring-security-web": [ + "org.springframework.security.web", + "org.springframework.security.web.access", + "org.springframework.security.web.access.expression", + "org.springframework.security.web.access.intercept", + "org.springframework.security.web.aot.hint", + "org.springframework.security.web.authentication", + "org.springframework.security.web.authentication.logout", + "org.springframework.security.web.authentication.ott", + "org.springframework.security.web.authentication.password", + "org.springframework.security.web.authentication.preauth", + "org.springframework.security.web.authentication.preauth.j2ee", + "org.springframework.security.web.authentication.preauth.websphere", + "org.springframework.security.web.authentication.preauth.x509", + "org.springframework.security.web.authentication.rememberme", + "org.springframework.security.web.authentication.session", + "org.springframework.security.web.authentication.switchuser", + "org.springframework.security.web.authentication.ui", + "org.springframework.security.web.authentication.www", + "org.springframework.security.web.bind", + "org.springframework.security.web.bind.annotation", + "org.springframework.security.web.bind.support", + "org.springframework.security.web.context", + "org.springframework.security.web.context.request.async", + "org.springframework.security.web.context.support", + "org.springframework.security.web.csrf", + "org.springframework.security.web.debug", + "org.springframework.security.web.firewall", + "org.springframework.security.web.header", + "org.springframework.security.web.header.writers", + "org.springframework.security.web.header.writers.frameoptions", + "org.springframework.security.web.http", + "org.springframework.security.web.jaasapi", + "org.springframework.security.web.jackson", + "org.springframework.security.web.jackson2", + "org.springframework.security.web.method.annotation", + "org.springframework.security.web.reactive.result.method.annotation", + "org.springframework.security.web.reactive.result.view", + "org.springframework.security.web.savedrequest", + "org.springframework.security.web.server", + "org.springframework.security.web.server.authentication", + "org.springframework.security.web.server.authentication.logout", + "org.springframework.security.web.server.authentication.ott", + "org.springframework.security.web.server.authorization", + "org.springframework.security.web.server.context", + "org.springframework.security.web.server.csrf", + "org.springframework.security.web.server.firewall", + "org.springframework.security.web.server.header", + "org.springframework.security.web.server.jackson", + "org.springframework.security.web.server.jackson2", + "org.springframework.security.web.server.savedrequest", + "org.springframework.security.web.server.transport", + "org.springframework.security.web.server.ui", + "org.springframework.security.web.server.util.matcher", + "org.springframework.security.web.servlet.support.csrf", + "org.springframework.security.web.servlet.util.matcher", + "org.springframework.security.web.servletapi", + "org.springframework.security.web.session", + "org.springframework.security.web.transport", + "org.springframework.security.web.util", + "org.springframework.security.web.util.matcher" ], "org.springframework:spring-aop": [ + "org.aopalliance", "org.aopalliance.aop", "org.aopalliance.intercept", "org.springframework.aop", @@ -1730,6 +7087,9 @@ "org.springframework.jmx.support", "org.springframework.jndi", "org.springframework.jndi.support", + "org.springframework.resilience", + "org.springframework.resilience.annotation", + "org.springframework.resilience.retry", "org.springframework.scheduling", "org.springframework.scheduling.annotation", "org.springframework.scheduling.concurrent", @@ -1742,8 +7102,6 @@ "org.springframework.scripting.support", "org.springframework.stereotype", "org.springframework.ui", - "org.springframework.ui.context", - "org.springframework.ui.context.support", "org.springframework.validation", "org.springframework.validation.annotation", "org.springframework.validation.beanvalidation", @@ -1759,7 +7117,6 @@ "org.springframework.aot.hint.support", "org.springframework.aot.nativex", "org.springframework.aot.nativex.feature", - "org.springframework.aot.nativex.substitution", "org.springframework.asm", "org.springframework.cglib", "org.springframework.cglib.beans", @@ -1783,6 +7140,8 @@ "org.springframework.core.log", "org.springframework.core.metrics", "org.springframework.core.metrics.jfr", + "org.springframework.core.retry", + "org.springframework.core.retry.support", "org.springframework.core.serializer", "org.springframework.core.serializer.support", "org.springframework.core.style", @@ -1819,10 +7178,6 @@ "org.springframework.expression.spel.standard", "org.springframework.expression.spel.support" ], - "org.springframework:spring-jcl": [ - "org.apache.commons.logging", - "org.apache.commons.logging.impl" - ], "org.springframework:spring-test": [ "org.springframework.mock.env", "org.springframework.mock.http", @@ -1835,6 +7190,9 @@ "org.springframework.test.annotation", "org.springframework.test.context", "org.springframework.test.context.aot", + "org.springframework.test.context.bean.override", + "org.springframework.test.context.bean.override.convention", + "org.springframework.test.context.bean.override.mockito", "org.springframework.test.context.cache", "org.springframework.test.context.event", "org.springframework.test.context.event.annotation", @@ -1852,20 +7210,42 @@ "org.springframework.test.context.util", "org.springframework.test.context.web", "org.springframework.test.context.web.socket", + "org.springframework.test.http", "org.springframework.test.jdbc", + "org.springframework.test.json", "org.springframework.test.util", + "org.springframework.test.validation", "org.springframework.test.web", "org.springframework.test.web.client", "org.springframework.test.web.client.match", "org.springframework.test.web.client.response", "org.springframework.test.web.reactive.server", + "org.springframework.test.web.reactive.server.assertj", "org.springframework.test.web.servlet", + "org.springframework.test.web.servlet.assertj", "org.springframework.test.web.servlet.client", + "org.springframework.test.web.servlet.client.assertj", "org.springframework.test.web.servlet.htmlunit", "org.springframework.test.web.servlet.htmlunit.webdriver", "org.springframework.test.web.servlet.request", "org.springframework.test.web.servlet.result", - "org.springframework.test.web.servlet.setup" + "org.springframework.test.web.servlet.setup", + "org.springframework.test.web.support" + ], + "org.springframework:spring-tx": [ + "org.springframework.dao", + "org.springframework.dao.annotation", + "org.springframework.dao.support", + "org.springframework.jca.endpoint", + "org.springframework.jca.support", + "org.springframework.transaction", + "org.springframework.transaction.annotation", + "org.springframework.transaction.config", + "org.springframework.transaction.event", + "org.springframework.transaction.interceptor", + "org.springframework.transaction.jta", + "org.springframework.transaction.reactive", + "org.springframework.transaction.support" ], "org.springframework:spring-web": [ "org.springframework.http", @@ -1878,6 +7258,7 @@ "org.springframework.http.codec.json", "org.springframework.http.codec.multipart", "org.springframework.http.codec.protobuf", + "org.springframework.http.codec.smile", "org.springframework.http.codec.support", "org.springframework.http.codec.xml", "org.springframework.http.converter", @@ -1888,6 +7269,7 @@ "org.springframework.http.converter.smile", "org.springframework.http.converter.support", "org.springframework.http.converter.xml", + "org.springframework.http.converter.yaml", "org.springframework.http.server", "org.springframework.http.server.observation", "org.springframework.http.server.reactive", @@ -1924,9 +7306,35 @@ "org.springframework.web.service", "org.springframework.web.service.annotation", "org.springframework.web.service.invoker", + "org.springframework.web.service.registry", "org.springframework.web.util", "org.springframework.web.util.pattern" ], + "org.springframework:spring-webflux": [ + "org.springframework.web.reactive", + "org.springframework.web.reactive.accept", + "org.springframework.web.reactive.config", + "org.springframework.web.reactive.function", + "org.springframework.web.reactive.function.client", + "org.springframework.web.reactive.function.client.support", + "org.springframework.web.reactive.function.server", + "org.springframework.web.reactive.function.server.support", + "org.springframework.web.reactive.handler", + "org.springframework.web.reactive.resource", + "org.springframework.web.reactive.result", + "org.springframework.web.reactive.result.condition", + "org.springframework.web.reactive.result.method", + "org.springframework.web.reactive.result.method.annotation", + "org.springframework.web.reactive.result.view", + "org.springframework.web.reactive.result.view.freemarker", + "org.springframework.web.reactive.result.view.script", + "org.springframework.web.reactive.socket", + "org.springframework.web.reactive.socket.adapter", + "org.springframework.web.reactive.socket.client", + "org.springframework.web.reactive.socket.server", + "org.springframework.web.reactive.socket.server.support", + "org.springframework.web.reactive.socket.server.upgrade" + ], "org.springframework:spring-webmvc": [ "org.springframework.web.servlet", "org.springframework.web.servlet.config", @@ -1945,7 +7353,6 @@ "org.springframework.web.servlet.support", "org.springframework.web.servlet.tags", "org.springframework.web.servlet.tags.form", - "org.springframework.web.servlet.theme", "org.springframework.web.servlet.view", "org.springframework.web.servlet.view.document", "org.springframework.web.servlet.view.feed", @@ -1956,6 +7363,872 @@ "org.springframework.web.servlet.view.xml", "org.springframework.web.servlet.view.xslt" ], + "org.testcontainers:testcontainers": [ + "org.testcontainers", + "org.testcontainers.containers", + "org.testcontainers.containers.output", + "org.testcontainers.containers.startupcheck", + "org.testcontainers.containers.traits", + "org.testcontainers.containers.wait.internal", + "org.testcontainers.containers.wait.strategy", + "org.testcontainers.core", + "org.testcontainers.dockerclient", + "org.testcontainers.images", + "org.testcontainers.images.builder", + "org.testcontainers.images.builder.dockerfile", + "org.testcontainers.images.builder.dockerfile.statement", + "org.testcontainers.images.builder.dockerfile.traits", + "org.testcontainers.images.builder.traits", + "org.testcontainers.jib", + "org.testcontainers.lifecycle", + "org.testcontainers.shaded.com.fasterxml.jackson.core", + "org.testcontainers.shaded.com.fasterxml.jackson.core.async", + "org.testcontainers.shaded.com.fasterxml.jackson.core.base", + "org.testcontainers.shaded.com.fasterxml.jackson.core.exc", + "org.testcontainers.shaded.com.fasterxml.jackson.core.filter", + "org.testcontainers.shaded.com.fasterxml.jackson.core.format", + "org.testcontainers.shaded.com.fasterxml.jackson.core.internal.shaded.fdp.v2_18_4", + "org.testcontainers.shaded.com.fasterxml.jackson.core.io", + "org.testcontainers.shaded.com.fasterxml.jackson.core.io.schubfach", + "org.testcontainers.shaded.com.fasterxml.jackson.core.json", + "org.testcontainers.shaded.com.fasterxml.jackson.core.json.async", + "org.testcontainers.shaded.com.fasterxml.jackson.core.sym", + "org.testcontainers.shaded.com.fasterxml.jackson.core.type", + "org.testcontainers.shaded.com.fasterxml.jackson.core.util", + "org.testcontainers.shaded.com.fasterxml.jackson.databind", + "org.testcontainers.shaded.com.fasterxml.jackson.databind.annotation", + "org.testcontainers.shaded.com.fasterxml.jackson.databind.cfg", + "org.testcontainers.shaded.com.fasterxml.jackson.databind.deser", + "org.testcontainers.shaded.com.fasterxml.jackson.databind.deser.impl", + "org.testcontainers.shaded.com.fasterxml.jackson.databind.deser.std", + "org.testcontainers.shaded.com.fasterxml.jackson.databind.exc", + "org.testcontainers.shaded.com.fasterxml.jackson.databind.ext", + "org.testcontainers.shaded.com.fasterxml.jackson.databind.introspect", + "org.testcontainers.shaded.com.fasterxml.jackson.databind.jdk14", + "org.testcontainers.shaded.com.fasterxml.jackson.databind.json", + "org.testcontainers.shaded.com.fasterxml.jackson.databind.jsonFormatVisitors", + "org.testcontainers.shaded.com.fasterxml.jackson.databind.jsonschema", + "org.testcontainers.shaded.com.fasterxml.jackson.databind.jsontype", + "org.testcontainers.shaded.com.fasterxml.jackson.databind.jsontype.impl", + "org.testcontainers.shaded.com.fasterxml.jackson.databind.module", + "org.testcontainers.shaded.com.fasterxml.jackson.databind.node", + "org.testcontainers.shaded.com.fasterxml.jackson.databind.ser", + "org.testcontainers.shaded.com.fasterxml.jackson.databind.ser.impl", + "org.testcontainers.shaded.com.fasterxml.jackson.databind.ser.std", + "org.testcontainers.shaded.com.fasterxml.jackson.databind.type", + "org.testcontainers.shaded.com.fasterxml.jackson.databind.util", + "org.testcontainers.shaded.com.fasterxml.jackson.databind.util.internal", + "org.testcontainers.shaded.com.github.dockerjava.core", + "org.testcontainers.shaded.com.github.dockerjava.core.async", + "org.testcontainers.shaded.com.github.dockerjava.core.command", + "org.testcontainers.shaded.com.github.dockerjava.core.dockerfile", + "org.testcontainers.shaded.com.github.dockerjava.core.exception", + "org.testcontainers.shaded.com.github.dockerjava.core.exec", + "org.testcontainers.shaded.com.github.dockerjava.core.util", + "org.testcontainers.shaded.com.google.common.annotations", + "org.testcontainers.shaded.com.google.common.base", + "org.testcontainers.shaded.com.google.common.base.internal", + "org.testcontainers.shaded.com.google.common.cache", + "org.testcontainers.shaded.com.google.common.collect", + "org.testcontainers.shaded.com.google.common.escape", + "org.testcontainers.shaded.com.google.common.eventbus", + "org.testcontainers.shaded.com.google.common.graph", + "org.testcontainers.shaded.com.google.common.hash", + "org.testcontainers.shaded.com.google.common.html", + "org.testcontainers.shaded.com.google.common.io", + "org.testcontainers.shaded.com.google.common.math", + "org.testcontainers.shaded.com.google.common.net", + "org.testcontainers.shaded.com.google.common.primitives", + "org.testcontainers.shaded.com.google.common.reflect", + "org.testcontainers.shaded.com.google.common.util.concurrent", + "org.testcontainers.shaded.com.google.common.util.concurrent.internal", + "org.testcontainers.shaded.com.google.common.xml", + "org.testcontainers.shaded.com.google.errorprone.annotations", + "org.testcontainers.shaded.com.google.errorprone.annotations.concurrent", + "org.testcontainers.shaded.com.google.thirdparty.publicsuffix", + "org.testcontainers.shaded.com.trilead.ssh2", + "org.testcontainers.shaded.com.trilead.ssh2.auth", + "org.testcontainers.shaded.com.trilead.ssh2.channel", + "org.testcontainers.shaded.com.trilead.ssh2.crypto", + "org.testcontainers.shaded.com.trilead.ssh2.crypto.cipher", + "org.testcontainers.shaded.com.trilead.ssh2.crypto.dh", + "org.testcontainers.shaded.com.trilead.ssh2.crypto.digest", + "org.testcontainers.shaded.com.trilead.ssh2.log", + "org.testcontainers.shaded.com.trilead.ssh2.packets", + "org.testcontainers.shaded.com.trilead.ssh2.sftp", + "org.testcontainers.shaded.com.trilead.ssh2.signature", + "org.testcontainers.shaded.com.trilead.ssh2.transport", + "org.testcontainers.shaded.com.trilead.ssh2.util", + "org.testcontainers.shaded.org.awaitility", + "org.testcontainers.shaded.org.awaitility.classpath", + "org.testcontainers.shaded.org.awaitility.constraint", + "org.testcontainers.shaded.org.awaitility.core", + "org.testcontainers.shaded.org.awaitility.pollinterval", + "org.testcontainers.shaded.org.awaitility.reflect", + "org.testcontainers.shaded.org.awaitility.reflect.exception", + "org.testcontainers.shaded.org.awaitility.spi", + "org.testcontainers.shaded.org.bouncycastle", + "org.testcontainers.shaded.org.bouncycastle.asn1", + "org.testcontainers.shaded.org.bouncycastle.asn1.anssi", + "org.testcontainers.shaded.org.bouncycastle.asn1.bc", + "org.testcontainers.shaded.org.bouncycastle.asn1.bsi", + "org.testcontainers.shaded.org.bouncycastle.asn1.cmc", + "org.testcontainers.shaded.org.bouncycastle.asn1.cmp", + "org.testcontainers.shaded.org.bouncycastle.asn1.cms", + "org.testcontainers.shaded.org.bouncycastle.asn1.cms.ecc", + "org.testcontainers.shaded.org.bouncycastle.asn1.crmf", + "org.testcontainers.shaded.org.bouncycastle.asn1.cryptlib", + "org.testcontainers.shaded.org.bouncycastle.asn1.cryptopro", + "org.testcontainers.shaded.org.bouncycastle.asn1.dvcs", + "org.testcontainers.shaded.org.bouncycastle.asn1.eac", + "org.testcontainers.shaded.org.bouncycastle.asn1.edec", + "org.testcontainers.shaded.org.bouncycastle.asn1.esf", + "org.testcontainers.shaded.org.bouncycastle.asn1.ess", + "org.testcontainers.shaded.org.bouncycastle.asn1.est", + "org.testcontainers.shaded.org.bouncycastle.asn1.gm", + "org.testcontainers.shaded.org.bouncycastle.asn1.gnu", + "org.testcontainers.shaded.org.bouncycastle.asn1.iana", + "org.testcontainers.shaded.org.bouncycastle.asn1.icao", + "org.testcontainers.shaded.org.bouncycastle.asn1.isara", + "org.testcontainers.shaded.org.bouncycastle.asn1.isismtt", + "org.testcontainers.shaded.org.bouncycastle.asn1.isismtt.ocsp", + "org.testcontainers.shaded.org.bouncycastle.asn1.isismtt.x509", + "org.testcontainers.shaded.org.bouncycastle.asn1.iso", + "org.testcontainers.shaded.org.bouncycastle.asn1.kisa", + "org.testcontainers.shaded.org.bouncycastle.asn1.microsoft", + "org.testcontainers.shaded.org.bouncycastle.asn1.misc", + "org.testcontainers.shaded.org.bouncycastle.asn1.mod", + "org.testcontainers.shaded.org.bouncycastle.asn1.mozilla", + "org.testcontainers.shaded.org.bouncycastle.asn1.nist", + "org.testcontainers.shaded.org.bouncycastle.asn1.nsri", + "org.testcontainers.shaded.org.bouncycastle.asn1.ntt", + "org.testcontainers.shaded.org.bouncycastle.asn1.ocsp", + "org.testcontainers.shaded.org.bouncycastle.asn1.oiw", + "org.testcontainers.shaded.org.bouncycastle.asn1.pkcs", + "org.testcontainers.shaded.org.bouncycastle.asn1.rosstandart", + "org.testcontainers.shaded.org.bouncycastle.asn1.sec", + "org.testcontainers.shaded.org.bouncycastle.asn1.smime", + "org.testcontainers.shaded.org.bouncycastle.asn1.teletrust", + "org.testcontainers.shaded.org.bouncycastle.asn1.tsp", + "org.testcontainers.shaded.org.bouncycastle.asn1.ua", + "org.testcontainers.shaded.org.bouncycastle.asn1.util", + "org.testcontainers.shaded.org.bouncycastle.asn1.x500", + "org.testcontainers.shaded.org.bouncycastle.asn1.x500.style", + "org.testcontainers.shaded.org.bouncycastle.asn1.x509", + "org.testcontainers.shaded.org.bouncycastle.asn1.x509.qualified", + "org.testcontainers.shaded.org.bouncycastle.asn1.x509.sigi", + "org.testcontainers.shaded.org.bouncycastle.asn1.x9", + "org.testcontainers.shaded.org.bouncycastle.cert", + "org.testcontainers.shaded.org.bouncycastle.cert.bc", + "org.testcontainers.shaded.org.bouncycastle.cert.cmp", + "org.testcontainers.shaded.org.bouncycastle.cert.crmf", + "org.testcontainers.shaded.org.bouncycastle.cert.crmf.bc", + "org.testcontainers.shaded.org.bouncycastle.cert.crmf.jcajce", + "org.testcontainers.shaded.org.bouncycastle.cert.dane", + "org.testcontainers.shaded.org.bouncycastle.cert.dane.fetcher", + "org.testcontainers.shaded.org.bouncycastle.cert.jcajce", + "org.testcontainers.shaded.org.bouncycastle.cert.ocsp", + "org.testcontainers.shaded.org.bouncycastle.cert.ocsp.jcajce", + "org.testcontainers.shaded.org.bouncycastle.cert.path", + "org.testcontainers.shaded.org.bouncycastle.cert.path.validations", + "org.testcontainers.shaded.org.bouncycastle.cert.selector", + "org.testcontainers.shaded.org.bouncycastle.cert.selector.jcajce", + "org.testcontainers.shaded.org.bouncycastle.cmc", + "org.testcontainers.shaded.org.bouncycastle.cms", + "org.testcontainers.shaded.org.bouncycastle.cms.bc", + "org.testcontainers.shaded.org.bouncycastle.cms.jcajce", + "org.testcontainers.shaded.org.bouncycastle.crypto", + "org.testcontainers.shaded.org.bouncycastle.crypto.agreement", + "org.testcontainers.shaded.org.bouncycastle.crypto.agreement.ecjpake", + "org.testcontainers.shaded.org.bouncycastle.crypto.agreement.jpake", + "org.testcontainers.shaded.org.bouncycastle.crypto.agreement.kdf", + "org.testcontainers.shaded.org.bouncycastle.crypto.agreement.srp", + "org.testcontainers.shaded.org.bouncycastle.crypto.commitments", + "org.testcontainers.shaded.org.bouncycastle.crypto.constraints", + "org.testcontainers.shaded.org.bouncycastle.crypto.digests", + "org.testcontainers.shaded.org.bouncycastle.crypto.ec", + "org.testcontainers.shaded.org.bouncycastle.crypto.encodings", + "org.testcontainers.shaded.org.bouncycastle.crypto.engines", + "org.testcontainers.shaded.org.bouncycastle.crypto.examples", + "org.testcontainers.shaded.org.bouncycastle.crypto.fpe", + "org.testcontainers.shaded.org.bouncycastle.crypto.generators", + "org.testcontainers.shaded.org.bouncycastle.crypto.hpke", + "org.testcontainers.shaded.org.bouncycastle.crypto.io", + "org.testcontainers.shaded.org.bouncycastle.crypto.kems", + "org.testcontainers.shaded.org.bouncycastle.crypto.macs", + "org.testcontainers.shaded.org.bouncycastle.crypto.modes", + "org.testcontainers.shaded.org.bouncycastle.crypto.modes.gcm", + "org.testcontainers.shaded.org.bouncycastle.crypto.modes.kgcm", + "org.testcontainers.shaded.org.bouncycastle.crypto.paddings", + "org.testcontainers.shaded.org.bouncycastle.crypto.params", + "org.testcontainers.shaded.org.bouncycastle.crypto.parsers", + "org.testcontainers.shaded.org.bouncycastle.crypto.prng", + "org.testcontainers.shaded.org.bouncycastle.crypto.prng.drbg", + "org.testcontainers.shaded.org.bouncycastle.crypto.signers", + "org.testcontainers.shaded.org.bouncycastle.crypto.threshold", + "org.testcontainers.shaded.org.bouncycastle.crypto.tls", + "org.testcontainers.shaded.org.bouncycastle.crypto.util", + "org.testcontainers.shaded.org.bouncycastle.dvcs", + "org.testcontainers.shaded.org.bouncycastle.eac", + "org.testcontainers.shaded.org.bouncycastle.eac.jcajce", + "org.testcontainers.shaded.org.bouncycastle.eac.operator", + "org.testcontainers.shaded.org.bouncycastle.eac.operator.jcajce", + "org.testcontainers.shaded.org.bouncycastle.est", + "org.testcontainers.shaded.org.bouncycastle.est.jcajce", + "org.testcontainers.shaded.org.bouncycastle.i18n", + "org.testcontainers.shaded.org.bouncycastle.i18n.filter", + "org.testcontainers.shaded.org.bouncycastle.iana", + "org.testcontainers.shaded.org.bouncycastle.internal.asn1.bsi", + "org.testcontainers.shaded.org.bouncycastle.internal.asn1.cms", + "org.testcontainers.shaded.org.bouncycastle.internal.asn1.cryptlib", + "org.testcontainers.shaded.org.bouncycastle.internal.asn1.eac", + "org.testcontainers.shaded.org.bouncycastle.internal.asn1.edec", + "org.testcontainers.shaded.org.bouncycastle.internal.asn1.gnu", + "org.testcontainers.shaded.org.bouncycastle.internal.asn1.iana", + "org.testcontainers.shaded.org.bouncycastle.internal.asn1.isara", + "org.testcontainers.shaded.org.bouncycastle.internal.asn1.isismtt", + "org.testcontainers.shaded.org.bouncycastle.internal.asn1.iso", + "org.testcontainers.shaded.org.bouncycastle.internal.asn1.kisa", + "org.testcontainers.shaded.org.bouncycastle.internal.asn1.microsoft", + "org.testcontainers.shaded.org.bouncycastle.internal.asn1.misc", + "org.testcontainers.shaded.org.bouncycastle.internal.asn1.nsri", + "org.testcontainers.shaded.org.bouncycastle.internal.asn1.ntt", + "org.testcontainers.shaded.org.bouncycastle.internal.asn1.oiw", + "org.testcontainers.shaded.org.bouncycastle.internal.asn1.rosstandart", + "org.testcontainers.shaded.org.bouncycastle.its", + "org.testcontainers.shaded.org.bouncycastle.its.bc", + "org.testcontainers.shaded.org.bouncycastle.its.jcajce", + "org.testcontainers.shaded.org.bouncycastle.its.operator", + "org.testcontainers.shaded.org.bouncycastle.jcajce", + "org.testcontainers.shaded.org.bouncycastle.jcajce.interfaces", + "org.testcontainers.shaded.org.bouncycastle.jcajce.io", + "org.testcontainers.shaded.org.bouncycastle.jcajce.provider.asymmetric", + "org.testcontainers.shaded.org.bouncycastle.jcajce.provider.asymmetric.compositesignatures", + "org.testcontainers.shaded.org.bouncycastle.jcajce.provider.asymmetric.dh", + "org.testcontainers.shaded.org.bouncycastle.jcajce.provider.asymmetric.dsa", + "org.testcontainers.shaded.org.bouncycastle.jcajce.provider.asymmetric.dstu", + "org.testcontainers.shaded.org.bouncycastle.jcajce.provider.asymmetric.ec", + "org.testcontainers.shaded.org.bouncycastle.jcajce.provider.asymmetric.ecgost", + "org.testcontainers.shaded.org.bouncycastle.jcajce.provider.asymmetric.ecgost12", + "org.testcontainers.shaded.org.bouncycastle.jcajce.provider.asymmetric.edec", + "org.testcontainers.shaded.org.bouncycastle.jcajce.provider.asymmetric.elgamal", + "org.testcontainers.shaded.org.bouncycastle.jcajce.provider.asymmetric.gost", + "org.testcontainers.shaded.org.bouncycastle.jcajce.provider.asymmetric.ies", + "org.testcontainers.shaded.org.bouncycastle.jcajce.provider.asymmetric.mldsa", + "org.testcontainers.shaded.org.bouncycastle.jcajce.provider.asymmetric.mlkem", + "org.testcontainers.shaded.org.bouncycastle.jcajce.provider.asymmetric.rsa", + "org.testcontainers.shaded.org.bouncycastle.jcajce.provider.asymmetric.slhdsa", + "org.testcontainers.shaded.org.bouncycastle.jcajce.provider.asymmetric.util", + "org.testcontainers.shaded.org.bouncycastle.jcajce.provider.asymmetric.x509", + "org.testcontainers.shaded.org.bouncycastle.jcajce.provider.config", + "org.testcontainers.shaded.org.bouncycastle.jcajce.provider.digest", + "org.testcontainers.shaded.org.bouncycastle.jcajce.provider.drbg", + "org.testcontainers.shaded.org.bouncycastle.jcajce.provider.keystore", + "org.testcontainers.shaded.org.bouncycastle.jcajce.provider.keystore.bc", + "org.testcontainers.shaded.org.bouncycastle.jcajce.provider.keystore.bcfks", + "org.testcontainers.shaded.org.bouncycastle.jcajce.provider.keystore.pkcs12", + "org.testcontainers.shaded.org.bouncycastle.jcajce.provider.keystore.util", + "org.testcontainers.shaded.org.bouncycastle.jcajce.provider.symmetric", + "org.testcontainers.shaded.org.bouncycastle.jcajce.provider.symmetric.util", + "org.testcontainers.shaded.org.bouncycastle.jcajce.provider.util", + "org.testcontainers.shaded.org.bouncycastle.jcajce.spec", + "org.testcontainers.shaded.org.bouncycastle.jcajce.util", + "org.testcontainers.shaded.org.bouncycastle.jce", + "org.testcontainers.shaded.org.bouncycastle.jce.exception", + "org.testcontainers.shaded.org.bouncycastle.jce.interfaces", + "org.testcontainers.shaded.org.bouncycastle.jce.netscape", + "org.testcontainers.shaded.org.bouncycastle.jce.provider", + "org.testcontainers.shaded.org.bouncycastle.jce.spec", + "org.testcontainers.shaded.org.bouncycastle.math", + "org.testcontainers.shaded.org.bouncycastle.math.ec", + "org.testcontainers.shaded.org.bouncycastle.math.ec.custom.djb", + "org.testcontainers.shaded.org.bouncycastle.math.ec.custom.gm", + "org.testcontainers.shaded.org.bouncycastle.math.ec.custom.sec", + "org.testcontainers.shaded.org.bouncycastle.math.ec.endo", + "org.testcontainers.shaded.org.bouncycastle.math.ec.rfc7748", + "org.testcontainers.shaded.org.bouncycastle.math.ec.rfc8032", + "org.testcontainers.shaded.org.bouncycastle.math.ec.tools", + "org.testcontainers.shaded.org.bouncycastle.math.field", + "org.testcontainers.shaded.org.bouncycastle.math.raw", + "org.testcontainers.shaded.org.bouncycastle.mime", + "org.testcontainers.shaded.org.bouncycastle.mime.encoding", + "org.testcontainers.shaded.org.bouncycastle.mime.smime", + "org.testcontainers.shaded.org.bouncycastle.mozilla", + "org.testcontainers.shaded.org.bouncycastle.mozilla.jcajce", + "org.testcontainers.shaded.org.bouncycastle.oer", + "org.testcontainers.shaded.org.bouncycastle.oer.its", + "org.testcontainers.shaded.org.bouncycastle.oer.its.etsi102941", + "org.testcontainers.shaded.org.bouncycastle.oer.its.etsi102941.basetypes", + "org.testcontainers.shaded.org.bouncycastle.oer.its.etsi103097", + "org.testcontainers.shaded.org.bouncycastle.oer.its.etsi103097.extension", + "org.testcontainers.shaded.org.bouncycastle.oer.its.ieee1609dot2", + "org.testcontainers.shaded.org.bouncycastle.oer.its.ieee1609dot2.basetypes", + "org.testcontainers.shaded.org.bouncycastle.oer.its.ieee1609dot2dot1", + "org.testcontainers.shaded.org.bouncycastle.oer.its.template.etsi102941", + "org.testcontainers.shaded.org.bouncycastle.oer.its.template.etsi102941.basetypes", + "org.testcontainers.shaded.org.bouncycastle.oer.its.template.etsi103097", + "org.testcontainers.shaded.org.bouncycastle.oer.its.template.etsi103097.extension", + "org.testcontainers.shaded.org.bouncycastle.oer.its.template.ieee1609dot2", + "org.testcontainers.shaded.org.bouncycastle.oer.its.template.ieee1609dot2.basetypes", + "org.testcontainers.shaded.org.bouncycastle.oer.its.template.ieee1609dot2dot1", + "org.testcontainers.shaded.org.bouncycastle.openssl", + "org.testcontainers.shaded.org.bouncycastle.openssl.bc", + "org.testcontainers.shaded.org.bouncycastle.openssl.jcajce", + "org.testcontainers.shaded.org.bouncycastle.operator", + "org.testcontainers.shaded.org.bouncycastle.operator.bc", + "org.testcontainers.shaded.org.bouncycastle.operator.jcajce", + "org.testcontainers.shaded.org.bouncycastle.pkcs", + "org.testcontainers.shaded.org.bouncycastle.pkcs.bc", + "org.testcontainers.shaded.org.bouncycastle.pkcs.jcajce", + "org.testcontainers.shaded.org.bouncycastle.pkix", + "org.testcontainers.shaded.org.bouncycastle.pkix.jcajce", + "org.testcontainers.shaded.org.bouncycastle.pkix.util", + "org.testcontainers.shaded.org.bouncycastle.pkix.util.filter", + "org.testcontainers.shaded.org.bouncycastle.pqc.asn1", + "org.testcontainers.shaded.org.bouncycastle.pqc.crypto", + "org.testcontainers.shaded.org.bouncycastle.pqc.crypto.bike", + "org.testcontainers.shaded.org.bouncycastle.pqc.crypto.cmce", + "org.testcontainers.shaded.org.bouncycastle.pqc.crypto.crystals.dilithium", + "org.testcontainers.shaded.org.bouncycastle.pqc.crypto.falcon", + "org.testcontainers.shaded.org.bouncycastle.pqc.crypto.frodo", + "org.testcontainers.shaded.org.bouncycastle.pqc.crypto.hqc", + "org.testcontainers.shaded.org.bouncycastle.pqc.crypto.lms", + "org.testcontainers.shaded.org.bouncycastle.pqc.crypto.mayo", + "org.testcontainers.shaded.org.bouncycastle.pqc.crypto.mldsa", + "org.testcontainers.shaded.org.bouncycastle.pqc.crypto.mlkem", + "org.testcontainers.shaded.org.bouncycastle.pqc.crypto.newhope", + "org.testcontainers.shaded.org.bouncycastle.pqc.crypto.ntru", + "org.testcontainers.shaded.org.bouncycastle.pqc.crypto.ntruprime", + "org.testcontainers.shaded.org.bouncycastle.pqc.crypto.picnic", + "org.testcontainers.shaded.org.bouncycastle.pqc.crypto.rainbow", + "org.testcontainers.shaded.org.bouncycastle.pqc.crypto.saber", + "org.testcontainers.shaded.org.bouncycastle.pqc.crypto.slhdsa", + "org.testcontainers.shaded.org.bouncycastle.pqc.crypto.snova", + "org.testcontainers.shaded.org.bouncycastle.pqc.crypto.sphincs", + "org.testcontainers.shaded.org.bouncycastle.pqc.crypto.sphincsplus", + "org.testcontainers.shaded.org.bouncycastle.pqc.crypto.util", + "org.testcontainers.shaded.org.bouncycastle.pqc.crypto.xmss", + "org.testcontainers.shaded.org.bouncycastle.pqc.crypto.xwing", + "org.testcontainers.shaded.org.bouncycastle.pqc.jcajce.interfaces", + "org.testcontainers.shaded.org.bouncycastle.pqc.jcajce.provider", + "org.testcontainers.shaded.org.bouncycastle.pqc.jcajce.provider.bike", + "org.testcontainers.shaded.org.bouncycastle.pqc.jcajce.provider.cmce", + "org.testcontainers.shaded.org.bouncycastle.pqc.jcajce.provider.dilithium", + "org.testcontainers.shaded.org.bouncycastle.pqc.jcajce.provider.falcon", + "org.testcontainers.shaded.org.bouncycastle.pqc.jcajce.provider.frodo", + "org.testcontainers.shaded.org.bouncycastle.pqc.jcajce.provider.hqc", + "org.testcontainers.shaded.org.bouncycastle.pqc.jcajce.provider.kyber", + "org.testcontainers.shaded.org.bouncycastle.pqc.jcajce.provider.lms", + "org.testcontainers.shaded.org.bouncycastle.pqc.jcajce.provider.mayo", + "org.testcontainers.shaded.org.bouncycastle.pqc.jcajce.provider.newhope", + "org.testcontainers.shaded.org.bouncycastle.pqc.jcajce.provider.ntru", + "org.testcontainers.shaded.org.bouncycastle.pqc.jcajce.provider.ntruprime", + "org.testcontainers.shaded.org.bouncycastle.pqc.jcajce.provider.picnic", + "org.testcontainers.shaded.org.bouncycastle.pqc.jcajce.provider.saber", + "org.testcontainers.shaded.org.bouncycastle.pqc.jcajce.provider.snova", + "org.testcontainers.shaded.org.bouncycastle.pqc.jcajce.provider.sphincs", + "org.testcontainers.shaded.org.bouncycastle.pqc.jcajce.provider.sphincsplus", + "org.testcontainers.shaded.org.bouncycastle.pqc.jcajce.provider.util", + "org.testcontainers.shaded.org.bouncycastle.pqc.jcajce.provider.xmss", + "org.testcontainers.shaded.org.bouncycastle.pqc.jcajce.spec", + "org.testcontainers.shaded.org.bouncycastle.pqc.math.ntru", + "org.testcontainers.shaded.org.bouncycastle.pqc.math.ntru.parameters", + "org.testcontainers.shaded.org.bouncycastle.tsp", + "org.testcontainers.shaded.org.bouncycastle.tsp.cms", + "org.testcontainers.shaded.org.bouncycastle.tsp.ers", + "org.testcontainers.shaded.org.bouncycastle.util", + "org.testcontainers.shaded.org.bouncycastle.util.encoders", + "org.testcontainers.shaded.org.bouncycastle.util.io", + "org.testcontainers.shaded.org.bouncycastle.util.io.pem", + "org.testcontainers.shaded.org.bouncycastle.util.test", + "org.testcontainers.shaded.org.bouncycastle.voms", + "org.testcontainers.shaded.org.bouncycastle.x509", + "org.testcontainers.shaded.org.bouncycastle.x509.extension", + "org.testcontainers.shaded.org.bouncycastle.x509.util", + "org.testcontainers.shaded.org.checkerframework.checker.builder.qual", + "org.testcontainers.shaded.org.checkerframework.checker.calledmethods.qual", + "org.testcontainers.shaded.org.checkerframework.checker.compilermsgs.qual", + "org.testcontainers.shaded.org.checkerframework.checker.fenum.qual", + "org.testcontainers.shaded.org.checkerframework.checker.formatter.qual", + "org.testcontainers.shaded.org.checkerframework.checker.guieffect.qual", + "org.testcontainers.shaded.org.checkerframework.checker.i18n.qual", + "org.testcontainers.shaded.org.checkerframework.checker.i18nformatter.qual", + "org.testcontainers.shaded.org.checkerframework.checker.index.qual", + "org.testcontainers.shaded.org.checkerframework.checker.initialization.qual", + "org.testcontainers.shaded.org.checkerframework.checker.interning.qual", + "org.testcontainers.shaded.org.checkerframework.checker.lock.qual", + "org.testcontainers.shaded.org.checkerframework.checker.mustcall.qual", + "org.testcontainers.shaded.org.checkerframework.checker.nullness.qual", + "org.testcontainers.shaded.org.checkerframework.checker.optional.qual", + "org.testcontainers.shaded.org.checkerframework.checker.propkey.qual", + "org.testcontainers.shaded.org.checkerframework.checker.regex.qual", + "org.testcontainers.shaded.org.checkerframework.checker.signature.qual", + "org.testcontainers.shaded.org.checkerframework.checker.signedness.qual", + "org.testcontainers.shaded.org.checkerframework.checker.tainting.qual", + "org.testcontainers.shaded.org.checkerframework.checker.units.qual", + "org.testcontainers.shaded.org.checkerframework.common.aliasing.qual", + "org.testcontainers.shaded.org.checkerframework.common.initializedfields.qual", + "org.testcontainers.shaded.org.checkerframework.common.reflection.qual", + "org.testcontainers.shaded.org.checkerframework.common.returnsreceiver.qual", + "org.testcontainers.shaded.org.checkerframework.common.subtyping.qual", + "org.testcontainers.shaded.org.checkerframework.common.util.count.report.qual", + "org.testcontainers.shaded.org.checkerframework.common.value.qual", + "org.testcontainers.shaded.org.checkerframework.dataflow.qual", + "org.testcontainers.shaded.org.checkerframework.framework.qual", + "org.testcontainers.shaded.org.hamcrest", + "org.testcontainers.shaded.org.hamcrest.beans", + "org.testcontainers.shaded.org.hamcrest.collection", + "org.testcontainers.shaded.org.hamcrest.comparator", + "org.testcontainers.shaded.org.hamcrest.core", + "org.testcontainers.shaded.org.hamcrest.internal", + "org.testcontainers.shaded.org.hamcrest.io", + "org.testcontainers.shaded.org.hamcrest.number", + "org.testcontainers.shaded.org.hamcrest.object", + "org.testcontainers.shaded.org.hamcrest.text", + "org.testcontainers.shaded.org.hamcrest.xml", + "org.testcontainers.shaded.org.yaml.snakeyaml", + "org.testcontainers.shaded.org.yaml.snakeyaml.comments", + "org.testcontainers.shaded.org.yaml.snakeyaml.composer", + "org.testcontainers.shaded.org.yaml.snakeyaml.constructor", + "org.testcontainers.shaded.org.yaml.snakeyaml.emitter", + "org.testcontainers.shaded.org.yaml.snakeyaml.env", + "org.testcontainers.shaded.org.yaml.snakeyaml.error", + "org.testcontainers.shaded.org.yaml.snakeyaml.events", + "org.testcontainers.shaded.org.yaml.snakeyaml.extensions.compactnotation", + "org.testcontainers.shaded.org.yaml.snakeyaml.external.com.google.gdata.util.common.base", + "org.testcontainers.shaded.org.yaml.snakeyaml.inspector", + "org.testcontainers.shaded.org.yaml.snakeyaml.internal", + "org.testcontainers.shaded.org.yaml.snakeyaml.introspector", + "org.testcontainers.shaded.org.yaml.snakeyaml.nodes", + "org.testcontainers.shaded.org.yaml.snakeyaml.parser", + "org.testcontainers.shaded.org.yaml.snakeyaml.reader", + "org.testcontainers.shaded.org.yaml.snakeyaml.representer", + "org.testcontainers.shaded.org.yaml.snakeyaml.resolver", + "org.testcontainers.shaded.org.yaml.snakeyaml.scanner", + "org.testcontainers.shaded.org.yaml.snakeyaml.serializer", + "org.testcontainers.shaded.org.yaml.snakeyaml.tokens", + "org.testcontainers.shaded.org.yaml.snakeyaml.util", + "org.testcontainers.shaded.org.zeroturnaround.exec", + "org.testcontainers.shaded.org.zeroturnaround.exec.close", + "org.testcontainers.shaded.org.zeroturnaround.exec.listener", + "org.testcontainers.shaded.org.zeroturnaround.exec.stop", + "org.testcontainers.shaded.org.zeroturnaround.exec.stream", + "org.testcontainers.shaded.org.zeroturnaround.exec.stream.slf4j", + "org.testcontainers.utility" + ], + "org.testcontainers:testcontainers-cassandra": [ + "org.testcontainers.cassandra", + "org.testcontainers.containers", + "org.testcontainers.containers.delegate", + "org.testcontainers.containers.wait" + ], + "org.testcontainers:testcontainers-database-commons": [ + "org.testcontainers.delegate", + "org.testcontainers.exception", + "org.testcontainers.ext" + ], + "org.testcontainers:testcontainers-junit-jupiter": [ + "org.testcontainers.junit.jupiter" + ], + "org.wiremock:wiremock-standalone": [ + "com.github.tomakehurst.wiremock", + "com.github.tomakehurst.wiremock.admin", + "com.github.tomakehurst.wiremock.admin.model", + "com.github.tomakehurst.wiremock.admin.tasks", + "com.github.tomakehurst.wiremock.client", + "com.github.tomakehurst.wiremock.common", + "com.github.tomakehurst.wiremock.common.filemaker", + "com.github.tomakehurst.wiremock.common.ssl", + "com.github.tomakehurst.wiremock.common.url", + "com.github.tomakehurst.wiremock.common.xml", + "com.github.tomakehurst.wiremock.core", + "com.github.tomakehurst.wiremock.direct", + "com.github.tomakehurst.wiremock.extension", + "com.github.tomakehurst.wiremock.extension.requestfilter", + "com.github.tomakehurst.wiremock.extension.responsetemplating", + "com.github.tomakehurst.wiremock.extension.responsetemplating.helpers", + "com.github.tomakehurst.wiremock.global", + "com.github.tomakehurst.wiremock.http", + "com.github.tomakehurst.wiremock.http.client", + "com.github.tomakehurst.wiremock.http.multipart", + "com.github.tomakehurst.wiremock.http.ssl", + "com.github.tomakehurst.wiremock.http.trafficlistener", + "com.github.tomakehurst.wiremock.jetty", + "com.github.tomakehurst.wiremock.jetty11", + "com.github.tomakehurst.wiremock.junit", + "com.github.tomakehurst.wiremock.junit5", + "com.github.tomakehurst.wiremock.matching", + "com.github.tomakehurst.wiremock.recording", + "com.github.tomakehurst.wiremock.security", + "com.github.tomakehurst.wiremock.servlet", + "com.github.tomakehurst.wiremock.standalone", + "com.github.tomakehurst.wiremock.store", + "com.github.tomakehurst.wiremock.store.files", + "com.github.tomakehurst.wiremock.stubbing", + "com.github.tomakehurst.wiremock.verification", + "com.github.tomakehurst.wiremock.verification.diff", + "com.github.tomakehurst.wiremock.verification.notmatched", + "org.jspecify.annotations", + "org.wiremock.annotations", + "org.wiremock.webhooks", + "wiremock", + "wiremock.com.ethlo.time", + "wiremock.com.ethlo.time.internal", + "wiremock.com.ethlo.time.internal.fixed", + "wiremock.com.ethlo.time.internal.token", + "wiremock.com.ethlo.time.internal.util", + "wiremock.com.ethlo.time.token", + "wiremock.com.fasterxml.jackson.annotation", + "wiremock.com.fasterxml.jackson.core", + "wiremock.com.fasterxml.jackson.core.async", + "wiremock.com.fasterxml.jackson.core.base", + "wiremock.com.fasterxml.jackson.core.exc", + "wiremock.com.fasterxml.jackson.core.filter", + "wiremock.com.fasterxml.jackson.core.format", + "wiremock.com.fasterxml.jackson.core.internal.shaded.fdp.v2_20_1", + "wiremock.com.fasterxml.jackson.core.internal.shaded.fdp.v2_20_1.bte", + "wiremock.com.fasterxml.jackson.core.internal.shaded.fdp.v2_20_1.chr", + "wiremock.com.fasterxml.jackson.core.io", + "wiremock.com.fasterxml.jackson.core.io.schubfach", + "wiremock.com.fasterxml.jackson.core.json", + "wiremock.com.fasterxml.jackson.core.json.async", + "wiremock.com.fasterxml.jackson.core.sym", + "wiremock.com.fasterxml.jackson.core.type", + "wiremock.com.fasterxml.jackson.core.util", + "wiremock.com.fasterxml.jackson.databind", + "wiremock.com.fasterxml.jackson.databind.annotation", + "wiremock.com.fasterxml.jackson.databind.cfg", + "wiremock.com.fasterxml.jackson.databind.deser", + "wiremock.com.fasterxml.jackson.databind.deser.impl", + "wiremock.com.fasterxml.jackson.databind.deser.std", + "wiremock.com.fasterxml.jackson.databind.exc", + "wiremock.com.fasterxml.jackson.databind.ext", + "wiremock.com.fasterxml.jackson.databind.introspect", + "wiremock.com.fasterxml.jackson.databind.jdk14", + "wiremock.com.fasterxml.jackson.databind.json", + "wiremock.com.fasterxml.jackson.databind.jsonFormatVisitors", + "wiremock.com.fasterxml.jackson.databind.jsonschema", + "wiremock.com.fasterxml.jackson.databind.jsontype", + "wiremock.com.fasterxml.jackson.databind.jsontype.impl", + "wiremock.com.fasterxml.jackson.databind.module", + "wiremock.com.fasterxml.jackson.databind.node", + "wiremock.com.fasterxml.jackson.databind.ser", + "wiremock.com.fasterxml.jackson.databind.ser.impl", + "wiremock.com.fasterxml.jackson.databind.ser.std", + "wiremock.com.fasterxml.jackson.databind.type", + "wiremock.com.fasterxml.jackson.databind.util", + "wiremock.com.fasterxml.jackson.databind.util.internal", + "wiremock.com.fasterxml.jackson.dataformat.yaml", + "wiremock.com.fasterxml.jackson.dataformat.yaml.snakeyaml.error", + "wiremock.com.fasterxml.jackson.dataformat.yaml.util", + "wiremock.com.fasterxml.jackson.datatype.jsr310", + "wiremock.com.fasterxml.jackson.datatype.jsr310.deser", + "wiremock.com.fasterxml.jackson.datatype.jsr310.deser.key", + "wiremock.com.fasterxml.jackson.datatype.jsr310.ser", + "wiremock.com.fasterxml.jackson.datatype.jsr310.ser.key", + "wiremock.com.fasterxml.jackson.datatype.jsr310.util", + "wiremock.com.github.jknack.handlebars", + "wiremock.com.github.jknack.handlebars.cache", + "wiremock.com.github.jknack.handlebars.context", + "wiremock.com.github.jknack.handlebars.helper", + "wiremock.com.github.jknack.handlebars.internal", + "wiremock.com.github.jknack.handlebars.internal.antlr", + "wiremock.com.github.jknack.handlebars.internal.antlr.atn", + "wiremock.com.github.jknack.handlebars.internal.antlr.dfa", + "wiremock.com.github.jknack.handlebars.internal.antlr.misc", + "wiremock.com.github.jknack.handlebars.internal.antlr.tree", + "wiremock.com.github.jknack.handlebars.internal.antlr.tree.pattern", + "wiremock.com.github.jknack.handlebars.internal.antlr.tree.xpath", + "wiremock.com.github.jknack.handlebars.internal.lang3", + "wiremock.com.github.jknack.handlebars.internal.lang3.builder", + "wiremock.com.github.jknack.handlebars.internal.lang3.exception", + "wiremock.com.github.jknack.handlebars.internal.lang3.function", + "wiremock.com.github.jknack.handlebars.internal.lang3.math", + "wiremock.com.github.jknack.handlebars.internal.lang3.mutable", + "wiremock.com.github.jknack.handlebars.internal.lang3.text", + "wiremock.com.github.jknack.handlebars.internal.lang3.text.translate", + "wiremock.com.github.jknack.handlebars.internal.lang3.time", + "wiremock.com.github.jknack.handlebars.internal.lang3.tuple", + "wiremock.com.github.jknack.handlebars.internal.path", + "wiremock.com.github.jknack.handlebars.internal.text", + "wiremock.com.github.jknack.handlebars.internal.text.diff", + "wiremock.com.github.jknack.handlebars.internal.text.io", + "wiremock.com.github.jknack.handlebars.internal.text.lookup", + "wiremock.com.github.jknack.handlebars.internal.text.matcher", + "wiremock.com.github.jknack.handlebars.internal.text.numbers", + "wiremock.com.github.jknack.handlebars.internal.text.similarity", + "wiremock.com.github.jknack.handlebars.internal.text.translate", + "wiremock.com.github.jknack.handlebars.io", + "wiremock.com.google.common.annotations", + "wiremock.com.google.common.base", + "wiremock.com.google.common.base.internal", + "wiremock.com.google.common.cache", + "wiremock.com.google.common.collect", + "wiremock.com.google.common.escape", + "wiremock.com.google.common.eventbus", + "wiremock.com.google.common.graph", + "wiremock.com.google.common.hash", + "wiremock.com.google.common.html", + "wiremock.com.google.common.io", + "wiremock.com.google.common.math", + "wiremock.com.google.common.net", + "wiremock.com.google.common.primitives", + "wiremock.com.google.common.reflect", + "wiremock.com.google.common.util.concurrent", + "wiremock.com.google.common.util.concurrent.internal", + "wiremock.com.google.common.xml", + "wiremock.com.google.errorprone.annotations", + "wiremock.com.google.errorprone.annotations.concurrent", + "wiremock.com.google.j2objc.annotations", + "wiremock.com.google.thirdparty.publicsuffix", + "wiremock.com.jayway.jsonpath", + "wiremock.com.jayway.jsonpath.internal", + "wiremock.com.jayway.jsonpath.internal.filter", + "wiremock.com.jayway.jsonpath.internal.function", + "wiremock.com.jayway.jsonpath.internal.function.json", + "wiremock.com.jayway.jsonpath.internal.function.latebinding", + "wiremock.com.jayway.jsonpath.internal.function.numeric", + "wiremock.com.jayway.jsonpath.internal.function.sequence", + "wiremock.com.jayway.jsonpath.internal.function.text", + "wiremock.com.jayway.jsonpath.internal.path", + "wiremock.com.jayway.jsonpath.spi.cache", + "wiremock.com.jayway.jsonpath.spi.json", + "wiremock.com.jayway.jsonpath.spi.mapper", + "wiremock.com.networknt.org.apache.commons.validator.routines", + "wiremock.com.networknt.schema", + "wiremock.com.networknt.schema.annotation", + "wiremock.com.networknt.schema.format", + "wiremock.com.networknt.schema.i18n", + "wiremock.com.networknt.schema.oas", + "wiremock.com.networknt.schema.output", + "wiremock.com.networknt.schema.regex", + "wiremock.com.networknt.schema.resource", + "wiremock.com.networknt.schema.result", + "wiremock.com.networknt.schema.serialization", + "wiremock.com.networknt.schema.serialization.node", + "wiremock.com.networknt.schema.utils", + "wiremock.com.networknt.schema.walk", + "wiremock.jakarta.servlet", + "wiremock.jakarta.servlet.annotation", + "wiremock.jakarta.servlet.descriptor", + "wiremock.jakarta.servlet.http", + "wiremock.joptsimple", + "wiremock.joptsimple.internal", + "wiremock.joptsimple.util", + "wiremock.net.javacrumbs.jsonunit.core", + "wiremock.net.javacrumbs.jsonunit.core.internal", + "wiremock.net.javacrumbs.jsonunit.core.internal.matchers", + "wiremock.net.javacrumbs.jsonunit.core.listener", + "wiremock.net.javacrumbs.jsonunit.core.util", + "wiremock.net.javacrumbs.jsonunit.providers", + "wiremock.net.minidev.asm", + "wiremock.net.minidev.asm.ex", + "wiremock.net.minidev.json", + "wiremock.net.minidev.json.annotate", + "wiremock.net.minidev.json.parser", + "wiremock.net.minidev.json.reader", + "wiremock.net.minidev.json.writer", + "wiremock.org.apache.commons.fileupload", + "wiremock.org.apache.commons.fileupload.disk", + "wiremock.org.apache.commons.fileupload.portlet", + "wiremock.org.apache.commons.fileupload.servlet", + "wiremock.org.apache.commons.fileupload.util", + "wiremock.org.apache.commons.fileupload.util.mime", + "wiremock.org.apache.commons.io", + "wiremock.org.apache.commons.io.build", + "wiremock.org.apache.commons.io.channels", + "wiremock.org.apache.commons.io.charset", + "wiremock.org.apache.commons.io.comparator", + "wiremock.org.apache.commons.io.file", + "wiremock.org.apache.commons.io.file.attribute", + "wiremock.org.apache.commons.io.file.spi", + "wiremock.org.apache.commons.io.filefilter", + "wiremock.org.apache.commons.io.function", + "wiremock.org.apache.commons.io.input", + "wiremock.org.apache.commons.io.input.buffer", + "wiremock.org.apache.commons.io.monitor", + "wiremock.org.apache.commons.io.output", + "wiremock.org.apache.commons.io.serialization", + "wiremock.org.apache.hc.client5.http", + "wiremock.org.apache.hc.client5.http.async", + "wiremock.org.apache.hc.client5.http.async.methods", + "wiremock.org.apache.hc.client5.http.auth", + "wiremock.org.apache.hc.client5.http.classic", + "wiremock.org.apache.hc.client5.http.classic.methods", + "wiremock.org.apache.hc.client5.http.config", + "wiremock.org.apache.hc.client5.http.cookie", + "wiremock.org.apache.hc.client5.http.entity", + "wiremock.org.apache.hc.client5.http.entity.mime", + "wiremock.org.apache.hc.client5.http.impl", + "wiremock.org.apache.hc.client5.http.impl.async", + "wiremock.org.apache.hc.client5.http.impl.auth", + "wiremock.org.apache.hc.client5.http.impl.classic", + "wiremock.org.apache.hc.client5.http.impl.compat", + "wiremock.org.apache.hc.client5.http.impl.cookie", + "wiremock.org.apache.hc.client5.http.impl.io", + "wiremock.org.apache.hc.client5.http.impl.nio", + "wiremock.org.apache.hc.client5.http.impl.routing", + "wiremock.org.apache.hc.client5.http.io", + "wiremock.org.apache.hc.client5.http.nio", + "wiremock.org.apache.hc.client5.http.protocol", + "wiremock.org.apache.hc.client5.http.psl", + "wiremock.org.apache.hc.client5.http.routing", + "wiremock.org.apache.hc.client5.http.socket", + "wiremock.org.apache.hc.client5.http.ssl", + "wiremock.org.apache.hc.client5.http.utils", + "wiremock.org.apache.hc.client5.http.validator", + "wiremock.org.apache.hc.core5.annotation", + "wiremock.org.apache.hc.core5.concurrent", + "wiremock.org.apache.hc.core5.function", + "wiremock.org.apache.hc.core5.http", + "wiremock.org.apache.hc.core5.http.config", + "wiremock.org.apache.hc.core5.http.impl", + "wiremock.org.apache.hc.core5.http.impl.bootstrap", + "wiremock.org.apache.hc.core5.http.impl.io", + "wiremock.org.apache.hc.core5.http.impl.nio", + "wiremock.org.apache.hc.core5.http.impl.routing", + "wiremock.org.apache.hc.core5.http.io", + "wiremock.org.apache.hc.core5.http.io.entity", + "wiremock.org.apache.hc.core5.http.io.ssl", + "wiremock.org.apache.hc.core5.http.io.support", + "wiremock.org.apache.hc.core5.http.message", + "wiremock.org.apache.hc.core5.http.nio", + "wiremock.org.apache.hc.core5.http.nio.command", + "wiremock.org.apache.hc.core5.http.nio.entity", + "wiremock.org.apache.hc.core5.http.nio.ssl", + "wiremock.org.apache.hc.core5.http.nio.support", + "wiremock.org.apache.hc.core5.http.nio.support.classic", + "wiremock.org.apache.hc.core5.http.protocol", + "wiremock.org.apache.hc.core5.http.ssl", + "wiremock.org.apache.hc.core5.http.support", + "wiremock.org.apache.hc.core5.http2", + "wiremock.org.apache.hc.core5.http2.config", + "wiremock.org.apache.hc.core5.http2.frame", + "wiremock.org.apache.hc.core5.http2.hpack", + "wiremock.org.apache.hc.core5.http2.impl", + "wiremock.org.apache.hc.core5.http2.impl.io", + "wiremock.org.apache.hc.core5.http2.impl.nio", + "wiremock.org.apache.hc.core5.http2.impl.nio.bootstrap", + "wiremock.org.apache.hc.core5.http2.nio", + "wiremock.org.apache.hc.core5.http2.nio.command", + "wiremock.org.apache.hc.core5.http2.nio.pool", + "wiremock.org.apache.hc.core5.http2.nio.support", + "wiremock.org.apache.hc.core5.http2.protocol", + "wiremock.org.apache.hc.core5.http2.ssl", + "wiremock.org.apache.hc.core5.io", + "wiremock.org.apache.hc.core5.net", + "wiremock.org.apache.hc.core5.pool", + "wiremock.org.apache.hc.core5.reactor", + "wiremock.org.apache.hc.core5.reactor.ssl", + "wiremock.org.apache.hc.core5.ssl", + "wiremock.org.apache.hc.core5.util", + "wiremock.org.custommonkey.xmlunit", + "wiremock.org.custommonkey.xmlunit.examples", + "wiremock.org.custommonkey.xmlunit.exceptions", + "wiremock.org.custommonkey.xmlunit.jaxp13", + "wiremock.org.custommonkey.xmlunit.util", + "wiremock.org.eclipse.jetty.alpn.client", + "wiremock.org.eclipse.jetty.alpn.java.client", + "wiremock.org.eclipse.jetty.alpn.java.server", + "wiremock.org.eclipse.jetty.alpn.server", + "wiremock.org.eclipse.jetty.client", + "wiremock.org.eclipse.jetty.client.api", + "wiremock.org.eclipse.jetty.client.dynamic", + "wiremock.org.eclipse.jetty.client.http", + "wiremock.org.eclipse.jetty.client.internal", + "wiremock.org.eclipse.jetty.client.jmx", + "wiremock.org.eclipse.jetty.client.util", + "wiremock.org.eclipse.jetty.http", + "wiremock.org.eclipse.jetty.http.compression", + "wiremock.org.eclipse.jetty.http.pathmap", + "wiremock.org.eclipse.jetty.http2", + "wiremock.org.eclipse.jetty.http2.api", + "wiremock.org.eclipse.jetty.http2.api.server", + "wiremock.org.eclipse.jetty.http2.frames", + "wiremock.org.eclipse.jetty.http2.generator", + "wiremock.org.eclipse.jetty.http2.hpack", + "wiremock.org.eclipse.jetty.http2.parser", + "wiremock.org.eclipse.jetty.http2.server", + "wiremock.org.eclipse.jetty.io", + "wiremock.org.eclipse.jetty.io.jmx", + "wiremock.org.eclipse.jetty.io.ssl", + "wiremock.org.eclipse.jetty.proxy", + "wiremock.org.eclipse.jetty.security", + "wiremock.org.eclipse.jetty.security.authentication", + "wiremock.org.eclipse.jetty.server", + "wiremock.org.eclipse.jetty.server.handler", + "wiremock.org.eclipse.jetty.server.handler.gzip", + "wiremock.org.eclipse.jetty.server.handler.jmx", + "wiremock.org.eclipse.jetty.server.jmx", + "wiremock.org.eclipse.jetty.server.resource", + "wiremock.org.eclipse.jetty.server.session", + "wiremock.org.eclipse.jetty.servlet", + "wiremock.org.eclipse.jetty.servlet.jmx", + "wiremock.org.eclipse.jetty.servlet.listener", + "wiremock.org.eclipse.jetty.servlets", + "wiremock.org.eclipse.jetty.util", + "wiremock.org.eclipse.jetty.util.annotation", + "wiremock.org.eclipse.jetty.util.component", + "wiremock.org.eclipse.jetty.util.compression", + "wiremock.org.eclipse.jetty.util.log", + "wiremock.org.eclipse.jetty.util.preventers", + "wiremock.org.eclipse.jetty.util.resource", + "wiremock.org.eclipse.jetty.util.security", + "wiremock.org.eclipse.jetty.util.ssl", + "wiremock.org.eclipse.jetty.util.statistic", + "wiremock.org.eclipse.jetty.util.thread", + "wiremock.org.eclipse.jetty.util.thread.strategy", + "wiremock.org.eclipse.jetty.webapp", + "wiremock.org.eclipse.jetty.xml", + "wiremock.org.hamcrest", + "wiremock.org.hamcrest.beans", + "wiremock.org.hamcrest.collection", + "wiremock.org.hamcrest.comparator", + "wiremock.org.hamcrest.core", + "wiremock.org.hamcrest.core.deprecated", + "wiremock.org.hamcrest.internal", + "wiremock.org.hamcrest.io", + "wiremock.org.hamcrest.number", + "wiremock.org.hamcrest.object", + "wiremock.org.hamcrest.text", + "wiremock.org.hamcrest.xml", + "wiremock.org.slf4j", + "wiremock.org.slf4j.event", + "wiremock.org.slf4j.helpers", + "wiremock.org.slf4j.spi", + "wiremock.org.xmlunit", + "wiremock.org.xmlunit.builder", + "wiremock.org.xmlunit.builder.javax_jaxb", + "wiremock.org.xmlunit.diff", + "wiremock.org.xmlunit.input", + "wiremock.org.xmlunit.placeholder", + "wiremock.org.xmlunit.transform", + "wiremock.org.xmlunit.util", + "wiremock.org.xmlunit.validation", + "wiremock.org.xmlunit.xpath", + "wiremock.org.yaml.snakeyaml", + "wiremock.org.yaml.snakeyaml.comments", + "wiremock.org.yaml.snakeyaml.composer", + "wiremock.org.yaml.snakeyaml.constructor", + "wiremock.org.yaml.snakeyaml.emitter", + "wiremock.org.yaml.snakeyaml.env", + "wiremock.org.yaml.snakeyaml.error", + "wiremock.org.yaml.snakeyaml.events", + "wiremock.org.yaml.snakeyaml.extensions.compactnotation", + "wiremock.org.yaml.snakeyaml.external.com.google.gdata.util.common.base", + "wiremock.org.yaml.snakeyaml.inspector", + "wiremock.org.yaml.snakeyaml.internal", + "wiremock.org.yaml.snakeyaml.introspector", + "wiremock.org.yaml.snakeyaml.nodes", + "wiremock.org.yaml.snakeyaml.parser", + "wiremock.org.yaml.snakeyaml.reader", + "wiremock.org.yaml.snakeyaml.representer", + "wiremock.org.yaml.snakeyaml.resolver", + "wiremock.org.yaml.snakeyaml.scanner", + "wiremock.org.yaml.snakeyaml.serializer", + "wiremock.org.yaml.snakeyaml.tokens", + "wiremock.org.yaml.snakeyaml.util" + ], "org.xmlunit:xmlunit-core": [ "org.xmlunit", "org.xmlunit.builder", @@ -1977,7 +8250,6 @@ "org.yaml.snakeyaml.error", "org.yaml.snakeyaml.events", "org.yaml.snakeyaml.extensions.compactnotation", - "org.yaml.snakeyaml.external.biz.base64Coder", "org.yaml.snakeyaml.external.com.google.gdata.util.common.base", "org.yaml.snakeyaml.inspector", "org.yaml.snakeyaml.internal", @@ -1991,158 +8263,1394 @@ "org.yaml.snakeyaml.serializer", "org.yaml.snakeyaml.tokens", "org.yaml.snakeyaml.util" + ], + "software.amazon.awssdk:annotations": [ + "software.amazon.awssdk.annotations" + ], + "software.amazon.awssdk:checksums": [ + "software.amazon.awssdk.checksums", + "software.amazon.awssdk.checksums.internal" + ], + "software.amazon.awssdk:checksums-spi": [ + "software.amazon.awssdk.checksums.spi" + ], + "software.amazon.awssdk:endpoints-spi": [ + "software.amazon.awssdk.endpoints" + ], + "software.amazon.awssdk:http-auth-aws": [ + "software.amazon.awssdk.http.auth.aws.crt.internal.io", + "software.amazon.awssdk.http.auth.aws.crt.internal.signer", + "software.amazon.awssdk.http.auth.aws.crt.internal.util", + "software.amazon.awssdk.http.auth.aws.eventstream.internal.io", + "software.amazon.awssdk.http.auth.aws.eventstream.internal.signer", + "software.amazon.awssdk.http.auth.aws.internal.scheme", + "software.amazon.awssdk.http.auth.aws.internal.signer", + "software.amazon.awssdk.http.auth.aws.internal.signer.checksums", + "software.amazon.awssdk.http.auth.aws.internal.signer.chunkedencoding", + "software.amazon.awssdk.http.auth.aws.internal.signer.io", + "software.amazon.awssdk.http.auth.aws.internal.signer.util", + "software.amazon.awssdk.http.auth.aws.scheme", + "software.amazon.awssdk.http.auth.aws.signer" + ], + "software.amazon.awssdk:http-auth-spi": [ + "software.amazon.awssdk.http.auth.spi.internal.scheme", + "software.amazon.awssdk.http.auth.spi.internal.signer", + "software.amazon.awssdk.http.auth.spi.scheme", + "software.amazon.awssdk.http.auth.spi.signer" + ], + "software.amazon.awssdk:http-client-spi": [ + "software.amazon.awssdk.http", + "software.amazon.awssdk.http.async", + "software.amazon.awssdk.internal.http" + ], + "software.amazon.awssdk:identity-spi": [ + "software.amazon.awssdk.identity.spi", + "software.amazon.awssdk.identity.spi.internal" + ], + "software.amazon.awssdk:json-utils": [ + "software.amazon.awssdk.protocols.jsoncore", + "software.amazon.awssdk.protocols.jsoncore.internal" + ], + "software.amazon.awssdk:metrics-spi": [ + "software.amazon.awssdk.metrics", + "software.amazon.awssdk.metrics.internal" + ], + "software.amazon.awssdk:profiles": [ + "software.amazon.awssdk.profiles", + "software.amazon.awssdk.profiles.internal" + ], + "software.amazon.awssdk:regions": [ + "software.amazon.awssdk.regions", + "software.amazon.awssdk.regions.internal", + "software.amazon.awssdk.regions.internal.util", + "software.amazon.awssdk.regions.partitionmetadata", + "software.amazon.awssdk.regions.providers", + "software.amazon.awssdk.regions.regionmetadata", + "software.amazon.awssdk.regions.servicemetadata", + "software.amazon.awssdk.regions.util" + ], + "software.amazon.awssdk:retries": [ + "software.amazon.awssdk.retries", + "software.amazon.awssdk.retries.internal", + "software.amazon.awssdk.retries.internal.circuitbreaker", + "software.amazon.awssdk.retries.internal.ratelimiter" + ], + "software.amazon.awssdk:retries-spi": [ + "software.amazon.awssdk.retries.api", + "software.amazon.awssdk.retries.api.internal", + "software.amazon.awssdk.retries.api.internal.backoff" + ], + "software.amazon.awssdk:sdk-core": [ + "software.amazon.awssdk.core", + "software.amazon.awssdk.core.adapter", + "software.amazon.awssdk.core.async", + "software.amazon.awssdk.core.async.listener", + "software.amazon.awssdk.core.checksums", + "software.amazon.awssdk.core.client.builder", + "software.amazon.awssdk.core.client.config", + "software.amazon.awssdk.core.client.handler", + "software.amazon.awssdk.core.document", + "software.amazon.awssdk.core.document.internal", + "software.amazon.awssdk.core.endpointdiscovery", + "software.amazon.awssdk.core.endpointdiscovery.providers", + "software.amazon.awssdk.core.exception", + "software.amazon.awssdk.core.http", + "software.amazon.awssdk.core.identity", + "software.amazon.awssdk.core.interceptor", + "software.amazon.awssdk.core.interceptor.trait", + "software.amazon.awssdk.core.internal", + "software.amazon.awssdk.core.internal.async", + "software.amazon.awssdk.core.internal.capacity", + "software.amazon.awssdk.core.internal.checksums", + "software.amazon.awssdk.core.internal.chunked", + "software.amazon.awssdk.core.internal.compression", + "software.amazon.awssdk.core.internal.handler", + "software.amazon.awssdk.core.internal.http", + "software.amazon.awssdk.core.internal.http.async", + "software.amazon.awssdk.core.internal.http.loader", + "software.amazon.awssdk.core.internal.http.pipeline", + "software.amazon.awssdk.core.internal.http.pipeline.stages", + "software.amazon.awssdk.core.internal.http.pipeline.stages.utils", + "software.amazon.awssdk.core.internal.http.timers", + "software.amazon.awssdk.core.internal.interceptor", + "software.amazon.awssdk.core.internal.interceptor.trait", + "software.amazon.awssdk.core.internal.io", + "software.amazon.awssdk.core.internal.metrics", + "software.amazon.awssdk.core.internal.pagination.async", + "software.amazon.awssdk.core.internal.retry", + "software.amazon.awssdk.core.internal.signer", + "software.amazon.awssdk.core.internal.sync", + "software.amazon.awssdk.core.internal.transform", + "software.amazon.awssdk.core.internal.useragent", + "software.amazon.awssdk.core.internal.util", + "software.amazon.awssdk.core.internal.waiters", + "software.amazon.awssdk.core.io", + "software.amazon.awssdk.core.metrics", + "software.amazon.awssdk.core.pagination.async", + "software.amazon.awssdk.core.pagination.sync", + "software.amazon.awssdk.core.protocol", + "software.amazon.awssdk.core.retry", + "software.amazon.awssdk.core.retry.backoff", + "software.amazon.awssdk.core.retry.conditions", + "software.amazon.awssdk.core.runtime", + "software.amazon.awssdk.core.runtime.transform", + "software.amazon.awssdk.core.signer", + "software.amazon.awssdk.core.sync", + "software.amazon.awssdk.core.traits", + "software.amazon.awssdk.core.useragent", + "software.amazon.awssdk.core.util", + "software.amazon.awssdk.core.waiters" + ], + "software.amazon.awssdk:third-party-jackson-core": [ + "software.amazon.awssdk.thirdparty.jackson.core", + "software.amazon.awssdk.thirdparty.jackson.core.async", + "software.amazon.awssdk.thirdparty.jackson.core.base", + "software.amazon.awssdk.thirdparty.jackson.core.exc", + "software.amazon.awssdk.thirdparty.jackson.core.filter", + "software.amazon.awssdk.thirdparty.jackson.core.format", + "software.amazon.awssdk.thirdparty.jackson.core.internal.shaded.fdp.v2_19_4", + "software.amazon.awssdk.thirdparty.jackson.core.io", + "software.amazon.awssdk.thirdparty.jackson.core.io.schubfach", + "software.amazon.awssdk.thirdparty.jackson.core.json", + "software.amazon.awssdk.thirdparty.jackson.core.json.async", + "software.amazon.awssdk.thirdparty.jackson.core.sym", + "software.amazon.awssdk.thirdparty.jackson.core.type", + "software.amazon.awssdk.thirdparty.jackson.core.util" + ], + "software.amazon.awssdk:utils": [ + "software.amazon.awssdk.utils", + "software.amazon.awssdk.utils.async", + "software.amazon.awssdk.utils.builder", + "software.amazon.awssdk.utils.cache", + "software.amazon.awssdk.utils.cache.bounded", + "software.amazon.awssdk.utils.cache.lru", + "software.amazon.awssdk.utils.http", + "software.amazon.awssdk.utils.internal", + "software.amazon.awssdk.utils.internal.async", + "software.amazon.awssdk.utils.internal.proxy", + "software.amazon.awssdk.utils.io", + "software.amazon.awssdk.utils.uri", + "software.amazon.awssdk.utils.uri.internal" + ], + "tools.jackson.core:jackson-core": [ + "tools.jackson.core", + "tools.jackson.core.async", + "tools.jackson.core.base", + "tools.jackson.core.exc", + "tools.jackson.core.filter", + "tools.jackson.core.internal.shaded.fdp", + "tools.jackson.core.internal.shaded.fdp.bte", + "tools.jackson.core.internal.shaded.fdp.chr", + "tools.jackson.core.io", + "tools.jackson.core.io.schubfach", + "tools.jackson.core.json", + "tools.jackson.core.json.async", + "tools.jackson.core.sym", + "tools.jackson.core.tree", + "tools.jackson.core.type", + "tools.jackson.core.util" + ], + "tools.jackson.core:jackson-databind": [ + "tools.jackson.databind", + "tools.jackson.databind.annotation", + "tools.jackson.databind.cfg", + "tools.jackson.databind.deser", + "tools.jackson.databind.deser.bean", + "tools.jackson.databind.deser.impl", + "tools.jackson.databind.deser.jackson", + "tools.jackson.databind.deser.jdk", + "tools.jackson.databind.deser.std", + "tools.jackson.databind.exc", + "tools.jackson.databind.ext", + "tools.jackson.databind.ext.beans", + "tools.jackson.databind.ext.javatime", + "tools.jackson.databind.ext.javatime.deser", + "tools.jackson.databind.ext.javatime.deser.key", + "tools.jackson.databind.ext.javatime.ser", + "tools.jackson.databind.ext.javatime.ser.key", + "tools.jackson.databind.ext.javatime.util", + "tools.jackson.databind.ext.jdk8", + "tools.jackson.databind.ext.sql", + "tools.jackson.databind.introspect", + "tools.jackson.databind.json", + "tools.jackson.databind.jsonFormatVisitors", + "tools.jackson.databind.jsontype", + "tools.jackson.databind.jsontype.impl", + "tools.jackson.databind.module", + "tools.jackson.databind.node", + "tools.jackson.databind.ser", + "tools.jackson.databind.ser.bean", + "tools.jackson.databind.ser.impl", + "tools.jackson.databind.ser.jackson", + "tools.jackson.databind.ser.jdk", + "tools.jackson.databind.ser.std", + "tools.jackson.databind.type", + "tools.jackson.databind.util", + "tools.jackson.databind.util.internal" + ], + "tools.jackson.module:jackson-module-blackbird": [ + "tools.jackson.module.blackbird", + "tools.jackson.module.blackbird.deser", + "tools.jackson.module.blackbird.ser", + "tools.jackson.module.blackbird.util" ] }, "repositories": { "https://maven-central.storage-download.googleapis.com/maven2/": [ + "aopalliance:aopalliance", + "aopalliance:aopalliance:jar:sources", + "args4j:args4j", + "args4j:args4j:jar:sources", + "at.yawk.lz4:lz4-java", + "at.yawk.lz4:lz4-java:jar:sources", "ch.qos.logback:logback-classic", + "ch.qos.logback:logback-classic:jar:sources", "ch.qos.logback:logback-core", + "ch.qos.logback:logback-core:jar:sources", + "com.datastax.cassandra:cassandra-driver-core", + "com.datastax.cassandra:cassandra-driver-core:jar:sources", + "com.datastax.oss:native-protocol", + "com.datastax.oss:native-protocol:jar:sources", "com.fasterxml.jackson.core:jackson-annotations", + "com.fasterxml.jackson.core:jackson-annotations:jar:sources", "com.fasterxml.jackson.core:jackson-core", + "com.fasterxml.jackson.core:jackson-core:jar:sources", "com.fasterxml.jackson.core:jackson-databind", - "com.fasterxml.jackson.datatype:jackson-datatype-jdk8", + "com.fasterxml.jackson.core:jackson-databind:jar:sources", + "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml", + "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:jar:sources", "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", - "com.fasterxml.jackson.module:jackson-module-parameter-names", + "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:jar:sources", + "com.fasterxml:classmate", + "com.fasterxml:classmate:jar:sources", + "com.github.ben-manes.caffeine:caffeine", + "com.github.ben-manes.caffeine:caffeine:jar:sources", + "com.github.ben-manes.caffeine:guava", + "com.github.ben-manes.caffeine:guava:jar:sources", + "com.github.docker-java:docker-java-api", + "com.github.docker-java:docker-java-api:jar:sources", + "com.github.docker-java:docker-java-transport", + "com.github.docker-java:docker-java-transport-zerodep", + "com.github.docker-java:docker-java-transport-zerodep:jar:sources", + "com.github.docker-java:docker-java-transport:jar:sources", + "com.github.java-json-tools:btf", + "com.github.java-json-tools:btf:jar:sources", + "com.github.java-json-tools:jackson-coreutils", + "com.github.java-json-tools:jackson-coreutils:jar:sources", + "com.github.java-json-tools:json-patch", + "com.github.java-json-tools:json-patch:jar:sources", + "com.github.java-json-tools:msg-simple", + "com.github.java-json-tools:msg-simple:jar:sources", + "com.github.jnr:jffi", + "com.github.jnr:jffi:jar:native", + "com.github.jnr:jffi:jar:sources", + "com.github.jnr:jnr-constants", + "com.github.jnr:jnr-constants:jar:sources", + "com.github.jnr:jnr-ffi", + "com.github.jnr:jnr-ffi:jar:sources", + "com.github.jnr:jnr-posix", + "com.github.jnr:jnr-posix:jar:sources", + "com.github.jnr:jnr-x86asm", + "com.github.jnr:jnr-x86asm:jar:sources", + "com.github.stephenc.jcip:jcip-annotations", + "com.github.stephenc.jcip:jcip-annotations:jar:sources", "com.google.code.findbugs:jsr305", - "com.google.code.gson:gson", "com.google.errorprone:error_prone_annotations", + "com.google.errorprone:error_prone_annotations:jar:sources", "com.google.guava:failureaccess", + "com.google.guava:failureaccess:jar:sources", "com.google.guava:guava", + "com.google.guava:guava:jar:sources", "com.google.guava:listenablefuture", "com.google.j2objc:j2objc-annotations", + "com.google.j2objc:j2objc-annotations:jar:sources", "com.jayway.jsonpath:json-path", + "com.jayway.jsonpath:json-path:jar:sources", + "com.nimbusds:content-type", + "com.nimbusds:content-type:jar:sources", + "com.nimbusds:lang-tag", + "com.nimbusds:lang-tag:jar:sources", + "com.nimbusds:nimbus-jose-jwt", + "com.nimbusds:nimbus-jose-jwt:jar:sources", + "com.nimbusds:oauth2-oidc-sdk", + "com.nimbusds:oauth2-oidc-sdk:jar:sources", + "com.squareup.okhttp3:okhttp-jvm", + "com.squareup.okhttp3:okhttp-jvm:jar:sources", + "com.squareup.okio:okio-jvm", + "com.squareup.okio:okio-jvm:jar:sources", + "com.typesafe:config", + "com.typesafe:config:jar:sources", "com.vaadin.external.google:android-json", + "com.vaadin.external.google:android-json:jar:sources", + "commons-codec:commons-codec", + "commons-codec:commons-codec:jar:sources", + "commons-io:commons-io", + "commons-io:commons-io:jar:sources", + "commons-logging:commons-logging", + "commons-logging:commons-logging:jar:sources", + "io.cloudevents:cloudevents-api", + "io.cloudevents:cloudevents-api:jar:sources", + "io.cloudevents:cloudevents-core", + "io.cloudevents:cloudevents-core:jar:sources", + "io.cloudevents:cloudevents-json-jackson", + "io.cloudevents:cloudevents-json-jackson:jar:sources", + "io.dropwizard.metrics:metrics-core", + "io.dropwizard.metrics:metrics-core:jar:sources", + "io.micrometer:context-propagation", + "io.micrometer:context-propagation:jar:sources", "io.micrometer:micrometer-commons", + "io.micrometer:micrometer-commons:jar:sources", + "io.micrometer:micrometer-core", + "io.micrometer:micrometer-core:jar:sources", + "io.micrometer:micrometer-jakarta9", + "io.micrometer:micrometer-jakarta9:jar:sources", "io.micrometer:micrometer-observation", + "io.micrometer:micrometer-observation-test", + "io.micrometer:micrometer-observation-test:jar:sources", + "io.micrometer:micrometer-observation:jar:sources", + "io.micrometer:micrometer-tracing", + "io.micrometer:micrometer-tracing-bridge-otel", + "io.micrometer:micrometer-tracing-bridge-otel:jar:sources", + "io.micrometer:micrometer-tracing:jar:sources", + "io.nats:jnats", + "io.nats:jnats:jar:sources", + "io.netty:netty-buffer", + "io.netty:netty-buffer:jar:sources", + "io.netty:netty-codec-base", + "io.netty:netty-codec-base:jar:sources", + "io.netty:netty-codec-classes-quic", + "io.netty:netty-codec-classes-quic:jar:sources", + "io.netty:netty-codec-compression", + "io.netty:netty-codec-compression:jar:sources", + "io.netty:netty-codec-dns", + "io.netty:netty-codec-dns:jar:sources", + "io.netty:netty-codec-http", + "io.netty:netty-codec-http2", + "io.netty:netty-codec-http2:jar:sources", + "io.netty:netty-codec-http3", + "io.netty:netty-codec-http3:jar:sources", + "io.netty:netty-codec-http:jar:sources", + "io.netty:netty-codec-native-quic:jar:linux-aarch_64", + "io.netty:netty-codec-native-quic:jar:linux-x86_64", + "io.netty:netty-codec-native-quic:jar:osx-aarch_64", + "io.netty:netty-codec-native-quic:jar:osx-x86_64", + "io.netty:netty-codec-native-quic:jar:sources", + "io.netty:netty-codec-native-quic:jar:windows-x86_64", + "io.netty:netty-codec-socks", + "io.netty:netty-codec-socks:jar:sources", + "io.netty:netty-common", + "io.netty:netty-common:jar:sources", + "io.netty:netty-handler", + "io.netty:netty-handler-proxy", + "io.netty:netty-handler-proxy:jar:sources", + "io.netty:netty-handler:jar:sources", + "io.netty:netty-resolver", + "io.netty:netty-resolver-dns", + "io.netty:netty-resolver-dns-classes-macos", + "io.netty:netty-resolver-dns-classes-macos:jar:sources", + "io.netty:netty-resolver-dns-native-macos:jar:osx-x86_64", + "io.netty:netty-resolver-dns-native-macos:jar:sources", + "io.netty:netty-resolver-dns:jar:sources", + "io.netty:netty-resolver:jar:sources", + "io.netty:netty-transport", + "io.netty:netty-transport-classes-epoll", + "io.netty:netty-transport-classes-epoll:jar:sources", + "io.netty:netty-transport-native-epoll:jar:linux-x86_64", + "io.netty:netty-transport-native-epoll:jar:sources", + "io.netty:netty-transport-native-unix-common", + "io.netty:netty-transport-native-unix-common:jar:sources", + "io.netty:netty-transport:jar:sources", + "io.opentelemetry.semconv:opentelemetry-semconv", + "io.opentelemetry.semconv:opentelemetry-semconv:jar:sources", + "io.opentelemetry:opentelemetry-api", + "io.opentelemetry:opentelemetry-api:jar:sources", + "io.opentelemetry:opentelemetry-common", + "io.opentelemetry:opentelemetry-common:jar:sources", + "io.opentelemetry:opentelemetry-context", + "io.opentelemetry:opentelemetry-context:jar:sources", + "io.opentelemetry:opentelemetry-exporter-common", + "io.opentelemetry:opentelemetry-exporter-common:jar:sources", + "io.opentelemetry:opentelemetry-exporter-otlp", + "io.opentelemetry:opentelemetry-exporter-otlp-common", + "io.opentelemetry:opentelemetry-exporter-otlp-common:jar:sources", + "io.opentelemetry:opentelemetry-exporter-otlp:jar:sources", + "io.opentelemetry:opentelemetry-exporter-sender-okhttp", + "io.opentelemetry:opentelemetry-exporter-sender-okhttp:jar:sources", + "io.opentelemetry:opentelemetry-extension-trace-propagators", + "io.opentelemetry:opentelemetry-extension-trace-propagators:jar:sources", + "io.opentelemetry:opentelemetry-sdk", + "io.opentelemetry:opentelemetry-sdk-common", + "io.opentelemetry:opentelemetry-sdk-common:jar:sources", + "io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi", + "io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:jar:sources", + "io.opentelemetry:opentelemetry-sdk-logs", + "io.opentelemetry:opentelemetry-sdk-logs:jar:sources", + "io.opentelemetry:opentelemetry-sdk-metrics", + "io.opentelemetry:opentelemetry-sdk-metrics:jar:sources", + "io.opentelemetry:opentelemetry-sdk-testing", + "io.opentelemetry:opentelemetry-sdk-testing:jar:sources", + "io.opentelemetry:opentelemetry-sdk-trace", + "io.opentelemetry:opentelemetry-sdk-trace:jar:sources", + "io.opentelemetry:opentelemetry-sdk:jar:sources", + "io.projectreactor.netty:reactor-netty-core", + "io.projectreactor.netty:reactor-netty-core:jar:sources", + "io.projectreactor.netty:reactor-netty-http", + "io.projectreactor.netty:reactor-netty-http:jar:sources", + "io.projectreactor:reactor-core", + "io.projectreactor:reactor-core:jar:sources", + "io.projectreactor:reactor-test", + "io.projectreactor:reactor-test:jar:sources", + "io.swagger.core.v3:swagger-annotations-jakarta", + "io.swagger.core.v3:swagger-annotations-jakarta:jar:sources", + "io.swagger.core.v3:swagger-core-jakarta", + "io.swagger.core.v3:swagger-core-jakarta:jar:sources", + "io.swagger.core.v3:swagger-models-jakarta", + "io.swagger.core.v3:swagger-models-jakarta:jar:sources", "jakarta.activation:jakarta.activation-api", + "jakarta.activation:jakarta.activation-api:jar:sources", "jakarta.annotation:jakarta.annotation-api", + "jakarta.annotation:jakarta.annotation-api:jar:sources", + "jakarta.servlet:jakarta.servlet-api", + "jakarta.servlet:jakarta.servlet-api:jar:sources", + "jakarta.validation:jakarta.validation-api", + "jakarta.validation:jakarta.validation-api:jar:sources", "jakarta.xml.bind:jakarta.xml.bind-api", + "jakarta.xml.bind:jakarta.xml.bind-api:jar:sources", "net.bytebuddy:byte-buddy", "net.bytebuddy:byte-buddy-agent", + "net.bytebuddy:byte-buddy-agent:jar:sources", + "net.bytebuddy:byte-buddy:jar:sources", + "net.java.dev.jna:jna", + "net.java.dev.jna:jna:jar:sources", "net.minidev:accessors-smart", + "net.minidev:accessors-smart:jar:sources", "net.minidev:json-smart", + "net.minidev:json-smart:jar:sources", + "org.apache.cassandra:java-driver-core", + "org.apache.cassandra:java-driver-core:jar:sources", + "org.apache.cassandra:java-driver-guava-shaded", + "org.apache.cassandra:java-driver-guava-shaded:jar:sources", + "org.apache.cassandra:java-driver-metrics-micrometer", + "org.apache.cassandra:java-driver-metrics-micrometer:jar:sources", + "org.apache.cassandra:java-driver-query-builder", + "org.apache.cassandra:java-driver-query-builder:jar:sources", + "org.apache.commons:commons-compress", + "org.apache.commons:commons-compress:jar:sources", + "org.apache.commons:commons-lang3", + "org.apache.commons:commons-lang3:jar:sources", "org.apache.logging.log4j:log4j-api", + "org.apache.logging.log4j:log4j-api:jar:sources", "org.apache.logging.log4j:log4j-to-slf4j", + "org.apache.logging.log4j:log4j-to-slf4j:jar:sources", "org.apache.tomcat.embed:tomcat-embed-core", + "org.apache.tomcat.embed:tomcat-embed-core:jar:sources", "org.apache.tomcat.embed:tomcat-embed-el", + "org.apache.tomcat.embed:tomcat-embed-el:jar:sources", "org.apache.tomcat.embed:tomcat-embed-websocket", + "org.apache.tomcat.embed:tomcat-embed-websocket:jar:sources", "org.apiguardian:apiguardian-api", + "org.apiguardian:apiguardian-api:jar:sources", "org.assertj:assertj-core", + "org.assertj:assertj-core:jar:sources", "org.awaitility:awaitility", - "org.checkerframework:checker-qual", + "org.awaitility:awaitility:jar:sources", + "org.bouncycastle:bcprov-jdk18on", + "org.bouncycastle:bcprov-jdk18on:jar:sources", + "org.bouncycastle:bcprov-lts8on", + "org.bouncycastle:bcprov-lts8on:jar:sources", "org.hamcrest:hamcrest", + "org.hamcrest:hamcrest:jar:sources", + "org.hdrhistogram:HdrHistogram", + "org.hdrhistogram:HdrHistogram:jar:sources", + "org.hibernate.validator:hibernate-validator", + "org.hibernate.validator:hibernate-validator:jar:sources", + "org.jacoco:org.jacoco.agent:jar:runtime", + "org.jacoco:org.jacoco.agent:jar:sources", + "org.jacoco:org.jacoco.cli", + "org.jacoco:org.jacoco.cli:jar:sources", + "org.jacoco:org.jacoco.core", + "org.jacoco:org.jacoco.core:jar:sources", + "org.jacoco:org.jacoco.report", + "org.jacoco:org.jacoco.report:jar:sources", + "org.jboss.logging:jboss-logging", + "org.jboss.logging:jboss-logging:jar:sources", + "org.jetbrains.kotlin:kotlin-stdlib", + "org.jetbrains.kotlin:kotlin-stdlib:jar:sources", + "org.jetbrains:annotations", + "org.jetbrains:annotations:jar:sources", + "org.jspecify:jspecify", + "org.jspecify:jspecify:jar:sources", "org.junit.jupiter:junit-jupiter", "org.junit.jupiter:junit-jupiter-api", + "org.junit.jupiter:junit-jupiter-api:jar:sources", "org.junit.jupiter:junit-jupiter-engine", + "org.junit.jupiter:junit-jupiter-engine:jar:sources", "org.junit.jupiter:junit-jupiter-params", + "org.junit.jupiter:junit-jupiter-params:jar:sources", + "org.junit.jupiter:junit-jupiter:jar:sources", "org.junit.platform:junit-platform-commons", + "org.junit.platform:junit-platform-commons:jar:sources", + "org.junit.platform:junit-platform-console-standalone", + "org.junit.platform:junit-platform-console-standalone:jar:sources", "org.junit.platform:junit-platform-engine", + "org.junit.platform:junit-platform-engine:jar:sources", "org.junit.platform:junit-platform-launcher", + "org.junit.platform:junit-platform-launcher:jar:sources", "org.junit.platform:junit-platform-reporting", + "org.junit.platform:junit-platform-reporting:jar:sources", + "org.latencyutils:LatencyUtils", + "org.latencyutils:LatencyUtils:jar:sources", "org.mockito:mockito-core", + "org.mockito:mockito-core:jar:sources", "org.mockito:mockito-junit-jupiter", + "org.mockito:mockito-junit-jupiter:jar:sources", "org.objenesis:objenesis", + "org.objenesis:objenesis:jar:sources", + "org.opentest4j.reporting:open-test-reporting-tooling-spi", + "org.opentest4j.reporting:open-test-reporting-tooling-spi:jar:sources", "org.opentest4j:opentest4j", + "org.opentest4j:opentest4j:jar:sources", "org.ow2.asm:asm", + "org.ow2.asm:asm-analysis", + "org.ow2.asm:asm-analysis:jar:sources", + "org.ow2.asm:asm-commons", + "org.ow2.asm:asm-commons:jar:sources", + "org.ow2.asm:asm-tree", + "org.ow2.asm:asm-tree:jar:sources", + "org.ow2.asm:asm-util", + "org.ow2.asm:asm-util:jar:sources", + "org.ow2.asm:asm:jar:sources", + "org.projectlombok:lombok", + "org.projectlombok:lombok:jar:sources", + "org.reactivestreams:reactive-streams", + "org.reactivestreams:reactive-streams:jar:sources", + "org.rnorth.duct-tape:duct-tape", + "org.rnorth.duct-tape:duct-tape:jar:sources", "org.skyscreamer:jsonassert", + "org.skyscreamer:jsonassert:jar:sources", "org.slf4j:jul-to-slf4j", + "org.slf4j:jul-to-slf4j:jar:sources", "org.slf4j:slf4j-api", + "org.slf4j:slf4j-api:jar:sources", + "org.springdoc:springdoc-openapi-starter-common", + "org.springdoc:springdoc-openapi-starter-common:jar:sources", + "org.springdoc:springdoc-openapi-starter-webflux-api", + "org.springdoc:springdoc-openapi-starter-webflux-api:jar:sources", + "org.springdoc:springdoc-openapi-starter-webmvc-api", + "org.springdoc:springdoc-openapi-starter-webmvc-api:jar:sources", "org.springframework.boot:spring-boot", + "org.springframework.boot:spring-boot-actuator", + "org.springframework.boot:spring-boot-actuator-autoconfigure", + "org.springframework.boot:spring-boot-actuator-autoconfigure:jar:sources", + "org.springframework.boot:spring-boot-actuator:jar:sources", "org.springframework.boot:spring-boot-autoconfigure", + "org.springframework.boot:spring-boot-autoconfigure:jar:sources", + "org.springframework.boot:spring-boot-cassandra", + "org.springframework.boot:spring-boot-cassandra:jar:sources", + "org.springframework.boot:spring-boot-data-cassandra", + "org.springframework.boot:spring-boot-data-cassandra-test", + "org.springframework.boot:spring-boot-data-cassandra-test:jar:sources", + "org.springframework.boot:spring-boot-data-cassandra:jar:sources", + "org.springframework.boot:spring-boot-data-commons", + "org.springframework.boot:spring-boot-data-commons:jar:sources", + "org.springframework.boot:spring-boot-health", + "org.springframework.boot:spring-boot-health:jar:sources", + "org.springframework.boot:spring-boot-http-client", + "org.springframework.boot:spring-boot-http-client:jar:sources", + "org.springframework.boot:spring-boot-http-codec", + "org.springframework.boot:spring-boot-http-codec:jar:sources", + "org.springframework.boot:spring-boot-http-converter", + "org.springframework.boot:spring-boot-http-converter:jar:sources", + "org.springframework.boot:spring-boot-jackson", + "org.springframework.boot:spring-boot-jackson:jar:sources", + "org.springframework.boot:spring-boot-micrometer-metrics", + "org.springframework.boot:spring-boot-micrometer-metrics-test", + "org.springframework.boot:spring-boot-micrometer-metrics-test:jar:sources", + "org.springframework.boot:spring-boot-micrometer-metrics:jar:sources", + "org.springframework.boot:spring-boot-micrometer-observation", + "org.springframework.boot:spring-boot-micrometer-observation:jar:sources", + "org.springframework.boot:spring-boot-micrometer-tracing", + "org.springframework.boot:spring-boot-micrometer-tracing-opentelemetry", + "org.springframework.boot:spring-boot-micrometer-tracing-opentelemetry:jar:sources", + "org.springframework.boot:spring-boot-micrometer-tracing:jar:sources", + "org.springframework.boot:spring-boot-netty", + "org.springframework.boot:spring-boot-netty:jar:sources", + "org.springframework.boot:spring-boot-opentelemetry", + "org.springframework.boot:spring-boot-opentelemetry:jar:sources", + "org.springframework.boot:spring-boot-persistence", + "org.springframework.boot:spring-boot-persistence:jar:sources", + "org.springframework.boot:spring-boot-reactor", + "org.springframework.boot:spring-boot-reactor-netty", + "org.springframework.boot:spring-boot-reactor-netty:jar:sources", + "org.springframework.boot:spring-boot-reactor:jar:sources", + "org.springframework.boot:spring-boot-restclient", + "org.springframework.boot:spring-boot-restclient:jar:sources", + "org.springframework.boot:spring-boot-resttestclient", + "org.springframework.boot:spring-boot-resttestclient:jar:sources", + "org.springframework.boot:spring-boot-security", + "org.springframework.boot:spring-boot-security-oauth2-client", + "org.springframework.boot:spring-boot-security-oauth2-client:jar:sources", + "org.springframework.boot:spring-boot-security-oauth2-resource-server", + "org.springframework.boot:spring-boot-security-oauth2-resource-server:jar:sources", + "org.springframework.boot:spring-boot-security-test", + "org.springframework.boot:spring-boot-security-test:jar:sources", + "org.springframework.boot:spring-boot-security:jar:sources", + "org.springframework.boot:spring-boot-servlet", + "org.springframework.boot:spring-boot-servlet:jar:sources", "org.springframework.boot:spring-boot-starter", - "org.springframework.boot:spring-boot-starter-json", + "org.springframework.boot:spring-boot-starter-actuator", + "org.springframework.boot:spring-boot-starter-actuator-test", + "org.springframework.boot:spring-boot-starter-actuator-test:jar:sources", + "org.springframework.boot:spring-boot-starter-actuator:jar:sources", + "org.springframework.boot:spring-boot-starter-data-cassandra", + "org.springframework.boot:spring-boot-starter-data-cassandra-test", + "org.springframework.boot:spring-boot-starter-data-cassandra-test:jar:sources", + "org.springframework.boot:spring-boot-starter-data-cassandra:jar:sources", + "org.springframework.boot:spring-boot-starter-jackson", + "org.springframework.boot:spring-boot-starter-jackson-test", + "org.springframework.boot:spring-boot-starter-jackson-test:jar:sources", + "org.springframework.boot:spring-boot-starter-jackson:jar:sources", "org.springframework.boot:spring-boot-starter-logging", + "org.springframework.boot:spring-boot-starter-logging:jar:sources", + "org.springframework.boot:spring-boot-starter-micrometer-metrics", + "org.springframework.boot:spring-boot-starter-micrometer-metrics-test", + "org.springframework.boot:spring-boot-starter-micrometer-metrics-test:jar:sources", + "org.springframework.boot:spring-boot-starter-micrometer-metrics:jar:sources", + "org.springframework.boot:spring-boot-starter-reactor-netty", + "org.springframework.boot:spring-boot-starter-reactor-netty:jar:sources", + "org.springframework.boot:spring-boot-starter-security", + "org.springframework.boot:spring-boot-starter-security-oauth2-client", + "org.springframework.boot:spring-boot-starter-security-oauth2-client:jar:sources", + "org.springframework.boot:spring-boot-starter-security-oauth2-resource-server", + "org.springframework.boot:spring-boot-starter-security-oauth2-resource-server-test", + "org.springframework.boot:spring-boot-starter-security-oauth2-resource-server-test:jar:sources", + "org.springframework.boot:spring-boot-starter-security-oauth2-resource-server:jar:sources", + "org.springframework.boot:spring-boot-starter-security-test", + "org.springframework.boot:spring-boot-starter-security-test:jar:sources", + "org.springframework.boot:spring-boot-starter-security:jar:sources", "org.springframework.boot:spring-boot-starter-test", + "org.springframework.boot:spring-boot-starter-test:jar:sources", "org.springframework.boot:spring-boot-starter-tomcat", + "org.springframework.boot:spring-boot-starter-tomcat-runtime", + "org.springframework.boot:spring-boot-starter-tomcat-runtime:jar:sources", + "org.springframework.boot:spring-boot-starter-tomcat:jar:sources", + "org.springframework.boot:spring-boot-starter-validation", + "org.springframework.boot:spring-boot-starter-validation:jar:sources", "org.springframework.boot:spring-boot-starter-web", + "org.springframework.boot:spring-boot-starter-web:jar:sources", + "org.springframework.boot:spring-boot-starter-webflux", + "org.springframework.boot:spring-boot-starter-webflux-test", + "org.springframework.boot:spring-boot-starter-webflux-test:jar:sources", + "org.springframework.boot:spring-boot-starter-webflux:jar:sources", + "org.springframework.boot:spring-boot-starter-webmvc", + "org.springframework.boot:spring-boot-starter-webmvc-test", + "org.springframework.boot:spring-boot-starter-webmvc-test:jar:sources", + "org.springframework.boot:spring-boot-starter-webmvc:jar:sources", + "org.springframework.boot:spring-boot-starter:jar:sources", "org.springframework.boot:spring-boot-test", "org.springframework.boot:spring-boot-test-autoconfigure", + "org.springframework.boot:spring-boot-test-autoconfigure:jar:sources", + "org.springframework.boot:spring-boot-test:jar:sources", + "org.springframework.boot:spring-boot-tomcat", + "org.springframework.boot:spring-boot-tomcat:jar:sources", + "org.springframework.boot:spring-boot-validation", + "org.springframework.boot:spring-boot-validation:jar:sources", + "org.springframework.boot:spring-boot-web-server", + "org.springframework.boot:spring-boot-web-server:jar:sources", + "org.springframework.boot:spring-boot-webclient", + "org.springframework.boot:spring-boot-webclient:jar:sources", + "org.springframework.boot:spring-boot-webflux", + "org.springframework.boot:spring-boot-webflux-test", + "org.springframework.boot:spring-boot-webflux-test:jar:sources", + "org.springframework.boot:spring-boot-webflux:jar:sources", + "org.springframework.boot:spring-boot-webmvc", + "org.springframework.boot:spring-boot-webmvc-test", + "org.springframework.boot:spring-boot-webmvc-test:jar:sources", + "org.springframework.boot:spring-boot-webmvc:jar:sources", + "org.springframework.boot:spring-boot-webtestclient", + "org.springframework.boot:spring-boot-webtestclient:jar:sources", + "org.springframework.boot:spring-boot:jar:sources", + "org.springframework.cloud:spring-cloud-commons", + "org.springframework.cloud:spring-cloud-commons:jar:sources", + "org.springframework.cloud:spring-cloud-context", + "org.springframework.cloud:spring-cloud-context:jar:sources", + "org.springframework.cloud:spring-cloud-starter", + "org.springframework.cloud:spring-cloud-starter-bootstrap", + "org.springframework.cloud:spring-cloud-starter-bootstrap:jar:sources", + "org.springframework.data:spring-data-cassandra", + "org.springframework.data:spring-data-cassandra:jar:sources", + "org.springframework.data:spring-data-commons", + "org.springframework.data:spring-data-commons:jar:sources", + "org.springframework.security:spring-security-config", + "org.springframework.security:spring-security-config:jar:sources", + "org.springframework.security:spring-security-core", + "org.springframework.security:spring-security-core:jar:sources", + "org.springframework.security:spring-security-crypto", + "org.springframework.security:spring-security-crypto:jar:sources", + "org.springframework.security:spring-security-oauth2-client", + "org.springframework.security:spring-security-oauth2-client:jar:sources", + "org.springframework.security:spring-security-oauth2-core", + "org.springframework.security:spring-security-oauth2-core:jar:sources", + "org.springframework.security:spring-security-oauth2-jose", + "org.springframework.security:spring-security-oauth2-jose:jar:sources", + "org.springframework.security:spring-security-oauth2-resource-server", + "org.springframework.security:spring-security-oauth2-resource-server:jar:sources", + "org.springframework.security:spring-security-test", + "org.springframework.security:spring-security-test:jar:sources", + "org.springframework.security:spring-security-web", + "org.springframework.security:spring-security-web:jar:sources", "org.springframework:spring-aop", + "org.springframework:spring-aop:jar:sources", "org.springframework:spring-beans", + "org.springframework:spring-beans:jar:sources", "org.springframework:spring-context", + "org.springframework:spring-context:jar:sources", "org.springframework:spring-core", + "org.springframework:spring-core:jar:sources", "org.springframework:spring-expression", - "org.springframework:spring-jcl", + "org.springframework:spring-expression:jar:sources", "org.springframework:spring-test", + "org.springframework:spring-test:jar:sources", + "org.springframework:spring-tx", + "org.springframework:spring-tx:jar:sources", "org.springframework:spring-web", + "org.springframework:spring-web:jar:sources", + "org.springframework:spring-webflux", + "org.springframework:spring-webflux:jar:sources", "org.springframework:spring-webmvc", + "org.springframework:spring-webmvc:jar:sources", + "org.testcontainers:testcontainers", + "org.testcontainers:testcontainers-cassandra", + "org.testcontainers:testcontainers-cassandra:jar:sources", + "org.testcontainers:testcontainers-database-commons", + "org.testcontainers:testcontainers-database-commons:jar:sources", + "org.testcontainers:testcontainers-junit-jupiter", + "org.testcontainers:testcontainers-junit-jupiter:jar:sources", + "org.testcontainers:testcontainers:jar:sources", + "org.wiremock:wiremock-standalone", + "org.wiremock:wiremock-standalone:jar:sources", "org.xmlunit:xmlunit-core", - "org.yaml:snakeyaml" + "org.xmlunit:xmlunit-core:jar:sources", + "org.yaml:snakeyaml", + "org.yaml:snakeyaml:jar:sources", + "software.amazon.awssdk:annotations", + "software.amazon.awssdk:annotations:jar:sources", + "software.amazon.awssdk:checksums", + "software.amazon.awssdk:checksums-spi", + "software.amazon.awssdk:checksums-spi:jar:sources", + "software.amazon.awssdk:checksums:jar:sources", + "software.amazon.awssdk:endpoints-spi", + "software.amazon.awssdk:endpoints-spi:jar:sources", + "software.amazon.awssdk:http-auth-aws", + "software.amazon.awssdk:http-auth-aws:jar:sources", + "software.amazon.awssdk:http-auth-spi", + "software.amazon.awssdk:http-auth-spi:jar:sources", + "software.amazon.awssdk:http-client-spi", + "software.amazon.awssdk:http-client-spi:jar:sources", + "software.amazon.awssdk:identity-spi", + "software.amazon.awssdk:identity-spi:jar:sources", + "software.amazon.awssdk:json-utils", + "software.amazon.awssdk:json-utils:jar:sources", + "software.amazon.awssdk:metrics-spi", + "software.amazon.awssdk:metrics-spi:jar:sources", + "software.amazon.awssdk:profiles", + "software.amazon.awssdk:profiles:jar:sources", + "software.amazon.awssdk:regions", + "software.amazon.awssdk:regions:jar:sources", + "software.amazon.awssdk:retries", + "software.amazon.awssdk:retries-spi", + "software.amazon.awssdk:retries-spi:jar:sources", + "software.amazon.awssdk:retries:jar:sources", + "software.amazon.awssdk:sdk-core", + "software.amazon.awssdk:sdk-core:jar:sources", + "software.amazon.awssdk:third-party-jackson-core", + "software.amazon.awssdk:third-party-jackson-core:jar:sources", + "software.amazon.awssdk:utils", + "software.amazon.awssdk:utils:jar:sources", + "tools.jackson.core:jackson-core", + "tools.jackson.core:jackson-core:jar:sources", + "tools.jackson.core:jackson-databind", + "tools.jackson.core:jackson-databind:jar:sources", + "tools.jackson.module:jackson-module-blackbird", + "tools.jackson.module:jackson-module-blackbird:jar:sources" ], "https://repo.maven.apache.org/maven2/": [ + "aopalliance:aopalliance", + "aopalliance:aopalliance:jar:sources", + "args4j:args4j", + "args4j:args4j:jar:sources", + "at.yawk.lz4:lz4-java", + "at.yawk.lz4:lz4-java:jar:sources", "ch.qos.logback:logback-classic", + "ch.qos.logback:logback-classic:jar:sources", "ch.qos.logback:logback-core", + "ch.qos.logback:logback-core:jar:sources", + "com.datastax.cassandra:cassandra-driver-core", + "com.datastax.cassandra:cassandra-driver-core:jar:sources", + "com.datastax.oss:native-protocol", + "com.datastax.oss:native-protocol:jar:sources", "com.fasterxml.jackson.core:jackson-annotations", + "com.fasterxml.jackson.core:jackson-annotations:jar:sources", "com.fasterxml.jackson.core:jackson-core", + "com.fasterxml.jackson.core:jackson-core:jar:sources", "com.fasterxml.jackson.core:jackson-databind", - "com.fasterxml.jackson.datatype:jackson-datatype-jdk8", + "com.fasterxml.jackson.core:jackson-databind:jar:sources", + "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml", + "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:jar:sources", "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", - "com.fasterxml.jackson.module:jackson-module-parameter-names", + "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:jar:sources", + "com.fasterxml:classmate", + "com.fasterxml:classmate:jar:sources", + "com.github.ben-manes.caffeine:caffeine", + "com.github.ben-manes.caffeine:caffeine:jar:sources", + "com.github.ben-manes.caffeine:guava", + "com.github.ben-manes.caffeine:guava:jar:sources", + "com.github.docker-java:docker-java-api", + "com.github.docker-java:docker-java-api:jar:sources", + "com.github.docker-java:docker-java-transport", + "com.github.docker-java:docker-java-transport-zerodep", + "com.github.docker-java:docker-java-transport-zerodep:jar:sources", + "com.github.docker-java:docker-java-transport:jar:sources", + "com.github.java-json-tools:btf", + "com.github.java-json-tools:btf:jar:sources", + "com.github.java-json-tools:jackson-coreutils", + "com.github.java-json-tools:jackson-coreutils:jar:sources", + "com.github.java-json-tools:json-patch", + "com.github.java-json-tools:json-patch:jar:sources", + "com.github.java-json-tools:msg-simple", + "com.github.java-json-tools:msg-simple:jar:sources", + "com.github.jnr:jffi", + "com.github.jnr:jffi:jar:native", + "com.github.jnr:jffi:jar:sources", + "com.github.jnr:jnr-constants", + "com.github.jnr:jnr-constants:jar:sources", + "com.github.jnr:jnr-ffi", + "com.github.jnr:jnr-ffi:jar:sources", + "com.github.jnr:jnr-posix", + "com.github.jnr:jnr-posix:jar:sources", + "com.github.jnr:jnr-x86asm", + "com.github.jnr:jnr-x86asm:jar:sources", + "com.github.stephenc.jcip:jcip-annotations", + "com.github.stephenc.jcip:jcip-annotations:jar:sources", "com.google.code.findbugs:jsr305", - "com.google.code.gson:gson", "com.google.errorprone:error_prone_annotations", + "com.google.errorprone:error_prone_annotations:jar:sources", "com.google.guava:failureaccess", + "com.google.guava:failureaccess:jar:sources", "com.google.guava:guava", + "com.google.guava:guava:jar:sources", "com.google.guava:listenablefuture", "com.google.j2objc:j2objc-annotations", + "com.google.j2objc:j2objc-annotations:jar:sources", "com.jayway.jsonpath:json-path", + "com.jayway.jsonpath:json-path:jar:sources", + "com.nimbusds:content-type", + "com.nimbusds:content-type:jar:sources", + "com.nimbusds:lang-tag", + "com.nimbusds:lang-tag:jar:sources", + "com.nimbusds:nimbus-jose-jwt", + "com.nimbusds:nimbus-jose-jwt:jar:sources", + "com.nimbusds:oauth2-oidc-sdk", + "com.nimbusds:oauth2-oidc-sdk:jar:sources", + "com.squareup.okhttp3:okhttp-jvm", + "com.squareup.okhttp3:okhttp-jvm:jar:sources", + "com.squareup.okio:okio-jvm", + "com.squareup.okio:okio-jvm:jar:sources", + "com.typesafe:config", + "com.typesafe:config:jar:sources", "com.vaadin.external.google:android-json", + "com.vaadin.external.google:android-json:jar:sources", + "commons-codec:commons-codec", + "commons-codec:commons-codec:jar:sources", + "commons-io:commons-io", + "commons-io:commons-io:jar:sources", + "commons-logging:commons-logging", + "commons-logging:commons-logging:jar:sources", + "io.cloudevents:cloudevents-api", + "io.cloudevents:cloudevents-api:jar:sources", + "io.cloudevents:cloudevents-core", + "io.cloudevents:cloudevents-core:jar:sources", + "io.cloudevents:cloudevents-json-jackson", + "io.cloudevents:cloudevents-json-jackson:jar:sources", + "io.dropwizard.metrics:metrics-core", + "io.dropwizard.metrics:metrics-core:jar:sources", + "io.micrometer:context-propagation", + "io.micrometer:context-propagation:jar:sources", "io.micrometer:micrometer-commons", + "io.micrometer:micrometer-commons:jar:sources", + "io.micrometer:micrometer-core", + "io.micrometer:micrometer-core:jar:sources", + "io.micrometer:micrometer-jakarta9", + "io.micrometer:micrometer-jakarta9:jar:sources", "io.micrometer:micrometer-observation", + "io.micrometer:micrometer-observation-test", + "io.micrometer:micrometer-observation-test:jar:sources", + "io.micrometer:micrometer-observation:jar:sources", + "io.micrometer:micrometer-tracing", + "io.micrometer:micrometer-tracing-bridge-otel", + "io.micrometer:micrometer-tracing-bridge-otel:jar:sources", + "io.micrometer:micrometer-tracing:jar:sources", + "io.nats:jnats", + "io.nats:jnats:jar:sources", + "io.netty:netty-buffer", + "io.netty:netty-buffer:jar:sources", + "io.netty:netty-codec-base", + "io.netty:netty-codec-base:jar:sources", + "io.netty:netty-codec-classes-quic", + "io.netty:netty-codec-classes-quic:jar:sources", + "io.netty:netty-codec-compression", + "io.netty:netty-codec-compression:jar:sources", + "io.netty:netty-codec-dns", + "io.netty:netty-codec-dns:jar:sources", + "io.netty:netty-codec-http", + "io.netty:netty-codec-http2", + "io.netty:netty-codec-http2:jar:sources", + "io.netty:netty-codec-http3", + "io.netty:netty-codec-http3:jar:sources", + "io.netty:netty-codec-http:jar:sources", + "io.netty:netty-codec-native-quic:jar:linux-aarch_64", + "io.netty:netty-codec-native-quic:jar:linux-x86_64", + "io.netty:netty-codec-native-quic:jar:osx-aarch_64", + "io.netty:netty-codec-native-quic:jar:osx-x86_64", + "io.netty:netty-codec-native-quic:jar:sources", + "io.netty:netty-codec-native-quic:jar:windows-x86_64", + "io.netty:netty-codec-socks", + "io.netty:netty-codec-socks:jar:sources", + "io.netty:netty-common", + "io.netty:netty-common:jar:sources", + "io.netty:netty-handler", + "io.netty:netty-handler-proxy", + "io.netty:netty-handler-proxy:jar:sources", + "io.netty:netty-handler:jar:sources", + "io.netty:netty-resolver", + "io.netty:netty-resolver-dns", + "io.netty:netty-resolver-dns-classes-macos", + "io.netty:netty-resolver-dns-classes-macos:jar:sources", + "io.netty:netty-resolver-dns-native-macos:jar:osx-x86_64", + "io.netty:netty-resolver-dns-native-macos:jar:sources", + "io.netty:netty-resolver-dns:jar:sources", + "io.netty:netty-resolver:jar:sources", + "io.netty:netty-transport", + "io.netty:netty-transport-classes-epoll", + "io.netty:netty-transport-classes-epoll:jar:sources", + "io.netty:netty-transport-native-epoll:jar:linux-x86_64", + "io.netty:netty-transport-native-epoll:jar:sources", + "io.netty:netty-transport-native-unix-common", + "io.netty:netty-transport-native-unix-common:jar:sources", + "io.netty:netty-transport:jar:sources", + "io.opentelemetry.semconv:opentelemetry-semconv", + "io.opentelemetry.semconv:opentelemetry-semconv:jar:sources", + "io.opentelemetry:opentelemetry-api", + "io.opentelemetry:opentelemetry-api:jar:sources", + "io.opentelemetry:opentelemetry-common", + "io.opentelemetry:opentelemetry-common:jar:sources", + "io.opentelemetry:opentelemetry-context", + "io.opentelemetry:opentelemetry-context:jar:sources", + "io.opentelemetry:opentelemetry-exporter-common", + "io.opentelemetry:opentelemetry-exporter-common:jar:sources", + "io.opentelemetry:opentelemetry-exporter-otlp", + "io.opentelemetry:opentelemetry-exporter-otlp-common", + "io.opentelemetry:opentelemetry-exporter-otlp-common:jar:sources", + "io.opentelemetry:opentelemetry-exporter-otlp:jar:sources", + "io.opentelemetry:opentelemetry-exporter-sender-okhttp", + "io.opentelemetry:opentelemetry-exporter-sender-okhttp:jar:sources", + "io.opentelemetry:opentelemetry-extension-trace-propagators", + "io.opentelemetry:opentelemetry-extension-trace-propagators:jar:sources", + "io.opentelemetry:opentelemetry-sdk", + "io.opentelemetry:opentelemetry-sdk-common", + "io.opentelemetry:opentelemetry-sdk-common:jar:sources", + "io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi", + "io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:jar:sources", + "io.opentelemetry:opentelemetry-sdk-logs", + "io.opentelemetry:opentelemetry-sdk-logs:jar:sources", + "io.opentelemetry:opentelemetry-sdk-metrics", + "io.opentelemetry:opentelemetry-sdk-metrics:jar:sources", + "io.opentelemetry:opentelemetry-sdk-testing", + "io.opentelemetry:opentelemetry-sdk-testing:jar:sources", + "io.opentelemetry:opentelemetry-sdk-trace", + "io.opentelemetry:opentelemetry-sdk-trace:jar:sources", + "io.opentelemetry:opentelemetry-sdk:jar:sources", + "io.projectreactor.netty:reactor-netty-core", + "io.projectreactor.netty:reactor-netty-core:jar:sources", + "io.projectreactor.netty:reactor-netty-http", + "io.projectreactor.netty:reactor-netty-http:jar:sources", + "io.projectreactor:reactor-core", + "io.projectreactor:reactor-core:jar:sources", + "io.projectreactor:reactor-test", + "io.projectreactor:reactor-test:jar:sources", + "io.swagger.core.v3:swagger-annotations-jakarta", + "io.swagger.core.v3:swagger-annotations-jakarta:jar:sources", + "io.swagger.core.v3:swagger-core-jakarta", + "io.swagger.core.v3:swagger-core-jakarta:jar:sources", + "io.swagger.core.v3:swagger-models-jakarta", + "io.swagger.core.v3:swagger-models-jakarta:jar:sources", "jakarta.activation:jakarta.activation-api", + "jakarta.activation:jakarta.activation-api:jar:sources", "jakarta.annotation:jakarta.annotation-api", + "jakarta.annotation:jakarta.annotation-api:jar:sources", + "jakarta.servlet:jakarta.servlet-api", + "jakarta.servlet:jakarta.servlet-api:jar:sources", + "jakarta.validation:jakarta.validation-api", + "jakarta.validation:jakarta.validation-api:jar:sources", "jakarta.xml.bind:jakarta.xml.bind-api", + "jakarta.xml.bind:jakarta.xml.bind-api:jar:sources", "net.bytebuddy:byte-buddy", "net.bytebuddy:byte-buddy-agent", + "net.bytebuddy:byte-buddy-agent:jar:sources", + "net.bytebuddy:byte-buddy:jar:sources", + "net.java.dev.jna:jna", + "net.java.dev.jna:jna:jar:sources", "net.minidev:accessors-smart", + "net.minidev:accessors-smart:jar:sources", "net.minidev:json-smart", + "net.minidev:json-smart:jar:sources", + "org.apache.cassandra:java-driver-core", + "org.apache.cassandra:java-driver-core:jar:sources", + "org.apache.cassandra:java-driver-guava-shaded", + "org.apache.cassandra:java-driver-guava-shaded:jar:sources", + "org.apache.cassandra:java-driver-metrics-micrometer", + "org.apache.cassandra:java-driver-metrics-micrometer:jar:sources", + "org.apache.cassandra:java-driver-query-builder", + "org.apache.cassandra:java-driver-query-builder:jar:sources", + "org.apache.commons:commons-compress", + "org.apache.commons:commons-compress:jar:sources", + "org.apache.commons:commons-lang3", + "org.apache.commons:commons-lang3:jar:sources", "org.apache.logging.log4j:log4j-api", + "org.apache.logging.log4j:log4j-api:jar:sources", "org.apache.logging.log4j:log4j-to-slf4j", + "org.apache.logging.log4j:log4j-to-slf4j:jar:sources", "org.apache.tomcat.embed:tomcat-embed-core", + "org.apache.tomcat.embed:tomcat-embed-core:jar:sources", "org.apache.tomcat.embed:tomcat-embed-el", + "org.apache.tomcat.embed:tomcat-embed-el:jar:sources", "org.apache.tomcat.embed:tomcat-embed-websocket", + "org.apache.tomcat.embed:tomcat-embed-websocket:jar:sources", "org.apiguardian:apiguardian-api", + "org.apiguardian:apiguardian-api:jar:sources", "org.assertj:assertj-core", + "org.assertj:assertj-core:jar:sources", "org.awaitility:awaitility", - "org.checkerframework:checker-qual", + "org.awaitility:awaitility:jar:sources", + "org.bouncycastle:bcprov-jdk18on", + "org.bouncycastle:bcprov-jdk18on:jar:sources", + "org.bouncycastle:bcprov-lts8on", + "org.bouncycastle:bcprov-lts8on:jar:sources", "org.hamcrest:hamcrest", + "org.hamcrest:hamcrest:jar:sources", + "org.hdrhistogram:HdrHistogram", + "org.hdrhistogram:HdrHistogram:jar:sources", + "org.hibernate.validator:hibernate-validator", + "org.hibernate.validator:hibernate-validator:jar:sources", + "org.jacoco:org.jacoco.agent:jar:runtime", + "org.jacoco:org.jacoco.agent:jar:sources", + "org.jacoco:org.jacoco.cli", + "org.jacoco:org.jacoco.cli:jar:sources", + "org.jacoco:org.jacoco.core", + "org.jacoco:org.jacoco.core:jar:sources", + "org.jacoco:org.jacoco.report", + "org.jacoco:org.jacoco.report:jar:sources", + "org.jboss.logging:jboss-logging", + "org.jboss.logging:jboss-logging:jar:sources", + "org.jetbrains.kotlin:kotlin-stdlib", + "org.jetbrains.kotlin:kotlin-stdlib:jar:sources", + "org.jetbrains:annotations", + "org.jetbrains:annotations:jar:sources", + "org.jspecify:jspecify", + "org.jspecify:jspecify:jar:sources", "org.junit.jupiter:junit-jupiter", "org.junit.jupiter:junit-jupiter-api", + "org.junit.jupiter:junit-jupiter-api:jar:sources", "org.junit.jupiter:junit-jupiter-engine", + "org.junit.jupiter:junit-jupiter-engine:jar:sources", "org.junit.jupiter:junit-jupiter-params", + "org.junit.jupiter:junit-jupiter-params:jar:sources", + "org.junit.jupiter:junit-jupiter:jar:sources", "org.junit.platform:junit-platform-commons", + "org.junit.platform:junit-platform-commons:jar:sources", + "org.junit.platform:junit-platform-console-standalone", + "org.junit.platform:junit-platform-console-standalone:jar:sources", "org.junit.platform:junit-platform-engine", + "org.junit.platform:junit-platform-engine:jar:sources", "org.junit.platform:junit-platform-launcher", + "org.junit.platform:junit-platform-launcher:jar:sources", "org.junit.platform:junit-platform-reporting", + "org.junit.platform:junit-platform-reporting:jar:sources", + "org.latencyutils:LatencyUtils", + "org.latencyutils:LatencyUtils:jar:sources", "org.mockito:mockito-core", + "org.mockito:mockito-core:jar:sources", "org.mockito:mockito-junit-jupiter", + "org.mockito:mockito-junit-jupiter:jar:sources", "org.objenesis:objenesis", + "org.objenesis:objenesis:jar:sources", + "org.opentest4j.reporting:open-test-reporting-tooling-spi", + "org.opentest4j.reporting:open-test-reporting-tooling-spi:jar:sources", "org.opentest4j:opentest4j", + "org.opentest4j:opentest4j:jar:sources", "org.ow2.asm:asm", + "org.ow2.asm:asm-analysis", + "org.ow2.asm:asm-analysis:jar:sources", + "org.ow2.asm:asm-commons", + "org.ow2.asm:asm-commons:jar:sources", + "org.ow2.asm:asm-tree", + "org.ow2.asm:asm-tree:jar:sources", + "org.ow2.asm:asm-util", + "org.ow2.asm:asm-util:jar:sources", + "org.ow2.asm:asm:jar:sources", + "org.projectlombok:lombok", + "org.projectlombok:lombok:jar:sources", + "org.reactivestreams:reactive-streams", + "org.reactivestreams:reactive-streams:jar:sources", + "org.rnorth.duct-tape:duct-tape", + "org.rnorth.duct-tape:duct-tape:jar:sources", "org.skyscreamer:jsonassert", + "org.skyscreamer:jsonassert:jar:sources", "org.slf4j:jul-to-slf4j", + "org.slf4j:jul-to-slf4j:jar:sources", "org.slf4j:slf4j-api", + "org.slf4j:slf4j-api:jar:sources", + "org.springdoc:springdoc-openapi-starter-common", + "org.springdoc:springdoc-openapi-starter-common:jar:sources", + "org.springdoc:springdoc-openapi-starter-webflux-api", + "org.springdoc:springdoc-openapi-starter-webflux-api:jar:sources", + "org.springdoc:springdoc-openapi-starter-webmvc-api", + "org.springdoc:springdoc-openapi-starter-webmvc-api:jar:sources", "org.springframework.boot:spring-boot", + "org.springframework.boot:spring-boot-actuator", + "org.springframework.boot:spring-boot-actuator-autoconfigure", + "org.springframework.boot:spring-boot-actuator-autoconfigure:jar:sources", + "org.springframework.boot:spring-boot-actuator:jar:sources", "org.springframework.boot:spring-boot-autoconfigure", + "org.springframework.boot:spring-boot-autoconfigure:jar:sources", + "org.springframework.boot:spring-boot-cassandra", + "org.springframework.boot:spring-boot-cassandra:jar:sources", + "org.springframework.boot:spring-boot-data-cassandra", + "org.springframework.boot:spring-boot-data-cassandra-test", + "org.springframework.boot:spring-boot-data-cassandra-test:jar:sources", + "org.springframework.boot:spring-boot-data-cassandra:jar:sources", + "org.springframework.boot:spring-boot-data-commons", + "org.springframework.boot:spring-boot-data-commons:jar:sources", + "org.springframework.boot:spring-boot-health", + "org.springframework.boot:spring-boot-health:jar:sources", + "org.springframework.boot:spring-boot-http-client", + "org.springframework.boot:spring-boot-http-client:jar:sources", + "org.springframework.boot:spring-boot-http-codec", + "org.springframework.boot:spring-boot-http-codec:jar:sources", + "org.springframework.boot:spring-boot-http-converter", + "org.springframework.boot:spring-boot-http-converter:jar:sources", + "org.springframework.boot:spring-boot-jackson", + "org.springframework.boot:spring-boot-jackson:jar:sources", + "org.springframework.boot:spring-boot-micrometer-metrics", + "org.springframework.boot:spring-boot-micrometer-metrics-test", + "org.springframework.boot:spring-boot-micrometer-metrics-test:jar:sources", + "org.springframework.boot:spring-boot-micrometer-metrics:jar:sources", + "org.springframework.boot:spring-boot-micrometer-observation", + "org.springframework.boot:spring-boot-micrometer-observation:jar:sources", + "org.springframework.boot:spring-boot-micrometer-tracing", + "org.springframework.boot:spring-boot-micrometer-tracing-opentelemetry", + "org.springframework.boot:spring-boot-micrometer-tracing-opentelemetry:jar:sources", + "org.springframework.boot:spring-boot-micrometer-tracing:jar:sources", + "org.springframework.boot:spring-boot-netty", + "org.springframework.boot:spring-boot-netty:jar:sources", + "org.springframework.boot:spring-boot-opentelemetry", + "org.springframework.boot:spring-boot-opentelemetry:jar:sources", + "org.springframework.boot:spring-boot-persistence", + "org.springframework.boot:spring-boot-persistence:jar:sources", + "org.springframework.boot:spring-boot-reactor", + "org.springframework.boot:spring-boot-reactor-netty", + "org.springframework.boot:spring-boot-reactor-netty:jar:sources", + "org.springframework.boot:spring-boot-reactor:jar:sources", + "org.springframework.boot:spring-boot-restclient", + "org.springframework.boot:spring-boot-restclient:jar:sources", + "org.springframework.boot:spring-boot-resttestclient", + "org.springframework.boot:spring-boot-resttestclient:jar:sources", + "org.springframework.boot:spring-boot-security", + "org.springframework.boot:spring-boot-security-oauth2-client", + "org.springframework.boot:spring-boot-security-oauth2-client:jar:sources", + "org.springframework.boot:spring-boot-security-oauth2-resource-server", + "org.springframework.boot:spring-boot-security-oauth2-resource-server:jar:sources", + "org.springframework.boot:spring-boot-security-test", + "org.springframework.boot:spring-boot-security-test:jar:sources", + "org.springframework.boot:spring-boot-security:jar:sources", + "org.springframework.boot:spring-boot-servlet", + "org.springframework.boot:spring-boot-servlet:jar:sources", "org.springframework.boot:spring-boot-starter", - "org.springframework.boot:spring-boot-starter-json", + "org.springframework.boot:spring-boot-starter-actuator", + "org.springframework.boot:spring-boot-starter-actuator-test", + "org.springframework.boot:spring-boot-starter-actuator-test:jar:sources", + "org.springframework.boot:spring-boot-starter-actuator:jar:sources", + "org.springframework.boot:spring-boot-starter-data-cassandra", + "org.springframework.boot:spring-boot-starter-data-cassandra-test", + "org.springframework.boot:spring-boot-starter-data-cassandra-test:jar:sources", + "org.springframework.boot:spring-boot-starter-data-cassandra:jar:sources", + "org.springframework.boot:spring-boot-starter-jackson", + "org.springframework.boot:spring-boot-starter-jackson-test", + "org.springframework.boot:spring-boot-starter-jackson-test:jar:sources", + "org.springframework.boot:spring-boot-starter-jackson:jar:sources", "org.springframework.boot:spring-boot-starter-logging", + "org.springframework.boot:spring-boot-starter-logging:jar:sources", + "org.springframework.boot:spring-boot-starter-micrometer-metrics", + "org.springframework.boot:spring-boot-starter-micrometer-metrics-test", + "org.springframework.boot:spring-boot-starter-micrometer-metrics-test:jar:sources", + "org.springframework.boot:spring-boot-starter-micrometer-metrics:jar:sources", + "org.springframework.boot:spring-boot-starter-reactor-netty", + "org.springframework.boot:spring-boot-starter-reactor-netty:jar:sources", + "org.springframework.boot:spring-boot-starter-security", + "org.springframework.boot:spring-boot-starter-security-oauth2-client", + "org.springframework.boot:spring-boot-starter-security-oauth2-client:jar:sources", + "org.springframework.boot:spring-boot-starter-security-oauth2-resource-server", + "org.springframework.boot:spring-boot-starter-security-oauth2-resource-server-test", + "org.springframework.boot:spring-boot-starter-security-oauth2-resource-server-test:jar:sources", + "org.springframework.boot:spring-boot-starter-security-oauth2-resource-server:jar:sources", + "org.springframework.boot:spring-boot-starter-security-test", + "org.springframework.boot:spring-boot-starter-security-test:jar:sources", + "org.springframework.boot:spring-boot-starter-security:jar:sources", "org.springframework.boot:spring-boot-starter-test", + "org.springframework.boot:spring-boot-starter-test:jar:sources", "org.springframework.boot:spring-boot-starter-tomcat", + "org.springframework.boot:spring-boot-starter-tomcat-runtime", + "org.springframework.boot:spring-boot-starter-tomcat-runtime:jar:sources", + "org.springframework.boot:spring-boot-starter-tomcat:jar:sources", + "org.springframework.boot:spring-boot-starter-validation", + "org.springframework.boot:spring-boot-starter-validation:jar:sources", "org.springframework.boot:spring-boot-starter-web", + "org.springframework.boot:spring-boot-starter-web:jar:sources", + "org.springframework.boot:spring-boot-starter-webflux", + "org.springframework.boot:spring-boot-starter-webflux-test", + "org.springframework.boot:spring-boot-starter-webflux-test:jar:sources", + "org.springframework.boot:spring-boot-starter-webflux:jar:sources", + "org.springframework.boot:spring-boot-starter-webmvc", + "org.springframework.boot:spring-boot-starter-webmvc-test", + "org.springframework.boot:spring-boot-starter-webmvc-test:jar:sources", + "org.springframework.boot:spring-boot-starter-webmvc:jar:sources", + "org.springframework.boot:spring-boot-starter:jar:sources", "org.springframework.boot:spring-boot-test", "org.springframework.boot:spring-boot-test-autoconfigure", + "org.springframework.boot:spring-boot-test-autoconfigure:jar:sources", + "org.springframework.boot:spring-boot-test:jar:sources", + "org.springframework.boot:spring-boot-tomcat", + "org.springframework.boot:spring-boot-tomcat:jar:sources", + "org.springframework.boot:spring-boot-validation", + "org.springframework.boot:spring-boot-validation:jar:sources", + "org.springframework.boot:spring-boot-web-server", + "org.springframework.boot:spring-boot-web-server:jar:sources", + "org.springframework.boot:spring-boot-webclient", + "org.springframework.boot:spring-boot-webclient:jar:sources", + "org.springframework.boot:spring-boot-webflux", + "org.springframework.boot:spring-boot-webflux-test", + "org.springframework.boot:spring-boot-webflux-test:jar:sources", + "org.springframework.boot:spring-boot-webflux:jar:sources", + "org.springframework.boot:spring-boot-webmvc", + "org.springframework.boot:spring-boot-webmvc-test", + "org.springframework.boot:spring-boot-webmvc-test:jar:sources", + "org.springframework.boot:spring-boot-webmvc:jar:sources", + "org.springframework.boot:spring-boot-webtestclient", + "org.springframework.boot:spring-boot-webtestclient:jar:sources", + "org.springframework.boot:spring-boot:jar:sources", + "org.springframework.cloud:spring-cloud-commons", + "org.springframework.cloud:spring-cloud-commons:jar:sources", + "org.springframework.cloud:spring-cloud-context", + "org.springframework.cloud:spring-cloud-context:jar:sources", + "org.springframework.cloud:spring-cloud-starter", + "org.springframework.cloud:spring-cloud-starter-bootstrap", + "org.springframework.cloud:spring-cloud-starter-bootstrap:jar:sources", + "org.springframework.data:spring-data-cassandra", + "org.springframework.data:spring-data-cassandra:jar:sources", + "org.springframework.data:spring-data-commons", + "org.springframework.data:spring-data-commons:jar:sources", + "org.springframework.security:spring-security-config", + "org.springframework.security:spring-security-config:jar:sources", + "org.springframework.security:spring-security-core", + "org.springframework.security:spring-security-core:jar:sources", + "org.springframework.security:spring-security-crypto", + "org.springframework.security:spring-security-crypto:jar:sources", + "org.springframework.security:spring-security-oauth2-client", + "org.springframework.security:spring-security-oauth2-client:jar:sources", + "org.springframework.security:spring-security-oauth2-core", + "org.springframework.security:spring-security-oauth2-core:jar:sources", + "org.springframework.security:spring-security-oauth2-jose", + "org.springframework.security:spring-security-oauth2-jose:jar:sources", + "org.springframework.security:spring-security-oauth2-resource-server", + "org.springframework.security:spring-security-oauth2-resource-server:jar:sources", + "org.springframework.security:spring-security-test", + "org.springframework.security:spring-security-test:jar:sources", + "org.springframework.security:spring-security-web", + "org.springframework.security:spring-security-web:jar:sources", "org.springframework:spring-aop", + "org.springframework:spring-aop:jar:sources", "org.springframework:spring-beans", + "org.springframework:spring-beans:jar:sources", "org.springframework:spring-context", + "org.springframework:spring-context:jar:sources", "org.springframework:spring-core", + "org.springframework:spring-core:jar:sources", "org.springframework:spring-expression", - "org.springframework:spring-jcl", + "org.springframework:spring-expression:jar:sources", "org.springframework:spring-test", + "org.springframework:spring-test:jar:sources", + "org.springframework:spring-tx", + "org.springframework:spring-tx:jar:sources", "org.springframework:spring-web", + "org.springframework:spring-web:jar:sources", + "org.springframework:spring-webflux", + "org.springframework:spring-webflux:jar:sources", "org.springframework:spring-webmvc", + "org.springframework:spring-webmvc:jar:sources", + "org.testcontainers:testcontainers", + "org.testcontainers:testcontainers-cassandra", + "org.testcontainers:testcontainers-cassandra:jar:sources", + "org.testcontainers:testcontainers-database-commons", + "org.testcontainers:testcontainers-database-commons:jar:sources", + "org.testcontainers:testcontainers-junit-jupiter", + "org.testcontainers:testcontainers-junit-jupiter:jar:sources", + "org.testcontainers:testcontainers:jar:sources", + "org.wiremock:wiremock-standalone", + "org.wiremock:wiremock-standalone:jar:sources", "org.xmlunit:xmlunit-core", - "org.yaml:snakeyaml" + "org.xmlunit:xmlunit-core:jar:sources", + "org.yaml:snakeyaml", + "org.yaml:snakeyaml:jar:sources", + "software.amazon.awssdk:annotations", + "software.amazon.awssdk:annotations:jar:sources", + "software.amazon.awssdk:checksums", + "software.amazon.awssdk:checksums-spi", + "software.amazon.awssdk:checksums-spi:jar:sources", + "software.amazon.awssdk:checksums:jar:sources", + "software.amazon.awssdk:endpoints-spi", + "software.amazon.awssdk:endpoints-spi:jar:sources", + "software.amazon.awssdk:http-auth-aws", + "software.amazon.awssdk:http-auth-aws:jar:sources", + "software.amazon.awssdk:http-auth-spi", + "software.amazon.awssdk:http-auth-spi:jar:sources", + "software.amazon.awssdk:http-client-spi", + "software.amazon.awssdk:http-client-spi:jar:sources", + "software.amazon.awssdk:identity-spi", + "software.amazon.awssdk:identity-spi:jar:sources", + "software.amazon.awssdk:json-utils", + "software.amazon.awssdk:json-utils:jar:sources", + "software.amazon.awssdk:metrics-spi", + "software.amazon.awssdk:metrics-spi:jar:sources", + "software.amazon.awssdk:profiles", + "software.amazon.awssdk:profiles:jar:sources", + "software.amazon.awssdk:regions", + "software.amazon.awssdk:regions:jar:sources", + "software.amazon.awssdk:retries", + "software.amazon.awssdk:retries-spi", + "software.amazon.awssdk:retries-spi:jar:sources", + "software.amazon.awssdk:retries:jar:sources", + "software.amazon.awssdk:sdk-core", + "software.amazon.awssdk:sdk-core:jar:sources", + "software.amazon.awssdk:third-party-jackson-core", + "software.amazon.awssdk:third-party-jackson-core:jar:sources", + "software.amazon.awssdk:utils", + "software.amazon.awssdk:utils:jar:sources", + "tools.jackson.core:jackson-core", + "tools.jackson.core:jackson-core:jar:sources", + "tools.jackson.core:jackson-databind", + "tools.jackson.core:jackson-databind:jar:sources", + "tools.jackson.module:jackson-module-blackbird", + "tools.jackson.module:jackson-module-blackbird:jar:sources" ] }, "services": { @@ -2164,9 +9672,12 @@ "com.fasterxml.jackson.databind.ObjectMapper" ] }, - "com.fasterxml.jackson.datatype:jackson-datatype-jdk8": { - "com.fasterxml.jackson.databind.Module": [ - "com.fasterxml.jackson.datatype.jdk8.Jdk8Module" + "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml": { + "com.fasterxml.jackson.core.JsonFactory": [ + "com.fasterxml.jackson.dataformat.yaml.YAMLFactory" + ], + "com.fasterxml.jackson.core.ObjectCodec": [ + "com.fasterxml.jackson.dataformat.yaml.YAMLMapper" ] }, "com.fasterxml.jackson.datatype:jackson-datatype-jsr310": { @@ -2174,9 +9685,9 @@ "com.fasterxml.jackson.datatype.jsr310.JavaTimeModule" ] }, - "com.fasterxml.jackson.module:jackson-module-parameter-names": { - "com.fasterxml.jackson.databind.Module": [ - "com.fasterxml.jackson.module.paramnames.ParameterNamesModule" + "io.cloudevents:cloudevents-json-jackson": { + "io.cloudevents.core.format.EventFormat": [ + "io.cloudevents.jackson.JsonFormat" ] }, "io.micrometer:micrometer-observation": { @@ -2184,6 +9695,75 @@ "io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor" ] }, + "io.netty:netty-common": { + "reactor.blockhound.integration.BlockHoundIntegration": [ + "io.netty.util.internal.Hidden$NettyBlockHoundIntegration" + ] + }, + "io.opentelemetry:opentelemetry-exporter-otlp": { + "io.opentelemetry.sdk.autoconfigure.spi.internal.ComponentProvider": [ + "io.opentelemetry.exporter.otlp.internal.OtlpGrpcLogRecordExporterComponentProvider", + "io.opentelemetry.exporter.otlp.internal.OtlpGrpcMetricExporterComponentProvider", + "io.opentelemetry.exporter.otlp.internal.OtlpGrpcSpanExporterComponentProvider", + "io.opentelemetry.exporter.otlp.internal.OtlpHttpLogRecordExporterComponentProvider", + "io.opentelemetry.exporter.otlp.internal.OtlpHttpMetricExporterComponentProvider", + "io.opentelemetry.exporter.otlp.internal.OtlpHttpSpanExporterComponentProvider" + ], + "io.opentelemetry.sdk.autoconfigure.spi.logs.ConfigurableLogRecordExporterProvider": [ + "io.opentelemetry.exporter.otlp.internal.OtlpLogRecordExporterProvider" + ], + "io.opentelemetry.sdk.autoconfigure.spi.metrics.ConfigurableMetricExporterProvider": [ + "io.opentelemetry.exporter.otlp.internal.OtlpMetricExporterProvider" + ], + "io.opentelemetry.sdk.autoconfigure.spi.traces.ConfigurableSpanExporterProvider": [ + "io.opentelemetry.exporter.otlp.internal.OtlpSpanExporterProvider" + ] + }, + "io.opentelemetry:opentelemetry-exporter-sender-okhttp": { + "io.opentelemetry.exporter.internal.grpc.GrpcSenderProvider": [ + "io.opentelemetry.exporter.sender.okhttp.internal.OkHttpGrpcSenderProvider" + ], + "io.opentelemetry.exporter.internal.http.HttpSenderProvider": [ + "io.opentelemetry.exporter.sender.okhttp.internal.OkHttpHttpSenderProvider" + ] + }, + "io.opentelemetry:opentelemetry-extension-trace-propagators": { + "io.opentelemetry.sdk.autoconfigure.spi.ConfigurablePropagatorProvider": [ + "io.opentelemetry.extension.trace.propagation.B3ConfigurablePropagator", + "io.opentelemetry.extension.trace.propagation.B3MultiConfigurablePropagator", + "io.opentelemetry.extension.trace.propagation.JaegerConfigurablePropagator", + "io.opentelemetry.extension.trace.propagation.OtTraceConfigurablePropagator" + ], + "io.opentelemetry.sdk.autoconfigure.spi.internal.ComponentProvider": [ + "io.opentelemetry.extension.trace.propagation.internal.B3ComponentProvider", + "io.opentelemetry.extension.trace.propagation.internal.B3MultiComponentProvider", + "io.opentelemetry.extension.trace.propagation.internal.JaegerComponentProvider", + "io.opentelemetry.extension.trace.propagation.internal.OtTraceComponentProvider" + ] + }, + "io.opentelemetry:opentelemetry-sdk-testing": { + "io.opentelemetry.context.ContextStorageProvider": [ + "io.opentelemetry.sdk.testing.context.SettableContextStorageProvider" + ] + }, + "io.projectreactor.netty:reactor-netty-core": { + "io.micrometer.context.ContextAccessor": [ + "reactor.netty.contextpropagation.ChannelContextAccessor" + ] + }, + "io.projectreactor:reactor-core": { + "io.micrometer.context.ContextAccessor": [ + "reactor.util.context.ReactorContextAccessor" + ], + "reactor.blockhound.integration.BlockHoundIntegration": [ + "reactor.core.scheduler.ReactorBlockHoundIntegration" + ] + }, + "org.apache.cassandra:java-driver-core": { + "reactor.blockhound.integration.BlockHoundIntegration": [ + "com.datastax.oss.driver.internal.core.util.concurrent.DriverBlockHoundIntegration" + ] + }, "org.apache.logging.log4j:log4j-api": { "org.apache.logging.log4j.util.PropertySource": [ "org.apache.logging.log4j.util.EnvironmentPropertySource", @@ -2211,11 +9791,60 @@ "org.apache.tomcat.websocket.server.DefaultServerEndpointConfigurator" ] }, + "org.bouncycastle:bcprov-jdk18on": { + "java.security.Provider": [ + "org.bouncycastle.jce.provider.BouncyCastleProvider", + "org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider" + ] + }, + "org.bouncycastle:bcprov-lts8on": { + "java.security.Provider": [ + "org.bouncycastle.jce.provider.BouncyCastleProvider", + "org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider" + ] + }, + "org.hibernate.validator:hibernate-validator": { + "jakarta.validation.spi.ValidationProvider": [ + "org.hibernate.validator.HibernateValidator" + ] + }, "org.junit.jupiter:junit-jupiter-engine": { "org.junit.platform.engine.TestEngine": [ "org.junit.jupiter.engine.JupiterTestEngine" ] }, + "org.junit.platform:junit-platform-console-standalone": { + "java.util.spi.ToolProvider": [ + "org.junit.platform.console.ConsoleLauncherToolProvider" + ], + "org.junit.platform.engine.TestEngine": [ + "org.junit.jupiter.engine.JupiterTestEngine", + "org.junit.platform.suite.engine.SuiteTestEngine", + "org.junit.vintage.engine.VintageTestEngine" + ], + "org.junit.platform.engine.discovery.DiscoverySelectorIdentifierParser": [ + "org.junit.platform.engine.discovery.ClassSelector$IdentifierParser", + "org.junit.platform.engine.discovery.ClasspathResourceSelector$IdentifierParser", + "org.junit.platform.engine.discovery.ClasspathRootSelector$IdentifierParser", + "org.junit.platform.engine.discovery.DirectorySelector$IdentifierParser", + "org.junit.platform.engine.discovery.FileSelector$IdentifierParser", + "org.junit.platform.engine.discovery.IterationSelector$IdentifierParser", + "org.junit.platform.engine.discovery.MethodSelector$IdentifierParser", + "org.junit.platform.engine.discovery.ModuleSelector$IdentifierParser", + "org.junit.platform.engine.discovery.NestedClassSelector$IdentifierParser", + "org.junit.platform.engine.discovery.NestedMethodSelector$IdentifierParser", + "org.junit.platform.engine.discovery.PackageSelector$IdentifierParser", + "org.junit.platform.engine.discovery.UniqueIdSelector$IdentifierParser", + "org.junit.platform.engine.discovery.UriSelector$IdentifierParser" + ], + "org.junit.platform.launcher.TestExecutionListener": [ + "org.junit.platform.launcher.listeners.UniqueIdTrackingListener", + "org.junit.platform.reporting.open.xml.OpenTestReportGeneratingListener" + ], + "org.opentest4j.reporting.tooling.spi.htmlreport.Contributor": [ + "org.junit.platform.reporting.open.xml.JUnitContributor" + ] + }, "org.junit.platform:junit-platform-engine": { "org.junit.platform.engine.discovery.DiscoverySelectorIdentifierParser": [ "org.junit.platform.engine.discovery.ClassSelector$IdentifierParser", @@ -2241,6 +9870,157 @@ "org.junit.platform:junit-platform-reporting": { "org.junit.platform.launcher.TestExecutionListener": [ "org.junit.platform.reporting.open.xml.OpenTestReportGeneratingListener" + ], + "org.opentest4j.reporting.tooling.spi.htmlreport.Contributor": [ + "org.junit.platform.reporting.open.xml.JUnitContributor" + ] + }, + "org.projectlombok:lombok": { + "javax.annotation.processing.Processor": [ + "lombok.launch.AnnotationProcessorHider$AnnotationProcessor", + "lombok.launch.AnnotationProcessorHider$ClaimingProcessor" + ], + "lombok.core.LombokApp": [ + "lombok.bytecode.PoolConstantsApp", + "lombok.bytecode.PostCompilerApp", + "lombok.core.Main$LicenseApp", + "lombok.core.Main$VersionApp", + "lombok.core.PublicApiCreatorApp", + "lombok.core.configuration.ConfigurationApp", + "lombok.core.runtimeDependencies.CreateLombokRuntimeApp", + "lombok.delombok.DelombokApp", + "lombok.eclipse.agent.MavenEcjBootstrapApp", + "lombok.installer.Installer$CommandLineInstallerApp", + "lombok.installer.Installer$CommandLineUninstallerApp", + "lombok.installer.Installer$GraphicalInstallerApp" + ], + "lombok.core.PostCompilerTransformation": [ + "lombok.bytecode.PreventNullAnalysisRemover", + "lombok.bytecode.SneakyThrowsRemover" + ], + "lombok.core.runtimeDependencies.RuntimeDependencyInfo": [ + "lombok.core.handlers.SneakyThrowsAndCleanupDependencyInfo" + ], + "lombok.eclipse.EclipseASTVisitor": [ + "lombok.eclipse.handlers.HandleFieldDefaults", + "lombok.eclipse.handlers.HandleVal" + ], + "lombok.eclipse.EclipseAnnotationHandler": [ + "lombok.eclipse.handlers.HandleAccessors", + "lombok.eclipse.handlers.HandleBuilder", + "lombok.eclipse.handlers.HandleBuilderDefault", + "lombok.eclipse.handlers.HandleCleanup", + "lombok.eclipse.handlers.HandleConstructor$HandleAllArgsConstructor", + "lombok.eclipse.handlers.HandleConstructor$HandleNoArgsConstructor", + "lombok.eclipse.handlers.HandleConstructor$HandleRequiredArgsConstructor", + "lombok.eclipse.handlers.HandleData", + "lombok.eclipse.handlers.HandleDelegate", + "lombok.eclipse.handlers.HandleEqualsAndHashCode", + "lombok.eclipse.handlers.HandleExtensionMethod", + "lombok.eclipse.handlers.HandleFieldNameConstants", + "lombok.eclipse.handlers.HandleGetter", + "lombok.eclipse.handlers.HandleHelper", + "lombok.eclipse.handlers.HandleJacksonized", + "lombok.eclipse.handlers.HandleLocked", + "lombok.eclipse.handlers.HandleLockedRead", + "lombok.eclipse.handlers.HandleLockedWrite", + "lombok.eclipse.handlers.HandleLog$HandleCommonsLog", + "lombok.eclipse.handlers.HandleLog$HandleCustomLog", + "lombok.eclipse.handlers.HandleLog$HandleFloggerLog", + "lombok.eclipse.handlers.HandleLog$HandleJBossLog", + "lombok.eclipse.handlers.HandleLog$HandleJulLog", + "lombok.eclipse.handlers.HandleLog$HandleLog4j2Log", + "lombok.eclipse.handlers.HandleLog$HandleLog4jLog", + "lombok.eclipse.handlers.HandleLog$HandleSlf4jLog", + "lombok.eclipse.handlers.HandleLog$HandleXSlf4jLog", + "lombok.eclipse.handlers.HandleNonNull", + "lombok.eclipse.handlers.HandlePrintAST", + "lombok.eclipse.handlers.HandleSetter", + "lombok.eclipse.handlers.HandleSneakyThrows", + "lombok.eclipse.handlers.HandleStandardException", + "lombok.eclipse.handlers.HandleSuperBuilder", + "lombok.eclipse.handlers.HandleSynchronized", + "lombok.eclipse.handlers.HandleToString", + "lombok.eclipse.handlers.HandleUtilityClass", + "lombok.eclipse.handlers.HandleValue", + "lombok.eclipse.handlers.HandleWith", + "lombok.eclipse.handlers.HandleWithBy" + ], + "lombok.eclipse.handlers.EclipseSingularsRecipes$EclipseSingularizer": [ + "lombok.eclipse.handlers.singulars.EclipseGuavaMapSingularizer", + "lombok.eclipse.handlers.singulars.EclipseGuavaSetListSingularizer", + "lombok.eclipse.handlers.singulars.EclipseGuavaTableSingularizer", + "lombok.eclipse.handlers.singulars.EclipseJavaUtilListSingularizer", + "lombok.eclipse.handlers.singulars.EclipseJavaUtilMapSingularizer", + "lombok.eclipse.handlers.singulars.EclipseJavaUtilSetSingularizer" + ], + "lombok.installer.IdeLocationProvider": [ + "lombok.installer.eclipse.AngularIDELocationProvider", + "lombok.installer.eclipse.EclipseLocationProvider", + "lombok.installer.eclipse.JbdsLocationProvider", + "lombok.installer.eclipse.MyEclipseLocationProvider", + "lombok.installer.eclipse.RhcrLocationProvider", + "lombok.installer.eclipse.RhdsLocationProvider", + "lombok.installer.eclipse.STS4LocationProvider", + "lombok.installer.eclipse.STS5LocationProvider", + "lombok.installer.eclipse.STSLocationProvider" + ], + "lombok.javac.JavacASTVisitor": [ + "lombok.javac.handlers.HandleFieldDefaults", + "lombok.javac.handlers.HandleVal" + ], + "lombok.javac.JavacAnnotationHandler": [ + "lombok.javac.handlers.HandleAccessors", + "lombok.javac.handlers.HandleBuilder", + "lombok.javac.handlers.HandleBuilderDefault", + "lombok.javac.handlers.HandleBuilderDefaultRemove", + "lombok.javac.handlers.HandleBuilderRemove", + "lombok.javac.handlers.HandleCleanup", + "lombok.javac.handlers.HandleConstructor$HandleAllArgsConstructor", + "lombok.javac.handlers.HandleConstructor$HandleNoArgsConstructor", + "lombok.javac.handlers.HandleConstructor$HandleRequiredArgsConstructor", + "lombok.javac.handlers.HandleData", + "lombok.javac.handlers.HandleDelegate", + "lombok.javac.handlers.HandleEqualsAndHashCode", + "lombok.javac.handlers.HandleExtensionMethod", + "lombok.javac.handlers.HandleFieldNameConstants", + "lombok.javac.handlers.HandleGetter", + "lombok.javac.handlers.HandleHelper", + "lombok.javac.handlers.HandleJacksonized", + "lombok.javac.handlers.HandleLocked", + "lombok.javac.handlers.HandleLockedRead", + "lombok.javac.handlers.HandleLockedWrite", + "lombok.javac.handlers.HandleLog$HandleCommonsLog", + "lombok.javac.handlers.HandleLog$HandleCustomLog", + "lombok.javac.handlers.HandleLog$HandleFloggerLog", + "lombok.javac.handlers.HandleLog$HandleJBossLog", + "lombok.javac.handlers.HandleLog$HandleJulLog", + "lombok.javac.handlers.HandleLog$HandleLog4j2Log", + "lombok.javac.handlers.HandleLog$HandleLog4jLog", + "lombok.javac.handlers.HandleLog$HandleSlf4jLog", + "lombok.javac.handlers.HandleLog$HandleXSlf4jLog", + "lombok.javac.handlers.HandleNonNull", + "lombok.javac.handlers.HandlePrintAST", + "lombok.javac.handlers.HandleSetter", + "lombok.javac.handlers.HandleSingularRemove", + "lombok.javac.handlers.HandleSneakyThrows", + "lombok.javac.handlers.HandleStandardException", + "lombok.javac.handlers.HandleSuperBuilder", + "lombok.javac.handlers.HandleSuperBuilderRemove", + "lombok.javac.handlers.HandleSynchronized", + "lombok.javac.handlers.HandleToString", + "lombok.javac.handlers.HandleUtilityClass", + "lombok.javac.handlers.HandleValue", + "lombok.javac.handlers.HandleWith", + "lombok.javac.handlers.HandleWithBy" + ], + "lombok.javac.handlers.JavacSingularsRecipes$JavacSingularizer": [ + "lombok.javac.handlers.singulars.JavacGuavaMapSingularizer", + "lombok.javac.handlers.singulars.JavacGuavaSetListSingularizer", + "lombok.javac.handlers.singulars.JavacGuavaTableSingularizer", + "lombok.javac.handlers.singulars.JavacJavaUtilListSingularizer", + "lombok.javac.handlers.singulars.JavacJavaUtilMapSingularizer", + "lombok.javac.handlers.singulars.JavacJavaUtilSetSingularizer" ] }, "org.springframework.boot:spring-boot": { @@ -2251,16 +10031,32 @@ "org.springframework.boot.logging.log4j2.SpringBootPropertySource" ] }, + "org.springframework.boot:spring-boot-micrometer-tracing-opentelemetry": { + "org.junit.platform.launcher.TestExecutionListener": [ + "org.springframework.boot.micrometer.tracing.opentelemetry.autoconfigure.OpenTelemetryEventPublisherBeansTestExecutionListener" + ] + }, + "org.springframework.data:spring-data-cassandra": { + "jakarta.enterprise.inject.spi.Extension": [ + "org.springframework.data.cassandra.repository.cdi.CassandraRepositoryExtension" + ] + }, + "org.springframework.security:spring-security-core": { + "io.micrometer.context.ThreadLocalAccessor": [ + "org.springframework.security.core.context.ReactiveSecurityContextHolderThreadLocalAccessor", + "org.springframework.security.core.context.SecurityContextHolderThreadLocalAccessor" + ] + }, + "org.springframework.security:spring-security-web": { + "io.micrometer.context.ThreadLocalAccessor": [ + "org.springframework.security.web.server.ServerWebExchangeThreadLocalAccessor" + ] + }, "org.springframework:spring-core": { "reactor.blockhound.integration.BlockHoundIntegration": [ "org.springframework.core.ReactiveAdapterRegistry$SpringCoreBlockHoundIntegration" ] }, - "org.springframework:spring-jcl": { - "org.apache.commons.logging.LogFactory": [ - "org.apache.commons.logging.LogFactoryService" - ] - }, "org.springframework:spring-web": { "jakarta.servlet.ServletContainerInitializer": [ "org.springframework.web.SpringServletContainerInitializer" @@ -2268,7 +10064,91 @@ "reactor.blockhound.integration.BlockHoundIntegration": [ "org.springframework.web.server.adapter.WebHttpHandlerBuilder$SpringWebBlockHoundIntegration" ] + }, + "org.testcontainers:testcontainers": { + "org.testcontainers.dockerclient.DockerClientProviderStrategy": [ + "org.testcontainers.dockerclient.DockerDesktopClientProviderStrategy", + "org.testcontainers.dockerclient.DockerMachineClientProviderStrategy", + "org.testcontainers.dockerclient.EnvironmentAndSystemPropertyClientProviderStrategy", + "org.testcontainers.dockerclient.NpipeSocketClientProviderStrategy", + "org.testcontainers.dockerclient.RootlessDockerClientProviderStrategy", + "org.testcontainers.dockerclient.TestcontainersHostPropertyClientProviderStrategy", + "org.testcontainers.dockerclient.UnixSocketClientProviderStrategy" + ], + "org.testcontainers.shaded.com.fasterxml.jackson.core.JsonFactory": [ + "org.testcontainers.shaded.com.fasterxml.jackson.core.JsonFactory" + ], + "org.testcontainers.shaded.com.fasterxml.jackson.core.ObjectCodec": [ + "org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper" + ] + }, + "org.wiremock:wiremock-standalone": { + "wiremock.com.fasterxml.jackson.core.JsonFactory": [ + "wiremock.com.fasterxml.jackson.core.JsonFactory", + "wiremock.com.fasterxml.jackson.dataformat.yaml.YAMLFactory" + ], + "wiremock.com.fasterxml.jackson.core.ObjectCodec": [ + "wiremock.com.fasterxml.jackson.databind.ObjectMapper", + "wiremock.com.fasterxml.jackson.dataformat.yaml.YAMLMapper" + ], + "wiremock.com.fasterxml.jackson.databind.Module": [ + "wiremock.com.fasterxml.jackson.datatype.jsr310.JavaTimeModule" + ], + "wiremock.org.eclipse.jetty.http.HttpFieldPreEncoder": [ + "wiremock.org.eclipse.jetty.http.Http1FieldPreEncoder", + "wiremock.org.eclipse.jetty.http2.hpack.HpackFieldPreEncoder" + ], + "wiremock.org.eclipse.jetty.io.ssl.ALPNProcessor$Client": [ + "wiremock.org.eclipse.jetty.alpn.java.client.JDK9ClientALPNProcessor" + ], + "wiremock.org.eclipse.jetty.io.ssl.ALPNProcessor$Server": [ + "wiremock.org.eclipse.jetty.alpn.java.server.JDK9ServerALPNProcessor" + ], + "wiremock.org.eclipse.jetty.webapp.Configuration": [ + "wiremock.org.eclipse.jetty.webapp.FragmentConfiguration", + "wiremock.org.eclipse.jetty.webapp.JaasConfiguration", + "wiremock.org.eclipse.jetty.webapp.JaspiConfiguration", + "wiremock.org.eclipse.jetty.webapp.JettyWebXmlConfiguration", + "wiremock.org.eclipse.jetty.webapp.JmxConfiguration", + "wiremock.org.eclipse.jetty.webapp.JndiConfiguration", + "wiremock.org.eclipse.jetty.webapp.JspConfiguration", + "wiremock.org.eclipse.jetty.webapp.MetaInfConfiguration", + "wiremock.org.eclipse.jetty.webapp.ServletsConfiguration", + "wiremock.org.eclipse.jetty.webapp.WebAppConfiguration", + "wiremock.org.eclipse.jetty.webapp.WebInfConfiguration", + "wiremock.org.eclipse.jetty.webapp.WebXmlConfiguration" + ], + "wiremock.org.slf4j.spi.SLF4JServiceProvider": [ + "wiremock.org.slf4j.helpers.NOP_FallbackServiceProvider" + ], + "wiremock.org.xmlunit.placeholder.PlaceholderHandler": [ + "wiremock.org.xmlunit.placeholder.IgnorePlaceholderHandler", + "wiremock.org.xmlunit.placeholder.IsDateTimePlaceholderHandler", + "wiremock.org.xmlunit.placeholder.IsNumberPlaceholderHandler", + "wiremock.org.xmlunit.placeholder.MatchesRegexPlaceholderHandler" + ] + }, + "software.amazon.awssdk:third-party-jackson-core": { + "software.amazon.awssdk.thirdparty.jackson.core.JsonFactory": [ + "software.amazon.awssdk.thirdparty.jackson.core.JsonFactory" + ] + }, + "tools.jackson.core:jackson-core": { + "tools.jackson.core.TokenStreamFactory": [ + "tools.jackson.core.json.JsonFactory" + ] + }, + "tools.jackson.core:jackson-databind": { + "tools.jackson.databind.ObjectMapper": [ + "tools.jackson.databind.json.JsonMapper" + ] + }, + "tools.jackson.module:jackson-module-blackbird": { + "tools.jackson.databind.JacksonModule": [ + "tools.jackson.module.blackbird.BlackbirdModule" + ] } }, + "skipped": [], "version": "3" } diff --git a/rules/java/README.md b/rules/java/README.md index f89ac43e1f..312493bfb2 100644 --- a/rules/java/README.md +++ b/rules/java/README.md @@ -51,9 +51,9 @@ Coordinates are resolved by `rules_jvm_external` and pinned in 1. Add the coordinate to `maven.install(artifacts = [...])` in the root `MODULE.bazel`. -2. Re-pin: `bazel run @maven//:pin` (or `REPIN=1 bazel run @maven//:pin` when +2. Re-pin: `bazel run @nv_third_party_deps//:pin` (or `REPIN=1 bazel run @nv_third_party_deps//:pin` when updating an existing set). -3. Reference it as `@maven//:group_artifact` in `deps`. List every artifact your +3. Reference it as `@nv_third_party_deps//:group_artifact` in `deps`. List every artifact your code imports directly, not just the aggregator starter, so the strict-deps header compiler resolves the symbols. diff --git a/src/libraries/java/nv-boot-parent/.gitignore b/src/libraries/java/nv-boot-parent/.gitignore new file mode 100644 index 0000000000..a4c507f894 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/.gitignore @@ -0,0 +1,44 @@ +# Build +target/ +**/target/ +/bazel-bin +/bazel-nv-boot-parent +/bazel-out +/bazel-testlogs +*.jar +*.war +*.ear +*.class + +# IDE +.idea/ +*.iml +*.ipr +*.iws +.project +.classpath +.settings/ +.vscode/ +.metadata/ +.recommenders/ +*.tern-project + +# OS +.DS_Store +Thumbs.db + +# Maven +pom.xml.tag +*.log + +# Misc +*.jtl +*.pyc +*.swp +node_modules +**/results +**/logs +**/tmp +/nvidia.log +deploy.sh +.editorconfig diff --git a/src/libraries/java/nv-boot-parent/BAZEL.md b/src/libraries/java/nv-boot-parent/BAZEL.md new file mode 100644 index 0000000000..0db1a11d59 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/BAZEL.md @@ -0,0 +1,663 @@ +# Bazel Developer Guide + +This repository supports Bazel alongside Maven during migration. Maven remains +the canonical build and publish path until the team explicitly cuts over. + +## Tooling + +- Bazel: 9.1.1, via `.bazelversion` +- Dependency mode: Bzlmod with `rules_jvm_external` +- Java: 25 +- Python: 3.11 through `rules_python` for Bazel NOTICE actions +- Docker: required for Testcontainers-backed tests such as Cassandra tests + +The Bazel build/test path uses Bazel-managed Java tooling for compilation and +tests. The CI image does not need `java` or `jar` on `PATH` for the opt-in +Bazel build/test job. + +Local command examples use one portable cache location. Set it once per shell: + +```bash +export BAZEL_OUTPUT_USER_ROOT="${TMPDIR:-/tmp}/nv-boot-parent-bazel-cache" +``` + +## Understanding the Dependency Files + +Bazel uses three top-level files for dependency declarations and locking. A +simple way to remember their responsibilities is: + +```text +MODULE.bazel = what this repository wants +maven_install.json = exact third-party Java artifacts that were resolved +MODULE.bazel.lock = exact Bazel modules and module extensions that were resolved +``` + +### `MODULE.bazel` + +Developers edit this file. It declares the repository as a Bazel module and +contains inputs such as: + +- `bazel_dep` entries for Bazel rules and tooling; +- overrides for Bazel source modules when needed; +- Maven-compatible BOMs and third-party Java dependency roots supplied to + `rules_jvm_external`; +- the shared `nv_third_party_deps` hub configuration. + +The hub keeps `fail_on_missing_checksum = True`, so resolution fails rather +than accepting an artifact without a recorded checksum. Do not disable this +globally to work around one repository; prove and document the repository issue +before considering a narrow exception. + +For someone familiar with Maven, this file serves some of the roles of the +root `pom.xml`, dependency management, and build-plugin configuration, but it +is not a POM and does not use Maven parent inheritance. + +### `maven_install.json` + +Do not edit this file manually. `rules_jvm_external` generates it when the +shared third-party hub is pinned. It records the resolved Java artifacts, +transitive dependency relationships, repository locations, checksums, and an +input signature. + +Its name contains `maven` because the external Java artifacts use Maven +coordinates and come from Maven-compatible repositories. It does not run a +Maven build, publish nv-boot artifacts, or make the Bazel outputs +Maven-shaped. Maven does not normally have a direct checked-in equivalent to +this dependency lockfile. + +After changing BOMs, third-party roots, or versions in `MODULE.bazel`, +regenerate it with: + +```bash +REPIN=1 bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ + run @nv_third_party_deps//:pin +``` + +### `MODULE.bazel.lock` + +Do not edit this file manually. Bazel generates and updates it for Bzlmod. It +locks the Bazel module graph and module-extension evaluation used to create +external repositories. For example, it records resolution associated with +rules such as `rules_java` and `rules_jvm_external`; it is not the lockfile for +the Spring, Jackson, or other Java jars listed in `maven_install.json`. + +Normal Bazel commands update this file when Bzlmod inputs change. + +### Commit Rules + +Commit all three files. When a dependency change updates more than one of +them, commit those changes together. The normal workflow is: + +1. Edit `MODULE.bazel`. +2. Repin `maven_install.json` when the third-party Java graph changes. +3. Run the build and tests, allowing Bazel to update `MODULE.bazel.lock`. +4. Review and commit every changed dependency file; never hand-edit either + lockfile. + +Also inspect related library families after repinning. A build tool can upgrade +some modules in a family that production code also uses. For example, JaCoCo +resolves `asm`, `asm-commons`, and `asm-tree` to ASM 9.9 while `jnr-ffi` also +uses `asm-analysis` and `asm-util`. Those two unmanaged modules are explicitly +aligned to 9.9 in `MODULE.bazel` so the shared hub does not contain a partially +upgraded ASM runtime. + +The commands below reuse `BAZEL_OUTPUT_USER_ROOT` so local builds do not fight +with other workspaces. `${TMPDIR:-/tmp}` works on macOS and Linux; the stable +project-specific suffix prevents cache collisions with other repositories. + +## Shared Neutral Dependency Hub + +`nv-boot-parent` exposes public Bazel source targets whose third-party labels +refer to one shared dependency repository: + +```text +@nv_third_party_deps +``` + +The exact configured name is `nv_third_party_deps`, with underscores. Every +Bazel application that consumes nv-boot targets must use this same name for its +`rules_jvm_external` install. A different name, including +`nv-third-party-deps`, creates a different repository and does not satisfy the +labels exposed by nv-boot. + +The name is intentionally neutral: + +- it describes a hub of third-party dependencies, not Maven publication; +- it contains Spring, Jackson, gRPC, Guava, and other external jar targets; +- it does not contain nv-boot libraries or application-owned libraries; +- nv-boot and application code remain first-party Bazel source targets. + +A downstream application's `MODULE.bazel` should follow this shape: + +```python +maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") +maven.install( + name = "nv_third_party_deps", + artifacts = [ + # Application-owned third-party roots only. + ], + boms = [ + # Application-owned BOMs, including the Spring Boot BOM as needed. + ], + known_contributing_modules = [ + "nv_boot_parent", + # Other Bazel modules that contribute to this same hub. + ], + lock_file = "//:maven_install.json", + ... +) +use_repo(maven, "nv_third_party_deps") +``` + +Bzlmod and `rules_jvm_external` then merge the upstream and application +contributions into one resolved graph. If an application creates a second hub, +its executable jar can contain duplicate or version-skewed libraries. Cloud +Tasks demonstrated this failure mode with an incompatible gRPC mix and a +218 MB two-hub app; the single shared hub produced the expected 142 MB app. + +After changing third-party roots or versions, repin the shared hub: + +```bash +REPIN=1 bazel --output_user_root="${TMPDIR:-/tmp}/-bazel-cache" \ + run @nv_third_party_deps//:pin +``` + +## Clean + +```bash +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" clean +``` + +Use `bazel clean --expunge` only when you intentionally want to discard the +whole cache. + +## Build + +Build the entire repository: + +```bash +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" build //... +``` + +Build one module: + +```bash +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ + build //nv-boot-starter-core:all +``` + +Build one module's Bazel-native Java library target: + +```bash +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ + build //nv-boot-starter-core:nv_boot_starter_core +``` + +The compiled library jar is written under `bazel-bin//`, for example: + +```text +bazel-bin/nv-boot-starter-core/libnv_boot_starter_core.jar +``` + +The Bazel path does not generate POMs, Maven-named jars, sources jars for Maven +publication, or local Maven install scripts. + +## Test + +Run all tests without reusing cached test results: + +```bash +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ + test //... \ + --cache_test_results=no \ + --test_output=errors +``` + +Run all tests and stream logs to the terminal: + +```bash +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ + test //... \ + --cache_test_results=no \ + --test_output=streamed +``` + +Run one module's tests: + +```bash +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ + test //nv-boot-starter-core:tests \ + --cache_test_results=no \ + --test_output=errors +``` + +Run one test class: + +```bash +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ + test //nv-boot-starter-core:tests \ + --cache_test_results=no \ + --test_output=streamed \ + --test_arg='--exclude-classname=^(?!com\.nvidia\.boot\.core\.env\.BootCoreEnvironmentPostProcessorTest$).*' +``` + +Run one test method: + +```bash +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ + test //nv-boot-starter-core:tests \ + --cache_test_results=no \ + --test_output=streamed \ + --test_arg='--exclude-classname=^(?!com\.nvidia\.boot\.core\.env\.BootCoreEnvironmentPostProcessorTest$).*' \ + --test_arg='--include-methodname=.*loadsDefaultPropertiesWhenPresent.*' +``` + +Do not use JUnit `--select-class` or `--select-method` with these test targets. +The shared test macro already uses JUnit ConsoleLauncher classpath scanning, and +JUnit does not allow explicit selectors and classpath scanning at the same time. +`--test_filter` is also not the preferred path for these targets because the +test macro runs JUnit ConsoleLauncher directly. + +Test logs are under: + +```text +bazel-testlogs//tests/test.log +bazel-testlogs//tests/test.outputs/junit/TEST-junit-jupiter.xml +``` + +The Jupiter XML contains the real Java testcases and is the report published by +GitLab. The nearby `bazel-testlogs//tests/test.xml` describes Bazel's +single outer `sh_test` wrapper and must not be used as the JUnit report. + +## Coverage + +Bazel test targets generate JaCoCo reports as part of ordinary `bazel test` +runs. The shared `nv_boot_library_test` macro attaches the JaCoCo agent, runs +JUnit, and writes HTML/XML reports into Bazel's preserved test output +directory. + +Run one module's tests and generate its JaCoCo report: + +```bash +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ + test //nv-boot-starter-core:tests \ + --cache_test_results=no \ + --test_output=errors +``` + +Open the generated HTML report: + +```text +bazel-testlogs/nv-boot-starter-core/tests/test.outputs/index.html +``` + +The same test output directory also contains: + +```text +bazel-testlogs/nv-boot-starter-core/tests/test.outputs/jacoco.xml +bazel-testlogs/nv-boot-starter-core/tests/test.outputs/jacoco.exec +``` + +Run all module tests and generate one JaCoCo report per test target: + +```bash +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ + test //... \ + --cache_test_results=no \ + --test_output=errors +``` + +Per-module reports are written under each test target's output directory: + +```text +bazel-testlogs//tests/test.outputs/junit/TEST-junit-jupiter.xml +bazel-testlogs//tests/test.outputs/index.html +bazel-testlogs//tests/test.outputs/jacoco.xml +bazel-testlogs//tests/test.outputs/jacoco.exec +``` + +For CI/Sonar Java coverage, publish the generated `jacoco.xml` files and pass +them to Sonar with: + +```text +sonar.coverage.jacoco.xmlReportPaths= +``` + +Bazel's native coverage command does not collect meaningful Java coverage from +`nv_boot_library_test` targets because the macro uses `use_testrunner = False` +and runs JUnit ConsoleLauncher directly. For this repository, use the JaCoCo XML +files above for Sonar coverage integration. + +The native coverage command is still useful if the workspace later contains +standard Bazel `java_test` targets. In that case, run: + +```bash +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ + coverage //... \ + --cache_test_results=no \ + --test_output=errors \ + --combined_report=lcov \ + --instrumentation_filter=//nv-boot +``` + +For standard `java_test` targets, the combined LCOV report is written to: + +```text +bazel-out/_coverage/_coverage_report.dat +``` + +Convert the combined LCOV report to SonarQube generic coverage XML: + +```bash +python3 tools/bazel/lcov_to_sonar_generic.py \ + --input bazel-out/_coverage/_coverage_report.dat \ + --output "${TMPDIR:-/tmp}/nv-boot-parent-sonar-coverage.xml" +``` + +For Sonar generic coverage wiring, publish the generated XML as a workspace +artifact and pass it to Sonar with: + +```text +sonar.coverageReportPaths= +``` + +## License And Notice + +During Maven/Bazel coexistence, the root `NOTICE` file remains the canonical +third-party notice artifact. It is generated by Bazel-native tooling from: + +- explicit production dependency roots in `tools/bazel/notice_roots.json`; +- the resolved dependency graph in `maven_install.json`; +- checked-in upstream artifact metadata in + `tools/bazel/notice_metadata.json`. + +The root list intentionally excludes test-only and provided dependencies. The +generator excludes first-party `com.nvidia.boot` artifacts and does not call +Maven, `license-maven-plugin`, or read project `pom.xml` files. + +Bazel invokes the generator through the `//tools/bazel:generate_notice_tool` +`py_binary`, so build actions use the Bazel-declared Python runtime instead of +looking up host `python3`. Explicit roots missing from `maven_install.json` are +errors. Application runtime scans also reject a packaged jar when its path +version differs from the lockfile version. + +Refresh `NOTICE` and the checked-in artifact metadata after dependency changes: + +```bash +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ + run //:generate_notice -- --update-metadata --write +``` + +The `--update-metadata` path reads upstream artifact POMs from the local Maven +cache first, then from the repositories configured in `maven_install.json` when +needed. Commit both `NOTICE` and `tools/bazel/notice_metadata.json` after a +refresh. + +Run a developer check without changing files: + +```bash +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ + run //:generate_notice -- --check +``` + +Validate the checked-in `NOTICE` file: + +```bash +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ + test //tools/bazel:notice_check_test \ + --cache_test_results=no \ + --test_output=errors +``` + +Build a Bazel output copy for CI artifact collection: + +```bash +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ + build //:third_party_notice +``` + +The generated copy is written to: + +```text +bazel-bin/THIRD_PARTY_NOTICE +``` + +This is not a `rules_license` integration. The build and test path is stable +because the human-readable license/name/homepage metadata is checked in, while +the explicit refresh command updates that metadata from upstream Maven artifact +POMs when dependencies change. + +## CI Opt-In + +Maven remains the default CI build and publish path during coexistence. Bazel CI +is opt-in with: + +```yaml +variables: + ENABLE_BAZEL_BUILD: "true" +``` + +The CI handoff and GitLab job shape are documented in +`bazel-enablement/ci-bazel-handoff.md`. This branch also has a project-local +`.gitlab-ci.yml` trial job named `bazel-build-test` guarded by that flag. +Bazel CI builds and tests the Bazel target graph; it does not publish +Maven-shaped artifacts to URM/Artifactory. + +For CI jobs that run Testcontainers-backed tests, pass Docker environment +variables into Bazel's restricted test environment: + +```bash +bazel --output_user_root=/tmp/nv-boot-parent-bazel-cache \ + test \ + --cache_test_results=no \ + --test_output=errors \ + --test_env=DOCKER_HOST \ + --test_env=DOCKER_TLS_VERIFY \ + --test_env=DOCKER_TLS_CERTDIR \ + --test_env=DOCKER_CERT_PATH \ + //... +``` + +## Build And Test Like `mvn clean install` + +For a Maven-like local validation loop: + +```bash +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" clean + +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ + test //... \ + --cache_test_results=no \ + --test_output=errors + +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ + build //... +``` + +`bazel test //...` runs tests and builds what those tests need. Run +`bazel build //...` when you also want all non-test Bazel outputs, including +the module library jars and generated third-party NOTICE. + +## Maven Coexistence + +Do not publish Maven-shaped jars from the Bazel toolchain. We briefly built and +validated that bridge, but it was the wrong long-term direction: it made Bazel +pretend to be Maven instead of letting Bazel consumers use Bazel source targets. + +During coexistence: + +- Maven remains the canonical remote publish path for Maven consumers. +- Bazel build/test remains the canonical path for Bazel consumers. +- Downstream Bazel applications should consume `nv-boot-parent` through Bzlmod + source dependencies, such as `git_override`, not through URM Maven artifacts. +- Bazel does not generate, install, or publish Maven-shaped project artifacts. + +## Dependency Updates + +When a module needs a new external dependency: + +1. Add the dependency close to the module that uses it, in that module's + `BUILD.bazel`. +2. Add the coordinate to `MODULE.bazel` if Bazel does not already resolve it. +3. Prefer versionless coordinates when a BOM manages the version. +4. Add an explicit version only for intentional pins or CVE overrides. +5. If the dependency is shipped by a starter, add its direct production root + to `tools/bazel/notice_roots.json`. +6. Repin and validate: + +```bash +REPIN=1 bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ + run @nv_third_party_deps//:pin + +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ + test //tools/bazel:notice_check_test \ + --cache_test_results=no \ + --test_output=errors +``` + +Keep direct build, test, NOTICE, and tool dependencies as explicit +`MODULE.bazel` roots when that makes ownership clearer than relying on an +unrelated transitive path. + +## Adding A New Module + +For a new nv-boot module during coexistence: + +1. Add the Maven module to root `pom.xml`. +2. Add the module's Maven `pom.xml`. +3. Add the module to `nv-boot-bom/pom.xml` if downstream apps should get its + version through the BOM. +4. Add `new-module/BUILD.bazel`. +5. Add `nv_boot_library(...)`. +6. Add `nv_boot_library_test(...)` if the module has tests, with + `coverage_library` set to the module's `nv_boot_library(...)` target. +7. Add any new shipped third-party roots to + `tools/bazel/notice_roots.json`. +8. Update migration docs or release docs if they list modules explicitly. + +For an internal-only Maven module, skip the Maven BOM entry. Its Bazel target +is still an ordinary source library target. + +## Reusable Bazel Enablement Skills + +The reusable Codex Bazel enablement skills are versioned in this repo at: + +```text +bazel-enablement/skills/maven-parent-bazel-enablement +bazel-enablement/skills/spring-boot-app-bazel-enablement +``` + +Keep these repo-owned copies centralized in `nv-boot-parent`. Application +repositories should reference the appropriate skill in their handoff docs, +but should not duplicate the skill directory. + +Use `maven-parent-bazel-enablement` for parent/aggregator and shared-library +repositories such as nv-boot-parent or nv-boot-managed-parent. Use +`spring-boot-app-bazel-enablement` for application reactors such as Cloud Tasks +or cloud-functions that must build/test libraries, package an executable app, +generate runtime NOTICE, and validate Docker/CI. + +Codex loads the installed runtime copy from: + +```text +$HOME/.codex/skills/maven-parent-bazel-enablement +$HOME/.codex/skills/spring-boot-app-bazel-enablement +``` + +Treat the repo copy as the source of truth. After updating and committing the +repo copy, sync it into the installed skill location: + +```bash +rsync -a --delete \ + bazel-enablement/skills/maven-parent-bazel-enablement/ \ + "${HOME}/.codex/skills/maven-parent-bazel-enablement/" + +rsync -a --delete \ + bazel-enablement/skills/spring-boot-app-bazel-enablement/ \ + "${HOME}/.codex/skills/spring-boot-app-bazel-enablement/" +``` + +Verify the two copies are aligned: + +```bash +diff -ru \ + bazel-enablement/skills/maven-parent-bazel-enablement \ + "${HOME}/.codex/skills/maven-parent-bazel-enablement" + +diff -ru \ + bazel-enablement/skills/spring-boot-app-bazel-enablement \ + "${HOME}/.codex/skills/spring-boot-app-bazel-enablement" +``` + +Until this is automated, periodically sync repo to installed copy after skill +changes so future Bazel enablement work, such as `nv-boot-managed-parent`, uses +the latest validated workflow. + +## Bazel-Native Status + +Migration rule of thumb: prefer Bazel-native generation when it removes Maven +CLI reliance, duplicated dependency truth, or CI ambiguity. Prefer boring, +standard JDK or shell tooling when it is obvious, maintainable, and not a Maven +bridge. + +Most of the migration work is Bazel-native: + +- build and test use Bazel Java targets and `nv_boot_library_test`, not + `maven-surefire-plugin`; +- coverage is generated by the Bazel test wrapper, not `jacoco-maven-plugin`; +- License/NOTICE generation uses `tools/bazel/notice_roots.json`, + `maven_install.json`, and `tools/bazel/notice_metadata.json`, not + `license-maven-plugin`; +- module library jars are ordinary Bazel Java outputs; +- POM generation, Maven-shaped artifact creation, local Maven installation, + and remote Maven deployment are absent from the Bazel toolchain. + +The latest dependency-root audit intentionally kept versionless Spring, +Spring Cloud, Micrometer, Jakarta, Jackson, and similar coordinates when they +are direct build, test, NOTICE, or tool roots. Those entries are not +version pins; their versions still come from the imported BOMs. The cleanup +target is duplicate version ownership, not hiding direct roots behind unrelated +transitive paths. + +Coverage uses the JaCoCo agent and its supported CLI without custom Java +toolchain code: + +- the agent uses `dumponexit=true` and writes `jacoco.exec` when the JUnit JVM + exits; +- the Bazel test wrapper preserves the JUnit exit status and then invokes the + JaCoCo CLI to generate HTML and XML reports; +- reports remain under + `bazel-testlogs//tests/test.outputs`; +- focused test selection and ordinary console logging continue to use the + JUnit Platform Console Launcher. + +Remote deploy through Bazel has been removed. Maven remains the remote publish +path for Maven consumers, and Bazel-native consumers should use source targets. + +Maven still owns parent/BOM publication during coexistence: + +- root `pom.xml` is still published as `com.nvidia.boot:nv-boot-parent:pom`; +- `nv-boot-bom/pom.xml` is still published as + `com.nvidia.boot:nv-boot-bom:pom`; +- Bazel has no corresponding POM or artifact targets. + +After Maven consumers have migrated, remove the Maven POM/publication path as a +separate cutover decision. Do not recreate it inside Bazel. + +## Current Gaps + +- Maven remains the canonical publishing path during coexistence. +- Downstream Maven consumption from URM has been validated with `cloud-tasks` + using version `15665e3b`. +- The Bazel remote publish/deploy bridge was removed after validation because + the target Bazel-native model is source-target consumption, not URM Maven + artifact consumption. +- Parent/BOM POM artifacts still come from the Maven build and checked-in + `pom.xml` files during coexistence. +- A project-local opt-in Bazel CI job is available; the shared CI template has + not been updated yet. +- License/NOTICE is covered by Bazel-native generation and checks from + `maven_install.json`, explicit production roots, and checked-in artifact + metadata. A formal `rules_license` integration is not implemented. +- Downstream Spring Boot executable app packaging belongs to downstream app + migration, not to `nv-boot-parent`. diff --git a/src/libraries/java/nv-boot-parent/BUILD.bazel b/src/libraries/java/nv-boot-parent/BUILD.bazel new file mode 100644 index 0000000000..3c9cae9421 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/BUILD.bazel @@ -0,0 +1,50 @@ +package(default_visibility = ["//visibility:public"]) + +exports_files(["NOTICE"]) + +genrule( + name = "third_party_notice", + srcs = [ + "//:maven_install.json", + "//src/libraries/java/nv-boot-parent/tools/bazel:notice_metadata.json", + "//src/libraries/java/nv-boot-parent/tools/bazel:notice_roots.json", + ], + tools = ["//src/libraries/java/nv-boot-parent/tools/bazel:generate_notice_tool"], + outs = ["THIRD_PARTY_NOTICE"], + cmd = """ +set -eu +"$(execpath //src/libraries/java/nv-boot-parent/tools/bazel:generate_notice_tool)" \ + --maven-install "$(location //:maven_install.json)" \ + --metadata "$(location //src/libraries/java/nv-boot-parent/tools/bazel:notice_metadata.json)" \ + --root-manifest "$(location //src/libraries/java/nv-boot-parent/tools/bazel:notice_roots.json)" \ + --output "$@" \ + --write +""", +) + +genrule( + name = "generate_notice", + srcs = [ + "//:maven_install.json", + "//src/libraries/java/nv-boot-parent/tools/bazel:notice_metadata.json", + "//src/libraries/java/nv-boot-parent/tools/bazel:notice_roots.json", + ], + tools = ["//src/libraries/java/nv-boot-parent/tools/bazel:generate_notice_tool"], + outs = ["generate_notice.sh"], + cmd = """ +cat > "$@" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +workspace="$${BUILD_WORKSPACE_DIRECTORY:-$$(pwd)}" +exec "$(execpath //src/libraries/java/nv-boot-parent/tools/bazel:generate_notice_tool)" \ + --maven-install "$(location //:maven_install.json)" \ + --metadata "$${workspace}/tools/bazel/notice_metadata.json" \ + --notice "$${workspace}/NOTICE" \ + --root-manifest "$(location //src/libraries/java/nv-boot-parent/tools/bazel:notice_roots.json)" \ + "$$@" +EOF +chmod +x "$@" +""", + executable = True, +) diff --git a/src/libraries/java/nv-boot-parent/CODE_OF_CONDUCT.md b/src/libraries/java/nv-boot-parent/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..02e18e123d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/CODE_OF_CONDUCT.md @@ -0,0 +1,42 @@ +# Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery, and sexual attention or advances +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information without explicit permission +- Other conduct which could reasonably be considered inappropriate + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the project maintainers. All complaints will be reviewed and +investigated promptly and fairly. + +## Attribution + +This Code of Conduct is adapted from the +[Contributor Covenant](https://www.contributor-covenant.org), version 2.1. diff --git a/src/libraries/java/nv-boot-parent/CONTRIBUTING.md b/src/libraries/java/nv-boot-parent/CONTRIBUTING.md new file mode 100644 index 0000000000..10122fa187 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/CONTRIBUTING.md @@ -0,0 +1,102 @@ +# Contributing to `nv-boot-parent` + +Thank you for your interest in contributing to this project. We welcome +contributions from the community. Please read these guidelines before +submitting a pull request. + +## Developer Certificate of Origin (DCO) + +All contributions must be signed off with the Developer Certificate of Origin. +By adding a `Signed-off-by` line to your commits you certify that you have the +right to submit the contribution under the project license. + +```bash +git commit -s -m "feat: your commit message" +``` + +This adds a line like: + +``` +Signed-off-by: Your Name +``` + +## How to Contribute + +1. **Fork** the repository and create your branch from `main`. +2. **Make your changes** and add tests where applicable. +3. **Ensure tests pass** (for example `mvn verify` or `mvn test`). +4. **Sign your commits** with `git commit -s`. +5. **Submit a pull request** with a clear description of the change. + +## Reporting Issues + +Please use the project’s issue tracker to report bugs or request features. +Include as much detail as possible. + +## Java code style + +This document complements the project’s Maven configuration (compiler settings, +Checkstyle, Spotless, etc.) when those tools are enabled. + +### Copyright and license headers + +- **Java sources** must use the block comment (`/* … */`) copyright header prescribed by NVIDIA Legal for Apache 2.0. See the [Apache 2.0](https://nvidia.atlassian.net/wiki/spaces/LEG/pages/2417590704/Apache+2.0) Confluence page for the authoritative text. +- **`.properties` files** must use the `#`-prefixed header from the same page. +- **`pom.xml` files** must use XML comments (``) with the same legal text, placed immediately after the `` declaration when present. +- **`META-INF/spring/*.imports`** files (including `org.springframework.boot.autoconfigure.AutoConfiguration.imports`) must use the `#`-prefixed header from the same page. +- If Legal updates the template, apply the new wording project-wide (new and touched files at minimum). + +### Language and platform + +- Target the **Java** and **Spring Boot** versions declared in the root POM. Prefer APIs available on that baseline; avoid preview features unless the build explicitly enables them and the team agrees. + +### Style and formatting + +- Follow the formatting rules enforced by the build (e.g. Spotless, formatter plugin). When in doubt, match existing modules in this repo. +- Use **4 spaces** for indentation in Java unless the formatter dictates otherwise. +- Prefer **explicit imports** over star imports; keep imports ordered consistently with the rest of the tree. +- **Line length**: stay within the limit enforced by Checkstyle/Spotless (or ~100–120 columns if no tool is configured), breaking only where readability improves. + +### Naming and structure + +- **Packages**: lowercase, no underscores; align with existing `com.nvidia.nv.boot.*` layout. +- **Classes**: `PascalCase`; methods and fields: `camelCase`; constants: `UPPER_SNAKE_CASE`. +- One top-level **public** class per file, named after the file. +- Keep classes **focused**: prefer small types and composition over large “god” classes. + +### Spring Boot and dependency injection + +- Prefer **constructor injection** for required collaborators; use `@Autowired` on the constructor when the class is a Spring-managed bean with multiple dependencies. +- Avoid field injection for mandatory dependencies unless there is a documented reason (e.g. framework limitations). +- Configuration properties should use typed `@ConfigurationProperties` where appropriate, with validation (`@Validated`, `@NotNull`, etc.) when values are required. + +### APIs and visibility + +- Minimize **public** surface area: prefer package-private or internal types until an API is intentionally stable. +- Use **Javadoc** on public types and non-obvious public methods—describe behavior, parameters, return values, and thrown exceptions, not redundant restatements of the method name. + +### Error handling and logging + +- Throw **specific, meaningful** exceptions; avoid bare `Exception` or `RuntimeException` for domain errors when a dedicated type exists or should exist. +- Log at **appropriate levels** (`error` for failures requiring attention, `warn` for recoverable issues, `debug`/`trace` for diagnostics). Do not log secrets or full payloads that may contain PII unless required and approved. + +### Testing + +- Add or update **unit tests** for behavior changes; use the same testing stack as sibling modules (JUnit 5, Mockito, Spring Test, etc.). +- Prefer **readable** test names (`methodConditionExpected`) and **Arrange–Act–Assert** structure. + +### Security and robustness + +- Do not hardcode **secrets**; use configuration or secret management appropriate to the deployment environment. +- Validate **external input** at boundaries (HTTP, messaging, file uploads). +- Be careful with **serialization** and reflection on user-controlled data; follow Spring and Jackson safe defaults. + +### Pull requests + +- Keep changes **scoped** to the task; avoid unrelated refactors in the same PR. +- Update **documentation** and **tests** when behavior or public APIs change. + +## License + +By contributing to this project, you agree that your contributions will be +licensed under the [Apache License 2.0](LICENSE). diff --git a/src/libraries/java/nv-boot-parent/LICENSE b/src/libraries/java/nv-boot-parent/LICENSE new file mode 100644 index 0000000000..decdb6aaff --- /dev/null +++ b/src/libraries/java/nv-boot-parent/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 NVIDIA CORPORATION & AFFILIATES + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/src/libraries/java/nv-boot-parent/NOTICE b/src/libraries/java/nv-boot-parent/NOTICE new file mode 100644 index 0000000000..ca15f90d38 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/NOTICE @@ -0,0 +1,210 @@ + +Lists of 208 third-party dependencies. + (Public Domain) AOP alliance (aopalliance:aopalliance:1.0 - http://aopalliance.sourceforge.net) + (Apache License, Version 2.0) LZ4 Java Compression (at.yawk.lz4:lz4-java:1.10.3 - https://github.com/yawkat/lz4-java) + (EPL-2.0) (LGPL-2.1-only) Logback Classic Module (ch.qos.logback:logback-classic:1.5.34 - http://logback.qos.ch) + (EPL-2.0) (LGPL-2.1-only) Logback Core Module (ch.qos.logback:logback-core:1.5.34 - http://logback.qos.ch) + (Apache 2) An implementation of the Apache Cassandra® native protocol (com.datastax.oss:native-protocol:1.5.2 - https://github.com/datastax/native-protocol) + (The Apache Software License, Version 2.0) Jackson-annotations (com.fasterxml.jackson.core:jackson-annotations:2.21 - https://github.com/FasterXML/jackson) + (The Apache Software License, Version 2.0) Jackson-core (com.fasterxml.jackson.core:jackson-core:2.21.4 - https://github.com/FasterXML/jackson-core) + (The Apache Software License, Version 2.0) jackson-databind (com.fasterxml.jackson.core:jackson-databind:2.21.4 - https://github.com/FasterXML/jackson) + (The Apache Software License, Version 2.0) Jackson-dataformat-YAML (com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.21.4 - https://github.com/FasterXML/jackson-dataformats-text) + (The Apache Software License, Version 2.0) Jackson datatype: JSR310 (com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.21.4 - https://github.com/FasterXML/jackson-modules-java8) + (Apache License, Version 2.0) ClassMate (com.fasterxml:classmate:1.7.3 - https://github.com/FasterXML/java-classmate) + (Apache License, Version 2.0) Caffeine cache (com.github.ben-manes.caffeine:caffeine:3.2.4 - https://github.com/ben-manes/caffeine) + (Apache License, Version 2.0) Caffeine cache (com.github.ben-manes.caffeine:guava:3.2.4 - https://github.com/ben-manes/caffeine) + (Lesser General Public License, version 3 or greater) (Apache Software License, version 2.0) btf (com.github.java-json-tools:btf:1.3 - https://github.com/java-json-tools/btf) + (Lesser General Public License, version 3 or greater) (Apache Software License, version 2.0) jackson-coreutils (com.github.java-json-tools:jackson-coreutils:2.0 - https://github.com/java-json-tools/jackson-coreutils) + (Lesser General Public License, version 3 or greater) (Apache Software License, version 2.0) json-patch (com.github.java-json-tools:json-patch:1.13 - https://github.com/java-json-tools/json-patch) + (Lesser General Public License, version 3 or greater) (Apache Software License, version 2.0) msg-simple (com.github.java-json-tools:msg-simple:1.2 - https://github.com/java-json-tools/msg-simple) + (The Apache Software License, Version 2.0) jffi (com.github.jnr:jffi:1.2.16 - http://github.com/jnr/jffi) + (The Apache Software License, Version 2.0) jnr-constants (com.github.jnr:jnr-constants:0.10.3 - http://github.com/jnr/jnr-constants) + (The Apache Software License, Version 2.0) jnr-ffi (com.github.jnr:jnr-ffi:2.1.7 - http://github.com/jnr/jnr-ffi) + (Eclipse Public License - v 2.0) (GNU General Public License Version 2) (GNU Lesser General Public License Version 2.1) jnr-posix (com.github.jnr:jnr-posix:3.1.15 - http://nexus.sonatype.org/oss-repository-hosting.html) + (MIT License) jnr-x86asm (com.github.jnr:jnr-x86asm:1.0.2 - http://github.com/jnr/jnr-x86asm) + (Apache License, Version 2.0) JCIP Annotations under Apache License (com.github.stephenc.jcip:jcip-annotations:1.0-1 - http://stephenc.github.com/jcip-annotations) + (The Apache Software License, Version 2.0) FindBugs-jsr305 (com.google.code.findbugs:jsr305:2.0.1 - http://findbugs.sourceforge.net/) + (Apache 2.0) error-prone annotations (com.google.errorprone:error_prone_annotations:2.49.0 - https://errorprone.info) + (Apache License, Version 2.0) Guava InternalFutureFailureAccess and InternalFutures (com.google.guava:failureaccess:1.0.3 - https://github.com/google/guava) + (Apache License, Version 2.0) Guava: Google Core Libraries for Java (com.google.guava:guava:33.6.0-jre - https://github.com/google/guava) + (The Apache Software License, Version 2.0) Guava ListenableFuture only (com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava - https://github.com/google/guava) + (Apache License, Version 2.0) J2ObjC Annotations (com.google.j2objc:j2objc-annotations:3.1 - https://github.com/google/j2objc/) + (The Apache Software License, Version 2.0) Nimbus Content Type (com.nimbusds:content-type:2.3 - https://bitbucket.org/connect2id/nimbus-content-type) + (The Apache Software License, Version 2.0) Nimbus LangTag (com.nimbusds:lang-tag:1.7 - https://bitbucket.org/connect2id/nimbus-language-tags) + (The Apache Software License, Version 2.0) Nimbus JOSE+JWT (com.nimbusds:nimbus-jose-jwt:10.4 - https://bitbucket.org/connect2id/nimbus-jose-jwt) + (Apache License, version 2.0) OAuth 2.0 SDK with OpenID Connect extensions (com.nimbusds:oauth2-oidc-sdk:11.26.1 - https://bitbucket.org/connect2id/oauth-2.0-sdk-with-openid-connect-extensions) + (The Apache Software License, Version 2.0) okhttp (com.squareup.okhttp3:okhttp-jvm:5.2.1 - https://square.github.io/okhttp/) + (The Apache Software License, Version 2.0) okio (com.squareup.okio:okio-jvm:3.16.1 - https://github.com/square/okio/) + (Apache-2.0) config (com.typesafe:config:1.4.1 - https://github.com/lightbend/config) + (Apache-2.0) Apache Commons Codec (commons-codec:commons-codec:1.19.0 - https://commons.apache.org/proper/commons-codec/) + (Apache-2.0) Apache Commons Logging (commons-logging:commons-logging:1.3.6 - https://commons.apache.org/proper/commons-logging/) + (The Apache Software License, Version 2.0) CloudEvents - API (io.cloudevents:cloudevents-api:4.1.1 - https://cloudevents.github.io/sdk-java/) + (The Apache Software License, Version 2.0) CloudEvents - Core (io.cloudevents:cloudevents-core:4.1.1 - https://cloudevents.github.io/sdk-java/) + (The Apache Software License, Version 2.0) CloudEvents - JSON Jackson (io.cloudevents:cloudevents-json-jackson:4.1.1 - https://cloudevents.github.io/sdk-java/) + (The Apache Software License, Version 2.0) context-propagation (io.micrometer:context-propagation:1.2.1 - https://github.com/micrometer-metrics/context-propagation) + (The Apache Software License, Version 2.0) micrometer-commons (io.micrometer:micrometer-commons:1.16.6 - https://github.com/micrometer-metrics/micrometer) + (The Apache Software License, Version 2.0) micrometer-core (io.micrometer:micrometer-core:1.16.6 - https://github.com/micrometer-metrics/micrometer) + (The Apache Software License, Version 2.0) micrometer-jakarta9 (io.micrometer:micrometer-jakarta9:1.16.6 - https://github.com/micrometer-metrics/micrometer) + (The Apache Software License, Version 2.0) micrometer-observation (io.micrometer:micrometer-observation:1.16.6 - https://github.com/micrometer-metrics/micrometer) + (The Apache Software License, Version 2.0) micrometer-tracing (io.micrometer:micrometer-tracing:1.6.6 - https://github.com/micrometer-metrics/tracing) + (The Apache Software License, Version 2.0) micrometer-tracing-bridge-otel (io.micrometer:micrometer-tracing-bridge-otel:1.6.6 - https://github.com/micrometer-metrics/tracing) + (The Apache License, Version 2.0) jnats (io.nats:jnats:2.23.0 - https://github.com/nats-io/nats.java) + (Apache License, Version 2.0) Netty/Buffer (io.netty:netty-buffer:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Codec/Base (io.netty:netty-codec-base:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Codec/Classes/Quic (io.netty:netty-codec-classes-quic:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Codec/Compression (io.netty:netty-codec-compression:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Codec/DNS (io.netty:netty-codec-dns:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Codec/HTTP (io.netty:netty-codec-http:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Codec/HTTP2 (io.netty:netty-codec-http2:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Codec/Http3 (io.netty:netty-codec-http3:4.2.15.Final - https://netty.io/netty-codec-http3/) + (Apache License, Version 2.0) Netty/Codec/Socks (io.netty:netty-codec-socks:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Common (io.netty:netty-common:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Handler (io.netty:netty-handler:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Handler/Proxy (io.netty:netty-handler-proxy:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Resolver (io.netty:netty-resolver:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Resolver/DNS (io.netty:netty-resolver-dns:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Transport (io.netty:netty-transport:4.2.15.Final - https://netty.io/) + (Apache License, Version 2.0) Netty/Transport/Native/Unix/Common (io.netty:netty-transport-native-unix-common:4.2.15.Final - https://netty.io/) + (The Apache License, Version 2.0) OpenTelemetry Semantic Conventions Java (io.opentelemetry.semconv:opentelemetry-semconv:1.37.0 - https://github.com/open-telemetry/semantic-conventions-java) + (The Apache License, Version 2.0) OpenTelemetry Java (io.opentelemetry:opentelemetry-api:1.55.0 - https://github.com/open-telemetry/opentelemetry-java) + (The Apache License, Version 2.0) OpenTelemetry Java (io.opentelemetry:opentelemetry-common:1.55.0 - https://github.com/open-telemetry/opentelemetry-java) + (The Apache License, Version 2.0) OpenTelemetry Java (io.opentelemetry:opentelemetry-context:1.55.0 - https://github.com/open-telemetry/opentelemetry-java) + (The Apache License, Version 2.0) OpenTelemetry Java (io.opentelemetry:opentelemetry-exporter-common:1.55.0 - https://github.com/open-telemetry/opentelemetry-java) + (The Apache License, Version 2.0) OpenTelemetry Java (io.opentelemetry:opentelemetry-exporter-otlp:1.55.0 - https://github.com/open-telemetry/opentelemetry-java) + (The Apache License, Version 2.0) OpenTelemetry Java (io.opentelemetry:opentelemetry-exporter-otlp-common:1.55.0 - https://github.com/open-telemetry/opentelemetry-java) + (The Apache License, Version 2.0) OpenTelemetry Java (io.opentelemetry:opentelemetry-exporter-sender-okhttp:1.55.0 - https://github.com/open-telemetry/opentelemetry-java) + (The Apache License, Version 2.0) OpenTelemetry Java (io.opentelemetry:opentelemetry-extension-trace-propagators:1.55.0 - https://github.com/open-telemetry/opentelemetry-java) + (The Apache License, Version 2.0) OpenTelemetry Java (io.opentelemetry:opentelemetry-sdk:1.55.0 - https://github.com/open-telemetry/opentelemetry-java) + (The Apache License, Version 2.0) OpenTelemetry Java (io.opentelemetry:opentelemetry-sdk-common:1.55.0 - https://github.com/open-telemetry/opentelemetry-java) + (The Apache License, Version 2.0) OpenTelemetry Java (io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:1.55.0 - https://github.com/open-telemetry/opentelemetry-java) + (The Apache License, Version 2.0) OpenTelemetry Java (io.opentelemetry:opentelemetry-sdk-logs:1.55.0 - https://github.com/open-telemetry/opentelemetry-java) + (The Apache License, Version 2.0) OpenTelemetry Java (io.opentelemetry:opentelemetry-sdk-metrics:1.55.0 - https://github.com/open-telemetry/opentelemetry-java) + (The Apache License, Version 2.0) OpenTelemetry Java (io.opentelemetry:opentelemetry-sdk-trace:1.55.0 - https://github.com/open-telemetry/opentelemetry-java) + (The Apache Software License, Version 2.0) Core functionality for the Reactor Netty library (io.projectreactor.netty:reactor-netty-core:1.3.6 - https://github.com/reactor/reactor-netty) + (The Apache Software License, Version 2.0) HTTP functionality for the Reactor Netty library (io.projectreactor.netty:reactor-netty-http:1.3.6 - https://github.com/reactor/reactor-netty) + (Apache License, Version 2.0) Non-Blocking Reactive Foundation for the JVM (io.projectreactor:reactor-core:3.8.6 - https://github.com/reactor/reactor-core) + (Apache License 2.0) swagger-annotations-jakarta (io.swagger.core.v3:swagger-annotations-jakarta:2.2.47 - https://github.com/swagger-api/swagger-core) + (Apache License 2.0) swagger-core-jakarta (io.swagger.core.v3:swagger-core-jakarta:2.2.47 - https://github.com/swagger-api/swagger-core) + (Apache License 2.0) swagger-models-jakarta (io.swagger.core.v3:swagger-models-jakarta:2.2.47 - https://github.com/swagger-api/swagger-core) + (EDL 1.0) Jakarta Activation API (jakarta.activation:jakarta.activation-api:2.1.4 - https://github.com/jakartaee/jaf-api) + (EPL 2.0) (GPL2 w/ CPE) Jakarta Annotations API (jakarta.annotation:jakarta.annotation-api:3.0.0 - https://projects.eclipse.org/projects/ee4j.ca) + (Apache License 2.0) Jakarta Validation API (jakarta.validation:jakarta.validation-api:3.1.1 - https://beanvalidation.org) + (Eclipse Distribution License - v 1.0) Jakarta XML Binding API (jakarta.xml.bind:jakarta.xml.bind-api:4.0.5 - https://github.com/jakartaee/jaxb-api) + (The Apache Software License, Version 2.0) ASM based accessors helper used by json-smart (net.minidev:accessors-smart:2.6.0 - https://urielch.github.io/) + (The Apache Software License, Version 2.0) JSON Small and Fast Parser (net.minidev:json-smart:2.6.0 - https://urielch.github.io/) + (Apache 2) Apache Cassandra Java Driver - core (org.apache.cassandra:java-driver-core:4.19.3 - https://github.com/datastax/java-driver) + (Apache 2) Apache Cassandra Java Driver - guava shaded dep (org.apache.cassandra:java-driver-guava-shaded:4.19.3 - https://github.com/datastax/java-driver) + (Apache 2) Apache Cassandra Java Driver - Metrics - Micrometer (org.apache.cassandra:java-driver-metrics-micrometer:4.19.3 - https://github.com/datastax/java-driver) + (Apache 2) Apache Cassandra Java Driver - query builder (org.apache.cassandra:java-driver-query-builder:4.19.3 - https://github.com/datastax/java-driver) + (Apache-2.0) Apache Commons Lang (org.apache.commons:commons-lang3:3.20.0 - https://commons.apache.org/proper/commons-lang/) + (Apache-2.0) Apache Log4j API (org.apache.logging.log4j:log4j-api:2.25.4 - https://logging.apache.org/log4j/2.x/) + (Apache-2.0) Log4j API to SLF4J Adapter (org.apache.logging.log4j:log4j-to-slf4j:2.25.4 - https://logging.apache.org/log4j/2.x/) + (Apache License, Version 2.0) tomcat-embed-el (org.apache.tomcat.embed:tomcat-embed-el:11.0.22 - https://tomcat.apache.org/) + (Bouncy Castle Licence) Bouncy Castle Provider (org.bouncycastle:bcprov-jdk18on:1.84 - https://www.bouncycastle.org/download/bouncy-castle-java/) + (Bouncy Castle Licence) Bouncy Castle Provider (LTS Distribution) (org.bouncycastle:bcprov-lts8on:2.73.8 - https://www.bouncycastle.org/lts-java) + (Public Domain, per Creative Commons CC0) (BSD-2-Clause) HdrHistogram (org.hdrhistogram:HdrHistogram:2.2.2 - http://hdrhistogram.github.io/HdrHistogram/) + (Apache License 2.0) Hibernate Validator Engine (org.hibernate.validator:hibernate-validator:9.0.1.Final - https://hibernate.org/validator) + (Apache License 2.0) JBoss Logging 3 (org.jboss.logging:jboss-logging:3.6.3.Final - https://www.jboss.org) + (Apache-2.0) Kotlin Stdlib (org.jetbrains.kotlin:kotlin-stdlib:2.2.21 - https://kotlinlang.org/) + (The Apache Software License, Version 2.0) JetBrains Java Annotations (org.jetbrains:annotations:17.0.0 - https://github.com/JetBrains/java-annotations) + (The Apache License, Version 2.0) JSpecify annotations (org.jspecify:jspecify:1.0.0 - http://jspecify.org/) + (Public Domain, per Creative Commons CC0) LatencyUtils (org.latencyutils:LatencyUtils:2.0.3 - http://latencyutils.github.io/LatencyUtils/) + (BSD-3-Clause) asm (org.ow2.asm:asm:9.9 - http://asm.ow2.io/) + (BSD-3-Clause) asm-analysis (org.ow2.asm:asm-analysis:9.9 - http://asm.ow2.io/) + (BSD-3-Clause) asm-commons (org.ow2.asm:asm-commons:9.9 - http://asm.ow2.io/) + (BSD-3-Clause) asm-tree (org.ow2.asm:asm-tree:9.9 - http://asm.ow2.io/) + (BSD-3-Clause) asm-util (org.ow2.asm:asm-util:9.9 - http://asm.ow2.io/) + (The MIT License) Project Lombok (org.projectlombok:lombok:1.18.46 - https://projectlombok.org) + (MIT-0) reactive-streams (org.reactivestreams:reactive-streams:1.0.4 - http://www.reactive-streams.org/) + (MIT) JUL to SLF4J bridge (org.slf4j:jul-to-slf4j:2.0.18 - http://www.slf4j.org) + (MIT) SLF4J API Module (org.slf4j:slf4j-api:2.0.18 - http://www.slf4j.org) + (The Apache License, Version 2.0) springdoc-openapi-starter-common (org.springdoc:springdoc-openapi-starter-common:3.0.3 - https://springdoc.org/) + (The Apache License, Version 2.0) springdoc-openapi-starter-webflux-api (org.springdoc:springdoc-openapi-starter-webflux-api:3.0.3 - https://springdoc.org/) + (The Apache License, Version 2.0) springdoc-openapi-starter-webmvc-api (org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.3 - https://springdoc.org/) + (Apache License, Version 2.0) spring-boot (org.springframework.boot:spring-boot:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-actuator (org.springframework.boot:spring-boot-actuator:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-actuator-autoconfigure (org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-autoconfigure (org.springframework.boot:spring-boot-autoconfigure:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-cassandra (org.springframework.boot:spring-boot-cassandra:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-data-cassandra (org.springframework.boot:spring-boot-data-cassandra:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-data-commons (org.springframework.boot:spring-boot-data-commons:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-health (org.springframework.boot:spring-boot-health:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-http-client (org.springframework.boot:spring-boot-http-client:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-http-codec (org.springframework.boot:spring-boot-http-codec:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-http-converter (org.springframework.boot:spring-boot-http-converter:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-jackson (org.springframework.boot:spring-boot-jackson:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-micrometer-metrics (org.springframework.boot:spring-boot-micrometer-metrics:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-micrometer-observation (org.springframework.boot:spring-boot-micrometer-observation:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-micrometer-tracing (org.springframework.boot:spring-boot-micrometer-tracing:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-micrometer-tracing-opentelemetry (org.springframework.boot:spring-boot-micrometer-tracing-opentelemetry:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-netty (org.springframework.boot:spring-boot-netty:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-opentelemetry (org.springframework.boot:spring-boot-opentelemetry:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-persistence (org.springframework.boot:spring-boot-persistence:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-reactor (org.springframework.boot:spring-boot-reactor:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-reactor-netty (org.springframework.boot:spring-boot-reactor-netty:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-security (org.springframework.boot:spring-boot-security:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-security-oauth2-client (org.springframework.boot:spring-boot-security-oauth2-client:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-security-oauth2-resource-server (org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-servlet (org.springframework.boot:spring-boot-servlet:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter (org.springframework.boot:spring-boot-starter:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-actuator (org.springframework.boot:spring-boot-starter-actuator:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-data-cassandra (org.springframework.boot:spring-boot-starter-data-cassandra:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-jackson (org.springframework.boot:spring-boot-starter-jackson:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-logging (org.springframework.boot:spring-boot-starter-logging:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-micrometer-metrics (org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-reactor-netty (org.springframework.boot:spring-boot-starter-reactor-netty:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-security (org.springframework.boot:spring-boot-starter-security:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-security-oauth2-client (org.springframework.boot:spring-boot-starter-security-oauth2-client:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-security-oauth2-resource-server (org.springframework.boot:spring-boot-starter-security-oauth2-resource-server:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-validation (org.springframework.boot:spring-boot-starter-validation:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-starter-webflux (org.springframework.boot:spring-boot-starter-webflux:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-validation (org.springframework.boot:spring-boot-validation:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-web-server (org.springframework.boot:spring-boot-web-server:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-webclient (org.springframework.boot:spring-boot-webclient:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-webflux (org.springframework.boot:spring-boot-webflux:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) spring-boot-webmvc (org.springframework.boot:spring-boot-webmvc:4.0.7 - https://spring.io/projects/spring-boot) + (Apache License, Version 2.0) Spring Cloud Commons (org.springframework.cloud:spring-cloud-commons:5.0.2 - https://projects.spring.io/spring-cloud/spring-cloud-commons/) + (Apache License, Version 2.0) Spring Cloud Context (org.springframework.cloud:spring-cloud-context:5.0.2 - https://projects.spring.io/spring-cloud/spring-cloud-context/) + (Apache License, Version 2.0) spring-cloud-starter (org.springframework.cloud:spring-cloud-starter:5.0.2 - https://projects.spring.io/spring-cloud) + (Apache License, Version 2.0) spring-cloud-starter-bootstrap (org.springframework.cloud:spring-cloud-starter-bootstrap:5.0.2 - https://projects.spring.io/spring-cloud) + (Apache License, Version 2.0) Spring Data for Apache Cassandra Core (org.springframework.data:spring-data-cassandra:5.0.6 - https://projects.spring.io/spring-data-cassandra/) + (Apache License, Version 2.0) Spring Data Core (org.springframework.data:spring-data-commons:4.0.6 - https://spring.io/projects/spring-data) + (Apache License, Version 2.0) spring-security-config (org.springframework.security:spring-security-config:7.0.6 - https://spring.io/projects/spring-security) + (Apache License, Version 2.0) spring-security-core (org.springframework.security:spring-security-core:7.0.6 - https://spring.io/projects/spring-security) + (Apache License, Version 2.0) spring-security-crypto (org.springframework.security:spring-security-crypto:7.0.6 - https://spring.io/projects/spring-security) + (Apache License, Version 2.0) spring-security-oauth2-client (org.springframework.security:spring-security-oauth2-client:7.0.6 - https://spring.io/projects/spring-security) + (Apache License, Version 2.0) spring-security-oauth2-core (org.springframework.security:spring-security-oauth2-core:7.0.6 - https://spring.io/projects/spring-security) + (Apache License, Version 2.0) spring-security-oauth2-jose (org.springframework.security:spring-security-oauth2-jose:7.0.6 - https://spring.io/projects/spring-security) + (Apache License, Version 2.0) spring-security-oauth2-resource-server (org.springframework.security:spring-security-oauth2-resource-server:7.0.6 - https://spring.io/projects/spring-security) + (Apache License, Version 2.0) spring-security-web (org.springframework.security:spring-security-web:7.0.6 - https://spring.io/projects/spring-security) + (Apache License, Version 2.0) Spring AOP (org.springframework:spring-aop:7.0.8 - https://github.com/spring-projects/spring-framework) + (Apache License, Version 2.0) Spring Beans (org.springframework:spring-beans:7.0.8 - https://github.com/spring-projects/spring-framework) + (Apache License, Version 2.0) Spring Context (org.springframework:spring-context:7.0.8 - https://github.com/spring-projects/spring-framework) + (Apache License, Version 2.0) Spring Core (org.springframework:spring-core:7.0.8 - https://github.com/spring-projects/spring-framework) + (Apache License, Version 2.0) Spring Expression Language (SpEL) (org.springframework:spring-expression:7.0.8 - https://github.com/spring-projects/spring-framework) + (Apache License, Version 2.0) Spring Transaction (org.springframework:spring-tx:7.0.8 - https://github.com/spring-projects/spring-framework) + (Apache License, Version 2.0) Spring Web (org.springframework:spring-web:7.0.8 - https://github.com/spring-projects/spring-framework) + (Apache License, Version 2.0) Spring WebFlux (org.springframework:spring-webflux:7.0.8 - https://github.com/spring-projects/spring-framework) + (Apache License, Version 2.0) Spring Web MVC (org.springframework:spring-webmvc:7.0.8 - https://github.com/spring-projects/spring-framework) + (The Apache Software License, Version 2.0) WireMock (org.wiremock:wiremock-standalone:3.13.2 - http://wiremock.org) + (Apache License, Version 2.0) SnakeYAML (org.yaml:snakeyaml:2.5 - https://bitbucket.org/snakeyaml/snakeyaml) + (Apache License, Version 2.0) AWS Java SDK :: Annotations (software.amazon.awssdk:annotations:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: Checksums (software.amazon.awssdk:checksums:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: Checksums SPI (software.amazon.awssdk:checksums-spi:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: Endpoints SPI (software.amazon.awssdk:endpoints-spi:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: HTTP Auth AWS (software.amazon.awssdk:http-auth-aws:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: HTTP Auth SPI (software.amazon.awssdk:http-auth-spi:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: HTTP Client Interface (software.amazon.awssdk:http-client-spi:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: Identity SPI (software.amazon.awssdk:identity-spi:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: Core :: Protocols :: Json Utils (software.amazon.awssdk:json-utils:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: Metrics SPI (software.amazon.awssdk:metrics-spi:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: Profiles (software.amazon.awssdk:profiles:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: Regions (software.amazon.awssdk:regions:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: Retries (software.amazon.awssdk:retries:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: Retries API (software.amazon.awssdk:retries-spi:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: SDK Core (software.amazon.awssdk:sdk-core:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: Third Party :: Jackson-core (software.amazon.awssdk:third-party-jackson-core:2.40.1 - https://aws.amazon.com/sdkforjava) + (Apache License, Version 2.0) AWS Java SDK :: Utilities (software.amazon.awssdk:utils:2.40.1 - https://aws.amazon.com/sdkforjava) + (The Apache Software License, Version 2.0) Jackson-core (tools.jackson.core:jackson-core:3.1.4 - https://github.com/FasterXML/jackson-core) + (The Apache Software License, Version 2.0) jackson-databind (tools.jackson.core:jackson-databind:3.1.4 - https://github.com/FasterXML/jackson) + (The Apache Software License, Version 2.0) Jackson module: Blackbird (tools.jackson.module:jackson-module-blackbird:3.1.4 - https://github.com/FasterXML/jackson-modules-base) diff --git a/src/libraries/java/nv-boot-parent/README.md b/src/libraries/java/nv-boot-parent/README.md new file mode 100644 index 0000000000..a7081a27e1 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/README.md @@ -0,0 +1,179 @@ +# NV Boot Parent + +NV Spring Boot: Shared libraries and Bill of Materials (BOM). The libraries +can be used by both internal/managed and self-hosted application deployments. + +**Baseline:** Spring Boot **4.0.7**, Spring Cloud **2025.1.2** (Oakwood), Java **25**. + +## Structure + +```text +nv-boot-parent (extends spring-boot-starter-parent 4.0.7) +├── nv-boot-bom — BOM for nv-boot-starter-* versions +└── nv-boot-starter-* — shared libraries +``` + +## Modules + +| Module | Description | +|------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------| +| [nv-boot-bom](nv-boot-bom/README.md) | Bill of Materials for nv-boot module versions | +| [nv-boot-mock-servers-test](nv-boot-mock-servers-test/README.md) | WireMock-based mock servers for testing | +| [nv-boot-starter-audit](nv-boot-starter-audit/README.md) | Audit logging and event tracking | +| [nv-boot-starter-cassandra](nv-boot-starter-cassandra/README.md) | Cassandra with SSL bundle and refreshable session support | +| [nv-boot-starter-core](nv-boot-starter-core/README.md) | Core utilities and shared configuration | +| [nv-boot-starter-exceptions](nv-boot-starter-exceptions/README.md) | Exception classes with RFC 7807 Problem Details support | +| [nv-boot-starter-jwt](nv-boot-starter-jwt/README.md) | JWT authentication and validation starter | +| [nv-boot-starter-data-migration-notification](nv-boot-starter-data-migration-notification/README.md) | Notification service for raising events such as begin or end of data migration | +| [nv-boot-starter-observability](nv-boot-starter-observability/README.md) | Tracing, logging, and metrics configuration | +| [nv-boot-starter-registries](nv-boot-starter-registries/README.md) | Container, Helm, Model, and Resource registry clients | +| [nv-boot-starter-reloadable-properties](nv-boot-starter-reloadable-properties/README.md) | File-based property reloading with context refresh | +| [nv-boot-starter-telemetry](nv-boot-starter-telemetry/README.md) | CloudEvents client for Telemetry servers (WebClient, OAuth2 bearer) | + +## Using in External Projects + +Spring Boot applications should extend `nv-boot-parent` as their Maven parent and import +`nv-boot-bom` in ``. This gives the application: + +- All Spring Boot build conventions (compiler, surefire, jacoco, etc.) +- Managed versions for all third-party dependencies (Bouncy Castle, Spring Cloud, etc.) +- Managed versions for all `nv-boot-starter-*` libraries via `nv-boot-bom` + +### Basic setup + +```xml + + com.nvidia.boot + nv-boot-parent + ${get.latest.version} + + + + + + com.nvidia.boot + nv-boot-bom + ${get.latest.version} + pom + import + + + +``` + +Once declared, nv-boot libraries and third-party dependencies can be used without specifying +a version: + +```xml + + + com.nvidia.boot + nv-boot-starter-jwt + + + org.bouncycastle + bcprov-jdk18on + + +``` + +### Projects that cannot extend nv-boot-parent + +If your project already has a corporate or framework parent POM and cannot extend +`nv-boot-parent`, import `nv-boot-bom` in your `` block instead. +This gives managed versions for all `nv-boot-starter-*` libraries, but does **not** provide the +build conventions or third-party version management that come with the full parent: + +```xml + + + + com.nvidia.boot + nv-boot-bom + ${get.latest.version} + pom + import + + + +``` + +Third-party dependencies used by the nv-boot libraries (e.g. Bouncy Castle, etc.) +will still be resolved transitively, but their versions will not be centrally managed — you will +need to manage them explicitly if you use them directly in your own code. + +### Overriding a dependency version + +All third-party dependency versions are declared as properties in `nv-boot-parent`. Because +external applications extend `nv-boot-parent`, any version property can be overridden in the +application's own `` block. This is the recommended approach for pulling in a patched +version to address a CVE without waiting for an `nv-boot-parent` release: + +```xml + + 1.85 + ... + +``` + +The overridden property takes effect for that dependency wherever it is used — both directly in +the application and transitively through any nv-boot library on the classpath. + +### Available version properties + +- **`nv-boot-parent`** (this project) — properties listed in the table below +- **`spring-boot-starter-parent`** — build and plugin version properties (e.g. `maven-compiler-plugin.version`) +- **`spring-boot-dependencies`** — all Spring Boot managed dependency versions + (e.g. `spring-framework.version`, `jackson-bom.version`, `logback.version`). See the + [Spring Boot dependency versions reference](https://docs.spring.io/spring-boot/appendix/dependency-versions/properties.html) + for the full list + +Properties defined in `nv-boot-parent`: + +| Property | Controls | +|-----------------------------|-------------------------------------------------------------| +| `bouncycastle.version` | `bcpkix-jdk18on`, `bcprov-jdk18on` | +| `commons-io.version` | `commons-io` | +| `commons-lang3.version` | `commons-lang3` | +| `commons-logging.version` | `commons-logging` | +| `commons-text.version` | `commons-text` | +| `cloudevents.version` | `cloudevents-core`, `cloudevents-json-jackson` | +| `guice.version` | `com.google.inject:guice` | +| `shedlock.version` | `net.javacrumbs.shedlock:*` | +| `spring-cloud.version` | `org.springframework.cloud:*` | +| `springdoc-openapi.version` | `springdoc-openapi-starter-*` (Boot 4 / OpenAPI 3.x line) | +| `wiremock.version` | `wiremock`, `wiremock-standalone` | + +## Minimum Requirements + +* [Eclipse Temurin OpenJDK 25](https://adoptium.net/temurin/releases/) +* [Maven 3.8.7](https://maven.apache.org/download.cgi) or higher +* [Git 2.15.2](https://git-scm.com/downloads) or higher +* [Docker](https://docs.docker.com/get-docker/) + +## Building + +```bash +mvn clean verify +``` + +Use `mvn clean install` when you only need artifacts locally without the full verify lifecycle. + +Bazel is available during migration. See [BAZEL.md](BAZEL.md) for build, test, +coverage, NOTICE, and downstream source-consumption commands. + +## Third-party notices + +The repository root **`NOTICE`** file lists shipped third-party dependencies and +their declared licenses. It is generated by the Bazel-native NOTICE tool from +`tools/bazel/notice_roots.json`, `maven_install.json`, and checked-in metadata; +it does not depend on project POM files or `license-maven-plugin`. + +```bash +export BAZEL_OUTPUT_USER_ROOT="${TMPDIR:-/tmp}/nv-boot-parent-bazel-cache" +bazel --output_user_root="${BAZEL_OUTPUT_USER_ROOT}" \ + run //:generate_notice -- --update-metadata --write +``` + +License metadata is only as accurate as upstream artifact metadata; review with +Legal/OSRB before release. diff --git a/src/libraries/java/nv-boot-parent/lombok.config b/src/libraries/java/nv-boot-parent/lombok.config new file mode 100644 index 0000000000..931a1b053c --- /dev/null +++ b/src/libraries/java/nv-boot-parent/lombok.config @@ -0,0 +1,4 @@ +config.stopBubbling = true +lombok.addLombokGeneratedAnnotation = true +clear lombok.jacksonized.jacksonVersion +lombok.jacksonized.jacksonVersion += 3 diff --git a/src/libraries/java/nv-boot-parent/nv-boot-bom/BUILD.bazel b/src/libraries/java/nv-boot-parent/nv-boot-bom/BUILD.bazel new file mode 100644 index 0000000000..ffd0fb0cdc --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-bom/BUILD.bazel @@ -0,0 +1 @@ +package(default_visibility = ["//visibility:public"]) diff --git a/src/libraries/java/nv-boot-parent/nv-boot-bom/README.md b/src/libraries/java/nv-boot-parent/nv-boot-bom/README.md new file mode 100644 index 0000000000..05153577b8 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-bom/README.md @@ -0,0 +1,54 @@ +# NV Boot BOM + +Bill of Materials (BOM) for `nv-boot-starter-*` module versions. + +## Preferred usage: extend nv-boot-parent + +Most applications should extend `nv-boot-parent` and import this BOM explicitly in +``. See the [nv-boot-parent README](../README.md) for full details. + +## Alternative usage: import as a BOM only + +If your project already has a parent POM it cannot change, import `nv-boot-bom` directly in +your `` block to get managed versions for all `nv-boot-starter-*` modules: + +```xml + + + + com.nvidia.boot + nv-boot-bom + ${get.latest.version} + pom + import + + + +``` + +Once imported, add individual nv-boot libraries without specifying versions: + +```xml + + + com.nvidia.boot + nv-boot-starter-jwt + + + com.nvidia.boot + nv-boot-starter-registries + + + +``` + +Note that importing this BOM does **not** provide managed versions for the third-party dependencies +used internally by nv-boot libraries (e.g. Bouncy Castle, Nimbus JOSE+JWT, Spring Cloud). Those +are managed in `nv-boot-parent` and are only available to projects that extend it. If you use +any of those libraries directly, you will need to declare their versions explicitly. + +## No Beans To Inject + +This is a BOM (Bill of Materials) module only. It does not provide any beans or auto-configuration. +It only manages dependency versions for `nv-boot-starter-*` modules. + diff --git a/src/libraries/java/nv-boot-parent/nv-boot-bom/pom.xml b/src/libraries/java/nv-boot-parent/nv-boot-bom/pom.xml new file mode 100644 index 0000000000..621646719d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-bom/pom.xml @@ -0,0 +1,94 @@ + + + + + 4.0.0 + + + com.nvidia.boot + nv-boot-parent + 0.0.1-SNAPSHOT + + + nv-boot-bom + pom + NV Boot BOM + Bill of Materials for nv-boot module versions. + + + + + com.nvidia.boot + nv-boot-starter-audit + ${project.version} + + + com.nvidia.boot + nv-boot-starter-exceptions + ${project.version} + + + com.nvidia.boot + nv-boot-starter-cassandra + ${project.version} + + + com.nvidia.boot + nv-boot-starter-reloadable-properties + ${project.version} + + + com.nvidia.boot + nv-boot-mock-servers-test + ${project.version} + + + com.nvidia.boot + nv-boot-starter-registries + ${project.version} + + + com.nvidia.boot + nv-boot-starter-core + ${project.version} + + + com.nvidia.boot + nv-boot-starter-jwt + ${project.version} + + + com.nvidia.boot + nv-boot-starter-observability + ${project.version} + + + com.nvidia.boot + nv-boot-starter-telemetry + ${project.version} + + + com.nvidia.boot + nv-boot-starter-data-migration-notification + ${project.version} + + + + diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/.gitignore b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/.gitignore new file mode 100644 index 0000000000..74df7495c9 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/.gitignore @@ -0,0 +1,21 @@ +**/target +**/.settings/ +**/.metadata/ +**/.recommenders/ +*/.classpath +*/.project +*/bin +*.iml +*.idea +*.project +*.jtl +*.tern-project +*.log +*.pyc +*.swp +**/results +.DS_Store +**/logs +**/tmp +.vscode + diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/BUILD.bazel b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/BUILD.bazel new file mode 100644 index 0000000000..29aa857953 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/BUILD.bazel @@ -0,0 +1,39 @@ +load("//src/libraries/java/nv-boot-parent/tools/bazel:java.bzl", "nv_boot_library", "nv_boot_library_test") + +MOCK_SERVERS_COMPILE_DEPS = [ + "@nv_third_party_deps//:com_fasterxml_jackson_core_jackson_annotations", + "@nv_third_party_deps//:com_nimbusds_nimbus_jose_jwt", + "@nv_third_party_deps//:jakarta_annotation_jakarta_annotation_api", + "@nv_third_party_deps//:jakarta_validation_jakarta_validation_api", + "@nv_third_party_deps//:org_apache_commons_commons_lang3", + "@nv_third_party_deps//:org_apache_logging_log4j_log4j_api", + "@nv_third_party_deps//:org_slf4j_slf4j_api", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_autoconfigure", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_jackson", + "@nv_third_party_deps//:org_springframework_cloud_spring_cloud_context", + "@nv_third_party_deps//:org_springframework_security_spring_security_oauth2_core", + "@nv_third_party_deps//:org_springframework_security_spring_security_oauth2_jose", + "@nv_third_party_deps//:org_springframework_spring_context", + "@nv_third_party_deps//:org_springframework_spring_web", + "@nv_third_party_deps//:org_wiremock_wiremock_standalone", + "@nv_third_party_deps//:tools_jackson_core_jackson_databind", +] + +nv_boot_library( + name = "nv_boot_mock_servers_test", + srcs = glob(["src/main/java/**/*.java"]), + visibility = ["//visibility:public"], + deps = MOCK_SERVERS_COMPILE_DEPS, +) + +nv_boot_library_test( + name = "tests", + srcs = glob(["src/test/java/**/*.java"]), + coverage_library = ":nv_boot_mock_servers_test", + deps = [ + ":nv_boot_mock_servers_test", + ] + MOCK_SERVERS_COMPILE_DEPS, + size = "medium", + tags = ["exclusive"], + timeout = "moderate", +) diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/README.md b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/README.md new file mode 100644 index 0000000000..3101d9c738 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/README.md @@ -0,0 +1,45 @@ +# NV Boot Mock Servers Test + +WireMock-based mock servers for container registries such as Docker, ECR, NGC, Harbor, ACR, OCI, +Volcengine, Artifactory, etc. and OAuth2 Token Server. Used for integration testing. + +## Adding as a Dependency + +Add this library as a `test` scope dependency to your application's or test module's `pom.xml`: + +```xml + + + + com.nvidia.boot + nv-boot-bom + ${nv-boot.version} + pom + import + + + + + + + com.nvidia.boot + nv-boot-mock-servers-test + test + + +``` + +## Auto Configured Beans + +This library does not autoconfigure and register any beans in the Spring application +context. + +This module provides **mock server utilities and test fixtures** for use in `@SpringBootTest` +or JUnit tests. It does not auto-configure beans. Use the mock classes from +package `com.nvidia.boot.mock.*` (e.g. `com.nvidia.boot.mock.docker`, `com.nvidia.boot.mock.azure`, +`com.nvidia.boot.mock.ngc`) to set up WireMock stubs for registry endpoints. + +### Test Usage + +Reference `BootTestConstants` and the mock server helpers when writing tests that need to validate +registry client behavior against simulated registry responses. diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/lombok.config b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/lombok.config new file mode 100644 index 0000000000..7324b9265c --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/lombok.config @@ -0,0 +1,3 @@ +config.stopBubbling = true +lombok.addLombokGeneratedAnnotation = true +lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/pom.xml b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/pom.xml new file mode 100644 index 0000000000..b936cf52a2 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/pom.xml @@ -0,0 +1,97 @@ + + + + + 4.0.0 + + com.nvidia.boot + nv-boot-parent + 0.0.1-SNAPSHOT + + + nv-boot-mock-servers-test + jar + NV Boot Mock Servers Test + WireMock-based mock servers for container registries (Docker, + ECR, NGC, Harbor, ACR, OCI, Volcengine, Artifactory) and OAuth2 + + + + org.springframework.boot + spring-boot-starter-jackson + + + org.apache.commons + commons-lang3 + + + org.projectlombok + lombok + true + + + org.springframework.security + spring-security-oauth2-jose + + + org.springframework + spring-context + + + org.springframework.cloud + spring-cloud-context + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework + spring-web + + + org.apache.logging.log4j + log4j-api + + + org.slf4j + slf4j-api + + + jakarta.annotation + jakarta.annotation-api + + + jakarta.validation + jakarta.validation-api + + + org.wiremock + wiremock-standalone + + + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/BootTestConstants.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/BootTestConstants.java new file mode 100644 index 0000000000..4cf54ac77d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/BootTestConstants.java @@ -0,0 +1,514 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock; + +import java.net.URI; + +public class BootTestConstants { + + public static final String IMAGE_MEDIA_TYPES = + // Most common format, which may build from Docker 1.10 or later versions. + "application/vnd.docker.distribution.manifest.v2+json," + // Use to describe a list of image manifests for different platforms. + + "application/vnd.docker.distribution.manifest.list.v2+json," + // Equivalent to Docker's manifest list, used for multi-platform images + // in the OCI ecosystem + + "application/vnd.oci.image.index.v1+json," + // OCI image specification (v1.0 and v1.1), which may build by podman. + + "application/vnd.oci.image.manifest.v1+json"; + + // Account Test Variables + public static final String TEST_VALID_ORG_NAME = "whw3rcpsilnj"; + public static final String TEST_VALID_TEAM_NAME = "jeff"; + public static final String TEST_UNKNOWN_ORG_NAME = "someone-org"; + public static final String TEST_UNKNOWN_TEAM_NAME = "test-unknown-team-name"; + public static final String TEST_VALID_ORG_NAME_INVALID_KEY = "test-valid-org-invalid-key"; + + // Model Test Variables + public static final String TEST_VALID_MODEL_NAME = "playground_llama2_trt_l40g"; + public static final String TEST_VALID_MODEL_NAME_2 = "playground_mixtral_trt_l40g"; + public static final String TEST_VALID_SMALL_MODEL_NAME = "playground_llama2_trt_small"; + + // Resource Test Variables + public static final String TEST_VALID_RESOURCE_NAME = "playground_llama2_trt_l40g"; + public static final String TEST_VALID_RESOURCE_NAME_2 = "playground_mixtral_trt_l40g"; + + // Helm Chart Test Variables + public static final String TEST_VALID_HELM_CHART_NAME = "test-helm-chart"; + public static final String TEST_VALID_HELM_CHART_VERSION = "0.6.0+mr.626af78b"; + public static final String TEST_UNKNOWN_HELM_CHART_VERSION = "0.7.0+mr.626af78b"; + + // Container Test Variables + public static final String TEST_VALID_CONTAINER_NAME = "test-container-image"; + public static final String TEST_VALID_CONTAINER_TAG = "latest"; + public static final String TEST_VALID_CONTAINER_PERMISSION_DENIED_TAG = "permission-denied"; + public static final String TEST_VALID_CONTAINER_NOT_EXIST_TAG = "not-exists"; + public static final String TEST_VALID_CONTAINER_HASH = + "sha256:d3f9786af0f21490f55299ac0af2f2da871f927865b042def17c63a3699d8d51"; + + // Docker Test Variables + public static final String TEST_VALID_DOCKER_NAMESPACE_NAME = "test-docker-namespace"; + public static final String TEST_VALID_DOCKER_REPO_NAME = "test-docker-repo"; + public static final String TEST_VALID_DOCKER_TAG_NAME = "test-docker-container-tag"; + public static final String TEST_PERMISSION_DENINED_DOCKER_NAMESPACE_NAME = + "test-docker-container-namespace-no-permission"; + public static final String TEST_NOT_EXIST_DOCKER_TAG_NAME = + "test-docker-container-tag-not-exists"; + public static final String TEST_DOCKER_CONTAINER_REGISTRY = "docker.io"; + public static final URI TEST_DOCKER_CONTAINER_IMAGE = + URI.create(TEST_DOCKER_CONTAINER_REGISTRY + "/%s/%s:%s" + .formatted(TEST_VALID_DOCKER_NAMESPACE_NAME, + TEST_VALID_DOCKER_REPO_NAME, + TEST_VALID_DOCKER_TAG_NAME)); + public static final URI TEST_DOCKER_CONTAINER_IMAGE_PERMISSION_DENIED = + URI.create(TEST_DOCKER_CONTAINER_REGISTRY + "/%s/%s:%s" + .formatted(TEST_PERMISSION_DENINED_DOCKER_NAMESPACE_NAME, + TEST_VALID_DOCKER_REPO_NAME, + TEST_VALID_DOCKER_TAG_NAME)); + public static final URI TEST_DOCKER_CONTAINER_IMAGE_NOT_EXISTS = + URI.create(TEST_DOCKER_CONTAINER_REGISTRY + "/%s/%s:%s" + .formatted(TEST_VALID_DOCKER_NAMESPACE_NAME, + TEST_VALID_DOCKER_REPO_NAME, + TEST_NOT_EXIST_DOCKER_TAG_NAME)); + public static final URI TEST_DOCKER_CONTAINER_IMAGE_WITH_DIGEST = + URI.create(TEST_DOCKER_CONTAINER_REGISTRY + "/%s/%s@%s" + .formatted(TEST_VALID_DOCKER_NAMESPACE_NAME, + TEST_VALID_DOCKER_REPO_NAME, + TEST_VALID_CONTAINER_HASH)); + public static final URI TEST_DOCKER_HELM_CHART = + URI.create(TEST_DOCKER_CONTAINER_REGISTRY + "/%s/%s:%s" + .formatted(TEST_VALID_DOCKER_NAMESPACE_NAME, + TEST_VALID_DOCKER_REPO_NAME, + TEST_VALID_HELM_CHART_NAME)); + + // ECR Test Variables + public static final String TEST_VALID_ECR_REGISTRY_ID = "779846807323"; + + // ECR Private Credentials - Valid (used in TestConstants.MOCK_RAW_ECR_CRED) + public static final String TEST_VALID_ECR_ACCESS_KEY_ID = "ecr_access_key_id_test_1"; + public static final String TEST_VALID_ECR_SECRET_ACCESS_KEY = "ecr_secret_access_key_test_1"; + + // ECR Private Credentials - Invalid + public static final String TEST_INVALID_ECR_ACCESS_KEY_ID = "invalid_ecr_key"; + public static final String TEST_INVALID_ECR_SECRET_ACCESS_KEY = "invalid_ecr_secret"; + + // ECR Public Credentials - Valid (used in TestConstants.MOCK_RAW_ECR_PUBLIC_CRED) + public static final String TEST_VALID_ECR_PUBLIC_ACCESS_KEY_ID = "ecr_public_access_key_id_test_1"; + public static final String TEST_VALID_ECR_PUBLIC_SECRET_ACCESS_KEY = "ecr_public_secret_access_key_test_1"; + + // ECR Public Credentials - Invalid + public static final String TEST_INVALID_ECR_PUBLIC_ACCESS_KEY_ID = "invalid_ecr_public_key"; + public static final String TEST_INVALID_ECR_PUBLIC_SECRET_ACCESS_KEY = "invalid_ecr_public_secret"; + + public static final String TEST_PERMISSION_DENIED_ECR_REPOSITORY_NAME = + "project/ecr/test/permission-denied"; + public static final String TEST_SERVER_ERROR_ECR_REPOSITORY_NAME = + "project/ecr/test/server-error"; + public static final String TEST_NOT_EXIST_ECR_REPOSITORY_NAME = "project/ecr/test/not-exist"; + public static final String TEST_VALID_ECR_CONTAINER_IMAGE_REPOSITORY_NAME = + "project/ecr/container/test-container-image"; + public static final String TEST_VALID_ECR_CONTAINER_IMAGE_TAG = "0.0.1"; + public static final String TEST_VALID_ECR_CONTAINER_IMAGE_DIGEST = + "sha256:55ed75bd4a2d86607b1ffd8585eb36f21ca82e306adea3ea441c15a6e0b7e490"; + public static final String TEST_NOT_EXIST_ECR_CONTAINER_IMAGE_TAG = + "not-exist-container-image-tag"; + public static final String TEST_NOT_EXIST_ECR_CONTAINER_IMAGE_DIGEST = + "sha256:0000000000000000000000000000000000000000000000000000000000000000"; + + public static final String TEST_VALID_ECR_HELM_CHART_REPOSITORY_NAME = + "project/ecr/helm-charts/test-helm-chart"; + public static final String TEST_VALID_ECR_HELM_CHART_TAG = "0.0.2"; + public static final String TEST_VALID_ECR_HELM_CHART_DIGEST = + "sha256:abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234"; + public static final String TEST_NOT_EXIST_ECR_HELM_CHART_TAG = "not-exist-helm-chart-tag"; + public static final String TEST_NOT_EXIST_ECR_HELM_CHART_DIGEST = + "sha256:1111111111111111111111111111111111111111111111111111111111111111"; + + // ECR Registry and URI Constants + public static final String TEST_ECR_CONTAINER_REGISTRY = + TEST_VALID_ECR_REGISTRY_ID + ".dkr.ecr.us-west-2.amazonaws.com"; + public static final URI TEST_ECR_CONTAINER_IMAGE_PERMISSION_DENIED = + URI.create(TEST_ECR_CONTAINER_REGISTRY + "/%s:%s" + .formatted(TEST_PERMISSION_DENIED_ECR_REPOSITORY_NAME, + TEST_VALID_ECR_CONTAINER_IMAGE_TAG)); + public static final URI TEST_ECR_CONTAINER_IMAGE_WITH_TAG = + URI.create(TEST_ECR_CONTAINER_REGISTRY + "/%s:%s" + .formatted(TEST_VALID_ECR_CONTAINER_IMAGE_REPOSITORY_NAME, + TEST_VALID_ECR_CONTAINER_IMAGE_TAG)); + public static final URI TEST_ECR_CONTAINER_IMAGE_WITH_DIGEST = + URI.create(TEST_ECR_CONTAINER_REGISTRY + "/%s@%s" + .formatted(TEST_VALID_ECR_CONTAINER_IMAGE_REPOSITORY_NAME, + TEST_VALID_ECR_CONTAINER_IMAGE_DIGEST)); + public static final URI TEST_ECR_CONTAINER_IMAGE_TAG_NOT_FOUND = + URI.create(TEST_ECR_CONTAINER_REGISTRY + "/%s:%s" + .formatted(TEST_VALID_ECR_CONTAINER_IMAGE_REPOSITORY_NAME, + TEST_NOT_EXIST_ECR_CONTAINER_IMAGE_TAG)); + public static final URI TEST_ECR_CONTAINER_IMAGE_DIGEST_NOT_FOUND = + URI.create(TEST_ECR_CONTAINER_REGISTRY + "/%s@%s" + .formatted(TEST_VALID_ECR_CONTAINER_IMAGE_REPOSITORY_NAME, + TEST_NOT_EXIST_ECR_CONTAINER_IMAGE_DIGEST)); + + public static final String TEST_ECR_HELM_CHART_REGISTRY_URI_PRE = + "oci://" + TEST_VALID_ECR_REGISTRY_ID + ".dkr.ecr.us-west-2.amazonaws.com"; + public static final URI TEST_ECR_HELM_CHART_PERMISSION_DENIED = + URI.create(TEST_ECR_HELM_CHART_REGISTRY_URI_PRE + "/%s:%s" + .formatted(TEST_PERMISSION_DENIED_ECR_REPOSITORY_NAME, + TEST_VALID_ECR_HELM_CHART_TAG)); + public static final URI TEST_ECR_HELM_CHART_WITH_TAG = + URI.create(TEST_ECR_HELM_CHART_REGISTRY_URI_PRE + "/%s:%s" + .formatted(TEST_VALID_ECR_HELM_CHART_REPOSITORY_NAME, + TEST_VALID_ECR_HELM_CHART_TAG)); + public static final URI TEST_ECR_HELM_CHART_WITH_DIGEST = + URI.create(TEST_ECR_HELM_CHART_REGISTRY_URI_PRE + "/%s@%s" + .formatted(TEST_VALID_ECR_HELM_CHART_REPOSITORY_NAME, + TEST_VALID_ECR_HELM_CHART_DIGEST)); + public static final URI TEST_ECR_HELM_CHART_TAG_NOT_FOUND = + URI.create(TEST_ECR_HELM_CHART_REGISTRY_URI_PRE + "/%s:%s" + .formatted(TEST_VALID_ECR_HELM_CHART_REPOSITORY_NAME, + TEST_NOT_EXIST_ECR_HELM_CHART_TAG)); + public static final URI TEST_ECR_HELM_CHART_DIGEST_NOT_FOUND = + URI.create(TEST_ECR_HELM_CHART_REGISTRY_URI_PRE + "/%s@%s" + .formatted(TEST_VALID_ECR_HELM_CHART_REPOSITORY_NAME, + TEST_NOT_EXIST_ECR_HELM_CHART_DIGEST)); + + // ECR Public Registry and URI Constants + public static final String TEST_ECR_PUBLIC_Alias = "z9d2w9t5"; + public static final String TEST_ECR_PUBLIC_CONTAINER_REGISTRY_URI_PRE = + "public.ecr.aws/" + TEST_ECR_PUBLIC_Alias; + public static final String TEST_ECR_PUBLIC_HELM_CHART_REGISTRY_URI_PRE = + "oci://public.ecr.aws/" + TEST_ECR_PUBLIC_Alias; + + public static final String TEST_VALID_ECR_PUBLIC_CONTAINER_IMAGE_REPOSITORY_NAME = + "project/ecr-public/container/test-container-image-1"; + public static final String TEST_VALID_ECR_PUBLIC_CONTAINER_IMAGE_TAG = "1.0.0"; + public static final String TEST_VALID_ECR_PUBLIC_CONTAINER_IMAGE_DIGEST = + "sha256:abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1235"; + public static final String TEST_NOT_EXIST_ECR_PUBLIC_CONTAINER_IMAGE_TAG = + "not-exist-public-tag"; + public static final String TEST_NOT_EXIST_ECR_PUBLIC_CONTAINER_IMAGE_DIGEST = + "sha256:2222222222222222222222222222222222222222222222222222222222222222"; + + public static final String TEST_VALID_ECR_PUBLIC_HELM_CHART_REPOSITORY_NAME = + "project/ecr-public/helm/test-helm-chart-1"; + public static final String TEST_VALID_ECR_PUBLIC_HELM_CHART_TAG = "2.0.0"; + public static final String TEST_VALID_ECR_PUBLIC_HELM_CHART_DIGEST = + "sha256:abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1236"; + public static final String TEST_NOT_EXIST_ECR_PUBLIC_HELM_CHART_TAG = + "not-exist-public-helm-tag"; + public static final String TEST_NOT_EXIST_ECR_PUBLIC_HELM_CHART_DIGEST = + "sha256:3333333333333333333333333333333333333333333333333333333333333333"; + + public static final String TEST_PERMISSION_DENIED_ECR_PUBLIC_REPOSITORY_NAME = + "project/ecr-public/test/permission-denied"; + public static final String TEST_NOT_EXIST_ECR_PUBLIC_REPOSITORY_NAME = + "project/ecr-public/test/not-exist"; + + // ECR Public Container Image URIs + public static final URI TEST_ECR_PUBLIC_CONTAINER_IMAGE_WITH_TAG = + URI.create(TEST_ECR_PUBLIC_CONTAINER_REGISTRY_URI_PRE + "/%s:%s" + .formatted(TEST_VALID_ECR_PUBLIC_CONTAINER_IMAGE_REPOSITORY_NAME, + TEST_VALID_ECR_PUBLIC_CONTAINER_IMAGE_TAG)); + public static final URI TEST_ECR_PUBLIC_CONTAINER_IMAGE_WITH_DIGEST = + URI.create(TEST_ECR_PUBLIC_CONTAINER_REGISTRY_URI_PRE + "/%s@%s" + .formatted(TEST_VALID_ECR_PUBLIC_CONTAINER_IMAGE_REPOSITORY_NAME, + TEST_VALID_ECR_PUBLIC_CONTAINER_IMAGE_DIGEST)); + public static final URI TEST_ECR_PUBLIC_CONTAINER_IMAGE_PERMISSION_DENIED = + URI.create(TEST_ECR_PUBLIC_CONTAINER_REGISTRY_URI_PRE + "/%s:%s" + .formatted(TEST_PERMISSION_DENIED_ECR_PUBLIC_REPOSITORY_NAME, + TEST_VALID_ECR_PUBLIC_CONTAINER_IMAGE_TAG)); + public static final URI TEST_ECR_PUBLIC_CONTAINER_IMAGE_TAG_NOT_FOUND = + URI.create(TEST_ECR_PUBLIC_CONTAINER_REGISTRY_URI_PRE + "/%s:%s" + .formatted(TEST_NOT_EXIST_ECR_PUBLIC_REPOSITORY_NAME, + TEST_NOT_EXIST_ECR_PUBLIC_CONTAINER_IMAGE_TAG)); + public static final URI TEST_ECR_PUBLIC_CONTAINER_IMAGE_DIGEST_NOT_FOUND = + URI.create(TEST_ECR_PUBLIC_CONTAINER_REGISTRY_URI_PRE + "/%s@%s" + .formatted(TEST_NOT_EXIST_ECR_PUBLIC_REPOSITORY_NAME, + TEST_NOT_EXIST_ECR_PUBLIC_CONTAINER_IMAGE_DIGEST)); + + // ECR Public Helm Chart URIs + public static final URI TEST_ECR_PUBLIC_HELM_CHART_WITH_TAG = + URI.create(TEST_ECR_PUBLIC_HELM_CHART_REGISTRY_URI_PRE + "/%s:%s" + .formatted(TEST_VALID_ECR_PUBLIC_HELM_CHART_REPOSITORY_NAME, + TEST_VALID_ECR_PUBLIC_HELM_CHART_TAG)); + public static final URI TEST_ECR_PUBLIC_HELM_CHART_WITH_DIGEST = + URI.create(TEST_ECR_PUBLIC_HELM_CHART_REGISTRY_URI_PRE + "/%s@%s" + .formatted(TEST_VALID_ECR_PUBLIC_HELM_CHART_REPOSITORY_NAME, + TEST_VALID_ECR_PUBLIC_HELM_CHART_DIGEST)); + public static final URI TEST_ECR_PUBLIC_HELM_CHART_PERMISSION_DENIED = + URI.create(TEST_ECR_PUBLIC_HELM_CHART_REGISTRY_URI_PRE + "/%s:%s" + .formatted(TEST_PERMISSION_DENIED_ECR_PUBLIC_REPOSITORY_NAME, + TEST_VALID_ECR_PUBLIC_HELM_CHART_TAG)); + public static final URI TEST_ECR_PUBLIC_HELM_CHART_TAG_NOT_FOUND = + URI.create(TEST_ECR_PUBLIC_HELM_CHART_REGISTRY_URI_PRE + "/%s:%s" + .formatted(TEST_NOT_EXIST_ECR_PUBLIC_REPOSITORY_NAME, + TEST_NOT_EXIST_ECR_PUBLIC_HELM_CHART_TAG)); + public static final URI TEST_ECR_PUBLIC_HELM_CHART_DIGEST_NOT_FOUND = + URI.create(TEST_ECR_PUBLIC_HELM_CHART_REGISTRY_URI_PRE + "/%s@%s" + .formatted(TEST_NOT_EXIST_ECR_PUBLIC_REPOSITORY_NAME, + TEST_NOT_EXIST_ECR_PUBLIC_HELM_CHART_DIGEST)); + + // Volcengine Test Variables + public static final String TEST_IMAGE_TYPE = "Image"; + public static final String TEST_HELM_CHART_TYPE = "Chart"; + public static final String TEST_VALID_VOLCENGINE_REGISTRY = "test-volcengine-registry"; + public static final String TEST_VALID_VOLCENGINE_REGION = "cn-beijing"; + public static final String TEST_VALID_VOLCENGINE_NAMESPACE = "test-volcengine-namespace"; + public static final String TEST_VALID_VOLCENGINE_CONTAINER_IMAGE_REPOSITORY = + "test-volcengine-repository"; + public static final String TEST_VALID_VOLCENGINE_IMAGE_TAG = "test-volcengine-image-tag"; + public static final String TEST_VALID_VOLCENGINE_HELM_REPOSITORY = + "test-volcengine-repository/test-helm-chart-1"; + public static final String TEST_VALID_VOLCENGINE_HELM_CHART_TAG = + "test-volcengine-helm-chart-tag"; + + public static final String TEST_VALID_VOLCENGINE_ACCESS_KEY_ID = "volcengine_access_key_id_test_1"; + public static final String TEST_VALID_VOLCENGINE_SECRET_ACCESS_KEY = "volcengine_secret_access_key_test_1"; + public static final String TEST_INVALID_VOLCENGINE_ACCESS_KEY_ID = "invalid_volcengine_key"; + public static final String TEST_INVALID_VOLCENGINE_SECRET_ACCESS_KEY = "invalid_volcengine_secret"; + + public static final String TEST_PERMISSION_DENIED_VOLCENGINE_REGISTRY = + "test-volcengine-registry-no-permission"; + public static final String TEST_NOT_EXIST_VOLCENGINE_IMAGE_TAG = + "test-volcengine-image-tag-not-exists"; + public static final String TEST_NOT_EXIST_VOLCENGINE_HELM_CHART_TAG = + "test-volcengine-helm-chart-tag-not-exists"; + public static final String TEST_SERVER_ERROR_VOLCENGINE_REGISTRY = + "test-volcengine-registry-server-error"; + public static final String TEST_NOT_EXIST_VOLCENGINE_REPOSITORY = + "test-volcengine-repository-not-exists"; + + // Volcengine Registry and URI Constants + public static final String TEST_VOLCENGINE_CONTAINER_REGISTRY = + TEST_VALID_VOLCENGINE_REGISTRY + "-" + TEST_VALID_VOLCENGINE_REGION + ".cr.volces.com"; + + // Volcengine Container Image URIs + public static final URI TEST_VOLCENGINE_CONTAINER_IMAGE_WITH_TAG = + URI.create(TEST_VOLCENGINE_CONTAINER_REGISTRY + "/%s/%s:%s" + .formatted(TEST_VALID_VOLCENGINE_NAMESPACE, + TEST_VALID_VOLCENGINE_CONTAINER_IMAGE_REPOSITORY, + TEST_VALID_VOLCENGINE_IMAGE_TAG)); + public static final URI TEST_VOLCENGINE_CONTAINER_IMAGE_PERMISSION_DENIED = + URI.create( + TEST_PERMISSION_DENIED_VOLCENGINE_REGISTRY + "-" + TEST_VALID_VOLCENGINE_REGION + + ".cr.volces.com/%s/%s:%s" + .formatted(TEST_VALID_VOLCENGINE_NAMESPACE, + TEST_VALID_VOLCENGINE_CONTAINER_IMAGE_REPOSITORY, + TEST_VALID_VOLCENGINE_IMAGE_TAG)); + public static final URI TEST_VOLCENGINE_CONTAINER_IMAGE_TAG_NOT_FOUND = + URI.create(TEST_VOLCENGINE_CONTAINER_REGISTRY + "/%s/%s:%s" + .formatted(TEST_VALID_VOLCENGINE_NAMESPACE, + TEST_VALID_VOLCENGINE_CONTAINER_IMAGE_REPOSITORY, + TEST_NOT_EXIST_VOLCENGINE_IMAGE_TAG)); + public static final URI TEST_VOLCENGINE_CONTAINER_IMAGE_REPOSITORY_NOT_FOUND = + URI.create(TEST_VOLCENGINE_CONTAINER_REGISTRY + "/%s/%s:%s" + .formatted(TEST_VALID_VOLCENGINE_NAMESPACE, + TEST_NOT_EXIST_VOLCENGINE_REPOSITORY, + TEST_VALID_VOLCENGINE_IMAGE_TAG)); + public static final URI TEST_VOLCENGINE_CONTAINER_IMAGE_SERVER_ERROR = + URI.create(TEST_SERVER_ERROR_VOLCENGINE_REGISTRY + "-" + TEST_VALID_VOLCENGINE_REGION + + ".cr.volces.com/%s/%s:%s" + .formatted(TEST_VALID_VOLCENGINE_NAMESPACE, + TEST_VALID_VOLCENGINE_CONTAINER_IMAGE_REPOSITORY, + TEST_VALID_VOLCENGINE_IMAGE_TAG)); + + // Volcengine Helm Chart URIs + public static final String TEST_VOLCENGINE_HELM_CHART_REGISTRY_URI_PRE = + "oci://" + TEST_VOLCENGINE_CONTAINER_REGISTRY; + public static final URI TEST_VOLCENGINE_HELM_CHART_WITH_TAG = + URI.create(TEST_VOLCENGINE_HELM_CHART_REGISTRY_URI_PRE + "/%s/%s:%s" + .formatted(TEST_VALID_VOLCENGINE_NAMESPACE, + TEST_VALID_VOLCENGINE_HELM_REPOSITORY, + TEST_VALID_VOLCENGINE_HELM_CHART_TAG)); + public static final URI TEST_VOLCENGINE_HELM_CHART_PERMISSION_DENIED = + URI.create("oci://" + TEST_PERMISSION_DENIED_VOLCENGINE_REGISTRY + "-" + + TEST_VALID_VOLCENGINE_REGION + ".cr.volces.com/%s/%s:%s" + .formatted(TEST_VALID_VOLCENGINE_NAMESPACE, + TEST_VALID_VOLCENGINE_HELM_REPOSITORY, + TEST_VALID_VOLCENGINE_HELM_CHART_TAG)); + public static final URI TEST_VOLCENGINE_HELM_CHART_TAG_NOT_FOUND = + URI.create(TEST_VOLCENGINE_HELM_CHART_REGISTRY_URI_PRE + "/%s/%s:%s" + .formatted(TEST_VALID_VOLCENGINE_NAMESPACE, + TEST_VALID_VOLCENGINE_HELM_REPOSITORY, + TEST_NOT_EXIST_VOLCENGINE_HELM_CHART_TAG)); + public static final URI TEST_VOLCENGINE_HELM_CHART_REPOSITORY_NOT_FOUND = + URI.create(TEST_VOLCENGINE_HELM_CHART_REGISTRY_URI_PRE + "/%s/%s:%s" + .formatted(TEST_VALID_VOLCENGINE_NAMESPACE, + TEST_NOT_EXIST_VOLCENGINE_REPOSITORY, + TEST_VALID_VOLCENGINE_HELM_CHART_TAG)); + public static final URI TEST_VOLCENGINE_HELM_CHART_SERVER_ERROR = + URI.create("oci://" + TEST_SERVER_ERROR_VOLCENGINE_REGISTRY + "-" + + TEST_VALID_VOLCENGINE_REGION + ".cr.volces.com/%s/%s:%s" + .formatted(TEST_VALID_VOLCENGINE_NAMESPACE, + TEST_VALID_VOLCENGINE_HELM_REPOSITORY, + TEST_VALID_VOLCENGINE_HELM_CHART_TAG)); + + // Generic OCI Registry Test Variables (for mock servers) + public static final String TEST_VALID_OCI_IMAGE_NAME = "test-oci-namespace/test-image-1"; + public static final String TEST_VALID_OCI_HELM_CHART_NAME = + "test-oci-namespace/test-helm-chart-1"; + public static final String TEST_PERMISSION_DENIED_OCI_IMAGE_NAME = + "test-oci-namespace/test-oci-image-repo-no-permission-1"; + public static final String TEST_PERMISSION_DENIED_OCI_HELM_CHART_NAME = + "test-oci-namespace/test-oci-helm-chart-repo-no-permission-1"; + public static final String TEST_VALID_OCI_IMAGE_TAG_NAME = "test-oci-image-tag-1"; + public static final String TEST_VALID_OCI_HELM_CHART_TAG_NAME = "test-oci-helm-chart-tag-1"; + public static final String TEST_NOT_EXIST_OCI_IMAGE_TAG_NAME = + "test-oci-image-tag-not-exists-1"; + public static final String TEST_NOT_EXIST_OCI_HELM_CHART_TAG_NAME = + "test-oci-helm-chart-tag-not-exists-1"; + public static final String TEST_VALID_OCI_IMAGE_DIGEST = + "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"; + public static final String TEST_VALID_OCI_HELM_CHART_DIGEST = + "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567691"; + + public static final String TEST_OCI_IMAGE_MANIFEST_URL_WITH_TAG = "/v2/" + + TEST_VALID_OCI_IMAGE_NAME + + "/manifests/" + TEST_VALID_OCI_IMAGE_TAG_NAME; + public static final String TEST_OCI_IMAGE_MANIFEST_URL_WITH_DIGEST = "/v2/" + + TEST_VALID_OCI_IMAGE_NAME + + "/manifests/" + TEST_VALID_OCI_IMAGE_DIGEST; + public static final String TEST_OCI_IMAGE_MANIFEST_LATEST_URL = "/v2/" + + TEST_VALID_OCI_IMAGE_NAME + + "/manifests/latest"; + public static final String TEST_OCI_IMAGE_MANIFEST_PERMISSION_DENIED_URL = "/v2/" + + TEST_PERMISSION_DENIED_OCI_IMAGE_NAME + + "/manifests/" + TEST_VALID_OCI_IMAGE_TAG_NAME; + public static final String TEST_OCI_IMAGE_MANIFEST_NOT_EXISTS_URL = "/v2/" + + TEST_VALID_OCI_IMAGE_NAME + + "/manifests/" + TEST_NOT_EXIST_OCI_IMAGE_TAG_NAME; + public static final String TEST_OCI_HELM_CHART_MANIFEST_URL_WITH_TAG = "/v2/" + + TEST_VALID_OCI_HELM_CHART_NAME + + "/manifests/" + TEST_VALID_OCI_HELM_CHART_TAG_NAME; + public static final String TEST_OCI_HELM_CHART_MANIFEST_URL_WITH_DIGEST = "/v2/" + + TEST_VALID_OCI_HELM_CHART_NAME + + "/manifests/" + TEST_VALID_OCI_HELM_CHART_DIGEST; + public static final String TEST_OCI_HELM_CHART_MANIFEST_PERMISSION_DENIED_URL = "/v2/" + + TEST_PERMISSION_DENIED_OCI_HELM_CHART_NAME + + "/manifests/" + TEST_VALID_OCI_HELM_CHART_TAG_NAME; + public static final String TEST_OCI_HELM_CHART_MANIFEST_NOT_EXISTS_URL = "/v2/" + + TEST_VALID_OCI_HELM_CHART_NAME + + "/manifests/" + TEST_NOT_EXIST_OCI_HELM_CHART_TAG_NAME; + + // Azure Container Registry (ACR) Test Variables + public static final String TEST_VALID_ACR_REGISTRY_NAME = "test1-bmfvajfxgfcrhba5"; + public static final String TEST_ACR_CONTAINER_REGISTRY = + TEST_VALID_ACR_REGISTRY_NAME + ".azurecr.io"; + + public static final URI TEST_ACR_CONTAINER_IMAGE_WITH_TAG = + URI.create(TEST_ACR_CONTAINER_REGISTRY + "/%s:%s" + .formatted(TEST_VALID_OCI_IMAGE_NAME, TEST_VALID_OCI_IMAGE_TAG_NAME)); + public static final URI TEST_ACR_CONTAINER_IMAGE_WITH_DIGEST = + URI.create(TEST_ACR_CONTAINER_REGISTRY + "/%s@%s" + .formatted(TEST_VALID_OCI_IMAGE_NAME, TEST_VALID_OCI_IMAGE_DIGEST)); + public static final URI TEST_ACR_CONTAINER_IMAGE_PERMISSION_DENIED = + URI.create(TEST_ACR_CONTAINER_REGISTRY + "/%s:%s" + .formatted(TEST_PERMISSION_DENIED_OCI_IMAGE_NAME, + TEST_VALID_OCI_IMAGE_TAG_NAME)); + public static final URI TEST_ACR_CONTAINER_IMAGE_NOT_EXISTS = + URI.create(TEST_ACR_CONTAINER_REGISTRY + "/%s:%s" + .formatted(TEST_VALID_OCI_IMAGE_NAME, TEST_NOT_EXIST_OCI_IMAGE_TAG_NAME)); + + public static final URI TEST_ACR_HELM_CHART_WITH_TAG = + URI.create("oci://" + TEST_ACR_CONTAINER_REGISTRY + "/%s:%s" + .formatted(TEST_VALID_OCI_HELM_CHART_NAME, TEST_VALID_OCI_HELM_CHART_TAG_NAME)); + public static final URI TEST_ACR_HELM_CHART_WITH_DIGEST = + URI.create("oci://" + TEST_ACR_CONTAINER_REGISTRY + "/%s@%s" + .formatted(TEST_VALID_OCI_HELM_CHART_NAME, TEST_VALID_OCI_HELM_CHART_DIGEST)); + public static final URI TEST_ACR_HELM_CHART_PERMISSION_DENIED = + URI.create("oci://" + TEST_ACR_CONTAINER_REGISTRY + "/%s:%s" + .formatted(TEST_PERMISSION_DENIED_OCI_HELM_CHART_NAME, + TEST_VALID_OCI_HELM_CHART_TAG_NAME)); + public static final URI TEST_ACR_HELM_CHART_NOT_EXISTS = + URI.create("oci://" + TEST_ACR_CONTAINER_REGISTRY + "/%s:%s" + .formatted(TEST_VALID_OCI_HELM_CHART_NAME, + TEST_NOT_EXIST_OCI_HELM_CHART_TAG_NAME)); + + // Harbor Container Registry Test Variables + public static final String TEST_HARBOR_REGISTRY = "demo.goharbor.io"; + + public static final URI TEST_HARBOR_CONTAINER_IMAGE_WITH_TAG = + URI.create(TEST_HARBOR_REGISTRY + "/%s:%s" + .formatted(TEST_VALID_OCI_IMAGE_NAME, TEST_VALID_OCI_IMAGE_TAG_NAME)); + public static final URI TEST_HARBOR_CONTAINER_IMAGE_WITH_DIGEST = + URI.create(TEST_HARBOR_REGISTRY + "/%s@%s" + .formatted(TEST_VALID_OCI_IMAGE_NAME, TEST_VALID_OCI_IMAGE_DIGEST)); + public static final URI TEST_HARBOR_CONTAINER_IMAGE_PERMISSION_DENIED = + URI.create(TEST_HARBOR_REGISTRY + "/%s:%s" + .formatted(TEST_PERMISSION_DENIED_OCI_IMAGE_NAME, + TEST_VALID_OCI_IMAGE_TAG_NAME)); + public static final URI TEST_HARBOR_CONTAINER_IMAGE_NOT_EXISTS = + URI.create(TEST_HARBOR_REGISTRY + "/%s:%s" + .formatted(TEST_VALID_OCI_IMAGE_NAME, TEST_NOT_EXIST_OCI_IMAGE_TAG_NAME)); + + // Harbor Helm Registry Test Variables + public static final URI TEST_HARBOR_HELM_CHART_WITH_TAG = + URI.create("oci://" + TEST_HARBOR_REGISTRY + "/%s:%s" + .formatted(TEST_VALID_OCI_HELM_CHART_NAME, + TEST_VALID_OCI_HELM_CHART_TAG_NAME)); + public static final URI TEST_HARBOR_HELM_CHART_WITH_DIGEST = + URI.create("oci://" + TEST_HARBOR_REGISTRY + "/%s@%s" + .formatted(TEST_VALID_OCI_HELM_CHART_NAME, + TEST_VALID_OCI_HELM_CHART_DIGEST)); + public static final URI TEST_HARBOR_HELM_CHART_PERMISSION_DENIED = + URI.create("oci://" + TEST_HARBOR_REGISTRY + "/%s:%s" + .formatted(TEST_PERMISSION_DENIED_OCI_HELM_CHART_NAME, + TEST_VALID_OCI_HELM_CHART_TAG_NAME)); + public static final URI TEST_HARBOR_HELM_CHART_NOT_EXISTS = + URI.create("oci://" + TEST_HARBOR_REGISTRY + "/%s:%s" + .formatted(TEST_VALID_OCI_HELM_CHART_NAME, + TEST_NOT_EXIST_OCI_HELM_CHART_TAG_NAME)); + + // Artifactory Test Variables + public static final String TEST_VALID_ARTIFACTORY_REGISTRY_NAME = "artifactorytest12345"; + public static final String TEST_ARTIFACTORY_REGISTRY = + TEST_VALID_ARTIFACTORY_REGISTRY_NAME + ".jfrog.io"; + + public static final URI TEST_ARTIFACTORY_CONTAINER_IMAGE_WITH_TAG = + URI.create(TEST_ARTIFACTORY_REGISTRY + "/%s:%s" + .formatted(TEST_VALID_OCI_IMAGE_NAME, + TEST_VALID_OCI_IMAGE_TAG_NAME)); + public static final URI TEST_ARTIFACTORY_CONTAINER_IMAGE_WITH_DIGEST = + URI.create(TEST_ARTIFACTORY_REGISTRY + "/%s@%s" + .formatted(TEST_VALID_OCI_IMAGE_NAME, + TEST_VALID_OCI_IMAGE_DIGEST)); + public static final URI TEST_ARTIFACTORY_CONTAINER_IMAGE_PERMISSION_DENIED = + URI.create(TEST_ARTIFACTORY_REGISTRY + "/%s:%s" + .formatted(TEST_PERMISSION_DENIED_OCI_IMAGE_NAME, + TEST_VALID_OCI_IMAGE_TAG_NAME)); + public static final URI TEST_ARTIFACTORY_CONTAINER_IMAGE_NOT_EXISTS = + URI.create(TEST_ARTIFACTORY_REGISTRY + "/%s:%s" + .formatted(TEST_VALID_OCI_IMAGE_NAME, + TEST_NOT_EXIST_OCI_IMAGE_TAG_NAME)); + + public static final URI TEST_ARTIFACTORY_HELM_CHART_WITH_TAG = + URI.create("oci://" + TEST_ARTIFACTORY_REGISTRY + "/%s:%s" + .formatted(TEST_VALID_OCI_HELM_CHART_NAME, + TEST_VALID_OCI_HELM_CHART_TAG_NAME)); + public static final URI TEST_ARTIFACTORY_HELM_CHART_WITH_DIGEST = + URI.create("oci://" + TEST_ARTIFACTORY_REGISTRY + "/%s@%s" + .formatted( TEST_VALID_OCI_HELM_CHART_NAME, + TEST_VALID_OCI_HELM_CHART_DIGEST)); + public static final URI TEST_ARTIFACTORY_HELM_CHART_PERMISSION_DENIED = + URI.create("oci://" + TEST_ARTIFACTORY_REGISTRY + "/%s:%s" + .formatted(TEST_PERMISSION_DENIED_OCI_HELM_CHART_NAME, + TEST_VALID_OCI_HELM_CHART_TAG_NAME)); + public static final URI TEST_ARTIFACTORY_HELM_CHART_NOT_EXISTS = + URI.create("oci://" + TEST_ARTIFACTORY_REGISTRY + "/%s:%s" + .formatted(TEST_VALID_OCI_HELM_CHART_NAME, + TEST_NOT_EXIST_OCI_HELM_CHART_TAG_NAME)); +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/artifactory/MockArtifactoryAuthServer.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/artifactory/MockArtifactoryAuthServer.java new file mode 100644 index 0000000000..f3bbed6b19 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/artifactory/MockArtifactoryAuthServer.java @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock.artifactory; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; + +import com.github.tomakehurst.wiremock.WireMockServer; +import java.net.URI; +import lombok.SneakyThrows; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; + +public class MockArtifactoryAuthServer { + + // Token endpoint with scope - /v2/token?service=...&scope=... + // Used for artifact validation + private static final String ARTIFACTORY_TOKEN_WITH_SCOPE_URL_PATTERN = + "/v2/token\\?service=[^&]+&scope=.*"; + + // Token endpoint without scope - /v2/token?service=... + // Used for credential validation only + private static final String ARTIFACTORY_TOKEN_WITHOUT_SCOPE_URL_PATTERN = + "/v2/token\\?service=[^&]+$"; + + private static final String ARTIFACTORY_TOKEN_RESPONSE = """ + { + "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LWlkIn0.eyJhdWQiOiJ0ZXN0LWpmcm9nLmlvIiwiaXNzIjoiamZyb2ctYXJ0aWZhY3RvcnkiLCJpYXQiOjE2MDAwMDAwMDAsImV4cCI6MTYwMDAwOTAwMCwic2NvcGUiOiJyZXBvc2l0b3J5OnRlc3QtcmVwbzpwdWxsIiwic3ViIjoidGVzdC11c2VyIn0.mockSignature" + } + """; + + private static WireMockServer artifactoryAuthMockServer; + + @SneakyThrows + public static void start(String artifactoryAuthBaseUrl) { + stop(); + artifactoryAuthMockServer = new WireMockServer(URI.create(artifactoryAuthBaseUrl).getPort()); + artifactoryAuthMockServer.start(); + + // Artifactory token endpoint with scope - for artifact validation + artifactoryAuthMockServer + .stubFor(get(urlMatching(ARTIFACTORY_TOKEN_WITH_SCOPE_URL_PATTERN)) + .willReturn(aResponse().withStatus(200) + .withHeader( + HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .withBody(ARTIFACTORY_TOKEN_RESPONSE))); + + // Artifactory token endpoint without scope - for credential validation + artifactoryAuthMockServer + .stubFor(get(urlMatching(ARTIFACTORY_TOKEN_WITHOUT_SCOPE_URL_PATTERN)) + .willReturn(aResponse().withStatus(200) + .withHeader( + HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .withBody(ARTIFACTORY_TOKEN_RESPONSE))); + } + + public static void setResponse(String url, byte[] body) { + artifactoryAuthMockServer + .stubFor(get(urlPathEqualTo(url)) + .willReturn(aResponse().withStatus(200) + .withHeader( + HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .withBody(body))); + } + + public static void stop() { + if (artifactoryAuthMockServer != null) { + artifactoryAuthMockServer.stop(); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/azure/MockAcrAuthServer.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/azure/MockAcrAuthServer.java new file mode 100644 index 0000000000..a134a8376d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/azure/MockAcrAuthServer.java @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock.azure; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; + +import com.github.tomakehurst.wiremock.WireMockServer; +import java.net.URI; +import lombok.SneakyThrows; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; + +public class MockAcrAuthServer { + + // OAuth2 token endpoint with scope - GET /oauth2/token?service=...&scope=... + // Used for artifact validation + private static final String OAUTH2_TOKEN_WITH_SCOPE_URL_PATTERN = + "/oauth2/token\\?service=[^&]+&scope=.*"; + + // OAuth2 token endpoint without scope - GET /oauth2/token?service=... + // Used for credential validation only + private static final String OAUTH2_TOKEN_WITHOUT_SCOPE_URL_PATTERN = + "/oauth2/token\\?service=[^&]+$"; + + private static final String OAUTH2_TOKEN_RESPONSE = """ + { + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IkJCOENlRlZqTWNObEJEajhMYktWME1oV2dEQSJ9.eyJhdWQiOiJ0ZXN0YWNycmVnLmF6dXJlY3IuaW8iLCJpc3MiOiJodHRwczovL2xvZ2luLm1pY3Jvc29mdG9ubGluZS5jb20vdGVzdC10ZW5hbnQtaWQvdjIuMCIsImlhdCI6MTYwMDAwMDAwMCwiZXhwIjoxNjAwMDAzNjAwLCJzY29wZSI6InJlcG9zaXRvcnk6dGVzdC1hY3ItcmVwbzpwdWxsIiwic3ViIjoidGVzdC11c2VyIn0.mockSignature" + } + """; + + private static WireMockServer acrAuthMockServer; + + @SneakyThrows + public static void start(String acrAuthBaseUrl) { + stop(); + acrAuthMockServer = new WireMockServer(URI.create(acrAuthBaseUrl).getPort()); + acrAuthMockServer.start(); + + // OAuth2 token endpoint with scope - for artifact validation + acrAuthMockServer + .stubFor(get(urlMatching(OAUTH2_TOKEN_WITH_SCOPE_URL_PATTERN)) + .willReturn(aResponse().withStatus(200) + .withHeader( + HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .withBody(OAUTH2_TOKEN_RESPONSE))); + + // OAuth2 token endpoint without scope - for credential validation + acrAuthMockServer + .stubFor(get(urlMatching(OAUTH2_TOKEN_WITHOUT_SCOPE_URL_PATTERN)) + .willReturn(aResponse().withStatus(200) + .withHeader( + HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .withBody(OAUTH2_TOKEN_RESPONSE))); + } + + public static void setResponse(String url, byte[] body) { + acrAuthMockServer + .stubFor(get(urlPathEqualTo(url)) + .willReturn(aResponse().withStatus(200) + .withHeader( + HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .withBody(body))); + } + + public static void stop() { + if (acrAuthMockServer != null) { + acrAuthMockServer.stop(); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/docker/MockDockerRegistryAuthServer.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/docker/MockDockerRegistryAuthServer.java new file mode 100644 index 0000000000..825002c820 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/docker/MockDockerRegistryAuthServer.java @@ -0,0 +1,87 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock.docker; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; + +import com.github.tomakehurst.wiremock.WireMockServer; +import java.net.URI; +import lombok.SneakyThrows; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; + +public class MockDockerRegistryAuthServer { + + // Proxy auth endpoint with scope - for artifact validation + private static final String PROXY_AUTH_URL = "/token\\?service=registry\\.docker\\" + + ".io&scope=repository:.*:pull"; + + // Credential validation endpoint without scope - for credential validation only + private static final String FETCH_TOKEN_URL = "/token\\?service=registry\\.docker\\.io$"; + + private static final String PROXY_AUTH = """ + { + "token": "mockBearerToken" + } + """; + private static WireMockServer dockerRegistryAuthMockServer; + + @SneakyThrows + public static void start(String ngcRegistryBaseUrl) { + stop(); + dockerRegistryAuthMockServer = new WireMockServer(URI.create(ngcRegistryBaseUrl).getPort()); + dockerRegistryAuthMockServer.start(); + + // Proxy auth endpoint with scope (artifact validation) + dockerRegistryAuthMockServer + .stubFor(get(urlMatching(PROXY_AUTH_URL)) + .willReturn(aResponse().withStatus(200) + .withHeader( + HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .withBody(PROXY_AUTH))); + + // Fetch token endpoint without scope (credential validation) + dockerRegistryAuthMockServer + .stubFor(get(urlMatching(FETCH_TOKEN_URL)) + .willReturn(aResponse().withStatus(200) + .withHeader( + HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .withBody(PROXY_AUTH))); + } + + public static void setResponse(String url, byte[] body) { + dockerRegistryAuthMockServer + .stubFor(get(urlPathEqualTo(url)) + .willReturn(aResponse().withStatus(200) + .withHeader( + HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .withBody(body))); + } + + public static void stop() { + if (dockerRegistryAuthMockServer != null) { + dockerRegistryAuthMockServer.stop(); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/docker/MockDockerRegistryServer.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/docker/MockDockerRegistryServer.java new file mode 100644 index 0000000000..9cc9b3c5ed --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/docker/MockDockerRegistryServer.java @@ -0,0 +1,202 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock.docker; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.nvidia.boot.mock.BootTestConstants.TEST_NOT_EXIST_DOCKER_TAG_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_PERMISSION_DENINED_DOCKER_NAMESPACE_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_DOCKER_NAMESPACE_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_DOCKER_REPO_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_DOCKER_TAG_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_HELM_CHART_NAME; + +import com.github.tomakehurst.wiremock.WireMockServer; +import java.net.URI; +import lombok.SneakyThrows; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; + +public class MockDockerRegistryServer { + + private static final String VALIDATE_MANIFEST_URL = "/v2/" + + TEST_VALID_DOCKER_NAMESPACE_NAME + + "/" + TEST_VALID_DOCKER_REPO_NAME + + "/manifests/" + TEST_VALID_DOCKER_TAG_NAME; + private static final String VALIDATE_HELM_MANIFEST_URL = "/v2/" + + TEST_VALID_DOCKER_NAMESPACE_NAME + + "/" + TEST_VALID_DOCKER_REPO_NAME + + "/manifests/" + TEST_VALID_HELM_CHART_NAME; + private static final String VALIDATE_MANIFEST_LATEST_URL = "/v2/" + + TEST_VALID_DOCKER_NAMESPACE_NAME + + "/" + TEST_VALID_DOCKER_REPO_NAME + + "/manifests/latest"; + private static final String VALIDATE_MANIFEST_PERMISSION_DENIED_URL = "/v2/" + + TEST_PERMISSION_DENINED_DOCKER_NAMESPACE_NAME + + "/" + TEST_VALID_DOCKER_REPO_NAME + + "/manifests/" + TEST_VALID_DOCKER_TAG_NAME; + private static final String VALIDATE_MANIFEST_NOT_EXISTS_URL = "/v2/" + + TEST_VALID_DOCKER_NAMESPACE_NAME + + "/" + TEST_VALID_DOCKER_REPO_NAME + + "/manifests/" + TEST_NOT_EXIST_DOCKER_TAG_NAME; + private static final String VALID_BODY_RESPONSE = """ + { + "schemaVersion": 2, + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "config": { + "mediaType": "application/vnd.docker.container.image.v1+json", + "size": 8705, + "digest": "sha256:3c05cf093d57c38edafd81260a770cfe631ed7713685a3bb1b6611b2625ed666" + }, + "layers": [ + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 49590984, + "digest": "sha256:6ee0baa58a3d368515336c1b5c1cade29c975e1b49a832f19e22f4c46f4a23a7" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 23582876, + "digest": "sha256:992a857ef57584af4efb4c62d68456f1e8513c95d6248fd796a9ea7f45da4d79" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 63990914, + "digest": "sha256:3861a6536e4e911503e7d2fc8f93228491ba45d1e5def0d2f3723e32e03d7466" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 202538246, + "digest": "sha256:e5e6faea05ead1ac9cd3244827816e2385b0d62299f7937a4574fc5a9651624c" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 6469487, + "digest": "sha256:91c9495e7b5aa8d2de58884c4284dc52584e0ce5041c2e394eb95f327117228b" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 16836641, + "digest": "sha256:663b9013dbfe8e48d1036303a9c6ca888c5f723bfa3a62ad45ceeb025f426be9" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 244, + "digest": "sha256:d81e31035534fba2c913fd55a22e6f44d79e2e12c1a4032ddea6cbe28ed44c91" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 3081424, + "digest": "sha256:2af84f8c5df41b914998374049ccf8f3957e9de129a562a49b04f5a2518405c6" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 93, + "digest": "sha256:a9379a1e3013f066f9033a0df990a1bc1b0dbe82c0e63ca234ba14afdbe975eb" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 172, + "digest": "sha256:925046b36a5f25a66cf20d11573bbba2a5a0b1811d7ad54c2d44bf9f4287ffc5" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 8752522, + "digest": "sha256:3c233ddc9ac45d07f0b6d0f6e2136c0e573c2a38cb7b072cef5bbab301d628f3" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 710, + "digest": "sha256:7e2ab0f683d06f5cff4a3296cb3d78315208c424a6cecdabc682029059579998" + } + ] + } + """; + private static WireMockServer dockerRegistryMockServer; + + @SneakyThrows + public static void start(String ngcRegistryBaseUrl) { + stop(); + dockerRegistryMockServer = new WireMockServer(URI.create(ngcRegistryBaseUrl).getPort()); + dockerRegistryMockServer.start(); + + // valid tag + dockerRegistryMockServer + .stubFor(get(urlMatching(VALIDATE_MANIFEST_URL)) + .willReturn(aResponse().withStatus(200) + .withHeader( + HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .withBody(VALID_BODY_RESPONSE))); + // valid tag: latest + dockerRegistryMockServer + .stubFor(get(urlMatching(VALIDATE_MANIFEST_LATEST_URL)) + .willReturn(aResponse().withStatus(200) + .withHeader( + HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .withBody(VALID_BODY_RESPONSE))); + + //valid digest + dockerRegistryMockServer + .stubFor(get(urlMatching("/v2/" + TEST_VALID_DOCKER_NAMESPACE_NAME + + "/" + TEST_VALID_DOCKER_REPO_NAME + + "/manifests/sha256%3A.*")) // url encoding + .willReturn(aResponse().withStatus(200) + .withHeader( + HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .withBody(VALID_BODY_RESPONSE))); + + // no permission + dockerRegistryMockServer + .stubFor(get(urlMatching(VALIDATE_MANIFEST_PERMISSION_DENIED_URL)) + .willReturn(aResponse().withStatus(403))); + + // does not exist + dockerRegistryMockServer + .stubFor(get(urlMatching(VALIDATE_MANIFEST_NOT_EXISTS_URL)) + .willReturn(aResponse().withStatus(404))); + // valid helm + dockerRegistryMockServer + .stubFor(get(urlMatching(VALIDATE_HELM_MANIFEST_URL)) + .willReturn(aResponse().withStatus(200) + .withHeader( + HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .withBody(VALID_BODY_RESPONSE))); + } + + public static void setResponse(String url, byte[] body) { + dockerRegistryMockServer + .stubFor(get(urlPathEqualTo(url)) + .willReturn(aResponse().withStatus(200) + .withHeader( + HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .withBody(body))); + } + + public static void stop() { + if (dockerRegistryMockServer != null) { + dockerRegistryMockServer.stop(); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/ecr/MockEcrPrivateRegistryServer.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/ecr/MockEcrPrivateRegistryServer.java new file mode 100644 index 0000000000..1bc4a1226d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/ecr/MockEcrPrivateRegistryServer.java @@ -0,0 +1,406 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock.ecr; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.matching; +import static com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.nvidia.boot.mock.BootTestConstants.TEST_INVALID_ECR_ACCESS_KEY_ID; +import static com.nvidia.boot.mock.BootTestConstants.TEST_NOT_EXIST_ECR_CONTAINER_IMAGE_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_NOT_EXIST_ECR_CONTAINER_IMAGE_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_NOT_EXIST_ECR_HELM_CHART_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_NOT_EXIST_ECR_HELM_CHART_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_NOT_EXIST_ECR_REPOSITORY_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_PERMISSION_DENIED_ECR_REPOSITORY_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_SERVER_ERROR_ECR_REPOSITORY_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_ECR_ACCESS_KEY_ID; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_ECR_CONTAINER_IMAGE_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_ECR_CONTAINER_IMAGE_REPOSITORY_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_ECR_CONTAINER_IMAGE_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_ECR_HELM_CHART_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_ECR_HELM_CHART_REPOSITORY_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_ECR_HELM_CHART_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_ECR_REGISTRY_ID; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_HELM_CHART_VERSION; + +import com.github.tomakehurst.wiremock.WireMockServer; +import java.net.URI; +import lombok.SneakyThrows; +import org.springframework.http.HttpHeaders; + +public class MockEcrPrivateRegistryServer { + + private static final String ECR_TARGET_DESCRIBE_IMAGES = + "AmazonEC2ContainerRegistry_V20150921.DescribeImages"; + private static final String ECR_TARGET_GET_AUTHORIZATION_TOKEN = + "AmazonEC2ContainerRegistry_V20150921.GetAuthorizationToken"; + private static final String ECR_ROOT_PATH = "/"; + private static final String AWS_AMZ_TARGET_HEADER = "X-Amz-Target"; + private static final String AWS_AMZ_JSON_CONTENT_TYPE = "application/x-amz-json-1.1"; + + private static final String VALID_DESCRIBE_IMAGES_RESPONSE = """ + { + "imageDetails": [ + { + "imageDigest": "%s", + "imageManifestMediaType": "application/vnd.oci.image.index.v1+json", + "imageTags": [ + "%s" + ], + "registryId": "%s", + "repositoryName": "%s" + } + ] + } + """.formatted(TEST_VALID_ECR_CONTAINER_IMAGE_DIGEST, TEST_VALID_ECR_CONTAINER_IMAGE_TAG, + TEST_VALID_ECR_REGISTRY_ID, + TEST_VALID_ECR_CONTAINER_IMAGE_REPOSITORY_NAME); + + private static final String REPOSITORY_NOT_FOUND_RESPONSE = """ + { + "__type": "RepositoryNotFoundException", + "message": "The repository with name '%s' does not exist in the registry with id '%s'" + } + """.formatted(TEST_NOT_EXIST_ECR_REPOSITORY_NAME, TEST_VALID_ECR_REGISTRY_ID); + + private static final String ACCESS_DENIED_RESPONSE = """ + { + "__type": "AccessDeniedException", + "message": "User: user-test-1 is not authorized to perform: ecr:DescribeImages on resource: arn:aws:ecr:us-west-2:%s:repository/%s" + } + """.formatted(TEST_VALID_ECR_REGISTRY_ID, TEST_PERMISSION_DENIED_ECR_REPOSITORY_NAME); + + private static final String IMAGE_TAG_NOT_FOUND_RESPONSE = """ + { + "__type": "ImageNotFoundException", + "message": "The image with imageId {imageTag:'%s'} does not exist within the repository with name '%s' in the registry with id '%s'" + } + """.formatted(TEST_NOT_EXIST_ECR_CONTAINER_IMAGE_TAG, + TEST_VALID_ECR_CONTAINER_IMAGE_REPOSITORY_NAME, + TEST_VALID_ECR_REGISTRY_ID); + + private static final String IMAGE_DIGEST_NOT_FOUND_RESPONSE = """ + { + "__type": "ImageNotFoundException", + "message": "The image with imageId {imageDigest:'%s'} does not exist within the repository with name '%s' in the registry with id '%s'" + } + """.formatted(TEST_NOT_EXIST_ECR_CONTAINER_IMAGE_DIGEST, + TEST_VALID_ECR_CONTAINER_IMAGE_REPOSITORY_NAME, + TEST_VALID_ECR_REGISTRY_ID); + + private static final String SERVER_ERROR_RESPONSE = """ + { + "__type": "ServerException", + "message": "An internal server error occurred" + } + """; + + private static final String VALID_HELM_CHART_RESPONSE = """ + { + "imageDetails": [ + { + "imageDigest": "%s", + "imageManifestMediaType": "application/vnd.oci.image.manifest.v1+json", + "imageTags": [ + "%s" + ], + "registryId": "%s", + "repositoryName": "%s" + } + ] + } + """.formatted(TEST_VALID_ECR_HELM_CHART_DIGEST, TEST_VALID_HELM_CHART_VERSION, + TEST_VALID_ECR_REGISTRY_ID, TEST_VALID_ECR_HELM_CHART_REPOSITORY_NAME); + + private static final String HELM_CHART_TAG_NOT_FOUND_RESPONSE = """ + { + "__type": "ImageNotFoundException", + "message": "The image with imageId {imageTag:'%s'} does not exist within the repository with name '%s' in the registry with id '%s'" + } + """.formatted(TEST_NOT_EXIST_ECR_HELM_CHART_TAG, TEST_VALID_ECR_HELM_CHART_REPOSITORY_NAME, + TEST_VALID_ECR_REGISTRY_ID); + + private static final String HELM_CHART_DIGEST_NOT_FOUND_RESPONSE = """ + { + "__type": "ImageNotFoundException", + "message": "The image with imageId {imageDigest:'%s'} does not exist within the repository with name '%s' in the registry with id '%s'" + } + """.formatted(TEST_NOT_EXIST_ECR_HELM_CHART_DIGEST, + TEST_VALID_ECR_HELM_CHART_REPOSITORY_NAME, + TEST_VALID_ECR_REGISTRY_ID); + + private static final String VALID_GET_AUTHORIZATION_TOKEN_RESPONSE = """ + { + "authorizationData": [ + { + "authorizationToken": "QVdTOmV5SndZWGxzYjJGa0lqb2lZV2RrYldGdWFYVnpJbjA9", + "expiresAt": 1735689600.0, + "proxyEndpoint": "https://%s.dkr.ecr.us-west-2.amazonaws.com" + } + ] + } + """.formatted(TEST_VALID_ECR_REGISTRY_ID); + + private static final String INVALID_CREDENTIALS_RESPONSE = """ + { + "__type": "InvalidSignatureException", + "message": "The request signature we calculated does not match the signature you provided" + } + """; + + private static WireMockServer ecrPrivateRegistryMockServer; + + @SneakyThrows + public static void start(String ecrRegistryAPIBaseUrl) { + stop(); + ecrPrivateRegistryMockServer = new WireMockServer(URI.create(ecrRegistryAPIBaseUrl).getPort()); + ecrPrivateRegistryMockServer.start(); + + // GetAuthorizationToken - Valid credentials + // Checks that Authorization header contains the valid access key ID in the Credential parameter + // Format: AWS4-HMAC-SHA256 Credential=TEST_VALID_ECR_ACCESS_KEY_ID/... + ecrPrivateRegistryMockServer + .stubFor(post(urlPathEqualTo(ECR_ROOT_PATH)) + .withHeader(AWS_AMZ_TARGET_HEADER, + equalTo(ECR_TARGET_GET_AUTHORIZATION_TOKEN)) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(AWS_AMZ_JSON_CONTENT_TYPE)) + .withHeader(HttpHeaders.AUTHORIZATION, + matching(".*Credential=" + TEST_VALID_ECR_ACCESS_KEY_ID + "/.*")) + .withRequestBody(equalTo("{}")) + .willReturn(aResponse().withStatus(200) + .withHeader(HttpHeaders.CONTENT_TYPE, + AWS_AMZ_JSON_CONTENT_TYPE) + .withBody(VALID_GET_AUTHORIZATION_TOKEN_RESPONSE))); + + // GetAuthorizationToken - Invalid credentials + // Checks that Authorization header contains the invalid access key ID in the Credential parameter + // Format: AWS4-HMAC-SHA256 Credential=TEST_INVALID_ECR_ACCESS_KEY_ID/... + ecrPrivateRegistryMockServer + .stubFor(post(urlPathEqualTo(ECR_ROOT_PATH)) + .withHeader(AWS_AMZ_TARGET_HEADER, + equalTo(ECR_TARGET_GET_AUTHORIZATION_TOKEN)) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(AWS_AMZ_JSON_CONTENT_TYPE)) + .withHeader(HttpHeaders.AUTHORIZATION, + matching(".*Credential=" + TEST_INVALID_ECR_ACCESS_KEY_ID + "/.*")) + .withRequestBody(equalTo("{}")) + .willReturn(aResponse().withStatus(400) + .withHeader(HttpHeaders.CONTENT_TYPE, + AWS_AMZ_JSON_CONTENT_TYPE) + .withBody(INVALID_CREDENTIALS_RESPONSE))); + + // Server error + ecrPrivateRegistryMockServer + .stubFor(post(urlPathEqualTo(ECR_ROOT_PATH)) + .withHeader(AWS_AMZ_TARGET_HEADER, + equalTo(ECR_TARGET_DESCRIBE_IMAGES)) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(AWS_AMZ_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.repositoryName", + equalTo(TEST_SERVER_ERROR_ECR_REPOSITORY_NAME))) + .willReturn(aResponse().withStatus(500) + .withHeader(HttpHeaders.CONTENT_TYPE, + AWS_AMZ_JSON_CONTENT_TYPE) + .withBody(SERVER_ERROR_RESPONSE))); + + // Permission denied repository + ecrPrivateRegistryMockServer + .stubFor(post(urlPathEqualTo(ECR_ROOT_PATH)) + .withHeader(AWS_AMZ_TARGET_HEADER, + equalTo(ECR_TARGET_DESCRIBE_IMAGES)) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(AWS_AMZ_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.repositoryName", + equalTo(TEST_PERMISSION_DENIED_ECR_REPOSITORY_NAME))) + .willReturn(aResponse().withStatus(403) + .withHeader(HttpHeaders.CONTENT_TYPE, + AWS_AMZ_JSON_CONTENT_TYPE) + .withBody(ACCESS_DENIED_RESPONSE))); + + // Repository not found + ecrPrivateRegistryMockServer + .stubFor(post(urlPathEqualTo(ECR_ROOT_PATH)) + .withHeader(AWS_AMZ_TARGET_HEADER, + equalTo(ECR_TARGET_DESCRIBE_IMAGES)) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(AWS_AMZ_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.repositoryName", + equalTo(TEST_NOT_EXIST_ECR_REPOSITORY_NAME))) + .willReturn(aResponse().withStatus(400) + .withHeader(HttpHeaders.CONTENT_TYPE, + AWS_AMZ_JSON_CONTENT_TYPE) + .withBody(REPOSITORY_NOT_FOUND_RESPONSE))); + + // Valid repository and image tag + ecrPrivateRegistryMockServer + .stubFor(post(urlPathEqualTo(ECR_ROOT_PATH)) + .withHeader(AWS_AMZ_TARGET_HEADER, + equalTo(ECR_TARGET_DESCRIBE_IMAGES)) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(AWS_AMZ_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.repositoryName", + equalTo(TEST_VALID_ECR_CONTAINER_IMAGE_REPOSITORY_NAME))) + .withRequestBody(matchingJsonPath("$.imageIds[0].imageTag", + equalTo(TEST_VALID_ECR_CONTAINER_IMAGE_TAG))) + .willReturn(aResponse().withStatus(200) + .withHeader(HttpHeaders.CONTENT_TYPE, + AWS_AMZ_JSON_CONTENT_TYPE) + .withBody(VALID_DESCRIBE_IMAGES_RESPONSE))); + + // Valid repository and image digest + ecrPrivateRegistryMockServer + .stubFor(post(urlPathEqualTo(ECR_ROOT_PATH)) + .withHeader(AWS_AMZ_TARGET_HEADER, + equalTo(ECR_TARGET_DESCRIBE_IMAGES)) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(AWS_AMZ_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.repositoryName", + equalTo(TEST_VALID_ECR_CONTAINER_IMAGE_REPOSITORY_NAME))) + .withRequestBody(matchingJsonPath("$.imageIds[0].imageDigest", + equalTo(TEST_VALID_ECR_CONTAINER_IMAGE_DIGEST))) + .willReturn(aResponse().withStatus(200) + .withHeader(HttpHeaders.CONTENT_TYPE, + AWS_AMZ_JSON_CONTENT_TYPE) + .withBody(VALID_DESCRIBE_IMAGES_RESPONSE))); + + // Image tag not found (valid repo but invalid tag) + ecrPrivateRegistryMockServer + .stubFor(post(urlPathEqualTo(ECR_ROOT_PATH)) + .withHeader(AWS_AMZ_TARGET_HEADER, + equalTo(ECR_TARGET_DESCRIBE_IMAGES)) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(AWS_AMZ_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.repositoryName", + equalTo(TEST_VALID_ECR_CONTAINER_IMAGE_REPOSITORY_NAME))) + .withRequestBody(matchingJsonPath("$.imageIds[0].imageTag", + equalTo(TEST_NOT_EXIST_ECR_CONTAINER_IMAGE_TAG))) + .willReturn(aResponse().withStatus(400) + .withHeader(HttpHeaders.CONTENT_TYPE, + AWS_AMZ_JSON_CONTENT_TYPE) + .withBody(IMAGE_TAG_NOT_FOUND_RESPONSE))); + + // Image not found (valid repo but invalid digest) + ecrPrivateRegistryMockServer + .stubFor(post(urlPathEqualTo(ECR_ROOT_PATH)) + .withHeader(AWS_AMZ_TARGET_HEADER, + equalTo(ECR_TARGET_DESCRIBE_IMAGES)) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(AWS_AMZ_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.repositoryName", + equalTo(TEST_VALID_ECR_CONTAINER_IMAGE_REPOSITORY_NAME))) + .withRequestBody(matchingJsonPath("$.imageIds[0].imageDigest", + equalTo(TEST_NOT_EXIST_ECR_CONTAINER_IMAGE_DIGEST))) + .willReturn(aResponse().withStatus(400) + .withHeader(HttpHeaders.CONTENT_TYPE, + AWS_AMZ_JSON_CONTENT_TYPE) + .withBody(IMAGE_DIGEST_NOT_FOUND_RESPONSE))); + + // Valid Helm chart with tag + ecrPrivateRegistryMockServer + .stubFor(post(urlPathEqualTo(ECR_ROOT_PATH)) + .withHeader(AWS_AMZ_TARGET_HEADER, + equalTo(ECR_TARGET_DESCRIBE_IMAGES)) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(AWS_AMZ_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.repositoryName", + equalTo(TEST_VALID_ECR_HELM_CHART_REPOSITORY_NAME))) + .withRequestBody(matchingJsonPath("$.imageIds[0].imageTag", + equalTo(TEST_VALID_ECR_HELM_CHART_TAG))) + .willReturn(aResponse().withStatus(200) + .withHeader(HttpHeaders.CONTENT_TYPE, + AWS_AMZ_JSON_CONTENT_TYPE) + .withBody(VALID_HELM_CHART_RESPONSE))); + + // Valid Helm chart with digest + ecrPrivateRegistryMockServer + .stubFor(post(urlPathEqualTo(ECR_ROOT_PATH)) + .withHeader(AWS_AMZ_TARGET_HEADER, + equalTo(ECR_TARGET_DESCRIBE_IMAGES)) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(AWS_AMZ_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.repositoryName", + equalTo(TEST_VALID_ECR_HELM_CHART_REPOSITORY_NAME))) + .withRequestBody(matchingJsonPath("$.imageIds[0].imageDigest", + equalTo(TEST_VALID_ECR_HELM_CHART_DIGEST))) + .willReturn(aResponse().withStatus(200) + .withHeader(HttpHeaders.CONTENT_TYPE, + AWS_AMZ_JSON_CONTENT_TYPE) + .withBody(VALID_HELM_CHART_RESPONSE))); + + // Helm chart not found with invalid tag + ecrPrivateRegistryMockServer + .stubFor(post(urlPathEqualTo(ECR_ROOT_PATH)) + .withHeader(AWS_AMZ_TARGET_HEADER, + equalTo(ECR_TARGET_DESCRIBE_IMAGES)) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(AWS_AMZ_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.repositoryName", + equalTo(TEST_VALID_ECR_HELM_CHART_REPOSITORY_NAME))) + .withRequestBody(matchingJsonPath("$.imageIds[0].imageTag", + equalTo(TEST_NOT_EXIST_ECR_HELM_CHART_TAG))) + .willReturn(aResponse().withStatus(400) + .withHeader(HttpHeaders.CONTENT_TYPE, + AWS_AMZ_JSON_CONTENT_TYPE) + .withBody(HELM_CHART_TAG_NOT_FOUND_RESPONSE))); + + // Helm chart not found with invalid digest + ecrPrivateRegistryMockServer + .stubFor(post(urlPathEqualTo(ECR_ROOT_PATH)) + .withHeader(AWS_AMZ_TARGET_HEADER, + equalTo(ECR_TARGET_DESCRIBE_IMAGES)) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(AWS_AMZ_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.repositoryName", + equalTo(TEST_VALID_ECR_HELM_CHART_REPOSITORY_NAME))) + .withRequestBody(matchingJsonPath("$.imageIds[0].imageDigest", + equalTo(TEST_NOT_EXIST_ECR_HELM_CHART_DIGEST))) + .willReturn(aResponse().withStatus(400) + .withHeader(HttpHeaders.CONTENT_TYPE, + AWS_AMZ_JSON_CONTENT_TYPE) + .withBody( + HELM_CHART_DIGEST_NOT_FOUND_RESPONSE))); + } + + public static void setCustomResponse(String repositoryName, String imageDigest, + String responseBody) { + ecrPrivateRegistryMockServer + .stubFor(post(urlPathEqualTo(ECR_ROOT_PATH)) + .withHeader(AWS_AMZ_TARGET_HEADER, + equalTo(ECR_TARGET_DESCRIBE_IMAGES)) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(AWS_AMZ_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.repositoryName", + equalTo(repositoryName))) + .withRequestBody(matchingJsonPath("$.imageIds[0].imageDigest", + equalTo(imageDigest))) + .willReturn(aResponse().withStatus(200) + .withHeader(HttpHeaders.CONTENT_TYPE, + AWS_AMZ_JSON_CONTENT_TYPE) + .withBody(responseBody))); + } + + public static void stop() { + if (ecrPrivateRegistryMockServer != null) { + ecrPrivateRegistryMockServer.stop(); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/ecr/MockEcrPublicRegistryServer.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/ecr/MockEcrPublicRegistryServer.java new file mode 100644 index 0000000000..e7656113da --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/ecr/MockEcrPublicRegistryServer.java @@ -0,0 +1,399 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock.ecr; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.matching; +import static com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_PUBLIC_Alias; +import static com.nvidia.boot.mock.BootTestConstants.TEST_INVALID_ECR_PUBLIC_ACCESS_KEY_ID; +import static com.nvidia.boot.mock.BootTestConstants.TEST_NOT_EXIST_ECR_PUBLIC_CONTAINER_IMAGE_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_NOT_EXIST_ECR_PUBLIC_CONTAINER_IMAGE_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_NOT_EXIST_ECR_PUBLIC_HELM_CHART_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_NOT_EXIST_ECR_PUBLIC_HELM_CHART_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_NOT_EXIST_ECR_PUBLIC_REPOSITORY_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_PERMISSION_DENIED_ECR_PUBLIC_REPOSITORY_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_ECR_PUBLIC_ACCESS_KEY_ID; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_ECR_PUBLIC_CONTAINER_IMAGE_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_ECR_PUBLIC_CONTAINER_IMAGE_REPOSITORY_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_ECR_PUBLIC_CONTAINER_IMAGE_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_ECR_PUBLIC_HELM_CHART_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_ECR_PUBLIC_HELM_CHART_REPOSITORY_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_ECR_PUBLIC_HELM_CHART_TAG; + +import com.github.tomakehurst.wiremock.WireMockServer; +import java.net.URI; +import lombok.SneakyThrows; +import org.springframework.http.HttpHeaders; + +public class MockEcrPublicRegistryServer { + private static final String ECR_TARGET_DESCRIBE_IMAGES = + "SpencerFrontendService.DescribeImages"; + private static final String ECR_TARGET_GET_AUTHORIZATION_TOKEN = + "SpencerFrontendService.GetAuthorizationToken"; + private static final String ECR_ROOT_PATH = "/"; + private static final String AWS_AMZ_TARGET_HEADER = "X-Amz-Target"; + private static final String AWS_AMZ_JSON_CONTENT_TYPE = "application/x-amz-json-1.1"; + + private static final String VALID_DESCRIBE_IMAGES_RESPONSE = """ + { + "imageDetails": [ + { + "imageDigest": "%s", + "imageManifestMediaType": "application/vnd.oci.image.index.v1+json", + "imageTags": [ + "%s" + ], + "repositoryName": "%s" + } + ] + } + """.formatted(TEST_VALID_ECR_PUBLIC_CONTAINER_IMAGE_DIGEST, + TEST_VALID_ECR_PUBLIC_CONTAINER_IMAGE_TAG, + TEST_VALID_ECR_PUBLIC_CONTAINER_IMAGE_REPOSITORY_NAME); + + private static final String REPOSITORY_NOT_FOUND_RESPONSE = """ + { + "__type": "RepositoryNotFoundException", + "message": "The repository with name '%s' does not exist in the registry with alias '%s'" + } + """.formatted(TEST_NOT_EXIST_ECR_PUBLIC_REPOSITORY_NAME, TEST_ECR_PUBLIC_Alias); + + private static final String ACCESS_DENIED_RESPONSE = """ + { + "__type": "AccessDeniedException", + "message": "User: user-test-1 is not authorized to perform: ecr-public:DescribeImages on resource: arn:aws:ecr-public::alias/%s:repository/%s" + } + """.formatted(TEST_ECR_PUBLIC_Alias, TEST_PERMISSION_DENIED_ECR_PUBLIC_REPOSITORY_NAME); + + private static final String IMAGE_TAG_NOT_FOUND_RESPONSE = """ + { + "__type": "ImageNotFoundException", + "message": "The image with imageId {imageTag:'%s'} does not exist within the repository with name '%s' in the registry with alias '%s'" + } + """.formatted(TEST_NOT_EXIST_ECR_PUBLIC_CONTAINER_IMAGE_TAG, + TEST_VALID_ECR_PUBLIC_CONTAINER_IMAGE_REPOSITORY_NAME, + TEST_ECR_PUBLIC_Alias); + + private static final String IMAGE_DIGEST_NOT_FOUND_RESPONSE = """ + { + "__type": "ImageNotFoundException", + "message": "The image with imageId {imageDigest:'%s'} does not exist within the repository with name '%s' in the registry with alias '%s'" + } + """.formatted(TEST_NOT_EXIST_ECR_PUBLIC_CONTAINER_IMAGE_DIGEST, + TEST_VALID_ECR_PUBLIC_CONTAINER_IMAGE_REPOSITORY_NAME, + TEST_ECR_PUBLIC_Alias); + + private static final String SERVER_ERROR_RESPONSE = """ + { + "__type": "ServerException", + "message": "An internal server error occurred" + } + """; + + private static final String VALID_HELM_CHART_RESPONSE = """ + { + "imageDetails": [ + { + "imageDigest": "%s", + "imageManifestMediaType": "application/vnd.oci.image.manifest.v1+json", + "imageTags": [ + "%s" + ], + "registryId": "%s", + "repositoryName": "%s" + } + ] + } + """.formatted(TEST_VALID_ECR_PUBLIC_HELM_CHART_DIGEST, TEST_VALID_ECR_PUBLIC_HELM_CHART_TAG, + TEST_ECR_PUBLIC_Alias, TEST_VALID_ECR_PUBLIC_HELM_CHART_REPOSITORY_NAME); + + private static final String HELM_CHART_TAG_NOT_FOUND_RESPONSE = """ + { + "__type": "ImageNotFoundException", + "message": "The image with imageId {imageTag:'%s'} does not exist within the repository with name '%s' in the registry with alias '%s'" + } + """.formatted(TEST_NOT_EXIST_ECR_PUBLIC_HELM_CHART_TAG, TEST_VALID_ECR_PUBLIC_HELM_CHART_REPOSITORY_NAME, + TEST_ECR_PUBLIC_Alias); + + private static final String HELM_CHART_DIGEST_NOT_FOUND_RESPONSE = """ + { + "__type": "ImageNotFoundException", + "message": "The image with imageId {imageDigest:'%s'} does not exist within the repository with name '%s' in the registry with alias '%s'" + } + """.formatted(TEST_NOT_EXIST_ECR_PUBLIC_HELM_CHART_DIGEST, + TEST_VALID_ECR_PUBLIC_HELM_CHART_REPOSITORY_NAME, + TEST_ECR_PUBLIC_Alias); + + private static final String VALID_GET_AUTHORIZATION_TOKEN_RESPONSE = """ + { + "authorizationData": { + "authorizationToken": "QVdTOmV5SndZWGxzYjJGa0lqb2lZV2RrYldGdWFYVnpJbjA9", + "expiresAt": 1735689600.0 + } + } + """; + + private static final String INVALID_CREDENTIALS_RESPONSE = """ + { + "__type": "InvalidSignatureException", + "message": "The request signature we calculated does not match the signature you provided" + } + """; + + private static WireMockServer ecrPublicRegistryMockServer; + + @SneakyThrows + public static void start(String ecrRegistryAPIBaseUrl) { + stop(); + ecrPublicRegistryMockServer = new WireMockServer(URI.create(ecrRegistryAPIBaseUrl).getPort()); + ecrPublicRegistryMockServer.start(); + + // GetAuthorizationToken - Valid credentials + // Checks that Authorization header contains the valid ECR public access key ID in the Credential parameter + // Format: AWS4-HMAC-SHA256 Credential=TEST_VALID_ECR_PUBLIC_ACCESS_KEY_ID/... + ecrPublicRegistryMockServer + .stubFor(post(urlPathEqualTo(ECR_ROOT_PATH)) + .withHeader(AWS_AMZ_TARGET_HEADER, + equalTo(ECR_TARGET_GET_AUTHORIZATION_TOKEN)) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(AWS_AMZ_JSON_CONTENT_TYPE)) + .withHeader(HttpHeaders.AUTHORIZATION, + matching(".*Credential=" + TEST_VALID_ECR_PUBLIC_ACCESS_KEY_ID + "/.*")) + .withRequestBody(equalTo("{}")) + .willReturn(aResponse().withStatus(200) + .withHeader(HttpHeaders.CONTENT_TYPE, + AWS_AMZ_JSON_CONTENT_TYPE) + .withBody(VALID_GET_AUTHORIZATION_TOKEN_RESPONSE))); + + // GetAuthorizationToken - Invalid credentials + // Checks that Authorization header contains the invalid ECR public access key ID in the Credential parameter + // Format: AWS4-HMAC-SHA256 Credential=TEST_INVALID_ECR_PUBLIC_ACCESS_KEY_ID/... + ecrPublicRegistryMockServer + .stubFor(post(urlPathEqualTo(ECR_ROOT_PATH)) + .withHeader(AWS_AMZ_TARGET_HEADER, + equalTo(ECR_TARGET_GET_AUTHORIZATION_TOKEN)) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(AWS_AMZ_JSON_CONTENT_TYPE)) + .withHeader(HttpHeaders.AUTHORIZATION, + matching(".*Credential=" + TEST_INVALID_ECR_PUBLIC_ACCESS_KEY_ID + "/.*")) + .withRequestBody(equalTo("{}")) + .willReturn(aResponse().withStatus(400) + .withHeader(HttpHeaders.CONTENT_TYPE, + AWS_AMZ_JSON_CONTENT_TYPE) + .withBody(INVALID_CREDENTIALS_RESPONSE))); + + // Server error + ecrPublicRegistryMockServer + .stubFor(post(urlPathEqualTo(ECR_ROOT_PATH)) + .withHeader(AWS_AMZ_TARGET_HEADER, + equalTo(ECR_TARGET_DESCRIBE_IMAGES)) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(AWS_AMZ_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.repositoryName", + equalTo(TEST_PERMISSION_DENIED_ECR_PUBLIC_REPOSITORY_NAME))) + .willReturn(aResponse().withStatus(500) + .withHeader(HttpHeaders.CONTENT_TYPE, + AWS_AMZ_JSON_CONTENT_TYPE) + .withBody(SERVER_ERROR_RESPONSE))); + + // Permission denied repository + ecrPublicRegistryMockServer + .stubFor(post(urlPathEqualTo(ECR_ROOT_PATH)) + .withHeader(AWS_AMZ_TARGET_HEADER, + equalTo(ECR_TARGET_DESCRIBE_IMAGES)) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(AWS_AMZ_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.repositoryName", + equalTo(TEST_PERMISSION_DENIED_ECR_PUBLIC_REPOSITORY_NAME))) + .willReturn(aResponse().withStatus(403) + .withHeader(HttpHeaders.CONTENT_TYPE, + AWS_AMZ_JSON_CONTENT_TYPE) + .withBody(ACCESS_DENIED_RESPONSE))); + + // Repository not found + ecrPublicRegistryMockServer + .stubFor(post(urlPathEqualTo(ECR_ROOT_PATH)) + .withHeader(AWS_AMZ_TARGET_HEADER, + equalTo(ECR_TARGET_DESCRIBE_IMAGES)) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(AWS_AMZ_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.repositoryName", + equalTo(TEST_NOT_EXIST_ECR_PUBLIC_REPOSITORY_NAME))) + .willReturn(aResponse().withStatus(400) + .withHeader(HttpHeaders.CONTENT_TYPE, + AWS_AMZ_JSON_CONTENT_TYPE) + .withBody(REPOSITORY_NOT_FOUND_RESPONSE))); + + // Valid repository and image tag + ecrPublicRegistryMockServer + .stubFor(post(urlPathEqualTo(ECR_ROOT_PATH)) + .withHeader(AWS_AMZ_TARGET_HEADER, + equalTo(ECR_TARGET_DESCRIBE_IMAGES)) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(AWS_AMZ_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.repositoryName", + equalTo(TEST_VALID_ECR_PUBLIC_CONTAINER_IMAGE_REPOSITORY_NAME))) + .withRequestBody(matchingJsonPath("$.imageIds[0].imageTag", + equalTo(TEST_VALID_ECR_PUBLIC_CONTAINER_IMAGE_TAG))) + .willReturn(aResponse().withStatus(200) + .withHeader(HttpHeaders.CONTENT_TYPE, + AWS_AMZ_JSON_CONTENT_TYPE) + .withBody(VALID_DESCRIBE_IMAGES_RESPONSE))); + + // Valid repository and image digest + ecrPublicRegistryMockServer + .stubFor(post(urlPathEqualTo(ECR_ROOT_PATH)) + .withHeader(AWS_AMZ_TARGET_HEADER, + equalTo(ECR_TARGET_DESCRIBE_IMAGES)) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(AWS_AMZ_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.repositoryName", + equalTo(TEST_VALID_ECR_PUBLIC_CONTAINER_IMAGE_REPOSITORY_NAME))) + .withRequestBody(matchingJsonPath("$.imageIds[0].imageDigest", + equalTo(TEST_VALID_ECR_PUBLIC_CONTAINER_IMAGE_DIGEST))) + .willReturn(aResponse().withStatus(200) + .withHeader(HttpHeaders.CONTENT_TYPE, + AWS_AMZ_JSON_CONTENT_TYPE) + .withBody(VALID_DESCRIBE_IMAGES_RESPONSE))); + + // Image tag not found (valid repo but invalid tag) + ecrPublicRegistryMockServer + .stubFor(post(urlPathEqualTo(ECR_ROOT_PATH)) + .withHeader(AWS_AMZ_TARGET_HEADER, + equalTo(ECR_TARGET_DESCRIBE_IMAGES)) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(AWS_AMZ_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.repositoryName", + equalTo(TEST_VALID_ECR_PUBLIC_CONTAINER_IMAGE_REPOSITORY_NAME))) + .withRequestBody(matchingJsonPath("$.imageIds[0].imageTag", + equalTo(TEST_NOT_EXIST_ECR_PUBLIC_CONTAINER_IMAGE_TAG))) + .willReturn(aResponse().withStatus(400) + .withHeader(HttpHeaders.CONTENT_TYPE, + AWS_AMZ_JSON_CONTENT_TYPE) + .withBody(IMAGE_TAG_NOT_FOUND_RESPONSE))); + + // Image not found (valid repo but invalid digest) + ecrPublicRegistryMockServer + .stubFor(post(urlPathEqualTo(ECR_ROOT_PATH)) + .withHeader(AWS_AMZ_TARGET_HEADER, + equalTo(ECR_TARGET_DESCRIBE_IMAGES)) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(AWS_AMZ_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.repositoryName", + equalTo(TEST_VALID_ECR_PUBLIC_CONTAINER_IMAGE_REPOSITORY_NAME))) + .withRequestBody(matchingJsonPath("$.imageIds[0].imageDigest", + equalTo(TEST_NOT_EXIST_ECR_PUBLIC_CONTAINER_IMAGE_DIGEST))) + .willReturn(aResponse().withStatus(400) + .withHeader(HttpHeaders.CONTENT_TYPE, + AWS_AMZ_JSON_CONTENT_TYPE) + .withBody(IMAGE_DIGEST_NOT_FOUND_RESPONSE))); + + // Valid Helm chart with tag + ecrPublicRegistryMockServer + .stubFor(post(urlPathEqualTo(ECR_ROOT_PATH)) + .withHeader(AWS_AMZ_TARGET_HEADER, + equalTo(ECR_TARGET_DESCRIBE_IMAGES)) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(AWS_AMZ_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.repositoryName", + equalTo(TEST_VALID_ECR_PUBLIC_HELM_CHART_REPOSITORY_NAME))) + .withRequestBody(matchingJsonPath("$.imageIds[0].imageTag", + equalTo(TEST_VALID_ECR_PUBLIC_HELM_CHART_TAG))) + .willReturn(aResponse().withStatus(200) + .withHeader(HttpHeaders.CONTENT_TYPE, + AWS_AMZ_JSON_CONTENT_TYPE) + .withBody(VALID_HELM_CHART_RESPONSE))); + + // Valid Helm chart with digest + ecrPublicRegistryMockServer + .stubFor(post(urlPathEqualTo(ECR_ROOT_PATH)) + .withHeader(AWS_AMZ_TARGET_HEADER, + equalTo(ECR_TARGET_DESCRIBE_IMAGES)) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(AWS_AMZ_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.repositoryName", + equalTo(TEST_VALID_ECR_PUBLIC_HELM_CHART_REPOSITORY_NAME))) + .withRequestBody(matchingJsonPath("$.imageIds[0].imageDigest", + equalTo(TEST_VALID_ECR_PUBLIC_HELM_CHART_DIGEST))) + .willReturn(aResponse().withStatus(200) + .withHeader(HttpHeaders.CONTENT_TYPE, + AWS_AMZ_JSON_CONTENT_TYPE) + .withBody(VALID_HELM_CHART_RESPONSE))); + + // Helm chart not found with invalid tag + ecrPublicRegistryMockServer + .stubFor(post(urlPathEqualTo(ECR_ROOT_PATH)) + .withHeader(AWS_AMZ_TARGET_HEADER, + equalTo(ECR_TARGET_DESCRIBE_IMAGES)) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(AWS_AMZ_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.repositoryName", + equalTo(TEST_VALID_ECR_PUBLIC_HELM_CHART_REPOSITORY_NAME))) + .withRequestBody(matchingJsonPath("$.imageIds[0].imageTag", + equalTo(TEST_NOT_EXIST_ECR_PUBLIC_HELM_CHART_TAG))) + .willReturn(aResponse().withStatus(400) + .withHeader(HttpHeaders.CONTENT_TYPE, + AWS_AMZ_JSON_CONTENT_TYPE) + .withBody(HELM_CHART_TAG_NOT_FOUND_RESPONSE))); + + // Helm chart not found with invalid digest + ecrPublicRegistryMockServer + .stubFor(post(urlPathEqualTo(ECR_ROOT_PATH)) + .withHeader(AWS_AMZ_TARGET_HEADER, + equalTo(ECR_TARGET_DESCRIBE_IMAGES)) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(AWS_AMZ_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.repositoryName", + equalTo(TEST_VALID_ECR_PUBLIC_HELM_CHART_REPOSITORY_NAME))) + .withRequestBody(matchingJsonPath("$.imageIds[0].imageDigest", + equalTo(TEST_NOT_EXIST_ECR_PUBLIC_HELM_CHART_DIGEST))) + .willReturn(aResponse().withStatus(400) + .withHeader(HttpHeaders.CONTENT_TYPE, + AWS_AMZ_JSON_CONTENT_TYPE) + .withBody( + HELM_CHART_DIGEST_NOT_FOUND_RESPONSE))); + } + + public static void setCustomResponse(String repositoryName, String imageDigest, + String responseBody) { + ecrPublicRegistryMockServer + .stubFor(post(urlPathEqualTo(ECR_ROOT_PATH)) + .withHeader(AWS_AMZ_TARGET_HEADER, + equalTo(ECR_TARGET_DESCRIBE_IMAGES)) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(AWS_AMZ_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.repositoryName", + equalTo(repositoryName))) + .withRequestBody(matchingJsonPath("$.imageIds[0].imageDigest", + equalTo(imageDigest))) + .willReturn(aResponse().withStatus(200) + .withHeader(HttpHeaders.CONTENT_TYPE, + AWS_AMZ_JSON_CONTENT_TYPE) + .withBody(responseBody))); + } + + public static void stop() { + if (ecrPublicRegistryMockServer != null) { + ecrPublicRegistryMockServer.stop(); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/harbor/MockHarborAuthServer.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/harbor/MockHarborAuthServer.java new file mode 100644 index 0000000000..1adf9b0c0c --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/harbor/MockHarborAuthServer.java @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock.harbor; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; + +import com.github.tomakehurst.wiremock.WireMockServer; +import java.net.URI; +import lombok.SneakyThrows; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; + +public class MockHarborAuthServer { + + // Harbor token endpoint with scope - GET /service/token?service=harbor-registry&scope=... + // The service is always harbor-registry and hard coded in harbor repo. + // Used for artifact validation + private static final String HARBOR_TOKEN_WITH_SCOPE_URL_PATTERN = + "/service/token\\?service=harbor-registry&scope=.*"; + + // Harbor token endpoint without scope - GET /service/token?service=harbor-registry + // Used for credential validation only + private static final String HARBOR_TOKEN_WITHOUT_SCOPE_URL_PATTERN = + "/service/token\\?service=harbor-registry$"; + + private static final String OAUTH2_TOKEN_RESPONSE = """ + { + "token":"eyJhbGciOiJSUzI1NiIsImtpZCI6IlZXVUg6WFJSNjpBRFFQOjYzVEg6VUgyNjpOQVREOlNIQ1Y6NzRKRDpaNzdXOkpJWUE6N0xQSjpHREVJIiwidHlwIjoiSldUIn0.eyJpc3MiOiJoYXJib3ItdG9rZW4taXNzdWVyIiwic3ViIjoiaHVhd2VpYyIsImF1ZCI6ImhhcmJvci1yZWdpc3RyeSIsImV4cCI6MTc1ODU0MTg4MCwibmJmIjoxNzU4NTQwMDgwLCJpYXQiOjE3NTg1NDAwODAsImp0aSI6InZ4NFY5dTlPMkpaaWQxQnEiLCJhY2Nlc3MiOlt7InR5cGUiOiJyZXBvc2l0b3J5IiwibmFtZSI6Imh1YXdlaWMtdGVzdC9idXN5Ym94IiwiYWN0aW9ucyI6WyJwdWxsIiwicHVzaCIsImRlbGV0ZSJdfV19.m3sb4tGhZCkLHJm6XGoSVpKkT-_FE9vV7Rxh7Lt_1ziygTi7DBlytXEEwCoIsJVpClc_yPivxVlNDpqxEsll9bU4bun-2ZkI7DwLpjXyrGBC1G6qZ7NJRu4FqtbIsQtP6sSQUesIDY4KA0kRKJjxb8T8v77676so0DpCwhZ2qMvLndOz7cbkGPADxQ2cxX8bkQz08ZSFbIOy8mGRaMkjx4jrW5w4biI5x6vSH_0COs2FvObdoT1olZ2QVcjvgPTapHlO9LHs0hpPC4CJ-npyb3ch21SGmYcI-D04e1p96ujImzHoP4kLiGGksVoIRMJwLop8J5rciRKg6VLqcFXj2g", + "access_token":"", + "expires_in":1800 + } + """; + + private static WireMockServer acrAuthMockServer; + + @SneakyThrows + public static void start(String acrAuthBaseUrl) { + stop(); + acrAuthMockServer = new WireMockServer(URI.create(acrAuthBaseUrl).getPort()); + acrAuthMockServer.start(); + + // Harbor token endpoint with scope - for artifact validation + acrAuthMockServer + .stubFor(get(urlMatching(HARBOR_TOKEN_WITH_SCOPE_URL_PATTERN)) + .willReturn(aResponse().withStatus(200) + .withHeader( + HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .withBody(OAUTH2_TOKEN_RESPONSE))); + + // Harbor token endpoint without scope - for credential validation + acrAuthMockServer + .stubFor(get(urlMatching(HARBOR_TOKEN_WITHOUT_SCOPE_URL_PATTERN)) + .willReturn(aResponse().withStatus(200) + .withHeader( + HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .withBody(OAUTH2_TOKEN_RESPONSE))); + } + + public static void setResponse(String url, byte[] body) { + acrAuthMockServer + .stubFor(get(urlPathEqualTo(url)) + .willReturn(aResponse().withStatus(200) + .withHeader( + HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .withBody(body))); + } + + public static void stop() { + if (acrAuthMockServer != null) { + acrAuthMockServer.stop(); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/ngc/MockCasServer.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/ngc/MockCasServer.java new file mode 100644 index 0000000000..c36107a4cf --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/ngc/MockCasServer.java @@ -0,0 +1,718 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock.ngc; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.delete; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.matching; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching; +import static com.nvidia.boot.mock.BootTestConstants.TEST_UNKNOWN_HELM_CHART_VERSION; +import static com.nvidia.boot.mock.BootTestConstants.TEST_UNKNOWN_ORG_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_UNKNOWN_TEAM_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_HELM_CHART_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_HELM_CHART_VERSION; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_MODEL_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_MODEL_NAME_2; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_ORG_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_ORG_NAME_INVALID_KEY; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_RESOURCE_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_RESOURCE_NAME_2; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_SMALL_MODEL_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_TEAM_NAME; +import static org.springframework.http.HttpHeaders.CONTENT_TYPE; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +import tools.jackson.databind.json.JsonMapper; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.MappingBuilder; +import com.github.tomakehurst.wiremock.matching.AnythingPattern; +import java.net.URI; +import java.util.List; +import lombok.Data; +import lombok.SneakyThrows; +import org.springframework.http.HttpHeaders; + +public class MockCasServer { + + private static final JsonMapper JSON_MAPPER = JsonMapper.builder().build(); + private static WireMockServer casMockServer; + private static WireMockServer authnMockServer; + + // Model Artifacts + public static final String MODEL_SIZE_URL = + "/v2/org/" + TEST_VALID_ORG_NAME + "/models/" + TEST_VALID_MODEL_NAME + "/versions/0.1"; + public static final String MODEL_FILES_URL = + "/v2/org/" + TEST_VALID_ORG_NAME + "/models/" + TEST_VALID_MODEL_NAME + "/0.1/files"; + public static final String MODEL_FILES_URL_WITH_VERSION = + "/v2/org/" + TEST_VALID_ORG_NAME + "/models/" + TEST_VALID_MODEL_NAME + + "/versions/0.1/files"; + public static final String MODEL_FILES_URL_2 = + "/v2/org/" + TEST_VALID_ORG_NAME + "/models/" + TEST_VALID_MODEL_NAME_2 + "/0.1/files"; + public static final String MODEL_FILES_NOT_EXIST_URL = + "/v2/org/" + TEST_VALID_ORG_NAME + "/models/" + TEST_VALID_MODEL_NAME + "/0.5/files"; + public static final String MODEL_FILE_PERMISSION_DENIED_URL = + "/v2/org/" + TEST_UNKNOWN_ORG_NAME + "/models/" + TEST_VALID_MODEL_NAME + "/0.1/files"; + public static final String MODEL_SIZE_NOT_EXISTS_URL = + "/v2/org/" + TEST_VALID_ORG_NAME + "/models/" + TEST_VALID_MODEL_NAME + "/versions/0.5"; + public static final String MODEL_SIZE_PERMISSION_DENIED_URL = + "/v2/org/" + TEST_UNKNOWN_ORG_NAME + "/models/" + TEST_VALID_MODEL_NAME + + "/versions/0.1"; + public static final String MODEL_SMALL_SIZE_URL = + "/v2/org/" + TEST_VALID_ORG_NAME + "/models/" + TEST_VALID_SMALL_MODEL_NAME + + "/versions/0.1"; + public static final String MODEL_SMALL_SIZE_FILES_URL = + "/v2/org/" + TEST_VALID_ORG_NAME + "/models/" + TEST_VALID_SMALL_MODEL_NAME + + "/0.1/files"; + public static final String MODEL_SIZE_URL_WITH_TEAM = + "/v2/org/" + TEST_VALID_ORG_NAME + "/team/" + TEST_VALID_TEAM_NAME + + "/models/" + TEST_VALID_MODEL_NAME + "/versions/0.1"; + public static final String MODEL_SIZE_URL_WITH_TEAM_2 = + "/v2/org/" + TEST_VALID_ORG_NAME + "/team/" + TEST_VALID_TEAM_NAME + + "/models/" + TEST_VALID_MODEL_NAME_2 + "/versions/0.1"; + public static final String MODEL_FILES_URL_WITH_TEAM = + "/v2/org/" + TEST_VALID_ORG_NAME + "/team/" + TEST_VALID_TEAM_NAME + + "/models/" + TEST_VALID_MODEL_NAME + "/0.1/files"; + public static final String MODEL_FILES_URL_WITH_TEAM_2 = + "/v2/org/" + TEST_VALID_ORG_NAME + "/team/" + TEST_VALID_TEAM_NAME + + "/models/" + TEST_VALID_MODEL_NAME_2 + "/0.1/files"; + public static final String MODEL_FILES_NOT_EXIST_URL_WITH_TEAM = + "/v2/org/" + TEST_VALID_ORG_NAME + "/team/" + TEST_VALID_TEAM_NAME + + "/models/" + TEST_VALID_MODEL_NAME + "/0.5/files"; + public static final String MODEL_FILE_PERMISSION_DENIED_URL_WITH_TEAM = + "/v2/org/" + TEST_UNKNOWN_ORG_NAME + "/team/" + TEST_VALID_TEAM_NAME + + "/models/" + TEST_VALID_MODEL_NAME + "/0.1/files"; + public static final String MODEL_SIZE_NOT_EXISTS_URL_WITH_TEAM = + "/v2/org/" + TEST_VALID_ORG_NAME + "/team/" + TEST_VALID_TEAM_NAME + + "/models/" + TEST_VALID_MODEL_NAME + "/versions/0.5"; + public static final String MODEL_SIZE_PERMISSION_DENIED_URL_WITH_TEAM = + "/v2/org/" + TEST_UNKNOWN_ORG_NAME + "/team/" + TEST_VALID_TEAM_NAME + + "/models/" + TEST_VALID_MODEL_NAME + "/versions/0.1"; + public static final String MODEL_SMALL_SIZE_URL_WITH_TEAM = + "/v2/org/" + TEST_VALID_ORG_NAME + "/team/" + TEST_VALID_TEAM_NAME + + "/models/" + TEST_VALID_SMALL_MODEL_NAME + "/versions/0.1"; + public static final String MODEL_SMALL_SIZE_FILES_URL_WITH_TEAM = + "/v2/org/" + TEST_VALID_ORG_NAME + "/team/" + TEST_VALID_TEAM_NAME + + "/models/" + TEST_VALID_SMALL_MODEL_NAME + "/0.1/files"; + // Resource artifacts + public static final String RESOURCE_SIZE_URL = + "/v2/org/" + TEST_VALID_ORG_NAME + "/resources/" + TEST_VALID_RESOURCE_NAME + + "/versions/0.1"; + public static final String RESOURCE_FILES_URL = + "/v2/org/" + TEST_VALID_ORG_NAME + "/resources/" + TEST_VALID_RESOURCE_NAME + + "/0.1/files"; + public static final String RESOURCE_FILES_NOT_EXISTS_URL = + "/v2/org/" + TEST_VALID_ORG_NAME + "/resources/" + TEST_VALID_RESOURCE_NAME + + "/0.5/files"; + public static final String RESOURCE_SIZE_NOT_EXISTS_URL = + "/v2/org/" + TEST_VALID_ORG_NAME + "/resources/" + TEST_VALID_RESOURCE_NAME + + "/versions/0.5"; + public static final String RESOURCE_SIZE_PERMISSION_DENIED_URL = + "/v2/org/" + TEST_UNKNOWN_ORG_NAME + "/resources/" + TEST_VALID_RESOURCE_NAME + + "/versions/0.1"; + public static final String RESOURCE_SIZE_URL_WITH_TEAM = + "/v2/org/" + TEST_VALID_ORG_NAME + "/team/" + TEST_VALID_TEAM_NAME + + "/resources/" + TEST_VALID_RESOURCE_NAME + "/versions/0.1"; + public static final String RESOURCE_SIZE_URL_WITH_TEAM_2 = + "/v2/org/" + TEST_VALID_ORG_NAME + "/team/" + TEST_VALID_TEAM_NAME + + "/resources/" + TEST_VALID_RESOURCE_NAME_2 + "/versions/0.1"; + public static final String RESOURCE_FILES_URL_WITH_TEAM = + "/v2/org/" + TEST_VALID_ORG_NAME + "/team/" + TEST_VALID_TEAM_NAME + + "/resources/" + TEST_VALID_RESOURCE_NAME + "/0.1/files"; + public static final String RESOURCE_FILES_URL_WITH_TEAM_2 = + "/v2/org/" + TEST_VALID_ORG_NAME + "/team/" + TEST_VALID_TEAM_NAME + + "/resources/" + TEST_VALID_RESOURCE_NAME_2 + "/0.1/files"; + public static final String RESOURCE_FILES_URL_WITH_VERSION = + "/v2/org/" + TEST_VALID_ORG_NAME + "/resources/" + TEST_VALID_RESOURCE_NAME + + "/versions/0.1/files"; + public static final String RESOURCE_SIZE_NOT_EXISTS_URL_WITH_TEAM = + "/v2/org/" + TEST_VALID_ORG_NAME + "/team/" + TEST_VALID_TEAM_NAME + + "/resources/" + TEST_VALID_RESOURCE_NAME + "/versions/0.5"; + public static final String RESOURCE_FILE_NOT_EXISTS_URL_WITH_TEAM = + "/v2/org/" + TEST_VALID_ORG_NAME + "/team/" + TEST_VALID_TEAM_NAME + + "/resources/" + TEST_VALID_RESOURCE_NAME + "/0.5/files"; + public static final String RESOURCE_SIZE_PERMISSION_DENIED_URL_WITH_TEAM = + "/v2/org/" + TEST_UNKNOWN_ORG_NAME + "/team/" + TEST_VALID_TEAM_NAME + + "/resources/" + TEST_VALID_RESOURCE_NAME + "/versions/0.1"; + public static final String RESOURCE_FILE_PERMISSION_DENIED_URL_WITH_TEAM = + "/v2/org/" + TEST_UNKNOWN_ORG_NAME + "/team/" + TEST_VALID_TEAM_NAME + + "/resources/" + TEST_VALID_RESOURCE_NAME + "/0.1/files"; + // Helm chart artifacts + public static final String HELM_SIZE_URL_WITH_TEAM = + "/v2/org/" + TEST_VALID_ORG_NAME + "/team/" + TEST_VALID_TEAM_NAME + + "/helm-charts/" + TEST_VALID_HELM_CHART_NAME + "/versions/" + + TEST_VALID_HELM_CHART_VERSION; + public static final String HELM_SIZE_NOT_EXISTS_URL_WITH_TEAM = + "/v2/org/" + TEST_VALID_ORG_NAME + "/team/" + TEST_VALID_TEAM_NAME + + "/helm-charts/" + TEST_VALID_HELM_CHART_NAME + "/versions/" + + TEST_UNKNOWN_HELM_CHART_VERSION; + public static final String HELM_SIZE_PERMISSION_DENIED_URL_WITH_TEAM = + "/v2/org/" + TEST_UNKNOWN_ORG_NAME + "/team/" + TEST_VALID_TEAM_NAME + + "/helm-charts/" + TEST_VALID_HELM_CHART_NAME + "/versions/" + + TEST_UNKNOWN_HELM_CHART_VERSION; + public static final String HELM_SIZE_URL = + "/v2/org/" + TEST_VALID_ORG_NAME + "/team/" + TEST_VALID_TEAM_NAME + + "/helm-charts/" + TEST_VALID_HELM_CHART_NAME + "/versions/" + + TEST_VALID_HELM_CHART_VERSION; + public static final String HELM_SIZE_NOT_EXISTS_URL = + "/v2/org/" + TEST_VALID_ORG_NAME + "/team/" + TEST_VALID_TEAM_NAME + + "/helm-charts/" + TEST_VALID_HELM_CHART_NAME + "/versions/" + + TEST_UNKNOWN_HELM_CHART_VERSION; + public static final String HELM_SIZE_PERMISSION_DENIED_URL = + "/v2/org/" + TEST_UNKNOWN_ORG_NAME + "/team/" + TEST_VALID_TEAM_NAME + + "/helm-charts/" + TEST_VALID_HELM_CHART_NAME + "/versions/" + + TEST_UNKNOWN_HELM_CHART_VERSION; + // 429 retry test paths + public static final String MODEL_FILES_RATE_LIMITED_URL = + "/v2/org/" + TEST_VALID_ORG_NAME + "/models/rate-limited-model/0.1/files"; + public static final String MODEL_FILES_PAGINATED_429_URL = + "/v2/org/" + TEST_VALID_ORG_NAME + "/models/paginated-429-model/0.1/files"; + + // For NVCT checkpoints/results creation + public static final String CHECKPOINT_URL = "/v2/org/" + TEST_VALID_ORG_NAME + "/models"; + public static final String CHECKPOINT_URL_WITH_TEAM = "/v2/org/" + TEST_VALID_ORG_NAME + + "/team/" + TEST_VALID_TEAM_NAME + "/models"; + public static final String CHECKPOINT_URL_WITH_UNKNOWN_ORG = + "/v2/org/" + TEST_UNKNOWN_ORG_NAME + "/models"; + public static final String CHECKPOINT_URL_WITH_UNKNOW_TEAM = "/v2/org/" + TEST_VALID_ORG_NAME + + "/team/" + TEST_UNKNOWN_TEAM_NAME + "/models"; + public static final String CHECKPOINT_URL_WITH_INVALID_KEY = + "/v2/org/" + TEST_VALID_ORG_NAME_INVALID_KEY + "/models"; + private static final String RESOURCE_SIZE = """ + { + "recipeVersion": { + "status": "UPLOAD_COMPLETE", + "description": "", + "totalSizeInBytes": 69753543449, + "totalFileCount": 138, + "versionId": "1", + "createdByUser": "nvssa-stg-PzDg7EYCG6JIQoVHt-dvDbwEO87wafx0kWwX7R6oYAk", + "createdDate": "2024-01-22T06:59:33.191Z", + "id": 1 + }, + "recipe": { + "orgName": "zq9tgrjzrfpo", + "latestVersionId": 1, + "shortDescription": "models/Mixtral-8x7B-Instruct-v0.1-offloading-demo", + "isReadOnly": true, + "publicDatasetUsed": {}, + "application": "Other", + "latestVersionSizeInBytes": 59753543449, + "isPublic": false, + "description": "", + "latestVersionIdStr": "1", + "canGuestDownload": false, + "precision": "FP16", + "framework": "Other", + "createdDate": "2024-01-22T06:57:41.704Z", + "name": "mixtral", + "displayName": "models/Mixtral-8x7B-Instruct-v0.1-offloading-demo", + "modelFormat": "PY_TORCH_PTH", + "updatedDate": "2024-01-22T08:06:15.240Z" + }, + "requestStatus": { + "statusCode": "SUCCESS", + "requestId": "de003843-4d8a-4713-bc86-d4e4da096172" + } + } + """; + private static final String MODEL_SIZE = """ + { + "modelVersion": { + "status": "UPLOAD_COMPLETE", + "description": "", + "totalSizeInBytes": 46484790292, + "totalFileCount": 556, + "versionId": "1", + "createdByUser": "nvssa-stg-PzDg7EYCG6JIQoVHt-dvDbwEO87wafx0kWwX7R6oYAk", + "createdDate": "2024-01-22T06:59:33.191Z", + "id": 1 + }, + "model": { + "orgName": "zq9tgrjzrfpo", + "latestVersionId": 1, + "shortDescription": "models/Mixtral-8x7B-Instruct-v0.1-offloading-demo", + "isReadOnly": true, + "publicDatasetUsed": {}, + "application": "Other", + "latestVersionSizeInBytes": 36484790292, + "isPublic": false, + "description": "", + "latestVersionIdStr": "1", + "canGuestDownload": false, + "precision": "FP16", + "framework": "Other", + "createdDate": "2024-01-22T06:57:41.704Z", + "name": "mixtral", + "displayName": "models/Mixtral-8x7B-Instruct-v0.1-offloading-demo", + "modelFormat": "PY_TORCH_PTH", + "updatedDate": "2024-01-22T08:06:15.240Z" + }, + "requestStatus": { + "statusCode": "SUCCESS", + "requestId": "" + } + } + """; + private static final String MODEL_SMALL_SIZE = """ + { + "modelVersion": { + "status": "UPLOAD_COMPLETE", + "description": "", + "totalSizeInBytes": 36, + "totalFileCount": 556, + "versionId": "1", + "createdByUser": "nvssa-stg-PzDg7EYCG6JIQoVHt-dvDbwEO87wafx0kWwX7R6oYAk", + "createdDate": "2024-01-22T06:59:33.191Z", + "id": 1 + }, + "model": { + "orgName": "zq9tgrjzrfpo", + "latestVersionId": 1, + "shortDescription": "models/Mixtral-8x7B-Instruct-v0.1-offloading-demo", + "isReadOnly": true, + "publicDatasetUsed": {}, + "application": "Other", + "latestVersionSizeInBytes": 36, + "isPublic": false, + "description": "", + "latestVersionIdStr": "1", + "canGuestDownload": false, + "precision": "FP16", + "framework": "Other", + "createdDate": "2024-01-22T06:57:41.704Z", + "name": "mixtral", + "displayName": "models/Mixtral-8x7B-Instruct-v0.1-offloading-demo", + "modelFormat": "PY_TORCH_PTH", + "updatedDate": "2024-01-22T08:06:15.240Z" + }, + "requestStatus": { + "statusCode": "SUCCESS", + "requestId": "" + } + } + """; + private static final String HELM_SIZE = """ + { + "requestStatus": { + "statusCode": "SUCCESS", + "requestId": "842391fc-d7aa-4a5b-8360-71241e76c513" + }, + "artifactVersion": { + "status": "UPLOAD_COMPLETE", + "createdDate": "2025-05-14T08:44:07.471Z", + "totalSizeInBytes": 1903394, + "storageVersion": "V1", + "totalFileCount": 577, + "updatedDate": "2025-05-14T08:44:07.471Z", + "createdByUser": "q2irs51sak51ei9g8fcjbor13l", + "customMetrics": [], + "isSigned": false, + "attributes": [], + "id": "" + TEST_VALID_HELM_CHART_VERSION + "" + }, + "artifact": { + "orgName": "0539907589386975", + "latestVersionId": "" + TEST_VALID_HELM_CHART_VERSION + "", + "isReadOnly": true, + "teamName": "mega-dev", + "latestVersionSizeInBytes": 1903394, + "hasSignedVersion": false, + "canGuestDownload": false, + "isPublic": false, + "createdDate": "2024-10-29T20:20:42.150Z", + "name": "mega-simulation-app", + "updatedDate": "2025-05-14T08:44:07.425Z", + "attributes": [], + "artifactType": "HELM_CHART" + } + } + """; + + @SneakyThrows + public static void start(String authnBaseUrl, String casBaseUrl) { + stop(); + authnMockServer = new WireMockServer(URI.create(authnBaseUrl).getPort()); + authnMockServer.start(); + var tokenResponse = """ + { + "access_token": "token", + "expires_in": 3600 + } + """; + + // The expected Authorization header format is Basic base64($oauthtoken:). + // The mock server is configured to not catch requests where the `api-key` starts with "nvapi-". + // This is achieved by checking that the Base64-encoded string in the `Authorization` header + // does not contain the pattern `$oauthtoken:nvapi-`. + authnMockServer.stubFor(post(urlPathEqualTo("/token")) + .withHeader(HttpHeaders.AUTHORIZATION, matching( + "^Basic (?!.*JG9hdXRodG9rZW46bnZhcGkt).+$")) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(tokenResponse))); + + casMockServer = new WireMockServer(URI.create(casBaseUrl).getPort()); + casMockServer.start(); + + casMockServer.stubFor(get(urlPathEqualTo(MODEL_SIZE_URL)) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(MODEL_SIZE))); + casMockServer.stubFor(get(urlPathEqualTo(MODEL_SIZE_URL_WITH_TEAM)) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(MODEL_SIZE))); + casMockServer.stubFor(get(urlPathEqualTo(MODEL_SIZE_URL_WITH_TEAM_2)) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(MODEL_SIZE))); + casMockServer.stubFor(get(urlPathEqualTo(MODEL_SMALL_SIZE_URL)) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(MODEL_SMALL_SIZE))); + casMockServer.stubFor(get(urlPathEqualTo(MODEL_SMALL_SIZE_URL_WITH_TEAM)) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(MODEL_SMALL_SIZE))); + casMockServer.stubFor(get(urlPathEqualTo(MODEL_SIZE_NOT_EXISTS_URL)) + .willReturn(aResponse().withStatus(404) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE))); + casMockServer.stubFor(get(urlPathEqualTo(MODEL_SIZE_NOT_EXISTS_URL_WITH_TEAM)) + .willReturn(aResponse().withStatus(404) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE))); + casMockServer.stubFor(get(urlPathEqualTo(MODEL_SIZE_PERMISSION_DENIED_URL)) + .willReturn(aResponse().withStatus(403) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE))); + casMockServer.stubFor(get(urlPathEqualTo(MODEL_SIZE_PERMISSION_DENIED_URL_WITH_TEAM)) + .willReturn(aResponse().withStatus(403) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE))); + casMockServer.stubFor(get(urlPathEqualTo(RESOURCE_SIZE_URL)) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(RESOURCE_SIZE))); + casMockServer.stubFor(get(urlPathEqualTo(RESOURCE_SIZE_URL_WITH_TEAM)) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(RESOURCE_SIZE))); + casMockServer.stubFor(get(urlPathEqualTo(RESOURCE_SIZE_URL_WITH_TEAM_2)) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(RESOURCE_SIZE))); + casMockServer.stubFor(get(urlPathEqualTo(RESOURCE_SIZE_NOT_EXISTS_URL)) + .willReturn(aResponse().withStatus(404) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE))); + casMockServer.stubFor(get(urlPathEqualTo(RESOURCE_FILES_NOT_EXISTS_URL)) + .willReturn(aResponse().withStatus(404) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE))); + casMockServer.stubFor(get(urlPathEqualTo(RESOURCE_SIZE_NOT_EXISTS_URL_WITH_TEAM)) + .willReturn(aResponse().withStatus(404) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE))); + casMockServer.stubFor(get(urlPathEqualTo(RESOURCE_FILE_NOT_EXISTS_URL_WITH_TEAM)) + .willReturn(aResponse().withStatus(404) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE))); + casMockServer.stubFor(get(urlPathEqualTo(RESOURCE_SIZE_PERMISSION_DENIED_URL)) + .willReturn(aResponse().withStatus(403) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE))); + casMockServer.stubFor(get(urlPathEqualTo(RESOURCE_FILE_PERMISSION_DENIED_URL_WITH_TEAM)) + .willReturn(aResponse().withStatus(403) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE))); + casMockServer.stubFor(get(urlPathEqualTo(RESOURCE_SIZE_PERMISSION_DENIED_URL_WITH_TEAM)) + .willReturn(aResponse().withStatus(403) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE))); + casMockServer.stubFor(get(urlPathEqualTo(HELM_SIZE_URL)) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(HELM_SIZE))); + casMockServer.stubFor(get(urlPathEqualTo(HELM_SIZE_URL_WITH_TEAM)) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(HELM_SIZE))); + casMockServer.stubFor(get(urlPathEqualTo(HELM_SIZE_NOT_EXISTS_URL)) + .willReturn(aResponse().withStatus(404) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE))); + casMockServer.stubFor(get(urlPathEqualTo(HELM_SIZE_NOT_EXISTS_URL_WITH_TEAM)) + .willReturn(aResponse().withStatus(404) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE))); + casMockServer.stubFor(get(urlPathEqualTo(HELM_SIZE_PERMISSION_DENIED_URL)) + .willReturn(aResponse().withStatus(403) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE))); + casMockServer.stubFor(get(urlPathEqualTo(HELM_SIZE_PERMISSION_DENIED_URL_WITH_TEAM)) + .willReturn(aResponse().withStatus(403) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE))); + casMockServer.stubFor(get(urlPathEqualTo(MODEL_FILES_URL)) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(getModelFileResponse()))); + casMockServer.stubFor(get(urlPathEqualTo(MODEL_FILES_URL_WITH_VERSION)) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(getModelFileResponse()))); + casMockServer.stubFor(get(urlPathEqualTo(MODEL_FILES_URL_2)) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(getModelFileResponse()))); + casMockServer.stubFor(get(urlPathEqualTo(MODEL_FILES_URL_WITH_TEAM)) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(getModelFileResponse()))); + casMockServer.stubFor(get(urlPathEqualTo(MODEL_FILES_URL_WITH_TEAM_2)) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(getModelFileResponse()))); + casMockServer.stubFor(get(urlPathEqualTo(MODEL_FILE_PERMISSION_DENIED_URL)) + .willReturn(aResponse().withStatus(403) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE))); + casMockServer.stubFor(get(urlPathEqualTo(MODEL_FILE_PERMISSION_DENIED_URL_WITH_TEAM)) + .willReturn(aResponse().withStatus(403) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE))); + casMockServer.stubFor(get(urlPathEqualTo(MODEL_FILES_NOT_EXIST_URL)) + .willReturn(aResponse().withStatus(404) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE))); + casMockServer.stubFor(get(urlPathEqualTo(MODEL_FILES_NOT_EXIST_URL_WITH_TEAM)) + .willReturn(aResponse().withStatus(404) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE))); + casMockServer.stubFor(get(urlPathEqualTo(MODEL_SMALL_SIZE_FILES_URL)) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(getModelFileResponse()))); + casMockServer.stubFor(get(urlPathEqualTo(MODEL_SMALL_SIZE_FILES_URL_WITH_TEAM)) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(getModelFileResponse()))); + casMockServer.stubFor(get(urlPathEqualTo(RESOURCE_FILES_URL)) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(getResourceFileResponse()))); + casMockServer.stubFor(get(urlPathEqualTo(RESOURCE_FILES_URL_WITH_VERSION)) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE))); + casMockServer.stubFor(get(urlPathEqualTo(RESOURCE_FILES_URL_WITH_TEAM)) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(getResourceFileResponse()))); + casMockServer.stubFor(get(urlPathEqualTo(RESOURCE_FILES_URL_WITH_TEAM_2)) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(getResourceFileResponse()))); + // 429 retry: always rate-limited + casMockServer.stubFor(get(urlPathEqualTo(MODEL_FILES_RATE_LIMITED_URL)) + .willReturn(aResponse().withStatus(429) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE))); + // 429 retry: paginated — first page succeeds (totalPages=2); second page uses scenario stubs + casMockServer.stubFor(get(urlPathEqualTo(MODEL_FILES_PAGINATED_429_URL)) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(getModelFilesFirstPageResponse()))); + + // For NVCT testing results/checkpoints creation + var ignoredResponseBody = """ + {"ignored": "true"} + """; + + casMockServer.stubFor(post(urlPathMatching(CHECKPOINT_URL)) + .withHeader(HttpHeaders.AUTHORIZATION, new AnythingPattern()) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(ignoredResponseBody))); + casMockServer.stubFor(post(urlPathMatching(CHECKPOINT_URL_WITH_TEAM)) + .withHeader(HttpHeaders.AUTHORIZATION, new AnythingPattern()) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(ignoredResponseBody))); + casMockServer.stubFor(post(urlPathMatching(CHECKPOINT_URL_WITH_UNKNOWN_ORG)) + .withHeader(HttpHeaders.AUTHORIZATION, new AnythingPattern()) + .willReturn(aResponse().withStatus(404) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(ignoredResponseBody))); + casMockServer.stubFor(post(urlPathMatching(CHECKPOINT_URL_WITH_UNKNOW_TEAM)) + .withHeader(HttpHeaders.AUTHORIZATION, new AnythingPattern()) + .willReturn(aResponse().withStatus(404) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(ignoredResponseBody))); + casMockServer.stubFor(post(urlPathMatching(CHECKPOINT_URL_WITH_INVALID_KEY)) + .withHeader(HttpHeaders.AUTHORIZATION, new AnythingPattern()) + .willReturn(aResponse().withStatus(401) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(ignoredResponseBody))); + casMockServer.stubFor(delete(urlPathMatching("/v2/org/(.+)?/models/.+")) + .withHeader(HttpHeaders.AUTHORIZATION, new AnythingPattern()) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(ignoredResponseBody))); + casMockServer.stubFor(delete(urlPathMatching("/v2/org/(.+)?/team/(.+)?/models/.+")) + .withHeader(HttpHeaders.AUTHORIZATION, new AnythingPattern()) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(ignoredResponseBody))); + } + + @SneakyThrows + public static byte[] getModelFileResponse() { + var response = new ArtifactRegistryFilesResponse(); + response.setPaginationInfo(new PaginationInfo()); + response.setRequestStatus(new RequestStatus()); + response.setUrls(List.of("https://api.stg.ngc.nvidia.com/file1", + "https://api.stg.ngc.nvidia.com/file2")); + response.setFilepath(List.of("/file1", "/file2")); + + return JSON_MAPPER.writeValueAsBytes(response); + } + + @SneakyThrows + public static byte[] getModelFilesFirstPageResponse() { + var paginationInfo = new PaginationInfo(); + paginationInfo.setTotalPages(2); + var response = new ArtifactRegistryFilesResponse(); + response.setPaginationInfo(paginationInfo); + response.setRequestStatus(new RequestStatus()); + response.setUrls(List.of("https://api.stg.ngc.nvidia.com/file1", + "https://api.stg.ngc.nvidia.com/file2")); + response.setFilepath(List.of("/file1", "/file2")); + return JSON_MAPPER.writeValueAsBytes(response); + } + + @SneakyThrows + public static byte[] getModelFilesSecondPageResponse() { + var paginationInfo = new PaginationInfo(); + paginationInfo.setTotalPages(2); + var response = new ArtifactRegistryFilesResponse(); + response.setPaginationInfo(paginationInfo); + response.setRequestStatus(new RequestStatus()); + response.setUrls(List.of("https://api.stg.ngc.nvidia.com/file3", + "https://api.stg.ngc.nvidia.com/file4")); + response.setFilepath(List.of("/file3", "/file4")); + return JSON_MAPPER.writeValueAsBytes(response); + } + + @SneakyThrows + private static byte[] getResourceFileResponse() { + var response = new ArtifactRegistryFilesResponse(); + response.setPaginationInfo(new PaginationInfo()); + response.setRequestStatus(new RequestStatus()); + response.setUrls(List.of("https://api.stg.ngc.nvidia.com/image1", + "https://api.stg.ngc.nvidia.com/image2")); + response.setFilepath(List.of("/image1", "/image2")); + return JSON_MAPPER.writeValueAsBytes(response); + } + + public static void setResponse(String url, byte[] body) { + casMockServer.stubFor(get(urlPathEqualTo(url)) + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(body))); + } + + public static void stubFor(MappingBuilder mappingBuilder) { + casMockServer.stubFor(mappingBuilder); + } + + public static void resetScenarios() { + casMockServer.resetScenarios(); + } + + public static void stop() { + if (casMockServer != null) { + casMockServer.stop(); + } + if (authnMockServer != null) { + authnMockServer.stop(); + } + } + + @Data + static class PaginationInfo { + + private int totalPages; + private int index; + private int totalResults; + private String nextPage; + private int size; + } + + @Data + static class RequestStatus { + + private String serverID; + private String statusCode; + private String statusDescription; + private String requestID; + } + + @Data + static class ArtifactRegistryFilesResponse { + + private PaginationInfo paginationInfo; + private RequestStatus requestStatus; + private List urls; + private List filepath; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/ngc/MockNgcContainerRegistryServer.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/ngc/MockNgcContainerRegistryServer.java new file mode 100644 index 0000000000..b5f3a007b3 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/ngc/MockNgcContainerRegistryServer.java @@ -0,0 +1,247 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock.ngc; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.nvidia.boot.mock.BootTestConstants.IMAGE_MEDIA_TYPES; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_CONTAINER_HASH; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_CONTAINER_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_CONTAINER_NOT_EXIST_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_CONTAINER_PERMISSION_DENIED_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_CONTAINER_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_ORG_NAME; + +import com.github.tomakehurst.wiremock.WireMockServer; +import java.net.URI; +import lombok.Getter; +import lombok.SneakyThrows; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; + +public class MockNgcContainerRegistryServer { + + @Getter + private static WireMockServer ngcContainerRegistryMockServer; + private static final String PROXY_AUTH_URL = "/proxy_auth"; + private static final String VALIDATE_MANIFEST_URL = + "/v2/" + TEST_VALID_ORG_NAME + "/" + TEST_VALID_CONTAINER_NAME + "/manifests/" + + TEST_VALID_CONTAINER_TAG; + private static final String VALIDATE_MANIFEST_URL_WITH_DIGEST = + "/v2/" + TEST_VALID_ORG_NAME + "/" + TEST_VALID_CONTAINER_NAME + + "/manifests/" + TEST_VALID_CONTAINER_HASH; + private static final String VALIDATE_MANIFEST_PERMISSION_DENIED_URL = + "/v2/" + TEST_VALID_ORG_NAME + "/" + TEST_VALID_CONTAINER_NAME + + "/manifests/" + TEST_VALID_CONTAINER_PERMISSION_DENIED_TAG; + private static final String VALIDATE_MANIFEST_NOT_EXISTS_URL = + "/v2/" + TEST_VALID_ORG_NAME + "/" + TEST_VALID_CONTAINER_NAME + + "/manifests/" + TEST_VALID_CONTAINER_NOT_EXIST_TAG; + private static final String PROXY_AUTH = """ + { + "expires_in": 600, + "token": "mockBearerToken" + } + """; + private static final String VALIDATE_MANIFEST = """ + 589386975/mega-dev/mega-scheduler-service@sha256:d3f9786af0f21490f55299ac0af2f2da871f927865b042def17c63a3699d8d51 + { + "schemaVersion": 2, + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "config": { + "mediaType": "application/vnd.docker.container.image.v1+json", + "size": 4174, + "digest": "sha256:9683445c1318f0d623fb43ac70cb980e9c63d3ad2b4010d898da605b0871c0da" + }, + "layers": [ + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 84670, + "digest": "sha256:51c1b6699f435b7ccff149db8fdfc0479d802406fea5712271fac54f97eb3b8f" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 12579, + "digest": "sha256:2e4cf50eeb92ac3a7afe75e15d96a26dee99449f86b46c75b5d95f4418a5bca0" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 455583, + "digest": "sha256:97710db6c874ac8fba16cebc4e65a51350d130de4a86ab8f8dfd4101f97095e9" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 75, + "digest": "sha256:0f8b424aa0b96c1c388a5fd4d90735604459256336853082afb61733438872b5" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 193, + "digest": "sha256:d557676654e572af3e3173c90e7874644207fda32cd87e9d3d66b5d7b98a7b21" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 107, + "digest": "sha256:9053c7a2530447e88fee5d33d1c5dad5aa48c039bed546c4b967d2c4247e9f77" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 173, + "digest": "sha256:d858cbc252ade14879807ff8dbc3043a26bbdb92087da98cda831ee040b172b3" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 97, + "digest": "sha256:1069fc2daed1aceff7232f4b8ab21200dd3d8b04f61be9da86977a34a105dfdc" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 382, + "digest": "sha256:b40161cd83fc5d470d6abe50e87aa288481b6b89137012881d74187cfbf9f502" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 326, + "digest": "sha256:3f4e2c5863480125882d92060440a5250766bce764fee10acdbac18c872e4dc7" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 129107, + "digest": "sha256:80a8c047508ae5cd6a591060fc43422cb8e3aea1bd908d913e8f0146e2297fea" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 639820, + "digest": "sha256:586cbe57ad0361ab6f127c4292c92a53158216474e586b8250ca9d7ff976bfcc" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 454347, + "digest": "sha256:17f0cbbecfbe215a53d878de286df9cc51268924c69ccf8af02598ac744cdd5c" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 5088161, + "digest": "sha256:e95e5abe292be5983f45cae778fa6bdbd0fdfbd21fcc8d0ff7e7057b873dd741" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 2569435, + "digest": "sha256:04155d74e8b44d7e5ba2e641655c607e9e1ff0250dd650f29bc927784444a040" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 138135, + "digest": "sha256:e4779a15f3b57f6794e289145a3c5524f58a5a77b23138b444de0382881d372f" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 804137, + "digest": "sha256:d62a48892d6a7ca2180bcf4eab1391fc1a39fdbac3934319676e579858857c50" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 59255, + "digest": "sha256:86a573e52aae0a885fc9012172c84b3451a91cc2cf77003982879284ee69d30a" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 157, + "digest": "sha256:12558547ba017ef70b593a8ace770d3f38026a80c4db7547fbcb6567628b9c51" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 108, + "digest": "sha256:427f7d18e1e6344a58904ff17aa981ea624a8052449ef9e7e7aa9678946f2050" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 143, + "digest": "sha256:7ad1a2b3b310bbd1523dcfa1616a6bb3738ab620d1451063a9fe723e3a461706" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 5763508, + "digest": "sha256:f9c166c3d1a469cb72cfd248e403f4f63530c9ead08a978d33ce4b520b6afe6b" + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 742, + "digest": "sha256:03c530502c52456045b035612e8309481433355b4d3e52ad935e704b4cd9adab" + } + ] + } + """; + + @SneakyThrows + public static void start(String ngcRegistryBaseUrl) { + stop(); + ngcContainerRegistryMockServer = new WireMockServer(URI.create(ngcRegistryBaseUrl).getPort()); + ngcContainerRegistryMockServer.start(); + + ngcContainerRegistryMockServer.stubFor(get(urlPathEqualTo(PROXY_AUTH_URL)) + .willReturn(aResponse().withStatus(200) + .withHeader( + HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .withBody(PROXY_AUTH))); + ngcContainerRegistryMockServer.stubFor(get(urlPathEqualTo(VALIDATE_MANIFEST_URL)) + .withHeader(HttpHeaders.ACCEPT, + equalTo(IMAGE_MEDIA_TYPES)) + .willReturn(aResponse().withStatus(200) + .withHeader( + HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .withBody( + VALIDATE_MANIFEST))); + ngcContainerRegistryMockServer.stubFor( + get(urlPathEqualTo(VALIDATE_MANIFEST_URL_WITH_DIGEST)) + .withHeader(HttpHeaders.ACCEPT, + equalTo(IMAGE_MEDIA_TYPES)) + .willReturn(aResponse().withStatus(200) + .withHeader( + HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .withBody( + VALIDATE_MANIFEST))); + ngcContainerRegistryMockServer.stubFor( + get(urlPathEqualTo(VALIDATE_MANIFEST_PERMISSION_DENIED_URL)) + .withHeader(HttpHeaders.ACCEPT, equalTo(IMAGE_MEDIA_TYPES)) + .willReturn(aResponse().withStatus(403))); + ngcContainerRegistryMockServer.stubFor(get(urlPathEqualTo(VALIDATE_MANIFEST_NOT_EXISTS_URL)) + .withHeader(HttpHeaders.ACCEPT, + equalTo(IMAGE_MEDIA_TYPES)) + .willReturn(aResponse().withStatus(404))); + } + + public static void setResponse(String url, byte[] body) { + ngcContainerRegistryMockServer.stubFor(get(urlPathEqualTo(url)) + .willReturn(aResponse().withStatus(200) + .withHeader( + HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .withBody(body))); + } + + public static void stop() { + if (ngcContainerRegistryMockServer != null) { + ngcContainerRegistryMockServer.stop(); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/oauth2/MockOAuth2TokenServer.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/oauth2/MockOAuth2TokenServer.java new file mode 100644 index 0000000000..e00b0ca762 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/oauth2/MockOAuth2TokenServer.java @@ -0,0 +1,168 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock.oauth2; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching; +import static org.springframework.http.HttpHeaders.CONTENT_TYPE; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.MappingBuilder; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.concurrent.locks.ReentrantLock; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import wiremock.org.apache.hc.core5.http.ContentType; +import wiremock.org.eclipse.jetty.http.HttpStatus; + +/** + * Mock OAuth2 Token Server for integration tests. + */ +@RequiredArgsConstructor +public class MockOAuth2TokenServer { + + @Getter + private final OAuth2TokenServerConfigurationProperties tokenServerConfiguration; + + @SneakyThrows + public WireMockServer start() { + var url = URI.create(tokenServerConfiguration.keysetUrl()).toURL(); + var port = url.getPort(); + var jwkSetUriPath = url.getPath(); + return WireMockOAuth2TokenServer.start(port, + WireMockOAuth2TokenServer + .getDefaultStub(jwkSetUriPath)); + } + + @SneakyThrows + public WireMockServer start(List stubs) { + var url = URI.create(tokenServerConfiguration.keysetUrl()).toURL(); + var port = url.getPort(); + return WireMockOAuth2TokenServer.start(port, stubs); + } + + public void stop() { + WireMockOAuth2TokenServer.stop(); + } + + @SneakyThrows + public String getJwt(String... scopes) { + var clients = tokenServerConfiguration.clientBindings(); + if (clients == null || clients.isEmpty()) { + clients = List.of("test-client"); + } + return getJwt(clients.get(0), List.of(scopes), 100); + } + + @SneakyThrows + public String getJwt(String subject, List scopes, int expireInSeconds) { + return getJwt(subject, scopes, expireInSeconds, null); + } + + @SneakyThrows + public String getJwt(String subject, List scopes, int expireInSeconds, String options) { + return OAuth2TestUtils.getJwt(subject, scopes, + expireInSeconds, + URI.create(tokenServerConfiguration.issuer()).toURL(), + options); + } + + @SneakyThrows + public String getJwt( + String subject, List scopes, int expireInSeconds, String options, + Map metadata) { + return OAuth2TestUtils.getJwt(subject, scopes, expireInSeconds, + URI.create(tokenServerConfiguration.issuer()).toURL(), + options, metadata); + } + + @SneakyThrows + public String getServiceID() { + return OAuth2TestUtils.getServiceId(URI.create(tokenServerConfiguration.issuer()).toURL()); + } + + public String getBaseUrl() { + return WireMockOAuth2TokenServer.getBaseUrl(); + } + + private static class WireMockOAuth2TokenServer { + + private static final ReentrantLock lock = new ReentrantLock(); + + private static WireMockServer wireMockServer; + + public static WireMockServer start(int port, List stubs) { + lock.lock(); + try { + if (wireMockServer != null && wireMockServer.isRunning()) { + return wireMockServer; + } + + var configuration = new WireMockConfiguration() + .port(port) + .extensions(new TokenEndpointResponseTransformer()); + + wireMockServer = new WireMockServer(configuration); + stubs.forEach(wireMockServer::stubFor); + wireMockServer.start(); + return wireMockServer; + } finally { + lock.unlock(); + } + } + + public static void stop() { + lock.lock(); + try { + if (wireMockServer == null || !wireMockServer.isRunning()) { + return; + } + + wireMockServer.stop(); + } finally { + lock.unlock(); + } + } + + public static String getBaseUrl() { + return wireMockServer.baseUrl(); + } + + public static List getDefaultStub(String jwkSetUriPath) { + var mimeTypeJson = ContentType.APPLICATION_JSON.getMimeType(); + var pubKeys = get(urlPathEqualTo(jwkSetUriPath)) + .willReturn(aResponse() + .withStatus(HttpStatus.OK_200) + .withHeader(CONTENT_TYPE, mimeTypeJson) + .withBody(OAuth2TestUtils.getJwks().toString())); + var token = post(urlPathMatching("/token")) + .willReturn(aResponse() + .withStatus(HttpStatus.OK_200) + .withHeader(CONTENT_TYPE, mimeTypeJson) + .withTransformers(TokenEndpointResponseTransformer.NAME)); + return List.of(pubKeys, token); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/oauth2/MockOAuth2TokenServerInstanced.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/oauth2/MockOAuth2TokenServerInstanced.java new file mode 100644 index 0000000000..810d95339b --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/oauth2/MockOAuth2TokenServerInstanced.java @@ -0,0 +1,137 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock.oauth2; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching; +import static org.springframework.http.HttpHeaders.CONTENT_TYPE; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.MappingBuilder; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import java.net.URI; +import java.util.List; +import java.util.Map; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import wiremock.org.apache.hc.core5.http.ContentType; +import wiremock.org.eclipse.jetty.http.HttpStatus; + +/** + * Instanced version of mock OAuth2 token server for integration tests. + * Each instance has its own WireMock server (unlike the static MockOAuth2TokenServer). + */ +@RequiredArgsConstructor +public class MockOAuth2TokenServerInstanced { + + @Getter + private final OAuth2TokenServerConfigurationProperties tokenServerConfiguration; + + private WireMockServer wireMockServer; + + @SneakyThrows + public WireMockServer start() { + var url = URI.create(tokenServerConfiguration.keysetUrl()).toURL(); + var jwkSetUriPath = url.getPath(); + return start(getDefaultStub(jwkSetUriPath)); + } + + @SneakyThrows + public WireMockServer start(List stubs) { + if (wireMockServer != null && wireMockServer.isRunning()) { + return wireMockServer; + } + + var url = URI.create(tokenServerConfiguration.keysetUrl()).toURL(); + var port = url.getPort(); + + var configuration = new WireMockConfiguration() + .port(port) + .extensions(new TokenEndpointResponseTransformer()); + + wireMockServer = new WireMockServer(configuration); + stubs.forEach(wireMockServer::stubFor); + wireMockServer.start(); + return wireMockServer; + } + + public void stop() { + if (wireMockServer != null && wireMockServer.isRunning()) { + wireMockServer.stop(); + } + } + + @SneakyThrows + public String getJwt(String... scopes) { + var clients = tokenServerConfiguration.clientBindings(); + if (clients == null || clients.isEmpty()) { + clients = List.of("test-client"); + } + return getJwt(clients.get(0), List.of(scopes), 100); + } + + @SneakyThrows + public String getJwt(String subject, List scopes, int expireInSeconds) { + return getJwt(subject, scopes, expireInSeconds, null); + } + + @SneakyThrows + public String getJwt(String subject, List scopes, int expireInSeconds, String options) { + return OAuth2TestUtils.getJwt(subject, scopes, + expireInSeconds, + URI.create(tokenServerConfiguration.issuer()).toURL(), + options); + } + + @SneakyThrows + public String getJwt( + String subject, List scopes, int expireInSeconds, String options, + Map metadata) { + return OAuth2TestUtils.getJwt(subject, scopes, expireInSeconds, + URI.create(tokenServerConfiguration.issuer()).toURL(), + options, metadata); + } + + @SneakyThrows + public String getServiceID() { + return OAuth2TestUtils.getServiceId(URI.create(tokenServerConfiguration.issuer()).toURL()); + } + + public String getBaseUrl() { + return wireMockServer != null ? wireMockServer.baseUrl() : null; + } + + private List getDefaultStub(String jwkSetUriPath) { + var mimeTypeJson = ContentType.APPLICATION_JSON.getMimeType(); + var pubKeys = get(urlPathEqualTo(jwkSetUriPath)) + .willReturn(aResponse() + .withStatus(HttpStatus.OK_200) + .withHeader(CONTENT_TYPE, mimeTypeJson) + .withBody(OAuth2TestUtils.getJwks().toString())); + var token = post(urlPathMatching("/token")) + .willReturn(aResponse() + .withStatus(HttpStatus.OK_200) + .withHeader(CONTENT_TYPE, mimeTypeJson) + .withTransformers(TokenEndpointResponseTransformer.NAME)); + return List.of(pubKeys, token); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/oauth2/OAuth2TestUtils.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/oauth2/OAuth2TestUtils.java new file mode 100644 index 0000000000..c9bc3d46ea --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/oauth2/OAuth2TestUtils.java @@ -0,0 +1,143 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock.oauth2; + +import static java.time.temporal.ChronoUnit.SECONDS; +import static org.springframework.security.oauth2.core.oidc.IdTokenClaimNames.AZP; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSSigner; +import com.nimbusds.jose.crypto.ECDSASigner; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import java.net.URL; +import java.security.KeyPairGenerator; +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.ECPublicKey; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import lombok.Getter; +import lombok.SneakyThrows; +import lombok.experimental.UtilityClass; +import org.apache.commons.lang3.StringUtils; +import org.jspecify.annotations.Nullable; + +/** + * Test utilities for JWT generation using Mock OAuth2 Token Server. + */ +@UtilityClass +public class OAuth2TestUtils { + + private static final JWSSigner signer; + + @Getter + private static final JWKSet jwks; + + static { + try { + var gen = KeyPairGenerator.getInstance("EC"); + gen.initialize(Curve.P_256.toECParameterSpec()); + var keyPair = gen.generateKeyPair(); + + var privateJwk = new ECKey.Builder(Curve.P_256, + (ECPublicKey) keyPair.getPublic()) + .privateKey((ECPrivateKey) keyPair.getPrivate()) + .keyID(UUID.randomUUID().toString()) + .algorithm(JWSAlgorithm.ES256) + .build(); + var publicJWK = privateJwk.toPublicJWK(); + jwks = new JWKSet(publicJWK); + signer = new ECDSASigner(privateJwk); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @SneakyThrows(JOSEException.class) + public static String getJwt(JWTClaimsSet.Builder claims) { + var jwk = jwks.getKeys().get(0); + SignedJWT signedJWT = new SignedJWT( + new JWSHeader.Builder((JWSAlgorithm) jwk.getAlgorithm()) + .keyID(jwk.getKeyID()) + .build(), + claims.build()); + signedJWT.sign(signer); + return signedJWT.serialize(); + } + + public static String getJwt( + String subject, List scopes, int expireInSeconds, URL issuer, String options) { + return getJwt(subject, scopes, expireInSeconds, issuer, options, null); + } + + public static String getJwt( + String subject, List scopes, int expireInSeconds, URL issuer, String options, + @Nullable Map metadata) { + if (scopes == null) { + scopes = List.of(); + } + + Date now = new Date(); + var claimsSetBuilder = new JWTClaimsSet.Builder() + .subject(subject) + .issueTime(now) + .expirationTime(Date.from(now.toInstant().plus(expireInSeconds, SECONDS))) + .claim("scopes", scopes) + .audience(List.of(getServiceId(issuer), subject)) + .issuer(issuer.toString()) + .claim(AZP, subject); + + if (StringUtils.isNotBlank(options)) { + claimsSetBuilder.claim("options", options); + } + + if (metadata != null && !metadata.isEmpty()) { + claimsSetBuilder.claim("metadata", metadata); + } + + return getJwt(claimsSetBuilder); + } + + public static String getJwt( + String subject, List scopes, int expireInSeconds, URL issuer) { + return getJwt(subject, scopes, expireInSeconds, issuer, null); + } + + @SneakyThrows + public static String getJwt(URL issuer, String... scopes) { + return getJwt("test-client", List.of(scopes), 100, issuer); + } + + /** + * Extracts a service ID from an issuer URL. + * The service ID is "s:" followed by the first segment of the hostname. + */ + public static String getServiceId(URL issuer) { + var host = issuer.getHost(); + var index = host.indexOf('.'); + var endIndex = (index != -1) ? index : host.length(); + return "s:" + host.substring(0, endIndex); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/oauth2/OAuth2TokenServerConfigurationProperties.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/oauth2/OAuth2TokenServerConfigurationProperties.java new file mode 100644 index 0000000000..9f95f9678e --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/oauth2/OAuth2TokenServerConfigurationProperties.java @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock.oauth2; + +import java.util.List; +import java.util.Map; + +/** + * Configuration properties for mock OAuth2 token server. + */ +public record OAuth2TokenServerConfigurationProperties( + String issuer, + String keysetUrl, + String jwsAlgorithm, + List serviceBindings, + List clientBindings, + Map customBindings) { +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/oauth2/OAuth2TokenServerResponse.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/oauth2/OAuth2TokenServerResponse.java new file mode 100644 index 0000000000..5ea4555445 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/oauth2/OAuth2TokenServerResponse.java @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock.oauth2; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Response model for mock OAuth2 server's /token endpoint. + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class OAuth2TokenServerResponse { + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("expires_in") + private Long expiresIn; + + private String scope; + + @JsonProperty("token_type") + private String tokenType; +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/oauth2/TokenEndpointResponseTransformer.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/oauth2/TokenEndpointResponseTransformer.java new file mode 100644 index 0000000000..062e037614 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/oauth2/TokenEndpointResponseTransformer.java @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock.oauth2; + +import com.github.tomakehurst.wiremock.common.Urls; +import com.github.tomakehurst.wiremock.extension.ResponseTransformerV2; +import com.github.tomakehurst.wiremock.http.Request; +import com.github.tomakehurst.wiremock.http.Response; +import com.github.tomakehurst.wiremock.stubbing.ServeEvent; +import java.net.URI; +import java.util.Arrays; +import java.util.Base64; +import lombok.SneakyThrows; +import tools.jackson.databind.json.JsonMapper; + +/** + * WireMock response transformer for /token endpoint. + */ +public class TokenEndpointResponseTransformer implements ResponseTransformerV2 { + + public static final String NAME = "jwt-generator"; + private final JsonMapper jsonMapper = JsonMapper.builder().build(); + + @Override + public String getName() { + return NAME; + } + + @Override + @SneakyThrows + public Response transform(Response response, ServeEvent serveEvent) { + var request = serveEvent.getRequest(); + var authorization = request.getHeader("Authorization"); + var decoded = new String( + Base64.getUrlDecoder().decode(authorization.substring("Basic ".length()))); + var clientID = decoded.split(":", 2)[0]; + var body = new String(request.getBody()); + var params = Urls.splitQuery(body); + var scopes = Arrays.asList(params.get("scope").firstValue().split(" ")); + var baseUrl = request.getAbsoluteUrl() + .substring(0, request.getAbsoluteUrl().length() - request.getUrl().length()); + var options = getOptionsQueryParameter(request); + var jwt = OAuth2TestUtils.getJwt(clientID, scopes, 3600, + URI.create(baseUrl).toURL(), options); + var tokenResponse = OAuth2TokenServerResponse.builder().accessToken(jwt).expiresIn(3600L) + .scope(params.get("scope").firstValue()).tokenType("Bearer").build(); + return Response.response() + .status(response.getStatus()) + .headers(response.getHeaders()) + .body(jsonMapper.writeValueAsBytes(tokenResponse)) + .build(); + } + + @Override + public boolean applyGlobally() { + return false; + } + + private String getOptionsQueryParameter(Request request) { + var optionsParameter = request.queryParameter("options"); + if (optionsParameter.isPresent()) { + return optionsParameter.firstValue(); + } + + var body = request.getBodyAsString(); + var nameValuePairs = body.split("&"); + for (String nameValuePair : nameValuePairs) { + if (nameValuePair.startsWith("options=")) { + return nameValuePair.substring("options=".length()); + } + } + + return null; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/oci/MockOciRegistryServer.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/oci/MockOciRegistryServer.java new file mode 100644 index 0000000000..7f833659e2 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/oci/MockOciRegistryServer.java @@ -0,0 +1,98 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock.oci; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.head; +import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching; +import static com.nvidia.boot.mock.BootTestConstants.TEST_OCI_HELM_CHART_MANIFEST_NOT_EXISTS_URL; +import static com.nvidia.boot.mock.BootTestConstants.TEST_OCI_HELM_CHART_MANIFEST_PERMISSION_DENIED_URL; +import static com.nvidia.boot.mock.BootTestConstants.TEST_OCI_HELM_CHART_MANIFEST_URL_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_OCI_HELM_CHART_MANIFEST_URL_WITH_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_OCI_IMAGE_MANIFEST_LATEST_URL; +import static com.nvidia.boot.mock.BootTestConstants.TEST_OCI_IMAGE_MANIFEST_NOT_EXISTS_URL; +import static com.nvidia.boot.mock.BootTestConstants.TEST_OCI_IMAGE_MANIFEST_PERMISSION_DENIED_URL; +import static com.nvidia.boot.mock.BootTestConstants.TEST_OCI_IMAGE_MANIFEST_URL_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_OCI_IMAGE_MANIFEST_URL_WITH_TAG; + +import com.github.tomakehurst.wiremock.WireMockServer; +import java.net.URI; +import lombok.SneakyThrows; + +public class MockOciRegistryServer { + + private WireMockServer ociRegistryMockServer; + + @SneakyThrows + public void start(String ociRegistryBaseUrl) { + stop(); + ociRegistryMockServer = new WireMockServer(URI.create(ociRegistryBaseUrl).getPort()); + ociRegistryMockServer.start(); + + // Valid container image tag - 200 + ociRegistryMockServer + .stubFor(head(urlMatching(TEST_OCI_IMAGE_MANIFEST_URL_WITH_TAG)) + .willReturn(aResponse().withStatus(200))); + + // Valid container image tag: latest - 200 + ociRegistryMockServer + .stubFor(head(urlMatching(TEST_OCI_IMAGE_MANIFEST_LATEST_URL)) + .willReturn(aResponse().withStatus(200))); + + // Valid container image digest - 200 + ociRegistryMockServer + .stubFor(head(urlMatching(TEST_OCI_IMAGE_MANIFEST_URL_WITH_DIGEST)) + .willReturn(aResponse().withStatus(200))); + + // Container image permission denied + ociRegistryMockServer + .stubFor(head(urlMatching(TEST_OCI_IMAGE_MANIFEST_PERMISSION_DENIED_URL)) + .willReturn(aResponse().withStatus(403))); + + // Container image does not exist + ociRegistryMockServer + .stubFor(head(urlMatching(TEST_OCI_IMAGE_MANIFEST_NOT_EXISTS_URL)) + .willReturn(aResponse().withStatus(404))); + + // Valid helm chart tag - 200 + ociRegistryMockServer + .stubFor(head(urlMatching(TEST_OCI_HELM_CHART_MANIFEST_URL_WITH_TAG)) + .willReturn(aResponse().withStatus(200))); + + // Valid helm chart digest - 200 + ociRegistryMockServer + .stubFor(head(urlMatching(TEST_OCI_HELM_CHART_MANIFEST_URL_WITH_DIGEST)) + .willReturn(aResponse().withStatus(200))); + + // Helm chart permission denied + ociRegistryMockServer + .stubFor(head(urlMatching(TEST_OCI_HELM_CHART_MANIFEST_PERMISSION_DENIED_URL)) + .willReturn(aResponse().withStatus(403))); + + // Helm chart does not exist + ociRegistryMockServer + .stubFor(head(urlMatching(TEST_OCI_HELM_CHART_MANIFEST_NOT_EXISTS_URL)) + .willReturn(aResponse().withStatus(404))); + } + + public void stop() { + if (ociRegistryMockServer != null) { + ociRegistryMockServer.stop(); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/volcengine/MockVolcengineRegistryServer.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/volcengine/MockVolcengineRegistryServer.java new file mode 100644 index 0000000000..453457248d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/main/java/com/nvidia/boot/mock/volcengine/MockVolcengineRegistryServer.java @@ -0,0 +1,459 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock.volcengine; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.matching; +import static com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.nvidia.boot.mock.BootTestConstants.TEST_HELM_CHART_TYPE; +import static com.nvidia.boot.mock.BootTestConstants.TEST_IMAGE_TYPE; +import static com.nvidia.boot.mock.BootTestConstants.TEST_INVALID_VOLCENGINE_ACCESS_KEY_ID; +import static com.nvidia.boot.mock.BootTestConstants.TEST_NOT_EXIST_VOLCENGINE_HELM_CHART_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_NOT_EXIST_VOLCENGINE_IMAGE_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_NOT_EXIST_VOLCENGINE_REPOSITORY; +import static com.nvidia.boot.mock.BootTestConstants.TEST_PERMISSION_DENIED_VOLCENGINE_REGISTRY; +import static com.nvidia.boot.mock.BootTestConstants.TEST_SERVER_ERROR_VOLCENGINE_REGISTRY; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_VOLCENGINE_ACCESS_KEY_ID; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_VOLCENGINE_CONTAINER_IMAGE_REPOSITORY; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_VOLCENGINE_HELM_CHART_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_VOLCENGINE_HELM_REPOSITORY; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_VOLCENGINE_IMAGE_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_VOLCENGINE_NAMESPACE; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_VOLCENGINE_REGISTRY; + +import com.github.tomakehurst.wiremock.WireMockServer; +import java.net.URI; +import lombok.SneakyThrows; +import org.springframework.http.HttpHeaders; + +public class MockVolcengineRegistryServer { + + private static final String VOLCENGINE_ROOT_PATH = "/"; + private static final String VOLCENGINE_JSON_CONTENT_TYPE = "application/json"; + + // Valid container image response + private static final String VALID_CONTAINER_IMAGE_RESPONSE = """ + { + "ResponseMetadata": { + "RequestId": "test-request-id-container-123", + "Action": "ListTags", + "Version": "2022-05-12", + "Service": "cr", + "Region": "cn-beijing" + }, + "Result": { + "Registry": "%s", + "Namespace": "%s", + "Repository": "%s", + "Items": [ + { + "Name": "%s", + "Type": "%s", + "Digest": "sha256:abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234", + "ImageAttributes": [ + { + "Architecture": "amd64", + "Os": "linux", + "Digest": "sha256:abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234" + } + ] + } + ] + } + } + """.formatted(TEST_VALID_VOLCENGINE_REGISTRY, TEST_VALID_VOLCENGINE_NAMESPACE, + TEST_VALID_VOLCENGINE_CONTAINER_IMAGE_REPOSITORY, + TEST_VALID_VOLCENGINE_IMAGE_TAG, + TEST_IMAGE_TYPE); + + // Valid helm chart response + private static final String VALID_HELM_CHART_RESPONSE = """ + { + "ResponseMetadata": { + "RequestId": "test-request-id-helm-456", + "Action": "ListTags", + "Version": "2022-05-12", + "Service": "cr", + "Region": "cn-beijing" + }, + "Result": { + "Registry": "%s", + "Namespace": "%s", + "Repository": "%s", + "Items": [ + { + "Name": "%s", + "Type": "%s", + "Digest": "sha256:abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1235", + "ChartAttribute": { + "ApiVersion": "v2", + "Name": "%s", + "Version": "%s" + } + } + ] + } + } + """.formatted(TEST_VALID_VOLCENGINE_REGISTRY, TEST_VALID_VOLCENGINE_NAMESPACE, + TEST_VALID_VOLCENGINE_HELM_REPOSITORY, + TEST_VALID_VOLCENGINE_HELM_CHART_TAG, + TEST_HELM_CHART_TYPE, TEST_VALID_VOLCENGINE_HELM_REPOSITORY, + TEST_VALID_VOLCENGINE_HELM_CHART_TAG); + + // Permission denied response + private static final String PERMISSION_DENIED_RESPONSE = """ + { + "ResponseMetadata": { + "RequestId": "test-request-id-error-403", + "Action": "ListTags", + "Version": "2022-05-12", + "Service": "cr", + "Region": "cn-beijing", + "Error": { + "Code": "AccessDenied", + "Message": "Access denied to registry '%s'" + } + } + } + """.formatted(TEST_PERMISSION_DENIED_VOLCENGINE_REGISTRY); + + // Not found response + private static final String NOT_FOUND_RESPONSE = """ + { + "ResponseMetadata": { + "RequestId": "test-request-id-error-404", + "Action": "ListTags", + "Version": "2022-05-12", + "Service": "cr", + "Region": "cn-beijing", + "Error": { + "Code": "ResourceNotFound", + "Message": "The specified resource was not found" + } + } + } + """; + + // Repository not found response + private static final String REPOSITORY_NOT_FOUND_RESPONSE = """ + { + "ResponseMetadata": { + "RequestId": "test-request-id-error-repo-404", + "Action": "ListTags", + "Version": "2022-05-12", + "Service": "cr", + "Region": "cn-beijing", + "Error": { + "Code": "NotFound.Repository", + "Message": "The specified resource %s not found.", + "Data": { + "__Message.parameterK": "Repository", + "__Message.parameterV": "%s" + } + } + } + } + """; + + // Empty result response for tag not found (200 with empty items) + private static final String EMPTY_RESULT_RESPONSE = """ + { + "ResponseMetadata": { + "RequestId": "test-request-id-empty-result", + "Action": "ListTags", + "Version": "2022-05-12", + "Service": "cr", + "Region": "cn-beijing" + }, + "Result": { + "Registry": "%s", + "TotalCount": 0, + "Namespace": "%s", + "Repository": "%s", + "Items": [], + "PageNumber": 1, + "PageSize": 10 + } + } + """; + + // Server error response + private static final String SERVER_ERROR_RESPONSE = """ + { + "ResponseMetadata": { + "RequestId": "test-request-id-error-500", + "Action": "ListTags", + "Version": "2022-05-12", + "Service": "cr", + "Region": "cn-beijing", + "Error": { + "Code": "InternalError", + "Message": "An internal server error occurred" + } + } + } + """; + + // Valid GetAuthorizationToken response + private static final String VALID_GET_AUTHORIZATION_TOKEN_RESPONSE = """ + { + "ResponseMetadata": { + "RequestId": "test-request-id-auth-token-123", + "Action": "GetAuthorizationToken", + "Version": "2022-05-12", + "Service": "cr", + "Region": "cn-beijing" + }, + "Result": { + "Token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ0ZXN0LXVzZXIifQ", + "Username": "test-user", + "ExpireTime": "2025-12-31T23:59:59Z" + } + } + """; + + // Invalid credentials response for GetAuthorizationToken + private static final String INVALID_CREDENTIALS_RESPONSE = """ + { + "ResponseMetadata": { + "RequestId": "test-request-id-auth-error-401", + "Action": "GetAuthorizationToken", + "Version": "2022-05-12", + "Service": "cr", + "Region": "cn-beijing", + "Error": { + "Code": "InvalidSignature", + "Message": "The request signature we calculated does not match the signature you provided" + } + } + } + """; + + private static WireMockServer volcengineArtifactRegistryMockServer; + + @SneakyThrows + public static void start(String volcengineRegistryAPIBaseUrl) { + stop(); + volcengineArtifactRegistryMockServer = + new WireMockServer(URI.create(volcengineRegistryAPIBaseUrl).getPort()); + volcengineArtifactRegistryMockServer.start(); + + // GetAuthorizationToken - Valid credentials + // Checks that Authorization header contains the valid access key ID in the Credential parameter + // Format: HMAC-SHA256 Credential=TEST_VALID_VOLCENGINE_ACCESS_KEY_ID/... + volcengineArtifactRegistryMockServer + .stubFor(post(urlPathEqualTo(VOLCENGINE_ROOT_PATH)) + .withQueryParam("Action", equalTo("GetAuthorizationToken")) + .withQueryParam("Version", equalTo("2022-05-12")) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(VOLCENGINE_JSON_CONTENT_TYPE)) + .withHeader(HttpHeaders.AUTHORIZATION, + matching(".*Credential=" + TEST_VALID_VOLCENGINE_ACCESS_KEY_ID + "/.*")) + .willReturn(aResponse().withStatus(200) + .withHeader(HttpHeaders.CONTENT_TYPE, + VOLCENGINE_JSON_CONTENT_TYPE) + .withBody(VALID_GET_AUTHORIZATION_TOKEN_RESPONSE))); + + // GetAuthorizationToken - Invalid credentials + // Checks that Authorization header contains the invalid access key ID in the Credential parameter + // Format: HMAC-SHA256 Credential=TEST_INVALID_VOLCENGINE_ACCESS_KEY_ID/... + volcengineArtifactRegistryMockServer + .stubFor(post(urlPathEqualTo(VOLCENGINE_ROOT_PATH)) + .withQueryParam("Action", equalTo("GetAuthorizationToken")) + .withQueryParam("Version", equalTo("2022-05-12")) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(VOLCENGINE_JSON_CONTENT_TYPE)) + .withHeader(HttpHeaders.AUTHORIZATION, + matching(".*Credential=" + TEST_INVALID_VOLCENGINE_ACCESS_KEY_ID + "/.*")) + .willReturn(aResponse().withStatus(400) + .withHeader(HttpHeaders.CONTENT_TYPE, + VOLCENGINE_JSON_CONTENT_TYPE) + .withBody(INVALID_CREDENTIALS_RESPONSE))); + + // Server error registry + volcengineArtifactRegistryMockServer + .stubFor(post(urlPathEqualTo(VOLCENGINE_ROOT_PATH)) + .withQueryParam("Action", equalTo("ListTags")) + .withQueryParam("Version", equalTo("2022-05-12")) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(VOLCENGINE_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.Registry", + equalTo(TEST_SERVER_ERROR_VOLCENGINE_REGISTRY))) + .willReturn(aResponse().withStatus(500) + .withHeader(HttpHeaders.CONTENT_TYPE, + VOLCENGINE_JSON_CONTENT_TYPE) + .withBody(SERVER_ERROR_RESPONSE))); + + // Permission denied registry + volcengineArtifactRegistryMockServer + .stubFor(post(urlPathEqualTo(VOLCENGINE_ROOT_PATH)) + .withQueryParam("Action", equalTo("ListTags")) + .withQueryParam("Version", equalTo("2022-05-12")) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(VOLCENGINE_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.Registry", + equalTo(TEST_PERMISSION_DENIED_VOLCENGINE_REGISTRY))) + .willReturn(aResponse().withStatus(403) + .withHeader(HttpHeaders.CONTENT_TYPE, + VOLCENGINE_JSON_CONTENT_TYPE) + .withBody(PERMISSION_DENIED_RESPONSE))); + + // Repository not found + volcengineArtifactRegistryMockServer + .stubFor(post(urlPathEqualTo(VOLCENGINE_ROOT_PATH)) + .withQueryParam("Action", equalTo("ListTags")) + .withQueryParam("Version", equalTo("2022-05-12")) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(VOLCENGINE_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.Repository", + equalTo(TEST_NOT_EXIST_VOLCENGINE_REPOSITORY))) + .willReturn(aResponse().withStatus(404) + .withHeader(HttpHeaders.CONTENT_TYPE, + VOLCENGINE_JSON_CONTENT_TYPE) + .withBody( + REPOSITORY_NOT_FOUND_RESPONSE.formatted( + TEST_NOT_EXIST_VOLCENGINE_REPOSITORY, + TEST_NOT_EXIST_VOLCENGINE_REPOSITORY)))); + + // Valid container image with tag + volcengineArtifactRegistryMockServer + .stubFor(post(urlPathEqualTo(VOLCENGINE_ROOT_PATH)) + .withQueryParam("Action", equalTo("ListTags")) + .withQueryParam("Version", equalTo("2022-05-12")) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(VOLCENGINE_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.Registry", + equalTo(TEST_VALID_VOLCENGINE_REGISTRY))) + .withRequestBody(matchingJsonPath("$.Namespace", + equalTo(TEST_VALID_VOLCENGINE_NAMESPACE))) + .withRequestBody(matchingJsonPath("$.Repository", + equalTo(TEST_VALID_VOLCENGINE_CONTAINER_IMAGE_REPOSITORY))) + .withRequestBody(matchingJsonPath("$.Filter.Names[0]", + equalTo(TEST_VALID_VOLCENGINE_IMAGE_TAG))) + .withRequestBody(matchingJsonPath("$.Filter.Types[0]", + equalTo(TEST_IMAGE_TYPE))) + .willReturn(aResponse().withStatus(200) + .withHeader(HttpHeaders.CONTENT_TYPE, + VOLCENGINE_JSON_CONTENT_TYPE) + .withBody(VALID_CONTAINER_IMAGE_RESPONSE))); + + // Non-existent container image tag + volcengineArtifactRegistryMockServer + .stubFor(post(urlPathEqualTo(VOLCENGINE_ROOT_PATH)) + .withQueryParam("Action", equalTo("ListTags")) + .withQueryParam("Version", equalTo("2022-05-12")) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(VOLCENGINE_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.Registry", + equalTo(TEST_VALID_VOLCENGINE_REGISTRY))) + .withRequestBody(matchingJsonPath("$.Namespace", + equalTo(TEST_VALID_VOLCENGINE_NAMESPACE))) + .withRequestBody(matchingJsonPath("$.Repository", + equalTo(TEST_VALID_VOLCENGINE_CONTAINER_IMAGE_REPOSITORY))) + .withRequestBody(matchingJsonPath("$.Filter.Names[0]", + equalTo(TEST_NOT_EXIST_VOLCENGINE_IMAGE_TAG))) + .withRequestBody(matchingJsonPath("$.Filter.Types[0]", + equalTo(TEST_IMAGE_TYPE))) + .willReturn(aResponse().withStatus(200) + .withHeader(HttpHeaders.CONTENT_TYPE, + VOLCENGINE_JSON_CONTENT_TYPE) + .withBody(EMPTY_RESULT_RESPONSE.formatted( + TEST_VALID_VOLCENGINE_REGISTRY, + TEST_VALID_VOLCENGINE_NAMESPACE, + TEST_VALID_VOLCENGINE_CONTAINER_IMAGE_REPOSITORY)))); + + // Valid helm chart with tag + volcengineArtifactRegistryMockServer + .stubFor(post(urlPathEqualTo(VOLCENGINE_ROOT_PATH)) + .withQueryParam("Action", equalTo("ListTags")) + .withQueryParam("Version", equalTo("2022-05-12")) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(VOLCENGINE_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.Registry", + equalTo(TEST_VALID_VOLCENGINE_REGISTRY))) + .withRequestBody(matchingJsonPath("$.Namespace", + equalTo(TEST_VALID_VOLCENGINE_NAMESPACE))) + .withRequestBody(matchingJsonPath("$.Repository", + equalTo(TEST_VALID_VOLCENGINE_HELM_REPOSITORY))) + .withRequestBody(matchingJsonPath("$.Filter.Names[0]", + equalTo(TEST_VALID_VOLCENGINE_HELM_CHART_TAG))) + .withRequestBody(matchingJsonPath("$.Filter.Types[0]", + equalTo(TEST_HELM_CHART_TYPE))) + .willReturn(aResponse().withStatus(200) + .withHeader(HttpHeaders.CONTENT_TYPE, + VOLCENGINE_JSON_CONTENT_TYPE) + .withBody(VALID_HELM_CHART_RESPONSE))); + + // Non-existent helm chart tag + volcengineArtifactRegistryMockServer + .stubFor(post(urlPathEqualTo(VOLCENGINE_ROOT_PATH)) + .withQueryParam("Action", equalTo("ListTags")) + .withQueryParam("Version", equalTo("2022-05-12")) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(VOLCENGINE_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.Registry", + equalTo(TEST_VALID_VOLCENGINE_REGISTRY))) + .withRequestBody(matchingJsonPath("$.Namespace", + equalTo(TEST_VALID_VOLCENGINE_NAMESPACE))) + .withRequestBody(matchingJsonPath("$.Repository", + equalTo(TEST_VALID_VOLCENGINE_HELM_REPOSITORY))) + .withRequestBody(matchingJsonPath("$.Filter.Names[0]", + equalTo(TEST_NOT_EXIST_VOLCENGINE_HELM_CHART_TAG))) + .withRequestBody(matchingJsonPath("$.Filter.Types[0]", + equalTo(TEST_HELM_CHART_TYPE))) + .willReturn(aResponse().withStatus(200) + .withHeader(HttpHeaders.CONTENT_TYPE, + VOLCENGINE_JSON_CONTENT_TYPE) + .withBody(EMPTY_RESULT_RESPONSE.formatted( + TEST_VALID_VOLCENGINE_REGISTRY, + TEST_VALID_VOLCENGINE_NAMESPACE, + TEST_VALID_VOLCENGINE_HELM_REPOSITORY)))); + } + + public static void setCustomResponse(String registry, String namespace, String repository, + String type, String tagName, String responseBody) { + volcengineArtifactRegistryMockServer + .stubFor(post(urlPathEqualTo(VOLCENGINE_ROOT_PATH)) + .withQueryParam("Action", equalTo("ListTags")) + .withQueryParam("Version", equalTo("2022-05-12")) + .withHeader(HttpHeaders.CONTENT_TYPE, + equalTo(VOLCENGINE_JSON_CONTENT_TYPE)) + .withRequestBody(matchingJsonPath("$.Registry", equalTo(registry))) + .withRequestBody( + matchingJsonPath("$.Namespace", equalTo(namespace))) + .withRequestBody( + matchingJsonPath("$.Repository", equalTo(repository))) + .withRequestBody(matchingJsonPath("$.Filter.Types[0]", + equalTo(type))) + .withRequestBody( + matchingJsonPath("$.Filter.Names[0]", equalTo(tagName))) + .willReturn(aResponse().withStatus(200) + .withHeader(HttpHeaders.CONTENT_TYPE, + VOLCENGINE_JSON_CONTENT_TYPE) + .withBody(responseBody))); + } + + public static void stop() { + if (volcengineArtifactRegistryMockServer != null) { + volcengineArtifactRegistryMockServer.stop(); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/TestConstants.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/TestConstants.java new file mode 100644 index 0000000000..e4339f9b73 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/TestConstants.java @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock; + +public class TestConstants { + + public static final String MOCK_NGC_CONTAINER_REGISTRY_URL = "http://localhost:9100"; + public static final String MOCK_NGC_REGISTRY_OAUTH2_BASE_URL = "http://localhost:9102"; + public static final String MOCK_DOCKER_REGISTRY_URL = "http://localhost:9103"; + public static final String MOCK_DOCKER_REGISTRY_AUTH_URL = "http://localhost:9104"; + public static final String MOCK_ECR_REGISTRY_URL = "http://localhost:9105"; + public static final String MOCK_ECR_PUBLIC_REGISTRY_URL = "http://localhost:9106"; + public static final String MOCK_VOLCENGINE_REGISTRY_URL = "http://localhost:9107"; + public static final String MOCK_OCI_REGISTRY_URL = "http://localhost:9110"; + public static final String MOCK_ACR_AUTH_URL = "http://localhost:9109"; + public static final String MOCK_HARBOR_AUTH_URL = "http://localhost:9111"; + public static final String MOCK_ARTIFACTORY_AUTH_URL = "http://localhost:9112"; +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/artifactory/MockArtifactoryAuthServerTest.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/artifactory/MockArtifactoryAuthServerTest.java new file mode 100644 index 0000000000..30e18b5f69 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/artifactory/MockArtifactoryAuthServerTest.java @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock.artifactory; + +import static com.nvidia.boot.mock.TestConstants.MOCK_ARTIFACTORY_AUTH_URL; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class MockArtifactoryAuthServerTest { + + private static final String TEST_RESPONSE = "{\"test\": \"data\"}"; + + @BeforeEach + void setUp() { + MockArtifactoryAuthServer.stop(); + } + + @AfterEach + void tearDown() { + MockArtifactoryAuthServer.stop(); + } + + @Test + void testStartAndStop() { + assertDoesNotThrow(() -> MockArtifactoryAuthServer.start(MOCK_ARTIFACTORY_AUTH_URL)); + assertDoesNotThrow(MockArtifactoryAuthServer::stop); + } + + @Test + void testStartMultipleTimes() { + assertDoesNotThrow(() -> MockArtifactoryAuthServer.start(MOCK_ARTIFACTORY_AUTH_URL)); + assertDoesNotThrow(() -> MockArtifactoryAuthServer.start(MOCK_ARTIFACTORY_AUTH_URL)); + assertDoesNotThrow(MockArtifactoryAuthServer::stop); + } + + @Test + void testStopWithoutStart() { + assertDoesNotThrow(MockArtifactoryAuthServer::stop); + } + + @Test + void testSetCustomResponse() { + MockArtifactoryAuthServer.start(MOCK_ARTIFACTORY_AUTH_URL); + + String customUrl = "/custom/test/endpoint"; + byte[] customBody = TEST_RESPONSE.getBytes(); + + assertDoesNotThrow(() -> MockArtifactoryAuthServer.setResponse(customUrl, customBody)); + + MockArtifactoryAuthServer.stop(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/azure/MockAcrAuthServerTest.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/azure/MockAcrAuthServerTest.java new file mode 100644 index 0000000000..d4c9401522 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/azure/MockAcrAuthServerTest.java @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock.azure; + +import static com.nvidia.boot.mock.TestConstants.MOCK_ACR_AUTH_URL; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import com.nvidia.boot.mock.docker.MockDockerRegistryAuthServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class MockAcrAuthServerTest { + + private static final String TEST_RESPONSE = "{\"test\": \"data\"}"; + + @BeforeEach + void setUp() { + MockAcrAuthServer.stop(); + } + + @AfterEach + void tearDown() { + MockAcrAuthServer.stop(); + } + + @Test + void testStartAndStop() { + assertDoesNotThrow(() -> MockAcrAuthServer.start(MOCK_ACR_AUTH_URL)); + assertDoesNotThrow(MockAcrAuthServer::stop); + } + + @Test + void testStartMultipleTimes() { + assertDoesNotThrow(() -> MockAcrAuthServer.start(MOCK_ACR_AUTH_URL)); + assertDoesNotThrow(() -> MockAcrAuthServer.start(MOCK_ACR_AUTH_URL)); + assertDoesNotThrow(MockAcrAuthServer::stop); + } + + @Test + void testStopWithoutStart() { + assertDoesNotThrow(MockAcrAuthServer::stop); + } + + @Test + void testSetCustomResponse() { + MockDockerRegistryAuthServer.start(MOCK_ACR_AUTH_URL); + + String customUrl = "/custom/test/endpoint"; + byte[] customBody = TEST_RESPONSE.getBytes(); + + assertDoesNotThrow(() -> MockDockerRegistryAuthServer.setResponse(customUrl, customBody)); + + MockDockerRegistryAuthServer.stop(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/docker/MockDockerRegistryAuthServerTest.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/docker/MockDockerRegistryAuthServerTest.java new file mode 100644 index 0000000000..cbe4d57a2a --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/docker/MockDockerRegistryAuthServerTest.java @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock.docker; + +import static com.nvidia.boot.mock.TestConstants.MOCK_DOCKER_REGISTRY_AUTH_URL; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class MockDockerRegistryAuthServerTest { + + private static final String TEST_RESPONSE = "{\"test\": \"data\"}"; + + @BeforeEach + void setUp() { + // Clean up any existing servers + MockDockerRegistryAuthServer.stop(); + } + + @AfterEach + void tearDown() { + MockDockerRegistryAuthServer.stop(); + } + + @Test + void testStartAndStop() { + // Test that start doesn't throw any exceptions + assertDoesNotThrow(() -> MockDockerRegistryAuthServer.start(MOCK_DOCKER_REGISTRY_AUTH_URL)); + + // Test that stop doesn't throw any exceptions + assertDoesNotThrow(() -> MockDockerRegistryAuthServer.stop()); + } + + @Test + void testStartMultipleTimes() { + // Test that starting multiple times doesn't cause issues + assertDoesNotThrow(() -> MockDockerRegistryAuthServer.start(MOCK_DOCKER_REGISTRY_AUTH_URL)); + assertDoesNotThrow(() -> MockDockerRegistryAuthServer.start(MOCK_DOCKER_REGISTRY_AUTH_URL)); + assertDoesNotThrow(() -> MockDockerRegistryAuthServer.stop()); + } + + @Test + void testStopWithoutStart() { + // Test that stopping without starting doesn't cause issues + assertDoesNotThrow(() -> MockDockerRegistryAuthServer.stop()); + } + + @Test + void testSetCustomResponse() { + MockDockerRegistryAuthServer.start(MOCK_DOCKER_REGISTRY_AUTH_URL); + + // Test setting a custom response + String customUrl = "/custom/test/endpoint"; + byte[] customBody = TEST_RESPONSE.getBytes(); + + assertDoesNotThrow(() -> MockDockerRegistryAuthServer.setResponse(customUrl, customBody)); + + MockDockerRegistryAuthServer.stop(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/docker/MockDockerRegistryServerTest.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/docker/MockDockerRegistryServerTest.java new file mode 100644 index 0000000000..82631878bd --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/docker/MockDockerRegistryServerTest.java @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock.docker; + +import static com.nvidia.boot.mock.TestConstants.MOCK_DOCKER_REGISTRY_URL; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class MockDockerRegistryServerTest { + + private static final String TEST_RESPONSE = "{\"test\": \"data\"}"; + + @BeforeEach + void setUp() { + // Clean up any existing servers + MockDockerRegistryServer.stop(); + } + + @AfterEach + void tearDown() { + MockDockerRegistryServer.stop(); + } + + @Test + void testStartAndStop() { + // Test that start doesn't throw any exceptions + assertDoesNotThrow(() -> MockDockerRegistryServer.start(MOCK_DOCKER_REGISTRY_URL)); + + // Test that stop doesn't throw any exceptions + assertDoesNotThrow(() -> MockDockerRegistryServer.stop()); + } + + @Test + void testStartMultipleTimes() { + // Test that starting multiple times doesn't cause issues + assertDoesNotThrow(() -> MockDockerRegistryServer.start(MOCK_DOCKER_REGISTRY_URL)); + assertDoesNotThrow(() -> MockDockerRegistryServer.start(MOCK_DOCKER_REGISTRY_URL)); + assertDoesNotThrow(() -> MockDockerRegistryServer.stop()); + } + + @Test + void testStopWithoutStart() { + // Test that stopping without starting doesn't cause issues + assertDoesNotThrow(() -> MockDockerRegistryServer.stop()); + } + + @Test + void testSetCustomResponse() { + MockDockerRegistryServer.start(MOCK_DOCKER_REGISTRY_URL); + + // Test setting a custom response + String customUrl = "/custom/test/endpoint"; + byte[] customBody = TEST_RESPONSE.getBytes(); + + assertDoesNotThrow(() -> MockDockerRegistryServer.setResponse(customUrl, customBody)); + + MockDockerRegistryServer.stop(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/ecr/MockEcrPrivateRegistryServerTest.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/ecr/MockEcrPrivateRegistryServerTest.java new file mode 100644 index 0000000000..f3a7615a2f --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/ecr/MockEcrPrivateRegistryServerTest.java @@ -0,0 +1,123 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock.ecr; + +import static com.nvidia.boot.mock.TestConstants.MOCK_ECR_REGISTRY_URL; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class MockEcrPrivateRegistryServerTest { + + private static final String TEST_CUSTOM_REPOSITORY = "test-custom/repository"; + private static final String TEST_CUSTOM_DIGEST = + "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"; + private static final String TEST_CUSTOM_RESPONSE = """ + { + "imageDetails": [ + { + "imageDigest": "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + "imageManifestMediaType": "application/vnd.oci.image.manifest.v1+json", + "imageTags": [ + "custom-tag" + ], + "registryId": "123456789012", + "repositoryName": "test-custom/repository" + } + ] + } + """; + + @BeforeEach + void setUp() { + // Clean up any existing servers + MockEcrPrivateRegistryServer.stop(); + } + + @AfterEach + void tearDown() { + MockEcrPrivateRegistryServer.stop(); + } + + @Test + void testStartAndStop() { + // Test that start doesn't throw any exceptions + assertDoesNotThrow(() -> MockEcrPrivateRegistryServer.start(MOCK_ECR_REGISTRY_URL)); + + // Test that stop doesn't throw any exceptions + assertDoesNotThrow(() -> MockEcrPrivateRegistryServer.stop()); + } + + @Test + void testStartMultipleTimes() { + // Test that starting multiple times doesn't cause issues + assertDoesNotThrow(() -> MockEcrPrivateRegistryServer.start(MOCK_ECR_REGISTRY_URL)); + assertDoesNotThrow(() -> MockEcrPrivateRegistryServer.start(MOCK_ECR_REGISTRY_URL)); + assertDoesNotThrow(() -> MockEcrPrivateRegistryServer.stop()); + } + + @Test + void testStopWithoutStart() { + // Test that stopping without starting doesn't cause issues + assertDoesNotThrow(() -> MockEcrPrivateRegistryServer.stop()); + } + + @Test + void testSetCustomResponse() { + MockEcrPrivateRegistryServer.start(MOCK_ECR_REGISTRY_URL); + + // Test setting a custom response + assertDoesNotThrow(() -> MockEcrPrivateRegistryServer.setCustomResponse( + TEST_CUSTOM_REPOSITORY, + TEST_CUSTOM_DIGEST, + TEST_CUSTOM_RESPONSE)); + + MockEcrPrivateRegistryServer.stop(); + } + + @Test + void testSetCustomResponseWithoutStart() { + // Test that setting custom response without starting doesn't cause issues + assertDoesNotThrow(() -> MockEcrPrivateRegistryServer.setCustomResponse( + TEST_CUSTOM_REPOSITORY, + TEST_CUSTOM_DIGEST, + TEST_CUSTOM_RESPONSE)); + } + + @Test + void testMultipleCustomResponses() { + MockEcrPrivateRegistryServer.start(MOCK_ECR_REGISTRY_URL); + + // Test setting multiple custom responses + assertDoesNotThrow(() -> { + MockEcrPrivateRegistryServer.setCustomResponse( + TEST_CUSTOM_REPOSITORY, + TEST_CUSTOM_DIGEST, + TEST_CUSTOM_RESPONSE); + + MockEcrPrivateRegistryServer.setCustomResponse( + "another-repo/test", + "sha256:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321", + TEST_CUSTOM_RESPONSE); + }); + + MockEcrPrivateRegistryServer.stop(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/ecr/MockEcrPublicRegistryServerTest.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/ecr/MockEcrPublicRegistryServerTest.java new file mode 100644 index 0000000000..c9a0c35884 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/ecr/MockEcrPublicRegistryServerTest.java @@ -0,0 +1,163 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock.ecr; + +import static com.nvidia.boot.mock.TestConstants.MOCK_ECR_PUBLIC_REGISTRY_URL; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class MockEcrPublicRegistryServerTest { + + private static final String TEST_CUSTOM_REPOSITORY = "test-custom/public-repository"; + private static final String TEST_CUSTOM_DIGEST = "sha256:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321"; + private static final String TEST_CUSTOM_RESPONSE = """ + { + "imageDetails": [ + { + "imageDigest": "sha256:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321", + "imageManifestMediaType": "application/vnd.oci.image.manifest.v1+json", + "imageTags": [ + "custom-public-tag" + ], + "repositoryName": "test-custom/public-repository" + } + ] + } + """; + + @BeforeEach + void setUp() { + // Clean up any existing servers + MockEcrPublicRegistryServer.stop(); + } + + @AfterEach + void tearDown() { + MockEcrPublicRegistryServer.stop(); + } + + @Test + void testStartAndStop() { + assertDoesNotThrow(() -> MockEcrPublicRegistryServer.start(MOCK_ECR_PUBLIC_REGISTRY_URL)); + assertDoesNotThrow(() -> MockEcrPublicRegistryServer.stop()); + } + + @Test + void testStartMultipleTimes() { + assertDoesNotThrow(() -> MockEcrPublicRegistryServer.start(MOCK_ECR_PUBLIC_REGISTRY_URL)); + assertDoesNotThrow(() -> MockEcrPublicRegistryServer.start(MOCK_ECR_PUBLIC_REGISTRY_URL)); + assertDoesNotThrow(() -> MockEcrPublicRegistryServer.stop()); + } + + @Test + void testStopWithoutStart() { + assertDoesNotThrow(() -> MockEcrPublicRegistryServer.stop()); + } + + @Test + void testSetCustomResponse() { + MockEcrPublicRegistryServer.start(MOCK_ECR_PUBLIC_REGISTRY_URL); + + assertDoesNotThrow(() -> MockEcrPublicRegistryServer.setCustomResponse( + TEST_CUSTOM_REPOSITORY, + TEST_CUSTOM_DIGEST, + TEST_CUSTOM_RESPONSE)); + + MockEcrPublicRegistryServer.stop(); + } + + @Test + void testSetCustomResponseWithoutStart() { + assertDoesNotThrow(() -> MockEcrPublicRegistryServer.setCustomResponse( + TEST_CUSTOM_REPOSITORY, + TEST_CUSTOM_DIGEST, + TEST_CUSTOM_RESPONSE)); + } + + @Test + void testMultipleCustomResponses() { + MockEcrPublicRegistryServer.start(MOCK_ECR_PUBLIC_REGISTRY_URL); + + assertDoesNotThrow(() -> { + MockEcrPublicRegistryServer.setCustomResponse( + TEST_CUSTOM_REPOSITORY, + TEST_CUSTOM_DIGEST, + TEST_CUSTOM_RESPONSE); + + MockEcrPublicRegistryServer.setCustomResponse( + "another-public-repo/test", + "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + TEST_CUSTOM_RESPONSE); + }); + + MockEcrPublicRegistryServer.stop(); + } + + @Test + void testServerLifecycle() { + assertDoesNotThrow(() -> { + MockEcrPublicRegistryServer.start(MOCK_ECR_PUBLIC_REGISTRY_URL); + + MockEcrPublicRegistryServer.setCustomResponse( + TEST_CUSTOM_REPOSITORY, + TEST_CUSTOM_DIGEST, + TEST_CUSTOM_RESPONSE); + + // Restart server (should handle cleanup properly) + MockEcrPublicRegistryServer.start(MOCK_ECR_PUBLIC_REGISTRY_URL); + + // Stop server + MockEcrPublicRegistryServer.stop(); + }); + } + + @Test + void testMultipleStopsAreSafe() { + // Test that multiple stops don't cause issues + MockEcrPublicRegistryServer.start(MOCK_ECR_PUBLIC_REGISTRY_URL); + + assertDoesNotThrow(() -> { + MockEcrPublicRegistryServer.stop(); + MockEcrPublicRegistryServer.stop(); + MockEcrPublicRegistryServer.stop(); + }); + } + + @Test + void testSetCustomResponseAfterRestart() { + // Test setting custom response after restart + MockEcrPublicRegistryServer.start(MOCK_ECR_PUBLIC_REGISTRY_URL); + MockEcrPublicRegistryServer.setCustomResponse( + TEST_CUSTOM_REPOSITORY, + TEST_CUSTOM_DIGEST, + TEST_CUSTOM_RESPONSE); + + // Restart and set again + MockEcrPublicRegistryServer.start(MOCK_ECR_PUBLIC_REGISTRY_URL); + + assertDoesNotThrow(() -> MockEcrPublicRegistryServer.setCustomResponse( + "new-repo/after-restart", + "sha256:1111111111111111111111111111111111111111111111111111111111111111", + TEST_CUSTOM_RESPONSE)); + + MockEcrPublicRegistryServer.stop(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/harbor/MockHarborAuthServerTest.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/harbor/MockHarborAuthServerTest.java new file mode 100644 index 0000000000..0fba80a143 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/harbor/MockHarborAuthServerTest.java @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock.harbor; + +import static com.nvidia.boot.mock.TestConstants.MOCK_HARBOR_AUTH_URL; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class MockHarborAuthServerTest { + + private static final String TEST_RESPONSE = "{\"test\": \"data\"}"; + + @BeforeEach + void setUp() { + MockHarborAuthServer.stop(); + } + + @AfterEach + void tearDown() { + MockHarborAuthServer.stop(); + } + + @Test + void testStartAndStop() { + assertDoesNotThrow(() -> MockHarborAuthServer.start(MOCK_HARBOR_AUTH_URL)); + assertDoesNotThrow(MockHarborAuthServer::stop); + } + + @Test + void testStartMultipleTimes() { + assertDoesNotThrow(() -> MockHarborAuthServer.start(MOCK_HARBOR_AUTH_URL)); + assertDoesNotThrow(() -> MockHarborAuthServer.start(MOCK_HARBOR_AUTH_URL)); + assertDoesNotThrow(MockHarborAuthServer::stop); + } + + @Test + void testStopWithoutStart() { + assertDoesNotThrow(MockHarborAuthServer::stop); + } + + @Test + void testSetCustomResponse() { + MockHarborAuthServer.start(MOCK_HARBOR_AUTH_URL); + + String customUrl = "/custom/test/endpoint"; + byte[] customBody = TEST_RESPONSE.getBytes(); + + assertDoesNotThrow(() -> MockHarborAuthServer.setResponse(customUrl, customBody)); + + MockHarborAuthServer.stop(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/ngc/MockCasServerTest.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/ngc/MockCasServerTest.java new file mode 100644 index 0000000000..0e24c3dd59 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/ngc/MockCasServerTest.java @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock.ngc; + +import static com.nvidia.boot.mock.TestConstants.MOCK_NGC_CONTAINER_REGISTRY_URL; +import static com.nvidia.boot.mock.TestConstants.MOCK_NGC_REGISTRY_OAUTH2_BASE_URL; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class MockCasServerTest { + + private static final String TEST_RESPONSE = "{\"test\": \"data\"}"; + + @BeforeEach + void setUp() { + // Clean up any existing servers + MockCasServer.stop(); + } + + @AfterEach + void tearDown() { + MockCasServer.stop(); + } + + @Test + void testStartAndStop() { + // Test that start doesn't throw any exceptions + assertDoesNotThrow(() -> MockCasServer.start(MOCK_NGC_REGISTRY_OAUTH2_BASE_URL, + MOCK_NGC_CONTAINER_REGISTRY_URL)); + + // Test that stop doesn't throw any exceptions + assertDoesNotThrow(() -> MockCasServer.stop()); + } + + @Test + void testStartMultipleTimes() { + // Test that starting multiple times doesn't cause issues + assertDoesNotThrow(() -> MockCasServer.start(MOCK_NGC_REGISTRY_OAUTH2_BASE_URL, + MOCK_NGC_CONTAINER_REGISTRY_URL)); + assertDoesNotThrow(() -> MockCasServer.start(MOCK_NGC_REGISTRY_OAUTH2_BASE_URL, + MOCK_NGC_CONTAINER_REGISTRY_URL)); + assertDoesNotThrow(() -> MockCasServer.stop()); + } + + @Test + void testStopWithoutStart() { + // Test that stopping without starting doesn't cause issues + assertDoesNotThrow(() -> MockCasServer.stop()); + } + + @Test + void testSetCustomResponse() { + MockCasServer.start(MOCK_NGC_REGISTRY_OAUTH2_BASE_URL, + MOCK_NGC_CONTAINER_REGISTRY_URL); + + // Test setting a custom response + String customUrl = "/custom/test/endpoint"; + byte[] customBody = TEST_RESPONSE.getBytes(); + + assertDoesNotThrow(() -> MockCasServer.setResponse(customUrl, customBody)); + + MockCasServer.stop(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/ngc/MockNgcContainerRegistryServerTest.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/ngc/MockNgcContainerRegistryServerTest.java new file mode 100644 index 0000000000..7dcaae6583 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/ngc/MockNgcContainerRegistryServerTest.java @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock.ngc; + +import static com.nvidia.boot.mock.TestConstants.MOCK_NGC_CONTAINER_REGISTRY_URL; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class MockNgcContainerRegistryServerTest { + + private static final String TEST_RESPONSE = "{\"test\": \"data\"}"; + + @BeforeEach + void setUp() { + // Clean up any existing servers + MockNgcContainerRegistryServer.stop(); + } + + @AfterEach + void tearDown() { + MockNgcContainerRegistryServer.stop(); + } + + @Test + void testStartAndStop() { + // Test that start doesn't throw any exceptions + assertDoesNotThrow(() -> MockNgcContainerRegistryServer.start(MOCK_NGC_CONTAINER_REGISTRY_URL)); + + // Test that stop doesn't throw any exceptions + assertDoesNotThrow(() -> MockNgcContainerRegistryServer.stop()); + } + + @Test + void testStartMultipleTimes() { + // Test that starting multiple times doesn't cause issues + assertDoesNotThrow(() -> MockNgcContainerRegistryServer.start(MOCK_NGC_CONTAINER_REGISTRY_URL)); + assertDoesNotThrow(() -> MockNgcContainerRegistryServer.start(MOCK_NGC_CONTAINER_REGISTRY_URL)); + assertDoesNotThrow(() -> MockNgcContainerRegistryServer.stop()); + } + + @Test + void testStopWithoutStart() { + // Test that stopping without starting doesn't cause issues + assertDoesNotThrow(() -> MockNgcContainerRegistryServer.stop()); + } + + @Test + void testSetCustomResponse() { + MockNgcContainerRegistryServer.start(MOCK_NGC_CONTAINER_REGISTRY_URL); + + // Test setting a custom response + String customUrl = "/custom/test/endpoint"; + byte[] customBody = TEST_RESPONSE.getBytes(); + + assertDoesNotThrow(() -> MockNgcContainerRegistryServer.setResponse(customUrl, customBody)); + + MockNgcContainerRegistryServer.stop(); + } +} \ No newline at end of file diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/oci/MockOciRegistryServerTest.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/oci/MockOciRegistryServerTest.java new file mode 100644 index 0000000000..c9fb2396c1 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/oci/MockOciRegistryServerTest.java @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock.oci; + +import static com.nvidia.boot.mock.TestConstants.MOCK_OCI_REGISTRY_URL; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class MockOciRegistryServerTest { + + MockOciRegistryServer mockOciRegistryServer = new MockOciRegistryServer(); + + @BeforeEach + void setUp() { + mockOciRegistryServer.stop(); + } + + @AfterEach + void tearDown() { + mockOciRegistryServer.stop(); + } + + @Test + void testStartAndStop() { + assertDoesNotThrow(() -> mockOciRegistryServer.start(MOCK_OCI_REGISTRY_URL)); + assertDoesNotThrow(() -> mockOciRegistryServer.stop()); + } + + @Test + void testStartMultipleTimes() { + assertDoesNotThrow(() -> mockOciRegistryServer.start(MOCK_OCI_REGISTRY_URL)); + assertDoesNotThrow(() -> mockOciRegistryServer.start(MOCK_OCI_REGISTRY_URL)); + assertDoesNotThrow(() -> mockOciRegistryServer.stop()); + } + + @Test + void testStopWithoutStart() { + assertDoesNotThrow(() -> mockOciRegistryServer.stop()); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/volcengine/MockVolcengineRegistryServerTest.java b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/volcengine/MockVolcengineRegistryServerTest.java new file mode 100644 index 0000000000..f00db1dfd4 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test/src/test/java/com/nvidia/boot/mock/volcengine/MockVolcengineRegistryServerTest.java @@ -0,0 +1,152 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.mock.volcengine; + +import static com.nvidia.boot.mock.BootTestConstants.TEST_IMAGE_TYPE; +import static com.nvidia.boot.mock.TestConstants.MOCK_VOLCENGINE_REGISTRY_URL; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class MockVolcengineRegistryServerTest { + + private static final String TEST_CUSTOM_REGISTRY = "test-custom-registry"; + private static final String TEST_CUSTOM_NAMESPACE = "test-custom-namespace"; + private static final String TEST_CUSTOM_REPOSITORY = "test-custom/repository"; + private static final String TEST_CUSTOM_TAG = "custom-tag"; + private static final String TEST_CUSTOM_RESPONSE = """ + { + "ResponseMetadata": { + "RequestId": "test-custom-request-id", + "Action": "ListTags", + "Version": "2022-05-12", + "Service": "cr", + "Region": "cn-beijing" + }, + "Result": { + "Registry": "test-custom-registry", + "Namespace": "test-custom-namespace", + "Repository": "test-custom/repository", + "Items": [ + { + "Name": "custom-tag", + "Type": "Image", + "Digest": "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + "ImageAttributes": [ + { + "Architecture": "amd64", + "Os": "linux", + "Digest": "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + } + ] + } + ] + } + } + """; + + @BeforeEach + void setUp() { + // Clean up any existing servers + MockVolcengineRegistryServer.stop(); + } + + @AfterEach + void tearDown() { + MockVolcengineRegistryServer.stop(); + } + + @Test + void testStartAndStop() { + assertDoesNotThrow( + () -> MockVolcengineRegistryServer.start(MOCK_VOLCENGINE_REGISTRY_URL)); + + assertDoesNotThrow(() -> MockVolcengineRegistryServer.stop()); + } + + @Test + void testStartMultipleTimes() { + assertDoesNotThrow( + () -> MockVolcengineRegistryServer.start(MOCK_VOLCENGINE_REGISTRY_URL)); + assertDoesNotThrow( + () -> MockVolcengineRegistryServer.start(MOCK_VOLCENGINE_REGISTRY_URL)); + assertDoesNotThrow(() -> MockVolcengineRegistryServer.stop()); + } + + @Test + void testStopWithoutStart() { + // Test that stopping without starting doesn't cause issues + assertDoesNotThrow(() -> MockVolcengineRegistryServer.stop()); + } + + @Test + void testSetCustomResponse() { + MockVolcengineRegistryServer.start(MOCK_VOLCENGINE_REGISTRY_URL); + + // Test setting a custom response + assertDoesNotThrow(() -> MockVolcengineRegistryServer.setCustomResponse( + TEST_CUSTOM_REGISTRY, + TEST_CUSTOM_NAMESPACE, + TEST_CUSTOM_REPOSITORY, + TEST_IMAGE_TYPE, + TEST_CUSTOM_TAG, + TEST_CUSTOM_RESPONSE)); + + MockVolcengineRegistryServer.stop(); + } + + @Test + void testSetCustomResponseWithoutStart() { + // Test that setting custom response without starting doesn't cause issues + assertDoesNotThrow(() -> MockVolcengineRegistryServer.setCustomResponse( + TEST_CUSTOM_REGISTRY, + TEST_CUSTOM_NAMESPACE, + TEST_CUSTOM_REPOSITORY, + TEST_IMAGE_TYPE, + TEST_CUSTOM_TAG, + TEST_CUSTOM_RESPONSE)); + } + + @Test + void testMultipleCustomResponses() { + MockVolcengineRegistryServer.start(MOCK_VOLCENGINE_REGISTRY_URL); + + // Test setting multiple custom responses + assertDoesNotThrow(() -> { + MockVolcengineRegistryServer.setCustomResponse( + TEST_CUSTOM_REGISTRY, + TEST_CUSTOM_NAMESPACE, + TEST_CUSTOM_REPOSITORY, + TEST_IMAGE_TYPE, + TEST_CUSTOM_TAG, + TEST_CUSTOM_RESPONSE); + + MockVolcengineRegistryServer.setCustomResponse( + "another-registry", + "another-namespace", + "another-repo/test", + TEST_IMAGE_TYPE, + "another-tag", + TEST_CUSTOM_RESPONSE); + }); + + MockVolcengineRegistryServer.stop(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/BUILD.bazel b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/BUILD.bazel new file mode 100644 index 0000000000..83dc87abfe --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/BUILD.bazel @@ -0,0 +1,70 @@ +load("@rules_java//java:defs.bzl", "java_library") +load("//src/libraries/java/nv-boot-parent/tools/bazel:java.bzl", "nv_boot_library", "nv_boot_library_test", "nv_boot_runtime_classpath_test") + +AUDIT_REQUIRED_DEPS = [ + "@nv_third_party_deps//:com_fasterxml_jackson_core_jackson_annotations", + "@nv_third_party_deps//:com_fasterxml_jackson_core_jackson_databind", + "@nv_third_party_deps//:com_github_java_json_tools_json_patch", + "@nv_third_party_deps//:org_apache_commons_commons_lang3", + "@nv_third_party_deps//:org_bouncycastle_bcprov_jdk18on", + "@nv_third_party_deps//:org_slf4j_slf4j_api", + "@nv_third_party_deps//:org_springframework_boot_spring_boot", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_autoconfigure", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_jackson", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_jackson", + "@nv_third_party_deps//:org_springframework_security_spring_security_core", + "@nv_third_party_deps//:org_springframework_spring_beans", + "@nv_third_party_deps//:org_springframework_spring_context", + "@nv_third_party_deps//:org_springframework_spring_core", + "@nv_third_party_deps//:tools_jackson_core_jackson_core", + "@nv_third_party_deps//:tools_jackson_core_jackson_databind", +] + +# The OAuth2 resource-server starter is optional in Maven. Keep its APIs on +# this module's compile/test classpaths without exporting them at runtime. +AUDIT_OPTIONAL_COMPILE_DEPS = [ + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_security_oauth2_resource_server", + "@nv_third_party_deps//:org_springframework_security_spring_security_oauth2_core", + "@nv_third_party_deps//:org_springframework_security_spring_security_oauth2_jose", + "@nv_third_party_deps//:org_springframework_security_spring_security_oauth2_resource_server", +] + +java_library( + name = "optional_compile_deps", + exports = AUDIT_OPTIONAL_COMPILE_DEPS, + neverlink = True, + visibility = ["//visibility:private"], +) + +nv_boot_library( + name = "nv_boot_starter_audit", + srcs = glob(["src/main/java/**/*.java"]), + resource_strip_prefix = "src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/resources", + resources = glob(["src/main/resources/**"]), + visibility = ["//visibility:public"], + deps = AUDIT_REQUIRED_DEPS + [":optional_compile_deps"], +) + +nv_boot_runtime_classpath_test( + name = "runtime_scope_test", + target = ":nv_boot_starter_audit", + forbidden_artifacts = [ + "spring-boot-starter-security-oauth2-resource-server", + "spring-security-oauth2-jose", + "spring-security-oauth2-resource-server", + ], +) + +nv_boot_library_test( + name = "tests", + srcs = glob(["src/test/java/**/*.java"]), + coverage_library = ":nv_boot_starter_audit", + deps = [ + ":nv_boot_starter_audit", + "@nv_third_party_deps//:ch_qos_logback_logback_classic", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_security_oauth2_resource_server_test", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_test", + "@nv_third_party_deps//:org_springframework_security_spring_security_test", + ] + AUDIT_REQUIRED_DEPS + AUDIT_OPTIONAL_COMPILE_DEPS, +) diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/README.md b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/README.md new file mode 100644 index 0000000000..61e8726a4b --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/README.md @@ -0,0 +1,169 @@ +# NV Boot Starter Audit + +Audit logging for applications as per NVIDIA Audit Specification. This starter ships a single +concrete JSON-based audit path(`AuditService`, `AuditEventPayload`, `BootAuditEvent`) that works +the same in open-source and managed deployments. Optional HMAC signing is enabled when the +application registers an `AuditProperties` bean (see below). + +## Adding as a Dependency + +Add to your application's `pom.xml`: + +```xml + + + + com.nvidia.boot + nv-boot-bom + ${nv-boot.version} + pom + import + + + + + + + com.nvidia.boot + nv-boot-starter-audit + + +``` + +> **Note:** Replace `${nv-boot.version}` with the desired nv-boot version, or use a property from your parent/BOM. + +## Auto Configured Beans - Library Provided + +This starter module autoconfigures an `AuditService` bean using `AuditAutoConfiguration`. Apps +can inject `AuditService` and use the builder to dispatch audit events as shown below: + +```java +@Service +@RequiredArgsConstructor +public class MyService { + + private final AuditService auditService; + + public void createResource(Resource resource) { + auditService.audit( + auditService.auditEventPayloadBuilder() + .operation("CREATE") + .type("RESOURCE") + .objectId(resource.getId()) + .state("CREATED") + .summary("Created resource " + resource.getName()) + ); + } +} +``` + +## Optional: Register `AuditProperties` for HMAC signing + +`AuditService` is always registered by auto-configuration. By default it produces **unsigned** +payloads and publishes `BootAuditEvent` with a **null** top-level `hmac`. + +You can optionally add a Spring bean of type `com.nvidia.boot.audit.AuditProperties` (this type +is **not** `@ConfigurationProperties`; the application owns binding from YAML, Vault, or other +sources). When the bean is present and both `hmacKeys` and `hmacKid` are non-blank, `AuditService` +switches to **signed** mode: `auditEventPayloadBuilder()` uses `AuditEventPayload.signedBuilder`, +and each published event includes a top-level `hmac` over the serialized payload. + +### HMAC format and computation + +Signing uses **HMac-SHA3-512** via the **BouncyCastle** (`BC`) JCE provider. Every formatted +value (top-level `hmac`, payload `hmacBefore`, payload `hmacAfter`) uses the same string shape: + +`{algorithm}:{kid}:{base64(hmac_bytes)}` + +The MAC input is **not** the raw JSON text. It is the **four big-endian bytes** of +`JsonNode.hashCode()` for the relevant Jackson `JsonNode` (the serialized payload tree for the +event-level `hmac`, or the `jsonBefore` / `jsonAfter` node for the payload fields). Verifiers must +use the same tree (parse JSON with a compatible `JsonMapper` / node equality) so `hashCode()` +matches. Choose HMAC key material with adequate length for SHA3-512 (for example 32+ random bytes). + +Example (illustrative base64 suffix): + +```json +"hmac": "HMac-SHA3-512:audit-key-v1:dGVzdCBobWFjIHZhbHVl..." +``` + +When the audit builder includes **`jsonBefore`** and/or **`jsonAfter`** (`JsonNode` state +snapshots), the serialized payload may also include **`hmacBefore`** and **`hmacAfter`**, using +the same **HMac-SHA3-512** `{algorithm}:{kid}:base64(...)` format over those nodes’ +`hashCode()`-derived inputs, so consumers can verify state transitions. If only +`jsonBefore` is set, only `hmacBefore` is populated; if only `jsonAfter` is set, only `hmacAfter` +is populated. When both are set, the payload also carries **`stateSummary`** and +**`historySummary`** (RFC 6902 JSON Patch strings) derived from those nodes. + +The following snippet shows how apps can bind secrets from Vault to app-specific configuration +properties: + +```yaml +myapp: + audit: + hmac: + kid: ${kv.audit-hmac-kid} + keys: ${kv.audit-hmac-keys} +``` + +Here is a sample snippet showing how apps can add `AuditProperties` to the Spring application +context: + +```java +@Configuration +public class AuditConfiguration { + + @Bean + public AuditProperties auditProperties( + @Value("${myapp.audit.hmac.keys}") String hmacKeys, + @Value("${myapp.audit.hmac.kid}") String hmacKid) { + var props = new AuditProperties(); + props.setHmacKeys(hmacKeys); + props.setHmacKid(hmacKid); + return props; + } +} +``` + +### HMAC key store format + +The `hmacKeys` value must be **Base64-encoded** JSON: + +```json +{ + "keys": [ + { "kid": "audit-key-v1", "key": "base64-encoded-hmac-key" }, + { "kid": "audit-key-v2", "key": "base64-encoded-hmac-key-2" } + ] +} +``` + +The `hmacKid` field selects which key ID from that store is used for signing. + +## Security considerations + +HMAC values in this starter are computed for **NVIDIA compatibility** and **wire-level +verification** (same algorithm, string format, and `JsonNode.hashCode()` canonicalization as +internal NVIDIA audit utilities). They help consumers confirm that an event was produced with the +expected signing key and that the JSON tree they parsed matches what the signer hashed. + +They do **not** provide the same guarantees as an HMAC over the full serialized JSON bytes: + +- **32-bit digest space:** The MAC input is only four big-endian bytes derived from + `JsonNode.hashCode()`, not the entire document. Distinct JSON payloads can, in principle, + collide on `hashCode()` and therefore produce identical HMACs (collision resistance is far + weaker than SHA3-512 over the full payload). +- **Jackson / JVM coupling:** Verifiers must parse JSON into a `JsonNode` compatible with the + signer’s expectations. While Jackson’s `JsonNode` contract is stable for a given library + version, treating `hashCode()` as a long-lived cross-version fingerprint without validation + risks mismatch if serialization or node semantics ever diverge. +- **Threat model:** Treat these HMACs as a **tamper-evident checksum on the agreed + canonicalization**, not as a standalone proof of unique payload identity for high-assurance + compliance. Where you need strong integrity over raw bytes, add an additional layer (for + example a conventional HMAC or signature over the exact octets you store or transmit). + +## Behavior + +- **AuditService** publishes `BootAuditEvent` for custom listeners; with `AuditProperties` as above, events carry a top-level `hmac` when signing is active, and the payload may include `hmacBefore` / `hmacAfter` (plus optional `stateSummary` / `historySummary`) when `jsonBefore` / `jsonAfter` are supplied on the builder +- **BootAuditEventListener** asynchronously logs `BootAuditEvent` payloads to stdout with an `[AUDIT]` prefix (registered by auto-configuration) +- Extracts actor/subject from JWT when `spring-boot-starter-oauth2-resource-server` is on the classpath diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/pom.xml b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/pom.xml new file mode 100644 index 0000000000..1d6983ea74 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/pom.xml @@ -0,0 +1,95 @@ + + + + + 4.0.0 + + + com.nvidia.boot + nv-boot-parent + 0.0.1-SNAPSHOT + + + nv-boot-starter-audit + jar + NV Boot Starter Audit + Audit logging and event tracking + + + 1.13 + + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.security + spring-security-core + + + org.springframework.boot + spring-boot-starter-security-oauth2-resource-server + true + + + org.springframework.boot + spring-boot-starter-jackson + + + org.apache.commons + commons-lang3 + + + org.bouncycastle + bcprov-jdk18on + + + org.projectlombok + lombok + true + + + + + com.github.java-json-tools + json-patch + ${json-patch.version} + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-security-oauth2-resource-server-test + test + + + org.springframework.security + spring-security-test + test + + + diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/AuditAutoConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/AuditAutoConfiguration.java new file mode 100644 index 0000000000..a55f1b93a2 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/AuditAutoConfiguration.java @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.audit; + +import com.nvidia.boot.audit.configuration.AsyncAuditEventConfiguration; +import com.nvidia.boot.audit.configuration.AuditConfiguration; +import com.nvidia.boot.audit.listener.BootAuditEventListener; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Import; + +/** + * Auto-configuration for NV Boot audit: AuditService, async event multicaster, + * and audit event listener. + */ +@AutoConfiguration +@Import({ + AuditConfiguration.class, + AsyncAuditEventConfiguration.class, + BootAuditEventListener.class +}) +public class AuditAutoConfiguration { +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/AuditProperties.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/AuditProperties.java new file mode 100644 index 0000000000..0128500e65 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/AuditProperties.java @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.audit; + +import lombok.Getter; +import lombok.Setter; + +/** + * HMAC signing configuration for audit. Applications supply a bean of this type (for example + * from {@code @Value} / Vault-backed properties). + */ +@Getter +@Setter +public class AuditProperties { + + /** Base64-encoded JSON of HMAC key store. */ + private String hmacKeys; + + /** Key ID to use for signing (must exist in {@link #hmacKeys}). */ + private String hmacKid; +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/AuditService.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/AuditService.java new file mode 100644 index 0000000000..f91e821189 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/AuditService.java @@ -0,0 +1,204 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.audit; + +import static org.springframework.security.oauth2.jwt.JwtClaimNames.ISS; +import static org.springframework.security.oauth2.jwt.JwtClaimNames.SUB; + +import org.springframework.util.CollectionUtils; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.audit.event.AuditEventPayload; +import com.nvidia.boot.audit.event.BootAuditEvent; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import lombok.Data; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +/** + * Publishes {@link BootAuditEvent} instances. When an {@link AuditProperties} bean is present with + * non-blank {@code hmacKeys} and {@code hmacKid}, payload builders use signed mode and each + * event carries a top-level {@code hmac} over the serialized payload. + */ +@Slf4j +public final class AuditService { + + private static final Authentication AUDIT_AUTHENTICATION_TOKEN = auditAuthenticationToken(); + private static final String DEFAULT_ACTOR_ID = "unknown"; + private static final String REMOTE_ADDRESS = "remoteAddress"; + + private static final String MESG_MISSING_KEY = "Missing key for kid '%s'"; + private static final String MESG_MISSING_KEYS_OR_KID = "Missing HMAC keys or kid"; + + private final ApplicationEventPublisher eventPublisher; + private final ObjectProvider auditPropertiesProvider; + private final JsonMapper jsonMapper; + + public AuditService( + ApplicationEventPublisher eventPublisher, + ObjectProvider auditPropertiesProvider, + JsonMapper jsonMapper) { + this.eventPublisher = eventPublisher; + this.auditPropertiesProvider = auditPropertiesProvider; + this.jsonMapper = jsonMapper; + } + + public AuditEventPayload.Builder auditEventPayloadBuilder() { + return auditEventPayloadBuilder(AUDIT_AUTHENTICATION_TOKEN, Collections.emptyMap()); + } + + public AuditEventPayload.Builder auditEventPayloadBuilder( + Authentication authentication, + Map customProperties) { + var props = customProperties != null ? + customProperties : Collections.emptyMap(); + var builder = resolveSigningContext() + .map(ctx -> AuditEventPayload.signedBuilder(ctx.kid(), ctx.key())) + .orElseGet(AuditEventPayload::builder); + builder.actorId(getActorId(authentication)) + .actorLocation(props.getOrDefault(REMOTE_ADDRESS, "127.0.0.1")) + .subjectId(getSubjectId(authentication)) + .subjectLocation(props.getOrDefault(REMOTE_ADDRESS, "127.0.0.1")); + props.forEach(builder::custom); + return builder; + } + + public void audit(AuditEventPayload.Builder payloadBuilder) { + var payload = payloadBuilder.build(); + var eventHmac = resolveSigningContext() + .map(ctx -> AuditUtils.computeHmacFormatted( + ctx.kid(), + ctx.key(), + readJsonTree(payload.toJson()))) + .orElse(null); + eventPublisher.publishEvent(new BootAuditEvent(payload, eventHmac, AuditService.class)); + } + + private JsonNode readJsonTree(String json) { + try { + return jsonMapper.readTree(json); + } catch (JacksonException e) { + throw new IllegalArgumentException("Failed to parse JSON in readJsonTree", e); + } + } + + private Optional resolveSigningContext() { + var auditProperties = auditPropertiesProvider.getIfAvailable(); + if (auditProperties == null) { + return Optional.empty(); + } + + var hmacKeys = auditProperties.getHmacKeys(); + var kid = auditProperties.getHmacKid(); + if (StringUtils.isBlank(hmacKeys) || StringUtils.isBlank(kid)) { + log.error(MESG_MISSING_KEYS_OR_KID); + throw new IllegalStateException(MESG_MISSING_KEYS_OR_KID); + } + + try { + var rawKeys = Base64.getDecoder().decode(hmacKeys); + var keyStore = jsonMapper.readValue(rawKeys, HmacKeyStore.class); + var keyMaterial = getKeyByKid(keyStore, kid); + byte[] key = Base64.getDecoder().decode(keyMaterial); + return Optional.of(new SigningContext(kid, key)); + } catch (JacksonException | IllegalArgumentException e) { + var mesg = "Failed to parse HMAC keys. Expected base64-encoded JSON with structure: " + + "{\"keys\": [{\"kid\": \"...\", \"key\": \"...\"}]}"; + log.error(mesg, e); + throw new IllegalStateException(mesg, e); + } + } + + private String getKeyByKid(HmacKeyStore keyStore, String kid) { + if (keyStore == null || keyStore.getKeys() == null || keyStore.getKeys().isEmpty()) { + throw new IllegalStateException("Failed to parse HMAC keys. Missing 'keys' array"); + } + + return keyStore.getKeys().stream() + .filter(hmacKeyMaterial -> kid.equals(hmacKeyMaterial.getKid())) + .findFirst() + .map(HmacKeyMaterial::getKey) + .orElseThrow(() -> new IllegalStateException(MESG_MISSING_KEY.formatted(kid))); + } + + private String getActorId(Authentication authentication) { + if (authentication instanceof JwtAuthenticationToken token) { + var issuer = (String) token.getTokenAttributes().get(ISS); + if (issuer != null && !issuer.isBlank()) { + var subject = (String) token.getTokenAttributes().get(SUB); + return (subject != null && !subject.isBlank()) + ? issuer + "_" + subject + : issuer; + } + } + return (authentication != null && authentication.getName() != null) + ? authentication.getName() : DEFAULT_ACTOR_ID; + } + + private String getSubjectId(Authentication authentication) { + if (authentication instanceof JwtAuthenticationToken token) { + var subject = (String) token.getTokenAttributes().get(SUB); + if (subject != null && !subject.isBlank()) { + return subject; + } + } + return (authentication != null && authentication.getName() != null) + ? authentication.getName() : DEFAULT_ACTOR_ID; + } + + private static Authentication auditAuthenticationToken() { + return new AbstractAuthenticationToken(AuthorityUtils.NO_AUTHORITIES) { + @Override + public Object getCredentials() { + return null; + } + + @Override + public Object getPrincipal() { + return "system:internal-thread"; + } + }; + } + + private record SigningContext(String kid, byte[] key) {} + + @Data + private static class HmacKeyMaterial { + private String kid; + + @ToString.Exclude + private String key; + } + + @Data + private static class HmacKeyStore { + private List keys; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/AuditUtils.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/AuditUtils.java new file mode 100644 index 0000000000..aa3984c2cf --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/AuditUtils.java @@ -0,0 +1,142 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.audit; + +import static java.lang.String.format; + +import tools.jackson.databind.JsonNode; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.nio.ByteBuffer; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.Security; +import java.util.Base64; +import java.util.Enumeration; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.ArrayUtils; +import org.apache.commons.lang3.StringUtils; +import org.bouncycastle.jce.provider.BouncyCastleProvider; + +/** + * Audit helpers: machine identifier and HMAC formatting uses (algorithm, canonicalization via + * {@link JsonNode#hashCode()}, and the {@code alg:kid:base64(hmac)} string format) as per + * NVIDIA Audit Specification. + */ +@Slf4j +public final class AuditUtils { + + private static final String HMAC_FORMAT = "%s:%s:%s"; + private static final String ALGORITHM_HMAC_SHA3_512 = "HMac-SHA3-512"; + + private static final String MESG_MAC_ADDRESS = + "Failed to obtain MAC address"; + private static final String MESG_INVALID_HMAC_KID = + "Invalid HMAC kid. HMAC kid cannot be empty"; + private static final String MESG_INVALID_HMAC_KEY = + "Invalid HMAC key. HMAC key cannot be empty"; + + static { + if (Security.getProvider("BC") == null) { + Security.addProvider(new BouncyCastleProvider()); + } + } + + private AuditUtils() {} + + /** + * Returns the first hardware MAC address found on the host, formatted as hex pairs separated + * by {@code '-'}. + */ + public static String getMacAddress() { + try { + byte[] macAddress = null; + Enumeration nwInterface = NetworkInterface.getNetworkInterfaces(); + while (nwInterface.hasMoreElements()) { + NetworkInterface nis = nwInterface.nextElement(); + if (nis != null) { + macAddress = nis.getHardwareAddress(); + if (macAddress != null) { + break; + } + } + } + if (macAddress == null) { + log.error(MESG_MAC_ADDRESS); + throw new IllegalStateException(MESG_MAC_ADDRESS); + } + var stringBuilder = new StringBuilder(); + for (int i = 0; i < macAddress.length; i++) { + if (i != 0) { + stringBuilder.append("-"); + } + String s = Integer.toHexString(macAddress[i] & 0xFF); + stringBuilder.append((s.length() == 1) ? "0" + s : s); + } + return stringBuilder.toString(); + } catch (SocketException e) { + log.error(MESG_MAC_ADDRESS); + throw new RuntimeException(MESG_MAC_ADDRESS, e); + } + } + + /** + * Returns {@code alg:kid:base64(hmac)} using HMAC-SHA3-512 over the four big-endian bytes of + * {@code jsonNode.hashCode()}. + */ + public static String computeHmacFormatted( + @NonNull String kid, + @NonNull byte[] key, + @NonNull JsonNode jsonNode) { + if (StringUtils.isBlank(kid)) { + log.error(MESG_INVALID_HMAC_KID); + throw new IllegalArgumentException(MESG_INVALID_HMAC_KID); + } + if (ArrayUtils.isEmpty(key)) { + log.error(MESG_INVALID_HMAC_KEY); + throw new IllegalArgumentException(MESG_INVALID_HMAC_KEY); + } + try { + Mac mac = Mac.getInstance(ALGORITHM_HMAC_SHA3_512, "BC"); + SecretKeySpec secretKeySpec = new SecretKeySpec(key, ALGORITHM_HMAC_SHA3_512); + mac.init(secretKeySpec); + + // Using two JSON strings, with identical property names and values but in different + // order, directly to compute HMAC will result in two different hash values. From + // JSON standpoint, the string representation may seem identical. However, when the + // strings are used to compute HMAC, they result in different hash values. To address + // this issue, we use Jackson's JsonNode.hashCode() to compute HMAC as it will always + // return the same value regardless of the property order. Jackson implements equals() + // and hashCode() correctly(that is, sticking to the contract established by + // java.lang.Object), for all JSON values (numbers, booleans, strings, objects + // recursively, arrays recursively, nulls). + byte[] jsonHashcode = ByteBuffer.allocate(4).putInt(jsonNode.hashCode()).array(); + byte[] hmac = mac.doFinal(jsonHashcode); + String encodedHmac = Base64.getEncoder().encodeToString(hmac); + return format(HMAC_FORMAT, ALGORITHM_HMAC_SHA3_512, kid, encodedHmac); + } catch (NoSuchAlgorithmException | InvalidKeyException | NoSuchProviderException e) { + var mesg = "Failed to compute HMAC"; + log.error(mesg, e); + throw new IllegalArgumentException(mesg, e); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/configuration/AsyncAuditEventConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/configuration/AsyncAuditEventConfiguration.java new file mode 100644 index 0000000000..f262cb0c3f --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/configuration/AsyncAuditEventConfiguration.java @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.audit.configuration; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.ApplicationEventMulticaster; +import org.springframework.context.event.SimpleApplicationEventMulticaster; + +/** + * Registers ApplicationEventMulticaster with a single-thread executor so audit events + * (and other Spring events) are dispatched asynchronously, avoiding blocking of the + * calling thread. Uses @ConditionalOnMissingBean if the app or other internal libraries + * register its own. + */ +@Configuration +@ConditionalOnMissingBean(ApplicationEventMulticaster.class) +public class AsyncAuditEventConfiguration { + + @Bean(destroyMethod = "shutdown") + public ExecutorService auditEventExecutor() { + return Executors.newSingleThreadExecutor(); + } + + @Bean(name = "applicationEventMulticaster") + public ApplicationEventMulticaster applicationEventMulticaster( + ExecutorService auditEventExecutor) { + var multicaster = new SimpleApplicationEventMulticaster(); + multicaster.setTaskExecutor(auditEventExecutor); + return multicaster; + }} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/configuration/AuditConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/configuration/AuditConfiguration.java new file mode 100644 index 0000000000..ce58d9e1cc --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/configuration/AuditConfiguration.java @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.audit.configuration; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.audit.AuditProperties; +import com.nvidia.boot.audit.AuditService; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class AuditConfiguration { + + @Bean + @ConditionalOnMissingBean(AuditService.class) + public AuditService auditService( + ApplicationEventPublisher eventPublisher, + ObjectProvider auditProperties, + JsonMapper jsonMapper) { + return new AuditService(eventPublisher, auditProperties, jsonMapper); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/event/AuditEventPayload.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/event/AuditEventPayload.java new file mode 100644 index 0000000000..9d785d740b --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/event/AuditEventPayload.java @@ -0,0 +1,492 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.audit.event; + +import static java.lang.String.format; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.audit.AuditUtils; +import java.time.Instant; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.Getter; +import org.apache.commons.lang3.StringUtils; + +/** + * Immutable audit payload aligned with internal NVIDIA serialization of events + * (including {@code machineId}, optional {@code hmacBefore}/{@code hmacAfter}, and JSON diff + * summaries when both {@code jsonBefore} and {@code jsonAfter} are set). + */ +@Getter +@JsonInclude(JsonInclude.Include.NON_NULL) +public final class AuditEventPayload { + + private static final String UNKNOWN_MACHINE_ID = "unknown"; + + private static volatile String macAddressCache; + + // Resolve machineId on first use, not at class init. AuditUtils.getMacAddress() walks network + // interfaces and throws if none are available. Running that in a static field initializer would + // fail class loading for the whole payload type (e.g. minimal containers). Lazy init defers + // work until an audit payload is built and falls back to "unknown" if lookup still fails. + private static String getMacAddressLazy() { + if (macAddressCache == null) { + synchronized (AuditEventPayload.class) { + if (macAddressCache == null) { + try { + macAddressCache = AuditUtils.getMacAddress(); + } catch (Exception e) { + macAddressCache = UNKNOWN_MACHINE_ID; + } + } + } + } + return macAddressCache; + } + + private static final String MESG_EMPTY_PAYLOAD_FIELD = + "Payload field '%s' cannot be blank or null"; + + private static final JsonMapper MAPPER = JsonMapper.builder() + .findAndAddModules() + .changeDefaultPropertyInclusion(v -> v.withValueInclusion(JsonInclude.Include.NON_NULL)) + .build(); + + /** + * Pattern for formatted audit HMAC strings: exactly three non-empty colon-separated segments + * ({@code algorithm}:{@code kid}:{@code base64Digest}). Used by the signed payload builder when + * validating {@code hmacBefore} / {@code hmacAfter}; callers may reuse it for verification. + */ + public static final Pattern FORMATTED_HMAC_PATTERN = + Pattern.compile("^[^:]+:[^:]+:[^:]+$"); + + private final UUID id; + private final Instant timestamp; + private final String machineId; + + private final String operation; + private final String type; + private final String actorId; + private final String actorLocation; + private final String subjectId; + private final String subjectLocation; + private final String objectId; + private final String objectLocation; + private final String groupType; + private final String hmacBefore; + private final String hmacAfter; + private final String state; + private final String stateSummary; + private final String historySummary; + private final String summary; + private final Map data; + + /** + * Jackson constructor (also used by {@link BuilderImpl} with {@code null} id/timestamp/machineId + * so those fields are assigned internally). + */ + @JsonCreator + private AuditEventPayload( + @JsonProperty("id") UUID id, + @JsonProperty("timestamp") Instant timestamp, + @JsonProperty("machineId") String machineId, + @JsonProperty("operation") String operation, + @JsonProperty("type") String type, + @JsonProperty("actorId") String actorId, + @JsonProperty("actorLocation") String actorLocation, + @JsonProperty("subjectId") String subjectId, + @JsonProperty("subjectLocation") String subjectLocation, + @JsonProperty("objectId") String objectId, + @JsonProperty("objectLocation") String objectLocation, + @JsonProperty("groupType") String groupType, + @JsonProperty("hmacBefore") String hmacBefore, + @JsonProperty("hmacAfter") String hmacAfter, + @JsonProperty("state") String state, + @JsonProperty("stateSummary") String stateSummary, + @JsonProperty("historySummary") String historySummary, + @JsonProperty("summary") String summary, + @JsonProperty("data") Map data) { + this.id = id != null ? id : UUID.randomUUID(); + this.timestamp = timestamp != null ? timestamp : Instant.now(); + this.machineId = machineId != null ? machineId : getMacAddressLazy(); + this.operation = operation; + this.type = type; + this.actorId = actorId; + this.actorLocation = actorLocation; + this.subjectId = subjectId; + this.subjectLocation = subjectLocation; + this.objectId = objectId; + this.objectLocation = objectLocation; + this.groupType = groupType; + this.hmacBefore = hmacBefore; + this.hmacAfter = hmacAfter; + this.state = state; + this.stateSummary = stateSummary; + this.historySummary = historySummary; + this.summary = summary; + this.data = data != null ? new LinkedHashMap<>(data) : new LinkedHashMap<>(); + } + + public static Builder builder() { + return new BuilderImpl(null, null); + } + + // Builder that computes {@code hmacBefore}/{@code hmacAfter} from + // {@code jsonBefore}/{@code jsonAfter} when those nodes are set. + public static Builder signedBuilder(String hmacKid, byte[] hmacKey) { + return new BuilderImpl(hmacKid, hmacKey); + } + + public Map getData() { + return Collections.unmodifiableMap(data); + } + + public String toJson() { + try { + return MAPPER.writeValueAsString(this); + } catch (JacksonException e) { + throw new RuntimeException("Failed to serialize audit payload", e); + } + } + + public static AuditEventPayload fromJson(String json) { + try { + return MAPPER.readValue(json, AuditEventPayload.class); + } catch (JacksonException e) { + throw new RuntimeException("Failed to deserialize audit payload", e); + } + } + + @Override + public String toString() { + return toJson(); + } + + public interface Builder { + Builder operation(String operation); + + Builder type(String type); + + Builder actorId(String actorId); + + Builder actorLocation(String actorLocation); + + /** + * Sets the logical subject id for the audit entry. At {@link #build()} time, if this + * value is blank per {@link org.apache.commons.lang3.StringUtils#isBlank(String)} (null, + * empty, or whitespace only), the implementation replaces it with {@code actorId} via a + * local {@code resolvedSubjectId} assignment. The built {@link AuditEventPayload} therefore + * never retains a blank subject id; callers must not assume an omitted or blank subject + * stays blank on the payload. + */ + Builder subjectId(String subjectId); + + Builder subjectLocation(String subjectLocation); + + Builder objectId(String objectId); + + Builder objectLocation(String objectLocation); + + Builder groupType(String groupType); + + Builder state(String state); + + Builder summary(String summary); + + Builder jsonBefore(JsonNode jsonBefore); + + Builder jsonAfter(JsonNode jsonAfter); + + /** + * Adds or replaces a single entry in the builder's internal data map. Later + * {@code custom} calls with the same {@code key} overwrite earlier values. The map is + * shared with {@link #data(Map)}: keys merged from {@code data} overwrite prior + * {@code custom} entries for the same key when {@code data} runs later, and a subsequent + * {@code custom} with the same key overwrites values supplied by {@code data}. + */ + Builder custom(String key, Object value); + + /** + * Merges the given map into the builder's internal data map. Existing keys are left in + * place except where the incoming map defines the same key, in which case the incoming + * value wins. Prior {@link #custom(String, Object)} entries are preserved for keys absent + * from {@code data}. A null argument does not clear existing entries; it is treated as + * "merge nothing" while ensuring the internal map exists. + */ + Builder data(Map data); + + /** + * Validates required fields and returns a new {@link AuditEventPayload}. Blank + * {@code subjectId} values (per {@link org.apache.commons.lang3.StringUtils#isBlank(String)}) + * are normalized to {@code actorId} when populating the payload: the builder's + * {@code build()} method assigns a {@code resolvedSubjectId} local variable + * ({@code actorId} when {@code subjectId} is blank, otherwise {@code subjectId}). + */ + AuditEventPayload build(); + } + + private static final class BuilderImpl implements Builder { + + private static final String MESG_INVALID_PARAM = + "Param '%s' cannot be empty or null"; + + private final String hmacKid; + private final byte[] hmacKey; + + private String operation; + private String type; + private String actorId; + private String actorLocation; + private String subjectId; + private String subjectLocation; + private String objectId; + private String objectLocation; + private String groupType; + private String state; + private String summary; + private JsonNode jsonBefore; + private JsonNode jsonAfter; + private Map data = new LinkedHashMap<>(); + + private BuilderImpl(String hmacKid, byte[] hmacKey) { + if (hmacKid != null || hmacKey != null) { + if (StringUtils.isBlank(hmacKid)) { + throw new IllegalArgumentException(format(MESG_INVALID_PARAM, "hmacKid")); + } + if (hmacKey == null) { + throw new IllegalArgumentException(format(MESG_INVALID_PARAM, "hmacKey")); + } + } + this.hmacKid = hmacKid; + this.hmacKey = hmacKey; + } + + @Override + public Builder operation(String operation) { + this.operation = operation; + return this; + } + + @Override + public Builder type(String type) { + this.type = type; + return this; + } + + @Override + public Builder actorId(String actorId) { + this.actorId = actorId; + return this; + } + + @Override + public Builder actorLocation(String actorLocation) { + this.actorLocation = actorLocation; + return this; + } + + /** + * {@inheritDoc} + */ + @Override + public Builder subjectId(String subjectId) { + this.subjectId = subjectId; + return this; + } + + @Override + public Builder subjectLocation(String subjectLocation) { + this.subjectLocation = subjectLocation; + return this; + } + + @Override + public Builder objectId(String objectId) { + this.objectId = objectId; + return this; + } + + @Override + public Builder objectLocation(String objectLocation) { + this.objectLocation = objectLocation; + return this; + } + + @Override + public Builder groupType(String groupType) { + this.groupType = groupType; + return this; + } + + @Override + public Builder state(String state) { + this.state = state; + return this; + } + + @Override + public Builder summary(String summary) { + this.summary = summary; + return this; + } + + @Override + public Builder jsonBefore(JsonNode jsonBefore) { + this.jsonBefore = jsonBefore; + return this; + } + + @Override + public Builder jsonAfter(JsonNode jsonAfter) { + this.jsonAfter = jsonAfter; + return this; + } + + /** + * {@inheritDoc} + */ + @Override + public Builder custom(String key, Object value) { + this.data.put(key, value); + return this; + } + + /** + * {@inheritDoc} + */ + @Override + public Builder data(Map data) { + if (this.data == null) { + this.data = new LinkedHashMap<>(); + } + if (data != null) { + this.data.putAll(data); + } + return this; + } + + /** + * {@inheritDoc} + */ + @Override + public AuditEventPayload build() { + validateRequired(); + var resolvedSubjectId = StringUtils.isBlank(subjectId) ? actorId : subjectId; + + String hmacBeforeVal = null; + String hmacAfterVal = null; + if (hmacKid != null) { + if (jsonBefore != null) { + hmacBeforeVal = AuditUtils.computeHmacFormatted(hmacKid, hmacKey, jsonBefore); + } + if (jsonAfter != null) { + hmacAfterVal = AuditUtils.computeHmacFormatted(hmacKid, hmacKey, jsonAfter); + } + } + + String stateSummaryValue = null; + String historySummaryValue = null; + if (jsonBefore != null && jsonAfter != null) { + stateSummaryValue = AuditJsonDiff.stateSummary(jsonBefore, jsonAfter); + historySummaryValue = AuditJsonDiff.historySummary(jsonBefore, jsonAfter); + } + + if (data == null) { + data = new LinkedHashMap<>(); + } + + validateHmacAndSummaries(hmacBeforeVal, hmacAfterVal, + stateSummaryValue, historySummaryValue); + + return new AuditEventPayload( + null, + null, + null, + operation, + type, + actorId, + actorLocation, + resolvedSubjectId, + subjectLocation, + objectId, + objectLocation, + groupType, + hmacBeforeVal, + hmacAfterVal, + state, + stateSummaryValue, + historySummaryValue, + summary, + new LinkedHashMap<>(data)); + } + + private void validateRequired() { + if (StringUtils.isBlank(operation)) { + throw new IllegalArgumentException(format(MESG_EMPTY_PAYLOAD_FIELD, "operation")); + } + if (StringUtils.isBlank(type)) { + throw new IllegalArgumentException(format(MESG_EMPTY_PAYLOAD_FIELD, "type")); + } + if (StringUtils.isBlank(actorId)) { + throw new IllegalArgumentException(format(MESG_EMPTY_PAYLOAD_FIELD, "actorId")); + } + } + + private void validateHmacAndSummaries( + String hmacBeforeVal, + String hmacAfterVal, + String stateSummaryValue, + String historySummaryValue) { + if (StringUtils.isNotBlank(hmacBeforeVal)) { + Matcher matcher = FORMATTED_HMAC_PATTERN.matcher(hmacBeforeVal); + if (!matcher.matches()) { + throw new IllegalStateException("hmacBefore does not match the pattern"); + } + } + if (StringUtils.isNotBlank(hmacAfterVal)) { + Matcher matcher = FORMATTED_HMAC_PATTERN.matcher(hmacAfterVal); + if (!matcher.matches()) { + throw new IllegalStateException("hmacAfter does not match the pattern"); + } + } + if (StringUtils.isNotBlank(stateSummaryValue)) { + try { + MAPPER.readTree(stateSummaryValue); + } catch (JacksonException e) { + throw new IllegalStateException("stateSummary is not valid JSON", e); + } + } + if (StringUtils.isNotBlank(historySummaryValue)) { + try { + MAPPER.readTree(historySummaryValue); + } catch (JacksonException e) { + throw new IllegalStateException("historySummary is not valid JSON", e); + } + } + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/event/AuditJsonDiff.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/event/AuditJsonDiff.java new file mode 100644 index 0000000000..6c84888a8a --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/event/AuditJsonDiff.java @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.audit.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.fge.jsonpatch.JsonPatch; +import com.github.fge.jsonpatch.diff.JsonDiff; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +/** + * Bridges Jackson 3 {@link JsonNode} trees to {@code json-patch} (Jackson 2 API) for summaries. + */ +final class AuditJsonDiff { + + private static final ObjectMapper JACKSON2 = new ObjectMapper(); + private static final JsonMapper JACKSON3 = JsonMapper.builder().build(); + + private AuditJsonDiff() {} + + static String stateSummary(JsonNode before, JsonNode after) { + return JsonDiff.asJson(toJackson2(before), toJackson2(after)).toString(); + } + + static String historySummary(JsonNode before, JsonNode after) { + return JsonDiff.asJson(toJackson2(after), toJackson2(before)).toString(); + } + + static JsonNode applyPatch(JsonNode state, JsonNode patchDoc) { + try { + var patch = JsonPatch.fromJson(toJackson2(patchDoc)); + return fromJackson2(patch.apply(toJackson2(state))); + } catch (Exception e) { + throw new IllegalArgumentException("Failed to apply JSON patch", e); + } + } + + private static com.fasterxml.jackson.databind.JsonNode toJackson2(JsonNode node) { + try { + return JACKSON2.readTree(node.toString()); + } catch (Exception e) { + throw new IllegalArgumentException("Failed to convert JSON for patch diff", e); + } + } + + private static JsonNode fromJackson2(com.fasterxml.jackson.databind.JsonNode node) { + try { + return JACKSON3.readTree(node.toString()); + } catch (Exception e) { + throw new IllegalArgumentException("Failed to convert JSON patch result", e); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/event/BootAuditEvent.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/event/BootAuditEvent.java new file mode 100644 index 0000000000..0464a8b64d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/event/BootAuditEvent.java @@ -0,0 +1,115 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.audit.event; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.SerializationFeature; +import tools.jackson.databind.cfg.MapperConfig; +import tools.jackson.databind.introspect.AnnotatedMember; +import tools.jackson.databind.introspect.JacksonAnnotationIntrospector; +import lombok.EqualsAndHashCode; +import org.springframework.context.ApplicationEvent; + +/** + * Spring application event carrying an audit payload and optional payload-level HMAC. + */ +@EqualsAndHashCode(callSuper = false) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BootAuditEvent extends ApplicationEvent { + + // Ignores fields from super classes when deserializing. + public static final JsonMapper AUDIT_EVENT_JSON_MAPPER = JsonMapper.builder() + .findAndAddModules() + .annotationIntrospector(new IgnoreInheritedMemberIntrospector()) + .changeDefaultPropertyInclusion(v -> v.withValueInclusion(JsonInclude.Include.NON_NULL)) + .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) + .build(); + + private final AuditEventPayload payload; + + // HMAC of the entire serialized payload; null when signing is not configured. + private final String hmac; + + @JsonIgnore + @EqualsAndHashCode.Exclude + private final Class auditedClass; + + public BootAuditEvent( + AuditEventPayload payload, + String hmac, + Class auditedClass) { + super(payload); + this.payload = payload; + this.hmac = hmac; + this.auditedClass = auditedClass; + } + + @JsonCreator + public static BootAuditEvent fromJsonProperties( + @JsonProperty("payload") AuditEventPayload payload, + @JsonProperty("hmac") String hmac) { + return new BootAuditEvent(payload, hmac, BootAuditEvent.class); + } + + public static BootAuditEvent fromJson(String json) throws JacksonException { + return AUDIT_EVENT_JSON_MAPPER.readValue(json, BootAuditEvent.class); + } + + @JsonProperty("payload") + public AuditEventPayload getPayload() { + return payload; + } + + @JsonProperty("hmac") + public String getHmac() { + return hmac; + } + + @JsonIgnore + public String getSourceClassName() { + return auditedClass.getCanonicalName(); + } + + public String toJson() { + try { + return AUDIT_EVENT_JSON_MAPPER.writeValueAsString(this); + } catch (JacksonException e) { + throw new IllegalStateException("Failed to serialize audit event", e); + } + } + + @Override + public String toString() { + return toJson(); + } + + private static class IgnoreInheritedMemberIntrospector extends JacksonAnnotationIntrospector { + + @Override + public boolean hasIgnoreMarker(MapperConfig config, AnnotatedMember member) { + return member.getDeclaringClass().getName().contains("org.spring") + || member.getDeclaringClass().getName().contains("java.util") + || super.hasIgnoreMarker(config, member); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/listener/BootAuditEventListener.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/listener/BootAuditEventListener.java new file mode 100644 index 0000000000..7a0d9161fc --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/java/com/nvidia/boot/audit/listener/BootAuditEventListener.java @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.audit.listener; + +import static org.slf4j.Logger.ROOT_LOGGER_NAME; + +import com.nvidia.boot.audit.event.BootAuditEvent; +import org.slf4j.LoggerFactory; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.stereotype.Component; + +/** + * Async listener for {@link BootAuditEvent}. Logs the full event JSON to the root audit logger + * namespace. + */ +@EnableAsync +@Component +public class BootAuditEventListener { + + @EventListener + @Async + public void onBootAuditEvent(BootAuditEvent event) { + var className = event.getSourceClassName(); + var contextLogger = LoggerFactory.getLogger(ROOT_LOGGER_NAME + "." + className); + contextLogger.info("[AUDIT] " + event.toJson()); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000000..2bd4105237 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +com.nvidia.boot.audit.AuditAutoConfiguration diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/test/java/com/nvidia/boot/audit/AuditServiceTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/test/java/com/nvidia/boot/audit/AuditServiceTest.java new file mode 100644 index 0000000000..fd4394463f --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/test/java/com/nvidia/boot/audit/AuditServiceTest.java @@ -0,0 +1,267 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.audit; + +import static com.nvidia.boot.audit.AuditUtils.computeHmacFormatted; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; +import com.nvidia.boot.audit.event.BootAuditEvent; +import java.util.Base64; +import java.util.Collections; +import java.util.Map; +import java.util.regex.Pattern; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +@ExtendWith(MockitoExtension.class) +class AuditServiceTest { + + @Mock + private ApplicationEventPublisher eventPublisher; + + @Mock + private ObjectProvider auditPropertiesProvider; + + private JsonMapper jsonMapper; + + private AuditService auditService; + + /** Raw HMAC key bytes (must match {@link #signingAuditProperties()} JSON entry). */ + private static final byte[] SIGNING_KEY_RAW = new byte[32]; + + private static final String SIGNING_KID = "test-signing-kid"; + + static { + for (int i = 0; i < SIGNING_KEY_RAW.length; i++) { + SIGNING_KEY_RAW[i] = (byte) (i + 1); + } + } + + @BeforeEach + void setUp() { + when(auditPropertiesProvider.getIfAvailable()).thenReturn(null); + jsonMapper = JsonMapper.builder().build(); + auditService = new AuditService(eventPublisher, auditPropertiesProvider, jsonMapper); + } + + private AuditProperties signingAuditProperties() throws JacksonException { + var props = new AuditProperties(); + props.setHmacKid(SIGNING_KID); + props.setHmacKeys(encodedSigningHmacKeys()); + return props; + } + + /** Base64-encoded key-store JSON matching {@link #SIGNING_KID} and raw key {@link #SIGNING_KEY_RAW}. */ + private String encodedSigningHmacKeys() throws JacksonException { + ArrayNode keys = jsonMapper.createArrayNode(); + ObjectNode entry = jsonMapper.createObjectNode(); + entry.put("kid", SIGNING_KID); + entry.put("key", Base64.getEncoder().encodeToString(SIGNING_KEY_RAW)); + keys.add(entry); + ObjectNode root = jsonMapper.createObjectNode(); + root.set("keys", keys); + return Base64.getEncoder().encodeToString(jsonMapper.writeValueAsBytes(root)); + } + + private void useSigningAuditService() throws JacksonException { + when(auditPropertiesProvider.getIfAvailable()).thenReturn(signingAuditProperties()); + auditService = new AuditService(eventPublisher, auditPropertiesProvider, jsonMapper); + } + + @Test + void auditEventPayloadBuilderReturnsBuilder() { + var builder = auditService.auditEventPayloadBuilder(); + + assertThat(builder).isNotNull(); + var payload = builder.operation("CREATE").type("TASK").build(); + assertThat(payload).isNotNull(); + assertThat(payload.getOperation()).isEqualTo("CREATE"); + } + + @Test + void auditEventPayloadBuilderWithAuthAndPropsPopulatesActorAndSubject() { + var auth = mock(Authentication.class); + when(auth.getName()).thenReturn("test-user"); + + var builder = auditService.auditEventPayloadBuilder(auth, + Map.of("remoteAddress", "192.168.1.1")); + var payload = builder.operation("CREATE").type("TASK").build(); + + assertThat(payload.getActorId()).isEqualTo("test-user"); + assertThat(payload.getSubjectId()).isEqualTo("test-user"); + assertThat(payload.getActorLocation()).isEqualTo("192.168.1.1"); + assertThat(payload.getSubjectLocation()).isEqualTo("192.168.1.1"); + } + + @Test + void auditEventPayloadBuilderWithNullAuthUsesDefaultActorId() { + var builder = auditService.auditEventPayloadBuilder(null, Collections.emptyMap()); + var payload = builder.operation("CREATE").type("TASK").build(); + + assertThat(payload.getActorId()).isEqualTo("unknown"); + assertThat(payload.getSubjectId()).isEqualTo("unknown"); + } + + @Test + void auditEventPayloadBuilderWithNullCustomPropsUsesDefaultLocation() { + var auth = mock(Authentication.class); + when(auth.getName()).thenReturn("user"); + + var builder = auditService.auditEventPayloadBuilder(auth, null); + var payload = builder.operation("CREATE").type("TASK").build(); + + assertThat(payload.getActorLocation()).isEqualTo("127.0.0.1"); + assertThat(payload.getSubjectLocation()).isEqualTo("127.0.0.1"); + } + + @Test + void auditEventPayloadBuilderWithJwtAuthUsesIssuerAndSubject() { + var jwtAuth = mock(JwtAuthenticationToken.class); + when(jwtAuth.getTokenAttributes()) + .thenReturn( + Map.of( + "iss", "https://auth.example.com", + "sub", "user-123")); + + var builder = auditService.auditEventPayloadBuilder(jwtAuth, Collections.emptyMap()); + var payload = builder.operation("CREATE").type("TASK").build(); + + assertThat(payload.getActorId()).isEqualTo("https://auth.example.com_user-123"); + assertThat(payload.getSubjectId()).isEqualTo("user-123"); + } + + @Test + void auditEventPayloadBuilderWithJwtAuthIssuerOnlyUsesIssuerAsActorId() { + var jwtAuth = mock(JwtAuthenticationToken.class); + when(jwtAuth.getTokenAttributes()).thenReturn(Map.of("iss", "https://auth.example.com")); + + var builder = auditService.auditEventPayloadBuilder(jwtAuth, Collections.emptyMap()); + var payload = builder.operation("CREATE").type("TASK").build(); + + assertThat(payload.getActorId()).isEqualTo("https://auth.example.com"); + assertThat(payload.getSubjectId()).isEqualTo("unknown"); + } + + @Test + void auditPublishesEvent() { + var builder = auditService.auditEventPayloadBuilder() + .operation("CREATE") + .type("TASK") + .summary("Created task"); + + auditService.audit(builder); + + var eventCaptor = ArgumentCaptor.forClass(BootAuditEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + + var event = eventCaptor.getValue(); + assertThat(event.getPayload()).isNotNull(); + assertThat(event.getPayload().getOperation()).isEqualTo("CREATE"); + assertThat(event.getPayload().getType()).isEqualTo("TASK"); + assertThat(event.getHmac()).isNull(); + } + + /** + * Counterpart to {@link #auditPublishesEvent()}: when {@link AuditProperties} supplies + * {@code hmacKid} and {@code hmacKeys}, the published event carries a non-null, formatted + * top-level {@code hmac}. + */ + @Test + void auditPublishesEventWithMockedAuditPropertiesAttachesFormattedEventHmac() { + var props = mock(AuditProperties.class); + when(props.getHmacKid()).thenReturn(SIGNING_KID); + when(props.getHmacKeys()).thenReturn(encodedSigningHmacKeys()); + when(auditPropertiesProvider.getIfAvailable()).thenReturn(props); + auditService = new AuditService(eventPublisher, auditPropertiesProvider, jsonMapper); + + var builder = auditService.auditEventPayloadBuilder() + .operation("CREATE") + .type("TASK") + .summary("signed audit"); + + auditService.audit(builder); + + var eventCaptor = ArgumentCaptor.forClass(BootAuditEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + + var event = eventCaptor.getValue(); + assertThat(event.getPayload()).isNotNull(); + assertThat(event.getPayload().getOperation()).isEqualTo("CREATE"); + assertThat(event.getPayload().getType()).isEqualTo("TASK"); + + assertThat(event.getHmac()).isNotNull(); + assertThat(event.getHmac()) + .matches(Pattern.compile("^HMac-SHA3-512:[^:]+:.+$")); + + JsonNode payloadTree = jsonMapper.readTree(event.getPayload().toJson()); + assertThat(event.getHmac()) + .isEqualTo(computeHmacFormatted(SIGNING_KID, SIGNING_KEY_RAW, payloadTree)); + } + + @Test + void customPropertiesArePassedToBuilder() { + var auth = mock(Authentication.class); + when(auth.getName()).thenReturn("user"); + + var builder = + auditService.auditEventPayloadBuilder( + auth, + Map.of("remoteAddress", "10.0.0.1", "customKey", "customValue")); + var payload = builder.operation("CREATE").type("TASK").build(); + + assertThat(payload.getData()).containsEntry("customKey", "customValue"); + } + + @Test + void auditEventPayloadBuilderUsesSignedModeWhenAuditPropertiesConfigured() { + useSigningAuditService(); + + JsonNode before = jsonMapper.readTree("{\"a\":1}"); + JsonNode after = jsonMapper.readTree("{\"a\":2}"); + + var payload = auditService.auditEventPayloadBuilder() + .operation("UPDATE") + .type("TASK") + .jsonBefore(before) + .jsonAfter(after) + .build(); + + assertThat(payload.getHmacBefore()) + .isEqualTo(computeHmacFormatted(SIGNING_KID, SIGNING_KEY_RAW, before)); + assertThat(payload.getHmacAfter()) + .isEqualTo(computeHmacFormatted(SIGNING_KID, SIGNING_KEY_RAW, after)); + assertThat(payload.getStateSummary()).isNotNull(); + assertThat(payload.getHistorySummary()).isNotNull(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/test/java/com/nvidia/boot/audit/NvBootAuditIntegrationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/test/java/com/nvidia/boot/audit/NvBootAuditIntegrationTest.java new file mode 100644 index 0000000000..1fbf39a57a --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/test/java/com/nvidia/boot/audit/NvBootAuditIntegrationTest.java @@ -0,0 +1,101 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.audit; + +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.audit.configuration.SynchronousAuditTestConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.context.annotation.Import; + +/** + * Integration test to verify audit auto-configuration is loaded when the app starts. + */ +@SpringBootTest(classes = TestAuditApplication.class) +@Import(SynchronousAuditTestConfiguration.class) +@ExtendWith(OutputCaptureExtension.class) +class NvBootAuditIntegrationTest { + + private static final JsonMapper JSON_MAPPER = JsonMapper.builder().build(); + + @Autowired + private AuditService auditService; + + @Test + void auditServiceBeanIsAutoConfigured() { + assertThat(auditService).isNotNull(); + } + + @Test + void auditServiceProducesBuilderAndAudits() { + var builder = auditService.auditEventPayloadBuilder() + .operation("CREATE") + .type("TASK") + .objectId("test-123") + .state("CREATED") + .summary("Integration test audit"); + + var payload = builder.build(); + + assertThat(payload).isNotNull(); + assertThat(payload.getOperation()).isEqualTo("CREATE"); + assertThat(payload.getType()).isEqualTo("TASK"); + assertThat(payload.getObjectId()).isEqualTo("test-123"); + + auditService.audit(builder); + } + + @Test + void auditEventLogsToStdout(CapturedOutput output) { + auditService.audit( + auditService.auditEventPayloadBuilder() + .operation("CREATE") + .type("TASK") + .objectId("obj-1") + .state("CREATED") + .summary("Test audit event")); + + assertThat(output).contains("[AUDIT]"); + } + + @Test + void auditWithJsonBeforeAndJsonAfterLogsToStdout(CapturedOutput output) throws Exception { + var before = JSON_MAPPER.readTree("{\"a\":1,\"b\":2}"); + var after = JSON_MAPPER.readTree("{\"a\":1,\"b\":3}"); + + auditService.audit( + auditService.auditEventPayloadBuilder() + .operation("UPDATE") + .type("TASK") + .objectId("obj-2") + .state("UPDATED") + .summary("Updated task") + .jsonBefore(before) + .jsonAfter(after)); + + assertThat(output).contains("[AUDIT]"); + assertThat(output).contains("stateSummary"); + assertThat(output).contains("historySummary"); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/test/java/com/nvidia/boot/audit/NvBootAuditSignedIntegrationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/test/java/com/nvidia/boot/audit/NvBootAuditSignedIntegrationTest.java new file mode 100644 index 0000000000..3f72c8f1d4 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/test/java/com/nvidia/boot/audit/NvBootAuditSignedIntegrationTest.java @@ -0,0 +1,133 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.audit; + +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.audit.configuration.SignedAuditPropertiesTestConfiguration; +import com.nvidia.boot.audit.configuration.SynchronousAuditTestConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.context.annotation.Import; + +/** + * Integration test: Spring context with {@link AuditProperties} so {@link AuditService} emits + * signed {@code BootAuditEvent} JSON (top-level {@code hmac} and optional payload HMAC fields). + */ +@SpringBootTest(classes = TestAuditApplication.class) +@Import({SynchronousAuditTestConfiguration.class, SignedAuditPropertiesTestConfiguration.class}) +@ExtendWith(OutputCaptureExtension.class) +class NvBootAuditSignedIntegrationTest { + + private static final JsonMapper JSON_MAPPER = JsonMapper.builder().build(); + + private static final String AUDIT_PREFIX = "[AUDIT] "; + + @Autowired + private AuditService auditService; + + @Test + void signedAuditLogContainsTopLevelHmac(CapturedOutput output) throws Exception { + auditService.audit( + auditService.auditEventPayloadBuilder() + .operation("CREATE") + .type("TASK") + .objectId("signed-obj-1") + .state("CREATED") + .summary("Signed integration audit")); + + assertThat(output).contains(AUDIT_PREFIX); + + JsonNode event = readLastAuditEventJson(output); + assertThat(event.hasNonNull("hmac")).isTrue(); + assertThat(event.get("hmac").asString()) + .startsWith("HMac-SHA3-512:" + SignedAuditPropertiesTestConfiguration.SIGNING_KID + ":"); + + JsonNode payload = event.get("payload"); + assertThat(payload).isNotNull(); + assertThat(payload.get("operation").asString()).isEqualTo("CREATE"); + assertThat(payload.get("objectId").asString()).isEqualTo("signed-obj-1"); + } + + @Test + void signedAuditWithJsonDiffContainsPayloadHmacs(CapturedOutput output) throws Exception { + var before = JSON_MAPPER.readTree("{\"a\":1,\"b\":2}"); + var after = JSON_MAPPER.readTree("{\"a\":1,\"b\":3}"); + + auditService.audit( + auditService.auditEventPayloadBuilder() + .operation("UPDATE") + .type("TASK") + .objectId("signed-obj-2") + .state("UPDATED") + .summary("Signed update with diff") + .jsonBefore(before) + .jsonAfter(after)); + + assertThat(output).contains(AUDIT_PREFIX); + + JsonNode event = readLastAuditEventJson(output); + assertThat(event.hasNonNull("hmac")).isTrue(); + + JsonNode payload = event.get("payload"); + assertThat(payload.hasNonNull("hmacBefore")).isTrue(); + assertThat(payload.hasNonNull("hmacAfter")).isTrue(); + assertThat(payload.hasNonNull("stateSummary")).isTrue(); + assertThat(payload.hasNonNull("historySummary")).isTrue(); + + assertThat(payload.get("hmacBefore").asString()) + .isEqualTo( + AuditUtils.computeHmacFormatted( + SignedAuditPropertiesTestConfiguration.SIGNING_KID, + signingKeyRaw(), + before)); + assertThat(payload.get("hmacAfter").asString()) + .isEqualTo( + AuditUtils.computeHmacFormatted( + SignedAuditPropertiesTestConfiguration.SIGNING_KID, + signingKeyRaw(), + after)); + } + + private static byte[] signingKeyRaw() { + byte[] raw = new byte[32]; + for (int i = 0; i < raw.length; i++) { + raw[i] = (byte) (i + 1); + } + return raw; + } + + private static JsonNode readLastAuditEventJson(CapturedOutput output) throws Exception { + String combined = output.getOut() + output.getErr(); + int idx = combined.lastIndexOf(AUDIT_PREFIX); + assertThat(idx).as("expected at least one [AUDIT] line in captured output") + .isGreaterThanOrEqualTo(0); + String tail = combined.substring(idx + AUDIT_PREFIX.length()).trim(); + int nl = tail.indexOf('\n'); + if (nl > 0) { + tail = tail.substring(0, nl); + } + return JSON_MAPPER.readTree(tail); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/test/java/com/nvidia/boot/audit/TestAuditApplication.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/test/java/com/nvidia/boot/audit/TestAuditApplication.java new file mode 100644 index 0000000000..5fb2b521ea --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/test/java/com/nvidia/boot/audit/TestAuditApplication.java @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.audit; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Minimal test application for audit integration tests. + */ +@SpringBootApplication +public class TestAuditApplication { + + public static void main(String[] args) { + SpringApplication.run(TestAuditApplication.class, args); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/test/java/com/nvidia/boot/audit/configuration/SignedAuditPropertiesTestConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/test/java/com/nvidia/boot/audit/configuration/SignedAuditPropertiesTestConfiguration.java new file mode 100644 index 0000000000..33bc48cd3d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/test/java/com/nvidia/boot/audit/configuration/SignedAuditPropertiesTestConfiguration.java @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.audit.configuration; + +import tools.jackson.core.JacksonException; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; +import com.nvidia.boot.audit.AuditProperties; +import java.util.Base64; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +/** + * Supplies {@link AuditProperties} for integration tests that exercise HMAC-signed audit events. + */ +@TestConfiguration +public class SignedAuditPropertiesTestConfiguration { + + /** {@link AuditProperties#setHmacKid} value; matches the key entry in the generated store. */ + public static final String SIGNING_KID = "integration-audit-kid"; + + private static final byte[] SIGNING_KEY_RAW = new byte[32]; + + static { + for (int i = 0; i < SIGNING_KEY_RAW.length; i++) { + SIGNING_KEY_RAW[i] = (byte) (i + 1); + } + } + + @Bean + public AuditProperties auditProperties(JsonMapper jsonMapper) throws JacksonException { + ArrayNode keys = jsonMapper.createArrayNode(); + ObjectNode entry = jsonMapper.createObjectNode(); + entry.put("kid", SIGNING_KID); + entry.put("key", Base64.getEncoder().encodeToString(SIGNING_KEY_RAW)); + keys.add(entry); + ObjectNode root = jsonMapper.createObjectNode(); + root.set("keys", keys); + var props = new AuditProperties(); + props.setHmacKid(SIGNING_KID); + props.setHmacKeys(Base64.getEncoder().encodeToString(jsonMapper.writeValueAsBytes(root))); + return props; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/test/java/com/nvidia/boot/audit/configuration/SynchronousAuditTestConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/test/java/com/nvidia/boot/audit/configuration/SynchronousAuditTestConfiguration.java new file mode 100644 index 0000000000..4759ce9c72 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/test/java/com/nvidia/boot/audit/configuration/SynchronousAuditTestConfiguration.java @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.audit.configuration; + +import java.util.concurrent.Executor; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.event.ApplicationEventMulticaster; +import org.springframework.context.event.SimpleApplicationEventMulticaster; +import org.springframework.core.task.SyncTaskExecutor; +import org.springframework.scheduling.annotation.AsyncConfigurer; +import org.springframework.scheduling.annotation.EnableAsync; + +/** + * Test configuration that makes audit event handling synchronous so CapturedOutput + * can reliably capture [AUDIT] logs. Uses SimpleApplicationEventMulticaster with + * SyncTaskExecutor instead of a concurrent executor, so events are handled on the + * calling thread. + */ +@TestConfiguration +@EnableAsync +public class SynchronousAuditTestConfiguration { + + @Bean(name = "applicationEventMulticaster") + ApplicationEventMulticaster applicationEventMulticaster() { + var multicaster = new SimpleApplicationEventMulticaster(); + multicaster.setTaskExecutor(new SyncTaskExecutor()); + return multicaster; + } + + @Bean + AsyncConfigurer synchronousAsyncConfigurer() { + return new AsyncConfigurer() { + @Override + public Executor getAsyncExecutor() { + return new SyncTaskExecutor(); + } + }; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/test/java/com/nvidia/boot/audit/event/AuditEventPayloadTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/test/java/com/nvidia/boot/audit/event/AuditEventPayloadTest.java new file mode 100644 index 0000000000..d3fbf1269d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/test/java/com/nvidia/boot/audit/event/AuditEventPayloadTest.java @@ -0,0 +1,377 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.audit.event; + +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.audit.AuditUtils; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class AuditEventPayloadTest { + + private static final JsonMapper JSON_MAPPER = JsonMapper.builder().build(); + + @Test + void builderProducesValidPayload() { + var payload = AuditEventPayload.builder() + .operation("CREATE") + .type("TASK") + .actorId("user-123") + .subjectId("user-123") + .objectId("task-456") + .state("CREATED") + .summary("Created task") + .build(); + + assertThat(payload.getId()).isNotNull(); + assertThat(payload.getTimestamp()).isNotNull(); + assertThat(payload.getMachineId()).isNotNull(); + assertThat(payload.getOperation()).isEqualTo("CREATE"); + assertThat(payload.getType()).isEqualTo("TASK"); + assertThat(payload.getActorId()).isEqualTo("user-123"); + assertThat(payload.getSubjectId()).isEqualTo("user-123"); + assertThat(payload.getObjectId()).isEqualTo("task-456"); + assertThat(payload.getState()).isEqualTo("CREATED"); + assertThat(payload.getSummary()).isEqualTo("Created task"); + assertThat(payload.getData()).isEmpty(); + } + + @Test + void subjectIdDefaultsToActorIdWhenBlank() { + var payload = AuditEventPayload.builder() + .operation("CREATE") + .type("TASK") + .actorId("user-123") + .subjectId("") + .build(); + + assertThat(payload.getSubjectId()).isEqualTo("user-123"); + } + + @Test + void subjectIdDefaultsToActorIdWhenNull() { + var payload = AuditEventPayload.builder() + .operation("CREATE") + .type("TASK") + .actorId("user-123") + .build(); + + assertThat(payload.getSubjectId()).isEqualTo("user-123"); + } + + @Test + void customDataIsIncludedInPayload() { + var payload = AuditEventPayload.builder() + .operation("CREATE") + .type("TASK") + .actorId("actor") + .custom("key1", "value1") + .custom("key2", 42) + .build(); + + assertThat(payload.getData()).containsEntry("key1", "value1"); + assertThat(payload.getData()).containsEntry("key2", 42); + } + + @Test + void dataMapMergesWithExistingCustomEntries() { + var payload = AuditEventPayload.builder() + .operation("CREATE") + .type("TASK") + .actorId("actor") + .custom("key1", "value1") + .data(Map.of("key2", "value2")) + .build(); + + assertThat(payload.getData()).containsEntry("key1", "value1"); + assertThat(payload.getData()).containsEntry("key2", "value2"); + } + + @Test + void dataMapOverwritesSameKeyAsEarlierCustom() { + var payload = AuditEventPayload.builder() + .operation("CREATE") + .type("TASK") + .actorId("actor") + .custom("key1", "first") + .data(Map.of("key1", "second")) + .build(); + + assertThat(payload.getData()).containsEntry("key1", "second"); + } + + @Test + void toJsonProducesValidJson() throws Exception { + var payload = AuditEventPayload.builder() + .operation("CREATE") + .type("TASK") + .actorId("user-123") + .build(); + + var json = payload.toJson(); + var node = JSON_MAPPER.readTree(json); + + assertThat(node.has("id")).isTrue(); + assertThat(node.has("timestamp")).isTrue(); + assertThat(node.has("machineId")).isTrue(); + assertThat(node.get("operation").asString()).isEqualTo("CREATE"); + assertThat(node.get("type").asString()).isEqualTo("TASK"); + assertThat(node.get("actorId").asString()).isEqualTo("user-123"); + } + + @Test + void jsonBeforeAndJsonAfterProduceStateAndHistorySummary() throws Exception { + var before = JSON_MAPPER.readTree("{\"a\":1,\"b\":2}"); + var after = JSON_MAPPER.readTree("{\"a\":1,\"b\":3}"); + + var payload = AuditEventPayload.builder() + .operation("UPDATE") + .type("TASK") + .actorId("actor") + .jsonBefore(before) + .jsonAfter(after) + .build(); + + assertThat(payload.getStateSummary()).isNotNull(); + assertThat(payload.getHistorySummary()).isNotNull(); + assertThat(payload.getStateSummary()).contains("b"); + assertThat(payload.getHistorySummary()).contains("b"); + } + + @Test + void jsonBeforeOnlyProducesNoStateOrHistorySummary() throws Exception { + var before = JSON_MAPPER.readTree("{\"a\":1,\"b\":2}"); + + var payload = AuditEventPayload.builder() + .operation("UPDATE") + .type("TASK") + .actorId("actor") + .jsonBefore(before) + .build(); + + assertThat(payload.getStateSummary()).isNull(); + assertThat(payload.getHistorySummary()).isNull(); + } + + @Test + void jsonAfterOnlyProducesNoStateOrHistorySummary() throws Exception { + var after = JSON_MAPPER.readTree("{\"a\":1,\"b\":3}"); + + var payload = AuditEventPayload.builder() + .operation("UPDATE") + .type("TASK") + .actorId("actor") + .jsonAfter(after) + .build(); + + assertThat(payload.getStateSummary()).isNull(); + assertThat(payload.getHistorySummary()).isNull(); + } + + @Test + void jsonBeforeAndJsonAfterWithAddedFieldProducesDiff() throws Exception { + var before = JSON_MAPPER.readTree("{\"name\":\"old\"}"); + var after = JSON_MAPPER.readTree("{\"name\":\"old\",\"newField\":\"value\"}"); + + var payload = AuditEventPayload.builder() + .operation("UPDATE") + .type("TASK") + .actorId("actor") + .jsonBefore(before) + .jsonAfter(after) + .build(); + + assertThat(payload.getStateSummary()).isNotNull(); + assertThat(payload.getHistorySummary()).isNotNull(); + assertThat(payload.getStateSummary()).contains("add"); + assertThat(payload.getStateSummary()).contains("newField"); + } + + @Test + void jsonBeforeAndJsonAfterWithRemovedFieldProducesDiff() throws Exception { + var before = JSON_MAPPER.readTree("{\"name\":\"old\",\"removed\":\"value\"}"); + var after = JSON_MAPPER.readTree("{\"name\":\"old\"}"); + + var payload = AuditEventPayload.builder() + .operation("UPDATE") + .type("TASK") + .actorId("actor") + .jsonBefore(before) + .jsonAfter(after) + .build(); + + assertThat(payload.getStateSummary()).isNotNull(); + assertThat(payload.getHistorySummary()).isNotNull(); + assertThat(payload.getStateSummary()).contains("remove"); + } + + @Test + void jsonBeforeAndJsonAfterHistorySummaryRevertsStateSummary() throws Exception { + var before = JSON_MAPPER.readTree("{\"a\":1,\"b\":2}"); + var after = JSON_MAPPER.readTree("{\"a\":1,\"b\":3}"); + + var payload = AuditEventPayload.builder() + .operation("UPDATE") + .type("TASK") + .actorId("actor") + .jsonBefore(before) + .jsonAfter(after) + .build(); + + assertThat(payload.getStateSummary()).isNotEqualTo(payload.getHistorySummary()); + assertThat(payload.getStateSummary()).contains("replace"); + assertThat(payload.getHistorySummary()).contains("replace"); + } + + @Test + void builderIsFluent() { + var builder = AuditEventPayload.builder(); + + var sameBuilder = builder.operation("CREATE") + .type("TASK") + .actorId("user-1") + .actorLocation("127.0.0.1") + .subjectId("user-1") + .subjectLocation("127.0.0.1") + .objectId("obj-1") + .objectLocation("loc") + .groupType("group") + .state("CREATED") + .summary("Created"); + + assertThat(sameBuilder).isSameAs(builder); + var payload = builder.build(); + assertThat(payload.getOperation()).isEqualTo("CREATE"); + } + + private static final String HMAC_KID = "test-kid"; + private static final byte[] HMAC_KEY = new byte[32]; + + static { + for (int i = 0; i < HMAC_KEY.length; i++) { + HMAC_KEY[i] = (byte) (i + 1); + } + } + + @Test + void signedBuilderSetsHmacBeforeAndHmacAfterWhenBothJsonNodesPresent() throws Exception { + var before = JSON_MAPPER.readTree("{\"a\":1,\"b\":2}"); + var after = JSON_MAPPER.readTree("{\"a\":1,\"b\":3}"); + + var payload = AuditEventPayload.signedBuilder(HMAC_KID, HMAC_KEY) + .operation("UPDATE") + .type("TASK") + .actorId("actor") + .jsonBefore(before) + .jsonAfter(after) + .build(); + + assertThat(payload.getHmacBefore()) + .isEqualTo(AuditUtils.computeHmacFormatted(HMAC_KID, HMAC_KEY, before)); + assertThat(payload.getHmacAfter()) + .isEqualTo(AuditUtils.computeHmacFormatted(HMAC_KID, HMAC_KEY, after)); + assertThat(payload.getStateSummary()).isNotNull(); + assertThat(payload.getHistorySummary()).isNotNull(); + } + + @Test + void signedBuilderSetsOnlyHmacBeforeWhenJsonAfterAbsent() throws Exception { + var before = JSON_MAPPER.readTree("{\"x\":true}"); + + var payload = AuditEventPayload.signedBuilder(HMAC_KID, HMAC_KEY) + .operation("DELETE") + .type("ROW") + .actorId("actor") + .jsonBefore(before) + .build(); + + assertThat(payload.getHmacBefore()) + .isEqualTo(AuditUtils.computeHmacFormatted(HMAC_KID, HMAC_KEY, before)); + assertThat(payload.getHmacAfter()).isNull(); + } + + @Test + void signedBuilderSetsOnlyHmacAfterWhenJsonBeforeAbsent() throws Exception { + var after = JSON_MAPPER.readTree("{\"y\":2}"); + + var payload = AuditEventPayload.signedBuilder(HMAC_KID, HMAC_KEY) + .operation("CREATE") + .type("ROW") + .actorId("actor") + .jsonAfter(after) + .build(); + + assertThat(payload.getHmacBefore()).isNull(); + assertThat(payload.getHmacAfter()) + .isEqualTo(AuditUtils.computeHmacFormatted(HMAC_KID, HMAC_KEY, after)); + } + + @Test + void unsignedBuilderLeavesHmacFieldsNullEvenWithJsonBeforeAndAfter() throws Exception { + var before = JSON_MAPPER.readTree("{\"a\":1}"); + var after = JSON_MAPPER.readTree("{\"a\":2}"); + + var payload = AuditEventPayload.builder() + .operation("UPDATE") + .type("TASK") + .actorId("actor") + .jsonBefore(before) + .jsonAfter(after) + .build(); + + assertThat(payload.getHmacBefore()).isNull(); + assertThat(payload.getHmacAfter()).isNull(); + } + + @Test + void formattedHmacPatternRejectsMalformedStrings() throws Exception { + var p = AuditEventPayload.FORMATTED_HMAC_PATTERN; + assertThat(p.matcher("").matches()).isFalse(); + assertThat(p.matcher("only-one").matches()).isFalse(); + assertThat(p.matcher("a:b").matches()).isFalse(); + assertThat(p.matcher(":b:c").matches()).isFalse(); + assertThat(p.matcher("a::c").matches()).isFalse(); + assertThat(p.matcher("a:b:c:d").matches()).isFalse(); + assertThat(p.matcher("a:b:c").matches()).isTrue(); + assertThat(AuditUtils.computeHmacFormatted(HMAC_KID, HMAC_KEY, JSON_MAPPER.readTree("{}"))) + .matches(p); + } + + @Test + void fromJsonRoundTripPreservesIdentityFields() { + var payload = AuditEventPayload.signedBuilder("kid-2", HMAC_KEY) + .operation("PATCH") + .type("DOC") + .actorId("a") + .summary("s") + .build(); + + var json = payload.toJson(); + var restored = AuditEventPayload.fromJson(json); + + assertThat(restored.getId()).isEqualTo(payload.getId()); + assertThat(restored.getTimestamp()).isEqualTo(payload.getTimestamp()); + assertThat(restored.getMachineId()).isEqualTo(payload.getMachineId()); + assertThat(restored.getOperation()).isEqualTo("PATCH"); + assertThat(restored.getType()).isEqualTo("DOC"); + assertThat(restored.getActorId()).isEqualTo("a"); + assertThat(restored.getSummary()).isEqualTo("s"); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/test/java/com/nvidia/boot/audit/event/BootAuditEventTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/test/java/com/nvidia/boot/audit/event/BootAuditEventTest.java new file mode 100644 index 0000000000..2ae93b8504 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/test/java/com/nvidia/boot/audit/event/BootAuditEventTest.java @@ -0,0 +1,204 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.audit.event; + +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.audit.AuditUtils; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.List; +import org.junit.jupiter.api.Test; + +class BootAuditEventTest { + + private static final JsonMapper JSON_MAPPER = JsonMapper.builder().build(); + + /** HMAC material for audit-chain tests (matches former {@link AuditEventPayloadTest} chain). */ + private static final String CHAIN_HMAC_KID = "test-kid"; + private static final byte[] CHAIN_HMAC_KEY = new byte[32]; + + static { + for (int i = 0; i < CHAIN_HMAC_KEY.length; i++) { + CHAIN_HMAC_KEY[i] = (byte) (i + 1); + } + } + + /** HMAC material for event-level {@code BootAuditEvent} round-trip with top-level {@code hmac}. */ + private static final String SERIAL_HMAC_KID = "event-kid"; + private static final byte[] SERIAL_HMAC_KEY = new byte[32]; + + static { + for (int i = 0; i < SERIAL_HMAC_KEY.length; i++) { + SERIAL_HMAC_KEY[i] = (byte) (0x40 + i); + } + } + + @Test + void eventWrapsPayload() { + var payload = AuditEventPayload.builder() + .operation("CREATE") + .type("TASK") + .actorId("actor") + .build(); + + var event = new BootAuditEvent(payload, null, BootAuditEventTest.class); + + assertThat(event.getPayload()).isSameAs(payload); + assertThat(event.getSource()).isSameAs(payload); + } + + @Test + void bootAuditEventRoundTripWithoutHmac() throws JacksonException { + var payload = AuditEventPayload.builder() + .operation("CREATE") + .type("TASK") + .actorId("actor") + .summary("no hmac") + .build(); + + var original = new BootAuditEvent(payload, null, BootAuditEventTest.class); + var json = original.toJson(); + + var restored = BootAuditEvent.fromJson(json); + + assertThat(restored.getHmac()).isNull(); + assertThat(restored.getPayload().getId()).isEqualTo(payload.getId()); + assertThat(restored.getPayload().getOperation()).isEqualTo("CREATE"); + assertThat(restored.getPayload().getType()).isEqualTo("TASK"); + assertThat(restored.getPayload().getSummary()).isEqualTo("no hmac"); + } + + @Test + void bootAuditEventRoundTripWithHmac() throws JacksonException { + var payload = AuditEventPayload.builder() + .operation("UPDATE") + .type("RESOURCE") + .actorId("actor") + .summary("signed event") + .build(); + + var payloadJsonNode = BootAuditEvent.AUDIT_EVENT_JSON_MAPPER.readTree(payload.toJson()); + var eventHmac = AuditUtils.computeHmacFormatted(SERIAL_HMAC_KID, SERIAL_HMAC_KEY, payloadJsonNode); + + var original = new BootAuditEvent(payload, eventHmac, BootAuditEventTest.class); + var json = original.toJson(); + + var restored = BootAuditEvent.fromJson(json); + + assertThat(restored.getHmac()).isEqualTo(eventHmac); + assertThat(restored.getPayload().getId()).isEqualTo(payload.getId()); + assertThat(restored.getPayload().getOperation()).isEqualTo("UPDATE"); + assertThat(restored.getPayload().getSummary()).isEqualTo("signed event"); + } + + /** + * Verify Audit Event Chain - walk backwards from the latest {@code jsonAfter} by applying + * {@code historySummary}; each step's HMAC must match that event payload's {@code hmacBefore}. + */ + @Test + void verifyAuditEventChainReverseHistoryMatchesHmacBefore() throws Exception { + var nonExistent = JSON_MAPPER.readTree("{}"); + var initialized = JSON_MAPPER.readTree("{\"id\":1,\"status\":\"INIT\",\"prop\":\"foo\"}"); + var created = JSON_MAPPER.readTree("{\"id\":1,\"status\":\"CREATED\",\"prop\":\"bar\"}"); + var updated = JSON_MAPPER.readTree("{\"id\":1,\"status\":\"UPDATED\",\"prop\":\"baz\"}"); + var deleted = JSON_MAPPER.readTree("{\"id\":1,\"status\":\"DELETED\",\"prop\":\"baz\"}"); + + var event1 = createSignedChainEvent( + "INIT", "INITIALIZED", "Initialized ID_1", nonExistent, initialized); + var event2 = createSignedChainEvent( + "CREATE", "CREATED", "Created ID_1", initialized, created); + var event3 = createSignedChainEvent( + "UPDATE", "UPDATED", "Updated ID_1", created, updated); + var event4 = createSignedChainEvent( + "DELETE", "DELETED", "Deleted ID_1", updated, deleted); + + Deque events = new ArrayDeque<>(); + events.push(event1); + events.push(event2); + events.push(event3); + events.push(event4); + + JsonNode currState = deleted; + while (!events.isEmpty()) { + BootAuditEvent curr = events.pop(); + JsonNode reverseDiff = JSON_MAPPER.readTree(curr.getPayload().getHistorySummary()); + currState = AuditJsonDiff.applyPatch(currState, reverseDiff); + assertThat(AuditUtils.computeHmacFormatted(CHAIN_HMAC_KID, CHAIN_HMAC_KEY, currState)) + .isEqualTo(curr.getPayload().getHmacBefore()); + } + assertThat(currState).isEqualTo(nonExistent); + } + + /** + * Forward companion: apply {@code stateSummary} in chronological order; each step's HMAC must + * match that event payload's {@code hmacAfter}. + */ + @Test + void verifyAuditEventChainForwardStateMatchesHmacAfter() throws Exception { + var nonExistent = JSON_MAPPER.readTree("{}"); + var initialized = JSON_MAPPER.readTree("{\"id\":1,\"status\":\"INIT\",\"prop\":\"foo\"}"); + var created = JSON_MAPPER.readTree("{\"id\":1,\"status\":\"CREATED\",\"prop\":\"bar\"}"); + var updated = JSON_MAPPER.readTree("{\"id\":1,\"status\":\"UPDATED\",\"prop\":\"baz\"}"); + var deleted = JSON_MAPPER.readTree("{\"id\":1,\"status\":\"DELETED\",\"prop\":\"baz\"}"); + + var event1 = createSignedChainEvent( + "INIT", "INITIALIZED", "Initialized ID_1", nonExistent, initialized); + var event2 = createSignedChainEvent( + "CREATE", "CREATED", "Created ID_1", initialized, created); + var event3 = createSignedChainEvent( + "UPDATE", "UPDATED", "Updated ID_1", created, updated); + var event4 = createSignedChainEvent( + "DELETE", "DELETED", "Deleted ID_1", updated, deleted); + + JsonNode currState = nonExistent; + for (BootAuditEvent event : List.of(event1, event2, event3, event4)) { + var payload = event.getPayload(); + JsonNode forwardDiff = JSON_MAPPER.readTree(payload.getStateSummary()); + currState = AuditJsonDiff.applyPatch(currState, forwardDiff); + assertThat(AuditUtils.computeHmacFormatted(CHAIN_HMAC_KID, CHAIN_HMAC_KEY, currState)) + .isEqualTo(payload.getHmacAfter()); + } + assertThat(currState).isEqualTo(deleted); + } + + private BootAuditEvent createSignedChainEvent( + String operation, + String state, + String summary, + JsonNode jsonBefore, + JsonNode jsonAfter) { + var payload = AuditEventPayload.signedBuilder(CHAIN_HMAC_KID, CHAIN_HMAC_KEY) + .operation(operation) + .type("type") + .groupType("group") + .actorId("john.doe") + .actorLocation("216.235.112.22") + .custom("max", Integer.MAX_VALUE) + .custom("hello", "world") + .state(state) + .summary(summary) + .jsonBefore(jsonBefore) + .jsonAfter(jsonAfter) + .build(); + return new BootAuditEvent(payload, null, BootAuditEventTest.class); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/test/java/com/nvidia/boot/audit/listener/BootAuditEventListenerTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/test/java/com/nvidia/boot/audit/listener/BootAuditEventListenerTest.java new file mode 100644 index 0000000000..2667eb728d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-audit/src/test/java/com/nvidia/boot/audit/listener/BootAuditEventListenerTest.java @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.audit.listener; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.nvidia.boot.audit.event.AuditEventPayload; +import com.nvidia.boot.audit.event.BootAuditEvent; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; + +@ExtendWith(OutputCaptureExtension.class) +class BootAuditEventListenerTest { + + private final BootAuditEventListener listener = new BootAuditEventListener(); + + @Test + void onBootAuditEventLogsPayloadToAudit(CapturedOutput output) { + var payload = AuditEventPayload.builder() + .operation("CREATE") + .type("TASK") + .actorId("actor") + .objectId("obj-123") + .state("CREATED") + .summary("Created task") + .build(); + var event = new BootAuditEvent(payload, null, BootAuditEventListenerTest.class); + + listener.onBootAuditEvent(event); + + assertThat(output).contains("[AUDIT]"); + assertThat(output).contains("CREATE"); + assertThat(output).contains("TASK"); + assertThat(output).contains("obj-123"); + } + + @Test + void onBootAuditEventIncludesPayloadJson(CapturedOutput output) { + var payload = AuditEventPayload.builder() + .operation("UPDATE") + .type("RESOURCE") + .actorId("actor") + .summary("Updated resource") + .build(); + var event = new BootAuditEvent(payload, null, BootAuditEventListenerTest.class); + + listener.onBootAuditEvent(event); + + assertThat(output).contains("[AUDIT]"); + assertThat(output).contains("\"operation\":\"UPDATE\""); + assertThat(output).contains("\"type\":\"RESOURCE\""); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/BUILD.bazel b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/BUILD.bazel new file mode 100644 index 0000000000..2f68d4a3c6 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/BUILD.bazel @@ -0,0 +1,69 @@ +load("//src/libraries/java/nv-boot-parent/tools/bazel:java.bzl", "nv_boot_library", "nv_boot_library_test") + +CASSANDRA_COMPILE_DEPS = [ + "@nv_third_party_deps//:at_yawk_lz4_lz4_java", + "@nv_third_party_deps//:com_github_ben_manes_caffeine_guava", + "@nv_third_party_deps//:com_google_guava_guava", + "@nv_third_party_deps//:io_micrometer_micrometer_commons", + "@nv_third_party_deps//:io_micrometer_micrometer_core", + "@nv_third_party_deps//:io_micrometer_micrometer_observation", + "@nv_third_party_deps//:jakarta_annotation_jakarta_annotation_api", + "@nv_third_party_deps//:jakarta_validation_jakarta_validation_api", + "@nv_third_party_deps//:org_apache_cassandra_java_driver_core", + "@nv_third_party_deps//:org_apache_cassandra_java_driver_metrics_micrometer", + "@nv_third_party_deps//:org_apache_commons_commons_lang3", + "@nv_third_party_deps//:org_jspecify_jspecify", + "@nv_third_party_deps//:org_slf4j_slf4j_api", + "@nv_third_party_deps//:org_springframework_boot_spring_boot", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_actuator", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_actuator_autoconfigure", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_autoconfigure", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_cassandra", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_data_cassandra", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_health", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_actuator", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_data_cassandra", + "@nv_third_party_deps//:org_springframework_cloud_spring_cloud_context", + "@nv_third_party_deps//:org_springframework_data_spring_data_cassandra", + "@nv_third_party_deps//:org_springframework_data_spring_data_commons", + "@nv_third_party_deps//:org_springframework_spring_beans", + "@nv_third_party_deps//:org_springframework_spring_context", + "@nv_third_party_deps//:org_springframework_spring_core", +] + +CASSANDRA_TEST_DEPS = [ + ":nv_boot_starter_cassandra", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_actuator_test", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_data_cassandra_test", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_webmvc_test", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_webmvc_test", + "@nv_third_party_deps//:org_springframework_spring_aop", + "@nv_third_party_deps//:org_springframework_spring_web", + "@nv_third_party_deps//:org_testcontainers_testcontainers", + "@nv_third_party_deps//:org_testcontainers_testcontainers_cassandra", + "@nv_third_party_deps//:org_testcontainers_testcontainers_junit_jupiter", +] + CASSANDRA_COMPILE_DEPS + +nv_boot_library( + name = "nv_boot_starter_cassandra", + srcs = glob(["src/main/java/**/*.java"]), + resource_strip_prefix = "src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/main/resources", + resources = glob(["src/main/resources/**"]), + visibility = ["//visibility:public"], + deps = CASSANDRA_COMPILE_DEPS, +) + +nv_boot_library_test( + name = "tests", + srcs = glob(["src/test/java/**/*.java"]), + coverage_library = ":nv_boot_starter_cassandra", + resource_strip_prefix = "src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/resources", + resources = glob(["src/test/resources/**"]), + deps = CASSANDRA_TEST_DEPS, + size = "large", + tags = [ + "integration", + "requires-docker", + ], + timeout = "long", +) diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/README.md b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/README.md new file mode 100644 index 0000000000..a8863faeb0 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/README.md @@ -0,0 +1,279 @@ +# NV Boot Starter Cassandra + +Library for Cassandra with SSL bundle support, credential rotation, refreshable +`CqlSession`, and optional Apache Cassandra Java Driver metrics via Micrometer. Supports +certificate rotation via Spring Boot SSL bundles and credential updates via +`RefreshScopeRefreshedEvent`. + +## Adding as a Dependency + +Add to your application's `pom.xml`: + +```xml + + + + com.nvidia.boot + nv-boot-bom + ${nv-boot.version} + pom + import + + + + + + + com.nvidia.boot + nv-boot-starter-cassandra + + +``` + +## Configuration + +Set `spring.cassandra.ssl.bundle` to the **same name** as the PEM bundle under +`spring.ssl.bundle.pem` (examples below use `cassandra-ssl`). + +For a **local plaintext** cluster, either do not set `spring.cassandra.ssl.enabled` or +set it to `spring.cassandra.ssl.enabled=false`. In such a scenario, the starter still registers the +refreshable `cassandraSession` without requiring `spring.cassandra.ssl.bundle` to be configured. + +### PEM files on the filesystem (`file:`) + +Use `file:` URLs so Spring Boot can load certificate and key material from disk. With +`reload-on-update: true`, Spring Boot watches those files and reloads the SSL bundle when they +change (see [SSL](https://docs.spring.io/spring-boot/reference/features/ssl.html) in the Spring +Boot reference). + +```yaml +spring: + ssl: + bundle: + pem: + cassandra-ssl: + reload-on-update: true + keystore: + certificate: "file:/vault/secrets/cassandra-app-cert.pem" + private-key: "file:/vault/secrets/cassandra-app-key.pem" + truststore: + certificate: "file:/vault/secrets/cassandra-tls-cert.pem" + + cassandra: + ssl: + bundle: cassandra-ssl +``` + +### Base64 material from properties (`base64:` + `kv.*`) + +When secrets are base64-encoded and exposed as properties (for example from a rendered secrets +file resolved as `kv.cassandra-ssl.*`), prefix values with `base64:` so Spring Boot decodes them +into PEM (see [SSL](https://docs.spring.io/spring-boot/reference/features/ssl.html)). Do not set +`reload-on-update` for this style - rotation is driven by the property source (for example +`nv-boot-starter-reloadable-properties` updating the secrets file and refreshing the context). + +```yaml +spring: + ssl: + bundle: + pem: + cassandra-ssl: + keystore: + certificate: "base64:${kv.cassandra-ssl.appCert}" + private-key: "base64:${kv.cassandra-ssl.appKey}" + truststore: + certificate: "base64:${kv.cassandra-ssl.tlsCert}" + + cassandra: + ssl: + bundle: cassandra-ssl +``` + +After a refresh, the starter rebuilds the Cassandra session from the rebound `SslProperties` +bean so TLS matches the updated `base64:` values (the registered `SslBundles` bean alone may +still reflect startup material until the bundle is reloaded by other means). + +## Driver metrics + +The starter pulls in `java-driver-metrics-micrometer`. You can export Cassandra Java Driver +[session and node metrics](https://apache.github.io/cassandra-java-driver/latest/core/metrics/) +to Micrometer by listing the metric names you want under `spring.cassandra.session-metrics` and/or +`spring.cassandra.node-metrics`. Both properties are **optional**; if you omit them (or leave both +lists empty), no driver metrics customizer is registered and the driver uses its default metrics +behavior. + +- **Opt-in:** At least one **non-empty** list is required. The configuration class is only applied + when `spring.cassandra.session-metrics` or `spring.cassandra.node-metrics` is bound to a list + with at least one entry. If a key is present with an empty list (for example + `session-metrics: []`), the customizer is **not** registered. +- **Implementation:** When enabled, the starter configures the Micrometer metrics factory, + `TaggingMetricIdGenerator` (so node address is carried in Micrometer tags rather than in metric + names—important for OpenTelemetry instrument naming), and the enabled metric lists you specify. + +Example: + +```yaml +spring: + cassandra: + session-name: my-app-session + session-metrics: + - bytes-sent + - connected-nodes + - cql-requests + node-metrics: + - pool.open-connections + - pool.in-flight +``` + +See the driver reference configuration for the full list of available metric names. + +## Observability (CQL tracing spans) + +This starter’s `CassandraSslBundleConfiguration` registers a `CqlSessionBuilderCustomizer` bean +named `cassandraObservationRequestTrackerCustomizer` that adds Spring Data Cassandra’s +`ObservationRequestTracker` to the shared `CqlSessionBuilder`, so observations started by +`ObservableCqlSessionFactory` can finish on each request. + +When an `ObservationRegistry` is also available (typically from `nv-boot-starter-observability`), +the primary `cassandraSession` bean (`RefreshingCqlSession`) is wrapped with +`ObservableCqlSessionFactory.wrap(...)` by default so synchronous CQL produces tracing spans. + +If you use **both** this starter and `nv-boot-starter-observability`, the observability starter +**does not** register a second `cassandraObservationRequestTrackerCustomizer`: it backs off when +that bean name is already present (this starter is ordered ahead and owns the customizer). If you +use **only** `nv-boot-starter-observability`, it registers that customizer for Boot’s default +`CqlSession` path. + +### When to disable it + +Reactive apps that already wrap at the reactive layer via +`ObservableReactiveSessionFactoryBean` (the Spring Data idiomatic reactive pattern) should opt out +of the library's sync wrap. Without that opt-out, reactive apps see duplicate + orphan sync CQL +spans: + +1. Spring Data's `CqlSessionObservationInterceptor` starts a new observation on every `execute` / `prepareAsync`. +2. On the CQL statement, the execution thread has nothing to connect the observation with (`ThreadLocal` did not get anything from reactive operations). +3. Span has no parent → emitted as a standalone trace *in addition to* the correctly-parented reactive span. + +### Opt-out: expose a `RefreshingCqlSessionObservabilityProperties` bean + +The library follows the "app-owned properties bean" convention - +`com.nvidia.boot.cassandra.configuration.RefreshingCqlSessionObservabilityProperties`: + +- **Bean absent** (default): treated as if `enabled=true`. `cqlSession` is wrapped with observability. +- **Bean present with `enabled=true`** (class default): same as bean absent. Library wraps. +- **Bean present with `enabled=false`**: library returns an unwrapped `CqlSession` so the app's + own wrap is the only observation layer active. + +```java +@Configuration +class CqlSessionObservabilityConfig { + + @Bean + RefreshingCqlSessionObservabilityProperties refreshingCqlSessionObservabilityProperties() { + var properties = new RefreshingCqlSessionObservabilityProperties(); + properties.setEnabled(false); + return properties; + } + + @Bean + CqlSessionBuilderCustomizer observationRequestTrackerCustomizer() { + return builder -> builder.addRequestTracker(ObservationRequestTracker.INSTANCE); + } + + @Bean + ObservableReactiveSessionFactoryBean observableReactiveSession( + CqlSession cqlSession, + ObservationRegistry observationRegistry) { + return new ObservableReactiveSessionFactoryBean(cqlSession, observationRegistry); + } + + // Recommended to suppress the metrics from the observation. These metrics are not useful as spring data + driver metrics are likely sufficient + @Bean + MeterFilter suppressCassandraObservationMetrics() { + Set suppressed = Set.of( + "execute", "execute.active", "execute.cassandra.node.success", + "prepare", "prepare.active"); + return MeterFilter.deny(id -> suppressed.contains(id.getName())); + } +} +``` + +### Long-term + +This opt-out is a **short-term** fix. + +The longer-term goal is to make the reactive and sync observation layers coexist on the same `CqlSession` without producing duplicate observations. + +This requires: +1. Remove `ObservationRegistry` from `RefreshingCqlSession` -> internal `delegate` is always the raw driver session +2. Make `RefreshingCqlSession` a non-primary bean under a qualified name (e.g. `refreshingCassandraSession`) +3. Keep `cassandraSession` as the `@Primary @Bean` name (preserving today's by-type and by-name injection behavior), but explicitly wrap `@Qualifier("refreshingCassandraSession")` `ObservableCqlSessionFactory.wrap(refreshingCassandraSession, registry)` +4. Add `ObservableReactiveSessionFactoryBean` bean that explicitly takes `@Qualifier("refreshingCassandraSession")` (not the `@Primary` `cassandraSession`) +5. Manage every edge case creating still creating `CqlSession` bean on every condition combination for backwards compatibility: + 1. `@ConditionalOnBean(ObservationRegistry.class)` on the sync wrap and the reactive factory bean - still create `cassandraSession` somehow + 2. `@ConditionalOnClass(ReactiveSession.class)` on the reactive factory bean + 3. When `CassandraSslBundleConfiguration` does not load (no `spring.cassandra.ssl.bundle` and + `spring.cassandra.ssl.enabled` not explicitly `false`): no `refreshingCassandraSession` -> need + to still do the same with `ObservableReactiveSessionFactoryBean` and `ObservableCqlSessionFactory` somehow +6. Add `@Autowired(required = false) CassandraObservationConvention` to plug-in conventions for both `ObservableReactiveSessionFactoryBean` and `ObservableCqlSessionFactory` + +## Auto configured beans + +`CassandraAutoConfiguration` imports the following `@Configuration` classes (this order): + +`CassandraConverterConfiguration` → `CassandraAdvancedConfiguration` → +`CassandraHealthConfiguration` → `CassandraMetricsConfiguration` → +`CassandraSslBundleConfiguration`. + +### CassandraConverterConfiguration + +| Bean | Type | Description | +|------|------|-------------| +| `cassandraCustomConversions` | `CassandraCustomConversions` | Registers converters for `CqlDuration` ↔ `java.time.Duration` for Spring Data Cassandra entity mapping. | + +### CassandraAdvancedConfiguration + +| Bean | Type | Description | +|------|------|-------------| +| `advancedProperties` | `DriverConfigLoaderBuilderCustomizer` (`@Primary`) | Binds `spring.cassandra.advanced.*` properties to the DataStax Java Driver. | + +### CassandraHealthConfiguration + +| Bean | Type | Description | +|------|------|-------------| +| `cassandraHealthContributor` | `HealthContributor` | Registers a Cassandra health indicator for each `CqlSession` bean. Exposed at `/actuator/health` when Spring Boot Actuator is on the classpath. Uses `NextHostRetryPolicy` for the health check query so failures fail over to the next host. | + +### CassandraMetricsConfiguration + +| Bean | Type | Description | +|------|------|-------------| +| `configLoaderBuilderCustomizer` | `DriverConfigLoaderBuilderCustomizer` | Registers Micrometer as the driver metrics factory, `TaggingMetricIdGenerator`, and the enabled session/node metric lists from `spring.cassandra.session-metrics` and `spring.cassandra.node-metrics`. **Only created when at least one of those lists is non-empty** (see [Driver metrics](#driver-metrics)). | + +### CassandraSslBundleConfiguration + +Loaded when **`spring.cassandra.ssl.bundle`** is set (any value, including empty for bundle name +only) **or** when **`spring.cassandra.ssl.enabled=false`** (plaintext / local Cassandra without +setting a dummy `spring.cassandra.ssl.bundle=` property). + +| Bean | Type | Description | +|------|------|-------------| +| `cassandraSslCustomizer` | `CqlSessionBuilderCustomizer` | Configures SSL context from the named Spring Boot SSL bundle when the bundle name is non-empty; otherwise leaves the builder without client TLS (plaintext). | +| `cassandraObservationRequestTrackerCustomizer` | `CqlSessionBuilderCustomizer` | Registers `ObservationRequestTracker` on the `CqlSessionBuilder` (present when `ObservationRequestTracker` is on the classpath). | +| `cassandraSession` | `CqlSession` (`@Primary`) | `RefreshingCqlSession` that refreshes on SSL bundle updates and credential changes. Wrapped with `ObservableCqlSessionFactory` when an `ObservationRegistry` is present; apps can opt out by exposing a `RefreshingCqlSessionObservabilityProperties` bean with `enabled=false`. See [Observability](#observability-cql-tracing-spans). | + +Apps can inject these beans and use them as shown below: + +```java +@Service +@RequiredArgsConstructor +public class MyCassandraService { + + private final CqlSession cassandraSession; + + public void query() { + var result = cassandraSession.execute("SELECT * FROM keyspace.table"); + // ... + } +} +``` diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/pom.xml b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/pom.xml new file mode 100644 index 0000000000..43d1081714 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/pom.xml @@ -0,0 +1,109 @@ + + + + + 4.0.0 + + com.nvidia.boot + nv-boot-parent + 0.0.1-SNAPSHOT + + + nv-boot-starter-cassandra + jar + NV Boot Starter Cassandra + Library to handle advanced properties and refreshable CqlSession + + + 1.10.3 + + + + + org.springframework.boot + spring-boot-starter-data-cassandra + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.cloud + spring-cloud-context + + + org.apache.cassandra + java-driver-metrics-micrometer + + + at.yawk.lz4 + lz4-java + ${lz4-java.version} + + + org.apache.commons + commons-lang3 + + + org.projectlombok + lombok + true + + + com.github.ben-manes.caffeine + guava + + + jakarta.annotation + jakarta.annotation-api + + + jakarta.validation + jakarta.validation-api + + + + + org.springframework.boot + spring-boot-starter-data-cassandra-test + test + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + org.springframework.boot + spring-boot-starter-actuator-test + test + + + org.testcontainers + testcontainers-cassandra + test + + + org.testcontainers + testcontainers-junit-jupiter + test + + + \ No newline at end of file diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/main/java/com/nvidia/boot/cassandra/CassandraAutoConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/main/java/com/nvidia/boot/cassandra/CassandraAutoConfiguration.java new file mode 100644 index 0000000000..9d668a9e68 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/main/java/com/nvidia/boot/cassandra/CassandraAutoConfiguration.java @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.cassandra; + +import com.nvidia.boot.cassandra.configuration.CassandraAdvancedConfiguration; +import com.nvidia.boot.cassandra.configuration.CassandraConverterConfiguration; +import com.nvidia.boot.cassandra.configuration.CassandraHealthConfiguration; +import com.nvidia.boot.cassandra.configuration.CassandraMetricsConfiguration; +import com.nvidia.boot.cassandra.configuration.CassandraSslBundleConfiguration; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Import; + +/** + * Auto-configuration for NV Boot Cassandra: custom converters, advanced properties, + * health indicator, and SSL bundle support with refreshable CqlSession. + */ +@AutoConfiguration +@Import({ + CassandraConverterConfiguration.class, + CassandraAdvancedConfiguration.class, + CassandraHealthConfiguration.class, + CassandraMetricsConfiguration.class, + CassandraSslBundleConfiguration.class +}) +public class CassandraAutoConfiguration { +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/main/java/com/nvidia/boot/cassandra/configuration/CassandraAdvancedConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/main/java/com/nvidia/boot/cassandra/configuration/CassandraAdvancedConfiguration.java new file mode 100644 index 0000000000..ab390cfa1a --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/main/java/com/nvidia/boot/cassandra/configuration/CassandraAdvancedConfiguration.java @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.cassandra.configuration; + +import com.datastax.oss.driver.api.core.config.TypedDriverOption; +import com.datastax.oss.driver.api.core.type.reflect.GenericType; +import java.time.Duration; +import java.util.Arrays; +import org.apache.commons.lang3.StringUtils; +import org.springframework.boot.cassandra.autoconfigure.DriverConfigLoaderBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.core.env.Environment; + +/** + * Generic configuration that applies spring.cassandra.advanced.* properties to the DataStax + * Java Driver. Iterates over all driver options with path starting "advanced." and binds + * spring.cassandra.<path> from the Environment. + */ +@Configuration +public class CassandraAdvancedConfiguration { + + @Bean + @Primary + public DriverConfigLoaderBuilderCustomizer advancedProperties(Environment environment) { + return customizer -> TypedDriverOption.builtInValues().forEach(option -> { + var rawOption = option.getRawOption(); + var path = rawOption.getPath(); + if (path.startsWith("advanced.")) { + var property = environment.getProperty("spring.cassandra." + path); + if (StringUtils.isNotBlank(property)) { + GenericType expectedType = option.getExpectedType(); + if (GenericType.BOOLEAN.equals(expectedType)) { + customizer.withBoolean(rawOption, Boolean.parseBoolean(property)); + } else if (GenericType.INTEGER.equals(expectedType)) { + customizer.withInt(rawOption, Integer.parseInt(property)); + } else if (GenericType.LONG.equals(expectedType)) { + customizer.withLong(rawOption, Long.parseLong(property)); + } else if (GenericType.STRING.equals(expectedType)) { + customizer.withString(rawOption, property); + } else if (GenericType.listOf(String.class).equals(expectedType)) { + customizer.withStringList(rawOption, Arrays.asList(property.split(","))); + } else if (GenericType.DURATION.equals(expectedType)) { + customizer.withDuration(rawOption, + Duration.ofMillis(Long.parseLong(property))); + } else { + throw new IllegalArgumentException( + String.format("property type (%s) for property %s is unsupported", + expectedType, path)); + } + } + } + }); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/main/java/com/nvidia/boot/cassandra/configuration/CassandraConverterConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/main/java/com/nvidia/boot/cassandra/configuration/CassandraConverterConfiguration.java new file mode 100644 index 0000000000..fa0186caa9 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/main/java/com/nvidia/boot/cassandra/configuration/CassandraConverterConfiguration.java @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.cassandra.configuration; + +import com.datastax.oss.driver.api.core.data.CqlDuration; +import java.time.Duration; +import java.util.List; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.cassandra.core.convert.CassandraCustomConversions; +import org.springframework.data.convert.ReadingConverter; +import org.springframework.data.convert.WritingConverter; + +@Configuration +public class CassandraConverterConfiguration { + + @ReadingConverter + private static class CqlDurationToDurationConverter + implements Converter { + @Override + public Duration convert(CqlDuration source) { + return Duration + .ofNanos(source.getNanoseconds()) + .plusDays(source.getDays()); + } + } + + @WritingConverter + private static class DurationToCqlDurationConverter + implements Converter { + @Override + public CqlDuration convert(Duration source) { + return CqlDuration.newInstance(0, 0, source.toNanos()); + } + } + + @Bean + public CassandraCustomConversions cassandraCustomConversions() { + return new CassandraCustomConversions(List.of(new DurationToCqlDurationConverter(), + new CqlDurationToDurationConverter())); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/main/java/com/nvidia/boot/cassandra/configuration/CassandraHealthConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/main/java/com/nvidia/boot/cassandra/configuration/CassandraHealthConfiguration.java new file mode 100644 index 0000000000..01e1f12b38 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/main/java/com/nvidia/boot/cassandra/configuration/CassandraHealthConfiguration.java @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.cassandra.configuration; + +import static com.datastax.oss.driver.api.core.config.DefaultDriverOption.RETRY_POLICY_CLASS; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.cql.SimpleStatement; +import com.nvidia.boot.cassandra.retry.NextHostRetryPolicy; +import java.util.Map; +import org.springframework.boot.health.autoconfigure.contributor.CompositeHealthContributorConfiguration; +import org.springframework.boot.cassandra.health.CassandraDriverHealthIndicator; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.HealthContributor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class CassandraHealthConfiguration extends + CompositeHealthContributorConfiguration { + + CassandraHealthConfiguration() { + super(ActiveCassandraDriverHealthIndicator::new); + } + + private static class ActiveCassandraDriverHealthIndicator extends + CassandraDriverHealthIndicator { + + private final CqlSession session; + private final SimpleStatement healthQuery; + + public ActiveCassandraDriverHealthIndicator(CqlSession session) { + super(session); + this.session = session; + var defaultProfile = session.getContext().getConfig().getDefaultProfile(); + var executionProfile = defaultProfile.withClass(RETRY_POLICY_CLASS, + NextHostRetryPolicy.class); + this.healthQuery = SimpleStatement.builder("SELECT release_version FROM system.local") + .setExecutionProfile(executionProfile) + .build(); + } + + @Override + protected void doHealthCheck(Health.Builder builder) throws Exception { + session.execute(healthQuery); + super.doHealthCheck(builder); + } + } + + @Bean + HealthContributor cassandraHealthContributor(Map sessions) { + return createContributor(sessions); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/main/java/com/nvidia/boot/cassandra/configuration/CassandraMetricsConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/main/java/com/nvidia/boot/cassandra/configuration/CassandraMetricsConfiguration.java new file mode 100644 index 0000000000..273d6ac930 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/main/java/com/nvidia/boot/cassandra/configuration/CassandraMetricsConfiguration.java @@ -0,0 +1,118 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.cassandra.configuration; + +import static com.datastax.oss.driver.api.core.config.DefaultDriverOption.METRICS_FACTORY_CLASS; +import static com.datastax.oss.driver.api.core.config.DefaultDriverOption.METRICS_ID_GENERATOR_CLASS; +import static com.datastax.oss.driver.api.core.config.DefaultDriverOption.METRICS_NODE_ENABLED; +import static com.datastax.oss.driver.api.core.config.DefaultDriverOption.METRICS_SESSION_ENABLED; + +import java.util.ArrayList; +import java.util.List; +import lombok.Data; +import org.jspecify.annotations.NonNull; +import org.springframework.boot.cassandra.autoconfigure.DriverConfigLoaderBuilderCustomizer; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.context.properties.bind.BindResult; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Condition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.type.AnnotatedTypeMetadata; + +// Nested types must be referenced with an enclosing name in annotations. This is required +// by javac forward-reference rules. +@Conditional(CassandraMetricsConfiguration.CassandraMetricsEnabledCondition.class) +@EnableConfigurationProperties(CassandraMetricsConfiguration.CassandraMetricsProperties.class) +@Configuration +public class CassandraMetricsConfiguration { + + @Bean + DriverConfigLoaderBuilderCustomizer configLoaderBuilderCustomizer( + CassandraMetricsProperties cassandraProperties) { + return builder -> { + builder.withString( + METRICS_FACTORY_CLASS, + "com.datastax.oss.driver.internal.metrics.micrometer.MicrometerMetricsFactory"); + // Use tagging id generator so host:port is a Micrometer tag (node=...) instead of part + // of the meter name. DefaultMetricIdGenerator embeds ":" in names + // (e.g. ...nodes.10_0_0_1:9042...), which OpenTelemetry rejects as an instrument name + // when Micrometer is bridged to OTel. + builder.withString( + METRICS_ID_GENERATOR_CLASS, + "com.datastax.oss.driver.internal.core.metrics.TaggingMetricIdGenerator"); + builder.withStringList(METRICS_SESSION_ENABLED, + cassandraProperties.getSessionMetrics()); + builder.withStringList(METRICS_NODE_ENABLED, + cassandraProperties.getNodeMetrics()); + }; + } + + /** + * Binds optional {@code spring.cassandra.session-metrics} and + * {@code spring.cassandra.node-metrics}. Micrometer driver metrics are configured only when + * at least one of these lists is non-empty; if a key is present with an empty list, the + * metrics customizer is not applied. + */ + @ConfigurationProperties(prefix = "spring.cassandra") + @Data + public static class CassandraMetricsProperties { + + private List sessionMetrics = new ArrayList<>(); + + private List nodeMetrics = new ArrayList<>(); + } + + /** + * Enables Micrometer Cassandra driver metrics configuration only when the application has set + * at least one metric name under {@code spring.cassandra.session-metrics} or + * {@code spring.cassandra.node-metrics}. + * + *

If a key is omitted, the customizer is not registered. If a key is present with an empty + * list (e.g. {@code session-metrics: []}), the customizer is also not registered—only non-empty + * lists opt in. + */ + public static class CassandraMetricsEnabledCondition implements Condition { + + private static final String SESSION_METRICS_KEY = "spring.cassandra.session-metrics"; + private static final String NODE_METRICS_KEY = "spring.cassandra.node-metrics"; + + @Override + public boolean matches( + @NonNull ConditionContext context, + @NonNull AnnotatedTypeMetadata metadata) { + var binder = Binder.get(context.getEnvironment()); + return hasNonEmptyMetricList(binder, SESSION_METRICS_KEY) + || hasNonEmptyMetricList(binder, NODE_METRICS_KEY); + } + + /** + * @return true only when the property is bound and has at least one element (key absent or + * empty list yields false). + */ + private static boolean hasNonEmptyMetricList(Binder binder, String propertyKey) { + BindResult> bound = binder.bind(propertyKey, + Bindable.listOf(String.class)); + return bound.isBound() && !bound.get().isEmpty(); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/main/java/com/nvidia/boot/cassandra/configuration/CassandraSslBundleConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/main/java/com/nvidia/boot/cassandra/configuration/CassandraSslBundleConfiguration.java new file mode 100644 index 0000000000..c64b75153c --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/main/java/com/nvidia/boot/cassandra/configuration/CassandraSslBundleConfiguration.java @@ -0,0 +1,357 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.cassandra.configuration; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.CqlSessionBuilder; +import com.google.common.annotations.VisibleForTesting; +import io.micrometer.common.util.StringUtils; +import io.micrometer.observation.ObservationRegistry; +import jakarta.annotation.PostConstruct; +import java.util.Objects; +import java.util.concurrent.locks.ReentrantLock; +import lombok.Getter; +import lombok.experimental.Delegate; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.cassandra.autoconfigure.CqlSessionBuilderCustomizer; +import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.ssl.PropertiesSslBundle; +import org.springframework.boot.autoconfigure.ssl.SslProperties; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.cloud.context.scope.refresh.RefreshScopeRefreshedEvent; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.context.event.EventListener; +import org.springframework.core.env.Environment; +import org.springframework.data.cassandra.observability.ObservableCqlSessionFactory; +import org.springframework.data.cassandra.observability.ObservationRequestTracker; + +/** + * Cassandra session wiring for the SSL-bundle / refreshable session path. Active when either + * {@code spring.cassandra.ssl.bundle} is specified (including empty, for PEM bundle name only) or + * when {@code spring.cassandra.ssl.enabled=false} so local plaintext clusters do not need a dummy + * {@code spring.cassandra.ssl.bundle=} property. When a non-empty bundle name is configured, SSL is + * applied on the {@link CqlSessionBuilder}; otherwise the builder is left without client TLS. + *

+ * Also registers a {@code @Primary} {@link #refreshableCqlSession refreshing session} and (when + * Spring Data Cassandra observability types are on the classpath) {@link ObservationRequestTracker} + * on the builder so {@link ObservableCqlSessionFactory} observations start and complete on the driver. + */ +@Slf4j +@Configuration +@Conditional(CassandraSslBundleConfiguration.ActivationConditions.class) +public class CassandraSslBundleConfiguration { + + /** + * Enables this configuration when apps use PEM SSL bundles + * ({@code spring.cassandra.ssl.bundle}) or disable Cassandra TLS explicitly for plaintext + * endpoints ({@code spring.cassandra.ssl.enabled=false}). + */ + static final class ActivationConditions extends AnyNestedCondition { + + ActivationConditions() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnProperty(name = "spring.cassandra.ssl.bundle") + static class OnBundleProperty { + } + + @ConditionalOnProperty(name = "spring.cassandra.ssl.enabled", matchIfMissing = false, + havingValue = "false") + static class OnCassandraSslDisabled { + } + } + + @Value("${spring.cassandra.ssl.bundle:}") + private String sslBundleName; + + /** + * Registers Spring Data Cassandra's {@link ObservationRequestTracker} on the shared + * {@link CqlSessionBuilder} so observations started by {@link ObservableCqlSessionFactory#wrap} + * are stopped when each request finishes. + */ + @Bean + @ConditionalOnClass(name = "org.springframework.data.cassandra.observability.ObservationRequestTracker") + public CqlSessionBuilderCustomizer cassandraObservationRequestTrackerCustomizer() { + return builder -> builder.addRequestTracker(ObservationRequestTracker.INSTANCE); + } + + @Bean + public CqlSessionBuilderCustomizer cassandraSslCustomizer(SslBundles sslBundles) { + return builder -> { + if (!sslBundleName.isEmpty()) { + log.info("Configuring Cassandra SSL with bundle: {}", sslBundleName); + var sslBundle = sslBundles.getBundle(sslBundleName); + var sslContext = sslBundle.createSslContext(); + builder.withSslContext(sslContext); + } + }; + } + + /** + * Creates a {@link RefreshingCqlSession} that validates new sessions before switching, + * handles SSL bundle updates (cert rotation), and credential rotation. + *

+ * When an {@link ObservationRegistry} bean is present (typically from Spring Boot Micrometer + * observation auto-configuration when observation dependencies are on the classpath) and + * {@link RefreshingCqlSessionObservabilityProperties} does not disable wrapping, the session + * is wrapped with {@link ObservableCqlSessionFactory#wrap} so CQL participates in observations. + * {@link #cassandraObservationRequestTrackerCustomizer()} in this same configuration registers + * {@link ObservationRequestTracker} on the builder so those observations complete. + *

+ * Apps that need to instrument observability manually (e.g. {@code ObservableReactiveSessionFactoryBean} + * in mutually exclusive) can expose a {@link RefreshingCqlSessionObservabilityProperties} bean with + * {@code enabled=false} to opt out and avoid duplicate or orphan spans. + */ + @Bean("cassandraSession") + @Primary + public RefreshingCqlSession refreshableCqlSession( + ApplicationContext applicationContext, + SslBundles sslBundles, + Environment environment, + @Autowired(required = false) SslProperties sslProperties, + CqlSessionBuilder cqlSessionBuilder, + @Autowired(required = false) ObservationRegistry observationRegistry, + @Autowired(required = false) RefreshingCqlSessionObservabilityProperties observationProperties) { + log.info("Creating RefreshingCqlSession with SSL bundle and credentials"); + var shouldWrap = observationProperties == null || observationProperties.isEnabled(); + var actualObservationRegistry = shouldWrap ? observationRegistry : null; + + var initialSession = cqlSessionBuilder.build(); + var sessionToUse = wrapWithObservability(initialSession, actualObservationRegistry); + return new RefreshingCqlSession(applicationContext, sslBundles, environment, sslProperties, + sslBundleName, sessionToUse, actualObservationRegistry); + } + + private static CqlSession wrapWithObservability( + CqlSession session, + ObservationRegistry observationRegistry) { + if (observationRegistry != null) { + return ObservableCqlSessionFactory.wrap(session, observationRegistry); + } + return session; + } + + /** + * CqlSession implementation that refreshes on SSL bundle updates and credential changes. + */ + @Slf4j + public static class RefreshingCqlSession implements CqlSession { + + private static final String SESSION_TEST_QUERY = "SELECT cluster_name FROM system.local"; + + private final ApplicationContext applicationContext; + private final SslBundles sslBundles; + private final Environment environment; + private final SslProperties sslProperties; + private final String sslBundleName; + private final ObservationRegistry observationRegistry; + private final ReentrantLock sessionLock = new ReentrantLock(); + + @Delegate + @Getter(onMethod_ = @VisibleForTesting) + private volatile CqlSession delegate; + + private String lastKnownUsername; + private String lastKnownPassword; + private SslMaterialSnapshot lastKnownSslMaterial; + + RefreshingCqlSession( + ApplicationContext applicationContext, + SslBundles sslBundles, + Environment environment, + SslProperties sslProperties, + String sslBundleName, + CqlSession initialSession, + ObservationRegistry observationRegistry) { + this.applicationContext = applicationContext; + this.sslBundles = sslBundles; + this.environment = environment; + this.sslProperties = sslProperties; + this.sslBundleName = sslBundleName; + this.observationRegistry = observationRegistry; + this.delegate = initialSession; + this.lastKnownUsername = environment.getProperty("spring.cassandra.username"); + this.lastKnownPassword = environment.getProperty("spring.cassandra.password"); + this.lastKnownSslMaterial = SslMaterialSnapshot.from(environment, sslBundleName); + } + + @PostConstruct + public void registerSslBundleUpdateHandler() { + if (sslBundleName != null && !sslBundleName.isEmpty()) { + sslBundles.addBundleUpdateHandler(sslBundleName, this::handleSslBundleUpdate); + log.info("Registered SSL bundle update handler for RefreshingCqlSession"); + } + } + + @EventListener(RefreshScopeRefreshedEvent.class) + public void handleRefreshComplete(RefreshScopeRefreshedEvent event) { + sessionLock.lock(); + try { + var username = environment.getProperty("spring.cassandra.username"); + var password = environment.getProperty("spring.cassandra.password"); + + if (!hasConnectionMaterialChanged(username, password)) { + log.info("Cassandra creds and SSL bundle material unchanged - no refresh needed"); + return; + } + + log.info("Cassandra creds or SSL bundle material changed - rebuilding session"); + var oldSession = this.delegate; + try { + var builder = applicationContext.getBean(CqlSessionBuilder.class); + var currentSslSnapshot = SslMaterialSnapshot.from(environment, sslBundleName); + var sslMaterialChanged = !StringUtils.isBlank(sslBundleName) + && !Objects.equals(lastKnownSslMaterial, currentSslSnapshot); + CqlSession newSession; + if (sslMaterialChanged && sslProperties != null) { + var pem = sslProperties.getBundle().getPem().get(sslBundleName); + if (pem != null) { + var freshBundle = PropertiesSslBundle.get(pem, applicationContext); + var sslContext = freshBundle.createSslContext(); + var sslSessionBuilder = builder.withSslContext(sslContext); + newSession = wrapWithObservability(sslSessionBuilder.build()); + } else { + log.warn("SSL PEM material changed in Environment but bundle '{}' is " + + "missing from SslProperties; rebuilding with " + + "CqlSessionBuilder defaults (registered SslBundles " + + "may still reflect old material)", + sslBundleName); + newSession = wrapWithObservability(builder.build()); + } + } else { + if (sslMaterialChanged && sslProperties == null) { + log.warn("SSL PEM material changed but SslProperties bean is not" + + "available; rebuilding with CqlSessionBuilder " + + "defaults (SslBundles may be stale)"); + } + newSession = wrapWithObservability(builder.build()); + } + if (isSessionValid(newSession)) { + log.info("Session validation successful - switching to new session"); + this.delegate = newSession; + lastKnownUsername = username; + lastKnownPassword = password; + lastKnownSslMaterial = SslMaterialSnapshot.from(environment, sslBundleName); + oldSession.close(); + } else { + log.error("New session validation failed - keeping old session"); + newSession.close(); + } + } catch (Exception e) { + log.error("Failed to build new session - keeping old session", e); + } + } finally { + sessionLock.unlock(); + } + } + + private CqlSession wrapWithObservability(CqlSession session) { + return CassandraSslBundleConfiguration.wrapWithObservability(session, + observationRegistry); + } + + /** + * Detects username/password or PEM bundle property changes under + * {@code spring.ssl.bundle.pem..*} (keystore cert/key, truststore cert), + * by comparing them to their corresponding last known values. + */ + private boolean hasConnectionMaterialChanged(String username, String password) { + if (hasCredentialsChanged(username, password)) { + return true; + } + if (StringUtils.isBlank(sslBundleName)) { + return false; + } + var currentSsl = SslMaterialSnapshot.from(environment, sslBundleName); + return !Objects.equals(lastKnownSslMaterial, currentSsl); + } + + private boolean hasCredentialsChanged(String username, String password) { + return !Objects.equals(username, lastKnownUsername) + || !Objects.equals(password, lastKnownPassword); + } + + private record SslMaterialSnapshot( + String keystoreCertificate, + String keystorePrivateKey, + String truststoreCertificate) { + + static SslMaterialSnapshot from(Environment environment, String bundleName) { + if (bundleName == null || bundleName.isEmpty()) { + return null; + } + var prefix = "spring.ssl.bundle.pem." + bundleName + "."; + return new SslMaterialSnapshot( + environment.getProperty(prefix + "keystore.certificate"), + environment.getProperty(prefix + "keystore.private-key"), + environment.getProperty(prefix + "truststore.certificate")); + } + } + + /* + * This get invoked by Spring when reload-on-update is true and when Spring detects + * changes to the files specified under SSL bundle. + */ + @VisibleForTesting + void handleSslBundleUpdate(SslBundle updatedBundle) { + sessionLock.lock(); + try { + log.info("SSL bundle updated - rebuilding Cassandra session"); + var oldSession = this.delegate; + try { + var sslContext = updatedBundle.createSslContext(); + var builder = applicationContext.getBean(CqlSessionBuilder.class); + var newSession = wrapWithObservability(builder.withSslContext(sslContext) + .build()); + if (isSessionValid(newSession)) { + log.info("Session validation successful - switching to new session"); + this.delegate = newSession; + oldSession.close(); + } else { + log.error("New session validation failed - keeping old session"); + newSession.close(); + } + } catch (Exception e) { + log.error("Failed to build new session - keeping old session", e); + } + } finally { + sessionLock.unlock(); + } + } + + private boolean isSessionValid(CqlSession session) { + try { + var resultSet = session.execute(SESSION_TEST_QUERY); + return resultSet.one() != null; + } catch (Exception e) { + log.error("Session validation failed", e); + return false; + } + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/main/java/com/nvidia/boot/cassandra/configuration/RefreshingCqlSessionObservabilityProperties.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/main/java/com/nvidia/boot/cassandra/configuration/RefreshingCqlSessionObservabilityProperties.java new file mode 100644 index 0000000000..c2d74e0c66 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/main/java/com/nvidia/boot/cassandra/configuration/RefreshingCqlSessionObservabilityProperties.java @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.cassandra.configuration; + +import io.micrometer.observation.ObservationRegistry; +import lombok.Data; +import org.springframework.data.cassandra.observability.ObservableCqlSessionFactory; + +/** + * Gates the {@link ObservableCqlSessionFactory#wrap} applied by the cassandra-starter-owned + * {@code cassandraSession} bean (the {@code RefreshingCqlSession} on the SSL-bundle path). + *

+ * By default, library wraps whenever an {@link ObservationRegistry} is available. Apps + * that want to instrument observability manually (e.g. {@code ObservableReactiveSessionFactoryBean}) + * should opt out to avoid duplicate + orphan spans. + *

+ * {@code
+ * @Configuration
+ * class CassandraObservabilityConfig {
+ *
+ *     @Bean
+ *     RefreshingCqlSessionObservabilityProperties refreshingCqlSessionObservabilityProperties() {
+ *         var props = new RefreshingCqlSessionObservabilityProperties();
+ *         props.setEnabled(false);
+ *         return props;
+ *     }
+ * }
+ * 
+ */ +@Data +public class RefreshingCqlSessionObservabilityProperties { + + private boolean enabled = true; +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/main/java/com/nvidia/boot/cassandra/retry/NextHostRetryPolicy.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/main/java/com/nvidia/boot/cassandra/retry/NextHostRetryPolicy.java new file mode 100644 index 0000000000..9b42707401 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/main/java/com/nvidia/boot/cassandra/retry/NextHostRetryPolicy.java @@ -0,0 +1,188 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.cassandra.retry; + +import com.datastax.oss.driver.api.core.ConsistencyLevel; +import com.datastax.oss.driver.api.core.connection.ClosedConnectionException; +import com.datastax.oss.driver.api.core.connection.HeartbeatException; +import com.datastax.oss.driver.api.core.context.DriverContext; +import com.datastax.oss.driver.api.core.retry.RetryDecision; +import com.datastax.oss.driver.api.core.retry.RetryPolicy; +import com.datastax.oss.driver.api.core.retry.RetryVerdict; +import com.datastax.oss.driver.api.core.servererrors.CoordinatorException; +import com.datastax.oss.driver.api.core.servererrors.ReadFailureException; +import com.datastax.oss.driver.api.core.servererrors.WriteFailureException; +import com.datastax.oss.driver.api.core.servererrors.WriteType; +import com.datastax.oss.driver.api.core.session.Request; +import lombok.extern.slf4j.Slf4j; + +/** + * Retry policy that retries on the next host for transient failures (connection/heartbeat, + * read timeout without data, write timeout, unavailable). Rethrows for ReadFailure, WriteFailure, + * and after one retry attempt. + */ +@Slf4j +public class NextHostRetryPolicy implements RetryPolicy { + + static final String VERDICT_ON_READ_TIMEOUT = "[{}] Verdict on read " + + "timeout (consistency: {}, required responses: {}, received responses: {}, " + + "data retrieved: {}, retries: {}): {}"; + static final String VERDICT_ON_WRITE_TIMEOUT = "[{}] Verdict on write " + + "timeout (consistency: {}, write type: {}, required acknowledgments: {}," + + " received acknowledgments: {}, retries: {}): {}"; + static final String VERDICT_ON_UNAVAILABLE = "[{}] Verdict on unavailable exception " + + "(consistency: {}, required replica: {}, alive replica: {}, " + + "retries: {}): {}"; + static final String VERDICT_ON_ABORTED = "[{}] Verdict on aborted request (type: {}, " + + "message: '{}', retries: {}): {}"; + static final String VERDICT_ON_ERROR = "[{}] Verdict on node error (type: {}, " + + "message: '{}', retries: {}): {}"; + + private final String logPrefix; + + public NextHostRetryPolicy(DriverContext context, String profileName) { + var sessionName = context != null ? context.getSessionName() : "No Context"; + this.logPrefix = sessionName + "|" + profileName; + } + + @Override + public RetryVerdict onReadTimeoutVerdict( + Request request, + ConsistencyLevel cl, + int blockFor, + int received, + boolean dataPresent, + int retryCount) { + var verdict = retryCount == 0 && !dataPresent ? + RetryVerdict.RETRY_NEXT : RetryVerdict.RETHROW; + log.info(VERDICT_ON_READ_TIMEOUT, logPrefix, cl, blockFor, received, dataPresent, + retryCount, verdict); + return verdict; + } + + @Override + public RetryVerdict onWriteTimeoutVerdict( + Request request, + ConsistencyLevel cl, + WriteType writeType, + int blockFor, + int received, + int retryCount) { + var verdict = retryCount == 0 ? RetryVerdict.RETRY_NEXT : RetryVerdict.RETHROW; + log.info(VERDICT_ON_WRITE_TIMEOUT, logPrefix, cl, writeType, blockFor, received, + retryCount, verdict); + return verdict; + } + + @Override + public RetryVerdict onUnavailableVerdict( + Request request, + ConsistencyLevel cl, + int required, + int alive, + int retryCount) { + var verdict = retryCount == 0 ? RetryVerdict.RETRY_NEXT : RetryVerdict.RETHROW; + log.info(VERDICT_ON_UNAVAILABLE, logPrefix, cl, required, alive, retryCount, verdict); + return verdict; + } + + @Override + public RetryVerdict onRequestAbortedVerdict( + Request request, + Throwable error, + int retryCount) { + var verdict = error instanceof ClosedConnectionException + || error instanceof HeartbeatException + ? RetryVerdict.RETRY_NEXT + : RetryVerdict.RETHROW; + log.info(VERDICT_ON_ABORTED, logPrefix, error.getClass().getSimpleName(), + error.getMessage(), retryCount, + verdict); + return verdict; + } + + @Override + public RetryVerdict onErrorResponseVerdict( + Request request, + CoordinatorException error, + int retryCount) { + var verdict = + error instanceof WriteFailureException || error instanceof ReadFailureException + ? RetryVerdict.RETHROW + : RetryVerdict.RETRY_NEXT; + log.info(VERDICT_ON_ERROR, logPrefix, error.getClass().getSimpleName(), + error.getMessage(), retryCount, + verdict); + return verdict; + } + + @Override + @Deprecated(forRemoval = true) + public RetryDecision onReadTimeout( + Request request, + ConsistencyLevel cl, + int blockFor, + int received, + boolean dataPresent, + int retryCount) { + throw new UnsupportedOperationException("onReadTimeout"); + } + + @Override + @Deprecated(forRemoval = true) + public RetryDecision onWriteTimeout( + Request request, + ConsistencyLevel cl, + WriteType writeType, + int blockFor, + int received, + int retryCount) { + throw new UnsupportedOperationException("onWriteTimeout"); + } + + @Override + @Deprecated(forRemoval = true) + public RetryDecision onUnavailable( + Request request, + ConsistencyLevel cl, + int required, + int alive, + int retryCount) { + throw new UnsupportedOperationException("onUnavailable"); + } + + @Override + @Deprecated(forRemoval = true) + public RetryDecision onRequestAborted(Request request, Throwable error, int retryCount) { + throw new UnsupportedOperationException("onRequestAborted"); + } + + @Override + @Deprecated(forRemoval = true) + public RetryDecision onErrorResponse( + Request request, + CoordinatorException error, + int retryCount) { + throw new UnsupportedOperationException("onErrorResponse"); + } + + @Override + public void close() { + // Nothing to specifically clean up. + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000000..8a5c2ea481 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +com.nvidia.boot.cassandra.CassandraAutoConfiguration diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/NvBootCassandraIntegrationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/NvBootCassandraIntegrationTest.java new file mode 100644 index 0000000000..35dd667f52 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/NvBootCassandraIntegrationTest.java @@ -0,0 +1,296 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.cassandra; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.cql.ResultSet; +import com.datastax.oss.driver.api.core.cql.SimpleStatement; +import com.nvidia.boot.cassandra.configuration.CassandraSslBundleConfiguration; +import io.micrometer.core.instrument.LongTaskTimer; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.composite.CompositeMeterRegistry; +import io.micrometer.observation.ObservationRegistry; +import java.time.Duration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.aop.support.AopUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.cassandra.autoconfigure.CqlSessionBuilderCustomizer; +import org.springframework.boot.cassandra.autoconfigure.DriverConfigLoaderBuilderCustomizer; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.data.cassandra.core.CassandraTemplate; +import org.springframework.data.cassandra.core.mapping.PrimaryKey; +import org.springframework.data.cassandra.core.mapping.Table; +import org.springframework.http.MediaType; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.testcontainers.cassandra.CassandraContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +/** + * Module-level Cassandra integration tests. {@code spring.cassandra.session-metrics} and + * {@code spring.cassandra.node-metrics} are set so {@code CassandraMetricsConfiguration} is active + * and driver Micrometer metrics wiring is covered together with a live Cassandra node. + *

+ * {@code spring.cassandra.ssl.enabled=false} activates {@code CassandraSslBundleConfiguration} + * (refreshing {@code cassandraSession} with {@code ObservationRequestTracker} on the builder) without + * setting {@code spring.cassandra.ssl.bundle} or any {@code spring.ssl.bundle.pem.*} placeholder. + */ +@SpringBootTest(classes = CassandraAutoConfiguration.class, properties = { + "management.endpoints.web.exposure.include=health", + "management.endpoint.health.show-details=always" +}) +@EnableAutoConfiguration +@AutoConfigureMockMvc +@Testcontainers +class NvBootCassandraIntegrationTest { + + private static final String KEYSPACE = "converter_test"; + private static final String TABLE = "duration_entity"; + + @Container + static final CassandraContainer CASSANDRA = new CassandraContainer("cassandra:5"); + + @DynamicPropertySource + static void cassandraProperties(DynamicPropertyRegistry registry) { + var contactPoint = CASSANDRA.getContactPoint(); + registry.add("spring.cassandra.contact-points", () -> contactPoint.getHostString()); + registry.add("spring.cassandra.port", () -> String.valueOf(contactPoint.getPort())); + registry.add("spring.cassandra.local-datacenter", CASSANDRA::getLocalDatacenter); + registry.add("spring.cassandra.keyspace-name", () -> KEYSPACE); + registry.add("spring.cassandra.ssl.enabled", () -> "false"); + registry.add("spring.cassandra.session-metrics[0]", () -> "connected-nodes"); + registry.add("spring.cassandra.node-metrics[0]", () -> "pool.in-flight"); + } + + @Autowired + ApplicationContext applicationContext; + + @Autowired + CqlSession cqlSession; + + @Autowired + CassandraTemplate cassandraTemplate; + + @Autowired + MockMvc mockMvc; + + @BeforeAll + static void createSchema() { + try (var session = CqlSession.builder() + .addContactPoint(CASSANDRA.getContactPoint()) + .withLocalDatacenter(CASSANDRA.getLocalDatacenter()) + .build()) { + + session.execute("CREATE KEYSPACE IF NOT EXISTS " + KEYSPACE + + " WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1}"); + session.execute("USE " + KEYSPACE); + session.execute("CREATE TABLE IF NOT EXISTS " + TABLE + + " (id text PRIMARY KEY, timeout duration)"); + } + } + + @Test + void cqlSessionConnectsAndExecutesQuery() { + assertThat(applicationContext.getBean("cassandraSession")) + .isInstanceOf(CassandraSslBundleConfiguration.RefreshingCqlSession.class); + assertThat(cqlSession).isNotNull(); + + ResultSet resultSet = cqlSession.execute("SELECT cluster_name FROM system.local"); + assertThat(resultSet.one()).isNotNull(); + } + + @Test + void sslDisabledWithoutBundleCqlSessionBuilderCustomizerBeansRegistered() { + assertThat(applicationContext.getBeanNamesForType(CqlSessionBuilderCustomizer.class)) + .isNotEmpty(); + } + + /** + * Exercises Spring Data Cassandra observations on the refreshable {@code cassandraSession} from + * {@link CassandraSslBundleConfiguration} ({@link org.springframework.data.cassandra.observability.ObservableCqlSessionFactory} + * wrap + {@code ObservationRequestTracker} on the builder). This integration test uses plaintext + * Cassandra ({@code spring.cassandra.ssl.enabled=false}). + * After each synchronous {@code execute}, the {@link ObservationRegistry} must not retain a + * thread-local current observation or scope, and no {@link LongTaskTimer} whose name is wired + * from Spring Data Cassandra observations should retain active tasks. + */ + @Test + void refreshingSessionObservationsCompleteWithoutLongTaskTimerLeak() { + assertThat(applicationContext.getBean("cassandraSession")) + .isInstanceOf(CassandraSslBundleConfiguration.RefreshingCqlSession.class); + var refreshing = applicationContext.getBean( + "cassandraSession", CassandraSslBundleConfiguration.RefreshingCqlSession.class); + assertThat(AopUtils.isAopProxy(refreshing.getDelegate())) + .as("Delegate should be ObservableCqlSessionFactory AOP proxy when ObservationRegistry is present") + .isTrue(); + + var observationRegistry = applicationContext.getBean(ObservationRegistry.class); + assertThat(observationRegistry.isNoop()) + .as("Integration uses a real ObservationRegistry from actuator auto-configuration") + .isFalse(); + + var meterRegistry = applicationContext.getBean(MeterRegistry.class); + + for (int i = 0; i < 25; i++) { + cqlSession.execute( + SimpleStatement.newInstance("SELECT release_version FROM system.local")); + assertThat(observationRegistry.getCurrentObservation()) + .as("Observation scope should not leak on the test thread after execute %s", i) + .isNull(); + assertThat(observationRegistry.getCurrentObservationScope()) + .as("Observation scope handle should be cleared after execute %s", i) + .isNull(); + assertThat(activeLongTaskTimerTasksForObservationNames(meterRegistry)) + .as("No observation-backed LongTaskTimer leak after execute %s", i) + .isZero(); + } + } + + /** + * {@link CompositeMeterRegistry#find(String)} does not always delegate to child registries the + * way {@link CompositeMeterRegistry#getMeters()} does. Timers for observations often live on + * a child. + */ + private static java.util.List meterRegistriesToSearch(MeterRegistry root) { + if (root instanceof CompositeMeterRegistry composite) { + return composite.getRegistries().stream().toList(); + } + return java.util.List.of(root); + } + + /** + * Micrometer observation long tasks are named after the observation + * (e.g. {@code spring.data.cassandra.query}). Other {@link LongTaskTimer} meters (executors, + * unrelated components) may be non-idle during startup. + */ + private static long activeLongTaskTimerTasksForObservationNames(MeterRegistry meterRegistry) { + long active = 0; + for (var reg : meterRegistriesToSearch(meterRegistry)) { + for (var meter : reg.getMeters()) { + if (meter instanceof LongTaskTimer longTaskTimer) { + var name = longTaskTimer.getId().getName(); + if (name.startsWith("spring.data.cassandra") + || name.startsWith("spring.data.")) { + active += longTaskTimer.activeTasks(); + } + } + } + } + return active; + } + + @Test + void cassandraMetricsConfigurationRegistersDriverCustomizer() { + assertThat(applicationContext.getBeanNamesForType(DriverConfigLoaderBuilderCustomizer.class)) + .as("CassandraMetricsConfiguration should register configLoaderBuilderCustomizer when metrics lists are set") + .contains("configLoaderBuilderCustomizer"); + } + + @Test + void durationRoundTripViaCassandraTemplate() { + var id = "test-" + System.currentTimeMillis(); + var timeout = Duration.ofSeconds(30).plusNanos(500_000_000); + var entity = new DurationEntity(id, timeout); + + cassandraTemplate.insert(entity); + + var loaded = cassandraTemplate.selectOneById(id, DurationEntity.class); + assertThat(loaded).isNotNull(); + assertThat(loaded.id).isEqualTo(id); + assertThat(loaded.timeout).isEqualTo(timeout); + } + + @Test + void durationWithDaysRoundTripViaCassandraTemplate() { + var id = "test-days-" + System.currentTimeMillis(); + var timeout = Duration.ofDays(1).plusHours(2).plusMinutes(30); + var entity = new DurationEntity(id, timeout); + + cassandraTemplate.insert(entity); + + var loaded = cassandraTemplate.selectOneById(id, DurationEntity.class); + assertThat(loaded).isNotNull(); + assertThat(loaded.timeout).isEqualTo(timeout); + } + + @Test + void actuatorHealthIncludesCassandraWithUpStatus() throws Exception { + ResultActions result = mockMvc.perform(get("/actuator/health") + .accept(MediaType.APPLICATION_JSON)); + + result.andExpect(status().isOk()); + + var json = result.andReturn().getResponse().getContentAsString(); + assertThat(json).contains("\"cassandra\""); + assertThat(json).contains("\"status\":\"UP\""); + } + + @Test + void overallHealthIsUpWhenCassandraConnected() throws Exception { + mockMvc.perform(get("/actuator/health").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(r -> { + var json = r.getResponse().getContentAsString(); + assertThat(json).contains("\"status\":\"UP\""); + }); + } + + @Table(TABLE) + public static class DurationEntity { + + @PrimaryKey + private String id; + private Duration timeout; + + public DurationEntity() { + } + + public DurationEntity(String id, Duration timeout) { + this.id = id; + this.timeout = timeout; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Duration getTimeout() { + return timeout; + } + + public void setTimeout(Duration timeout) { + this.timeout = timeout; + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/configuration/CassandraAdvancedConfigurationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/configuration/CassandraAdvancedConfigurationTest.java new file mode 100644 index 0000000000..fa82e0f393 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/configuration/CassandraAdvancedConfigurationTest.java @@ -0,0 +1,83 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.cassandra.configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.datastax.oss.driver.api.core.config.DriverConfigLoader; +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; + +class CassandraAdvancedConfigurationTest { + + private final CassandraAdvancedConfiguration config = new CassandraAdvancedConfiguration(); + + @Test + void advancedPropertiesReturnsCustomizer() { + var env = new MockEnvironment(); + var customizer = config.advancedProperties(env); + assertThat(customizer).isNotNull(); + } + + @Test + void advancedPropertiesAppliesIntegerFromEnvironment() { + var env = new MockEnvironment(); + env.setProperty("spring.cassandra.advanced.connection.max-requests-per-connection", "10000"); + + var customizer = config.advancedProperties(env); + var builder = DriverConfigLoader.programmaticBuilder(); + customizer.customize(builder); + var loader = builder.build(); + assertThat(loader).isNotNull(); + } + + @Test + void advancedPropertiesAppliesDurationFromEnvironment() { + var env = new MockEnvironment(); + env.setProperty("spring.cassandra.advanced.basic.request.timeout", "5000"); + + var customizer = config.advancedProperties(env); + var builder = DriverConfigLoader.programmaticBuilder(); + customizer.customize(builder); + var loader = builder.build(); + assertThat(loader).isNotNull(); + } + + @Test + void advancedPropertiesIgnoresNonAdvancedProperties() { + var env = new MockEnvironment(); + env.setProperty("spring.cassandra.contact-points", "localhost"); + env.setProperty("spring.cassandra.advanced.connection.max-requests-per-connection", "100"); + + var customizer = config.advancedProperties(env); + var builder = DriverConfigLoader.programmaticBuilder(); + customizer.customize(builder); + assertThat(builder.build()).isNotNull(); + } + + @Test + void advancedPropertiesIgnoresBlankProperties() { + var env = new MockEnvironment(); + env.setProperty("spring.cassandra.advanced.connection.max-requests-per-connection", " "); + + var customizer = config.advancedProperties(env); + var builder = DriverConfigLoader.programmaticBuilder(); + customizer.customize(builder); + assertThat(builder.build()).isNotNull(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/configuration/CassandraConverterConfigurationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/configuration/CassandraConverterConfigurationTest.java new file mode 100644 index 0000000000..5afb0628e3 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/configuration/CassandraConverterConfigurationTest.java @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.cassandra.configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.datastax.oss.driver.api.core.data.CqlDuration; +import java.time.Duration; +import org.junit.jupiter.api.Test; +import org.springframework.core.convert.support.GenericConversionService; + +class CassandraConverterConfigurationTest { + + private final CassandraConverterConfiguration config = new CassandraConverterConfiguration(); + + @Test + void cassandraCustomConversionsReturnsNonNullBean() { + var conversions = config.cassandraCustomConversions(); + assertThat(conversions).isNotNull(); + } + + @Test + void cassandraCustomConversionsRegistersDurationWriteConverter() { + var conversions = config.cassandraCustomConversions(); + assertThat(conversions.hasCustomWriteTarget(Duration.class)).isTrue(); + } + + @Test + void cassandraCustomConversionsRegistersCqlDurationReadConverter() { + var conversions = config.cassandraCustomConversions(); + assertThat(conversions.hasCustomReadTarget(CqlDuration.class, Duration.class)).isTrue(); + } + + @Test + void durationToCqlDurationConvertsCorrectly() { + var conversions = config.cassandraCustomConversions(); + var conversionService = new GenericConversionService(); + conversions.registerConvertersIn(conversionService); + + var duration = Duration.ofSeconds(30).plusNanos(500_000_000); + var cqlDuration = conversionService.convert(duration, CqlDuration.class); + + assertThat(cqlDuration).isNotNull(); + assertThat(cqlDuration.getDays()).isZero(); + assertThat(cqlDuration.getMonths()).isZero(); + assertThat(cqlDuration.getNanoseconds()).isEqualTo(30_500_000_000L); + } + + @Test + void cqlDurationToDurationConvertsCorrectly() { + var conversions = config.cassandraCustomConversions(); + var conversionService = new GenericConversionService(); + conversions.registerConvertersIn(conversionService); + + // CqlDuration(months, days, nanoseconds) - converter uses days and nanoseconds only + var cqlDuration = CqlDuration.newInstance(0, 1, 5_000_000_000L); + var duration = conversionService.convert(cqlDuration, Duration.class); + + assertThat(duration).isNotNull(); + assertThat(duration).isEqualTo(Duration.ofDays(1).plusNanos(5_000_000_000L)); + } + + @Test + void durationRoundTripPreservesValue() { + var conversions = config.cassandraCustomConversions(); + var conversionService = new GenericConversionService(); + conversions.registerConvertersIn(conversionService); + + var original = Duration.ofHours(2).plusMinutes(30).plusNanos(123); + var cqlDuration = conversionService.convert(original, CqlDuration.class); + var roundTripped = conversionService.convert(cqlDuration, Duration.class); + + assertThat(roundTripped).isEqualTo(original); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/configuration/CassandraHealthConfigurationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/configuration/CassandraHealthConfigurationTest.java new file mode 100644 index 0000000000..c869d1c60d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/configuration/CassandraHealthConfigurationTest.java @@ -0,0 +1,119 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.cassandra.configuration; + +import static com.datastax.oss.driver.api.core.config.DefaultDriverOption.RETRY_POLICY_CLASS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.config.DriverConfig; +import com.datastax.oss.driver.api.core.config.DriverExecutionProfile; +import com.datastax.oss.driver.api.core.context.DriverContext; +import com.datastax.oss.driver.api.core.cql.ResultSet; +import com.datastax.oss.driver.api.core.cql.Statement; +import com.datastax.oss.driver.api.core.metadata.Metadata; +import com.nvidia.boot.cassandra.retry.NextHostRetryPolicy; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.health.contributor.CompositeHealthContributor; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.HealthContributor; +import org.springframework.boot.health.contributor.HealthIndicator; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class CassandraHealthConfigurationTest { + + private ApplicationContextRunner contextRunner; + + @BeforeEach + void setUp() { + contextRunner = new ApplicationContextRunner() + .withUserConfiguration(CassandraHealthConfiguration.class) + .withBean("cassandra", CqlSession.class, this::createMockCqlSession); + } + + private CqlSession createMockCqlSession() { + var session = mock(CqlSession.class); + var context = mock(DriverContext.class); + var config = mock(DriverConfig.class); + var defaultProfile = mock(DriverExecutionProfile.class); + var profileWithRetry = mock(DriverExecutionProfile.class); + + var metadata = mock(Metadata.class); + lenient().when(session.getMetadata()).thenReturn(metadata); + lenient().when(metadata.getNodes()).thenReturn(java.util.Collections.emptyMap()); + lenient().when(session.getContext()).thenReturn(context); + lenient().when(context.getConfig()).thenReturn(config); + lenient().when(config.getDefaultProfile()).thenReturn(defaultProfile); + lenient().when(defaultProfile.withClass(eq(RETRY_POLICY_CLASS), eq(NextHostRetryPolicy.class))) + .thenReturn(profileWithRetry); + + var resultSet = mock(ResultSet.class); + lenient().when(session.execute(any(Statement.class))).thenReturn(resultSet); + + return session; + } + + @Test + void cassandraHealthContributorIsCreatedWhenCqlSessionAvailable() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(HealthContributor.class); + var contributor = context.getBean("cassandraHealthContributor", HealthContributor.class); + assertThat(contributor).isNotNull(); + }); + } + + @Test + void healthIndicatorReturnsHealth() { + contextRunner.run(context -> { + var contributor = context.getBean("cassandraHealthContributor", HealthContributor.class); + assertThat(contributor).isInstanceOf(HealthIndicator.class); + Health health = ((HealthIndicator) contributor).health(); + assertThat(health).isNotNull(); + assertThat(health.getStatus()).isNotNull(); + }); + } + + @Test + void cassandraHealthContributorIsCompositeWithMultipleSessions() { + new ApplicationContextRunner() + .withUserConfiguration(CassandraHealthConfiguration.class) + .withBean("cassandra", CqlSession.class, this::createMockCqlSession) + .withBean("cassandraSecondary", CqlSession.class, this::createMockCqlSession) + .run(context -> { + var contributor = context.getBean("cassandraHealthContributor", HealthContributor.class); + assertThat(contributor).isInstanceOf(CompositeHealthContributor.class); + + var composite = (CompositeHealthContributor) contributor; + var names = new java.util.ArrayList(); + composite.forEach(nc -> names.add(nc.name())); + assertThat(names).containsExactlyInAnyOrder("cassandra", "cassandraSecondary"); + }); + } + + @Test + void contextFailsToStartWhenNoCqlSessionsAvailable() { + new ApplicationContextRunner() + .withUserConfiguration(CassandraHealthConfiguration.class) + .run(context -> assertThat(context).hasFailed()); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/configuration/CassandraMetricsConfigurationIntegrationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/configuration/CassandraMetricsConfigurationIntegrationTest.java new file mode 100644 index 0000000000..8ee43740ce --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/configuration/CassandraMetricsConfigurationIntegrationTest.java @@ -0,0 +1,114 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.cassandra.configuration; + +import static com.datastax.oss.driver.api.core.config.DefaultDriverOption.METRICS_FACTORY_CLASS; +import static com.datastax.oss.driver.api.core.config.DefaultDriverOption.METRICS_ID_GENERATOR_CLASS; +import static com.datastax.oss.driver.api.core.config.DefaultDriverOption.METRICS_NODE_ENABLED; +import static com.datastax.oss.driver.api.core.config.DefaultDriverOption.METRICS_SESSION_ENABLED; +import static org.assertj.core.api.Assertions.assertThat; + +import com.datastax.oss.driver.api.core.config.DriverConfigLoader; +import java.util.Collections; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.boot.cassandra.autoconfigure.DriverConfigLoaderBuilderCustomizer; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MapPropertySource; + +class CassandraMetricsConfigurationIntegrationTest { + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withUserConfiguration(CassandraMetricsConfiguration.class); + + @Test + void doesNotRegisterCustomizerWhenNoMetricsConfigured() { + runner.run(context -> assertThat(context.getBeansOfType(DriverConfigLoaderBuilderCustomizer.class)) + .isEmpty()); + } + + @Test + void doesNotRegisterCustomizerWhenSessionMetricsExplicitlyEmpty() { + runner.withInitializer(applicationContext -> { + var env = applicationContext.getEnvironment(); + env.getPropertySources() + .addFirst(new MapPropertySource( + "empty-session-metrics", + Map.of("spring.cassandra.session-metrics", Collections.emptyList()))); + }) + .run(context -> assertThat(context.getBeansOfType(DriverConfigLoaderBuilderCustomizer.class)) + .isEmpty()); + } + + @Test + void registersCustomizerAndAppliesDriverOptionsWhenSessionMetricsSet() { + runner.withPropertyValues("spring.cassandra.session-metrics[0]=bytes-sent") + .run(context -> { + assertThat(context.getBeansOfType(DriverConfigLoaderBuilderCustomizer.class)) + .hasSize(1); + var customizer = context.getBean(DriverConfigLoaderBuilderCustomizer.class); + var builder = DriverConfigLoader.programmaticBuilder(); + customizer.customize(builder); + var profile = builder.build().getInitialConfig().getDefaultProfile(); + assertThat(profile.getString(METRICS_FACTORY_CLASS)) + .isEqualTo("com.datastax.oss.driver.internal.metrics.micrometer.MicrometerMetricsFactory"); + assertThat(profile.getString(METRICS_ID_GENERATOR_CLASS)) + .isEqualTo("com.datastax.oss.driver.internal.core.metrics.TaggingMetricIdGenerator"); + assertThat(profile.getStringList(METRICS_SESSION_ENABLED)) + .containsExactly("bytes-sent"); + }); + } + + @Test + void registersCustomizerWhenOnlyNodeMetricsSet() { + runner.withPropertyValues("spring.cassandra.node-metrics[0]=pool.in-flight") + .run(context -> { + assertThat(context.getBeansOfType(DriverConfigLoaderBuilderCustomizer.class)) + .hasSize(1); + var customizer = context.getBean(DriverConfigLoaderBuilderCustomizer.class); + var builder = DriverConfigLoader.programmaticBuilder(); + customizer.customize(builder); + var profile = builder.build().getInitialConfig().getDefaultProfile(); + assertThat(profile.getStringList(METRICS_NODE_ENABLED)) + .containsExactly("pool.in-flight"); + assertThat(profile.getStringList(METRICS_SESSION_ENABLED)).isEmpty(); + }); + } + + @Test + void registersCustomizerWithBothSessionAndNodeLists() { + runner.withPropertyValues( + "spring.cassandra.session-metrics[0]=connected-nodes", + "spring.cassandra.session-metrics[1]=cql-requests", + "spring.cassandra.node-metrics[0]=pool.open-connections") + .run(context -> { + assertThat(context.getBeansOfType(DriverConfigLoaderBuilderCustomizer.class)) + .hasSize(1); + var customizer = + context.getBean(DriverConfigLoaderBuilderCustomizer.class); + var builder = DriverConfigLoader.programmaticBuilder(); + customizer.customize(builder); + var profile = builder.build().getInitialConfig().getDefaultProfile(); + assertThat(profile.getStringList(METRICS_SESSION_ENABLED)) + .containsExactly("connected-nodes", "cql-requests"); + assertThat(profile.getStringList(METRICS_NODE_ENABLED)) + .containsExactly("pool.open-connections"); + }); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/configuration/CassandraMetricsEnabledConditionTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/configuration/CassandraMetricsEnabledConditionTest.java new file mode 100644 index 0000000000..10e107ac5c --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/configuration/CassandraMetricsEnabledConditionTest.java @@ -0,0 +1,112 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.cassandra.configuration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.type.AnnotatedTypeMetadata; +import org.springframework.mock.env.MockEnvironment; + +class CassandraMetricsEnabledConditionTest { + + private final CassandraMetricsConfiguration.CassandraMetricsEnabledCondition condition = + new CassandraMetricsConfiguration.CassandraMetricsEnabledCondition(); + + @Test + void matchesFalseWhenNoMetricsProperties() { + var env = new MockEnvironment(); + assertThat(matches(env)).isFalse(); + } + + @Test + void matchesFalseWhenOnlyUnrelatedCassandraProperty() { + var env = new MockEnvironment(); + env.setProperty("spring.cassandra.port", "9042"); + assertThat(matches(env)).isFalse(); + } + + @Test + void matchesTrueWhenSessionMetricsIndexed() { + var env = new MockEnvironment(); + env.setProperty("spring.cassandra.session-metrics[0]", "connected-nodes"); + assertThat(matches(env)).isTrue(); + } + + @Test + void matchesTrueWhenNodeMetricsIndexed() { + var env = new MockEnvironment(); + env.setProperty("spring.cassandra.node-metrics[0]", "pool.in-flight"); + assertThat(matches(env)).isTrue(); + } + + @Test + void matchesTrueWhenBothListsHaveEntries() { + var env = new MockEnvironment(); + env.setProperty("spring.cassandra.session-metrics[0]", "bytes-sent"); + env.setProperty("spring.cassandra.node-metrics[0]", "pool.open-connections"); + assertThat(matches(env)).isTrue(); + } + + @Test + void matchesFalseWhenSessionMetricsExplicitlyEmpty() { + var env = new MockEnvironment(); + env.getPropertySources() + .addFirst(new MapPropertySource( + "test", + Map.of("spring.cassandra.session-metrics", Collections.emptyList()))); + assertThat(matches(env)).isFalse(); + } + + @Test + void matchesFalseWhenBothListsExplicitlyEmpty() { + var env = new MockEnvironment(); + env.getPropertySources() + .addFirst(new MapPropertySource( + "test", + Map.of( + "spring.cassandra.session-metrics", List.of(), + "spring.cassandra.node-metrics", List.of()))); + assertThat(matches(env)).isFalse(); + } + + @Test + void matchesTrueWhenSessionNonEmptyAndNodeExplicitlyEmpty() { + var env = new MockEnvironment(); + env.getPropertySources() + .addFirst(new MapPropertySource( + "empty-node", + Map.of("spring.cassandra.node-metrics", List.of()))); + env.setProperty("spring.cassandra.session-metrics[0]", "cql-requests"); + assertThat(matches(env)).isTrue(); + } + + private boolean matches(org.springframework.core.env.Environment env) { + var ctx = mock(ConditionContext.class); + when(ctx.getEnvironment()).thenReturn(env); + var meta = mock(AnnotatedTypeMetadata.class); + return condition.matches(ctx, meta); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/configuration/CassandraSslBundleConfigurationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/configuration/CassandraSslBundleConfigurationTest.java new file mode 100644 index 0000000000..e597e16438 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/configuration/CassandraSslBundleConfigurationTest.java @@ -0,0 +1,143 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.cassandra.configuration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.CqlSessionBuilder; +import io.micrometer.observation.ObservationRegistry; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.aop.TargetSource; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class CassandraSslBundleConfigurationTest { + + private static final String BUNDLE_NAME = "cassandra-ssl"; + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(CassandraSslBundleConfiguration.class) + .withPropertyValues("spring.cassandra.ssl.bundle=" + BUNDLE_NAME) + .withBean(SslBundles.class, () -> mock(SslBundles.class)) + .withBean(CqlSessionBuilder.class, this::createMockBuilder); + + private final ApplicationContextRunner sslDisabledWithoutBundleRunner = new ApplicationContextRunner() + .withUserConfiguration(CassandraSslBundleConfiguration.class) + .withPropertyValues("spring.cassandra.ssl.enabled=false") + .withBean(SslBundles.class, () -> mock(SslBundles.class)) + .withBean(CqlSessionBuilder.class, this::createMockBuilder); + + private CqlSessionBuilder createMockBuilder() { + var builder = mock(CqlSessionBuilder.class); + var initialSession = mock(CqlSession.class); + lenient().when(builder.build()).thenReturn(initialSession); + return builder; + } + + @Test + @DisplayName("Loads cassandraSession when spring.cassandra.ssl.enabled=false without spring.cassandra.ssl.bundle") + void loadsWhenSslDisabledWithoutBundleProperty() { + sslDisabledWithoutBundleRunner.run(context -> assertThat(context).hasBean("cassandraSession")); + } + + @Test + @DisplayName("Wraps CqlSession with observability by default when ObservationRegistry is present and no RefreshingCqlSessionObservabilityProperties bean is declared") + void wrapsByDefault() { + contextRunner + .withBean(ObservationRegistry.class, ObservationRegistry::create) + .run(context -> { + assertThat(context).hasBean("cassandraSession"); + var refreshing = context.getBean( + "cassandraSession", + CassandraSslBundleConfiguration.RefreshingCqlSession.class); + assertThat(refreshing.getDelegate()) + .as("Default: observation wrap on, delegate should be the " + + "ObservableCqlSessionFactory AOP proxy (a TargetSource)") + .isInstanceOf(TargetSource.class); + }); + } + + @Test + @DisplayName("Wraps CqlSession when RefreshingCqlSessionObservabilityProperties bean is declared with enabled=true") + void wrapsWhenPropertiesBeanEnabledTrue() { + contextRunner + .withBean(ObservationRegistry.class, ObservationRegistry::create) + .withBean( + RefreshingCqlSessionObservabilityProperties.class, + CassandraSslBundleConfigurationTest::enabledTrueProperties) + .run(context -> { + assertThat(context).hasBean("cassandraSession"); + var refreshing = context.getBean( + "cassandraSession", + CassandraSslBundleConfiguration.RefreshingCqlSession.class); + assertThat(refreshing.getDelegate()) + .as("enabled=true: delegate should be the AOP-proxied observable wrap") + .isInstanceOf(TargetSource.class); + }); + } + + @Test + @DisplayName("Returns unwrapped CqlSession when RefreshingCqlSessionObservabilityProperties bean has enabled=false, even though ObservationRegistry is present") + void skipsWrapWhenPropertiesBeanEnabledFalse() { + contextRunner + .withBean(ObservationRegistry.class, ObservationRegistry::create) + .withBean( + RefreshingCqlSessionObservabilityProperties.class, + CassandraSslBundleConfigurationTest::enabledFalseProperties) + .run(context -> { + assertThat(context).hasBean("cassandraSession"); + var refreshing = context.getBean( + "cassandraSession", + CassandraSslBundleConfiguration.RefreshingCqlSession.class); + assertThat(refreshing.getDelegate()) + .as("Opt-out: delegate should be the raw CqlSession " + + "(not an AOP-proxied observable wrap)") + .isNotInstanceOf(TargetSource.class); + }); + } + + @Test + @DisplayName("Returns unwrapped CqlSession when no ObservationRegistry is present") + void doesNotWrapWithoutObservationRegistry() { + contextRunner.run(context -> { + assertThat(context).hasBean("cassandraSession"); + var refreshing = context.getBean( + "cassandraSession", + CassandraSslBundleConfiguration.RefreshingCqlSession.class); + assertThat(refreshing.getDelegate()) + .as("No ObservationRegistry: delegate should be the raw CqlSession") + .isNotInstanceOf(TargetSource.class); + }); + } + + private static RefreshingCqlSessionObservabilityProperties enabledTrueProperties() { + var props = new RefreshingCqlSessionObservabilityProperties(); + props.setEnabled(true); + return props; + } + + private static RefreshingCqlSessionObservabilityProperties enabledFalseProperties() { + var props = new RefreshingCqlSessionObservabilityProperties(); + props.setEnabled(false); + return props; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/configuration/RefreshingCqlSessionSslPropertiesRefreshTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/configuration/RefreshingCqlSessionSslPropertiesRefreshTest.java new file mode 100644 index 0000000000..049b513d85 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/configuration/RefreshingCqlSessionSslPropertiesRefreshTest.java @@ -0,0 +1,112 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.cassandra.configuration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.CqlSessionBuilder; +import com.datastax.oss.driver.api.core.cql.ResultSet; +import com.datastax.oss.driver.api.core.cql.Row; +import java.util.Map; +import javax.net.ssl.SSLContext; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springframework.boot.autoconfigure.ssl.PemSslBundleProperties; +import org.springframework.boot.autoconfigure.ssl.PropertiesSslBundle; +import org.springframework.boot.autoconfigure.ssl.SslProperties; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.cloud.context.scope.refresh.RefreshScopeRefreshedEvent; +import org.springframework.context.ApplicationContext; +import org.springframework.mock.env.MockEnvironment; + +/** + * Ensures {@link CassandraSslBundleConfiguration.RefreshingCqlSession#handleRefreshComplete} builds + * TLS from {@link SslProperties} and {@link PropertiesSslBundle} when PEM material changes, so the + * session does not keep using stale {@link SslBundles} from startup. + */ +class RefreshingCqlSessionSslPropertiesRefreshTest { + + @Test + @DisplayName("When SSL PEM changes and SslProperties has the bundle, uses PropertiesSslBundle.get and withSslContext") + void whenSslMaterialChangesAndSslPropertiesPresentUsesFreshSslBundle() { + var environment = new MockEnvironment(); + environment.setProperty("spring.cassandra.username", "u"); + environment.setProperty("spring.cassandra.password", "p"); + environment.setProperty("spring.ssl.bundle.pem.cassandra-ssl.keystore.certificate", "cert-v1"); + environment.setProperty("spring.ssl.bundle.pem.cassandra-ssl.keystore.private-key", "key-v1"); + + var pemProps = mock(PemSslBundleProperties.class); + var bundles = mock(SslProperties.Bundles.class); + when(bundles.getPem()).thenReturn(Map.of("cassandra-ssl", pemProps)); + var sslProperties = mock(SslProperties.class); + when(sslProperties.getBundle()).thenReturn(bundles); + + var delegateSession = mock(CqlSession.class); + var newSession = mock(CqlSession.class); + var resultSet = mock(ResultSet.class); + var row = mock(Row.class); + when(resultSet.one()).thenReturn(row); + when(newSession.execute(anyString())).thenReturn(resultSet); + + var sslContext = mock(SSLContext.class); + var freshBundle = mock(SslBundle.class); + when(freshBundle.createSslContext()).thenReturn(sslContext); + + var builder = mock(CqlSessionBuilder.class); + when(builder.withSslContext(sslContext)).thenReturn(builder); + when(builder.build()).thenReturn(newSession); + + var applicationContext = mock(ApplicationContext.class); + when(applicationContext.getBean(CqlSessionBuilder.class)).thenReturn(builder); + + var sslBundles = mock(SslBundles.class); + var session = new CassandraSslBundleConfiguration.RefreshingCqlSession( + applicationContext, + sslBundles, + environment, + sslProperties, + "cassandra-ssl", + delegateSession, + null); + + environment.setProperty("spring.ssl.bundle.pem.cassandra-ssl.keystore.certificate", "cert-v2"); + + try (MockedStatic sslBundleStatic = mockStatic(PropertiesSslBundle.class)) { + sslBundleStatic + .when(() -> PropertiesSslBundle.get(eq(pemProps), eq(applicationContext))) + .thenReturn(freshBundle); + + session.handleRefreshComplete(new RefreshScopeRefreshedEvent()); + + sslBundleStatic.verify(() -> PropertiesSslBundle.get(eq(pemProps), eq(applicationContext))); + } + + verify(builder).withSslContext(sslContext); + verify(delegateSession).close(); + assertThat(session.getDelegate()).isSameAs(newSession); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/configuration/RefreshingCqlSessionTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/configuration/RefreshingCqlSessionTest.java new file mode 100644 index 0000000000..aff9d81ad9 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/configuration/RefreshingCqlSessionTest.java @@ -0,0 +1,319 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.cassandra.configuration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.CqlSessionBuilder; +import com.datastax.oss.driver.api.core.cql.ResultSet; +import com.datastax.oss.driver.api.core.cql.Row; +import com.datastax.oss.driver.api.core.cql.Statement; +import io.micrometer.observation.ObservationRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.cloud.context.scope.refresh.RefreshScopeRefreshedEvent; +import org.springframework.context.ApplicationContext; +import org.springframework.mock.env.MockEnvironment; + +class RefreshingCqlSessionTest { + + private ApplicationContext applicationContext; + private CassandraSslBundleConfiguration.RefreshingCqlSession session; + private CqlSession delegateSession; + private MockEnvironment environment; + + @BeforeEach + void setUp() { + applicationContext = mock(ApplicationContext.class); + delegateSession = mock(CqlSession.class); + environment = new MockEnvironment(); + environment.setProperty("spring.cassandra.username", "user1"); + environment.setProperty("spring.cassandra.password", "pass1"); + + var sslBundles = mock(SslBundles.class); + session = new CassandraSslBundleConfiguration.RefreshingCqlSession( + applicationContext, + sslBundles, + environment, + null, + "", + delegateSession, + null); + } + + @Test + @DisplayName("Skips rebuild when credentials and SSL PEM properties unchanged") + void handleRefreshCompleteSkipsRebuildWhenCredentialsUnchanged() { + var event = new RefreshScopeRefreshedEvent(); + + session.handleRefreshComplete(event); + + verify(delegateSession, never()).close(); + } + + @Test + @DisplayName("Skips rebuild when credentials unchanged and SSL bundle PEM unchanged") + void handleRefreshCompleteSkipsWhenSslBundleMaterialUnchanged() { + environment.setProperty("spring.ssl.bundle.pem.cassandra-ssl.keystore.certificate", "cert-v1"); + environment.setProperty("spring.ssl.bundle.pem.cassandra-ssl.keystore.private-key", "key-v1"); + var sslBundles = mock(SslBundles.class); + var sessionWithBundle = new CassandraSslBundleConfiguration.RefreshingCqlSession( + applicationContext, + sslBundles, + environment, + null, + "cassandra-ssl", + delegateSession, + null); + + sessionWithBundle.handleRefreshComplete(new RefreshScopeRefreshedEvent()); + + verify(delegateSession, never()).close(); + } + + @Test + @DisplayName("Rebuilds when SSL bundle PEM material changes but credentials unchanged") + void handleRefreshCompleteRebuildsWhenSslMaterialChanged() { + environment.setProperty("spring.ssl.bundle.pem.cassandra-ssl.keystore.certificate", "cert-v1"); + environment.setProperty("spring.ssl.bundle.pem.cassandra-ssl.keystore.private-key", "key-v1"); + + var sslBundles = mock(SslBundles.class); + var sessionWithBundle = new CassandraSslBundleConfiguration.RefreshingCqlSession( + applicationContext, + sslBundles, + environment, + null, + "cassandra-ssl", + delegateSession, + null); + + var newSession = mock(CqlSession.class); + var resultSet = mock(ResultSet.class); + var row = mock(Row.class); + when(resultSet.one()).thenReturn(row); + when(newSession.execute(anyString())).thenReturn(resultSet); + + var builder = mock(CqlSessionBuilder.class); + when(builder.build()).thenReturn(newSession); + when(applicationContext.getBean(CqlSessionBuilder.class)).thenReturn(builder); + + environment.setProperty("spring.ssl.bundle.pem.cassandra-ssl.keystore.certificate", "cert-v2"); + + sessionWithBundle.handleRefreshComplete(new RefreshScopeRefreshedEvent()); + + verify(delegateSession).close(); + verify(newSession).execute(anyString()); + } + + @Test + @DisplayName("Rebuilds when credentials changed") + void handleRefreshCompleteRebuildsWhenCredentialsChanged() { + var newSession = mock(CqlSession.class); + var resultSet = mock(ResultSet.class); + var row = mock(Row.class); + when(resultSet.one()).thenReturn(row); + when(newSession.execute(anyString())).thenReturn(resultSet); + + var builder = mock(CqlSessionBuilder.class); + when(builder.build()).thenReturn(newSession); + when(applicationContext.getBean(CqlSessionBuilder.class)).thenReturn(builder); + + environment.setProperty("spring.cassandra.username", "user2"); + environment.setProperty("spring.cassandra.password", "pass2"); + + var event = new RefreshScopeRefreshedEvent(); + session.handleRefreshComplete(event); + + verify(delegateSession).close(); + verify(newSession).execute(anyString()); + } + + @Test + @DisplayName("Delegates execute to session") + void executeDelegatesToSession() { + var resultSet = mock(ResultSet.class); + when(delegateSession.execute(anyString())).thenReturn(resultSet); + + var result = session.execute("SELECT 1"); + assertThat(result).isSameAs(resultSet); + verify(delegateSession).execute("SELECT 1"); + } + + @Nested + @DisplayName("With ObservationRegistry (observability wrapping)") + class WithObservationRegistry { + + @Test + @DisplayName("Wraps new session with observability on credential refresh") + void handleRefreshCompleteWrapsNewSessionWithObservability() throws Exception { + var newSession = mock(CqlSession.class); + var resultSet = mock(ResultSet.class); + var row = mock(Row.class); + when(resultSet.one()).thenReturn(row); + when(newSession.execute(any(Statement.class))).thenReturn(resultSet); + + var builder = mock(CqlSessionBuilder.class); + when(builder.build()).thenReturn(newSession); + when(applicationContext.getBean(CqlSessionBuilder.class)).thenReturn(builder); + + var sslBundles = mock(SslBundles.class); + var sessionWithObservability = new CassandraSslBundleConfiguration.RefreshingCqlSession( + applicationContext, + sslBundles, + environment, + null, + "", + delegateSession, + ObservationRegistry.create()); + + environment.setProperty("spring.cassandra.username", "user2"); + environment.setProperty("spring.cassandra.password", "pass2"); + + sessionWithObservability.handleRefreshComplete(new RefreshScopeRefreshedEvent()); + + verify(delegateSession).close(); + verify(newSession).execute(any(Statement.class)); + + var delegate = sessionWithObservability.getDelegate(); + assertThat(delegate).isNotNull().isNotSameAs(newSession); + } + + @Test + @DisplayName("Wraps new session with observability on SSL bundle update") + void handleSslBundleUpdateWrapsNewSessionWithObservability() throws Exception { + var newSession = mock(CqlSession.class); + var resultSet = mock(ResultSet.class); + var row = mock(Row.class); + when(resultSet.one()).thenReturn(row); + when(newSession.execute(any(Statement.class))).thenReturn(resultSet); + + var builder = mock(CqlSessionBuilder.class); + when(builder.withSslContext(any())).thenReturn(builder); + when(builder.build()).thenReturn(newSession); + when(applicationContext.getBean(CqlSessionBuilder.class)).thenReturn(builder); + + var sslBundle = mock(SslBundle.class); + when(sslBundle.createSslContext()).thenReturn(null); + + var sslBundles = mock(SslBundles.class); + var sessionWithObservability = new CassandraSslBundleConfiguration.RefreshingCqlSession( + applicationContext, + sslBundles, + environment, + null, + "test-bundle", + delegateSession, + ObservationRegistry.create()); + + sessionWithObservability.handleSslBundleUpdate(sslBundle); + + verify(delegateSession).close(); + verify(newSession).execute(any(Statement.class)); + + var delegate = sessionWithObservability.getDelegate(); + assertThat(delegate).isNotNull().isNotSameAs(newSession); + } + } + + @Nested + @DisplayName("When observation disabled (null ObservationRegistry)") + class WhenObservationDisabled { + + @Test + @DisplayName("Keeps new session unwrapped on credential refresh") + void handleRefreshCompleteKeepsNewSessionUnwrapped() { + var newSession = mock(CqlSession.class); + var resultSet = mock(ResultSet.class); + var row = mock(Row.class); + when(resultSet.one()).thenReturn(row); + when(newSession.execute(anyString())).thenReturn(resultSet); + + var builder = mock(CqlSessionBuilder.class); + when(builder.build()).thenReturn(newSession); + when(applicationContext.getBean(CqlSessionBuilder.class)).thenReturn(builder); + + var sslBundles = mock(SslBundles.class); + var sessionWithoutObservability = + new CassandraSslBundleConfiguration.RefreshingCqlSession( + applicationContext, + sslBundles, + environment, + null, + "", + delegateSession, + null); + + environment.setProperty("spring.cassandra.username", "user2"); + environment.setProperty("spring.cassandra.password", "pass2"); + + sessionWithoutObservability.handleRefreshComplete(new RefreshScopeRefreshedEvent()); + + verify(delegateSession).close(); + verify(newSession).execute(anyString()); + + assertThat(sessionWithoutObservability.getDelegate()).isSameAs(newSession); + } + + @Test + @DisplayName("Keeps new session unwrapped on SSL bundle update") + void handleSslBundleUpdateKeepsNewSessionUnwrapped() { + var newSession = mock(CqlSession.class); + var resultSet = mock(ResultSet.class); + var row = mock(Row.class); + when(resultSet.one()).thenReturn(row); + when(newSession.execute(anyString())).thenReturn(resultSet); + + var builder = mock(CqlSessionBuilder.class); + when(builder.withSslContext(any())).thenReturn(builder); + when(builder.build()).thenReturn(newSession); + when(applicationContext.getBean(CqlSessionBuilder.class)).thenReturn(builder); + + var sslBundle = mock(SslBundle.class); + when(sslBundle.createSslContext()).thenReturn(null); + + var sslBundles = mock(SslBundles.class); + var sessionWithoutObservability = + new CassandraSslBundleConfiguration.RefreshingCqlSession( + applicationContext, + sslBundles, + environment, + null, + "test-bundle", + delegateSession, + null); + + sessionWithoutObservability.handleSslBundleUpdate(sslBundle); + + verify(delegateSession).close(); + verify(newSession).execute(anyString()); + + assertThat(sessionWithoutObservability.getDelegate()).isSameAs(newSession); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/retry/NextHostRetryPolicyIntegrationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/retry/NextHostRetryPolicyIntegrationTest.java new file mode 100644 index 0000000000..dc6df176f0 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/retry/NextHostRetryPolicyIntegrationTest.java @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.cassandra.retry; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.config.DefaultDriverOption; +import com.datastax.oss.driver.api.core.config.DriverConfigLoader; +import org.junit.jupiter.api.Test; +import org.testcontainers.cassandra.CassandraContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers +class NextHostRetryPolicyIntegrationTest { + + @Container + static final CassandraContainer CASSANDRA = new CassandraContainer("cassandra:5"); + + @Test + void nextHostRetryPolicyCanBeRegisteredWithDriver() { + var contactPoint = CASSANDRA.getContactPoint(); + + var loader = DriverConfigLoader.programmaticBuilder() + .withClass(DefaultDriverOption.RETRY_POLICY_CLASS, NextHostRetryPolicy.class) + .build(); + + try (var session = CqlSession.builder() + .addContactPoint(contactPoint) + .withLocalDatacenter(CASSANDRA.getLocalDatacenter()) + .withConfigLoader(loader) + .build()) { + assertEquals(NextHostRetryPolicy.class, + session.getContext().getRetryPolicies().get("default").getClass()); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/retry/NextHostRetryPolicyTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/retry/NextHostRetryPolicyTest.java new file mode 100644 index 0000000000..b8637c0ca0 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/retry/NextHostRetryPolicyTest.java @@ -0,0 +1,135 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.cassandra.retry; + +import static com.datastax.oss.driver.api.core.ConsistencyLevel.QUORUM; +import static com.datastax.oss.driver.api.core.retry.RetryDecision.RETHROW; +import static com.datastax.oss.driver.api.core.retry.RetryDecision.RETRY_NEXT; +import static com.datastax.oss.driver.api.core.servererrors.WriteType.BATCH_LOG; +import static com.datastax.oss.driver.api.core.servererrors.WriteType.UNLOGGED_BATCH; +import static org.mockito.Mockito.mock; + +import com.datastax.oss.driver.api.core.ConsistencyLevel; +import com.datastax.oss.driver.api.core.connection.ClosedConnectionException; +import com.datastax.oss.driver.api.core.connection.HeartbeatException; +import com.datastax.oss.driver.api.core.metadata.Node; +import com.datastax.oss.driver.api.core.servererrors.CoordinatorException; +import com.datastax.oss.driver.api.core.servererrors.OverloadedException; +import com.datastax.oss.driver.api.core.servererrors.ReadFailureException; +import com.datastax.oss.driver.api.core.servererrors.ServerError; +import com.datastax.oss.driver.api.core.servererrors.TruncateException; +import com.datastax.oss.driver.api.core.servererrors.UnavailableException; +import com.datastax.oss.driver.api.core.servererrors.WriteFailureException; +import com.datastax.oss.driver.api.core.servererrors.WriteType; +import com.datastax.oss.driver.api.core.session.Request; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class NextHostRetryPolicyTest extends RetryPolicyTestBase { + + NextHostRetryPolicyTest() { + super(new NextHostRetryPolicy(null, "testSession1")); + } + + @Test + void shouldProcessReadTimeouts() { + assertOnReadTimeout(QUORUM, 2, 2, false, 1).hasDecision(RETHROW); + assertOnReadTimeout(QUORUM, 2, 1, false, 0).hasDecision(RETRY_NEXT); + assertOnReadTimeout(QUORUM, 2, 1, true, 0).hasDecision(RETHROW); + } + + @Test + void shouldProcessWriteTimeouts() { + assertOnWriteTimeout(QUORUM, BATCH_LOG, 2, 2, 1).hasDecision(RETHROW); + assertOnWriteTimeout(QUORUM, BATCH_LOG, 1, 1, 0).hasDecision(RETRY_NEXT); + assertOnWriteTimeout(QUORUM, UNLOGGED_BATCH, 1, 1, 0).hasDecision(RETRY_NEXT); + } + + @Test + void shouldProcessUnavailable() { + assertOnUnavailable(QUORUM, 2, 1, 1).hasDecision(RETHROW); + assertOnUnavailable(QUORUM, 2, 1, 0).hasDecision(RETRY_NEXT); + } + + @Test + void shouldProcessAbortedRequest() { + assertOnRequestAborted(ClosedConnectionException.class, 0).hasDecision(RETRY_NEXT); + assertOnRequestAborted(ClosedConnectionException.class, 1).hasDecision(RETRY_NEXT); + assertOnRequestAborted(HeartbeatException.class, 0).hasDecision(RETRY_NEXT); + assertOnRequestAborted(HeartbeatException.class, 1).hasDecision(RETRY_NEXT); + assertOnRequestAborted(Throwable.class, 0).hasDecision(RETHROW); + } + + @Test + void shouldProcessErrorResponse() { + assertOnErrorResponse(ReadFailureException.class, 0).hasDecision(RETHROW); + assertOnErrorResponse(ReadFailureException.class, 1).hasDecision(RETHROW); + assertOnErrorResponse(WriteFailureException.class, 0).hasDecision(RETHROW); + assertOnErrorResponse(WriteFailureException.class, 1).hasDecision(RETHROW); + + assertOnErrorResponse(OverloadedException.class, 0).hasDecision(RETRY_NEXT); + assertOnErrorResponse(OverloadedException.class, 1).hasDecision(RETRY_NEXT); + assertOnErrorResponse(ServerError.class, 0).hasDecision(RETRY_NEXT); + assertOnErrorResponse(ServerError.class, 1).hasDecision(RETRY_NEXT); + assertOnErrorResponse(TruncateException.class, 0).hasDecision(RETRY_NEXT); + assertOnErrorResponse(TruncateException.class, 1).hasDecision(RETRY_NEXT); + } + + @Test + void shouldHandleUnsupportedOperation() throws Exception { + var requestMock = mock(Request.class); + var mockNode = mock(Node.class); + var unavailableEx = new UnavailableException(mockNode, QUORUM, 1, 1); + var policy = new NextHostRetryPolicy(null, "testSession1"); + + // Using reflection to shutup IntelliJ from complaining about using deprecated methods + // in NextHostRetryPolicy. + assertDeprecatedThrows(policy, NextHostRetryPolicy.class.getMethod("onReadTimeout", + Request.class, ConsistencyLevel.class, int.class, int.class, boolean.class, int.class), + requestMock, QUORUM, 1, 1, false, 1); + assertDeprecatedThrows(policy, NextHostRetryPolicy.class.getMethod("onWriteTimeout", + Request.class, ConsistencyLevel.class, WriteType.class, int.class, int.class, int.class), + requestMock, QUORUM, BATCH_LOG, 1, 1, 1); + assertDeprecatedThrows(policy, NextHostRetryPolicy.class.getMethod("onUnavailable", + Request.class, ConsistencyLevel.class, int.class, int.class, int.class), + requestMock, QUORUM, 1, 1, 1); + assertDeprecatedThrows(policy, NextHostRetryPolicy.class.getMethod("onRequestAborted", + Request.class, Throwable.class, int.class), + requestMock, unavailableEx, 1); + assertDeprecatedThrows(policy, NextHostRetryPolicy.class.getMethod("onErrorResponse", + Request.class, CoordinatorException.class, int.class), + requestMock, unavailableEx, 1); + } + + private void assertDeprecatedThrows(NextHostRetryPolicy policy, Method method, Object... args) { + var thrown = Assertions.assertThrows(InvocationTargetException.class, + () -> method.invoke(policy, args)); + Assertions.assertInstanceOf(UnsupportedOperationException.class, thrown.getCause()); + } + + @Test + void testClose() { + var policy = new NextHostRetryPolicy(null, "testSession1"); + Assertions.assertDoesNotThrow(policy::close); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/retry/RetryPolicyTestBase.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/retry/RetryPolicyTestBase.java new file mode 100644 index 0000000000..67871623c4 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/java/com/nvidia/boot/cassandra/retry/RetryPolicyTestBase.java @@ -0,0 +1,96 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.cassandra.retry; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import com.datastax.oss.driver.api.core.ConsistencyLevel; +import com.datastax.oss.driver.api.core.retry.RetryDecision; +import com.datastax.oss.driver.api.core.retry.RetryPolicy; +import com.datastax.oss.driver.api.core.retry.RetryVerdict; +import com.datastax.oss.driver.api.core.servererrors.CoordinatorException; +import com.datastax.oss.driver.api.core.servererrors.WriteType; +import com.datastax.oss.driver.api.core.session.Request; +import org.assertj.core.api.AbstractAssert; +import org.mockito.Mock; + +public abstract class RetryPolicyTestBase { + private final RetryPolicy policy; + + @Mock + private Request request; + + RetryPolicyTestBase(RetryPolicy policy) { + this.policy = policy; + } + + RetryVerdictAssert assertOnReadTimeout( + ConsistencyLevel cl, + int blockFor, + int received, + boolean dataPresent, + int retryCount) { + return new RetryVerdictAssert( + policy.onReadTimeoutVerdict(request, cl, blockFor, received, dataPresent, retryCount)); + } + + RetryVerdictAssert assertOnWriteTimeout( + ConsistencyLevel cl, + WriteType writeType, + int blockFor, + int received, + int retryCount) { + return new RetryVerdictAssert( + policy.onWriteTimeoutVerdict(request, cl, writeType, blockFor, received, retryCount)); + } + + RetryVerdictAssert assertOnUnavailable( + ConsistencyLevel cl, + int required, + int alive, + int retryCount) { + return new RetryVerdictAssert( + policy.onUnavailableVerdict(request, cl, required, alive, retryCount)); + } + + RetryVerdictAssert assertOnRequestAborted( + Class errorClass, + int retryCount) { + return new RetryVerdictAssert( + policy.onRequestAbortedVerdict(request, mock(errorClass), retryCount)); + } + + RetryVerdictAssert assertOnErrorResponse( + Class errorClass, + int retryCount) { + return new RetryVerdictAssert( + policy.onErrorResponseVerdict(request, mock(errorClass), retryCount)); + } + + static class RetryVerdictAssert extends AbstractAssert { + RetryVerdictAssert(RetryVerdict actual) { + super(actual, RetryVerdictAssert.class); + } + + public RetryVerdictAssert hasDecision(RetryDecision decision) { + assertThat(actual.getRetryDecision()).isEqualTo(decision); + return this; + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/resources/docker-java.properties b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/resources/docker-java.properties new file mode 100644 index 0000000000..1275d78dff --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-cassandra/src/test/resources/docker-java.properties @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/BUILD.bazel b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/BUILD.bazel new file mode 100644 index 0000000000..9ad6e524ac --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/BUILD.bazel @@ -0,0 +1,78 @@ +load("@rules_java//java:defs.bzl", "java_library") +load("//src/libraries/java/nv-boot-parent/tools/bazel:java.bzl", "nv_boot_library", "nv_boot_library_test", "nv_boot_runtime_classpath_test") + +CORE_REQUIRED_DEPS = [ + "@nv_third_party_deps//:com_github_ben_manes_caffeine_guava", + "@nv_third_party_deps//:com_google_guava_guava", + "@nv_third_party_deps//:jakarta_annotation_jakarta_annotation_api", + "@nv_third_party_deps//:org_apache_commons_commons_lang3", + "@nv_third_party_deps//:org_slf4j_slf4j_api", + "@nv_third_party_deps//:org_springframework_boot_spring_boot", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_actuator_autoconfigure", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_autoconfigure", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_health", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_actuator", + "@nv_third_party_deps//:org_springframework_cloud_spring_cloud_context", + "@nv_third_party_deps//:org_springframework_spring_beans", + "@nv_third_party_deps//:org_springframework_spring_context", + "@nv_third_party_deps//:org_springframework_spring_core", +] + +# Maven optional/provided integrations must compile with the starter, but they +# must not become runtime dependencies of every downstream Bazel application. +CORE_OPTIONAL_COMPILE_DEPS = [ + "@nv_third_party_deps//:io_swagger_core_v3_swagger_models_jakarta", + "@nv_third_party_deps//:jakarta_servlet_jakarta_servlet_api", + "@nv_third_party_deps//:org_springdoc_springdoc_openapi_starter_webflux_api", + "@nv_third_party_deps//:org_springdoc_springdoc_openapi_starter_webmvc_api", + "@nv_third_party_deps//:org_springframework_spring_web", + "@nv_third_party_deps//:org_springframework_spring_webflux", + "@nv_third_party_deps//:org_springframework_spring_webmvc", +] + +java_library( + name = "optional_compile_deps", + exports = CORE_OPTIONAL_COMPILE_DEPS, + neverlink = True, + visibility = ["//visibility:private"], +) + +nv_boot_library( + name = "nv_boot_starter_core", + srcs = glob(["src/main/java/**/*.java"]), + resource_strip_prefix = "src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/resources", + resources = glob(["src/main/resources/**"]), + visibility = ["//visibility:public"], + deps = CORE_REQUIRED_DEPS + [":optional_compile_deps"], +) + +nv_boot_runtime_classpath_test( + name = "runtime_scope_test", + target = ":nv_boot_starter_core", + forbidden_artifacts = [ + "jakarta.servlet-api", + "spring-web-", + "spring-webflux-", + "spring-webmvc-", + "springdoc-openapi-starter-webflux-api", + "springdoc-openapi-starter-webmvc-api", + "swagger-models-jakarta", + ], +) + +nv_boot_library_test( + name = "tests", + srcs = glob(["src/test/java/**/*.java"]), + coverage_library = ":nv_boot_starter_core", + resource_strip_prefix = "src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/resources", + resources = glob(["src/test/resources/**"]), + deps = [ + ":nv_boot_starter_core", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_actuator_test", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_webflux_test", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_webmvc_test", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_webmvc_test", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_webtestclient", + ] + CORE_REQUIRED_DEPS + CORE_OPTIONAL_COMPILE_DEPS, +) diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/README.md b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/README.md new file mode 100644 index 0000000000..39e9181071 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/README.md @@ -0,0 +1,165 @@ +# NV Boot Starter Core + +Core utilities and shared configuration for NV Boot applications. Provides defaults, +DNS cache TTL, environment post-processors, health endpoint, and OpenAPI governance. + +## Adding as a Dependency + +Add the library as a dependency in your application's `pom.xml`: + +```xml + + + + com.nvidia.boot + nv-boot-bom + ${nv-boot.version} + pom + import + + + + + + + com.nvidia.boot + nv-boot-starter-core + + +``` + +Depending on the type of web application used, add a dependency on correct spring web starter. + +```yaml +spring: + main: + web-application-type: [reactive|servlet] +``` + +MVC/Servlet: +```xml + + + org.springframework.boot + spring-boot-starter-web + + +``` + +Webflux/Reactive: +```xml + + + org.springframework.boot + spring-boot-starter-webflux + + +``` + +## Configuration + +Configure via `application.yaml` using `springdoc.*` and `management.*` properties: + +```yaml +springdoc: + api-docs: + path: /v3/openapi + packages-to-scan: com.nvidia + info: + title: "My API" + description: "My API Description" + version: "1.0.0" +``` + +## Auto Configured Beans + +The starter autoconfigures and registers the following beans with the Spring application +context: + +| Bean | Type | Description | +|------|------|-------------| +| HealthResponseCacheProperties | configuration | `java.time.Duration` TTL for caching `GET /health` responses; default **3s** if you do not register your own bean | +| CachedHealthResponseService | service | Caches minimal `/health` responses per TTL | +| HealthController | `@RestController` | `GET /health` endpoint with status, application, version | +| ApplicationHealthIndicator | `HealthIndicator` | Adds app name, profile, version to health response | +| OpenAPI | `OpenAPI` | Default API metadata (title, version, contact) when none exists; uses `springdoc.info.*` | +| OpenApiCustomizer | `OpenApiCustomizer` | Applies governance to component schemas | + +## Auto Configuration + +The library uses `CoreAutoConfiguration` as the single entry point, which imports: + +| Class | Package | Description | +|----------------------------------|---------|------------------------------------------------------------------------------------------------------------------------------| +| ServletCoreCorsConfiguration | cors | Conditional on Servlet Application Type: registers FilterRegistrationBean for CORS | +| ReactiveCoreCorsConfiguration | cors | Conditional on Reactive Application Type: registers CorsWebFilter for CORS | +| HealthConfiguration | health | Registers HealthResponseCacheProperties (default), CachedHealthResponseService, HealthController, ApplicationHealthIndicator | +| OpenApiConfiguration | openapi | Registers default OpenAPI bean when none exists | +| ServletOpenApiCorsConfiguration | openapi | Conditional on Servlet Application Type: registers FilterRegistrationBean for api-docs path | +| ReactiveOpenApiCorsConfiguration | openapi | Conditional on Reactive Application Type: registers WebFilter for api-docs path | + +## Bootstrap Configuration + +The library uses following bootstrap configuration classes (in `bootstrap` package): + +| Class | Description | +|-----------------------------|--------------| +| DnsCacheBootstrapConfiguration | Sets JVM DNS cache TTL to 60 seconds | +| MiscBootstrapConfiguration | Validates active profiles at startup | + +## EnvironmentPostProcessor + +The library uses following environment post processors (in `env` package): + +| Class | Description | +|-------------------------------|--------------| +| BootCoreEnvironmentPostProcessor | Loads default properties and version from git.properties | +| ValidateEnvironmentPostProcessor | Validates required properties (spring.application.name, spring.application.version, spring.profiles.active) at startup | + +## Health response cache (`GET /health`) + +`GET /health` responses are cached to reduce load from frequent probes. The default +time-to-live is **3 seconds**. + +To use a custom TTL, register a `HealthResponseCacheProperties` bean (for example 5 seconds): + +```java +@Configuration +class MyHealthCacheConfiguration { + @Bean + HealthResponseCacheProperties healthResponseCacheProperties() { + return new HealthResponseCacheProperties(Duration.ofSeconds(5)); + } +} +``` + +If this bean is **absent**, the starter defaults TTL to **3 seconds**. + +## Warmup + +The `com.nvidia.boot.core.warmup` package provides `BootWarmupBase` for running cache or dependency +warmup after the application is ready. + +- Extend `BootWarmupBase` and implement `createWarmupTasks()` to return a list of + `BootWarmupBase.WarmupRunnable` tasks (each has a name, execution order, and `Runnable` body). +- Register the concrete class as a Spring bean (for example `@Component`). +- Pass `true` to the constructor to keep Actuator health **DOWN** until warmup completes, or + pass a `Duration` to cap how long health stays down before timing out (default cap is 5 minutes). + +Example: + +```java +@Component +public class MyWarmup extends BootWarmupBase { + + public MyWarmup(MyService myService) { + super(true); // block health until warmup completes + } + + @Override + public List createWarmupTasks() { + return List.of( + new WarmupRunnable("load-cache", 0, () -> { /* ... */ })); + } +} +``` diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/pom.xml b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/pom.xml new file mode 100644 index 0000000000..593754b105 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/pom.xml @@ -0,0 +1,120 @@ + + + + + 4.0.0 + + + com.nvidia.boot + nv-boot-parent + 0.0.1-SNAPSHOT + + + nv-boot-starter-core + jar + NV Boot Starter Core + Core utilities and shared configuration for NV Boot applications + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework + spring-web + true + + + + jakarta.servlet + jakarta.servlet-api + true + provided + + + org.springframework + spring-webmvc + true + provided + + + + org.springframework + spring-webflux + true + provided + + + org.springframework.cloud + spring-cloud-context + + + org.projectlombok + lombok + true + + + com.github.ben-manes.caffeine + guava + + + org.springdoc + springdoc-openapi-starter-webmvc-api + ${springdoc-openapi.version} + true + + + org.springdoc + springdoc-openapi-starter-webflux-api + ${springdoc-openapi.version} + true + + + org.apache.commons + commons-lang3 + + + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + org.springframework.boot + spring-boot-starter-webflux-test + test + + + org.springframework.boot + spring-boot-starter-actuator-test + test + + + diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/CoreAutoConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/CoreAutoConfiguration.java new file mode 100644 index 0000000000..f5bd919f33 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/CoreAutoConfiguration.java @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core; + +import com.nvidia.boot.core.cors.ServletCoreCorsConfiguration; +import com.nvidia.boot.core.cors.ReactiveCoreCorsConfiguration; +import com.nvidia.boot.core.health.HealthConfiguration; +import com.nvidia.boot.core.openapi.OpenApiConfiguration; +import com.nvidia.boot.core.openapi.ServletOpenApiCorsConfiguration; +import com.nvidia.boot.core.openapi.ReactiveOpenApiCorsConfiguration; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Import; + +@AutoConfiguration +@Import({ + HealthConfiguration.class, + OpenApiConfiguration.class, + ReactiveCoreCorsConfiguration.class, + ReactiveOpenApiCorsConfiguration.class, + ServletCoreCorsConfiguration.class, + ServletOpenApiCorsConfiguration.class +}) +public class CoreAutoConfiguration { +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/bootstrap/DnsCacheBootstrapConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/bootstrap/DnsCacheBootstrapConfiguration.java new file mode 100644 index 0000000000..16e184b0bc --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/bootstrap/DnsCacheBootstrapConfiguration.java @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core.bootstrap; + +import jakarta.annotation.PostConstruct; +import java.security.Security; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cloud.bootstrap.BootstrapConfiguration; + +/** + * Sets JVM DNS cache TTL to 60 seconds. + * Ensures DNS entries are refreshed regularly for cloud environments where IP addresses may change. + */ +@Slf4j +@BootstrapConfiguration +public class DnsCacheBootstrapConfiguration { + + private static final String DNS_CACHE_TTL_SECONDS = "60"; + + @PostConstruct + public void configureDnsCacheTtl() { + var currentTtl = Security.getProperty("networkaddress.cache.ttl"); + if (currentTtl == null || !DNS_CACHE_TTL_SECONDS.equals(currentTtl)) { + Security.setProperty("networkaddress.cache.ttl", DNS_CACHE_TTL_SECONDS); + log.info("Set networkaddress.cache.ttl to {} seconds", DNS_CACHE_TTL_SECONDS); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/bootstrap/MiscBootstrapConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/bootstrap/MiscBootstrapConfiguration.java new file mode 100644 index 0000000000..1f742fe6c3 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/bootstrap/MiscBootstrapConfiguration.java @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core.bootstrap; + +import java.util.Arrays; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.cloud.bootstrap.BootstrapConfiguration; +import org.springframework.core.env.Environment; + +@Slf4j +@BootstrapConfiguration +public class MiscBootstrapConfiguration { + private static final String MESG_ACTIVE_PROFILES = "The following {}: {}"; + private static final String MESG_MISSING_ACTIVE_PROFILES = + "Property 'spring.profiles.active' must be defined"; + + public MiscBootstrapConfiguration(Environment environment) { + var activeProfiles = Arrays.asList(environment.getActiveProfiles()); + if (activeProfiles.isEmpty()) { + log.error(MESG_MISSING_ACTIVE_PROFILES); + throw new IllegalArgumentException(MESG_MISSING_ACTIVE_PROFILES); + } + + var message = (activeProfiles.size() == 1) ? "1 profile is active: " + : activeProfiles.size() + " profiles are active: "; + var profiles = activeProfiles.stream() + .map(profile -> "\"" + profile + "\"") + .collect(Collectors.toList()); + log.info(MESG_ACTIVE_PROFILES, message, StringUtils.join(profiles, ", ")); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/cors/ReactiveCoreCorsConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/cors/ReactiveCoreCorsConfiguration.java new file mode 100644 index 0000000000..5a4b9c21e8 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/cors/ReactiveCoreCorsConfiguration.java @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core.cors; + +import com.google.common.annotations.VisibleForTesting; +import java.time.Duration; +import java.util.List; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.reactive.CorsWebFilter; +import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource; + +@Configuration +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) +public class ReactiveCoreCorsConfiguration { + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE + 1) + public CorsWebFilter corsWebFilter() { + var source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", corsConfig()); + return new CorsWebFilter(source); + } + + @VisibleForTesting + static CorsConfiguration corsConfig() { + var config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.addAllowedHeader(CorsConfiguration.ALL); + config.addAllowedMethod(CorsConfiguration.ALL); + config.setMaxAge(Duration.ofDays(1)); + config.setAllowedOriginPatterns(List.of(CorsConfiguration.ALL)); + config.setExposedHeaders(List.of(CorsConfiguration.ALL)); + return config; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/cors/ServletCoreCorsConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/cors/ServletCoreCorsConfiguration.java new file mode 100644 index 0000000000..d830eb2424 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/cors/ServletCoreCorsConfiguration.java @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core.cors; + +import com.google.common.annotations.VisibleForTesting; +import java.time.Duration; +import java.util.List; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +@Configuration +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +public class ServletCoreCorsConfiguration { + + @Bean + public FilterRegistrationBean corsFilterFilterRegistrationBean() { + var source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", corsConfig()); + + var registrationBean = new FilterRegistrationBean(); + registrationBean.setFilter(new CorsFilter(source)); + registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE + 1); + return registrationBean; + } + + @VisibleForTesting + static CorsConfiguration corsConfig() { + var config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.addAllowedHeader(CorsConfiguration.ALL); + config.addAllowedMethod(CorsConfiguration.ALL); + config.setMaxAge(Duration.ofDays(1)); + config.setAllowedOriginPatterns(List.of(CorsConfiguration.ALL)); + config.setExposedHeaders(List.of(CorsConfiguration.ALL)); + return config; + } + +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/env/BootCoreEnvironmentPostProcessor.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/env/BootCoreEnvironmentPostProcessor.java new file mode 100644 index 0000000000..17d45a0102 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/env/BootCoreEnvironmentPostProcessor.java @@ -0,0 +1,135 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core.env; + +import java.io.IOException; +import java.util.HashMap; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.EnvironmentPostProcessor; +import org.springframework.core.Ordered; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.PropertiesPropertySource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.support.PropertiesLoaderUtils; + +/** + * Environment post processor that loads default properties and + * spring.application.version from git.properties when present. + * + *

Order is: "git.closest.tag.name" -> "git.build.version" -> "git.commit.id.abbrev" + * + * + *

Logback configuration has been moved to nv-boot-starter-observability. + */ +@Slf4j +public class BootCoreEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered { + + private static final String CORE_DEFAULTS_FILE = "nv-boot-core-defaults.properties"; + private static final String GIT_PROPERTIES_FILE = "git.properties"; + + @Override + public void postProcessEnvironment( + ConfigurableEnvironment environment, + SpringApplication application) { + loadDefaultProperties(environment); + loadAppVersionFromGitProperties(environment); + } + + private void loadDefaultProperties(ConfigurableEnvironment environment) { + loadPropertiesFile(environment, CORE_DEFAULTS_FILE, "nv-boot-core-defaults"); + } + + private void loadPropertiesFile( + ConfigurableEnvironment environment, + String filename, + String sourceName) { + try { + var resource = new ClassPathResource(filename); + if (resource.exists()) { + var properties = PropertiesLoaderUtils.loadProperties(resource); + environment.getPropertySources().addLast( + new PropertiesPropertySource(sourceName, properties) + ); + } + } catch (IOException e) { + // Log warning but don't fail startup. + log.warn("Failed to load properties file '{}'", filename); + } + } + + private void loadAppVersionFromGitProperties(ConfigurableEnvironment environment) { + try { + var resource = new ClassPathResource(GIT_PROPERTIES_FILE); + if (resource.exists()) { + var gitProperties = PropertiesLoaderUtils.loadProperties(resource); + var versionProps = new HashMap(); + + // Only set spring.application.version from git when not already present. + // Spring Boot 3.4+ populates it from MANIFEST.MF via ApplicationInfoPropertySource + // (https://github.com/spring-projects/spring-boot/commit/f4b4f4f). + // moveToEnd() seems to be called after this post-processor runs, so ApplicationInfoPropertySource never overrides this + // endresult is always [..., "nv-boot-git-properties", "applicationInfo", "..."] + var existingVersion = environment.getProperty("spring.application.version"); + if (StringUtils.isBlank(existingVersion)) { + var version = gitProperties.getProperty("git.closest.tag.name"); + + if (StringUtils.isBlank(version)) { + version = gitProperties.getProperty("git.build.version"); + } + + if (StringUtils.isBlank(version)) { + version = gitProperties.getProperty("git.commit.id.abbrev", "unknown"); + } + + versionProps.put("spring.application.version", version); + } + + var commitId = gitProperties.getProperty("git.commit.id.abbrev"); + if (StringUtils.isNotBlank(commitId)) { + versionProps.put("app.git.commit", commitId); + } + + var branch = gitProperties.getProperty("git.branch"); + if (StringUtils.isNotBlank(branch)) { + versionProps.put("app.git.branch", branch); + } + + var tag = gitProperties.getProperty("git.closest.tag.name"); + if (StringUtils.isNotBlank(tag)) { + versionProps.put("app.git.tag", tag); + } + + if (!versionProps.isEmpty()) { + environment.getPropertySources().addLast( + new MapPropertySource("nv-boot-git-properties", versionProps) + ); + } + } + } catch (IOException e) { + // git.properties not available - this is fine + } + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE - 10; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/env/ValidateEnvironmentPostProcessor.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/env/ValidateEnvironmentPostProcessor.java new file mode 100644 index 0000000000..aa4c2d103e --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/env/ValidateEnvironmentPostProcessor.java @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core.env; + +import java.util.ArrayList; +import java.util.List; +import org.apache.commons.lang3.StringUtils; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.EnvironmentPostProcessor; +import org.springframework.core.Ordered; +import org.springframework.core.env.ConfigurableEnvironment; + +/** + * Validates that required properties are configured at startup. + * Fails fast if critical configuration is missing. + */ +public class ValidateEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered { + + private static final List REQUIRED_PROPERTIES = List.of( + "spring.application.name", + "spring.application.version", + "spring.profiles.active" + ); + + @Override + public void postProcessEnvironment( + ConfigurableEnvironment environment, + SpringApplication application) { + var missingRequired = new ArrayList(); + + for (var property : REQUIRED_PROPERTIES) { + if (!environment.containsProperty(property) + || StringUtils.isBlank(environment.getProperty(property))) { + missingRequired.add(property); + } + } + + if (!missingRequired.isEmpty()) { + throw new IllegalStateException( + "Required properties not configured: " + missingRequired + + ". Please set these properties in application.yaml or " + + "environment variables." + ); + } + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/health/ApplicationHealthIndicator.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/health/ApplicationHealthIndicator.java new file mode 100644 index 0000000000..81733872af --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/health/ApplicationHealthIndicator.java @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core.health; + +import java.util.Optional; +import org.springframework.boot.health.contributor.AbstractHealthIndicator; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.info.BuildProperties; +import org.springframework.core.env.Environment; + +/** + * Health indicator that adds application metadata to health response. + */ +public class ApplicationHealthIndicator extends AbstractHealthIndicator { + + private final Environment environment; + private final Optional buildProperties; + + public ApplicationHealthIndicator(Environment environment, + Optional buildProperties) { + this.environment = environment; + this.buildProperties = buildProperties; + } + + @Override + protected void doHealthCheck(Health.Builder builder) { + var name = environment.getProperty("spring.application.name", "unknown"); + var profiles = String.join(",", environment.getActiveProfiles()); + var version = buildProperties + .map(BuildProperties::getVersion) + .orElse(environment.getProperty("spring.application.version", "unknown")); + + builder.up() + .withDetail("application", name) + .withDetail("application.profile", profiles) + .withDetail("version", version); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/health/CachedHealthResponseService.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/health/CachedHealthResponseService.java new file mode 100644 index 0000000000..1e9823f204 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/health/CachedHealthResponseService.java @@ -0,0 +1,84 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core.health; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicReference; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.actuate.endpoint.HealthEndpoint; +import org.springframework.boot.health.contributor.Status; +import org.springframework.http.ResponseEntity; + +/** + * Caches minimal {@code /health} responses per {@link HealthResponseCacheProperties#timeToLive()} + * to reduce load from frequent probes. + */ +public class CachedHealthResponseService { + + private final HealthEndpoint healthEndpoint; + private final HealthResponseCacheProperties healthResponseCacheProperties; + + private final AtomicReference cache = new AtomicReference<>(); + + public CachedHealthResponseService( + HealthEndpoint healthEndpoint, HealthResponseCacheProperties healthResponseCacheProperties) { + this.healthEndpoint = healthEndpoint; + this.healthResponseCacheProperties = healthResponseCacheProperties; + } + + // Returns a cached ResponseEntity when still fresh; otherwise refreshes from HealthEndpoint#health(). + public ResponseEntity getHealth() { + long accessTime = System.currentTimeMillis(); + var cached = cache.get(); + if (cached == null + || cached.isStale(accessTime, healthResponseCacheProperties.timeToLive())) { + var health = healthEndpoint.health(); + var status = health.getStatus(); + var healthComponent = Health.status(status).build(); + ResponseEntity response; + if (Status.UP.equals(status)) { + response = ResponseEntity.ok(healthComponent); + } else if (Status.DOWN.equals(status)) { + response = ResponseEntity.status(503).body(healthComponent); + } else { + response = ResponseEntity.status(500).body(healthComponent); + } + cache.set(new CachedEntry(response, accessTime)); + return response; + } + return cached.response(); + } + + private static final class CachedEntry { + private final ResponseEntity response; + private final long creationTime; + + private CachedEntry(ResponseEntity response, long creationTime) { + this.response = response; + this.creationTime = creationTime; + } + + private boolean isStale(long accessTime, Duration timeToLive) { + return (accessTime - creationTime) >= timeToLive.toMillis(); + } + + private ResponseEntity response() { + return response; + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/health/HealthConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/health/HealthConfiguration.java new file mode 100644 index 0000000000..52efc5fbd9 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/health/HealthConfiguration.java @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core.health; + +import java.util.Optional; +import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint; +import org.springframework.boot.health.actuate.endpoint.HealthEndpoint; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.info.BuildProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +/** Auto-configuration for health endpoint and controller. */ +@Configuration +@ConditionalOnWebApplication +@ConditionalOnClass(HealthEndpoint.class) +public class HealthConfiguration { + + // Default cache TTL for GET /health when the app does not define its own + // HealthResponseCacheProperties bean. Applications may override with a @Bean of + // type HealthResponseCacheProperties. + @Bean + @ConditionalOnMissingBean(HealthResponseCacheProperties.class) + @ConditionalOnAvailableEndpoint(endpoint = HealthEndpoint.class) + public HealthResponseCacheProperties healthResponseCacheProperties() { + return HealthResponseCacheProperties.ofDefaults(); + } + + @Bean + @ConditionalOnAvailableEndpoint(endpoint = HealthEndpoint.class) + public CachedHealthResponseService cachedHealthResponseService( + HealthEndpoint healthEndpoint, + HealthResponseCacheProperties healthResponseCacheProperties) { + return new CachedHealthResponseService(healthEndpoint, healthResponseCacheProperties); + } + + @Bean + @ConditionalOnAvailableEndpoint(endpoint = HealthEndpoint.class) + public HealthController healthController( + CachedHealthResponseService cachedHealthResponseService) { + return new HealthController(cachedHealthResponseService); + } + + @Bean + public ApplicationHealthIndicator applicationHealthIndicator( + Environment environment, + Optional buildProperties) { + return new ApplicationHealthIndicator(environment, buildProperties); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/health/HealthController.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/health/HealthController.java new file mode 100644 index 0000000000..b762a5be91 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/health/HealthController.java @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core.health; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.health.contributor.Health; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Custom health controller for {@code GET /health}. Returns {@link Health} with only + * status and matching HTTP status. Responses are cached per {@link HealthResponseCacheProperties}. + */ +@RestController +@RequiredArgsConstructor +public class HealthController { + + private final CachedHealthResponseService cachedHealthResponseService; + + @GetMapping("/health") + public ResponseEntity getHealth() { + return cachedHealthResponseService.getHealth(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/health/HealthResponseCacheProperties.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/health/HealthResponseCacheProperties.java new file mode 100644 index 0000000000..b3b7619c14 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/health/HealthResponseCacheProperties.java @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core.health; + +import java.time.Duration; + +/** + * Configuration for caching {@code GET /health} responses in {@link CachedHealthResponseService}. + * + *

Applications may register a {@link org.springframework.context.annotation.Bean} of this type + * to set the cache time-to-live. If no bean is present, the starter registers a default with {@link + * #DEFAULT_TIME_TO_LIVE}. + * + *

{@code
+ * @Configuration
+ * class MyAppHealthConfiguration {
+ *     @Bean
+ *     HealthResponseCacheProperties healthResponseCacheProperties() {
+ *         return HealthResponseCacheProperties.of(Duration.ofSeconds(5));
+ *     }
+ * }
+ * }
+ */ +public record HealthResponseCacheProperties(Duration timeToLive) { + + // Default TTL when the application does not define a HealthResponseCacheProperties bean. + public static final Duration DEFAULT_TIME_TO_LIVE = Duration.ofSeconds(3); + + public HealthResponseCacheProperties { + if (timeToLive.isNegative()) { + throw new IllegalArgumentException("timeToLive must not be negative"); + } + } + + // Default TTL; used by the starter when no custom bean is registered. + public static HealthResponseCacheProperties ofDefaults() { + return new HealthResponseCacheProperties(DEFAULT_TIME_TO_LIVE); + } + + // Custom TTL. Zero means entries are always considered stale (refresh on every request). + public static HealthResponseCacheProperties of(Duration timeToLive) { + return new HealthResponseCacheProperties(timeToLive); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/openapi/OpenApiConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/openapi/OpenApiConfiguration.java new file mode 100644 index 0000000000..71ec19dd1b --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/openapi/OpenApiConfiguration.java @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core.openapi; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +/** + * Provides a default OpenAPI bean when none exists. + * Uses springdoc.info.* and falls back to spring.application.name/version. + */ +@Configuration +@ConditionalOnClass(name = "io.swagger.v3.oas.models.OpenAPI") +@ConditionalOnMissingBean(OpenAPI.class) +@EnableConfigurationProperties(SpringDocProperties.class) +@RequiredArgsConstructor +public class OpenApiConfiguration { + + private final SpringDocProperties properties; + private final Environment environment; + + @Bean + public OpenAPI customOpenAPI() { + var info = new Info() + .title(resolveTitle()) + .description(properties.getInfo().getDescription()) + .version(resolveVersion()); + + var contact = properties.getInfo().getContact(); + info.contact(new Contact() + .name(StringUtils.isNotBlank(contact.getName()) ? contact.getName() : "NVIDIA") + .email(contact.getEmail()) + .url(StringUtils.isNotBlank(contact.getUrl()) ? contact.getUrl() : + "https://www.nvidia.com/")); + + var termsOfService = properties.getInfo().getTermsOfService(); + info.termsOfService(StringUtils.isNotBlank(termsOfService) ? termsOfService : + "https://www.nvidia.com/en-us/legal_info"); + + return new OpenAPI().info(info); + } + + private String resolveTitle() { + var title = properties.getInfo().getTitle(); + if (StringUtils.isNotBlank(title)) { + return title; + } + var appName = environment.getProperty("spring.application.name", "API"); + return capitalizeFully(appName.replace("-", " ")); + } + + private String resolveVersion() { + var version = properties.getInfo().getVersion(); + if (StringUtils.isNotBlank(version)) { + return version; + } + return environment.getProperty("spring.application.version", "1.0.0"); + } + + private static String capitalizeFully(String str) { + if (StringUtils.isBlank(str)) { + return str; + } + var words = str.split("\\s+"); + var result = new StringBuilder(); + for (var i = 0; i < words.length; i++) { + if (i > 0) { + result.append(" "); + } + var word = words[i]; + if (!word.isEmpty()) { + result.append(Character.toUpperCase(word.charAt(0))) + .append(word.substring(1).toLowerCase()); + } + } + return result.toString(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/openapi/ReactiveOpenApiCorsConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/openapi/ReactiveOpenApiCorsConfiguration.java new file mode 100644 index 0000000000..5e93366a43 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/openapi/ReactiveOpenApiCorsConfiguration.java @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core.openapi; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.server.WebFilter; + +@Configuration +@ConditionalOnProperty(name = "springdoc.api-docs.cors.enabled", matchIfMissing = true) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) +public class ReactiveOpenApiCorsConfiguration { + + @Bean + public WebFilter openApiReactiveCorsFilter( + @Value("${springdoc.api-docs.path:/v3/openapi}") String apiDocsPath) { + return (exchange, chain) -> { + if (exchange.getRequest().getURI().getPath().equals(apiDocsPath)) { + var headers = exchange.getResponse().getHeaders(); + headers.set("Access-Control-Allow-Origin", "*"); + headers.set("Access-Control-Allow-Methods", "*"); + headers.set("Access-Control-Max-Age", "3600"); + headers.set("Access-Control-Allow-Credentials", "true"); + headers.set("Access-Control-Allow-Headers", "*"); + } + return chain.filter(exchange); + }; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/openapi/ServletOpenApiCorsConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/openapi/ServletOpenApiCorsConfiguration.java new file mode 100644 index 0000000000..5d182d4f43 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/openapi/ServletOpenApiCorsConfiguration.java @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core.openapi; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.filter.OncePerRequestFilter; + +@Configuration +@ConditionalOnProperty(name = "springdoc.api-docs.cors.enabled", matchIfMissing = true) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +public class ServletOpenApiCorsConfiguration { + + @Bean + public FilterRegistrationBean swaggerCorsFilter( + @Value("${springdoc.api-docs.path:/v3/openapi}") String apiDocsPath) { + var registrationBean = new FilterRegistrationBean(); + registrationBean.setFilter(new OpenApiCorsFilter()); + registrationBean.addUrlPatterns(apiDocsPath); + return registrationBean; + } + + public static class OpenApiCorsFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Access-Control-Allow-Methods", "*"); + response.setHeader("Access-Control-Max-Age", "3600"); + response.setHeader("Access-Control-Allow-Credentials", "true"); + response.setHeader("Access-Control-Allow-Headers", "*"); + filterChain.doFilter(request, response); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/openapi/SpringDocProperties.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/openapi/SpringDocProperties.java new file mode 100644 index 0000000000..af2107f1fb --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/openapi/SpringDocProperties.java @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core.openapi; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for SpringDoc/OpenAPI. + * Binds to springdoc.* in application.yaml. + */ +@ConfigurationProperties(prefix = "springdoc") +@Getter +@Setter +public class SpringDocProperties { + + private ApiGov apiGov = new ApiGov(); + private Info info = new Info(); + + @Getter + @Setter + public static class ApiGov { + private boolean enabled = true; + private int maxStringLength = 262144; + } + + @Getter + @Setter + public static class Info { + private String title; + private String description; + private String version; // cannot have a default. If not set, resolves to spring.application.version + private String termsOfService; + private Contact contact = new Contact(); + + @Getter + @Setter + public static class Contact { + private String name; + private String email; + private String url; + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/warmup/BootWarmupBase.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/warmup/BootWarmupBase.java new file mode 100644 index 0000000000..c93a194cdb --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/java/com/nvidia/boot/core/warmup/BootWarmupBase.java @@ -0,0 +1,184 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core.warmup; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.HealthIndicator; +import org.springframework.context.ApplicationListener; + +/** + * Base class for cache and dependency warmup at application start. Implement {@link + * #createWarmupTasks()} in a concrete type annotated with {@code @Component}; the health indicator + * reports {@link Health#up()} after warmup completes when blocking is enabled. + */ +@Slf4j +public abstract class BootWarmupBase + implements HealthIndicator, ApplicationListener { + + // Cap for default timeout when maxTimeout is null or non-positive. + private static final long MAX_TIMEOUT_MINUTES = 5; + + @Getter + @Setter(AccessLevel.PRIVATE) + private boolean warmingComplete; + + @Getter + @Setter(AccessLevel.PRIVATE) + private boolean blockHealthStatusUntilComplete; + + // Latest instant after which health() stops reporting DOWN due to incomplete warmup (default + // cap MAX_TIMEOUT_MINUTES when maxTimeout is not set). + @Getter + @Setter(AccessLevel.PRIVATE) + private Instant timeoutExceeded; + + // blockHealthStatusUntilComplete: if true, health stays DOWN until warmup finishes (or timeout). + // Warmup still runs when false. + protected BootWarmupBase(boolean blockHealthStatusUntilComplete) { + this(blockHealthStatusUntilComplete, null); + } + + // blockHealthStatusUntilComplete: if true, health stays DOWN until warmup finishes (or timeout). + // maxTimeout: max wait for createWarmupTasks() before health reverts; null or non-positive uses + // MAX_TIMEOUT_MINUTES minutes. + protected BootWarmupBase(boolean blockHealthStatusUntilComplete, Duration maxTimeout) { + this.setBlockHealthStatusUntilComplete(blockHealthStatusUntilComplete); + if (maxTimeout != null && !maxTimeout.isZero() && !maxTimeout.isNegative()) { + this.setTimeoutExceeded(Instant.now().plus(maxTimeout)); + } else { + this.setTimeoutExceeded(Instant.now().plus(Duration.ofMinutes(MAX_TIMEOUT_MINUTES))); + } + } + + @Override + public Health health() { + String key = this.getClass().getName() + ".CacheStatus"; + + if (this.isBlockHealthStatusUntilComplete() + && !this.isWarmingComplete() + && timeoutExceeded.isBefore(Instant.now())) { + log.info("Warmup did not complete in time. Re-enabling health check"); + this.setWarmingComplete(true); + } + + if (!this.isBlockHealthStatusUntilComplete() || this.isWarmingComplete()) { + return Health.up().withDetail(key, "Warming Complete").build(); + } + + return Health.down().withDetail(key, "Warming Incomplete").build(); + } + + @Override + public void onApplicationEvent(ApplicationReadyEvent event) { + var start = Instant.now(); + long totalSuccesses = 0; + int total = 0; + var runnables = this.createWarmupTasks(); + var map = runnables.stream().collect(Collectors.groupingBy(WarmupRunnable::getOrder)); + + var ordering = map.keySet().stream().sorted().toList(); + + // In Java 9+, ForkJoinPool.commonPool() (in CompletableFuture.supplyAsync()) threads use the system AppClassLoader + // However, AppClassLoader cannot load classes (libs) that Spring needs (it uses LaunchedURLClassLoader). + // Capture the caller's classloader so we can propagate into each worker thread. + // + // https://stackoverflow.com/questions/49113207/completablefuture-forkjoinpool-set-class-loader + // https://github.com/spring-projects/spring-boot/issues/39843 + var callerClassLoader = Thread.currentThread().getContextClassLoader(); + + for (var order : ordering) { + var innerStart = Instant.now(); + var futures = runnables.stream() + .filter(runnable -> runnable.getOrder() == order) + .map(runnable -> CompletableFuture.supplyAsync(() -> { + var originalClassLoader = Thread.currentThread().getContextClassLoader(); + Thread.currentThread().setContextClassLoader(callerClassLoader); + try { + runnable.run(); + log.info("Warmup operation {} succeeded.", runnable.getName()); + return true; + } catch (Exception ex) { + // Swallow the exception. + log.error("Warmup operation {} failed.", runnable.getName(), ex); + return false; + } finally { + Thread.currentThread().setContextClassLoader(originalClassLoader); + } + })) + .toArray(CompletableFuture[]::new); + CompletableFuture.allOf(futures).join(); + + var successCount = Stream.of(futures) + .map(future -> (Boolean) future.join()) + .filter(b -> b) + .count(); + totalSuccesses += successCount; + total += futures.length; + + var innerElapsed = Duration.between(innerStart, Instant.now()); + log.info( + "Order #{} warmup completed with {} / {} tasks succeeding in {}", + order, + successCount, + futures.length, + innerElapsed); + } + + this.setWarmingComplete(true); + + var elapsed = Duration.between(start, Instant.now()); + + log.info( + "{} warmup completed {} / {} tasks in {}", + getClass().getName(), + totalSuccesses, + total, + elapsed); + } + + // Implement in the concrete class. Exceptions are caught per parallel task. + public abstract List createWarmupTasks(); + + @RequiredArgsConstructor + public static class WarmupRunnable implements Runnable { + @Getter + private final String name; + + @Getter + private final int order; + + private final Runnable runnable; + + @Override + public void run() { + this.runnable.run(); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/resources/META-INF/spring.factories b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000000..c9c202896f --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/resources/META-INF/spring.factories @@ -0,0 +1,9 @@ +# Environment Post Processors +org.springframework.boot.EnvironmentPostProcessor=\ +com.nvidia.boot.core.env.BootCoreEnvironmentPostProcessor,\ +com.nvidia.boot.core.env.ValidateEnvironmentPostProcessor + +# Bootstrap Configuration +org.springframework.cloud.bootstrap.BootstrapConfiguration=\ +com.nvidia.boot.core.bootstrap.DnsCacheBootstrapConfiguration,\ +com.nvidia.boot.core.bootstrap.MiscBootstrapConfiguration diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000000..277b8766e6 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +com.nvidia.boot.core.CoreAutoConfiguration diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/resources/nv-boot-core-defaults.properties b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/resources/nv-boot-core-defaults.properties new file mode 100644 index 0000000000..c8f05d7d3b --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/main/resources/nv-boot-core-defaults.properties @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Default properties for nv-boot-starter-core +server.shutdown=graceful +springdoc.api-docs.path=/v3/openapi +springdoc.packages-to-scan=com.nvidia +springdoc.model-and-view-allowed=true +# Addresses issues with Vault Actuator endpoint +# always returning 503. This causes issues with +# deployments that use the health of the service +# to decide whether to rollback or continue. +management.health.vault.enabled=false +management.endpoint.health.cache.time-to-live=1000 +server.http2.enabled=true +# explicitly setting virtual threads to false if present, clients can opt-in and enable +spring.threads.virtual.enabled=false +spring.mvc.problemdetails.enabled=true +spring.webflux.problemdetails.enabled=true diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/NvBootCoreIntegrationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/NvBootCoreIntegrationTest.java new file mode 100644 index 0000000000..563396a9be --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/NvBootCoreIntegrationTest.java @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.nvidia.boot.core.cors.ReactiveCoreCorsConfiguration; +import com.nvidia.boot.core.health.HealthController; +import com.nvidia.boot.core.openapi.ReactiveOpenApiCorsConfiguration; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.test.web.servlet.MockMvc; + +/** + * Integration test for nv-boot-starter-core with a minimal web application. + */ +@SpringBootTest( + classes = TestApplication.class, + properties = { + "spring.application.name=test-app", + "spring.application.version=1.0.0", + "spring.profiles.active=test", + "spring.main.web-application-type=servlet" + }) +@AutoConfigureMockMvc +class NvBootCoreIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private HealthController healthController; + + @Autowired + private ApplicationContext context; + + @Test + void reactiveConfigurationsAreNotLoaded() { + assertThat(context.getBeansOfType(ReactiveCoreCorsConfiguration.class)).isEmpty(); + assertThat(context.getBeansOfType(ReactiveOpenApiCorsConfiguration.class)).isEmpty(); + } + + @Test + void healthEndpointReturnsStatusOnly() throws Exception { + mockMvc.perform(get("/health")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("UP")); + } + + @Test + void corsHeadersPresentOnHealthEndpoint() throws Exception { + var origin = "https://example.com"; + mockMvc.perform(get("/health").header("Origin", origin)) + .andExpect(status().isOk()) + .andExpect(header().string("Access-Control-Allow-Origin", origin)) + .andExpect(header().string("Access-Control-Allow-Credentials", "true")); + } + + @Test + void corsPreflightSucceeds() throws Exception { + var origin = "https://example.com"; + mockMvc.perform(options("/health") + .header("Origin", origin) + .header("Access-Control-Request-Method", "GET")) + .andExpect(status().isOk()) + .andExpect(header().string("Access-Control-Allow-Origin", origin)) + .andExpect(header().string("Access-Control-Allow-Methods", "GET")) + .andExpect(header().string("Access-Control-Allow-Credentials", "true")) + .andExpect(header().string("Access-Control-Max-Age", "86400")); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/NvBootCoreReactiveIntegrationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/NvBootCoreReactiveIntegrationTest.java new file mode 100644 index 0000000000..cf14b633cc --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/NvBootCoreReactiveIntegrationTest.java @@ -0,0 +1,87 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.nvidia.boot.core.cors.ServletCoreCorsConfiguration; +import com.nvidia.boot.core.openapi.ServletOpenApiCorsConfiguration; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; +import org.springframework.context.ApplicationContext; +import org.springframework.test.web.reactive.server.WebTestClient; + +@SpringBootTest( + classes = TestApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "spring.application.name=test-app", + "spring.application.version=1.0.0", + "spring.profiles.active=test", + "spring.main.web-application-type=reactive" + }) +@AutoConfigureWebTestClient +class NvBootCoreReactiveIntegrationTest { + + @Autowired + private WebTestClient webTestClient; + + @Autowired + private ApplicationContext context; + + @Test + void servletConfigurationsAreNotLoaded() { + assertThat(context.getBeansOfType(ServletCoreCorsConfiguration.class)).isEmpty(); + assertThat(context.getBeansOfType(ServletOpenApiCorsConfiguration.class)).isEmpty(); + } + + @Test + void healthEndpointReturnsStatusOnly() { + webTestClient.get().uri("/health") + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$.status").isEqualTo("UP"); + } + + @Test + void corsHeadersPresentOnHealthEndpoint() { + var origin = "https://example.com"; + webTestClient.get().uri("/health") + .header("Origin", origin) + .exchange() + .expectStatus().isOk() + .expectHeader().valueEquals("Access-Control-Allow-Origin", origin) + .expectHeader().valueEquals("Access-Control-Allow-Credentials", "true"); + } + + @Test + void corsPreflightSucceeds() { + var origin = "https://example.com"; + webTestClient.options().uri("/health") + .header("Origin", origin) + .header("Access-Control-Request-Method", "GET") + .exchange() + .expectStatus().isOk() + .expectHeader().valueEquals("Access-Control-Allow-Origin", origin) + .expectHeader().valueEquals("Access-Control-Allow-Credentials", "true") + .expectHeader().valueEquals("Access-Control-Max-Age", "86400"); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/TestApplication.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/TestApplication.java new file mode 100644 index 0000000000..9ac03488f3 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/TestApplication.java @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Import; + +/** + * Minimal test application for integration tests. + * Excludes ValidateEnvironmentPostProcessor via spring.factories override - we use + * @SpringBootTest properties instead. + */ +@SpringBootApplication +@Import(CoreAutoConfiguration.class) +public class TestApplication { + + public static void main(String[] args) { + SpringApplication.run(TestApplication.class, args); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/cors/ReactiveCoreCorsConfigurationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/cors/ReactiveCoreCorsConfigurationTest.java new file mode 100644 index 0000000000..354aacebd4 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/cors/ReactiveCoreCorsConfigurationTest.java @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core.cors; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.reactive.CorsWebFilter; + +class ReactiveCoreCorsConfigurationTest { + + private final ReactiveWebApplicationContextRunner contextRunner = + new ReactiveWebApplicationContextRunner() + .withUserConfiguration( + ReactiveCoreCorsConfiguration.class, + ServletCoreCorsConfiguration.class); + + @Test + void servletCorsBeanIsNotLoaded() { + contextRunner.run(context -> { + assertThat(context).doesNotHaveBean(ServletCoreCorsConfiguration.class); + }); + } + + @Test + void corsWebFilterBeanIsRegistered() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(CorsWebFilter.class); + }); + } + + @Test + void corsConfigHasExpectedSettings() { + var config = ReactiveCoreCorsConfiguration.corsConfig(); + + assertThat(config.getAllowCredentials()).isTrue(); + assertThat(config.getAllowedHeaders()).containsExactly(CorsConfiguration.ALL); + assertThat(config.getAllowedMethods()).containsExactly(CorsConfiguration.ALL); + assertThat(config.getAllowedOriginPatterns()).containsExactly(CorsConfiguration.ALL); + assertThat(config.getExposedHeaders()).containsExactly(CorsConfiguration.ALL); + assertThat(config.getMaxAge()).isEqualTo(86400L); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/cors/ServletCoreCorsConfigurationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/cors/ServletCoreCorsConfigurationTest.java new file mode 100644 index 0000000000..e0a9c1fe34 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/cors/ServletCoreCorsConfigurationTest.java @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core.cors; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.web.cors.CorsConfiguration; + +class ServletCoreCorsConfigurationTest { + + private final WebApplicationContextRunner contextRunner = + new WebApplicationContextRunner() + .withUserConfiguration( + ServletCoreCorsConfiguration.class, + ReactiveCoreCorsConfiguration.class); + + @Test + void reactiveCorsBeanIsNotLoaded() { + contextRunner.run(context -> { + assertThat(context).doesNotHaveBean(ReactiveCoreCorsConfiguration.class); + }); + } + + @Test + void corsFilterBeanIsRegistered() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(FilterRegistrationBean.class); + + var registrationBean = context.getBean(FilterRegistrationBean.class); + assertThat(registrationBean.getFilter()).isNotNull(); + }); + } + + @Test + void corsConfigHasExpectedSettings() { + var config = ServletCoreCorsConfiguration.corsConfig(); + + assertThat(config.getAllowCredentials()).isTrue(); + assertThat(config.getAllowedHeaders()).containsExactly(CorsConfiguration.ALL); + assertThat(config.getAllowedMethods()).containsExactly(CorsConfiguration.ALL); + assertThat(config.getAllowedOriginPatterns()).containsExactly(CorsConfiguration.ALL); + assertThat(config.getExposedHeaders()).containsExactly(CorsConfiguration.ALL); + assertThat(config.getMaxAge()).isEqualTo(86400L); // 1 day in seconds + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/env/BootCoreEnvironmentPostProcessorTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/env/BootCoreEnvironmentPostProcessorTest.java new file mode 100644 index 0000000000..f3ae70d466 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/env/BootCoreEnvironmentPostProcessorTest.java @@ -0,0 +1,167 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core.env; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; +import org.springframework.boot.SpringApplication; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.mock.env.MockEnvironment; + +class BootCoreEnvironmentPostProcessorTest { + + private final BootCoreEnvironmentPostProcessor processor = new BootCoreEnvironmentPostProcessor(); + + @Test + void loadsDefaultPropertiesWhenPresent() { + var environment = new MockEnvironment(); + + processor.postProcessEnvironment(environment, new SpringApplication()); + + // nv-boot-core-defaults.properties exists in resources + assertThat(environment.getProperty("server.shutdown")).isEqualTo("graceful"); + assertThat(environment.getProperty("springdoc.api-docs.path")).isEqualTo("/v3/openapi"); + } + + @Test + void hasCorrectOrder() { + assertThat(processor.getOrder()).isEqualTo(Integer.MAX_VALUE - 10); + } + + @Test + void usesClosestTagNameAsVersionWhenPresent() { + var env = processWithGitProperties(""" + git.closest.tag.name=v2.5.0 + git.build.version=2.5.0-SNAPSHOT + git.commit.id.abbrev=abc1234 + """); + + assertThat(env.getProperty("spring.application.version")).isEqualTo("v2.5.0"); + assertThat(env.getProperty("app.git.tag")).isEqualTo("v2.5.0"); + assertThat(env.getProperty("app.git.commit")).isEqualTo("abc1234"); + assertThat(env.getProperty("app.git.branch")).isNull(); + } + + @Test + void fallsBackToBuildVersionWhenTagAbsent() { + var env = processWithGitProperties(""" + git.build.version=2.5.0-SNAPSHOT + git.commit.id.abbrev=abc1234 + """); + + assertThat(env.getProperty("spring.application.version")).isEqualTo("2.5.0-SNAPSHOT"); + assertThat(env.getProperty("app.git.tag")).isNull(); + assertThat(env.getProperty("app.git.commit")).isEqualTo("abc1234"); + assertThat(env.getProperty("app.git.branch")).isNull(); + } + + @Test + void fallsBackToCommitIdWhenTagAndBuildVersionAbsent() { + var env = processWithGitProperties(""" + git.commit.id.abbrev=abc1234 + """); + + assertThat(env.getProperty("spring.application.version")).isEqualTo("abc1234"); + assertThat(env.getProperty("app.git.tag")).isNull(); + assertThat(env.getProperty("app.git.commit")).isEqualTo("abc1234"); + assertThat(env.getProperty("app.git.branch")).isNull(); + } + + @Test + void fallsBackToUnknownWhenAllVersionFieldsAbsent() { + var env = processWithGitProperties(""" + git.branch=main + """); + + assertThat(env.getProperty("spring.application.version")).isEqualTo("unknown"); + assertThat(env.getProperty("app.git.tag")).isNull(); + assertThat(env.getProperty("app.git.commit")).isNull(); + assertThat(env.getProperty("app.git.branch")).isEqualTo("main"); + } + + @Test + void populatesAllGitMetadataProperties() { + var env = processWithGitProperties(""" + git.closest.tag.name=v2.5.0 + git.build.version=2.5.0-SNAPSHOT + git.commit.id.abbrev=abc1234 + git.branch=main + """); + + assertThat(env.getProperty("app.git.commit")).isEqualTo("abc1234"); + assertThat(env.getProperty("app.git.branch")).isEqualTo("main"); + assertThat(env.getProperty("app.git.tag")).isEqualTo("v2.5.0"); + } + + @Test + void doesNotOverwriteExistingApplicationVersion() { + var env = processWithGitProperties(""" + git.closest.tag.name=v2.4.0 + git.commit.id.abbrev=abc1234 + git.branch=main + """, + "v2.5.0"); + + assertThat(env.getProperty("spring.application.version")).isEqualTo("v2.5.0"); + assertThat(env.getProperty("app.git.commit")).isEqualTo("abc1234"); + assertThat(env.getProperty("app.git.branch")).isEqualTo("main"); + assertThat(env.getProperty("app.git.tag")).isEqualTo("v2.4.0"); + } + + private ConfigurableEnvironment processWithGitProperties(String content) { + return processWithGitProperties(content, null); + } + + private ConfigurableEnvironment processWithGitProperties(String content, String existingVersion) { + var bytes = content.getBytes(StandardCharsets.UTF_8); + var originalCl = Thread.currentThread().getContextClassLoader(); + try { + Thread.currentThread().setContextClassLoader(new ClassLoader(originalCl) { + @Override + public URL getResource(String name) { + if ("git.properties".equals(name)) { + return Object.class.getResource("Object.class"); + } + return super.getResource(name); + } + + @Override + public InputStream getResourceAsStream(String name) { + if ("git.properties".equals(name)) { + return new ByteArrayInputStream(bytes); + } + return super.getResourceAsStream(name); + } + }); + + var environment = new MockEnvironment(); + if (existingVersion != null) { + environment.setProperty("spring.application.version", existingVersion); + } + processor.postProcessEnvironment(environment, new SpringApplication()); + return environment; + } finally { + Thread.currentThread().setContextClassLoader(originalCl); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/env/ValidateEnvironmentPostProcessorTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/env/ValidateEnvironmentPostProcessorTest.java new file mode 100644 index 0000000000..d925006e29 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/env/ValidateEnvironmentPostProcessorTest.java @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core.env; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.SpringApplication; +import org.springframework.mock.env.MockEnvironment; + +class ValidateEnvironmentPostProcessorTest { + + @Test + void throwsWhenRequiredPropertiesMissing() { + var environment = new MockEnvironment(); + var application = new SpringApplication(); + var processor = new ValidateEnvironmentPostProcessor(); + + assertThatThrownBy(() -> processor.postProcessEnvironment(environment, application)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Required properties not configured"); + } + + @Test + void throwsWhenRequiredPropertiesBlank() { + var environment = new MockEnvironment(); + environment.setProperty("spring.application.name", " "); + environment.setProperty("spring.application.version", ""); + environment.setProperty("spring.profiles.active", "test"); + var application = new SpringApplication(); + var processor = new ValidateEnvironmentPostProcessor(); + + assertThatThrownBy(() -> processor.postProcessEnvironment(environment, application)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void succeedsWhenAllRequiredPropertiesSet() { + var environment = new MockEnvironment(); + environment.setProperty("spring.application.name", "my-app"); + environment.setProperty("spring.application.version", "1.0.0"); + environment.setProperty("spring.profiles.active", "test"); + var application = new SpringApplication(); + var processor = new ValidateEnvironmentPostProcessor(); + + processor.postProcessEnvironment(environment, application); + + assertThat(processor.getOrder()).isEqualTo(Integer.MAX_VALUE); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/health/ApplicationHealthIndicatorTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/health/ApplicationHealthIndicatorTest.java new file mode 100644 index 0000000000..2a1f35c50b --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/health/ApplicationHealthIndicatorTest.java @@ -0,0 +1,75 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core.health; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import java.util.Properties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.info.BuildProperties; +import org.springframework.core.env.Environment; + +class ApplicationHealthIndicatorTest { + + private Environment environment; + private ApplicationHealthIndicator indicator; + + @BeforeEach + void setUp() { + environment = mock(Environment.class); + } + + @Test + void healthUsesBuildPropertiesVersionWhenPresent() { + var props = new Properties(); + props.setProperty("version", "2.0.0"); + var buildProperties = new BuildProperties(props); + when(environment.getProperty("spring.application.name", "unknown")).thenReturn("my-app"); + when(environment.getActiveProfiles()).thenReturn(new String[]{"test"}); + indicator = new ApplicationHealthIndicator(environment, Optional.of(buildProperties)); + + Health health = indicator.health(); + + assertThat(health.getStatus().getCode()).isEqualTo("UP"); + assertThat(health.getDetails()) + .containsEntry("application", "my-app") + .containsEntry("application.profile", "test") + .containsEntry("version", "2.0.0"); + } + + @Test + void healthUsesEnvironmentVersionWhenBuildPropertiesAbsent() { + when(environment.getProperty("spring.application.name", "unknown")).thenReturn("my-app"); + when(environment.getActiveProfiles()).thenReturn(new String[]{"prod"}); + when(environment.getProperty("spring.application.version", "unknown")).thenReturn("3.0.0"); + indicator = new ApplicationHealthIndicator(environment, Optional.empty()); + + Health health = indicator.health(); + + assertThat(health.getStatus().getCode()).isEqualTo("UP"); + assertThat(health.getDetails()) + .containsEntry("application", "my-app") + .containsEntry("application.profile", "prod") + .containsEntry("version", "3.0.0"); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/health/CachedHealthResponseServiceTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/health/CachedHealthResponseServiceTest.java new file mode 100644 index 0000000000..ad8e7b8a5e --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/health/CachedHealthResponseServiceTest.java @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core.health; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import org.junit.jupiter.api.Test; +import org.springframework.boot.health.actuate.endpoint.HealthEndpoint; +import org.springframework.boot.health.contributor.Status; + +class CachedHealthResponseServiceTest { + + @Test + void cachesUntilTtlExpires() { + HealthEndpoint healthEndpoint = mock(HealthEndpoint.class); + when(healthEndpoint.health()).thenReturn(HealthDescriptors.withStatus(Status.UP)); + + var service = + new CachedHealthResponseService(healthEndpoint, HealthResponseCacheProperties.of(Duration.ofMinutes(1))); + + service.getHealth(); + service.getHealth(); + service.getHealth(); + + verify(healthEndpoint, times(1)).health(); + } + + @Test + void refreshesWhenTtlIsZero() { + HealthEndpoint healthEndpoint = mock(HealthEndpoint.class); + when(healthEndpoint.health()).thenReturn(HealthDescriptors.withStatus(Status.UP)); + + var service = new CachedHealthResponseService(healthEndpoint, HealthResponseCacheProperties.of(Duration.ZERO)); + + service.getHealth(); + service.getHealth(); + + verify(healthEndpoint, times(2)).health(); + } + + @Test + void defaultConfigurationMatchesDefaultTimeToLive() { + assertThat(HealthResponseCacheProperties.ofDefaults().timeToLive()) + .isEqualTo(HealthResponseCacheProperties.DEFAULT_TIME_TO_LIVE); + } + + @Test + void rejectsNegativeTtl() { + assertThatThrownBy(() -> HealthResponseCacheProperties.of(Duration.ofMillis(-1))) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/health/HealthControllerTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/health/HealthControllerTest.java new file mode 100644 index 0000000000..25d661043b --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/health/HealthControllerTest.java @@ -0,0 +1,81 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core.health; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.health.actuate.endpoint.HealthEndpoint; +import org.springframework.boot.health.contributor.Status; + +class HealthControllerTest { + + private HealthEndpoint healthEndpoint; + private HealthController controller; + + @BeforeEach + void setUp() { + healthEndpoint = mock(HealthEndpoint.class); + // TTL 0: always refresh so tests mirror uncached HealthEndpoint behavior + var cache = new CachedHealthResponseService( + healthEndpoint, HealthResponseCacheProperties.of(Duration.ZERO)); + controller = new HealthController(cache); + } + + @Test + void getHealthReturnsOkWhenStatusUp() { + when(healthEndpoint.health()).thenReturn(healthDescriptorWithStatus(Status.UP)); + + var response = controller.getHealth(); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getStatus().getCode()).isEqualTo("UP"); + } + + @Test + void getHealthReturns503WhenStatusDown() { + when(healthEndpoint.health()).thenReturn(healthDescriptorWithStatus(Status.DOWN)); + + var response = controller.getHealth(); + + assertThat(response.getStatusCode().value()).isEqualTo(503); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getStatus().getCode()).isEqualTo("DOWN"); + } + + @Test + void getHealthReturns500ForUnknownStatus() { + when(healthEndpoint.health()).thenReturn(healthDescriptorWithStatus(Status.UNKNOWN)); + + var response = controller.getHealth(); + + assertThat(response.getStatusCode().value()).isEqualTo(500); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getStatus().getCode()).isEqualTo("UNKNOWN"); + } + + private static org.springframework.boot.health.actuate.endpoint.HealthDescriptor healthDescriptorWithStatus( + Status status) { + return HealthDescriptors.withStatus(status); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/health/HealthDescriptors.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/health/HealthDescriptors.java new file mode 100644 index 0000000000..15644a2e6a --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/health/HealthDescriptors.java @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core.health; + +import org.springframework.boot.health.actuate.endpoint.HealthDescriptor; +import org.springframework.boot.health.actuate.endpoint.IndicatedHealthDescriptor; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.Status; + +final class HealthDescriptors { + + private HealthDescriptors() {} + + static HealthDescriptor withStatus(Status status) { + try { + var constructor = IndicatedHealthDescriptor.class.getDeclaredConstructor(Health.class); + constructor.setAccessible(true); + return constructor.newInstance(Health.status(status).build()); + } catch (ReflectiveOperationException ex) { + throw new IllegalStateException("Failed to create health descriptor for tests", ex); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/openapi/OpenApiConfigurationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/openapi/OpenApiConfigurationTest.java new file mode 100644 index 0000000000..aaf083bd18 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/openapi/OpenApiConfigurationTest.java @@ -0,0 +1,138 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core.openapi; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.swagger.v3.oas.models.OpenAPI; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.core.env.StandardEnvironment; + +class OpenApiConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues( + "spring.application.name=my-api", + "spring.application.version=2.1.0", + "springdoc.info.title=Custom API", + "springdoc.info.description=Custom description", + "springdoc.info.version=3.0.0") + .withUserConfiguration(OpenApiConfiguration.class); + + @Test + void createsOpenApiBeanWithSpringDocInfo() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(OpenAPI.class); + var openApi = context.getBean(OpenAPI.class); + var info = openApi.getInfo(); + + assertThat(info.getTitle()).isEqualTo("Custom API"); + assertThat(info.getDescription()).isEqualTo("Custom description"); + assertThat(info.getVersion()).isEqualTo("3.0.0"); + assertThat(info.getContact().getName()).isEqualTo("NVIDIA"); + assertThat(info.getContact().getUrl()).isEqualTo("https://www.nvidia.com/"); + assertThat(info.getTermsOfService()).isEqualTo("https://www.nvidia.com/en-us/legal_info"); + }); + } + + @Test + void usesDefaultsWhenSpringDocInfoBlank() { + var runner = new ApplicationContextRunner() + .withPropertyValues( + "spring.application.name=my-service", + "spring.application.version=1.0.0") + .withUserConfiguration(OpenApiConfiguration.class); + + runner.run(context -> { + var openApi = context.getBean(OpenAPI.class); + var info = openApi.getInfo(); + + assertThat(info.getTitle()).isEqualTo("My Service"); + assertThat(info.getVersion()).isEqualTo("1.0.0"); + assertThat(info.getContact().getName()).isEqualTo("NVIDIA"); + assertThat(info.getContact().getUrl()).isEqualTo("https://www.nvidia.com/"); + }); + } + + @Test + void usesCustomContactWhenConfigured() { + var runner = new ApplicationContextRunner() + .withPropertyValues( + "spring.application.name=test", + "spring.application.version=1.0.0", + "springdoc.info.contact.name=My Team", + "springdoc.info.contact.email=team@example.com", + "springdoc.info.contact.url=https://example.com") + .withUserConfiguration(OpenApiConfiguration.class); + + runner.run(context -> { + var openApi = context.getBean(OpenAPI.class); + var contact = openApi.getInfo().getContact(); + + assertThat(contact.getName()).isEqualTo("My Team"); + assertThat(contact.getEmail()).isEqualTo("team@example.com"); + assertThat(contact.getUrl()).isEqualTo("https://example.com"); + }); + } + + @Test + void fallsBackToSpringApplicationVersionWhenInfoVersionNotSet() { + var runner = new ApplicationContextRunner() + .withPropertyValues( + "spring.application.name=my-service", + "spring.application.version=5.2.0") + .withInitializer(context -> context.getEnvironment().getPropertySources() + .remove(StandardEnvironment.SYSTEM_PROPERTIES_PROPERTY_SOURCE_NAME)) + .withUserConfiguration(OpenApiConfiguration.class); + + runner.run(context -> { + var openApi = context.getBean(OpenAPI.class); + assertThat(openApi.getInfo().getVersion()).isEqualTo("5.2.0"); + }); + } + + @Test + void usesHardcodedDefaultWhenNoVersionConfigured() { + var runner = new ApplicationContextRunner() + .withPropertyValues("spring.application.name=my-service") + .withInitializer(context -> context.getEnvironment().getPropertySources() + .remove(StandardEnvironment.SYSTEM_PROPERTIES_PROPERTY_SOURCE_NAME)) + .withUserConfiguration(OpenApiConfiguration.class); + + runner.run(context -> { + var openApi = context.getBean(OpenAPI.class); + assertThat(openApi.getInfo().getVersion()).isEqualTo("1.0.0"); + }); + } + + @Test + void explicitInfoVersionTakesPriorityOverApplicationVersion() { + var runner = new ApplicationContextRunner() + .withPropertyValues( + "spring.application.name=my-service", + "spring.application.version=5.2.0", + "springdoc.info.version=9.0.0") + .withUserConfiguration(OpenApiConfiguration.class); + + runner.run(context -> { + var openApi = context.getBean(OpenAPI.class); + assertThat(openApi.getInfo().getVersion()).isEqualTo("9.0.0"); + }); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/openapi/ReactiveOpenApiCorsConfigurationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/openapi/ReactiveOpenApiCorsConfigurationTest.java new file mode 100644 index 0000000000..193bca6292 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/openapi/ReactiveOpenApiCorsConfigurationTest.java @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core.openapi; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@SpringBootTest(properties = { + "spring.profiles.active=test", + "spring.main.web-application-type=reactive" +}) +@AutoConfigureWebTestClient +class ReactiveOpenApiCorsConfigurationTest { + + @Autowired + private WebTestClient webTestClient; + + @Autowired + private ApplicationContext context; + + @Test + void servletOpenApiCorsIsNotLoaded() { + assertThat(context.getBeansOfType(ServletOpenApiCorsConfiguration.class)).isEmpty(); + } + + @Test + void corsPresentOnOpenApi() { + webTestClient.get().uri("/v3/openapi") + .exchange() + .expectStatus().isOk() + .expectHeader().valueEquals("Access-Control-Allow-Origin", "*") + .expectHeader().valueEquals("Access-Control-Allow-Methods", "*") + .expectHeader().valueEquals("Access-Control-Allow-Headers", "*") + .expectHeader().valueEquals("Access-Control-Allow-Credentials", "true") + .expectHeader().valueEquals("Access-Control-Max-Age", "3600"); + } + + @Test + void corsMissingOnRegularEndpoint() { + webTestClient.get().uri("/") + .exchange() + .expectStatus().isOk() + .expectHeader().doesNotExist("Access-Control-Allow-Origin") + .expectHeader().doesNotExist("Access-Control-Allow-Methods") + .expectHeader().doesNotExist("Access-Control-Allow-Headers") + .expectHeader().doesNotExist("Access-Control-Allow-Credentials") + .expectHeader().doesNotExist("Access-Control-Max-Age"); + } + + @SpringBootApplication + static class TestApplication { + + @RestController + static class TestController { + + @GetMapping("/") + public String get() { + return ""; + } + + @GetMapping("/v3/openapi") + public String openapi() { + return "{}"; + } + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/openapi/ServletOpenApiCoreCorsConfigurationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/openapi/ServletOpenApiCoreCorsConfigurationTest.java new file mode 100644 index 0000000000..a39d86f506 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/openapi/ServletOpenApiCoreCorsConfigurationTest.java @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core.openapi; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@AutoConfigureMockMvc +@SpringBootTest(properties = { + "spring.profiles.active=test", + "spring.main.web-application-type=servlet" +}) +class ServletOpenApiCoreCorsConfigurationTest { + + @Autowired + private MockMvc mvc; + + @Autowired + private ApplicationContext context; + + @Test + void reactiveOpenApiCorsIsNotLoaded() { + assertThat(context.getBeansOfType(ReactiveOpenApiCorsConfiguration.class)).isEmpty(); + } + + @Test + void corsPresentOnOpenApi() + throws Exception { + mvc.perform(get("/v3/openapi")) + .andExpect(status().isOk()) + .andExpect(header().string("Access-Control-Allow-Origin", "*")) + .andExpect(header().string("Access-Control-Allow-Methods", "*")) + .andExpect(header().string("Access-Control-Allow-Headers", "*")) + .andExpect(header().string("Access-Control-Allow-Credentials", "true")) + .andExpect(header().string("Access-Control-Max-Age", "3600")); + } + + @Test + void corsMissingOnRegularEndpoint() + throws Exception { + mvc.perform(get("/")) + .andExpect(status().isOk()) + .andExpect(header().doesNotExist("Access-Control-Allow-Origin")) + .andExpect(header().doesNotExist("Access-Control-Allow-Methods")) + .andExpect(header().doesNotExist("Access-Control-Allow-Headers")) + .andExpect(header().doesNotExist("Access-Control-Allow-Credentials")) + .andExpect(header().doesNotExist("Access-Control-Max-Age")); + } + + @SpringBootApplication + static class TestApplication { + + @RestController + static class TestController { + + @GetMapping("/") + public void get() { + // no-op + } + } + + } + +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/warmup/BootWarmupBaseTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/warmup/BootWarmupBaseTest.java new file mode 100644 index 0000000000..cfd6aa8512 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/java/com/nvidia/boot/core/warmup/BootWarmupBaseTest.java @@ -0,0 +1,161 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.core.warmup; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.health.contributor.Status; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.ConfigurableApplicationContext; + +class BootWarmupBaseTest { + + @Test + void healthIsUpWhenNotBlockingUntilComplete() { + var warmup = new TestWarmup(false, List.of()); + + assertThat(warmup.health().getStatus()).isEqualTo(Status.UP); + } + + @Test + void healthIsDownWhenBlockingAndWarmupNotStarted() { + var warmup = new TestWarmup(true, List.of()); + + assertThat(warmup.health().getStatus()).isEqualTo(Status.DOWN); + } + + @Test + void healthBecomesUpAfterApplicationReadyRunsWarmup() { + var warmup = new TestWarmup(true, List.of()); + warmup.onApplicationEvent(applicationReadyEvent()); + + assertThat(warmup.health().getStatus()).isEqualTo(Status.UP); + assertThat(warmup.isWarmingComplete()).isTrue(); + } + + @Test + void laterOrderRunsAfterEarlierOrderCompletes() { + var phase0 = new AtomicInteger(0); + var tasks = List.of( + new BootWarmupBase.WarmupRunnable("p0-a", 0, () -> phase0.incrementAndGet()), + new BootWarmupBase.WarmupRunnable("p0-b", 0, () -> phase0.incrementAndGet()), + new BootWarmupBase.WarmupRunnable( + "p1", 1, () -> assertThat(phase0.get()).isEqualTo(2))); + + var warmup = new TestWarmup(false, tasks); + warmup.onApplicationEvent(applicationReadyEvent()); + + assertThat(phase0.get()).isEqualTo(2); + } + + @Test + void failedTaskStillCompletesWarmup() { + var tasks = List.of(new BootWarmupBase.WarmupRunnable("fails", 0, () -> { + throw new IllegalStateException("boom"); + })); + var warmup = new TestWarmup(true, tasks); + warmup.onApplicationEvent(applicationReadyEvent()); + + assertThat(warmup.isWarmingComplete()).isTrue(); + assertThat(warmup.health().getStatus()).isEqualTo(Status.UP); + } + + @Test + void healthRevertsToUpAfterTimeoutWhenBlockingAndWarmupNeverFinishes() throws Exception { + var warmup = new NeverCompletesWarmup(true); + Thread.sleep(20); + + assertThat(warmup.health().getStatus()).isEqualTo(Status.UP); + } + + @Test + void warmupTaskSeesCallerContextClassLoader() { + var customClassLoader = new ClassLoader(getClass().getClassLoader()) {}; + var observed = new AtomicReference(); + + var tasks = List.of(new BootWarmupBase.WarmupRunnable( + "cl-check", 0, () -> observed.set(Thread.currentThread().getContextClassLoader()))); + var warmup = new TestWarmup(false, tasks); + + Thread.currentThread().setContextClassLoader(customClassLoader); + try { + warmup.onApplicationEvent(applicationReadyEvent()); + } finally { + Thread.currentThread().setContextClassLoader(getClass().getClassLoader()); + } + + assertThat(observed.get()) + .as("ForkJoinPool worker should see the caller's context classloader") + .isSameAs(customClassLoader); + } + + @Test + void warmupRunnableDelegatesToRunnable() { + var ran = new boolean[] {false}; + var wr = new BootWarmupBase.WarmupRunnable("x", 0, () -> ran[0] = true); + assertThat(wr.getName()).isEqualTo("x"); + assertThat(wr.getOrder()).isZero(); + wr.run(); + assertThat(ran[0]).isTrue(); + } + + private static ApplicationReadyEvent applicationReadyEvent() { + SpringApplication app = mock(SpringApplication.class); + ConfigurableApplicationContext ctx = mock(ConfigurableApplicationContext.class); + return new ApplicationReadyEvent(app, new String[0], ctx, Duration.ZERO); + } + + private static final class TestWarmup extends BootWarmupBase { + private final List tasks; + + TestWarmup(boolean block, List tasks) { + super(block); + this.tasks = tasks; + } + + @Override + public List createWarmupTasks() { + return tasks; + } + } + + /** Blocks readiness until timeout path in {@link BootWarmupBase#health()} runs. */ + private static final class NeverCompletesWarmup extends BootWarmupBase { + NeverCompletesWarmup(boolean block) { + super(block, Duration.ofMillis(1)); + } + + @Override + public List createWarmupTasks() { + return new ArrayList<>(); + } + + @Override + public void onApplicationEvent(ApplicationReadyEvent event) { + // skip — simulates warmup never running / never completing + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/resources/application-test.yaml b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/resources/application-test.yaml new file mode 100644 index 0000000000..f2dee13f4a --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-core/src/test/resources/application-test.yaml @@ -0,0 +1,5 @@ +# Required by ValidateEnvironmentPostProcessor +spring: + application: + name: test-app + version: 1.0.0 diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/BUILD.bazel b/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/BUILD.bazel new file mode 100644 index 0000000000..39b7b73cd9 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/BUILD.bazel @@ -0,0 +1,41 @@ +load("//src/libraries/java/nv-boot-parent/tools/bazel:java.bzl", "nv_boot_library", "nv_boot_library_test") + +DATA_MIGRATION_NOTIFICATION_COMPILE_DEPS = [ + "@nv_third_party_deps//:io_cloudevents_cloudevents_api", + "@nv_third_party_deps//:io_cloudevents_cloudevents_core", + "@nv_third_party_deps//:io_cloudevents_cloudevents_json_jackson", + "@nv_third_party_deps//:io_nats_jnats", + "@nv_third_party_deps//:jakarta_annotation_jakarta_annotation_api", + "@nv_third_party_deps//:jakarta_validation_jakarta_validation_api", + "@nv_third_party_deps//:org_apache_commons_commons_lang3", + "@nv_third_party_deps//:org_slf4j_slf4j_api", + "@nv_third_party_deps//:org_springframework_boot_spring_boot", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_autoconfigure", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_jackson", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_jackson", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_validation", + "@nv_third_party_deps//:org_springframework_spring_beans", + "@nv_third_party_deps//:org_springframework_spring_context", + "@nv_third_party_deps//:org_springframework_spring_core", + "@nv_third_party_deps//:tools_jackson_core_jackson_databind", +] + +nv_boot_library( + name = "nv_boot_starter_data_migration_notification", + srcs = glob(["src/main/java/**/*.java"]), + resource_strip_prefix = "src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/main/resources", + resources = glob(["src/main/resources/**"]), + visibility = ["//visibility:public"], + deps = DATA_MIGRATION_NOTIFICATION_COMPILE_DEPS, +) + +nv_boot_library_test( + name = "tests", + srcs = glob(["src/test/java/**/*.java"]), + coverage_library = ":nv_boot_starter_data_migration_notification", + deps = [ + ":nv_boot_starter_data_migration_notification", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_test", + ] + DATA_MIGRATION_NOTIFICATION_COMPILE_DEPS, +) diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/README.md b/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/README.md new file mode 100644 index 0000000000..bc3b5fd12d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/README.md @@ -0,0 +1,191 @@ +# NV Boot Starter Data Migration Notification + +CloudEvents-based data migration notifications for NV applications. The starter builds +data migration payloads, serializes them as CloudEvents, and publishes them to NATS using +a caller-provided `Connection`. + +**Apps must register a `DataMigrationNotificationProperties` bean in the Spring context.** + +## Adding as a Dependency + +```xml + + + + com.nvidia.boot + nv-boot-bom + ${nv-boot.version} + pom + import + + + + + + + com.nvidia.boot + nv-boot-starter-data-migration-notification + + +``` + +## Registering DataMigrationNotificationProperties + +Apps must provide a `DataMigrationNotificationProperties` bean from +`com.nvidia.boot.migration.notification.service`. `DataMigrationNotificationAutoConfiguration` +is conditional on this bean and backs off when it is missing. + +### ConfigurationProperties binding + +Bind from `application.yml` using an app-specific custom prefix: + +```java +@Configuration +public class DataMigrationNotificationConfig { + + @Bean + @ConfigurationProperties(prefix = "app.data-migration-notification") + public DataMigrationNotificationProperties dataMigrationNotificationProperties() { + return new DataMigrationNotificationProperties(); + } +} +``` + +```yaml +app: + data-migration-notification: + name: ${spring.application.name} + version: ${spring.application.version} + hostname: ${HOSTNAME:localhost} +``` + +## Required App Provided Beans + +The starter library relies on the app to register the following bean(s) with Spring application +context to be able to use this library: + +| Bean | Type | Description | +|---------------------------------------|---------------------------------------|-------------| +| `dataMigrationNotificationProperties` | `DataMigrationNotificationProperties` | **Required.** Application identity used in CloudEvent source, payloads, and NATS subjects. | +| `jsonMapper` | `JsonMapper` | Required by `DataMigrationNotificationService`; Spring Boot usually provides this when Jackson is on the classpath. | + +The notification module does not own the NATS connection lifecycle. Callers pass a +`Connection` to the `DataMigrationNotificationService` notification method for each lifecycle +event. + +## Usage + +Create or inject a NATS `Connection` in your application: + +```java +@Configuration +public class NatsConfiguration { + + @Bean(destroyMethod = "close") + public Connection natsConnection(@Value("${nats.url}") String natsUrl) + throws IOException, InterruptedException { + return Nats.connect(natsUrl); + } +} +``` + +Annotate migration tasks with `@DataMigration` to document schema changes and cleanup +expectations. The annotation is retained at runtime and can be discovered by migration +tooling or application code. + +```java +@DataMigration( + keyspace = "nvcf_api", + newTables = {"table1", "table2"}, + newColumns = {"table1.field1"}, + requiresTables = {"gpu_specifications"}, + description = "Migrate GPU specs from functions_deployment_v2 to gpu_specifications") +public class AsyncFooMigration { +} +``` + +`@DataMigration` attributes: + +| Attribute | Required | Description | +|-----------|----------|-------------| +| `keyspace` | Yes | Cassandra keyspace affected by the migration. | +| `newTables` | No | Tables created or introduced by the migration. | +| `newColumns` | No | Columns created or introduced by the migration, usually in `table.column` format. | +| `requiresTables` | No | Existing tables that must be present before the migration can run. | +| `description` | Yes | Human-readable summary of the migration's purpose. | + +Dispatch a data migration notification: + +```java +import com.nvidia.boot.migration.notification.service.DataMigrationNotificationService; +import io.nats.client.Connection; + +@Service +@RequiredArgsConstructor +public class MyMigrationTask { + + private final DataMigrationNotificationService notificationService; + private final Connection natsConnection; + + public void run() { + notificationService.notifyOnStart(natsConnection, taskName, Optional.of("Starting migration task")); + try { + // do migration stuff + notificationService.notifyOnEnd(natsConnection, taskName, Optional.of("Migration task completed")); + } catch (Exception e) { + notificationService.notifyOnError(natsConnection, taskName, Optional.ofNullable(e.getMessage())); + // handle error + } + } +} +``` + +Available notification methods: + +| Method | Type | +|--------|------| +| `notifyOnStart(Connection, String, Optional)` | `START` | +| `notifyOnEnd(Connection, String, Optional)` | `END` | +| `notifyOnError(Connection, String, Optional)` | `ERROR` | + +Use `Optional.empty()` when there is no message. `DataMigrationType` values are `START`, +`END`, and `ERROR`. + +## Behavior + +- `DataMigrationNotificationService` creates CloudEvents with: + - random UUID event id + - source format: `{name}:{version}@{hostname}` from `DataMigrationNotificationProperties` + - event type `data-migration` + - content type `application/json` + - UTC timestamp +- `DataMigrationNotificationService` publishes the serialized CloudEvent to NATS and flushes + the connection with a 5 second timeout. +- Send failures are logged and not rethrown. +- `DataMigrationNotificationService` builds NATS subjects as: + +```text +{applicationName}.{taskName}.{type} +``` + +For example: + +```text +my-service.backfill.END +``` + +`applicationName`, `taskName`, and `type` are used as NATS subject tokens. These values must +not contain `.`, `*`, or `>`; invalid values fail before CloudEvent serialization or NATS +publishing. + +## Testing + +Run module tests: + +```bash +mvn -pl nv-boot-starter-data-migration-notification test +``` + +- **Unit tests** cover `DataMigrationNotificationAutoConfiguration` conditions and backoff. +- **Unit tests** cover `DataMigrationNotificationService` CloudEvent creation, payload + generation, NATS subject generation and validation, publish, and flush behavior. diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/pom.xml b/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/pom.xml new file mode 100644 index 0000000000..6921634a3f --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/pom.xml @@ -0,0 +1,82 @@ + + + + + 4.0.0 + + + com.nvidia.boot + nv-boot-parent + 0.0.1-SNAPSHOT + + + nv-boot-starter-data-migration-notification + jar + NV Boot Starter Data Migration Notification + Library for sending events/notifications during data migration + + + 2.23.0 + + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-jackson + + + io.cloudevents + cloudevents-core + + + io.cloudevents + cloudevents-json-jackson + + + org.apache.commons + commons-lang3 + + + org.projectlombok + lombok + true + + + io.nats + jnats + ${nats.version} + + + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/main/java/com/nvidia/boot/migration/notification/DataMigrationNotificationAutoConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/main/java/com/nvidia/boot/migration/notification/DataMigrationNotificationAutoConfiguration.java new file mode 100644 index 0000000000..89b4f0eab8 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/main/java/com/nvidia/boot/migration/notification/DataMigrationNotificationAutoConfiguration.java @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.migration.notification; + +import static io.cloudevents.jackson.JsonFormat.CONTENT_TYPE; + +import com.nvidia.boot.migration.notification.service.DataMigrationNotificationProperties; +import com.nvidia.boot.migration.notification.service.DataMigrationNotificationService; +import io.cloudevents.core.format.EventFormat; +import io.cloudevents.core.provider.EventFormatProvider; +import java.util.Objects; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import tools.jackson.databind.json.JsonMapper; + +@AutoConfiguration +@ConditionalOnBean(DataMigrationNotificationProperties.class) +public class DataMigrationNotificationAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(DataMigrationNotificationService.class) + public DataMigrationNotificationService dataMigrationNotificationService( + JsonMapper jsonMapper, + EventFormat eventFormat, + DataMigrationNotificationProperties properties) { + return new DataMigrationNotificationService(jsonMapper, eventFormat, properties); + } + + @Bean + @ConditionalOnMissingBean(EventFormat.class) + public EventFormat eventFormat() { + var format = EventFormatProvider.getInstance().resolveFormat(CONTENT_TYPE); + return Objects.requireNonNull( + format, + "Failed to resolve CloudEvents JSON format. " + + "Ensure cloudevents-json-jackson is on classpath."); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/main/java/com/nvidia/boot/migration/notification/event/DataMigrationEvent.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/main/java/com/nvidia/boot/migration/notification/event/DataMigrationEvent.java new file mode 100644 index 0000000000..643b93e042 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/main/java/com/nvidia/boot/migration/notification/event/DataMigrationEvent.java @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.migration.notification.event; + +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +@Getter +public class DataMigrationEvent extends ApplicationEvent { + private final DataMigrationEventPayload payload; + + public DataMigrationEvent(DataMigrationEventPayload payload) { + super(payload); + this.payload = payload; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/main/java/com/nvidia/boot/migration/notification/event/DataMigrationEventPayload.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/main/java/com/nvidia/boot/migration/notification/event/DataMigrationEventPayload.java new file mode 100644 index 0000000000..87f1b68ce9 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/main/java/com/nvidia/boot/migration/notification/event/DataMigrationEventPayload.java @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.migration.notification.event; + +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import org.springframework.validation.annotation.Validated; + +@Builder +@Getter +@Validated +public class DataMigrationEventPayload { + @NotBlank + private String applicationName; + + @NotBlank + private String applicationVersion; + + @NotBlank + private String hostname; + + @NotBlank + private String jobName; + + @NotNull + private DataMigrationType type; + + @Nullable + private String message; +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/main/java/com/nvidia/boot/migration/notification/event/DataMigrationType.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/main/java/com/nvidia/boot/migration/notification/event/DataMigrationType.java new file mode 100644 index 0000000000..6e5d11f2eb --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/main/java/com/nvidia/boot/migration/notification/event/DataMigrationType.java @@ -0,0 +1,7 @@ +package com.nvidia.boot.migration.notification.event; + +public enum DataMigrationType { + START, + END, + ERROR +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/main/java/com/nvidia/boot/migration/notification/service/DataMigration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/main/java/com/nvidia/boot/migration/notification/service/DataMigration.java new file mode 100644 index 0000000000..15351e344d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/main/java/com/nvidia/boot/migration/notification/service/DataMigration.java @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.migration.notification.service; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotate migration tasks with {@code @DataMigration} to document the schema changes and cleanup + * expectations associated with the migration. The annotation is retained at runtime and can be + * discovered by migration tooling or application code. + * + *
{@code
+ * @DataMigration(
+ *         keyspace = "nvcf_api",
+ *         newTables = {"table1", "table2"},
+ *         newColumns = {"table1.field1"},
+ *         requiresTables = {"gpu_specifications"},
+ *         description = "Migrate GPU specs from functions_deployment_v2 to gpu_specifications")
+ * public class AsyncFooMigration {
+ * }
+ * }
+ * + * Attributes: + *
    + *
  • {@code keyspace}: Cassandra keyspace affected by the migration. Required.
  • + *
  • {@code newTables}: Tables created or introduced by the migration.
  • + *
  • {@code newColumns}: Columns created or introduced by the migration, usually in + * {@code table.column} format.
  • + *
  • {@code requiresTables}: Existing tables that must be present before the migration can + * run.
  • + *
  • {@code description}: Human-readable summary of the migration's purpose. Required.
  • + *
+ */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DataMigration { + + String keyspace(); + + String[] newTables() default {}; + + String[] newColumns() default {}; + + String[] requiresTables() default {}; + + String description(); +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/main/java/com/nvidia/boot/migration/notification/service/DataMigrationNotificationProperties.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/main/java/com/nvidia/boot/migration/notification/service/DataMigrationNotificationProperties.java new file mode 100644 index 0000000000..7ebfbc5362 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/main/java/com/nvidia/boot/migration/notification/service/DataMigrationNotificationProperties.java @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.migration.notification.service; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class DataMigrationNotificationProperties { + + @NotBlank + private String name; + + @NotBlank + private String version; + + @NotBlank + private String hostname; +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/main/java/com/nvidia/boot/migration/notification/service/DataMigrationNotificationService.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/main/java/com/nvidia/boot/migration/notification/service/DataMigrationNotificationService.java new file mode 100644 index 0000000000..427d6472b9 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/main/java/com/nvidia/boot/migration/notification/service/DataMigrationNotificationService.java @@ -0,0 +1,146 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.migration.notification.service; + +import static org.springframework.util.MimeTypeUtils.APPLICATION_JSON_VALUE; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.migration.notification.event.DataMigrationEvent; +import com.nvidia.boot.migration.notification.event.DataMigrationEventPayload; +import com.nvidia.boot.migration.notification.event.DataMigrationType; +import io.cloudevents.CloudEvent; +import io.cloudevents.core.builder.CloudEventBuilder; +import io.cloudevents.core.data.PojoCloudEventData; +import io.cloudevents.core.format.EventFormat; +import io.nats.client.Connection; +import jakarta.annotation.Nullable; +import java.net.URI; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.springframework.context.ApplicationEvent; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class DataMigrationNotificationService { + + private static final String MESG_ERROR_SENDING_NATS = "Error sending NATs notification: {}"; + private static final String MESG_INVALID_NATS_SUBJECT_FIELD = + "NATS subject field '%s' must not contain '.', '*', or '>'"; + private static final String EVENT_TYPE_DATA_MIGRATION = "data-migration"; + + private final EventFormat format; + private final JsonMapper jsonMapper; + private final String applicationName; + private final String applicationVersion; + private final String hostname; + private final URI source; + + public DataMigrationNotificationService( + JsonMapper jsonMapper, + EventFormat format, + DataMigrationNotificationProperties properties) { + this.jsonMapper = jsonMapper; + this.format = format; + this.applicationName = properties.getName(); + this.applicationVersion = properties.getVersion(); + this.hostname = properties.getHostname(); + validateNatsSubjectField("applicationName", applicationName); + var sourceValue = "%s:%s@%s".formatted( + applicationName, + applicationVersion, + hostname); + this.source = URI.create(sourceValue); + } + + public void notifyOnStart( + Connection connection, String taskName, Optional message) { + dispatchDataMigrationEvent(connection, taskName, DataMigrationType.START, message); + } + + public void notifyOnEnd( + Connection connection, String taskName, Optional message) { + dispatchDataMigrationEvent(connection, taskName, DataMigrationType.END, message); + } + + public void notifyOnError( + Connection connection, String taskName, Optional message) { + dispatchDataMigrationEvent(connection, taskName, DataMigrationType.ERROR, message); + } + + private void dispatchDataMigrationEvent( + Connection connection, + String taskName, + DataMigrationType type, + Optional message) { + var subject = buildNatsSubject(taskName, type); + var migrationEventPayload = DataMigrationEventPayload.builder() + .applicationName(applicationName) + .applicationVersion(applicationVersion) + .hostname(hostname) + .jobName(taskName) + .type(type) + .message(message.orElse(null)) + .build(); + var migrationEvent = new DataMigrationEvent(migrationEventPayload); + var cloudEvent = getCloudEvent(migrationEvent); + try { + connection.publish(subject, format.serialize(cloudEvent)); + connection.flush(Duration.ofSeconds(5)); + } catch (Exception e) { + log.error(MESG_ERROR_SENDING_NATS, ExceptionUtils.getStackTrace(e)); + } + } + + private String buildNatsSubject( + String taskName, + DataMigrationType type) { + var typeToken = type.toString(); + validateNatsSubjectField("applicationName", applicationName); + validateNatsSubjectField("taskName", taskName); + validateNatsSubjectField("type", typeToken); + return applicationName + "." + taskName + "." + typeToken; + } + + private static void validateNatsSubjectField(String fieldName, @Nullable String fieldValue) { + if (fieldValue != null + && (fieldValue.indexOf('.') >= 0 + || fieldValue.indexOf('*') >= 0 + || fieldValue.indexOf('>') >= 0)) { + throw new IllegalArgumentException( + MESG_INVALID_NATS_SUBJECT_FIELD.formatted(fieldName)); + } + } + + private CloudEvent getCloudEvent(ApplicationEvent event) { + var cloudEventData = PojoCloudEventData.wrap(event, jsonMapper::writeValueAsBytes); + return CloudEventBuilder.v1() + .withId(UUID.randomUUID().toString()) + .withSource(source) + .withTime(Instant.now().atOffset(ZoneOffset.UTC)) + .withType(EVENT_TYPE_DATA_MIGRATION) + .withDataContentType(APPLICATION_JSON_VALUE) + .withData(cloudEventData) + .build(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000000..7c17fc7df8 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +com.nvidia.boot.migration.notification.DataMigrationNotificationAutoConfiguration diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/test/java/com/nvidia/boot/migration/notification/DataMigrationNotificationAutoConfigurationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/test/java/com/nvidia/boot/migration/notification/DataMigrationNotificationAutoConfigurationTest.java new file mode 100644 index 0000000000..38d607460a --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/test/java/com/nvidia/boot/migration/notification/DataMigrationNotificationAutoConfigurationTest.java @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.migration.notification; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.nvidia.boot.migration.notification.service.DataMigrationNotificationProperties; +import com.nvidia.boot.migration.notification.service.DataMigrationNotificationService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import tools.jackson.databind.json.JsonMapper; + +/** + * Spring context integration test for {@link DataMigrationNotificationAutoConfiguration}. + */ +@SpringBootTest( + classes = DataMigrationNotificationAutoConfigurationTest.TestApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE) +class DataMigrationNotificationAutoConfigurationTest { + + @Autowired + private DataMigrationNotificationService notificationService; + + @Test + void autoConfiguresDataMigrationBeansWhenPropertiesPresent() { + assertThat(notificationService).isNotNull(); + } + + /** + * {@link DataMigrationNotificationAutoConfiguration} is + * {@code @ConditionalOnBean(DataMigrationNotificationProperties.class)}. + * Register {@link DataMigrationNotificationProperties} in a separate imported config + * so it exists before the auto-configuration is processed. + */ + @SpringBootConfiguration + @Import({ + DataMigrationNotificationPropertiesConfig.class, + DataMigrationNotificationAutoConfiguration.class + }) + static class TestApplication { + @Bean + JsonMapper jsonMapper() { + return new JsonMapper(); + } + } + + @Configuration + static class DataMigrationNotificationPropertiesConfig { + + @Bean + private static DataMigrationNotificationProperties notificationProperties() { + var properties = new DataMigrationNotificationProperties(); + properties.setName("test-app"); + properties.setVersion("1.2.3"); + properties.setHostname("host-1"); + return properties; + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/test/java/com/nvidia/boot/migration/notification/service/DataMigrationNotificationServiceTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/test/java/com/nvidia/boot/migration/notification/service/DataMigrationNotificationServiceTest.java new file mode 100644 index 0000000000..820937e7c7 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-data-migration-notification/src/test/java/com/nvidia/boot/migration/notification/service/DataMigrationNotificationServiceTest.java @@ -0,0 +1,236 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.migration.notification.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.util.MimeTypeUtils.APPLICATION_JSON_VALUE; + +import com.nvidia.boot.migration.notification.event.DataMigrationEvent; +import com.nvidia.boot.migration.notification.event.DataMigrationEventPayload; +import com.nvidia.boot.migration.notification.event.DataMigrationType; +import io.cloudevents.CloudEvent; +import io.cloudevents.core.data.PojoCloudEventData; +import io.cloudevents.core.format.EventFormat; +import io.nats.client.Connection; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Optional; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import tools.jackson.databind.json.JsonMapper; + +@ExtendWith(MockitoExtension.class) +class DataMigrationNotificationServiceTest { + + private static final String EVENT_TYPE = "data-migration"; + + @Mock + private EventFormat format; + + @Mock + private Connection connection; + + private DataMigrationNotificationService notificationService; + + @BeforeEach + void setUp() { + notificationService = new DataMigrationNotificationService( + new JsonMapper(), + format, + notificationProperties()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("notificationMethods") + void notificationMethodPublishesSerializedCloudEvent( + String testName, + NotificationMethod notificationMethod, + String expectedSubject, + DataMigrationType expectedType) throws Exception { + var serialized = "{\"specversion\":\"1.0\"}".getBytes(StandardCharsets.UTF_8); + when(format.serialize(any(CloudEvent.class))).thenReturn(serialized); + + notificationMethod.notify( + notificationService, connection, "backfill", Optional.of("copy finished")); + + var cloudEventCaptor = ArgumentCaptor.forClass(CloudEvent.class); + verify(format).serialize(cloudEventCaptor.capture()); + verify(connection).publish(expectedSubject, serialized); + verify(connection).flush(Duration.ofSeconds(5)); + + var cloudEvent = cloudEventCaptor.getValue(); + assertCloudEventMetadata(cloudEvent); + + var payload = payloadFrom(cloudEvent); + assertThat(payload.getApplicationName()).isEqualTo("test-app"); + assertThat(payload.getApplicationVersion()).isEqualTo("1.2.3"); + assertThat(payload.getHostname()).isEqualTo("host-1"); + assertThat(payload.getJobName()).isEqualTo("backfill"); + assertThat(payload.getType()).isEqualTo(expectedType); + assertThat(payload.getMessage()).isEqualTo("copy finished"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("notificationMethods") + void notificationMethodOmitsMessageWhenMessageIsNull( + String testName, + NotificationMethod notificationMethod, + String expectedSubject, + DataMigrationType expectedType) throws Exception { + var serialized = "{\"specversion\":\"1.0\"}".getBytes(StandardCharsets.UTF_8); + when(format.serialize(any(CloudEvent.class))).thenReturn(serialized); + + notificationMethod.notify(notificationService, connection, "validate", Optional.empty()); + + var cloudEventCaptor = ArgumentCaptor.forClass(CloudEvent.class); + verify(format).serialize(cloudEventCaptor.capture()); + verify(connection).publish(expectedSubject.replace(".backfill.", ".validate."), serialized); + verify(connection).flush(Duration.ofSeconds(5)); + + var cloudEvent = cloudEventCaptor.getValue(); + assertCloudEventMetadata(cloudEvent); + + var payload = payloadFrom(cloudEvent); + assertThat(payload.getApplicationName()).isEqualTo("test-app"); + assertThat(payload.getApplicationVersion()).isEqualTo("1.2.3"); + assertThat(payload.getHostname()).isEqualTo("host-1"); + assertThat(payload.getJobName()).isEqualTo("validate"); + assertThat(payload.getType()).isEqualTo(expectedType); + assertThat(payload.getMessage()).isNull(); + } + + @ParameterizedTest(name = "applicationName={0}") + @MethodSource("invalidNatsSubjectTokens") + void constructorRejectsApplicationNameContainingReservedNatsSubjectCharacter( + String applicationName, String invalidCharacter) { + var properties = notificationProperties(applicationName); + + assertThatThrownBy(() -> new DataMigrationNotificationService( + new JsonMapper(), format, properties)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("applicationName") + .hasMessageContaining(invalidCharacter); + + verifyNoInteractions(format, connection); + } + + @ParameterizedTest(name = "{0} rejects taskName={1}") + @MethodSource("notificationMethodsWithInvalidTaskNames") + void notificationMethodRejectsTaskNameContainingReservedNatsSubjectCharacter( + String testName, + NotificationMethod notificationMethod, + String taskName, + String invalidCharacter) { + assertThatThrownBy(() -> notificationMethod.notify( + notificationService, + connection, + taskName, + Optional.of("message"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("taskName") + .hasMessageContaining(invalidCharacter); + + verifyNoInteractions(format, connection); + } + + private static Stream notificationMethods() { + return Stream.of( + Arguments.of( + "notifyOnExecutionStart", + (NotificationMethod) DataMigrationNotificationService::notifyOnStart, + "test-app.backfill.START", + DataMigrationType.START), + Arguments.of( + "notifyOnCompleteSuccessfully", + (NotificationMethod) DataMigrationNotificationService::notifyOnEnd, + "test-app.backfill.END", + DataMigrationType.END), + Arguments.of( + "notifyOnCompleteWithError", + (NotificationMethod) DataMigrationNotificationService::notifyOnError, + "test-app.backfill.ERROR", + DataMigrationType.ERROR)); + } + + private static Stream notificationMethodsWithInvalidTaskNames() { + return notificationMethods() + .flatMap(notificationMethodArguments -> invalidNatsSubjectTokens() + .map(invalidTokenArguments -> Arguments.of( + notificationMethodArguments.get()[0], + notificationMethodArguments.get()[1], + invalidTokenArguments.get()[0], + invalidTokenArguments.get()[1]))); + } + + private static Stream invalidNatsSubjectTokens() { + return Stream.of( + Arguments.of("invalid.name", "."), + Arguments.of("invalid*name", "*"), + Arguments.of("invalid>name", ">")); + } + + @FunctionalInterface + private interface NotificationMethod { + void notify( + DataMigrationNotificationService notificationService, + Connection connection, + String taskName, + Optional message); + } + + private static void assertCloudEventMetadata(CloudEvent cloudEvent) { + assertThat(cloudEvent.getId()).isNotBlank(); + assertThat(cloudEvent.getSource()).isEqualTo(URI.create("test-app:1.2.3@host-1")); + assertThat(cloudEvent.getType()).isEqualTo(EVENT_TYPE); + assertThat(cloudEvent.getDataContentType()).isEqualTo(APPLICATION_JSON_VALUE); + assertThat(cloudEvent.getData()).isNotNull(); + assertThat(cloudEvent.getTime()).isNotNull(); + } + + private static DataMigrationEventPayload payloadFrom(CloudEvent cloudEvent) { + assertThat(cloudEvent.getData()).isInstanceOf(PojoCloudEventData.class); + var data = (PojoCloudEventData) cloudEvent.getData(); + assertThat(data.getValue()).isInstanceOf(DataMigrationEvent.class); + return ((DataMigrationEvent) data.getValue()).getPayload(); + } + + private static DataMigrationNotificationProperties notificationProperties() { + return notificationProperties("test-app"); + } + + private static DataMigrationNotificationProperties notificationProperties(String applicationName) { + var properties = new DataMigrationNotificationProperties(); + properties.setName(applicationName); + properties.setVersion("1.2.3"); + properties.setHostname("host-1"); + return properties; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/BUILD.bazel b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/BUILD.bazel new file mode 100644 index 0000000000..fe31262534 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/BUILD.bazel @@ -0,0 +1,65 @@ +load("@rules_java//java:defs.bzl", "java_library") +load("//src/libraries/java/nv-boot-parent/tools/bazel:java.bzl", "nv_boot_library", "nv_boot_library_test", "nv_boot_runtime_classpath_test") + +EXCEPTIONS_REQUIRED_DEPS = [ + "@nv_third_party_deps//:jakarta_annotation_jakarta_annotation_api", + "@nv_third_party_deps//:org_slf4j_slf4j_api", +] + +# Maven marks the Spring MVC, WebFlux, Security, and autoconfiguration APIs as +# optional/provided. They compile conditional handlers but must not select a +# downstream application's web stack or security runtime. +EXCEPTIONS_OPTIONAL_COMPILE_DEPS = [ + "@nv_third_party_deps//:io_projectreactor_reactor_core", + "@nv_third_party_deps//:org_jspecify_jspecify", + "@nv_third_party_deps//:org_springframework_boot_spring_boot", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_autoconfigure", + "@nv_third_party_deps//:org_springframework_security_spring_security_core", + "@nv_third_party_deps//:org_springframework_spring_context", + "@nv_third_party_deps//:org_springframework_spring_core", + "@nv_third_party_deps//:org_springframework_spring_web", + "@nv_third_party_deps//:org_springframework_spring_webflux", + "@nv_third_party_deps//:org_springframework_spring_webmvc", +] + +java_library( + name = "optional_compile_deps", + exports = EXCEPTIONS_OPTIONAL_COMPILE_DEPS, + neverlink = True, + visibility = ["//visibility:private"], +) + +nv_boot_library( + name = "nv_boot_starter_exceptions", + srcs = glob(["src/main/java/**/*.java"]), + resource_strip_prefix = "src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/resources", + resources = glob(["src/main/resources/**"]), + visibility = ["//visibility:public"], + deps = EXCEPTIONS_REQUIRED_DEPS + [":optional_compile_deps"], +) + +nv_boot_runtime_classpath_test( + name = "runtime_scope_test", + target = ":nv_boot_starter_exceptions", + forbidden_artifacts = [ + "reactor-core", + "spring-boot-autoconfigure", + "spring-security-core", + "spring-web-", + "spring-webflux-", + "spring-webmvc-", + ], +) + +nv_boot_library_test( + name = "tests", + srcs = glob(["src/test/java/**/*.java"]), + coverage_library = ":nv_boot_starter_exceptions", + resource_strip_prefix = "src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/test/resources", + resources = glob(["src/test/resources/**"]), + deps = [ + ":nv_boot_starter_exceptions", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_webflux_test", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_webmvc_test", + ] + EXCEPTIONS_REQUIRED_DEPS + EXCEPTIONS_OPTIONAL_COMPILE_DEPS, +) diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/README.md b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/README.md new file mode 100644 index 0000000000..109f48a940 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/README.md @@ -0,0 +1,73 @@ +# NV Boot Starter Exceptions + +Exception classes with RFC 7807 Problem Details support. Extends Spring's +`ErrorResponseException` for consistent error handling across NV applications. + +## Adding as a Dependency + +Add to your application's `pom.xml`: + +```xml + + + + com.nvidia.boot + nv-boot-bom + ${nv-boot.version} + pom + import + + + + + + + com.nvidia.boot + nv-boot-starter-exceptions + + +``` + +## Auto Configured Beans + +Spring Boot loads auto-configuration from this starter via +`META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`. + +Depending on your web stack and classpath, one of these may be registered (each only +when no bean of the corresponding `ResponseEntityExceptionHandler` type is already defined): + +| Configuration | When it applies | Bean | +|-----------------|-----------------|------| +| `ServletExceptionsAutoConfiguration` | Servlet web app (`WebApplicationType.SERVLET`), `spring-webmvc` present | `DefaultMvcExceptionHandler` | +| `ReactiveExceptionsAutoConfiguration` | Reactive web app (`WebApplicationType.REACTIVE`), `spring-webflux` present | `DefaultReactiveExceptionHandler` | + +If both Spring MVC and WebFlux are on the classpath, Spring Boot still chooses a single +web application type (typically Servlet unless you set `spring.main.web-application-type=reactive`). +See the Spring Boot reference on [web applications](https://docs.spring.io/spring-boot/reference/web/index.html). +Provide your own `@ControllerAdvice` / `ResponseEntityExceptionHandler` bean to replace the defaults. + +### Exception Classes (throw, not inject) + +| Class | HTTP Status | Use Case | +|-------|-------------|----------| +| `BadRequestException` | 400 | Invalid request parameters | +| `UnauthorizedException` | 401 | Authentication required | +| `ForbiddenException` | 403 | Access denied | +| `NotFoundException` | 404 | Resource not found | +| `ConflictException` | 409 | Resource already exists | +| `TooManyRequestsException` | 429 | Rate limit exceeded | +| `UpstreamException` | 502 | Upstream service failure | +| `UnprocessableEntityException` | 422 | Validation failed | +| `PaymentRequiredException` | 402 | Payment required | + +### Exception handlers (extend to customize) + +For servlet apps, extend `BootMvcExceptionHandler`; for WebFlux, extend +`BootReactiveExceptionHandler`. The auto-configured defaults extend the same bases. + +```java +@RestControllerAdvice +public class MyExceptionHandler extends BootMvcExceptionHandler { + // Inherits handling for BootResponseException and security mappings +} +``` diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/pom.xml b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/pom.xml new file mode 100644 index 0000000000..7afadedf01 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/pom.xml @@ -0,0 +1,89 @@ + + + + + 4.0.0 + + + com.nvidia.boot + nv-boot-parent + 0.0.1-SNAPSHOT + + + nv-boot-starter-exceptions + jar + NV Boot Starter Exceptions + Exception classes and handlers with RFC 7807 Problem Details support + + + + org.springframework.security + spring-security-core + true + + + org.springframework + spring-web + true + + + org.springframework + spring-webmvc + true + provided + + + org.springframework + spring-webflux + true + provided + + + org.springframework.boot + spring-boot-autoconfigure + true + + + org.slf4j + slf4j-api + + + org.projectlombok + lombok + + + jakarta.annotation + jakarta.annotation-api + compile + + + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + org.springframework.boot + spring-boot-starter-webflux-test + test + + + diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/BadRequestException.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/BadRequestException.java new file mode 100644 index 0000000000..8bb1e9dae5 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/BadRequestException.java @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.exceptions; + +import org.springframework.http.HttpStatus; + +public class BadRequestException extends BootResponseException { + + public BadRequestException(String message) { + super(HttpStatus.BAD_REQUEST, message, BadRequestException.class); + } + + public BadRequestException(String message, Throwable cause) { + super(HttpStatus.BAD_REQUEST, message, cause, BadRequestException.class); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/BootResponseException.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/BootResponseException.java new file mode 100644 index 0000000000..5879ddcba2 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/BootResponseException.java @@ -0,0 +1,75 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.exceptions; + +import java.net.URI; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.web.ErrorResponseException; + +/** + * Base exception for all nv-boot-exceptions. Extends ErrorResponseException + * to support RFC 7807 Problem Details with auto-generated type URNs. + */ +public abstract class BootResponseException extends ErrorResponseException { + + private static final String URN_PREFIX = "urn:nv-boot:problem-details:"; + + protected BootResponseException(HttpStatus status, String message, + Class type) { + super(status, createProblemDetail(status, message, type), null); + } + + protected BootResponseException(HttpStatus status, String message, Throwable cause, + Class type) { + super(status, createProblemDetail(status, message, type), cause); + } + + /** + * Creates a ProblemDetail with status, detail, type URN, and title. + * The type URN is derived from the exception class name (e.g., NotFoundException → not-found). + * If message is null or blank, the status reason phrase is used as the detail. + */ + private static ProblemDetail createProblemDetail(HttpStatus status, String message, + Class type) { + var detail = (message != null && !message.isBlank()) ? message : status.getReasonPhrase(); + var problemDetail = ProblemDetail.forStatusAndDetail(status, detail); + var typeSuffix = classToKebabCase(type.getSimpleName()); + problemDetail.setType(URI.create(URN_PREFIX + typeSuffix)); + problemDetail.setTitle(status.getReasonPhrase()); + return problemDetail; + } + + private static String classToKebabCase(String className) { + var withoutException = className.endsWith("Exception") + ? className.substring(0, className.length() - "Exception".length()) + : className; + return camelToKebab(withoutException).toLowerCase(); + } + + private static String camelToKebab(String input) { + var out = new StringBuilder(input.length()); + input.codePoints().forEach(c -> { + if (Character.isUpperCase(c) && !out.isEmpty()) { + out.append('-'); + } + out.appendCodePoint(Character.toLowerCase(c)); + }); + return out.toString(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/ConflictException.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/ConflictException.java new file mode 100644 index 0000000000..469583c6bb --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/ConflictException.java @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.exceptions; + +import org.springframework.http.HttpStatus; + +public class ConflictException extends BootResponseException { + + public ConflictException(String message) { + super(HttpStatus.CONFLICT, message, ConflictException.class); + } + + public ConflictException(String message, Throwable cause) { + super(HttpStatus.CONFLICT, message, cause, ConflictException.class); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/ForbiddenException.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/ForbiddenException.java new file mode 100644 index 0000000000..fcd49a6e9d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/ForbiddenException.java @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.exceptions; + +import org.springframework.http.HttpStatus; + +public class ForbiddenException extends BootResponseException { + + public ForbiddenException(String message) { + super(HttpStatus.FORBIDDEN, message, ForbiddenException.class); + } + + public ForbiddenException(String message, Throwable cause) { + super(HttpStatus.FORBIDDEN, message, cause, ForbiddenException.class); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/NotFoundException.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/NotFoundException.java new file mode 100644 index 0000000000..a431a3b8d0 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/NotFoundException.java @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.exceptions; + +import org.springframework.http.HttpStatus; + +public class NotFoundException extends BootResponseException { + + public NotFoundException(String message) { + super(HttpStatus.NOT_FOUND, message, NotFoundException.class); + } + + public NotFoundException(String message, Throwable cause) { + super(HttpStatus.NOT_FOUND, message, cause, NotFoundException.class); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/PaymentRequiredException.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/PaymentRequiredException.java new file mode 100644 index 0000000000..0fcb6d865e --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/PaymentRequiredException.java @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.exceptions; + +import org.springframework.http.HttpStatus; + +public class PaymentRequiredException extends BootResponseException { + + public PaymentRequiredException(String message) { + super(HttpStatus.PAYMENT_REQUIRED, message, PaymentRequiredException.class); + } + + public PaymentRequiredException(String message, Throwable cause) { + super(HttpStatus.PAYMENT_REQUIRED, message, cause, PaymentRequiredException.class); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/ReactiveExceptionsAutoConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/ReactiveExceptionsAutoConfiguration.java new file mode 100644 index 0000000000..f68c032919 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/ReactiveExceptionsAutoConfiguration.java @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.exceptions; + +import com.nvidia.boot.exceptions.handlers.DefaultReactiveExceptionHandler; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.web.reactive.result.method.annotation.ResponseEntityExceptionHandler; + +/** WebFlux exception handling (requires spring-webflux on the classpath). */ +@AutoConfiguration +@ConditionalOnClass(ResponseEntityExceptionHandler.class) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) +public class ReactiveExceptionsAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(ResponseEntityExceptionHandler.class) + public DefaultReactiveExceptionHandler problemDetailsReactiveExceptionHandler() { + return new DefaultReactiveExceptionHandler(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/ServletExceptionsAutoConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/ServletExceptionsAutoConfiguration.java new file mode 100644 index 0000000000..ad980caa40 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/ServletExceptionsAutoConfiguration.java @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.exceptions; + +import com.nvidia.boot.exceptions.handlers.DefaultMvcExceptionHandler; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +/** Servlet MVC exception handling (requires spring-webmvc on the classpath). */ +@AutoConfiguration +@ConditionalOnClass(ResponseEntityExceptionHandler.class) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +public class ServletExceptionsAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(ResponseEntityExceptionHandler.class) + public DefaultMvcExceptionHandler problemDetailsMvcExceptionHandler() { + return new DefaultMvcExceptionHandler(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/TooManyRequestsException.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/TooManyRequestsException.java new file mode 100644 index 0000000000..a279a2bda2 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/TooManyRequestsException.java @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.exceptions; + +import org.springframework.http.HttpStatus; + +public class TooManyRequestsException extends BootResponseException { + + public TooManyRequestsException(String message) { + super(HttpStatus.TOO_MANY_REQUESTS, message, TooManyRequestsException.class); + } + + public TooManyRequestsException(String message, Throwable cause) { + super(HttpStatus.TOO_MANY_REQUESTS, message, cause, TooManyRequestsException.class); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/UnauthorizedException.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/UnauthorizedException.java new file mode 100644 index 0000000000..4b45c93ef6 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/UnauthorizedException.java @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.exceptions; + +import org.springframework.http.HttpStatus; + +public class UnauthorizedException extends BootResponseException { + + public UnauthorizedException(String message) { + super(HttpStatus.UNAUTHORIZED, message, UnauthorizedException.class); + } + + public UnauthorizedException(String message, Throwable cause) { + super(HttpStatus.UNAUTHORIZED, message, cause, UnauthorizedException.class); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/UnprocessableEntityException.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/UnprocessableEntityException.java new file mode 100644 index 0000000000..b266dae406 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/UnprocessableEntityException.java @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.exceptions; + +import org.springframework.http.HttpStatus; + +public class UnprocessableEntityException extends BootResponseException { + + public UnprocessableEntityException(String message) { + super(HttpStatus.UNPROCESSABLE_CONTENT, message, UnprocessableEntityException.class); + } + + public UnprocessableEntityException(String message, Throwable cause) { + super(HttpStatus.UNPROCESSABLE_CONTENT, message, cause, UnprocessableEntityException.class); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/UpstreamException.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/UpstreamException.java new file mode 100644 index 0000000000..48914086f4 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/UpstreamException.java @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.exceptions; + +import org.springframework.http.HttpStatus; + +public class UpstreamException extends BootResponseException { + + public UpstreamException(String message) { + super(HttpStatus.BAD_GATEWAY, message, UpstreamException.class); + } + + public UpstreamException(String message, Throwable cause) { + super(HttpStatus.BAD_GATEWAY, message, cause, UpstreamException.class); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/env/ExceptionsEnvironmentPostProcessor.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/env/ExceptionsEnvironmentPostProcessor.java new file mode 100644 index 0000000000..c5edbe3020 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/env/ExceptionsEnvironmentPostProcessor.java @@ -0,0 +1,72 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.exceptions.env; + +import java.io.IOException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.EnvironmentPostProcessor; +import org.springframework.core.Ordered; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.PropertiesPropertySource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.support.PropertiesLoaderUtils; + +/** + * Environment post processor that loads logback configuration properties + * for filtered stack traces (StackFilteringThrowableConverter). + */ +@Slf4j +public class ExceptionsEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered { + + private static final String EXCEPTIONS_DEFAULTS_FILE = + "nv-boot-exceptions-defaults.properties"; + + @Override + public void postProcessEnvironment( + ConfigurableEnvironment environment, + SpringApplication application) { + loadExceptionDefaults(environment); + } + + private void loadExceptionDefaults(ConfigurableEnvironment environment) { + loadPropertiesFile(environment, EXCEPTIONS_DEFAULTS_FILE, "nv-boot-exceptions-defaults"); + } + + private void loadPropertiesFile( + ConfigurableEnvironment environment, + String filename, + String sourceName) { + try { + var resource = new ClassPathResource(filename); + if (resource.exists()) { + var properties = PropertiesLoaderUtils.loadProperties(resource); + environment.getPropertySources().addLast( + new PropertiesPropertySource(sourceName, properties) + ); + } + } catch (IOException e) { + log.warn("Failed to load properties file '{}'", filename); + } + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE - 10; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/handlers/BootMvcExceptionHandler.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/handlers/BootMvcExceptionHandler.java new file mode 100644 index 0000000000..3be510c514 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/handlers/BootMvcExceptionHandler.java @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.exceptions.handlers; + +import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; + +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.UnauthorizedException; +import org.jspecify.annotations.Nullable; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.web.ErrorResponseException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +/** + * Base exception handler provides security mapping + optional OpenTelemetry span recording; + * apps may extend and override {@link #recordException(Exception)} when needed. + * + *

+ */ +public abstract class BootMvcExceptionHandler extends ResponseEntityExceptionHandler { + + /** + * Override to record the exception (e.g. on the current OpenTelemetry span). + * Default is no-op. + * + * @param ex the exception that was handled + */ + protected void recordException(Exception ex) { + // no-op + } + + @Override + protected @Nullable ResponseEntity handleExceptionInternal( + Exception ex, @Nullable Object body, HttpHeaders headers, + HttpStatusCode statusCode, WebRequest request) { + recordException(ex); + return super.handleExceptionInternal(ex, body, headers, statusCode, request); + } + + @ExceptionHandler(AccessDeniedException.class) + protected ResponseEntity handleAccessDenied( + AccessDeniedException ex, + WebRequest request) throws Exception { + var message = ex.getMessage() != null ? ex.getMessage() : "Access denied"; + return super.handleException(new ForbiddenException(message, ex), request); + } + + @ExceptionHandler(AuthenticationException.class) + protected ResponseEntity handleAuthentication( + AuthenticationException ex, + WebRequest request) throws Exception { + var message = ex.getMessage() != null ? ex.getMessage() : "Unauthorized"; + return super.handleException(new UnauthorizedException(message, ex), request); + } + + @ExceptionHandler(Exception.class) + protected ResponseEntity handleUnmapped( + Exception ex, + WebRequest request) throws Exception { + recordException(ex); + var wrapped = + new ErrorResponseException(INTERNAL_SERVER_ERROR, + ProblemDetail.forStatusAndDetail( + INTERNAL_SERVER_ERROR, + ex.getMessage() != null ? ex.getMessage() : + "Internal server error"), + ex); + return super.handleException(wrapped, request); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/handlers/BootReactiveExceptionHandler.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/handlers/BootReactiveExceptionHandler.java new file mode 100644 index 0000000000..ae9b728886 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/handlers/BootReactiveExceptionHandler.java @@ -0,0 +1,83 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.exceptions.handlers; + +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.UnauthorizedException; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.web.ErrorResponseException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.reactive.result.method.annotation.ResponseEntityExceptionHandler; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +@Slf4j +public abstract class BootReactiveExceptionHandler extends ResponseEntityExceptionHandler { + + /** + * Override to record the exception (e.g. on the current OpenTelemetry span). + * Default is no-op. + * + * @param ex the exception that was handled + */ + protected void recordException(Exception ex) { + // no-op + } + + @Nonnull + @Override + protected Mono> handleExceptionInternal( + @Nonnull Exception ex, @Nullable Object body, @Nullable HttpHeaders headers, + @Nonnull HttpStatusCode statusCode, @Nonnull ServerWebExchange exchange) { + recordException(ex); + return super.handleExceptionInternal(ex, body, headers, statusCode, exchange); + } + + @ExceptionHandler(AccessDeniedException.class) + protected Mono> handleException( + AccessDeniedException ex, ServerWebExchange exchange) { + return super.handleException( + new ForbiddenException(ex.getMessage(), ex.getCause()), exchange); + } + + @ExceptionHandler(AuthenticationException.class) + protected Mono> handleException( + AuthenticationException ex, ServerWebExchange exchange) { + return super.handleException( + new UnauthorizedException(ex.getMessage(), ex.getCause()), exchange); + } + + @ExceptionHandler(Exception.class) + protected Mono> handleExceptionCatchAll( + Exception ex, ServerWebExchange exchange) { + return super.handleException( + new ErrorResponseException(HttpStatus.INTERNAL_SERVER_ERROR, + ProblemDetail.forStatusAndDetail( + HttpStatus.INTERNAL_SERVER_ERROR, + ex.getMessage()), ex), exchange); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/handlers/DefaultMvcExceptionHandler.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/handlers/DefaultMvcExceptionHandler.java new file mode 100644 index 0000000000..e14d4a082d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/handlers/DefaultMvcExceptionHandler.java @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.exceptions.handlers; + +import org.springframework.web.bind.annotation.ControllerAdvice; + +@ControllerAdvice +public class DefaultMvcExceptionHandler extends BootMvcExceptionHandler { + +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/handlers/DefaultReactiveExceptionHandler.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/handlers/DefaultReactiveExceptionHandler.java new file mode 100644 index 0000000000..07a908676f --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/java/com/nvidia/boot/exceptions/handlers/DefaultReactiveExceptionHandler.java @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.exceptions.handlers; + +import org.springframework.web.bind.annotation.ControllerAdvice; + +@ControllerAdvice +public class DefaultReactiveExceptionHandler extends BootReactiveExceptionHandler { + +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/resources/META-INF/spring.factories b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000000..1c1822d86b --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/resources/META-INF/spring.factories @@ -0,0 +1,3 @@ +# Environment Post Processors +org.springframework.boot.EnvironmentPostProcessor=\ +com.nvidia.boot.exceptions.env.ExceptionsEnvironmentPostProcessor diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000000..ce56cae0cc --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +com.nvidia.boot.exceptions.ReactiveExceptionsAutoConfiguration +com.nvidia.boot.exceptions.ServletExceptionsAutoConfiguration diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/resources/nv-boot-exceptions-defaults.properties b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/resources/nv-boot-exceptions-defaults.properties new file mode 100644 index 0000000000..7da5c19a59 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/main/resources/nv-boot-exceptions-defaults.properties @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Default properties for nv-boot-starter-exceptions +spring.mvc.problemdetails.enabled=true +spring.webflux.problemdetails.enabled=true diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/test/java/com/nvidia/boot/exceptions/BootResponseExceptionTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/test/java/com/nvidia/boot/exceptions/BootResponseExceptionTest.java new file mode 100644 index 0000000000..84f13eff3e --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/test/java/com/nvidia/boot/exceptions/BootResponseExceptionTest.java @@ -0,0 +1,119 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.exceptions; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; + +class BootResponseExceptionTest { + + private static final String MESSAGE = "something went wrong"; + + @Test + void badRequestExceptionSetsStatusDetailAndUrn() { + var ex = new BadRequestException(MESSAGE); + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + var body = ex.getBody(); + assertThat(body.getDetail()).isEqualTo(MESSAGE); + assertThat(body.getType()).hasToString("urn:nv-boot:problem-details:bad-request"); + assertThat(body.getTitle()).isEqualTo(HttpStatus.BAD_REQUEST.getReasonPhrase()); + } + + @Test + void badRequestExceptionWithCausePreservesCause() { + var cause = new RuntimeException("root"); + var ex = new BadRequestException(MESSAGE, cause); + assertThat(ex.getCause()).isSameAs(cause); + assertThat(ex.getBody().getDetail()).isEqualTo(MESSAGE); + } + + @Test + void badRequestExceptionNullMessageUsesStatusReasonPhraseAsDetail() { + var ex = new BadRequestException(null); + assertThat(ex.getBody().getDetail()).isEqualTo(HttpStatus.BAD_REQUEST.getReasonPhrase()); + assertThat(ex.getBody().getType()).hasToString("urn:nv-boot:problem-details:bad-request"); + } + + @Test + void badRequestExceptionBlankMessageUsesStatusReasonPhraseAsDetail() { + var ex = new BadRequestException(" "); + assertThat(ex.getBody().getDetail()).isEqualTo(HttpStatus.BAD_REQUEST.getReasonPhrase()); + } + + @Test + void notFoundExceptionSetsCorrectUrn() { + var ex = new NotFoundException(MESSAGE); + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(ex.getBody().getType()).hasToString("urn:nv-boot:problem-details:not-found"); + assertThat(ex.getBody().getDetail()).isEqualTo(MESSAGE); + } + + @Test + void unauthorizedExceptionSetsCorrectUrn() { + var ex = new UnauthorizedException(MESSAGE); + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(ex.getBody().getType()).hasToString("urn:nv-boot:problem-details:unauthorized"); + } + + @Test + void forbiddenExceptionSetsCorrectUrn() { + var ex = new ForbiddenException(MESSAGE); + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(ex.getBody().getType()).hasToString("urn:nv-boot:problem-details:forbidden"); + } + + @Test + void conflictExceptionSetsCorrectUrn() { + var ex = new ConflictException(MESSAGE); + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(ex.getBody().getType()).hasToString("urn:nv-boot:problem-details:conflict"); + } + + @Test + void unprocessableEntityExceptionSetsCorrectUrn() { + var ex = new UnprocessableEntityException(MESSAGE); + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT); + assertThat(ex.getBody().getType()) + .hasToString("urn:nv-boot:problem-details:unprocessable-entity"); + } + + @Test + void tooManyRequestsExceptionSetsCorrectUrn() { + var ex = new TooManyRequestsException(MESSAGE); + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.TOO_MANY_REQUESTS); + assertThat(ex.getBody().getType()) + .hasToString("urn:nv-boot:problem-details:too-many-requests"); + } + + @Test + void upstreamExceptionSetsCorrectUrn() { + var ex = new UpstreamException(MESSAGE); + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY); + assertThat(ex.getBody().getType()).hasToString("urn:nv-boot:problem-details:upstream"); + } + + @Test + void paymentRequiredExceptionSetsCorrectUrn() { + var ex = new PaymentRequiredException(MESSAGE); + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.PAYMENT_REQUIRED); + assertThat(ex.getBody().getType()) + .hasToString("urn:nv-boot:problem-details:payment-required"); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/test/java/com/nvidia/boot/exceptions/ReactiveExceptionsAutoConfigurationIntegrationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/test/java/com/nvidia/boot/exceptions/ReactiveExceptionsAutoConfigurationIntegrationTest.java new file mode 100644 index 0000000000..ba061ff577 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/test/java/com/nvidia/boot/exceptions/ReactiveExceptionsAutoConfigurationIntegrationTest.java @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.exceptions; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.nvidia.boot.exceptions.handlers.DefaultReactiveExceptionHandler; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.result.method.annotation.ResponseEntityExceptionHandler; + +class ReactiveExceptionsAutoConfigurationIntegrationTest { + + private final ReactiveWebApplicationContextRunner contextRunner = + new ReactiveWebApplicationContextRunner() + .withPropertyValues("spring.main.web-application-type=reactive") + .withConfiguration(AutoConfigurations.of(ReactiveExceptionsAutoConfiguration.class)); + + @Test + void registersDefaultReactiveExceptionHandler() { + contextRunner.run((var context) -> assertThat(context).hasSingleBean(DefaultReactiveExceptionHandler.class)); + } + + @Test + void customReactiveResponseEntityExceptionHandlerPreventsDefaultBean() { + new ReactiveWebApplicationContextRunner() + .withPropertyValues("spring.main.web-application-type=reactive") + .withUserConfiguration(CustomReactiveHandlerConfig.class) + .withConfiguration(AutoConfigurations.of(ReactiveExceptionsAutoConfiguration.class)) + .run((var context) -> { + assertThat(context).doesNotHaveBean(DefaultReactiveExceptionHandler.class); + assertThat(context).hasBean("customReactiveHandler"); + }); + } + + @Configuration + static class CustomReactiveHandlerConfig { + + @Bean + ResponseEntityExceptionHandler customReactiveHandler() { + return new ResponseEntityExceptionHandler() {}; + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/test/java/com/nvidia/boot/exceptions/ServletExceptionsAutoConfigurationIntegrationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/test/java/com/nvidia/boot/exceptions/ServletExceptionsAutoConfigurationIntegrationTest.java new file mode 100644 index 0000000000..0dcff11acf --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/test/java/com/nvidia/boot/exceptions/ServletExceptionsAutoConfigurationIntegrationTest.java @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.exceptions; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.nvidia.boot.exceptions.handlers.DefaultMvcExceptionHandler; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +class ServletExceptionsAutoConfigurationIntegrationTest { + + private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner() + .withPropertyValues("spring.main.web-application-type=servlet") + .withConfiguration(AutoConfigurations.of(ServletExceptionsAutoConfiguration.class)); + + @Test + void registersDefaultMvcExceptionHandler() { + contextRunner.run((var context) -> assertThat(context).hasSingleBean(DefaultMvcExceptionHandler.class)); + } + + @Test + void customServletResponseEntityExceptionHandlerPreventsDefaultBean() { + new WebApplicationContextRunner() + .withPropertyValues("spring.main.web-application-type=servlet") + .withUserConfiguration(CustomMvcHandlerConfig.class) + .withConfiguration(AutoConfigurations.of(ServletExceptionsAutoConfiguration.class)) + .run((var context) -> { + assertThat(context).doesNotHaveBean(DefaultMvcExceptionHandler.class); + assertThat(context).hasBean("customMvcHandler"); + }); + } + + @Configuration + static class CustomMvcHandlerConfig { + + @Bean + ResponseEntityExceptionHandler customMvcHandler() { + return new ResponseEntityExceptionHandler() {}; + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/test/java/com/nvidia/boot/exceptions/handlers/BootMvcExceptionHandlerTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/test/java/com/nvidia/boot/exceptions/handlers/BootMvcExceptionHandlerTest.java new file mode 100644 index 0000000000..69e9942301 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/test/java/com/nvidia/boot/exceptions/handlers/BootMvcExceptionHandlerTest.java @@ -0,0 +1,125 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.exceptions.handlers; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import com.nvidia.boot.exceptions.NotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.web.context.request.WebRequest; + +class BootMvcExceptionHandlerTest { + + private TestBootExceptionHandler handler; + private WebRequest webRequest; + + @BeforeEach + void setUp() { + handler = new TestBootExceptionHandler(); + webRequest = mock(WebRequest.class); + } + + @Test + void handleAccessDeniedReturnsForbiddenWithProblemDetail() throws Exception { + var ex = new AccessDeniedException("denied"); + var response = handler.handleAccessDenied(ex, webRequest); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody()).isInstanceOf(ProblemDetail.class); + var body = (ProblemDetail) response.getBody(); + assertThat(body.getDetail()).isEqualTo("denied"); + assertThat(body.getType()).hasToString("urn:nv-boot:problem-details:forbidden"); + } + + @Test + void handleAccessDeniedNullMessageUsesDefault() throws Exception { + var ex = new AccessDeniedException(null); + var response = handler.handleAccessDenied(ex, webRequest); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + var body = (ProblemDetail) response.getBody(); + assertThat(body.getDetail()).isEqualTo("Access denied"); + } + + @Test + void handleAuthenticationReturnsUnauthorizedWithProblemDetail() throws Exception { + var ex = new TestAuthenticationException("bad credentials"); + var response = handler.handleAuthentication(ex, webRequest); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(response.getBody()).isInstanceOf(ProblemDetail.class); + var body = (ProblemDetail) response.getBody(); + assertThat(body.getDetail()).isEqualTo("bad credentials"); + assertThat(body.getType()).hasToString("urn:nv-boot:problem-details:unauthorized"); + } + + @Test + void handleAuthenticationNullMessageUsesDefault() throws Exception { + var ex = new TestAuthenticationException(null); + var response = handler.handleAuthentication(ex, webRequest); + var body = (ProblemDetail) response.getBody(); + assertThat(body.getDetail()).isEqualTo("Unauthorized"); + } + + @Test + void handleUnmappedReturnsInternalServerErrorAndRecordsException() throws Exception { + var ex = new IllegalStateException("unexpected"); + var response = handler.handleUnmapped(ex, webRequest); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(response.getBody()).isInstanceOf(ProblemDetail.class); + var body = (ProblemDetail) response.getBody(); + assertThat(body.getDetail()).isEqualTo("unexpected"); + // recordException is invoked from handleExceptionInternal with the wrapped exception + assertThat(handler.recordedException).isInstanceOf(org.springframework.web.ErrorResponseException.class); + assertThat(handler.recordedException.getCause()).isSameAs(ex); + } + + @Test + void handleUnmappedNullMessageUsesDefault() throws Exception { + var ex = new Exception(); + var response = handler.handleUnmapped(ex, webRequest); + var body = (ProblemDetail) response.getBody(); + assertThat(body.getDetail()).isEqualTo("Internal server error"); + } + + @Test + void handleExceptionInternalCallsRecordException() { + var ex = new NotFoundException("missing"); + handler.handleExceptionInternal(ex, ex.getBody(), null, ex.getStatusCode(), webRequest); + assertThat(handler.recordedException).isSameAs(ex); + } + + private static final class TestBootExceptionHandler extends BootMvcExceptionHandler { + Exception recordedException; + + @Override + protected void recordException(Exception ex) { + this.recordedException = ex; + } + } + + @SuppressWarnings("serial") + private static final class TestAuthenticationException extends AuthenticationException { + TestAuthenticationException(String message) { + super(message); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/test/java/com/nvidia/boot/exceptions/handlers/BootReactiveExceptionHandlerTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/test/java/com/nvidia/boot/exceptions/handlers/BootReactiveExceptionHandlerTest.java new file mode 100644 index 0000000000..a0e6adcaff --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/test/java/com/nvidia/boot/exceptions/handlers/BootReactiveExceptionHandlerTest.java @@ -0,0 +1,103 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.exceptions.handlers; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.nvidia.boot.exceptions.NotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.web.server.ServerWebExchange; + +class BootReactiveExceptionHandlerTest { + + private TestBootReactiveExceptionHandler handler; + private ServerWebExchange exchange; + + @BeforeEach + void setUp() { + handler = new TestBootReactiveExceptionHandler(); + exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/").build()); + } + + @Test + void handleAccessDeniedReturnsForbiddenWithProblemDetail() { + var ex = new AccessDeniedException("denied"); + var response = handler.handleException(ex, exchange).block(); + assertThat(response).isNotNull(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody()).isInstanceOf(ProblemDetail.class); + var body = (ProblemDetail) response.getBody(); + assertThat(body.getDetail()).isEqualTo("denied"); + assertThat(body.getType()).hasToString("urn:nv-boot:problem-details:forbidden"); + } + + @Test + void handleAuthenticationReturnsUnauthorizedWithProblemDetail() { + var ex = new TestAuthenticationException("bad credentials"); + var response = handler.handleException(ex, exchange).block(); + assertThat(response).isNotNull(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(response.getBody()).isInstanceOf(ProblemDetail.class); + var body = (ProblemDetail) response.getBody(); + assertThat(body.getDetail()).isEqualTo("bad credentials"); + assertThat(body.getType()).hasToString("urn:nv-boot:problem-details:unauthorized"); + } + + @Test + void handleExceptionCatchAllReturnsInternalServerError() { + var ex = new IllegalStateException("unexpected"); + var response = handler.handleExceptionCatchAll(ex, exchange).block(); + assertThat(response).isNotNull(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(response.getBody()).isInstanceOf(ProblemDetail.class); + var body = (ProblemDetail) response.getBody(); + assertThat(body.getDetail()).isEqualTo("unexpected"); + } + + @Test + void handleExceptionInternalCallsRecordException() { + var ex = new NotFoundException("missing"); + assertThat(handler.handleExceptionInternal( + ex, ex.getBody(), null, ex.getStatusCode(), exchange).block()).isNotNull(); + assertThat(handler.recordedException).isSameAs(ex); + } + + private static final class TestBootReactiveExceptionHandler + extends BootReactiveExceptionHandler { + Exception recordedException; + + @Override + protected void recordException(Exception ex) { + this.recordedException = ex; + } + } + + @SuppressWarnings("serial") + private static final class TestAuthenticationException extends AuthenticationException { + TestAuthenticationException(String message) { + super(message); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/test/java/com/nvidia/boot/exceptions/handlers/DefaultExceptionHandlersTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/test/java/com/nvidia/boot/exceptions/handlers/DefaultExceptionHandlersTest.java new file mode 100644 index 0000000000..c6f5b5ff82 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/test/java/com/nvidia/boot/exceptions/handlers/DefaultExceptionHandlersTest.java @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.exceptions.handlers; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.web.bind.annotation.ControllerAdvice; + +class DefaultExceptionHandlersTest { + + @Test + void defaultMvcExceptionHandlerIsControllerAdvice() { + var advice = + AnnotationUtils.findAnnotation(DefaultMvcExceptionHandler.class, + ControllerAdvice.class); + assertThat(advice).isNotNull(); + assertThat(DefaultMvcExceptionHandler.class.getSuperclass()) + .isEqualTo(BootMvcExceptionHandler.class); + } + + @Test + void defaultReactiveExceptionHandlerIsControllerAdvice() { + var advice = + AnnotationUtils.findAnnotation(DefaultReactiveExceptionHandler.class, + ControllerAdvice.class); + assertThat(advice).isNotNull(); + assertThat(DefaultReactiveExceptionHandler.class.getSuperclass()) + .isEqualTo(BootReactiveExceptionHandler.class); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000000..1f0955d450 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/BUILD.bazel b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/BUILD.bazel new file mode 100644 index 0000000000..8e2b15054a --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/BUILD.bazel @@ -0,0 +1,48 @@ +load("//src/libraries/java/nv-boot-parent/tools/bazel:java.bzl", "nv_boot_library", "nv_boot_library_test") + +JWT_COMPILE_DEPS = [ + "@nv_third_party_deps//:com_fasterxml_jackson_core_jackson_annotations", + "@nv_third_party_deps//:com_github_ben_manes_caffeine_guava", + "@nv_third_party_deps//:com_google_guava_guava", + "@nv_third_party_deps//:com_nimbusds_nimbus_jose_jwt", + "@nv_third_party_deps//:io_opentelemetry_opentelemetry_api", + "@nv_third_party_deps//:org_apache_commons_commons_lang3", + "@nv_third_party_deps//:org_bouncycastle_bcprov_jdk18on", + "@nv_third_party_deps//:org_slf4j_slf4j_api", + "@nv_third_party_deps//:org_springframework_boot_spring_boot", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_autoconfigure", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_jackson", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_jackson", + "@nv_third_party_deps//:org_springframework_cloud_spring_cloud_context", + "@nv_third_party_deps//:org_springframework_security_spring_security_oauth2_core", + "@nv_third_party_deps//:org_springframework_security_spring_security_oauth2_jose", + "@nv_third_party_deps//:org_springframework_spring_beans", + "@nv_third_party_deps//:org_springframework_spring_context", + "@nv_third_party_deps//:org_springframework_spring_core", + "@nv_third_party_deps//:org_springframework_spring_web", + "@nv_third_party_deps//:tools_jackson_core_jackson_core", + "@nv_third_party_deps//:tools_jackson_core_jackson_databind", + "@nv_third_party_deps//:tools_jackson_module_jackson_module_blackbird", +] + +nv_boot_library( + name = "nv_boot_starter_jwt", + srcs = glob(["src/main/java/**/*.java"]), + resource_strip_prefix = "src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/resources", + resources = glob(["src/main/resources/**"]), + visibility = ["//visibility:public"], + deps = JWT_COMPILE_DEPS, +) + +nv_boot_library_test( + name = "tests", + srcs = glob(["src/test/java/**/*.java"]), + coverage_library = ":nv_boot_starter_jwt", + resource_strip_prefix = "src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/resources", + resources = glob(["src/test/resources/**"]), + deps = [ + ":nv_boot_starter_jwt", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_webmvc_test", + ] + JWT_COMPILE_DEPS, +) diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/README.md b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/README.md new file mode 100644 index 0000000000..bbc1f17b7d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/README.md @@ -0,0 +1,110 @@ +# NV Boot Starter JWT + +This is a library that allows operations on JWT: +* Signing +* Verifying signature +* Encrypt JWE +* Decrypt JWE + +## Adding as a Dependency + +Add to your application's `pom.xml`: + +```xml + + + + com.nvidia.boot + nv-boot-bom + ${nv-boot.version} + pom + import + + + + + + + com.nvidia.boot + nv-boot-starter-jwt + + +``` + +## Spring Context Configuration + +### Beans the Application Must Provide + +The library requires the following beans to be present in the Spring context before it can activate: + +| Bean | Type | Description | +|------|------|-------------| +| `privateJwks` | `PrivateJwksString` | Holds the JSON string content of the private JWK Set. Used for signing JWTs and encrypting/decrypting payloads. | +| `jweKeysMapping` | `JweKeysMapping` | Maps keyset names (used in application code) to JWE key IDs (`kid`) in the JWK Set. | +| `encryptedModelConverterProperties` | `EncryptedModelConverterProperties` | *(Optional)* Provide this bean to activate the `EncryptedModelConverter`. Sets the base package to scan for `@ValueObject`-annotated classes. | + +**Example configuration:** + +```java +@Configuration +public class JwtConfiguration { + + @Bean + public PrivateJwksString privateJwks() throws IOException { + // Load from file, Vault, or other secure source + ClassPathResource keyResource = new ClassPathResource("keys/jwks_private.json"); + return new PrivateJwksString( + new String(keyResource.getInputStream().readAllBytes())); + } + + @Bean + public JweKeysMapping jweKeysMapping() { + return JweKeysMapping.builder() + .keysMapping(Map.of("my-keyset-name", "kid-from-jwks")) + .build(); + } + + // Optional: provide to enable EncryptedModelConverter + @Bean + public EncryptedModelConverterProperties encryptedModelConverterProperties( + @Value("${my-app.jwt.base-package}") String basePackage) { + var props = new EncryptedModelConverterProperties(); + props.setBasePackage(basePackage); + return props; + } +} +``` + +### Auto Configuration + +The library uses `JwtAutoConfiguration` as the single entry point, which imports `JwksConfiguration` +(in the `configuration` package). + +### Beans the Library Auto-Configures + +When `PrivateJwksString` and `JweKeysMapping` are present, the library registers these beans: + +| Bean | Type | Description | +|------|---------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------| +| `jwkSet` | `JWKSet` | Parsed JWK Set from `PrivateJwksString`. | +| `jwtService` | `JwtService` | JWT signing, verification, encryption, and decryption. | +| `genericEncryptedJsonMapper` | `JsonMapper` | JsonMapper configured for encrypted model conversion. Only created if no bean with this name exists. | +| `encryptedModelConverter` | `EncryptedModelConverter` | Converts between model and value object types with support for encrypted fields. Only created when an `EncryptedModelConverterProperties` bean is present. | + + +## Usage +### Generating keys +* [generate EC (private key)](./src/test/java/com/nvidia/boot/jwt/services/JwtServiceTest.java#L201) +* [generate AES (symmetric key)](../src/test/java/com/nvidia/boot/jwt/services/JwtServiceTest.java#L220) + +### Getting JwtService +`JwtService` has two dependencies: +* key-set of private keys +* key type to id mapping + +The key mapping is needed for your services to be able to rotate encryption keys. Consumer service +will use key type and JwtService will resolve kid using the mapping. This way your app services +don't deal with key ids directly. If you do need to use particular key, you still can. + +See [JwtServiceTest](../main/src/test/java/com/nvidia/boot/jwt/services/JwtServiceTest.java) +for more usage examples. diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/pom.xml b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/pom.xml new file mode 100644 index 0000000000..dd4b04c18c --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/pom.xml @@ -0,0 +1,108 @@ + + + + + 4.0.0 + + + com.nvidia.boot + nv-boot-parent + 0.0.1-SNAPSHOT + + + nv-boot-starter-jwt + jar + NV Boot Starter JWT + JWT authentication and validation starter + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-jackson + + + tools.jackson.module + jackson-module-blackbird + + + org.springframework.security + spring-security-oauth2-jose + + + org.springframework.cloud + spring-cloud-context + + + com.github.ben-manes.caffeine + guava + + + org.projectlombok + lombok + + + org.apache.commons + commons-lang3 + + + org.bouncycastle + bcprov-jdk18on + + + io.opentelemetry + opentelemetry-api + + + + org.slf4j + slf4j-api + + + + + org.junit.jupiter + junit-jupiter-engine + test + + + + org.mockito + mockito-core + test + + + + org.springframework + spring-test + test + + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + + diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/JwtAutoConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/JwtAutoConfiguration.java new file mode 100644 index 0000000000..fde21524df --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/JwtAutoConfiguration.java @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.jwt; + +import com.nvidia.boot.jwt.configuration.JwksConfiguration; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Import; + +@AutoConfiguration +@Import(JwksConfiguration.class) +public class JwtAutoConfiguration { +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/configuration/EncryptedModelConverterProperties.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/configuration/EncryptedModelConverterProperties.java new file mode 100644 index 0000000000..3887b3b4bb --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/configuration/EncryptedModelConverterProperties.java @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.jwt.configuration; + +import lombok.Getter; +import lombok.Setter; + +/** + * Properties for encrypted model conversion. Applications must provide a bean of this type + * when using the {@link com.nvidia.boot.jwt.services.mapping.EncryptedModelConverter}, + * populating it from their own configuration. + */ +@Getter +@Setter +public class EncryptedModelConverterProperties { + + /** Base package to scan for classes annotated with {@code @ValueObject}. */ + private String basePackage; +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/configuration/JweKeysMapping.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/configuration/JweKeysMapping.java new file mode 100644 index 0000000000..30a43384a3 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/configuration/JweKeysMapping.java @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.jwt.configuration; + +import java.util.HashMap; +import java.util.Map; +import lombok.Builder; +import lombok.Builder.Default; +import lombok.Data; + +@Builder +@Data +public class JweKeysMapping { + + @Default + private Map keysMapping = new HashMap<>(); +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/configuration/JwksConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/configuration/JwksConfiguration.java new file mode 100644 index 0000000000..ca2886b499 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/configuration/JwksConfiguration.java @@ -0,0 +1,98 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.jwt.configuration; + +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.module.blackbird.BlackbirdModule; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.jwk.JWKSet; +import com.nvidia.boot.jwt.services.JwtService; +import com.nvidia.boot.jwt.services.mapping.EncryptedModelConverter; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.text.ParseException; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.EnableMBeanExport; +import org.springframework.jmx.support.RegistrationPolicy; + +@Configuration +/** + * @EnableMBeanExport is used to fix JMX registration conflict which only applicable when running + * locally. JMX must be disabled in all environments as there is no plan to use it and incorrect + * configuration is a major security threat. + *

+ * application.yaml: + * spring: + * jmx: + * enabled: false + */ +@EnableMBeanExport(registration = RegistrationPolicy.IGNORE_EXISTING) +@ConditionalOnBean({PrivateJwksString.class, JweKeysMapping.class}) +public class JwksConfiguration { + + @Bean + @RefreshScope + public JWKSet jwkSet(PrivateJwksString privateJwksString) { + try { + return JWKSet.load(new ByteArrayInputStream(privateJwksString.getValue().getBytes())); + } catch (ParseException | IOException e) { + throw new IllegalStateException("Failed to read private JWKs", e); + } + } + + @Bean + @RefreshScope + public JwtService jwtService( + JWKSet jwkSet, + JweKeysMapping jweKeysMapping) throws JOSEException { + return new JwtService(jwkSet, jweKeysMapping); + } + + // defaultCandidate = false keeps the bean available by @Qualifier("genericEncryptedJsonMapper") + // for EncryptedModelConverter, but hides it from type-based resolution so it does not satisfy + // Spring Boot's @ConditionalOnMissingBean(JsonMapper.class) in JacksonAutoConfiguration. If it + // did, Spring Boot would skip creating its primary JsonMapper and Spring MVC would fall back + // to this one for HTTP message conversion — changing serialization semantics (e.g. ProblemDetail + // .properties is emitted as `null` instead of being omitted). + @Bean(name = "genericEncryptedJsonMapper", defaultCandidate = false) + @ConditionalOnMissingBean(name = "genericEncryptedJsonMapper") + public JsonMapper genericEncryptedJsonMapper() { + return JsonMapper.builder() + .addModule(new BlackbirdModule()) + .findAndAddModules() + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) // data migrations + .build(); + } + + @Bean + @RefreshScope + @ConditionalOnBean(EncryptedModelConverterProperties.class) + public EncryptedModelConverter encryptedModelConverter( + JwtService jwtService, + @Qualifier("genericEncryptedJsonMapper") JsonMapper genericEncryptedJsonMapper, + EncryptedModelConverterProperties encryptedModelConverterProperties) { + var basePackage = encryptedModelConverterProperties.getBasePackage(); + return new EncryptedModelConverter(jwtService, genericEncryptedJsonMapper, basePackage); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/configuration/PrivateJwksString.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/configuration/PrivateJwksString.java new file mode 100644 index 0000000000..95c54a67e6 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/configuration/PrivateJwksString.java @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.jwt.configuration; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@EqualsAndHashCode +@RequiredArgsConstructor +public class PrivateJwksString { + + @Getter + private final String value; +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/exceptions/EncryptionException.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/exceptions/EncryptionException.java new file mode 100644 index 0000000000..6bcdd85b1b --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/exceptions/EncryptionException.java @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.jwt.exceptions; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.web.ErrorResponseException; + +public class EncryptionException extends ErrorResponseException { + + public EncryptionException(String message, Exception cause) { + super(HttpStatus.INTERNAL_SERVER_ERROR, + ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, message), cause); + } + + public EncryptionException(String message) { + this(message, null); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/exceptions/JwtException.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/exceptions/JwtException.java new file mode 100644 index 0000000000..7543bc65ed --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/exceptions/JwtException.java @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.jwt.exceptions; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.web.ErrorResponseException; + +public class JwtException extends ErrorResponseException { + + public JwtException(String message, Exception cause) { + super(HttpStatus.INTERNAL_SERVER_ERROR, + ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, message), cause); + } + + public JwtException(String message) { + this(message, null); + } + +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/exceptions/ModelConversionException.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/exceptions/ModelConversionException.java new file mode 100644 index 0000000000..a323ea9b80 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/exceptions/ModelConversionException.java @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.jwt.exceptions; + +public class ModelConversionException extends RuntimeException { + + public ModelConversionException(String message) { + super(message); + } + + public ModelConversionException(String message, Exception e) { + super(message, e); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/exceptions/SigningException.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/exceptions/SigningException.java new file mode 100644 index 0000000000..0d3c44366d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/exceptions/SigningException.java @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.jwt.exceptions; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.web.ErrorResponseException; + +public class SigningException extends ErrorResponseException { + + public SigningException(String message, Throwable e) { + super(HttpStatus.INTERNAL_SERVER_ERROR, + ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, message), e); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/exceptions/ValidationException.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/exceptions/ValidationException.java new file mode 100644 index 0000000000..5513e1980b --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/exceptions/ValidationException.java @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.jwt.exceptions; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.web.ErrorResponseException; + +public class ValidationException extends ErrorResponseException { + + public ValidationException(String message) { + super(HttpStatus.BAD_REQUEST, + ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, message), null); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/services/JwtService.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/services/JwtService.java new file mode 100644 index 0000000000..862ab7a79b --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/services/JwtService.java @@ -0,0 +1,405 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.jwt.services; + +import static org.springframework.util.CollectionUtils.isEmpty; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; +import com.nimbusds.jose.EncryptionMethod; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWEAlgorithm; +import com.nimbusds.jose.JWEDecrypter; +import com.nimbusds.jose.JWEEncrypter; +import com.nimbusds.jose.JWEHeader; +import com.nimbusds.jose.JWEObject; +import com.nimbusds.jose.JWSSigner; +import com.nimbusds.jose.JWSVerifier; +import com.nimbusds.jose.Payload; +import com.nimbusds.jose.crypto.AESDecrypter; +import com.nimbusds.jose.crypto.AESEncrypter; +import com.nimbusds.jose.crypto.ECDHDecrypter; +import com.nimbusds.jose.crypto.ECDHEncrypter; +import com.nimbusds.jose.crypto.ECDSASigner; +import com.nimbusds.jose.crypto.ECDSAVerifier; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jose.crypto.MACVerifier; +import com.nimbusds.jose.crypto.RSADecrypter; +import com.nimbusds.jose.crypto.RSAEncrypter; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.crypto.RSASSAVerifier; +import com.nimbusds.jose.crypto.bc.BouncyCastleProviderSingleton; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.OctetSequenceKey; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.util.Base64URL; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.nvidia.boot.jwt.configuration.JweKeysMapping; +import com.nvidia.boot.jwt.exceptions.EncryptionException; +import com.nvidia.boot.jwt.exceptions.JwtException; +import com.nvidia.boot.jwt.exceptions.SigningException; +import com.nvidia.boot.jwt.exceptions.ValidationException; +import io.opentelemetry.api.trace.Span; +import java.text.ParseException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; + +@Slf4j +public class JwtService { + + public static final String FAILED_TO_PARSE_TOKEN = "Failed to parse token"; + public static final String ERROR_CANT_VALIDATE_EMPTY_TOKEN = "Can't validate empty token"; + public static final String ERROR_TOKEN_DOESNT_HAVE_HEADER = "Token doesn't have header"; + public static final String ERROR_TOKEN_MISSING_KEY_ID_IN_HEADER = "Token missing key id in header"; + public static final String ERROR_NO_KEY_REGISTERED_WITH_ID = "No key registered with id:"; + public static final String ENCRYPTION_KID_TAG = "JWE_KEY_ID"; + public static final String ERROR_NO_JWK_SET_PROVIDED = "No JWKSet provided"; + public static final String ERROR_ENCRYPTION_KEY_MAPPING_NOT_SET = "Encryption key mapping is not set in config file."; + public static final String ERROR_NO_SIGNER_DEFINED_FOR_KID = "No signer defined for kid:"; + /** + * Both signers and verifiers in the library are thread safe and use factories under the hood. + * Pooling or caching unlikely to improve performance. + */ + // Signers accessible by key id + private final Map signersByKidMap = new HashMap<>(); + + // Verifiers accessible by key id + private final Map verifiersByKidMap = new HashMap<>(); + + // Encrypters accessible by key id + private final Map encryptersByKidMap = new HashMap<>(); + + // Decrypters accessible by key id + private final Map decryptersByKidMap = new HashMap<>(); + + private static final EncryptionMethod encryptionMethod = EncryptionMethod.A256GCM; + private static final JWEAlgorithm jweAlgorithm = JWEAlgorithm.A256GCMKW; + + private final Map keyNameToIdMap; + + /** + * Build this service based on the given keystore. All keys must have a key id ({@code kid}) + * field in order to be used. + * + * @param jwkSet the key set to load all keys from + */ + @Autowired + public JwtService( + @NonNull JWKSet jwkSet, + JweKeysMapping jweKeysMapping) + throws JOSEException { + List jwkSetKeys = jwkSet.getKeys(); + if (isEmpty(jwkSetKeys)) { + throw new IllegalStateException(ERROR_NO_JWK_SET_PROVIDED); + } + for (JWK key : jwkSetKeys) { + if (Strings.isNullOrEmpty(key.getKeyID())) { + // halt service + throw new IllegalStateException("One of the keys in key set has no id."); + } + + if (key instanceof RSAKey rsaKey) { + registerKey(rsaKey); + } else if (key instanceof ECKey ecKey) { + registerKey(ecKey); + } else if (key instanceof OctetSequenceKey octetSequenceKey) { + registerKey(octetSequenceKey); + } else { + throw new IllegalArgumentException("Only RSA, EC and MAC keys supported so far"); + } + } + + if (isEmpty(jweKeysMapping.getKeysMapping())) { + throw new IllegalStateException(ERROR_ENCRYPTION_KEY_MAPPING_NOT_SET); + } + this.keyNameToIdMap = jweKeysMapping.getKeysMapping(); + } + + private void registerKey(OctetSequenceKey key) + throws JOSEException { + addSignerVerifier(key); + addEncrypterDecrypter(key); + } + + private void registerKey(ECKey key) + throws JOSEException { + addSignerVerifier(key); + addEncrypterDecrypter(key); + } + + private void registerKey(RSAKey key) + throws JOSEException { + addSignerVerifier(key); + addEncrypterDecrypter(key); + } + + private void addSignerVerifier(RSAKey key) + throws JOSEException { + if (key.isPrivate()) { + RSASSASigner signer = new RSASSASigner(key); + signersByKidMap.put(key.getKeyID(), signer); + } + + RSASSAVerifier verifier = new RSASSAVerifier(key); + verifiersByKidMap.put(key.getKeyID(), verifier); + } + + private void addSignerVerifier(ECKey key) + throws JOSEException { + if (key.isPrivate()) { + ECDSASigner signer = new ECDSASigner(key); + signersByKidMap.put(key.getKeyID(), signer); + } + + ECDSAVerifier verifier = new ECDSAVerifier(key); + verifiersByKidMap.put(key.getKeyID(), verifier); + } + + private void addSignerVerifier(OctetSequenceKey key) + throws JOSEException { + // its symmetric thus both public and private + MACSigner signer = new MACSigner(key); + signersByKidMap.put(key.getKeyID(), signer); + + MACVerifier verifier = new MACVerifier(key); + verifiersByKidMap.put(key.getKeyID(), verifier); + } + + /** + * Signs the JWT with selected key and algorithm + * + * @param jwt token to sign + * @param keyId id of private key to use for signing + */ + public void signJwt(SignedJWT jwt, String keyId) { + addCustomTag(ENCRYPTION_KID_TAG, keyId); + if (!signersByKidMap.containsKey(keyId)) { + throw new IllegalArgumentException(ERROR_NO_SIGNER_DEFINED_FOR_KID + keyId); + } + + JWSSigner signer = signersByKidMap.get(keyId); + + try { + jwt.sign(signer); + } catch (JOSEException e) { + throw new SigningException("Error while signing token", e); + } + log.debug("Signed token using kid: {}", keyId); + } + + /** + * Validates token signature using registered keys + * + * @param jwt token to verify + * @return whether token is valid + */ + public boolean validateSignature(SignedJWT jwt) { + + boolean result = false; + + if (jwt == null) { + throw new ValidationException(ERROR_CANT_VALIDATE_EMPTY_TOKEN); + } + + if (jwt.getHeader() == null) { + throw new ValidationException(ERROR_TOKEN_DOESNT_HAVE_HEADER); + } + + String kid = jwt.getHeader().getKeyID(); + if (Strings.isNullOrEmpty(kid)) { + throw new ValidationException(ERROR_TOKEN_MISSING_KEY_ID_IN_HEADER); + } + + addCustomTag(ENCRYPTION_KID_TAG, kid); + log.debug("verifying token using kid: {}", kid); + JWSVerifier jwsVerifier = verifiersByKidMap.get(kid); + if (jwsVerifier == null) { + throw new ValidationException(ERROR_NO_KEY_REGISTERED_WITH_ID + kid); + } + + try { + if (jwt.verify(jwsVerifier)) { + result = true; + } + } catch (JOSEException e) { + log.error("Failed to validate signature with {} error message: {}", jwsVerifier, e + .getMessage()); + } + return result; + } + + private void addEncrypterDecrypter(RSAKey key) + throws JOSEException { + RSAEncrypter encrypter = new RSAEncrypter(key); + String kid = key.getKeyID(); + + if (key.isPrivate()) { + RSADecrypter decrypter = new RSADecrypter(key); + addEncrypterDecrypter(kid, encrypter, decrypter); + } else { + addEncrypter(kid, encrypter); + log.warn("No private key for kid: {}", kid); + } + } + + private void addEncrypterDecrypter(ECKey key) + throws JOSEException { + ECDHEncrypter encrypter = new ECDHEncrypter(key); + String kid = key.getKeyID(); + + if (key.isPrivate()) { + ECDHDecrypter decrypter = new ECDHDecrypter(key); + addEncrypterDecrypter(kid, encrypter, decrypter); + } else { + addEncrypter(kid, encrypter); + log.warn("No private key for kid: {}", kid); + } + } + + private void addEncrypterDecrypter(OctetSequenceKey key) + throws JOSEException { + addEncrypterDecrypter(key.getKeyID(), new AESEncrypter(key), new AESDecrypter(key)); + } + + private void addEncrypterDecrypter(String kid, JWEEncrypter encrypter, JWEDecrypter decrypter) { + addEncrypter(kid, encrypter); + decrypter.getJCAContext().setProvider(BouncyCastleProviderSingleton.getInstance()); + decryptersByKidMap.put(kid, decrypter); + } + + private void addEncrypter(String kid, JWEEncrypter encrypter) { + encrypter.getJCAContext().setProvider(BouncyCastleProviderSingleton.getInstance()); + encryptersByKidMap.put(kid, encrypter); + } + + // Visible to be used in tests to validate values in token. + public JWTClaimsSet getJwtClaimsSet(String token) + throws ParseException { + SignedJWT signedJWT = getSignedJWT(token); + return signedJWT.getJWTClaimsSet(); + } + + @VisibleForTesting + SignedJWT getSignedJWT(String token) { + SignedJWT signedJWT = null; + try { + signedJWT = SignedJWT.parse(token); + } catch (ParseException e) { + throw new JwtException(FAILED_TO_PARSE_TOKEN, e); + } + + if (!validateSignature(signedJWT)) { + throw new JwtException("Cant de-tokenize due to invalid signature"); + } + return signedJWT; + } + + /** + * Encrypts payload with key from named key set + * + * @param keySetName name of the key set + * @param payload payload to encryptWithKeysetName + * @return encrypted payload + */ + public String encryptWithKeysetName(String keySetName, String payload) { + return encryptWithKeyId(getKid(keySetName), payload); + } + + /** + * Encrypts string payload with selected key + */ + public String encryptWithKeyId(String kid, String stringPayload) { + addCustomTag(ENCRYPTION_KID_TAG, kid); + + if (!encryptersByKidMap.containsKey(kid)) { + throw new EncryptionException("No encrypter defined for kid:" + kid); + } + + JWEHeader jweHeader = new JWEHeader.Builder(jweAlgorithm, encryptionMethod) + .keyID(kid) + .build(); + + Payload payload = new Payload(Base64URL.encode(stringPayload)); + JWEObject jweObject = new JWEObject(jweHeader, payload); + + JWEEncrypter encrypter = encryptersByKidMap.get(kid); + try { + jweObject.encrypt(encrypter); + } catch (JOSEException e) { + throw new EncryptionException( + "Failed to encryptWithKeysetName JWT, error is: " + e.getMessage(), e); + } + return jweObject.serialize(); + } + + /** + * Decrypts JWT and returns payload + * + * @param encryptedJweString string token to decrypt + */ + public String decrypt(String encryptedJweString) { + JWEObject jweObject; + try { + jweObject = JWEObject.parse(encryptedJweString); + } catch (ParseException e) { + throw new EncryptionException("Can't parse encrypted jwe string:" + e.getMessage(), e); + } + + JWEHeader header = jweObject.getHeader(); + + if (header == null || Strings.isNullOrEmpty(header.getKeyID())) { + throw new EncryptionException("JWT is missing kid in header"); + } + String kid = header.getKeyID(); + addCustomTag(ENCRYPTION_KID_TAG, kid); + JWEDecrypter decrypter = decryptersByKidMap.get(kid); + + if (decrypter == null) { + throw new EncryptionException("Can't decrypt JWT, no decrypter defined for key: " + + kid); + } + + try { + jweObject.decrypt(decrypter); + } catch (JOSEException e) { + throw new EncryptionException("Failed to decrypt JWT, error was: ", e); + } + + // Payload is never null, it's set in jwt.decrypt() + return jweObject.getPayload().toString(); + } + + private String getKid(String keySetName) { + String kid = keyNameToIdMap.get(keySetName); + if (kid == null) { + throw new EncryptionException("Keyset " + keySetName + " is not configured"); + } + return kid; + } + + private static void addCustomTag(String tagName, String tagValue) { + Span.current().setAttribute(tagName, tagValue != null ? tagValue : "UNKNOWN"); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/services/mapping/ClassPathUtils.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/services/mapping/ClassPathUtils.java new file mode 100644 index 0000000000..ac59c9590d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/services/mapping/ClassPathUtils.java @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.jwt.services.mapping; + +import java.lang.annotation.Annotation; +import java.util.List; +import java.util.Objects; +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.core.type.filter.AnnotationTypeFilter; +import org.springframework.util.ClassUtils; + +@Slf4j +@UtilityClass +public class ClassPathUtils { + + public static List> findClassesByAnnotation( + String basePackage, Class annotation) { + ClassPathScanningCandidateComponentProvider scanner = + new ClassPathScanningCandidateComponentProvider(false); + scanner.addIncludeFilter(new AnnotationTypeFilter(annotation)); + var classes = scanner.findCandidateComponents(basePackage) + .stream() + .map(BeanDefinition::getBeanClassName) + .distinct() + .filter(Objects::nonNull) + .map(name -> { + try { + return ClassUtils.forName(name, null); + } catch (ClassNotFoundException e) { + return (Class) null; + } + }) + .filter(Objects::nonNull) + .toList(); + log.debug("while looking for class annotation {}, found {}", + annotation.getSimpleName(), classes); + return classes; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/services/mapping/EncryptedModelConverter.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/services/mapping/EncryptedModelConverter.java new file mode 100644 index 0000000000..86bd3c2efe --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/services/mapping/EncryptedModelConverter.java @@ -0,0 +1,318 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.jwt.services.mapping; + +import static com.google.common.collect.ImmutableBiMap.toImmutableBiMap; +import static com.nvidia.boot.jwt.services.mapping.ClassPathUtils.findClassesByAnnotation; +import static java.util.stream.Collectors.toUnmodifiableMap; + +import tools.jackson.core.JacksonException; +import tools.jackson.databind.BeanDescription; +import tools.jackson.databind.JavaType; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.cfg.MapperConfig; +import tools.jackson.databind.introspect.AnnotatedField; +import tools.jackson.databind.introspect.AnnotatedMember; +import tools.jackson.databind.introspect.BeanPropertyDefinition; +import tools.jackson.databind.introspect.JacksonAnnotationIntrospector; +import com.google.common.annotations.Beta; +import com.google.common.collect.BiMap; +import com.nvidia.boot.jwt.exceptions.ModelConversionException; +import com.nvidia.boot.jwt.services.JwtService; +import com.nvidia.boot.jwt.services.mapping.annotation.EncryptedFields; +import com.nvidia.boot.jwt.services.mapping.annotation.ValueObject; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * this module is in BETA. breaking changes may happen. + */ +@Beta +@Slf4j +public class EncryptedModelConverter { + + private final BiMap, Class> modelToVoClass; + private final Map, BeanPropertyDefinition> encryptedFieldCache; + private final Map, Class>, Map> mapModelPropsToVoPropsCache; + private final Map, Class>, JsonMapper> encryptedFieldsMapperCache; + private final JwtService jwtService; + private final JsonMapper jsonMapper; + + public EncryptedModelConverter( + JwtService jwtService, + JsonMapper genericEncryptedJsonMapper, + String basePackage) { + this.jwtService = jwtService; + this.jsonMapper = genericEncryptedJsonMapper; + // even though the class is generic, spring instantiates one copy of the class for all + // generic type autowiring. this means we need to handle all Model Vo combos in one + // instance. + // prepopulate object mappings for perf and to fail-fast + this.modelToVoClass = findClassesByAnnotation(basePackage, ValueObject.class) + .stream() + .collect(toImmutableBiMap(vo -> vo.getAnnotation(ValueObject.class).model(), + Function.identity())); + if (modelToVoClass.isEmpty()) { + throw new ModelConversionException( + "could not find any model mappings! " + + "this is usually an error. " + + "if it is not an error, " + + "disable the EncryptedModelConverter service."); + } + this.encryptedFieldCache = modelToVoClass.keySet().stream() + .collect(toUnmodifiableMap(Function.identity(), this::getEncryptedFieldRaw)); + this.mapModelPropsToVoPropsCache = modelToVoClass.entrySet().stream() + .collect(toUnmodifiableMap(Function.identity(), + entry -> mapModelPropsToVoPropsRaw(entry.getKey(), + entry.getValue()))); + this.encryptedFieldsMapperCache = modelToVoClass.entrySet().stream() + .collect(toUnmodifiableMap(Function.identity(), + entry -> getEncryptedFieldsMapperRaw(entry.getKey(), + entry.getValue()))); + } + + public Vo modelToVo(Model model) { + var encryptedField = getEncryptedField(model.getClass()); + Class valueObjectClass = encryptedField.getField() + .getAnnotation(EncryptedFields.class) + .valueObject(); + + // decrypt JWE + String data = (String) encryptedField.getAccessor().getValue(model); + String decryptedData = jwtService.decrypt(data); + + // map values from encrypted field onto new Value Object + Vo valueObject; + try { + //noinspection unchecked + valueObject = (Vo) jsonMapper.readValue(decryptedData, valueObjectClass); + } catch (JacksonException e) { + throw new ModelConversionException("cannot map decrypted fields onto Vo", e); + } + + // copy non encrypted values from Model onto ValueObject + var modelPropsToValueProps = mapModelPropsToVoProps(model.getClass(), valueObjectClass); + modelPropsToValueProps.forEach((modelProp, voProp) -> { + Object modelPropValue = modelProp.getAccessor().getValue(model); + voProp.getNonConstructorMutator().setValue(valueObject, modelPropValue); + }); + return valueObject; + } + + public Model voToModel(Vo valueObject) { + Class valueType = valueObject.getClass(); + Class modelType = getModelTypeFromValueType(valueType); + + String encryptedData = encryptVoFields(valueObject, modelType); + + // add all other fields to model + //noinspection unchecked + Model model = (Model) jsonMapper.convertValue(valueObject, modelType); + // add encrypted data to model + var encryptedField = getEncryptedField(modelType); + encryptedField.getNonConstructorMutator().setValue(model, encryptedData); + return model; + } + + private Class getModelTypeFromValueType(Class valueType) { + var ret = modelToVoClass.inverse().get(valueType); + if (ret == null) { + throw new ModelConversionException( + "unable to find model mapping for Vo %s".formatted(valueType.getSimpleName())); + } + return ret; + } + + private String encryptVoFields(Vo valueObject, Class modelType) { + var encryptedFieldsMapper = getEncryptedFieldsMapper(modelType, valueObject.getClass()); + String dataToEncrypt; + try { + dataToEncrypt = encryptedFieldsMapper.writeValueAsString(valueObject); + } catch (JacksonException e) { + throw new ModelConversionException("failed to serialise encrypted Vo fields", e); + } + + // encrypt JWE + var encryptedField = getEncryptedField(modelType); + String keySetName = encryptedField.getField() + .getAnnotation(EncryptedFields.class) + .encryptionKeyName(); + return jwtService.encryptWithKeysetName(keySetName, dataToEncrypt); + } + + private JsonMapper getEncryptedFieldsMapper(Class modelType, Class valueObjectType) { + var ret = encryptedFieldsMapperCache.get(Map.entry(modelType, valueObjectType)); + if (ret == null) { + throw new ModelConversionException( + "unable to find encrypted fields mapper for Model %s".formatted( + modelType.getSimpleName())); + } + return ret; + } + + /** + * @return custom mapper that only reads fields meant to be encrypted from the Vo + */ + private JsonMapper getEncryptedFieldsMapperRaw(Class modelType, Class valueObjectType) { + var voFieldsToModelFields = mapVoPropsToModelProps(modelType, valueObjectType); + var voPropNamesToModelProps = voFieldsToModelFields.entrySet().stream() + .collect(toUnmodifiableMap(prop -> prop.getKey().getField().getName(), + Entry::getValue)); + + // build a list of Vo fields that have no mapping from the Vo to the Model + var voDescription = getBeanDescriptionForSerialization(valueObjectType); + var voFieldsForEncryption = voDescription.findProperties() + .stream() + .map(BeanPropertyDefinition::getName) + .filter(Predicate.not(voPropNamesToModelProps::containsKey)) + .collect(Collectors.toUnmodifiableSet()); + + // we will ignore the fields that have a mapping because we assume that all fields + // without a mapping should be included in the encrypted model field. + // this mapping also respects jackson annotations, such as @JsonIgnore + return jsonMapper.rebuild() + .annotationIntrospector(new OnlyMapEncryptedProperties(modelToVoClass.values(), + voFieldsForEncryption)) + .build(); + } + + private Map mapModelPropsToVoProps( + Class modelClass, Class valueObjectClass) { + var ret = mapModelPropsToVoPropsCache.get(Map.entry(modelClass, valueObjectClass)); + if (ret == null) { + throw new ModelConversionException( + "unable to find model props mapping for Model %s".formatted( + modelClass.getSimpleName())); + } + return ret; + } + + /** + * order matters, since it uses deserialisation config for Model serialisation config for Vo + */ + private Map mapModelPropsToVoPropsRaw( + Class modelClass, Class valueObjectClass) { + var encryptedField = getEncryptedField(modelClass); + var modelDescription = getBeanDescriptionForDeserialization(modelClass); + var valueObjectDescription = getBeanDescriptionForSerialization(valueObjectClass); + var valueObjectPropsByName = valueObjectDescription.findProperties() + .stream() + .collect(toUnmodifiableMap(BeanPropertyDefinition::getName, Function.identity())); + return modelDescription.findProperties() + .stream() + .filter(modelProp -> !modelProp.getName().equals(encryptedField.getName())) + .peek(modelProp -> { + if (!valueObjectPropsByName.containsKey(modelProp.getName())) { + throw new ModelConversionException(String.format( + "could not find field %s while mapping Model %s to Vo %s", + modelProp.getName(), modelClass.getSimpleName(), + valueObjectClass.getSimpleName())); + } + }) + .collect(toUnmodifiableMap(Function.identity(), modelProp -> + valueObjectPropsByName.get(modelProp.getName()))); + } + + /** + * order matters, since it uses deserialisation config for Vo serialisation config for Model + */ + private Map mapVoPropsToModelProps( + Class modelClass, Class valueObjectClass) { + var modelDescription = getBeanDescriptionForSerialization(modelClass); + var valueObjectDescription = getBeanDescriptionForDeserialization(valueObjectClass); + var modelPropsByName = modelDescription.findProperties() + .stream() + .collect(toUnmodifiableMap(BeanPropertyDefinition::getName, Function.identity())); + return valueObjectDescription.findProperties() + .stream() + .filter(voProp -> modelPropsByName.containsKey(voProp.getName())) + .collect(toUnmodifiableMap(Function.identity(), + voProp -> modelPropsByName.get(voProp.getName()))); + } + + private BeanPropertyDefinition getEncryptedField(Class modelClass) { + var ret = encryptedFieldCache.get(modelClass); + if (ret == null) { + throw new ModelConversionException( + "unable to find encrypted field for model %s".formatted( + modelClass.getSimpleName())); + } + return ret; + } + + private BeanPropertyDefinition getEncryptedFieldRaw(Class modelClass) { + var modelDescription = getBeanDescriptionForDeserialization(modelClass); + var encryptedFields = modelDescription.findProperties().stream() + .filter(prop -> prop.getField().hasAnnotation(EncryptedFields.class)) + .toList(); + if (encryptedFields.size() != 1) { + throw new IllegalArgumentException(modelClass.getSimpleName() + + " must have exactly one EncryptedFields annotated field"); + } + var encryptedField = encryptedFields.get(0); + if (!encryptedField.getPrimaryType().isTypeOrSubTypeOf(String.class)) { + throw new IllegalArgumentException(modelClass.getSimpleName() + + " EncryptedFields are only valid for a String type"); + } + return encryptedField; + } + + private BeanDescription getBeanDescriptionForDeserialization(Class clazz) { + JavaType modelType = jsonMapper.constructType(clazz); + var config = jsonMapper.deserializationConfig(); + var introspector = config.classIntrospectorInstance().forOperation(config); + var annotated = introspector.introspectClassAnnotations(modelType); + return introspector.introspectForDeserialization(modelType, annotated); + } + + private BeanDescription getBeanDescriptionForSerialization(Class clazz) { + JavaType modelType = jsonMapper.constructType(clazz); + var config = jsonMapper.serializationConfig(); + var introspector = config.classIntrospectorInstance().forOperation(config); + var annotated = introspector.introspectClassAnnotations(modelType); + return introspector.introspectForSerialization(modelType, annotated); + } + + @RequiredArgsConstructor + private static class OnlyMapEncryptedProperties extends JacksonAnnotationIntrospector { + + private final Set> voClasses; + private final Set voFieldsForEncryption; + + @Override + public boolean hasIgnoreMarker(MapperConfig config, AnnotatedMember member) { + if (member instanceof AnnotatedField field) { + // we only want to filter Vo fields if the field is actually from a Vo class + if (voClasses.contains(field.getDeclaringClass())) { + // if the Vo field is not marked for encryption, + // don't include it for encryption + if (!voFieldsForEncryption.contains(field.getName())) { + return true; + } + } + } + return super.hasIgnoreMarker(config, member); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/services/mapping/annotation/EncryptedFields.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/services/mapping/annotation/EncryptedFields.java new file mode 100644 index 0000000000..0ef77aee3b --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/services/mapping/annotation/EncryptedFields.java @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.jwt.services.mapping.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface EncryptedFields { + + Class valueObject(); + + String encryptionKeyName(); +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/services/mapping/annotation/ValueObject.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/services/mapping/annotation/ValueObject.java new file mode 100644 index 0000000000..05ffe6741c --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/java/com/nvidia/boot/jwt/services/mapping/annotation/ValueObject.java @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.jwt.services.mapping.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface ValueObject { + + Class model(); +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000000..dfae2940e4 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +com.nvidia.boot.jwt.JwtAutoConfiguration diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/NvBootJwtIntegrationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/NvBootJwtIntegrationTest.java new file mode 100644 index 0000000000..c81892259f --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/NvBootJwtIntegrationTest.java @@ -0,0 +1,145 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.jwt; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.nvidia.boot.jwt.configuration.EncryptedModelConverterProperties; +import com.nvidia.boot.jwt.configuration.JweKeysMapping; +import com.nvidia.boot.jwt.configuration.PrivateJwksString; +import com.nvidia.boot.jwt.services.JwtService; +import com.nvidia.boot.jwt.services.mapping.EncryptedModelConverter; +import com.nvidia.boot.jwt.services.mapping.model.ServiceModel; +import com.nvidia.boot.jwt.services.mapping.vo.ServiceVo; +import java.io.IOException; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.io.ClassPathResource; + +/** + * Integration test verifying nv-boot-starter-jwt auto-configuration and beans work end-to-end. + * Uses the same configuration pattern as a real application: app provides PrivateJwksString + * and JweKeysMapping, library auto-configures JwtService, EncryptedModelConverter, etc. + */ +@SpringBootTest( + classes = NvBootJwtIntegrationTest.App.class, + webEnvironment = WebEnvironment.NONE) +class NvBootJwtIntegrationTest { + + private static final String TEST_KEY = "test-A256GCM-key"; + + @SpringBootApplication + @Import(App.JwtKeysConfiguration.class) + static class App { + + @Configuration + static class JwtKeysConfiguration { + + private static final JweKeysMapping jweKeysMapping = JweKeysMapping + .builder() + .keysMapping(Map.of(TEST_KEY, "test-A256GCM-key")) + .build(); + + @Bean + public PrivateJwksString privateJwks() + throws IOException { + var keyResource = new ClassPathResource("keys/dev_jwks_private.json"); + return new PrivateJwksString( + new String(keyResource.getInputStream().readAllBytes())); + } + + @Bean + public JweKeysMapping jweKeysMapping() { + return jweKeysMapping; + } + + @Bean + public EncryptedModelConverterProperties encryptedModelConverterProperties() { + var props = new EncryptedModelConverterProperties(); + props.setBasePackage("com.nvidia.boot.jwt.services.mapping"); + return props; + } + } + } + + @Autowired + private JwtService jwtService; + + @Autowired + private EncryptedModelConverter serviceEncryptedModelConverter; + + @Test + void jwtServiceSignAndValidate() throws Exception { + var jti = UUID.randomUUID().toString(); + var claimsSet = new JWTClaimsSet.Builder() + .jwtID(jti) + .build(); + + var signedJWT = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.ES256) + .keyID("dev-key-id") + .build(), + claimsSet); + + jwtService.signJwt(signedJWT, "dev-key-id"); + var serializedJwt = signedJWT.serialize(); + + // getJwtClaimsSet validates signature internally and returns claims when valid + var parsedClaims = jwtService.getJwtClaimsSet(serializedJwt); + assertThat(parsedClaims.getClaim("jti")).isEqualTo(jti); + } + + @Test + void jwtServiceEncryptAndDecrypt() { + var payload = "secret message"; + var encrypted = jwtService.encryptWithKeysetName(TEST_KEY, payload); + var decrypted = jwtService.decrypt(encrypted); + assertThat(decrypted).isEqualTo(payload); + } + + @Test + void encryptedModelConverterRoundTrip() { + var vo = ServiceVo.builder() + .serviceId("service-id-1") + .sfClientIds(Set.of("client-1", "client-2")) + .maxApiKeysPerUser(2) + .maxApiKeyTtlDays(3) + .maxAuthzSizeChars(4) + .minAuthzUpdateIntervalSeconds(5) + .maxApiKeysPerAccount(6) + .build(); + + var model = serviceEncryptedModelConverter.voToModel(vo); + var roundTripped = serviceEncryptedModelConverter.modelToVo(model); + + assertThat(roundTripped).isEqualTo(vo); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/configuration/JwksConfigurationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/configuration/JwksConfigurationTest.java new file mode 100644 index 0000000000..2ed553bd8c --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/configuration/JwksConfigurationTest.java @@ -0,0 +1,117 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.jwt.configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.nimbusds.jose.util.IOUtils; +import com.nvidia.boot.jwt.services.mapping.EncryptedModelConverter; +import java.io.IOException; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.support.AbstractBeanDefinition; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.core.io.ClassPathResource; +import tools.jackson.databind.json.JsonMapper; + +class JwksConfigurationTest { + + private static final String TEST_JWKS_FILE = "keys/dev_jwks_private.json"; + + private final JwksConfiguration jwksConfiguration = new JwksConfiguration(); + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class)) + .withUserConfiguration(JwksConfiguration.class) + .withBean(PrivateJwksString.class, () -> { + try { + var keyResource = new ClassPathResource(TEST_JWKS_FILE); + return new PrivateJwksString( + IOUtils.readInputStreamToString(keyResource.getInputStream())); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .withBean(JweKeysMapping.class, () -> JweKeysMapping.builder() + .keysMapping(Map.of("test-A256GCM-key", "test-A256GCM-key")) + .build()); + + @Test + void jwkSet() + throws IOException { + var jwksString = IOUtils.readInputStreamToString( + new ClassPathResource(TEST_JWKS_FILE).getInputStream()); + var jwkSet = jwksConfiguration.jwkSet(new PrivateJwksString(jwksString)); + assertThat(jwkSet.getKeyByKeyId("dev-key-id")).isNotNull(); + assertThat(jwkSet.getKeyByKeyId("test-A256GCM-key")).isNotNull(); + } + + @Test + @DisplayName("EncryptedModelConverter is created when EncryptedModelConverterProperties bean is present") + void encryptedModelConverterCreatedWhenPropertiesPresent() { + runner.withBean(EncryptedModelConverterProperties.class, () -> { + var props = new EncryptedModelConverterProperties(); + props.setBasePackage("com.nvidia.boot"); + return props; + }).run(context -> assertThat(context).hasBean("encryptedModelConverter")); + } + + @Test + @DisplayName("EncryptedModelConverter is not created when EncryptedModelConverterProperties bean is absent") + void encryptedModelConverterNotCreatedWhenPropertiesAbsent() { + runner.run(context -> assertThat(context).doesNotHaveBean(EncryptedModelConverter.class)); + } + + @Test + @DisplayName("jwkSet bean is @RefreshScope so vault rotation rebuilds the keyset") + void jwkSetIsRefreshScoped() { + runner.run(context -> { + var beanFactory = context.getSourceApplicationContext().getBeanFactory(); + assertThat(beanFactory.getBeanDefinition("scopedTarget.jwkSet").getScope()) + .isEqualTo("refresh"); + }); + } + + @Test + @DisplayName("jwtService bean is @RefreshScope so vault rotation rebuilds the signer") + void jwtServiceIsRefreshScoped() { + runner.run(context -> { + var beanFactory = context.getSourceApplicationContext().getBeanFactory(); + assertThat(beanFactory.getBeanDefinition("scopedTarget.jwtService").getScope()) + .isEqualTo("refresh"); + }); + } + + @Test + @DisplayName("genericEncryptedJsonMapper is not a default autowire candidate — " + + "apps keep Spring Boot's primary JsonMapper for HTTP message conversion") + void genericEncryptedJsonMapperIsNotDefaultCandidate() { + runner.run(context -> { + var beanFactory = context.getSourceApplicationContext().getBeanFactory(); + assertThat(beanFactory.containsBean("genericEncryptedJsonMapper")).isTrue(); + var bd = (AbstractBeanDefinition) + beanFactory.getBeanDefinition("genericEncryptedJsonMapper"); + assertThat(bd.isDefaultCandidate()).isFalse(); + assertThat(context.getBean(JsonMapper.class)) + .isNotSameAs(context.getBean("genericEncryptedJsonMapper", JsonMapper.class)); + }); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/JwtServiceTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/JwtServiceTest.java new file mode 100644 index 0000000000..f81b0b16bd --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/JwtServiceTest.java @@ -0,0 +1,301 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.jwt.services; + +import static com.nvidia.boot.jwt.services.JwtService.ERROR_CANT_VALIDATE_EMPTY_TOKEN; +import static com.nvidia.boot.jwt.services.JwtService.ERROR_NO_JWK_SET_PROVIDED; +import static com.nvidia.boot.jwt.services.JwtService.ERROR_NO_KEY_REGISTERED_WITH_ID; +import static com.nvidia.boot.jwt.services.JwtService.ERROR_NO_SIGNER_DEFINED_FOR_KID; +import static com.nvidia.boot.jwt.services.JwtService.ERROR_TOKEN_DOESNT_HAVE_HEADER; +import static com.nvidia.boot.jwt.services.JwtService.ERROR_TOKEN_MISSING_KEY_ID_IN_HEADER; +import static com.nvidia.boot.jwt.services.JwtService.FAILED_TO_PARSE_TOKEN; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableMap; +import com.nimbusds.jose.EncryptionMethod; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.OctetSequenceKey; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.nvidia.boot.jwt.configuration.JweKeysMapping; +import com.nvidia.boot.jwt.exceptions.EncryptionException; +import com.nvidia.boot.jwt.exceptions.JwtException; +import com.nvidia.boot.jwt.exceptions.ValidationException; +import java.io.IOException; +import java.security.InvalidAlgorithmParameterException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.ECPublicKey; +import java.text.ParseException; +import java.util.UUID; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.ClassPathResource; + +@ExtendWith(MockitoExtension.class) +class JwtServiceTest { + + private static final String TEST_A_256_GCM_KEY = "test-A256GCM-key"; + + private JwtService jwtService; + + private JWKSet testKeySet; + + @Mock + private SignedJWT signedJWTMock; + + private JweKeysMapping jweKeysMapping = JweKeysMapping + .builder() + .keysMapping(ImmutableMap.of( + "test-A256GCM-key", "test-A256GCM-key" + )) + .build(); + + @BeforeEach + void init() + throws IOException, ParseException, JOSEException { + ClassPathResource keyResource = new ClassPathResource("keys/dev_jwks_private.json"); + testKeySet = JWKSet.load(keyResource.getInputStream()); + jwtService = new JwtService(testKeySet, jweKeysMapping); + } + + @Test + void shouldThrowIfKeySetEmpty() { + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> new JwtService(new JWKSet(), jweKeysMapping)); + + assertThat(exception.getMessage()).isEqualTo(ERROR_NO_JWK_SET_PROVIDED); + } + + @Test + void shouldEncryptAndDecrypt() { + String payload = "test message"; + String serializedJwe = jwtService + .encryptWithKeysetName(TEST_A_256_GCM_KEY, payload); + + // verify we can decrypt it + String decryptedPayload = jwtService.decrypt(serializedJwe); + assertEquals(payload, decryptedPayload); + } + + @Test + void shouldThrowIfWrongKey() { + String kid = "wrong-key"; + + EncryptionException exception = assertThrows( + EncryptionException.class, + () -> jwtService.encryptWithKeyId(kid, "payload")); + assertThat(exception.getBody().getDetail()).isEqualTo( + "No encrypter defined for kid:" + kid); + } + + @Test + void shouldThrowIfCantParse() { + EncryptionException exception = assertThrows( + EncryptionException.class, + () -> jwtService.decrypt("not-jwe-at-all")); + assertThat(exception.getBody().getDetail()).startsWith("Can't parse encrypted jwe string:"); + } + + @Test + void shouldThrowIfCantFindKey() { + // test message that was encrypted with the key + // { + // "kty": "oct", + // "kid": "im-key-that-has-been-deleted", + // "k": "2AIyuwTgBgF_yUuopz2Y8KP7hrM5wf8veHGCqR8iI3U", + // "alg": "A256GCM" + // } + String payload = "eyJraWQiOiJpbS1rZXktdGhhdC1oYXMtYmVlbi1kZWxldGVkIiwiZW5jIjoiQTI1NkdDT" + + "SIsInRhZyI6InJaSzJrLVR3TXBla2tnLXE3eUxsSXciLCJhbGciOiJBMjU2R0NNS1ciLCJpdiI6I" + + "ncwWXd0Wi01N2d2eFZkWUEifQ.dXolUep0AjT91J2HnRkAUaWZHpwdpUCPMXwYvvvqP6E.PLFFG_" + + "q8ygCcMk2A.nzI_SEHmayPI7Yvq.cJ6TgGNCmvc-tmH6fzCqMA"; + EncryptionException exception = assertThrows( + EncryptionException.class, + () -> jwtService.decrypt(payload)); + assertThat(exception.getBody().getDetail()).isEqualTo( + "Can't decrypt JWT, no decrypter defined for key: im-key-that-has-been-deleted"); + } + + @Test + void shouldThrowIfNoHeader() { + // specially fabricated payload without kid + String payloadNoKid = "eyJlbmMiOiJBMjU2R0NNIiwidGFnIjoib3BfbHk4eUtWUHRBTzBjOXhtVi1ndyIs" + + "ImFsZyI6IkEyNTZHQ01LVyIsIml2IjoiV2dyRV9EVi02WmtFQno3ViJ9.M9KA2k7epZwUTlwJeO5pfB" + + "F3tN4ei9slulWJ14NH0Uw.OUFn-3f0wcdfj3Nl.SuWueOtxRoHe0K50.E08Jm9r7sEMDe0cpqwb-fA"; + EncryptionException exception = assertThrows( + EncryptionException.class, + () -> jwtService.decrypt(payloadNoKid)); + assertThat(exception.getBody().getDetail()).isEqualTo("JWT is missing kid in header"); + } + + @Test + void shouldThrowIfEncryptedMessageWasTemperedWith() { + String payload = "eyJraWQiOiJ0ZXN0LUEyNTZHQ00ta2V5IiwiZW5jIjoiQTI1NkdDTSIsInRhZyI6IlhlbnA4" + + "dGtZVzBORW1ZemRlY3JSSVEiLCJhbGciOiJBMjU2R0NNS1ciLCJpdiI6IlV3blhOdkNyQ1BXdlhRUlA" + + "ifQ.9A2u4RYiH6spVwsG3hUA5LgotchamofohqFSspwXbgbNK9Qfly8U8.TyDIcwS-Ri1-db8c.uTqa" + + "nt7gTetyCt5R.vjLzlRpP_zu4PTFx1T4HDg"; + EncryptionException exception = assertThrows( + EncryptionException.class, + () -> jwtService.decrypt(payload)); + assertThat(exception.getBody().getDetail()).startsWith( + "Failed to decrypt JWT, error was: "); + } + + @Test + void shouldThrowWhenValidatingNull() { + ValidationException exception = assertThrows( + ValidationException.class, + () -> jwtService.validateSignature(null)); + assertThat(exception.getBody().getDetail()).isEqualTo(ERROR_CANT_VALIDATE_EMPTY_TOKEN); + } + + @Test + void shouldThrowWhenHeaderNull() { + when(signedJWTMock.getHeader()).thenReturn(null); + ValidationException exception = assertThrows( + ValidationException.class, + () -> jwtService.validateSignature(signedJWTMock)); + assertThat(exception.getBody().getDetail()).isEqualTo(ERROR_TOKEN_DOESNT_HAVE_HEADER); + } + + @Test + void shouldThrowIfKidUnset() { + JWSHeader jwsHeader = new JWSHeader.Builder(JWSAlgorithm.ES512).build(); + when(signedJWTMock.getHeader()).thenReturn(jwsHeader); + ValidationException exception = assertThrows( + ValidationException.class, + () -> jwtService.validateSignature(signedJWTMock)); + assertThat(exception.getBody().getDetail()).isEqualTo(ERROR_TOKEN_MISSING_KEY_ID_IN_HEADER); + } + + @Test + void shouldThrowIfNoJwsVerifierRegistered() { + JWSHeader jwsHeader = new JWSHeader.Builder(JWSAlgorithm.ES512) + .keyID("non-existing-key").build(); + + when(signedJWTMock.getHeader()).thenReturn(jwsHeader); + + ValidationException exception = assertThrows( + ValidationException.class, + () -> jwtService.validateSignature(signedJWTMock)); + assertThat(exception.getBody().getDetail()).startsWith(ERROR_NO_KEY_REGISTERED_WITH_ID); + } + + // Use this piece to generate new keys + @Test + void aesKeygen() + throws NoSuchAlgorithmException { + // Generate a secret AES key with 256 bits + KeyGenerator gen = KeyGenerator.getInstance("AES"); + gen.init(256); + SecretKey aesKey = gen.generateKey(); + + // Convert to JWK format + JWK jwk = new OctetSequenceKey.Builder(aesKey) + .keyID(UUID.randomUUID().toString()) + .algorithm(EncryptionMethod.A256GCM) + .build(); + + assertNotNull(jwk); + System.out.println(jwk.toJSONString()); + } + + @Test + void ecKeygen() + throws NoSuchAlgorithmException, InvalidAlgorithmParameterException { + // Generate EC key pair with P-256 curve + KeyPairGenerator gen = KeyPairGenerator.getInstance("EC"); + gen.initialize(Curve.P_256.toECParameterSpec()); + KeyPair keyPair = gen.generateKeyPair(); + + // Convert to JWK format + JWK jwk = new ECKey.Builder(Curve.P_256, (ECPublicKey) keyPair.getPublic()) + .privateKey((ECPrivateKey) keyPair.getPrivate()) + .build(); + + assertNotNull(jwk); + System.out.println(jwk.toJSONString()); + } + + @Test + void signJwt() + throws ParseException { + JWTClaimsSet.Builder claimsSetBuilder = new JWTClaimsSet.Builder(); + String jti = UUID.randomUUID().toString(); + claimsSetBuilder.jwtID(jti); + + JWSAlgorithm jwsAlgorithm = JWSAlgorithm.parse("ES256"); + String tokenKid = "dev-key-id"; + + // prepare token for signing + SignedJWT signedJWT = new SignedJWT( + new JWSHeader.Builder(jwsAlgorithm) + .keyID(tokenKid) + .build(), + claimsSetBuilder.build()); + + jwtService.signJwt(signedJWT, tokenKid); + + // Serialize the JWS to compact form + String serializedJwt = signedJWT.serialize(); + + SignedJWT parsedJwt = jwtService.getSignedJWT(serializedJwt); + assertThat(jwtService.validateSignature(parsedJwt)).isTrue(); + + JWTClaimsSet jwtClaimsSet = jwtService.getJwtClaimsSet(serializedJwt); + assertThat(jwtClaimsSet.getClaim("jti")).isEqualTo(jti); + } + + @Test + void shouldThrowIfSigningKeyDoesntExist() { + String keyId = "nope"; + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> jwtService.signJwt(null, keyId)); + + assertThat(exception.getMessage()).isEqualTo(ERROR_NO_SIGNER_DEFINED_FOR_KID + keyId); + } + + @Test + void shouldThrowOnBadTokens() { + JwtException exception = assertThrows( + JwtException.class, + () -> jwtService.getSignedJWT("sick")); + assertThat(exception.getBody().getDetail()).isEqualTo(FAILED_TO_PARSE_TOKEN); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/EncryptedModelConverterTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/EncryptedModelConverterTest.java new file mode 100644 index 0000000000..95c7ab6516 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/EncryptedModelConverterTest.java @@ -0,0 +1,397 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.jwt.services.mapping; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.jwt.configuration.EncryptedModelConverterProperties; +import com.nvidia.boot.jwt.configuration.JweKeysMapping; +import com.nvidia.boot.jwt.configuration.PrivateJwksString; +import com.nvidia.boot.jwt.services.JwtService; +import com.nvidia.boot.jwt.services.mapping.EncryptedModelConverterTest.SubObjectFieldVo.SubObject; +import com.nvidia.boot.jwt.services.mapping.EncryptedModelConverterTest.SubObjectFieldVo.SubObject.EvenSubberObject; +import com.nvidia.boot.jwt.services.mapping.annotation.EncryptedFields; +import com.nvidia.boot.jwt.services.mapping.annotation.ValueObject; +import com.nvidia.boot.jwt.services.mapping.model.KeyByOwnerAndServiceModel; +import com.nvidia.boot.jwt.services.mapping.model.KeyModel; +import com.nvidia.boot.jwt.services.mapping.model.ServiceModel; +import com.nvidia.boot.jwt.services.mapping.vo.KeyByOwnerAndServiceVo; +import com.nvidia.boot.jwt.services.mapping.vo.KeyOwnerStatus; +import com.nvidia.boot.jwt.services.mapping.vo.KeyOwnerType; +import com.nvidia.boot.jwt.services.mapping.vo.KeyStatus; +import com.nvidia.boot.jwt.services.mapping.vo.KeyVo; +import com.nvidia.boot.jwt.services.mapping.vo.ServiceVo; +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Set; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.io.ClassPathResource; + +@Slf4j +@SpringBootTest( + classes = EncryptedModelConverterTest.App.class, + webEnvironment = WebEnvironment.NONE) +public class EncryptedModelConverterTest { + + public static final String TEST_KEY = "test-A256GCM-key"; + + @SpringBootApplication + @Import(App.JwtKeysConfiguration.class) + static class App { + + @Configuration + static class JwtKeysConfiguration { + + private static final JweKeysMapping jweKeysMapping = JweKeysMapping + .builder() + .keysMapping(Map.of(TEST_KEY, "test-A256GCM-key")) + .build(); + + @Bean + public PrivateJwksString privateJwks() + throws IOException { + ClassPathResource keyResource = new ClassPathResource("keys/dev_jwks_private.json"); + return new PrivateJwksString( + new String(keyResource.getInputStream().readAllBytes())); + } + + @Bean + public JweKeysMapping jweKeysMapping() { + return jweKeysMapping; + } + + @Bean + public EncryptedModelConverterProperties encryptedModelConverterProperties() { + var props = new EncryptedModelConverterProperties(); + props.setBasePackage("com.nvidia.boot"); + return props; + } + } + } + + @Autowired + private JwtService jwtService; + + @Autowired + private EncryptedModelConverter encryptedModelConverter; + + @Autowired + private EncryptedModelConverter serviceEncryptedModelConverter; + + @Autowired + private EncryptedModelConverter keyEncryptedModelConverter; + + private static final JsonMapper JSON_MAPPER = JsonMapper.builder().build(); + + @Test + void singleConverterWithGenerics() { + // must be the exact same object + assert encryptedModelConverter == (Object) keyEncryptedModelConverter; + assert encryptedModelConverter == (Object) serviceEncryptedModelConverter; + } + + @Test + void serviceDao() { + var vo = ServiceVo.builder() + .serviceId("service id") + .sfClientIds(Set.of("client id 1", "client id 2")) + .maxApiKeysPerUser(2) + .maxApiKeyTtlDays(3) + .maxAuthzSizeChars(4) + .minAuthzUpdateIntervalSeconds(5) + .maxApiKeysPerAccount(6) + .build(); + var model = serviceEncryptedModelConverter.voToModel(vo); + var voTranslated = serviceEncryptedModelConverter.modelToVo(model); + assertThat(vo).isEqualTo(voTranslated); + } + + @Test + @Disabled + void perf() { + Instant start = Instant.now(); + for (int i = 0; i < 100000; i++) { + var vo = ServiceVo.builder() + .serviceId("service id") + .sfClientIds(Set.of("client id 1", "client id 2")) + .maxApiKeysPerUser(2) + .maxApiKeyTtlDays(3) + .maxAuthzSizeChars(4) + .minAuthzUpdateIntervalSeconds(5) + .maxApiKeysPerAccount(6) + .build(); + var model = serviceEncryptedModelConverter.voToModel(vo); + var voTranslated = serviceEncryptedModelConverter.modelToVo(model); + assert voTranslated != null; + } + log.info("took {}", Duration.between(start, Instant.now())); + } + + @Test + void keyModel() { + var vo = KeyVo.builder() + .serviceId("service id") + .status(KeyStatus.ACTIVE) + .ownerType(KeyOwnerType.USER) + .ownerId("owner id") + .issuerServiceId("issuer service id") + .audienceServiceId("audience service id") + .keyId("key id") + .keyHash("a hash") + .expiresAt(Instant.now()) + .deletesAt(Instant.now().plusSeconds(1234)) + .apiKeySuffix("a suffix") + .authorization("some auth") + .description("a description") + .build(); + var model = keyEncryptedModelConverter.voToModel(vo); + var voTranslated = keyEncryptedModelConverter.modelToVo(model); + assertThat(vo).isEqualTo(voTranslated); + } + + @Test + void keyByOwnerAndService() { + var vo = KeyByOwnerAndServiceVo.builder() + .ownerType(KeyOwnerType.USER) + .ownerID("owner id") + .issuerServiceID("issuer service id") + .keyId("key id") + .ownerStatus(KeyOwnerStatus.ACTIVE) + .updatedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(12345)) + .deletesAt(Instant.now().plusSeconds(123456)) + .keyStatus(KeyStatus.ACTIVE) + .apiKeyHash("brown") + .apiKeySuffix("a suffix") + .audienceServiceId("audience service id") + .description("a description") + .build(); + var model = (KeyByOwnerAndServiceModel) encryptedModelConverter.voToModel(vo); + var voTranslated = (KeyByOwnerAndServiceVo) encryptedModelConverter.modelToVo(model); + assertThat(vo).isEqualTo(voTranslated); + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class StandardModel { + + String field1; + + String field2; + @EncryptedFields(encryptionKeyName = TEST_KEY, valueObject = VoStandard.class) + String encryptedField; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @ValueObject(model = StandardModel.class) + public static class VoStandard { + + String field1; + String field2; + @JsonIgnore + String ignoreMe; + String encryptedField1; + String encryptedField2; + } + + @Test + void ignoreVoField() { + var vo = VoStandard.builder() + .field1("field1 value") + .field2("field2 value") + .encryptedField1("encrypted field value") + .ignoreMe("shouldn't make it into the model") + .build(); + var model = (StandardModel) encryptedModelConverter.voToModel(vo); + var voTranslated = (VoStandard) encryptedModelConverter.modelToVo(model); + var expectedVo = VoStandard.builder() + .field1("field1 value") + .field2("field2 value") + .encryptedField1("encrypted field value") + .build(); + assertThat(expectedVo).isEqualTo(voTranslated); + } + + @Test + void addEncryptedFieldToVo() + throws JacksonException { + // model doesn't contain encryptedField2 + var oldVoEncryptedFields = Map.of("encryptedField1", "encrypted field value"); + var model = StandardModel.builder() + .field1("field1 value") + .field2("field2 value") + .encryptedField(encryptPayload(oldVoEncryptedFields)) + .build(); + var voTranslated = (VoStandard) encryptedModelConverter.modelToVo(model); + var expectedVo = VoStandard.builder() + .field1("field1 value") + .field2("field2 value") + .encryptedField1("encrypted field value") + .build(); + assertThat(expectedVo).isEqualTo(voTranslated); + } + + @Test + void noFieldsToStart() + throws JacksonException { + // model will have no values in the encrypted field, so Vo encrypted fields will be empty + Map oldVoEncryptedFields = Map.of(); + var model = StandardModel.builder() + .field1("field1 value") + .field2("field2 value") + .encryptedField(encryptPayload(oldVoEncryptedFields)) + .build(); + var voTranslated = (VoStandard) encryptedModelConverter.modelToVo(model); + var expectedVo = VoStandard.builder() + .field1("field1 value") + .field2("field2 value") + .build(); + assertThat(expectedVo).isEqualTo(voTranslated); + } + + @Test + void removeEncryptedFieldFromVo() + throws JacksonException { + // model has an extra field not in the Vo anymore, encryptedField3 + var oldVoEncryptedFields = Map.of("encryptedField1", "encrypted field value", + "encryptedField3", "encrypted field value 3"); + var model = StandardModel.builder() + .field1("field1 value") + .field2("field2 value") + .encryptedField(encryptPayload(oldVoEncryptedFields)) + .build(); + var voTranslated = (VoStandard) encryptedModelConverter.modelToVo(model); + var expectedVo = VoStandard.builder() + .field1("field1 value") + .field2("field2 value") + .encryptedField1("encrypted field value") + .build(); + assertThat(expectedVo).isEqualTo(voTranslated); + } + + private String encryptPayload(Map voFields) + throws JacksonException { + return jwtService.encryptWithKeysetName(TEST_KEY, + JSON_MAPPER.writeValueAsString(voFields)); + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class SubObjectFieldModel { + + String field1; + @EncryptedFields(encryptionKeyName = TEST_KEY, valueObject = SubObjectFieldVo.class) + String encryptedField; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @ValueObject(model = SubObjectFieldModel.class) + public static class SubObjectFieldVo { + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class SubObject { + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class EvenSubberObject { + + String encryptedField3; + String encryptedField4; + } + + String encryptedField1; + String encryptedField2; + EvenSubberObject encryptedObject; + } + + String field1; + SubObject subObject1; + @JsonIgnore + SubObject subObject2; + } + + @Test + void encryptedSubObject() { + var vo = SubObjectFieldVo.builder() + .field1("field 1") + .subObject1(SubObject.builder() + .encryptedField1("encrypted field 1") + .encryptedField2("encrypted field 2") + .encryptedObject(EvenSubberObject.builder() + .encryptedField3("encrypted field 3") + .encryptedField4("encrypted field 4") + .build()) + .build()) + .subObject2(SubObject.builder() + .encryptedField1("encrypted field 1") + .encryptedField2("encrypted field 2") + .encryptedObject(EvenSubberObject.builder() + .encryptedField3("encrypted field 3") + .encryptedField4("encrypted field 4") + .build()) + .build()) + .build(); + var model = (SubObjectFieldModel) encryptedModelConverter.voToModel(vo); + var voTranslated = (SubObjectFieldVo) encryptedModelConverter.modelToVo(model); + var expectedVo = SubObjectFieldVo.builder() + .field1("field 1") + .subObject1(SubObject.builder() + .encryptedField1("encrypted field 1") + .encryptedField2("encrypted field 2") + .encryptedObject(EvenSubberObject.builder() + .encryptedField3("encrypted field 3") + .encryptedField4("encrypted field 4") + .build()) + .build()) + .build(); + assertThat(expectedVo).isEqualTo(voTranslated); + } +} \ No newline at end of file diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/model/KeyByOwnerAndServiceModel.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/model/KeyByOwnerAndServiceModel.java new file mode 100644 index 0000000000..14363de690 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/model/KeyByOwnerAndServiceModel.java @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.jwt.services.mapping.model; + +import com.nvidia.boot.jwt.services.mapping.EncryptedModelConverterTest; +import com.nvidia.boot.jwt.services.mapping.annotation.EncryptedFields; +import com.nvidia.boot.jwt.services.mapping.vo.KeyByOwnerAndServiceVo; +import com.nvidia.boot.jwt.services.mapping.vo.KeyOwnerStatus; +import com.nvidia.boot.jwt.services.mapping.vo.KeyOwnerType; +import com.nvidia.boot.jwt.services.mapping.vo.KeyStatus; +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +@Builder(toBuilder = true) +@Data +@NoArgsConstructor +@AllArgsConstructor +public class KeyByOwnerAndServiceModel { + + @NonNull + private KeyOwnerType ownerType; + + @NonNull + private String ownerID; + + @NonNull + private String issuerServiceID; + + @NonNull + private String keyId; + + private KeyOwnerStatus ownerStatus; + + private Instant updatedAt; + + private Instant expiresAt; + + private Instant deletesAt; + + private KeyStatus keyStatus; + + @EncryptedFields(encryptionKeyName = EncryptedModelConverterTest.TEST_KEY, valueObject = KeyByOwnerAndServiceVo.class) + private String keyDetails; +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/model/KeyModel.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/model/KeyModel.java new file mode 100644 index 0000000000..203c78da59 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/model/KeyModel.java @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.jwt.services.mapping.model; + +import com.nvidia.boot.jwt.services.mapping.EncryptedModelConverterTest; +import com.nvidia.boot.jwt.services.mapping.annotation.EncryptedFields; +import com.nvidia.boot.jwt.services.mapping.vo.KeyStatus; +import com.nvidia.boot.jwt.services.mapping.vo.KeyVo; +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +@Builder(toBuilder = true) +@Data +@NoArgsConstructor +@AllArgsConstructor +public class KeyModel { + + @NonNull + private String keyHash; + + @NonNull + private KeyStatus status; + + private Instant expiresAt; + + private Instant deletesAt; + + @EncryptedFields(encryptionKeyName = EncryptedModelConverterTest.TEST_KEY, valueObject = KeyVo.class) + private String keyDetails; +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/model/ServiceModel.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/model/ServiceModel.java new file mode 100644 index 0000000000..dbbf4a2b93 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/model/ServiceModel.java @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.jwt.services.mapping.model; + +import com.nvidia.boot.jwt.services.mapping.EncryptedModelConverterTest; +import com.nvidia.boot.jwt.services.mapping.annotation.EncryptedFields; +import com.nvidia.boot.jwt.services.mapping.vo.ServiceVo; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +@Builder(toBuilder = true) +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ServiceModel { + + @NonNull + private String serviceId; + + @EncryptedFields(encryptionKeyName = EncryptedModelConverterTest.TEST_KEY, valueObject = ServiceVo.class) + private String details; +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/vo/KeyByOwnerAndServiceVo.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/vo/KeyByOwnerAndServiceVo.java new file mode 100644 index 0000000000..fc4ade1e0c --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/vo/KeyByOwnerAndServiceVo.java @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.jwt.services.mapping.vo; + +import com.nvidia.boot.jwt.services.mapping.annotation.ValueObject; +import com.nvidia.boot.jwt.services.mapping.model.KeyByOwnerAndServiceModel; +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Builder +@Data +@NoArgsConstructor +@AllArgsConstructor +@ValueObject(model = KeyByOwnerAndServiceModel.class) +public class KeyByOwnerAndServiceVo { + + private KeyOwnerType ownerType; + private String ownerID; + private String issuerServiceID; + private String keyId; + private KeyOwnerStatus ownerStatus; + private Instant updatedAt; + private Instant expiresAt; + private Instant deletesAt; + private KeyStatus keyStatus; + private String apiKeyHash; + private String apiKeySuffix; + private String audienceServiceId; + private String description; + +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/vo/KeyOwnerStatus.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/vo/KeyOwnerStatus.java new file mode 100644 index 0000000000..06fd87735b --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/vo/KeyOwnerStatus.java @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.jwt.services.mapping.vo; + +public enum KeyOwnerStatus { + ACTIVE +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/vo/KeyOwnerType.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/vo/KeyOwnerType.java new file mode 100644 index 0000000000..a373d27fec --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/vo/KeyOwnerType.java @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.jwt.services.mapping.vo; + +public enum KeyOwnerType { + USER, + CLOUD_ACCOUNT +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/vo/KeyStatus.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/vo/KeyStatus.java new file mode 100644 index 0000000000..9b746fb112 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/vo/KeyStatus.java @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.jwt.services.mapping.vo; + +public enum KeyStatus { + ACTIVE +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/vo/KeyVo.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/vo/KeyVo.java new file mode 100644 index 0000000000..868d736c61 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/vo/KeyVo.java @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.jwt.services.mapping.vo; + +import com.nvidia.boot.jwt.services.mapping.annotation.ValueObject; +import com.nvidia.boot.jwt.services.mapping.model.KeyModel; +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Builder +@Data +@NoArgsConstructor +@AllArgsConstructor +@ValueObject(model = KeyModel.class) +public class KeyVo { + + private String serviceId; + private KeyStatus status; + private KeyOwnerType ownerType; + private String ownerId; + private String issuerServiceId; + private String audienceServiceId; + private String keyId; + private String keyHash; + private Instant expiresAt; + private Instant deletesAt; + private String apiKeySuffix; + private String authorization; + private String description; +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/vo/ServiceVo.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/vo/ServiceVo.java new file mode 100644 index 0000000000..d00334c9a8 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/java/com/nvidia/boot/jwt/services/mapping/vo/ServiceVo.java @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.jwt.services.mapping.vo; + +import com.nvidia.boot.jwt.services.mapping.annotation.ValueObject; +import com.nvidia.boot.jwt.services.mapping.model.ServiceModel; +import java.util.Set; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@ValueObject(model = ServiceModel.class) +public class ServiceVo { + + private String serviceId; + private Set sfClientIds; + private int maxApiKeysPerUser; + private int maxApiKeysPerAccount; + private int maxApiKeyTtlDays; + private int maxAuthzSizeChars; + private int minAuthzUpdateIntervalSeconds; +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/resources/keys/dev_jwks_private.json b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/resources/keys/dev_jwks_private.json new file mode 100644 index 0000000000..be5b1d4106 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-jwt/src/test/resources/keys/dev_jwks_private.json @@ -0,0 +1,18 @@ +{ + "keys": [ + { + "kty": "EC", + "d": "rLsyH4_OoPNc5sV564IasD8-BSbVjsdt1bnYgp0-drg", + "crv": "P-256", + "kid": "dev-key-id", + "x": "ZgL92Pz3_JXtUvAd5OO9hMwtf1xcXBpY0aoBENwGriw", + "y": "LlmITADCxtBC_PabPR7mPPzzK7fB6yfd6_XvIgtwQ5M" + }, + { + "kty": "oct", + "kid": "test-A256GCM-key", + "k": "4UmpACy38IBlbxiCGuspeA9pqp4UaCwtWhN2eCgkOQE", + "alg": "A256GCM" + } + ] +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/BUILD.bazel b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/BUILD.bazel new file mode 100644 index 0000000000..cc4df67886 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/BUILD.bazel @@ -0,0 +1,102 @@ +load("@rules_java//java:defs.bzl", "java_library") +load("//src/libraries/java/nv-boot-parent/tools/bazel:java.bzl", "nv_boot_library", "nv_boot_library_test", "nv_boot_runtime_classpath_test") + +OBSERVABILITY_REQUIRED_DEPS = [ + "@nv_third_party_deps//:ch_qos_logback_logback_classic", + "@nv_third_party_deps//:ch_qos_logback_logback_core", + "@nv_third_party_deps//:io_micrometer_micrometer_commons", + "@nv_third_party_deps//:io_micrometer_micrometer_core", + "@nv_third_party_deps//:io_micrometer_micrometer_observation", + "@nv_third_party_deps//:io_micrometer_micrometer_tracing", + "@nv_third_party_deps//:io_micrometer_micrometer_tracing_bridge_otel", + "@nv_third_party_deps//:io_opentelemetry_opentelemetry_api", + "@nv_third_party_deps//:io_opentelemetry_opentelemetry_sdk_common", + "@nv_third_party_deps//:io_opentelemetry_opentelemetry_sdk_trace", + "@nv_third_party_deps//:io_opentelemetry_semconv_opentelemetry_semconv", + "@nv_third_party_deps//:org_apache_commons_commons_lang3", + "@nv_third_party_deps//:org_slf4j_slf4j_api", + "@nv_third_party_deps//:org_springframework_boot_spring_boot", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_actuator", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_actuator_autoconfigure", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_autoconfigure", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_health", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_micrometer_metrics", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_micrometer_observation", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_micrometer_tracing", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_micrometer_tracing_opentelemetry", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_opentelemetry", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_actuator", + "@nv_third_party_deps//:org_springframework_spring_beans", + "@nv_third_party_deps//:org_springframework_spring_context", + "@nv_third_party_deps//:org_springframework_spring_core", +] + +# Cassandra, Spring Cloud, servlet, MVC, and WebFlux support is optional in the +# Maven POM. Keep those APIs available to compile/test conditional integration +# code without adding those frameworks to every downstream runtime classpath. +OBSERVABILITY_OPTIONAL_COMPILE_DEPS = [ + "@nv_third_party_deps//:jakarta_servlet_jakarta_servlet_api", + "@nv_third_party_deps//:org_apache_cassandra_java_driver_core", + "@nv_third_party_deps//:org_apache_cassandra_java_driver_guava_shaded", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_cassandra", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_data_cassandra", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_data_cassandra", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_web_server", + "@nv_third_party_deps//:org_springframework_cloud_spring_cloud_commons", + "@nv_third_party_deps//:org_springframework_data_spring_data_cassandra", + "@nv_third_party_deps//:org_springframework_spring_web", + "@nv_third_party_deps//:org_springframework_spring_webflux", + "@nv_third_party_deps//:org_springframework_spring_webmvc", +] + +java_library( + name = "optional_compile_deps", + exports = OBSERVABILITY_OPTIONAL_COMPILE_DEPS, + neverlink = True, + visibility = ["//visibility:private"], +) + +nv_boot_library( + name = "nv_boot_starter_observability", + srcs = glob(["src/main/java/**/*.java"]), + resource_strip_prefix = "src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/resources", + resources = glob(["src/main/resources/**"]), + visibility = ["//visibility:public"], + deps = OBSERVABILITY_REQUIRED_DEPS + [":optional_compile_deps"], +) + +nv_boot_runtime_classpath_test( + name = "runtime_scope_test", + target = ":nv_boot_starter_observability", + forbidden_artifacts = [ + "cassandra", + "java-driver-core", + "java-driver-guava-shaded", + "jakarta.servlet-api", + "spring-cloud-commons", + "spring-web-", + "spring-webflux-", + "spring-webmvc-", + ], +) + +nv_boot_library_test( + name = "tests", + srcs = glob(["src/test/java/**/*.java"]), + coverage_library = ":nv_boot_starter_observability", + resource_strip_prefix = "src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/resources", + resources = glob(["src/test/resources/**"]), + deps = [ + ":nv_boot_starter_observability", + "@nv_third_party_deps//:io_opentelemetry_opentelemetry_sdk_testing", + "@nv_third_party_deps//:org_awaitility_awaitility", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_restclient", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_resttestclient", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_actuator_test", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_webflux_test", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_webmvc_test", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_webmvc_test", + ] + OBSERVABILITY_REQUIRED_DEPS + OBSERVABILITY_OPTIONAL_COMPILE_DEPS, + size = "medium", + timeout = "moderate", +) diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/README.md b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/README.md new file mode 100644 index 0000000000..75aeefb732 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/README.md @@ -0,0 +1,282 @@ +# NV Boot Starter Observability + +Tracing, logging, and metrics configuration for NV Boot applications: OpenTelemetry semantic +conventions, management context tracing, actuator endpoint tracing, filtered log stack traces, +and common metrics tags. + +## Adding as a Dependency + +Add to your application's `pom.xml`: + +```xml + + + + com.nvidia.boot + nv-boot-bom + ${nv-boot.version} + pom + import + + + + + + + com.nvidia.boot + nv-boot-starter-observability + + +``` + +> **Note:** Replace `${nv-boot.version}` with the desired nv-boot version, or use a property from your parent/BOM. + +Depending on the type of web application used, add a dependency on correct spring web starter. + +```yaml +spring: + main: + web-application-type: [reactive|servlet] +``` + +MVC/Servlet: +```xml + + + org.springframework.boot + spring-boot-starter-web + + +``` + +Webflux/Reactive: +```xml + + + org.springframework.boot + spring-boot-starter-webflux + + +``` + +## Features + +- **Common metrics tags** – Adds env, host_id, host_dc to all metrics via `MeterRegistryCustomizer` +- **Filtered log stack traces** – Logback configuration that filters noisy stack trace elements + (Spring, Reactor, Netty, reflection, etc.) via `StackFilteringThrowableConverter`. Loaded by + `LogbackEnvironmentPostProcessor` at startup. +- **OTel semantic conventions** – HTTP spans use OpenTelemetry attribute + names (`http.request.method`, `url.path`, `http.route`, etc.) for consistency with the OTel Java agent +- **Resource attributes** – Adds host, process, and runtime attributes (PID, arch, OS, Java version) to spans +- **Attribute redaction** – Redacts sensitive column values (e.g. passwords, secrets) from Cassandra query spans + before export when configured; supports YAML `sensitive-columns` / `scan-package` and + `@DoNotTraceValue` annotation scanning (scanning requires Spring Data Cassandra on the classpath) +- **Exception shortening** – Shortens exception stack traces in span events to keep only tracked + packages (e.g. `com.nvidia`) plus one line of context +- **Management context tracing** – When actuator runs on a separate + port (e.g. `management.server.port=8181`), HTTP requests to actuator endpoints are + traced via `ServerHttpObservationFilter` (MVC only) registered in the management context +- **Cassandra observability** – When Spring Data Cassandra’s observability types are on the + classpath, registers a `CqlSessionBuilderCustomizer` named + `cassandraObservationRequestTrackerCustomizer` that adds `ObservationRequestTracker` to the + shared `CqlSessionBuilder`, so CQL queries produce spans with `db.*` attributes (e.g. + `db.cassandra.consistency_level`, `db.cassandra.coordinator.dc`, `db.name`, `db.operation`, + `db.statement`, `db.system`). This bean is **not** registered if a bean with the same name + already exists (for example `nv-boot-starter-cassandra` is ordered earlier and provides it when + `CassandraSslBundleConfiguration` is active). + - When no bean named `cassandraSession` exists, installs a `@Primary` `observableCqlSession` + that wraps Boot’s default `CqlSession` with `ObservableCqlSessionFactory`; apps can opt out by + exposing a `CqlSessionObservabilityProperties` bean with `enabled=false`. See + [Cassandra observation opt-out](#cassandra-observation-opt-out) below. If `cassandraSession` + is present (including `nv-boot-starter-cassandra`’s `RefreshingCqlSession`), this wrap is + skipped; wrapping for that path is owned by the Cassandra starter. + +## Environment Post Processors + +- `LogbackEnvironmentPostProcessor` – Loads logback configuration properties + (`logging.config`, `logging.exception-conversion-word`) for filtered stack traces. + +## Auto Configured Beans + +This starter adds the following beans to the Spring context: + +- `MeterRegistryCustomizer` – Adds common tags (env, host_id, host_dc) to all metrics +- `ServerRequestObservationConvention` – Custom convention that emits OTel semantic convention attributes for HTTP spans +- `SdkTracerProviderBuilderCustomizer` – Adds process/host/runtime resource attributes to the tracer +- `AttributeRedactingSpanExporter` (via BeanPostProcessor) – Wraps SpanExporter to redact sensitive attributes in Cassandra spans when `sensitive-columns` or `scan-package` discovery yields column names +- `ExceptionShorteningSpanExporter` (via BeanPostProcessor) – Wraps SpanExporter to shorten exception stack traces before export +- `ServerHttpObservationFilter` – (MVC only) registered in the management context so actuator endpoints on a separate port produce traces +- `CqlSessionBuilderCustomizer` (`cassandraObservationRequestTrackerCustomizer`) – Registers + `ObservationRequestTracker` on the `CqlSessionBuilder` when Spring Data Cassandra observability + is on the classpath, unless a bean with that name already exists (e.g. from + `nv-boot-starter-cassandra`) + +### SpanExporter wrapper order + +When both redaction and exception shortening are enabled, the SpanExporter chain is (outermost to innermost): + +1. **ExceptionShorteningSpanExporter** – Shortens exception stack traces +2. **AttributeRedactingSpanExporter** – Redacts sensitive Cassandra attributes (only when `sensitive-columns` is non-empty) +3. **OTLP exporter** – Sends spans to the collector + +Attribute redaction is applied before exception shortening (closer to the OTLP exporter). If +`sensitive-columns` is empty or not configured, `AttributeRedactingSpanExporter` is not added +and no redaction occurs. + +### Cassandra observation opt-out + +Without a `cassandraSession` bean, `ObservableCqlSessionFactory.wrap(cqlSession)` is applied to generate observations (metrics & spans) + +Apps that want to instrument manually (e.g. via `ObservableReactiveSessionFactoryBean`) +should opt out to avoid duplicate / orphan sync spans. Opt out by registering a +`com.nvidia.boot.observability.tracing.cassandra.CqlSessionObservabilityProperties` bean: + +- **Bean absent** (default): treated as if `enabled=true`. `cqlSession` is wrapped with observability. +- **Bean present with `enabled=true`** (class default): same as bean absent. Library wraps. +- **Bean present with `enabled=false`**: library returns an unwrapped `CqlSession` so the app's + own wrap is the only observation layer active. + +```java +@Configuration +class CqlSessionObservabilityConfig { + + @Bean + CqlSessionObservabilityProperties cqlSessionObservabilityProperties() { + var properties = new CqlSessionObservabilityProperties(); + properties.setEnabled(false); + return properties; + } + + @Bean + CqlSessionBuilderCustomizer observationRequestTrackerCustomizer() { + return builder -> builder.addRequestTracker(ObservationRequestTracker.INSTANCE); + } + + @Bean + ObservableReactiveSessionFactoryBean observableReactiveSession( + CqlSession cqlSession, + ObservationRegistry observationRegistry) { + return new ObservableReactiveSessionFactoryBean(cqlSession, observationRegistry); + } + + // Recommended to suppress the metrics from the observation. These metrics are not useful + // as spring data + driver metrics are likely sufficient + @Bean + MeterFilter suppressCassandraObservationMetrics() { + Set suppressed = Set.of( + "execute", "execute.active", "execute.cassandra.node.success", + "prepare", "prepare.active"); + return MeterFilter.deny(id -> suppressed.contains(id.getName())); + } +} +``` + +**IMPORTANT:** If a `cassandraSession` bean is present (from `nv-boot-starter-cassandra` or +elsewhere), the `observableCqlSession` wrap is not applied; `CqlSessionObservabilityProperties` only +gates that fallback. For the refreshing session path, use +`RefreshingCqlSessionObservabilityProperties` from `nv-boot-starter-cassandra` to opt out of sync +wrap. The `CqlSessionBuilderCustomizer` for `ObservationRequestTracker` is provided once: either +by the Cassandra starter (when its configuration is active) or by this starter, not both. + +## Configuration + +Configure OTLP export, tracing, and metrics in `application.yml`: + +```yaml +management: + metrics: + tags: + env: ${ENVIRONMENT:dev} + host_id: ${HOSTNAME:unknown} + host_dc: ${AWS_REGION:${CLOUD_REGION:unknown}} + tracing: + sampling: + probability: 1.0 + redaction: + enabled: true + cassandra: + sensitive-columns: [] # Add column names to redact, e.g. [password_hash, api_key] + exceptions: + shorten: true + packages: ["com.nvidia"] + otlp: + tracing: + endpoint: http://localhost:4317/v1/traces +``` + +### Attribute redaction (secrets, passwords) + +Redacts sensitive column values from Cassandra spans (`db.query.text`, `db.query.parameter.*`) +before export. + +```yaml +management: + tracing: + redaction: + enabled: true + cassandra: + sensitive-columns: + - password_hash + - secret_data + - api_key + - container_environment # May contain secrets + # Optional: scan @Table classes for @DoNotTraceValue (merge with sensitive-columns) + scan-package: com.nvidia.foo.bar.app_root # Root package of the app. +``` + +- `management.tracing.redaction.enabled` – Enable redaction wiring (default: `true`). Actual + redaction runs only when `sensitive-columns` is non-empty and/or `scan-package` discovers + columns; set to `false` to disable the feature entirely. +- `management.tracing.redaction.cassandra.sensitive-columns` – Column names to redact in Cassandra query traces. + Values matching `column = 'value'` are replaced with `column = ?`. Case-insensitive. + **If empty or not specified, no redaction is applied** – the SpanExporter is not wrapped. +- `management.tracing.redaction.cassandra.scan-package` – Base package to scan for `@Table` entity classes + with `@DoNotTraceValue` on fields. When set and Spring Data Cassandra is on the classpath, discovered + column names are merged with `sensitive-columns`. Use for annotation-based discovery (e.g. `com.nvidia`). + Use `@DoNotTraceValue` from `com.nvidia.boot.observability.tracing.redaction` + on entity fields; add `scan-package: com.nvidia` (or your entity package) to auto-discover + columns without listing them in YAML. + +### Exception shortening + +- `management.tracing.exceptions.shorten` – Enable shortening (default: `true`) +- `management.tracing.exceptions.packages` – Package prefixes to keep in stack traces + (default: `["com.nvidia"]`). Stack traces are truncated after the last line matching any + of these packages, plus one line. + +### Batch span export + +If the collector rejects spans with a gRPC error such as: + +``` +grpc: received message after decompression larger than max +``` + +reduce the export batch size so each request sends fewer spans: + +```yaml +management: + tracing: + opentelemetry: + export: + max-batch-size: 100 + max-queue-size: 1024 +``` + +| Property | Default | Description | +|----------|---------|-------------| +| `management.tracing.opentelemetry.export.max-batch-size` | 512 | Maximum spans per export. Must be ≤ max-queue-size. | +| `management.tracing.opentelemetry.export.max-queue-size` | 2048 | Maximum spans in the queue before dropping. | +| `management.tracing.opentelemetry.export.schedule-delay` | 5s | Delay between exports. | +| `management.tracing.opentelemetry.export.timeout` | 30s | Maximum time an export may run before being cancelled. | + +**Note:** Smaller batches mean more frequent, smaller requests. + +## Dependencies + +This starter brings in: + +- `spring-boot-starter-actuator` +- `spring-boot-starter-web` +- `micrometer-tracing-bridge-otel` +- `opentelemetry-exporter-otlp` diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/pom.xml b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/pom.xml new file mode 100644 index 0000000000..3997407c7d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/pom.xml @@ -0,0 +1,149 @@ + + + + + 4.0.0 + + + com.nvidia.boot + nv-boot-parent + 0.0.1-SNAPSHOT + + + nv-boot-starter-observability + jar + NV Boot Starter Observability + Tracing configuration for NV Boot applications: OTel semantic conventions, + management context tracing, and actuator endpoint tracing. + + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework + spring-web + true + + + org.springframework.boot + spring-boot-micrometer-tracing + + + org.springframework.boot + spring-boot-micrometer-tracing-opentelemetry + + + org.springframework.boot + spring-boot-micrometer-metrics + + + io.micrometer + micrometer-tracing-bridge-otel + + + io.opentelemetry + opentelemetry-exporter-otlp + + + org.apache.commons + commons-lang3 + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter-data-cassandra + true + + + + + + org.springframework.cloud + spring-cloud-commons + true + + + + + jakarta.servlet + jakarta.servlet-api + true + provided + + + org.springframework + spring-webmvc + true + provided + + + + + org.springframework + spring-webflux + true + + + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + org.springframework.boot + spring-boot-restclient + test + + + org.springframework.boot + spring-boot-starter-webflux-test + test + + + org.springframework.boot + spring-boot-starter-actuator-test + test + + + org.assertj + assertj-core + test + + + org.awaitility + awaitility + test + + + io.opentelemetry + opentelemetry-sdk-testing + test + + + diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/ObservabilityAutoConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/ObservabilityAutoConfiguration.java new file mode 100644 index 0000000000..ddb740001a --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/ObservabilityAutoConfiguration.java @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability; + +import com.nvidia.boot.observability.metrics.MetricsConfiguration; +import com.nvidia.boot.observability.tracing.client.OtelBlockingClientTracingConfiguration; +import com.nvidia.boot.observability.tracing.server.OtelReactiveTracingConfiguration; +import com.nvidia.boot.observability.tracing.server.OtelServletTracingConfiguration; +import com.nvidia.boot.observability.tracing.OtelTracingConfiguration; +import com.nvidia.boot.observability.tracing.SpringCloudInfrastructureRoleConfiguration; +import com.nvidia.boot.observability.tracing.redaction.AttributeRedactingSpanExporterConfiguration; +import com.nvidia.boot.observability.tracing.stacktrace.ExceptionShorteningSpanExporterConfiguration; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Import; + +/** + * Auto-configuration for NV Boot observability: logging (filtered stack traces), + * metrics (common tags), OpenTelemetry tracing with semantic conventions, + * attribute redaction, exception shortening, and management context tracing + * for actuator endpoints. + */ +@AutoConfiguration +@Import({ + MetricsConfiguration.class, + OtelTracingConfiguration.class, + OtelServletTracingConfiguration.class, + OtelBlockingClientTracingConfiguration.class, + OtelReactiveTracingConfiguration.class, + AttributeRedactingSpanExporterConfiguration.class, + ExceptionShorteningSpanExporterConfiguration.class, + SpringCloudInfrastructureRoleConfiguration.class +}) +public class ObservabilityAutoConfiguration { +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/logging/LogbackEnvironmentPostProcessor.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/logging/LogbackEnvironmentPostProcessor.java new file mode 100644 index 0000000000..df3aa6851f --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/logging/LogbackEnvironmentPostProcessor.java @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.logging; + +import java.io.IOException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.EnvironmentPostProcessor; +import org.springframework.core.Ordered; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.PropertiesPropertySource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.support.PropertiesLoaderUtils; + +/** + * Environment post processor that loads logback configuration properties + * for filtered stack traces (StackFilteringThrowableConverter). + */ +@Slf4j +public class LogbackEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered { + + private static final String LOGBACK_DEFAULTS_FILE = + "nv-boot-observability-logback-defaults.properties"; + + @Override + public void postProcessEnvironment( + ConfigurableEnvironment environment, + SpringApplication application) { + loadLogbackDefaults(environment); + } + + private void loadLogbackDefaults(ConfigurableEnvironment environment) { + loadPropertiesFile(environment, LOGBACK_DEFAULTS_FILE, + "nv-boot-observability-logback-defaults"); + } + + private void loadPropertiesFile( + ConfigurableEnvironment environment, + String filename, + String sourceName) { + try { + var resource = new ClassPathResource(filename); + if (resource.exists()) { + var properties = PropertiesLoaderUtils.loadProperties(resource); + environment.getPropertySources().addLast( + new PropertiesPropertySource(sourceName, properties) + ); + } + } catch (IOException e) { + log.warn("Failed to load properties file '{}'", filename); + } + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE - 10; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/logging/StackFilteringThrowableConverter.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/logging/StackFilteringThrowableConverter.java new file mode 100644 index 0000000000..c1b71e5379 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/logging/StackFilteringThrowableConverter.java @@ -0,0 +1,112 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.logging; + +import ch.qos.logback.classic.pattern.ThrowableProxyConverter; +import ch.qos.logback.classic.spi.IThrowableProxy; +import ch.qos.logback.classic.spi.ThrowableProxyUtil; +import ch.qos.logback.core.CoreConstants; +import java.util.List; +import java.util.regex.Pattern; + +/** + * Custom Logback converter that filters out noisy stack trace elements + * from common frameworks (Spring, Reactor, Netty, etc.). + */ +public class StackFilteringThrowableConverter extends ThrowableProxyConverter { + + private static final List FILTER_PATTERNS = List.of( + Pattern.compile("^sun\\.reflect\\..*"), + Pattern.compile("^java\\.lang\\.reflect\\..*"), + Pattern.compile("^org\\.springframework\\.cglib\\..*"), + Pattern.compile("^org\\.springframework\\.aop\\..*"), + Pattern.compile("^org\\.springframework\\.web\\.filter\\..*"), + Pattern.compile("^org\\.apache\\.catalina\\..*"), + Pattern.compile("^org\\.apache\\.coyote\\..*"), + Pattern.compile("^org\\.apache\\.tomcat\\..*"), + Pattern.compile("^io\\.netty\\..*"), + Pattern.compile("^reactor\\.core\\..*"), + Pattern.compile("^reactor\\.netty\\..*"), + Pattern.compile("^jdk\\.internal\\..*") + ); + + @Override + protected String throwableProxyToString(IThrowableProxy tp) { + var sb = new StringBuilder(); + recursiveAppend(sb, null, ThrowableProxyUtil.REGULAR_EXCEPTION_INDENT, tp); + return sb.toString(); + } + + private void recursiveAppend(StringBuilder sb, String prefix, int indent, IThrowableProxy tp) { + if (tp == null) { + return; + } + subjoinFirstLine(sb, prefix, indent, tp); + sb.append(CoreConstants.LINE_SEPARATOR); + subjoinStackTraceElements(sb, indent, tp); + + var cause = tp.getCause(); + if (cause != null) { + recursiveAppend(sb, "Caused by: ", indent, cause); + } + } + + private void subjoinFirstLine(StringBuilder sb, String prefix, int indent, IThrowableProxy tp) { + ThrowableProxyUtil.indent(sb, indent - 1); + if (prefix != null) { + sb.append(prefix); + } + sb.append(tp.getClassName()).append(": ").append(tp.getMessage()); + } + + private void subjoinStackTraceElements(StringBuilder sb, int indent, IThrowableProxy tp) { + var stepArray = tp.getStackTraceElementProxyArray(); + var filteredCount = 0; + + for (var step : stepArray) { + var className = step.getStackTraceElement().getClassName(); + + if (shouldFilter(className)) { + filteredCount++; + continue; + } + + if (filteredCount > 0) { + ThrowableProxyUtil.indent(sb, indent); + sb.append("... ").append(filteredCount).append(" filtered frames") + .append(CoreConstants.LINE_SEPARATOR); + filteredCount = 0; + } + + ThrowableProxyUtil.indent(sb, indent); + sb.append(step.toString()); + sb.append(CoreConstants.LINE_SEPARATOR); + } + + if (filteredCount > 0) { + ThrowableProxyUtil.indent(sb, indent); + sb.append("... ").append(filteredCount).append(" filtered frames") + .append(CoreConstants.LINE_SEPARATOR); + } + } + + private boolean shouldFilter(String className) { + return FILTER_PATTERNS.stream() + .anyMatch(pattern -> pattern.matcher(className).matches()); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/metrics/MetricsConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/metrics/MetricsConfiguration.java new file mode 100644 index 0000000000..d2d8e1ddd4 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/metrics/MetricsConfiguration.java @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.metrics; + +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.micrometer.metrics.autoconfigure.MeterRegistryCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Configures common tags for all metrics. + */ +@Configuration +public class MetricsConfiguration { + + @Bean + public MeterRegistryCustomizer commonTagsMeterRegistryCustomizer( + @Value("${management.metrics.tags.env:${ENVIRONMENT:default}}") String env, + @Value("${management.metrics.tags.host_id:${HOSTNAME:unknown}}") String hostId, + @Value("${management.metrics.tags.host_dc:${AWS_REGION:${CLOUD_REGION:unknown}}}") + String hostDc) { + return registry -> registry.config() + .commonTags("env", env) + .commonTags("host_id", hostId) + .commonTags("host_dc", hostDc); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/OtelTracingConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/OtelTracingConfiguration.java new file mode 100644 index 0000000000..d251fe1839 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/OtelTracingConfiguration.java @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.sdk.resources.Resource; +import java.lang.management.ManagementFactory; +import org.springframework.boot.micrometer.tracing.opentelemetry.autoconfigure.SdkTracerProviderBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * OpenTelemetry tracing configuration shared by servlet and reactive stacks: resource attributes + * for host/process/runtime. Servlet- and WebFlux-specific HTTP observation conventions are + * registered by {@link com.nvidia.boot.observability.tracing.server.OtelServletTracingConfiguration} + * and {@link com.nvidia.boot.observability.tracing.server.OtelReactiveTracingConfiguration}. + */ +@Configuration +public class OtelTracingConfiguration { + + /** + * Adds host, process, and runtime resource attributes to spans, aligning with + * what the OpenTelemetry Java agent provides by default. + */ + @Bean + public SdkTracerProviderBuilderCustomizer otelResourceAttributesCustomizer() { + return builder -> builder.addResource(createProcessAndHostResource()); + } + + private static Resource createProcessAndHostResource() { + var attributes = Attributes.builder() + .put(AttributeKey.longKey("process.pid"), ProcessHandle.current().pid()) + .put(AttributeKey.stringKey("host.arch"), System.getProperty("os.arch", "unknown")) + .put(AttributeKey.stringKey("os.type"), System.getProperty("os.name", "unknown")) + .put(AttributeKey.stringKey("os.description"), buildOsDescription()) + .put(AttributeKey.stringKey("process.runtime.name"), "Java") + .put(AttributeKey.stringKey("process.runtime.version"), + System.getProperty("java.version", "unknown")) + .put(AttributeKey.stringKey("process.runtime.description"), + buildProcessRuntimeDescription()) + .put(AttributeKey.stringKey("process.executable.path"), + System.getProperty("java.home", "") + "/bin/java") + .put(AttributeKey.stringArrayKey("process.command_args"), + ManagementFactory.getRuntimeMXBean().getInputArguments()) + .build(); + + return Resource.create(attributes); + } + + private static String buildOsDescription() { + var osName = System.getProperty("os.name", ""); + var osVersion = System.getProperty("os.version", ""); + return (osName + " " + osVersion).trim(); + } + + private static String buildProcessRuntimeDescription() { + var runtimeName = System.getProperty("java.runtime.name", "Java"); + var runtimeVersion = System.getProperty("java.runtime.version", ""); + return (runtimeName + " " + runtimeVersion).trim(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/SpringCloudInfrastructureRoleConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/SpringCloudInfrastructureRoleConfiguration.java new file mode 100644 index 0000000000..c96d4d37a9 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/SpringCloudInfrastructureRoleConfiguration.java @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing; + +import java.util.Set; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.beans.factory.support.AbstractBeanDefinition; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.cloud.commons.config.CommonsConfigAutoConfiguration; +import org.springframework.cloud.commons.config.DefaultsBindHandlerAdvisor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.StringUtils; + +/** + * Workaround for BeanPostProcessorChecker warnings when Spring Cloud Commons is on the classpath. + * These beans are eagerly loaded when our early BeanPostProcessors (e.g. + * attributeRedactingSpanExporterBeanPostProcessor) are created, but do not declare + * ROLE_INFRASTRUCTURE. This configuration sets the role so the checker does not warn. + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass(CommonsConfigAutoConfiguration.class) +public class SpringCloudInfrastructureRoleConfiguration { + + private static final Set SPRING_CLOUD_INFRASTRUCTURE_CLASS_NAMES = Set.of( + CommonsConfigAutoConfiguration.class.getName(), + DefaultsBindHandlerAdvisor.class.getName() + ); + + // Bean names for factory-method beans where getBeanClassName() may not match the target type. + private static final Set SPRING_CLOUD_INFRASTRUCTURE_BEAN_NAMES = Set.of( + defaultBeanName(DefaultsBindHandlerAdvisor.class) + ); + + private static String defaultBeanName(Class clazz) { + var name = clazz.getSimpleName(); + return Character.toLowerCase(name.charAt(0)) + name.substring(1); + } + + @Bean + static BeanFactoryPostProcessor springCloudCommonsConfigRolePostProcessor() { + return beanFactory -> { + if (beanFactory instanceof BeanDefinitionRegistry registry) { + for (var beanName : registry.getBeanDefinitionNames()) { + var bd = registry.getBeanDefinition(beanName); + if (!(bd instanceof AbstractBeanDefinition abd)) { + continue; + } + var className = bd.getBeanClassName(); + var matchByClass = StringUtils.hasText(className) + && SPRING_CLOUD_INFRASTRUCTURE_CLASS_NAMES.contains(className); + var matchByName = SPRING_CLOUD_INFRASTRUCTURE_BEAN_NAMES.contains(beanName); + if (matchByClass || matchByName) { + abd.setRole(BeanDefinition.ROLE_INFRASTRUCTURE); + } + } + } + }; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/actuator/ServletManagementTracingConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/actuator/ServletManagementTracingConfiguration.java new file mode 100644 index 0000000000..87ba0d25bf --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/actuator/ServletManagementTracingConfiguration.java @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.actuator; + +import io.micrometer.observation.ObservationRegistry; +import jakarta.servlet.DispatcherType; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.micrometer.tracing.autoconfigure.MicrometerTracingAutoConfiguration; +import org.springframework.boot.micrometer.tracing.opentelemetry.autoconfigure.OpenTelemetryTracingAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.core.Ordered; +import org.springframework.http.server.observation.ServerRequestObservationConvention; +import org.springframework.web.filter.ServerHttpObservationFilter; + +/** + * Management context configuration that enables tracing for actuator endpoints + * when the management server runs on a separate port (e.g. 8181). + * + *

Servlet filters from the main application context are not applied to the + * management child context (Spring Boot #31811). This configuration registers + * {@link ServerHttpObservationFilter} in the management context so HTTP + * requests to actuator endpoints produce traces. + * + *

Placed in {@code com.nvidia.boot.observability.tracing.actuator} so it is not scanned by + * the main application. Registration is via {@code ManagementContextConfiguration.imports}. + */ +@ManagementContextConfiguration +@Import({ + OpenTelemetryTracingAutoConfiguration.class, + MicrometerTracingAutoConfiguration.class +}) +@ConditionalOnWebApplication(type = Type.SERVLET) +public class ServletManagementTracingConfiguration { + + @Bean + public FilterRegistrationBean serverHttpObservationFilter( + ObservationRegistry observationRegistry, + ObjectProvider observationConventionProvider) { + var convention = observationConventionProvider.getIfAvailable(); + var filter = convention != null + ? new ServerHttpObservationFilter(observationRegistry, convention) + : new ServerHttpObservationFilter(observationRegistry); + var registration = new FilterRegistrationBean(filter); + registration.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ASYNC, + DispatcherType.ERROR); + registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 1); + registration.addUrlPatterns("/*"); + return registration; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/cassandra/CassandraTracingAutoConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/cassandra/CassandraTracingAutoConfiguration.java new file mode 100644 index 0000000000..94b5ccfd39 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/cassandra/CassandraTracingAutoConfiguration.java @@ -0,0 +1,100 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.cassandra; + +import com.datastax.oss.driver.api.core.CqlSession; +import io.micrometer.observation.ObservationRegistry; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfigureBefore; +import org.springframework.boot.cassandra.autoconfigure.CassandraAutoConfiguration; +import org.springframework.boot.cassandra.autoconfigure.CqlSessionBuilderCustomizer; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.data.cassandra.observability.ObservableCqlSessionFactory; +import org.springframework.data.cassandra.observability.ObservationRequestTracker; + +/** + * Cassandra observability for the Spring Boot default {@link CqlSession} path: + * wraps the auto-configured session with {@link ObservableCqlSessionFactory} so queries can emit + * observations (including db.* attributes such as consistency, coordinator DC, keyspace, etc.) + * when an {@link ObservationRegistry} is present. + * + *

When {@code nv-boot-starter-cassandra}'s {@code CassandraSslBundleConfiguration} is active, + * it registers {@link ObservationRequestTracker} on the + * {@link com.datastax.oss.driver.api.core.CqlSessionBuilder} (bean + * {@code cassandraObservationRequestTrackerCustomizer}) and performs wrapping in + * {@code com.nvidia.boot.cassandra.configuration.CassandraSslBundleConfiguration}; this configuration + * then omits the duplicate customizer via + * {@code @ConditionalOnMissingBean(name = "cassandraObservationRequestTrackerCustomizer")} and skips + * {@link #observableCqlSession} via {@code @ConditionalOnMissingBean(name = "cassandraSession")}. + * + *

Runs before {@link CassandraAutoConfiguration} so this {@code @Primary} replacement is ordered + * ahead of default Cassandra session creation. The {@link #observableCqlSession} bean post-processes + * the existing {@code CqlSession} bean (Spring resolves it after the session exists). + * + *

Registered in {@code META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports} + * (not pulled in only by {@link com.nvidia.boot.observability.ObservabilityAutoConfiguration}) because + * Spring Data Cassandra is an optional dependency. + */ +@Configuration +@AutoConfigureBefore(CassandraAutoConfiguration.class) +public class CassandraTracingAutoConfiguration { + + /** + * Registers {@link ObservationRequestTracker} on Spring Boot's shared + * {@link com.datastax.oss.driver.api.core.CqlSessionBuilder} for the default {@link CqlSession} + * path. Omitted when {@code nv-boot-starter-cassandra} already declared the same bean. + */ + @Bean + @ConditionalOnClass(name = "org.springframework.data.cassandra.observability.ObservationRequestTracker") + @ConditionalOnMissingBean(name = "cassandraObservationRequestTrackerCustomizer") + public CqlSessionBuilderCustomizer cassandraObservationRequestTrackerCustomizer() { + return builder -> builder.addRequestTracker(ObservationRequestTracker.INSTANCE); + } + + /** + * Returns a {@code @Primary} {@link CqlSession} that wraps Boot's default session with + * {@link ObservableCqlSessionFactory} when no bean named {@code cassandraSession} exists. + *

+ * For the {@code cassandraSession} / SSL-bundle path, {@code nv-boot-starter-cassandra} owns + * both {@link ObservationRequestTracker} registration and wrapping; this method does not run + * in that case. + *

+ * Apps that want to instrument observability manually (e.g. {@code ObservableReactiveSessionFactoryBean} + * is mutually exclusive) can create a {@link CqlSessionObservabilityProperties} bean with + * {@code enabled=false} to opt out and avoid duplicate or orphan spans. + */ + @Bean + @Primary + @ConditionalOnBean({CqlSession.class, ObservationRegistry.class}) + @ConditionalOnMissingBean(name = "cassandraSession") + public CqlSession observableCqlSession( + ObjectProvider sessionProvider, + ObservationRegistry observationRegistry, + ObjectProvider observationPropertiesProvider) { + var session = sessionProvider.getObject(); + var observationProperties = observationPropertiesProvider.getIfAvailable(); + var shouldWrap = observationProperties == null || observationProperties.isEnabled(); + + return shouldWrap ? ObservableCqlSessionFactory.wrap(session, observationRegistry) : session; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/cassandra/CqlSessionObservabilityProperties.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/cassandra/CqlSessionObservabilityProperties.java new file mode 100644 index 0000000000..4aad239c1f --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/cassandra/CqlSessionObservabilityProperties.java @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.cassandra; + +import io.micrometer.observation.ObservationRegistry; +import lombok.Data; +import org.springframework.data.cassandra.observability.ObservableCqlSessionFactory; + +/** + * Gates the fallback {@link ObservableCqlSessionFactory#wrap} applied by + * {@code CassandraTracingAutoConfiguration#observableCqlSession} when + * {@code nv-boot-starter-cassandra}'s {@code cassandraSession} bean is absent. + *

+ * By default, library wraps whenever an {@link ObservationRegistry} is available. Apps + * that want to instrument observability manually (e.g. {@code ObservableReactiveSessionFactoryBean}) + * should opt out to avoid duplicate + orphan spans. + *

+ * Note: not used when {@code nv-boot-starter-cassandra} is present. Register a bean of {@code nv-boot-starter-cassandra}'s RefreshingCqlSessionObservabilityProperties instead + *

+ * {@code
+ * @Configuration
+ * class CqlSessionObservabilityConfig {
+ *
+ *     @Bean
+ *     CqlSessionObservabilityProperties cqlSessionObservabilityProperties() {
+ *         var props = new CqlSessionObservabilityProperties();
+ *         props.setEnabled(false);
+ *         return props;
+ *     }
+ * }
+ * 
+ */ +@Data +public class CqlSessionObservabilityProperties { + + private boolean enabled = true; +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/client/OtelBlockingClientRequestObservationConvention.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/client/OtelBlockingClientRequestObservationConvention.java new file mode 100644 index 0000000000..4bedf1b3e6 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/client/OtelBlockingClientRequestObservationConvention.java @@ -0,0 +1,130 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.client; + +import static com.nvidia.boot.observability.tracing.client.OtelClientObservationConventionSupport.statusCodeFamily; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.KeyValues; +import java.io.IOException; +import java.net.URI; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Optional; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.client.observation.ClientRequestObservationContext; +import org.springframework.http.client.observation.DefaultClientRequestObservationConvention; + +/** + * HTTP client (blocking / {@link org.springframework.web.client.RestClient}) observation convention + * that emits OpenTelemetry semantic attribute names (e.g. {@code http.request.method}, + * {@code server.address}, {@code url.full}) so client spans match dashboards and the OTel Java agent. + */ +public class OtelBlockingClientRequestObservationConvention + extends DefaultClientRequestObservationConvention { + + private static final String HTTP_REQUEST_METHOD = "http.request.method"; + private static final String HTTP_RESPONSE_STATUS_CODE = "http.response.status_code"; + private static final String HTTP_STATUS_CODE_FAMILY = "http.status_code_family"; + private static final String HTTP_ROUTE = "http.route"; + private static final String SERVER_ADDRESS = "server.address"; + private static final String SERVER_PORT = "server.port"; + private static final String URL_FULL = "url.full"; + private static final String EXCEPTION_TYPE = "exception.type"; + + public OtelBlockingClientRequestObservationConvention() { + super(); + } + + @Override + public String getContextualName(ClientRequestObservationContext context) { + var carrier = context.getCarrier(); + if (carrier == null) { + return super.getContextualName(context); + } + var method = carrier.getMethod().name(); + var template = context.getUriTemplate(); + if (StringUtils.isNotBlank(template)) { + return method + " " + OtelClientObservationConventionSupport + .extractPathFromUriTemplate(template); + } + return method; + } + + @Override + public KeyValues getLowCardinalityKeyValues(ClientRequestObservationContext context) { + var list = new ArrayList(); + + var carrier = context.getCarrier(); + if (carrier != null) { + var uri = Optional.ofNullable(carrier.getURI()); + list.add(KeyValue.of(SERVER_ADDRESS, uri.map(URI::getHost).filter(StringUtils::isNotBlank).orElse(KeyValue.NONE_VALUE))); + list.add(KeyValue.of(SERVER_PORT, uri.map(URI::getPort).filter(p -> p >= 0).map(String::valueOf).orElse(KeyValue.NONE_VALUE))); + list.add(KeyValue.of(HTTP_REQUEST_METHOD, carrier.getMethod().name())); + } else { + list.add(KeyValue.of(SERVER_ADDRESS, KeyValue.NONE_VALUE)); + list.add(KeyValue.of(SERVER_PORT, KeyValue.NONE_VALUE)); + list.add(KeyValue.of(HTTP_REQUEST_METHOD, KeyValue.NONE_VALUE)); + } + + var template = context.getUriTemplate(); + if (StringUtils.isNotBlank(template)) { + list.add(KeyValue.of(HTTP_ROUTE, OtelClientObservationConventionSupport.extractPathFromUriTemplate(template))); + } else { + list.add(KeyValue.of(HTTP_ROUTE, KeyValue.NONE_VALUE)); + } + + var response = context.getResponse(); + if (response != null) { + try { + var statusCode = response.getStatusCode().value(); + list.add(KeyValue.of(HTTP_RESPONSE_STATUS_CODE, String.valueOf(statusCode))); + list.add(KeyValue.of(HTTP_STATUS_CODE_FAMILY, statusCodeFamily(statusCode))); + } catch (IOException ignored) { + // align with DefaultClientRequestObservationConvention#status + list.add(KeyValue.of(HTTP_RESPONSE_STATUS_CODE, KeyValue.NONE_VALUE)); + list.add(KeyValue.of(HTTP_STATUS_CODE_FAMILY, KeyValue.NONE_VALUE)); + } + } else { + list.add(KeyValue.of(HTTP_RESPONSE_STATUS_CODE, KeyValue.NONE_VALUE)); + list.add(KeyValue.of(HTTP_STATUS_CODE_FAMILY, KeyValue.NONE_VALUE)); + } + + var error = context.getError(); + if (error != null) { + list.add(KeyValue.of(EXCEPTION_TYPE, error.getClass().getName())); + } else { + list.add(KeyValue.of(EXCEPTION_TYPE, KeyValue.NONE_VALUE)); + } + + list.add(super.outcome(context)); + + list.sort(Comparator.comparing(KeyValue::getKey)); + return KeyValues.of(list); + } + + @Override + public KeyValues getHighCardinalityKeyValues(ClientRequestObservationContext context) { + var carrier = context.getCarrier(); + if (carrier == null) { + return KeyValues.empty(); + } + var uri = carrier.getURI(); + return KeyValues.of(KeyValue.of(URL_FULL, uri.toASCIIString())); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/client/OtelBlockingClientTracingConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/client/OtelBlockingClientTracingConfiguration.java new file mode 100644 index 0000000000..0f2556eb52 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/client/OtelBlockingClientTracingConfiguration.java @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.client; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.observation.ClientRequestObservationConvention; + +/** + * Registers {@link OtelBlockingClientRequestObservationConvention} when Spring's blocking HTTP client + * observation types are present ({@link org.springframework.http.client.observation.ClientRequestObservationContext}). + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass(name = "org.springframework.http.client.observation.ClientRequestObservationConvention") +public class OtelBlockingClientTracingConfiguration { + + @Bean + @ConditionalOnMissingBean(ClientRequestObservationConvention.class) + public ClientRequestObservationConvention otelBlockingClientRequestObservationConvention() { + return new OtelBlockingClientRequestObservationConvention(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/client/OtelClientObservationConventionSupport.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/client/OtelClientObservationConventionSupport.java new file mode 100644 index 0000000000..3b5dad3469 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/client/OtelClientObservationConventionSupport.java @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.client; + +import java.util.regex.Pattern; + +/** + * Shared helpers for HTTP client observation conventions (blocking + reactive). + */ +final class OtelClientObservationConventionSupport { + + private static final Pattern PATTERN_BEFORE_PATH = Pattern.compile("^https?://[^/]+"); + + private OtelClientObservationConventionSupport() {} + + /** + * Matches Spring's {@code DefaultClientRequestObservationConvention#extractPath}: strips scheme and + * authority, normalizes to a path starting with {@code /}. + */ + static String extractPathFromUriTemplate(String uriTemplate) { + if (uriTemplate == null || uriTemplate.isBlank()) { + return ""; + } + var path = PATTERN_BEFORE_PATH.matcher(uriTemplate).replaceFirst(""); + if (path.startsWith("/")) { + return path; + } + return "/" + path; + } + + static String statusCodeFamily(int statusCode) { + if (statusCode >= 100 && statusCode < 200) { + return "1xx"; + } + if (statusCode >= 200 && statusCode < 300) { + return "2xx"; + } + if (statusCode >= 300 && statusCode < 400) { + return "3xx"; + } + if (statusCode >= 400 && statusCode < 500) { + return "4xx"; + } + if (statusCode >= 500 && statusCode < 600) { + return "5xx"; + } + return "other"; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/client/OtelReactiveClientRequestObservationConvention.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/client/OtelReactiveClientRequestObservationConvention.java new file mode 100644 index 0000000000..f98ba46a90 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/client/OtelReactiveClientRequestObservationConvention.java @@ -0,0 +1,123 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.client; + +import static com.nvidia.boot.observability.tracing.client.OtelClientObservationConventionSupport.statusCodeFamily; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.KeyValues; +import java.net.URI; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Optional; +import org.apache.commons.lang3.StringUtils; +import org.springframework.web.reactive.function.client.ClientRequestObservationContext; +import org.springframework.web.reactive.function.client.DefaultClientRequestObservationConvention; + +/** + * WebClient observation convention that emits OpenTelemetry semantic attribute names for outbound HTTP + * client spans (e.g. {@code http.request.method}, {@code server.address}, {@code url.full}). + */ +public class OtelReactiveClientRequestObservationConvention + extends DefaultClientRequestObservationConvention { + + private static final String HTTP_REQUEST_METHOD = "http.request.method"; + private static final String HTTP_RESPONSE_STATUS_CODE = "http.response.status_code"; + private static final String HTTP_STATUS_CODE_FAMILY = "http.status_code_family"; + private static final String HTTP_ROUTE = "http.route"; + private static final String SERVER_ADDRESS = "server.address"; + private static final String SERVER_PORT = "server.port"; + private static final String URL_FULL = "url.full"; + private static final String EXCEPTION_TYPE = "exception.type"; + + public OtelReactiveClientRequestObservationConvention() { + super(); + } + + @Override + public String getContextualName(ClientRequestObservationContext context) { + var request = context.getRequest(); + if (request == null) { + return super.getContextualName(context); + } + var method = request.method().name(); + var template = context.getUriTemplate(); + if (StringUtils.isNotBlank(template)) { + return method + " " + OtelClientObservationConventionSupport + .extractPathFromUriTemplate(template); + } + return method; + } + + @Override + public KeyValues getLowCardinalityKeyValues(ClientRequestObservationContext context) { + var list = new ArrayList(); + + var request = context.getRequest(); + if (request != null) { + var uri = Optional.ofNullable(request.url()); + list.add(KeyValue.of(SERVER_ADDRESS, uri.map(URI::getHost).filter(StringUtils::isNotBlank).orElse(KeyValue.NONE_VALUE))); + list.add(KeyValue.of(SERVER_PORT, uri.map(URI::getPort).filter(p -> p >= 0).map(String::valueOf).orElse(KeyValue.NONE_VALUE))); + list.add(KeyValue.of(HTTP_REQUEST_METHOD, request.method().name())); + } else { + list.add(KeyValue.of(SERVER_ADDRESS, KeyValue.NONE_VALUE)); + list.add(KeyValue.of(SERVER_PORT, KeyValue.NONE_VALUE)); + list.add(KeyValue.of(HTTP_REQUEST_METHOD, KeyValue.NONE_VALUE)); + } + + var template = context.getUriTemplate(); + if (StringUtils.isNotBlank(template)) { + list.add(KeyValue.of(HTTP_ROUTE, OtelClientObservationConventionSupport + .extractPathFromUriTemplate(template))); + } else { + list.add(KeyValue.of(HTTP_ROUTE, KeyValue.NONE_VALUE)); + } + + var response = context.getResponse(); + if (!context.isAborted() && response != null) { + var statusCode = response.statusCode().value(); + list.add(KeyValue.of(HTTP_RESPONSE_STATUS_CODE, String.valueOf(statusCode))); + list.add(KeyValue.of(HTTP_STATUS_CODE_FAMILY, statusCodeFamily(statusCode))); + } else { + list.add(KeyValue.of(HTTP_RESPONSE_STATUS_CODE, KeyValue.NONE_VALUE)); + list.add(KeyValue.of(HTTP_STATUS_CODE_FAMILY, KeyValue.NONE_VALUE)); + } + + var error = context.getError(); + if (error != null) { + list.add(KeyValue.of(EXCEPTION_TYPE, error.getClass().getName())); + } else { + list.add(KeyValue.of(EXCEPTION_TYPE, KeyValue.NONE_VALUE)); + } + + list.add(super.outcome(context)); + + list.sort(Comparator.comparing(KeyValue::getKey)); + return KeyValues.of(list); + } + + @Override + public KeyValues getHighCardinalityKeyValues(ClientRequestObservationContext context) { + var request = context.getRequest(); + if (request == null) { + return KeyValues.empty(); + } + var uri = request.url(); + return KeyValues.of(KeyValue.of(URL_FULL, uri.toASCIIString())); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/client/OtelReactiveClientTracingConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/client/OtelReactiveClientTracingConfiguration.java new file mode 100644 index 0000000000..ae8621b608 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/client/OtelReactiveClientTracingConfiguration.java @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.client; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.ClientRequestObservationConvention; + +/** + * Registers {@link OtelReactiveClientRequestObservationConvention} when WebClient observation support + * is on the classpath. Listed in {@code META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports} + * (not {@code @Import}) so servlet-only applications without spring-webflux do not load WebFlux types. + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass(name = "org.springframework.web.reactive.function.client.ClientRequestObservationConvention") +public class OtelReactiveClientTracingConfiguration { + + @Bean + @ConditionalOnMissingBean(ClientRequestObservationConvention.class) + public ClientRequestObservationConvention otelReactiveClientRequestObservationConvention() { + return new OtelReactiveClientRequestObservationConvention(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/redaction/AttributeRedactingSpanExporter.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/redaction/AttributeRedactingSpanExporter.java new file mode 100644 index 0000000000..984d464f02 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/redaction/AttributeRedactingSpanExporter.java @@ -0,0 +1,232 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.redaction; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.common.InstrumentationLibraryInfo; +import io.opentelemetry.sdk.common.InstrumentationScopeInfo; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.data.EventData; +import io.opentelemetry.sdk.trace.data.LinkData; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.data.StatusData; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * SpanExporter that wraps a delegate and redacts sensitive attribute values + * (e.g. Cassandra query parameters) before export. + */ +class AttributeRedactingSpanExporter implements SpanExporter { + + private static final String DB_SYSTEM_CASSANDRA = "cassandra"; + private static final String REDACTED_PLACEHOLDER = "?"; + + private final SpanExporter delegate; + private final Pattern columnValuePattern; + private final boolean hasSensitiveColumns; + + AttributeRedactingSpanExporter(SpanExporter delegate, Set sensitiveColumns) { + this.delegate = delegate; + this.hasSensitiveColumns = sensitiveColumns != null && !sensitiveColumns.isEmpty(); + if (hasSensitiveColumns) { + var columnRegex = sensitiveColumns.stream() + .map(Pattern::quote) + .collect(Collectors.joining("|", "(", ")")); + this.columnValuePattern = Pattern.compile( + columnRegex + "\\s*=\\s*'[^']*'", + Pattern.CASE_INSENSITIVE); + } else { + this.columnValuePattern = null; + } + } + + @Override + public CompletableResultCode export(Collection spans) { + if (!hasSensitiveColumns) { + return delegate.export(spans); + } + var redacted = spans.stream() + .map(this::redactIfCassandra) + .collect(Collectors.toList()); + return delegate.export(redacted); + } + + private SpanData redactIfCassandra(SpanData span) { + var dbSystem = span.getAttributes().get(AttributeKey.stringKey("db.system")); + if (!DB_SYSTEM_CASSANDRA.equals(dbSystem)) { + return span; + } + return new RedactingSpanData(span, columnValuePattern); + } + + @Override + public CompletableResultCode flush() { + return delegate.flush(); + } + + @Override + public CompletableResultCode shutdown() { + return delegate.shutdown(); + } + + private static class RedactingSpanData implements SpanData { + + private final SpanData delegate; + private final Attributes redactedAttributes; + + RedactingSpanData(SpanData delegate, Pattern columnValuePattern) { + this.delegate = delegate; + var original = delegate.getAttributes(); + var builder = Attributes.builder(); + original.forEach((key, value) -> { + var keyStr = key.getKey(); + if (("db.query.text".equals(keyStr) || keyStr.startsWith("db.query.parameter.")) + && value instanceof String) { + var str = (String) value; + var redacted = columnValuePattern.matcher(str).replaceAll("$1 = " + REDACTED_PLACEHOLDER); + builder.put(keyStr, redacted); + } else { + putAttributeValue(builder, keyStr, value); + } + }); + this.redactedAttributes = builder.build(); + } + + @SuppressWarnings("unchecked") + private static void putAttributeValue(AttributesBuilder builder, String key, Object value) { + if (value instanceof String) { + builder.put(key, (String) value); + } else if (value instanceof Long) { + builder.put(key, (Long) value); + } else if (value instanceof Double) { + builder.put(key, (Double) value); + } else if (value instanceof Boolean) { + builder.put(key, (Boolean) value); + } else if (value instanceof List) { + var list = (List) value; + if (!list.isEmpty() && list.get(0) instanceof String) { + builder.put(key, ((List) list).toArray(new String[0])); + } + } + } + + @Override + public Attributes getAttributes() { + return redactedAttributes; + } + + @Override + public int getTotalAttributeCount() { + return redactedAttributes.size(); + } + + @Override + public String getName() { + return delegate.getName(); + } + + @Override + public List getLinks() { + return delegate.getLinks(); + } + + @Override + public int getTotalRecordedLinks() { + return delegate.getTotalRecordedLinks(); + } + + @Override + public int getTotalRecordedEvents() { + return delegate.getTotalRecordedEvents(); + } + + @Override + public io.opentelemetry.api.trace.SpanContext getSpanContext() { + return delegate.getSpanContext(); + } + + @Override + public io.opentelemetry.api.trace.SpanContext getParentSpanContext() { + return delegate.getParentSpanContext(); + } + + @Override + public String getTraceId() { + return delegate.getTraceId(); + } + + @Override + public String getSpanId() { + return delegate.getSpanId(); + } + + @Override + public StatusData getStatus() { + return delegate.getStatus(); + } + + @Override + public long getStartEpochNanos() { + return delegate.getStartEpochNanos(); + } + + @Override + public List getEvents() { + return delegate.getEvents(); + } + + @Override + public long getEndEpochNanos() { + return delegate.getEndEpochNanos(); + } + + @Override + public boolean hasEnded() { + return delegate.hasEnded(); + } + + @Override + public Resource getResource() { + return delegate.getResource(); + } + + @Override + public InstrumentationScopeInfo getInstrumentationScopeInfo() { + return delegate.getInstrumentationScopeInfo(); + } + + @Override + public io.opentelemetry.api.trace.SpanKind getKind() { + return delegate.getKind(); + } + + @Override + @SuppressWarnings("deprecation") + public InstrumentationLibraryInfo getInstrumentationLibraryInfo() { + return delegate.getInstrumentationLibraryInfo(); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/redaction/AttributeRedactingSpanExporterConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/redaction/AttributeRedactingSpanExporterConfiguration.java new file mode 100644 index 0000000000..fde7f0e22b --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/redaction/AttributeRedactingSpanExporterConfiguration.java @@ -0,0 +1,104 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.redaction; + +import io.opentelemetry.sdk.trace.export.SpanExporter; +import java.util.HashSet; +import java.util.stream.Collectors; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Role; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.util.StringUtils; + +/** + * Configuration that wraps SpanExporter beans with AttributeRedactingSpanExporter + * when redaction is enabled and sensitive columns are configured. + * + *

Runs before ExceptionShorteningSpanExporter (lower order) so redaction is + * the innermost wrapper, closest to the OTLP exporter. + * + *

Sensitive columns are merged from: + *

    + *
  • {@code management.tracing.redaction.cassandra.sensitive-columns} (YAML)
  • + *
  • Columns discovered by scanning {@code @Table} classes for {@code @DoNotTraceValue} + * when {@code management.tracing.redaction.cassandra.scan-package} is set
  • + *
+ */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnProperty(name = "management.tracing.redaction.enabled", havingValue = "true", + matchIfMissing = true) +@EnableConfigurationProperties(RedactionProperties.class) +@Role(BeanDefinition.ROLE_INFRASTRUCTURE) +public class AttributeRedactingSpanExporterConfiguration { + + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + DoNotTraceValueScanner doNotTraceValueScanner() { + return new DoNotTraceValueScanner(); + } + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + static AttributeRedactingSpanExporterBeanPostProcessor attributeRedactingSpanExporterBeanPostProcessor( + RedactionProperties properties, DoNotTraceValueScanner scanner) { + return new AttributeRedactingSpanExporterBeanPostProcessor(properties, scanner); + } + + public static class AttributeRedactingSpanExporterBeanPostProcessor + implements BeanPostProcessor { + + private final RedactionProperties properties; + private final DoNotTraceValueScanner scanner; + + AttributeRedactingSpanExporterBeanPostProcessor(RedactionProperties properties, + DoNotTraceValueScanner scanner) { + this.properties = properties; + this.scanner = scanner; + } + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) + throws BeansException { + if (bean instanceof SpanExporter && !(bean instanceof AttributeRedactingSpanExporter)) { + var sensitiveColumns = new HashSet( + properties.getCassandra().getSensitiveColumns() + .stream() + .map(String::toLowerCase) + .filter(s -> !s.isBlank()) + .collect(Collectors.toSet())); + if (StringUtils.hasText(properties.getCassandra().getScanPackage())) { + sensitiveColumns.addAll(scanner.scan(properties.getCassandra() + .getScanPackage())); + } + if (!sensitiveColumns.isEmpty()) { + return new AttributeRedactingSpanExporter((SpanExporter) bean, + sensitiveColumns); + } + } + return bean; + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/redaction/DoNotTraceValue.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/redaction/DoNotTraceValue.java new file mode 100644 index 0000000000..2acc2bad68 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/redaction/DoNotTraceValue.java @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.redaction; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * When present on a {@code @Table} entity field mapped to a Cassandra column, + * that column's values are redacted from trace spans. + * + *

Scanned at startup when {@code management.tracing.redaction.cassandra.scan-package} + * is configured and Spring Data Cassandra is on the classpath. Column names are + * merged with {@code management.tracing.redaction.cassandra.sensitive-columns}. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface DoNotTraceValue { +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/redaction/DoNotTraceValueScanner.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/redaction/DoNotTraceValueScanner.java new file mode 100644 index 0000000000..e062f6d882 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/redaction/DoNotTraceValueScanner.java @@ -0,0 +1,178 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.redaction; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.util.HashSet; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.core.type.filter.AnnotationTypeFilter; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; + +/** + * Scans for {@code @Table} entity classes and collects Cassandra column names + * from fields annotated with {@code @DoNotTraceValue} and {@code @Column} or + * {@code @PrimaryKeyColumn}. + * + *

Uses only reflection and fully qualified class names so this type loads when + * {@code spring-boot-starter-data-cassandra} is absent; {@link #scan(String)} then + * returns an empty set until those classes are on the classpath. + */ +@Slf4j +public class DoNotTraceValueScanner { + + private static final String TABLE_ANNOTATION = + "org.springframework.data.cassandra.core.mapping.Table"; + private static final String COLUMN_ANNOTATION = + "org.springframework.data.cassandra.core.mapping.Column"; + private static final String PRIMARY_KEY_ANNOTATION = + "org.springframework.data.cassandra.core.mapping.PrimaryKey"; + private static final String PRIMARY_KEY_COLUMN_ANNOTATION = + "org.springframework.data.cassandra.core.mapping.PrimaryKeyColumn"; + + /** + * Scans the given base package for {@code @Table} classes and returns the + * set of column names from fields annotated with {@code @DoNotTraceValue}. + * + * @param basePackage base package to scan (e.g. {@code com.nvidia}) + * @return lower-case column names to redact; never null. Empty if the package is blank, + * or if Spring Data Cassandra mapping types are not on the classpath. + */ + public Set scan(String basePackage) { + var columns = new HashSet(); + if (!StringUtils.hasText(basePackage)) { + return columns; + } + + final Class tableAnnotationType; + final Class columnAnnotationType; + final Class primaryKeyAnnotationType; + final Class primaryKeyColumnAnnotationType; + try { + tableAnnotationType = loadAnnotationClass(TABLE_ANNOTATION); + columnAnnotationType = loadAnnotationClass(COLUMN_ANNOTATION); + primaryKeyAnnotationType = loadAnnotationClass(PRIMARY_KEY_ANNOTATION); + primaryKeyColumnAnnotationType = loadAnnotationClass(PRIMARY_KEY_COLUMN_ANNOTATION); + } catch (ClassNotFoundException e) { + log.debug("Spring Data Cassandra mapping API not on classpath ({}), " + + "skipping @DoNotTraceValue scan", + e.getMessage()); + return columns; + } + + try { + var provider = new ClassPathScanningCandidateComponentProvider(false); + provider.addIncludeFilter(new AnnotationTypeFilter(tableAnnotationType)); + + for (BeanDefinition bd : provider.findCandidateComponents(basePackage)) { + var className = bd.getBeanClassName(); + if (className == null) { + continue; + } + try { + var clazz = ClassUtils.forName(className, null); + collectFromClass(clazz, columns, columnAnnotationType, primaryKeyAnnotationType, + primaryKeyColumnAnnotationType); + } catch (ClassNotFoundException e) { + log.debug("Could not load table class {}: {}", className, e.getMessage()); + } + } + } catch (Exception e) { + log.debug("DoNotTraceValue scan failed: {}", e.getMessage()); + } + + if (log.isDebugEnabled() && !columns.isEmpty()) { + log.debug("Discovered sensitive columns from @DoNotTraceValue: {}", columns); + } + return columns; + } + + @SuppressWarnings("unchecked") + private static Class loadAnnotationClass(String fullyQualifiedName) + throws ClassNotFoundException { + return (Class) Class.forName(fullyQualifiedName); + } + + private void collectFromClass( + Class clazz, + Set columns, + Class columnAnnotation, + Class primaryKeyAnnotation, + Class primaryKeyColumnAnnotation) { + for (Field field : clazz.getDeclaredFields()) { + if (field.getAnnotation(DoNotTraceValue.class) == null) { + continue; + } + var columnName = resolveColumnName(field, columnAnnotation, + primaryKeyAnnotation, primaryKeyColumnAnnotation); + if (columnName != null && !columnName.isBlank()) { + columns.add(columnName.toLowerCase()); + } + } + } + + private String resolveColumnName( + Field field, + Class columnAnnotation, + Class primaryKeyAnnotation, + Class primaryKeyColumnAnnotation) { + // @Column(value = "column_name") + var column = field.getAnnotation(columnAnnotation); + if (column != null) { + return invokeValue(column); + } + // @PrimaryKey(value = "column_name") for single-column PK + var primaryKey = field.getAnnotation(primaryKeyAnnotation); + if (primaryKey != null) { + return invokeValue(primaryKey); + } + // @PrimaryKeyColumn(name = "column_name") or value + var primaryKeyColumn = field.getAnnotation(primaryKeyColumnAnnotation); + if (primaryKeyColumn != null) { + var name = invokeName(primaryKeyColumn); + if (StringUtils.hasText(name)) { + return name; + } + return invokeValue(primaryKeyColumn); + } + // Fallback to field name (Java naming) + return field.getName(); + } + + private static String invokeValue(Object annotation) { + try { + var value = annotation.getClass().getMethod("value").invoke(annotation); + return value != null ? value.toString() : null; + } catch (ReflectiveOperationException e) { + return null; + } + } + + private static String invokeName(Object annotation) { + try { + var name = annotation.getClass().getMethod("name").invoke(annotation); + return name != null ? name.toString() : null; + } catch (ReflectiveOperationException e) { + return null; + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/redaction/RedactionProperties.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/redaction/RedactionProperties.java new file mode 100644 index 0000000000..6d86754386 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/redaction/RedactionProperties.java @@ -0,0 +1,85 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.redaction; + +import java.util.Collections; +import java.util.List; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Role; + +/** + * Configuration properties for span attribute redaction (secrets, passwords). + */ +@ConfigurationProperties(prefix = "management.tracing.redaction") +@Role(BeanDefinition.ROLE_INFRASTRUCTURE) +public class RedactionProperties { + + private boolean enabled = true; + + /** + * Cassandra-specific: column names to redact in db.query.text and + * db.query.parameter.* attributes. + */ + private Cassandra cassandra = new Cassandra(); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public Cassandra getCassandra() { + return cassandra; + } + + public void setCassandra(Cassandra cassandra) { + this.cassandra = cassandra; + } + + public static class Cassandra { + private List sensitiveColumns = Collections.emptyList(); + + /** + * Base package to scan for @Table classes with @DoNotTraceValue. + * When set and Spring Data Cassandra is on the classpath, discovered + * column names are merged with sensitive-columns. Default empty (no scan). + */ + private String scanPackage = ""; + + public List getSensitiveColumns() { + return sensitiveColumns; + } + + public void setSensitiveColumns(List sensitiveColumns) { + this.sensitiveColumns = sensitiveColumns != null ? + sensitiveColumns : Collections.emptyList(); + } + + public String getScanPackage() { + return scanPackage; + } + + public void setScanPackage(String scanPackage) { + this.scanPackage = StringUtils.isNotBlank(scanPackage) ? scanPackage : ""; + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/server/OtelReactiveServerRequestObservationConvention.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/server/OtelReactiveServerRequestObservationConvention.java new file mode 100644 index 0000000000..05ad2abcb6 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/server/OtelReactiveServerRequestObservationConvention.java @@ -0,0 +1,203 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.server; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.KeyValues; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Optional; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.server.reactive.observation.DefaultServerRequestObservationConvention; +import org.springframework.http.server.reactive.observation.ServerRequestObservationContext; + +/** + * Reactive (WebFlux) variant of {@link OtelServerRequestObservationConvention}: Emits OpenTelemetry + * semantic convention attribute names for HTTP spans and uses OTel-style contextual span names + * ({@code POST /v1/foo}) instead of Spring's default {@code http post /v1/foo}. This class + * overrides {@link #getContextualName} to follow that format so that traces generated + * using Spring Boot 3 + Micrometer do not require changes to the existing Lightstep dashboards. + * + * @see OtelServerRequestObservationConvention + */ +public class OtelReactiveServerRequestObservationConvention + extends DefaultServerRequestObservationConvention { + + private static final String HTTP_REQUEST_METHOD = "http.request.method"; + private static final String HTTP_RESPONSE_STATUS_CODE = "http.response.status_code"; + private static final String HTTP_STATUS_CODE_FAMILY = "http.status_code_family"; + private static final String HTTP_ROUTE = "http.route"; + private static final String URL_PATH = "url.path"; + private static final String URL_SCHEME = "url.scheme"; + private static final String USER_AGENT_ORIGINAL = "user_agent.original"; + private static final String NETWORK_PEER_ADDRESS = "network.peer.address"; + private static final String NETWORK_PEER_PORT = "network.peer.port"; + private static final String SERVER_ADDRESS = "server.address"; + private static final String SERVER_PORT = "server.port"; + private static final String CLIENT_ADDRESS = "client.address"; + private static final String EXCEPTION_TYPE = "exception.type"; + private static final String THREAD_ID = "thread.id"; + private static final String THREAD_NAME = "thread.name"; + + public OtelReactiveServerRequestObservationConvention() { + super(); + } + + /** + * Use OpenTelemetry HTTP span naming ({@code POST /api/items}) instead of Spring's default + * {@code http post /api/items}. + */ + @Override + public String getContextualName(ServerRequestObservationContext context) { + var request = context.getCarrier(); + if (request == null) { + return super.getContextualName(context); + } + var method = request.getMethod().name(); + var route = context.getPathPattern(); + if (StringUtils.isNotBlank(route)) { + return method + " " + route; + } + return method; + } + + @Override + public KeyValues getLowCardinalityKeyValues(ServerRequestObservationContext context) { + var request = context.getCarrier(); + var response = context.getResponse(); + var keyValues = new ArrayList(); + + keyValues.add(KeyValue.of(HTTP_REQUEST_METHOD, request.getMethod().name())); + + var httpStatus = response != null ? response.getStatusCode() : null; + if (httpStatus != null) { + int statusCode = httpStatus.value(); + keyValues.add(KeyValue.of(HTTP_RESPONSE_STATUS_CODE, String.valueOf(statusCode))); + keyValues.add(KeyValue.of(HTTP_STATUS_CODE_FAMILY, statusCodeFamily(statusCode))); + } else { + keyValues.add(KeyValue.of(HTTP_RESPONSE_STATUS_CODE, KeyValue.NONE_VALUE)); + keyValues.add(KeyValue.of(HTTP_STATUS_CODE_FAMILY, KeyValue.NONE_VALUE)); + } + + keyValues.add(KeyValue.of(HTTP_ROUTE, + Optional.ofNullable(context.getPathPattern()).orElse(KeyValue.NONE_VALUE))); + + keyValues.add(outcome(context)); + + var error = context.getError(); + if (error != null) { + keyValues.add(KeyValue.of(EXCEPTION_TYPE, error.getClass().getName())); + } else { + keyValues.add(KeyValue.of(EXCEPTION_TYPE, KeyValue.NONE_VALUE)); + } + + return KeyValues.of(keyValues); + } + + @Override + public KeyValues getHighCardinalityKeyValues(ServerRequestObservationContext context) { + var request = context.getCarrier(); + var keyValues = new ArrayList(); + + var path = request.getPath(); + if (path != null) { + keyValues.add(KeyValue.of(URL_PATH, path.toString())); + } + + var uri = request.getURI(); + if (uri != null && uri.getScheme() != null) { + keyValues.add(KeyValue.of(URL_SCHEME, uri.getScheme())); + } + + var userAgent = request.getHeaders().getFirst("User-Agent"); + if (StringUtils.isNotBlank(userAgent)) { + keyValues.add(KeyValue.of(USER_AGENT_ORIGINAL, userAgent)); + } + + var clientAddr = resolveClientAddress(request); + if (StringUtils.isNotBlank(clientAddr)) { + keyValues.add(KeyValue.of(NETWORK_PEER_ADDRESS, clientAddr)); + keyValues.add(KeyValue.of(CLIENT_ADDRESS, clientAddr)); + } + + var remote = request.getRemoteAddress(); + if (remote != null && remote.getPort() > 0) { + keyValues.add(KeyValue.of(NETWORK_PEER_PORT, String.valueOf(remote.getPort()))); + } + + var local = request.getLocalAddress(); + if (local != null) { + if (local.getHostString() != null && !local.getHostString().isBlank()) { + keyValues.add(KeyValue.of(SERVER_ADDRESS, local.getHostString())); + } + if (local.getPort() > 0) { + keyValues.add(KeyValue.of(SERVER_PORT, String.valueOf(local.getPort()))); + } + } + + var thread = Thread.currentThread(); + keyValues.add(KeyValue.of(THREAD_ID, String.valueOf(thread.threadId()))); + var threadName = thread.getName(); + if (StringUtils.isNotBlank(threadName)) { + keyValues.add(KeyValue.of(THREAD_NAME, threadName)); + } + + return KeyValues.of(keyValues); + } + + private static String resolveClientAddress(org.springframework.http.server.reactive.ServerHttpRequest request) { + var forwardedFor = request.getHeaders().getFirst("X-Forwarded-For"); + if (StringUtils.isNotBlank(forwardedFor)) { + var firstIp = Arrays.stream(forwardedFor.split(",")) + .map(String::trim) + .filter(s -> !s.isBlank()) + .findFirst(); + if (firstIp.isPresent()) { + return firstIp.get(); + } + } + var realIp = request.getHeaders().getFirst("X-Real-IP"); + if (StringUtils.isNotBlank(realIp)) { + return realIp.trim(); + } + var remote = request.getRemoteAddress(); + if (remote != null && remote.getAddress() != null) { + return remote.getAddress().getHostAddress(); + } + return null; + } + + private static String statusCodeFamily(int statusCode) { + if (statusCode >= 100 && statusCode < 200) { + return "1xx"; + } + if (statusCode >= 200 && statusCode < 300) { + return "2xx"; + } + if (statusCode >= 300 && statusCode < 400) { + return "3xx"; + } + if (statusCode >= 400 && statusCode < 500) { + return "4xx"; + } + if (statusCode >= 500 && statusCode < 600) { + return "5xx"; + } + return "other"; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/server/OtelReactiveTracingConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/server/OtelReactiveTracingConfiguration.java new file mode 100644 index 0000000000..35e695e7fc --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/server/OtelReactiveTracingConfiguration.java @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.server; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.server.reactive.observation.ServerRequestObservationConvention; + +/** + * Registers {@link OtelReactiveServerRequestObservationConvention} when Spring WebFlux is present + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnWebApplication(type = Type.REACTIVE) +public class OtelReactiveTracingConfiguration { + + @Bean + @ConditionalOnMissingBean(ServerRequestObservationConvention.class) + public ServerRequestObservationConvention otelReactiveServerRequestObservationConvention() { + return new OtelReactiveServerRequestObservationConvention(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/server/OtelServerRequestObservationConvention.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/server/OtelServerRequestObservationConvention.java new file mode 100644 index 0000000000..e45bda63ec --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/server/OtelServerRequestObservationConvention.java @@ -0,0 +1,233 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.server; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.KeyValues; +import jakarta.servlet.http.HttpServletRequest; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Optional; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.server.observation.DefaultServerRequestObservationConvention; +import org.springframework.http.server.observation.ServerRequestObservationContext; + +/** + * Custom {@link org.springframework.http.server.observation.ServerRequestObservationConvention} + * that emits OpenTelemetry semantic convention attribute names for HTTP spans, aligning with + * what the OpenTelemetry Java agent produces (e.g. http.request.method, url.path, http.route). + * + *

Spring's default {@link DefaultServerRequestObservationConvention} sets the observation + * contextual name to {@code http get /path} (literal prefix {@code http } plus lowercase verb). + * That becomes the OTLP span name and shows up in Lightstep as {@code Operation} like + * {@code http post /v1/foo}. The OpenTelemetry HTTP semantic conventions use + * {@code {METHOD} {http.route}} instead (e.g. {@code POST /v1/foo}), matching Java agent behavior. + * This class overrides {@link #getContextualName} to follow that format so that traces generated + * using Spring Boot 3 + Micrometer do not require changes to the existing Lightstep dashboards. + */ +public class OtelServerRequestObservationConvention + extends DefaultServerRequestObservationConvention { + + private static final String HTTP_REQUEST_METHOD = "http.request.method"; + private static final String HTTP_RESPONSE_STATUS_CODE = "http.response.status_code"; + private static final String HTTP_STATUS_CODE_FAMILY = "http.status_code_family"; + private static final String HTTP_ROUTE = "http.route"; + private static final String URL_PATH = "url.path"; + private static final String URL_SCHEME = "url.scheme"; + private static final String USER_AGENT_ORIGINAL = "user_agent.original"; + private static final String NETWORK_PEER_ADDRESS = "network.peer.address"; + private static final String NETWORK_PEER_PORT = "network.peer.port"; + private static final String SERVER_ADDRESS = "server.address"; + private static final String SERVER_PORT = "server.port"; + private static final String CLIENT_ADDRESS = "client.address"; + private static final String EXCEPTION_TYPE = "exception.type"; + private static final String THREAD_ID = "thread.id"; + private static final String THREAD_NAME = "thread.name"; + + public OtelServerRequestObservationConvention() { + super(); + } + + /** + * Use OpenTelemetry HTTP span naming ({@code POST /v1/nvct/tasks}) instead of Spring's default + * {@code http post /v1/nvct/tasks}, so backends such as Lightstep show Operations consistent + * with the OpenTelemetry Java agent. + */ + @Override + public String getContextualName(ServerRequestObservationContext context) { + var request = context.getCarrier(); + if (request == null) { + return super.getContextualName(context); + } + var method = request.getMethod(); + var route = context.getPathPattern(); + if (StringUtils.isNotBlank(route)) { + return method + " " + route; + } + return method; + } + + @Override + public KeyValues getLowCardinalityKeyValues(ServerRequestObservationContext context) { + var request = context.getCarrier(); + var response = context.getResponse(); + var keyValues = new ArrayList(); + + keyValues.add(KeyValue.of(HTTP_REQUEST_METHOD, request.getMethod())); + + if (response != null) { + var statusCode = response.getStatus(); + keyValues.add(KeyValue.of(HTTP_RESPONSE_STATUS_CODE, String.valueOf(statusCode))); + keyValues.add(KeyValue.of(HTTP_STATUS_CODE_FAMILY, statusCodeFamily(statusCode))); + } else { + keyValues.add(KeyValue.of(HTTP_RESPONSE_STATUS_CODE, KeyValue.NONE_VALUE)); + keyValues.add(KeyValue.of(HTTP_STATUS_CODE_FAMILY, KeyValue.NONE_VALUE)); + } + + keyValues.add(KeyValue.of(HTTP_ROUTE, + Optional.ofNullable(context.getPathPattern()).orElse(KeyValue.NONE_VALUE))); + + keyValues.add(outcome(context)); + + var error = context.getError(); + if (error != null) { + keyValues.add(KeyValue.of(EXCEPTION_TYPE, error.getClass().getName())); + } else { + keyValues.add(KeyValue.of(EXCEPTION_TYPE, KeyValue.NONE_VALUE)); + } + + return KeyValues.of(keyValues); + } + + @Override + public KeyValues getHighCardinalityKeyValues(ServerRequestObservationContext context) { + var request = context.getCarrier(); + var keyValues = new ArrayList(); + + var requestUri = request.getRequestURI(); + if (requestUri != null) { + keyValues.add(KeyValue.of(URL_PATH, requestUri)); + } + + var scheme = request.getScheme(); + if (scheme != null) { + keyValues.add(KeyValue.of(URL_SCHEME, scheme)); + } + + var userAgent = request.getHeader("User-Agent"); + if (StringUtils.isNotBlank(userAgent)) { + keyValues.add(KeyValue.of(USER_AGENT_ORIGINAL, userAgent)); + } + + var clientAddr = resolveClientAddress(request); + if (StringUtils.isNotBlank(clientAddr)) { + keyValues.add(KeyValue.of(NETWORK_PEER_ADDRESS, clientAddr)); + keyValues.add(KeyValue.of(CLIENT_ADDRESS, clientAddr)); + } + + var remotePort = request.getRemotePort(); + if (remotePort > 0) { + keyValues.add(KeyValue.of(NETWORK_PEER_PORT, String.valueOf(remotePort))); + } + + var serverAddr = resolveServerAddress(request); + if (StringUtils.isNotBlank(serverAddr)) { + keyValues.add(KeyValue.of(SERVER_ADDRESS, serverAddr)); + } + + var serverPort = request.getServerPort(); + if (serverPort > 0) { + keyValues.add(KeyValue.of(SERVER_PORT, String.valueOf(serverPort))); + } + + var thread = Thread.currentThread(); + keyValues.add(KeyValue.of(THREAD_ID, String.valueOf(thread.threadId()))); + var threadName = thread.getName(); + if (StringUtils.isNotBlank(threadName)) { + keyValues.add(KeyValue.of(THREAD_NAME, threadName)); + } + + return KeyValues.of(keyValues); + } + + /** + * Resolves the client IP address, preferring proxy headers when present. + * Checks X-Forwarded-For (first IP in the chain), then X-Real-IP, then the direct remote addr. + */ + private static String resolveClientAddress(HttpServletRequest request) { + var forwardedFor = request.getHeader("X-Forwarded-For"); + if (StringUtils.isNotBlank(forwardedFor)) { + var firstIp = Arrays.stream(forwardedFor.split(",")) + .map(String::trim) + .filter(s -> !s.isBlank()) + .findFirst(); + if (firstIp.isPresent()) { + return firstIp.get(); + } + } + var realIp = request.getHeader("X-Real-IP"); + if (StringUtils.isNotBlank(realIp)) { + return realIp.trim(); + } + return request.getRemoteAddr(); + } + + /** + * Resolves the server address/hostname, preferring proxy headers when present. + * Checks X-Forwarded-Host, then Host, then the request server name. + */ + private static String resolveServerAddress(HttpServletRequest request) { + var forwardedHost = request.getHeader("X-Forwarded-Host"); + if (StringUtils.isNotBlank(forwardedHost)) { + var firstHost = Arrays.stream(forwardedHost.split(",")) + .map(String::trim) + .filter(s -> !s.isBlank()) + .findFirst(); + if (firstHost.isPresent()) { + return firstHost.get(); + } + } + var host = request.getHeader("Host"); + if (StringUtils.isNotBlank(host)) { + var hostWithoutPort = host.split(":")[0].trim(); + if (!hostWithoutPort.isBlank()) { + return hostWithoutPort; + } + } + return request.getServerName(); + } + + private static String statusCodeFamily(int statusCode) { + if (statusCode >= 100 && statusCode < 200) { + return "1xx"; + } + if (statusCode >= 200 && statusCode < 300) { + return "2xx"; + } + if (statusCode >= 300 && statusCode < 400) { + return "3xx"; + } + if (statusCode >= 400 && statusCode < 500) { + return "4xx"; + } + if (statusCode >= 500 && statusCode < 600) { + return "5xx"; + } + return "other"; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/server/OtelServletTracingConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/server/OtelServletTracingConfiguration.java new file mode 100644 index 0000000000..48570d63c5 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/server/OtelServletTracingConfiguration.java @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.server; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.server.observation.ServerRequestObservationConvention; + +/** + * Registers {@link OtelServerRequestObservationConvention} when the Spring MVC servlet stack is + * present + * + *

This mirrors {@link OtelReactiveTracingConfiguration}, which registers the WebFlux convention when the application is reactive. + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnWebApplication(type = Type.SERVLET) +public class OtelServletTracingConfiguration { + + @Bean + @ConditionalOnMissingBean(ServerRequestObservationConvention.class) + public ServerRequestObservationConvention otelServerRequestObservationConvention() { + return new OtelServerRequestObservationConvention(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/stacktrace/ExceptionShorteningProcessor.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/stacktrace/ExceptionShorteningProcessor.java new file mode 100644 index 0000000000..1a0fe712b0 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/stacktrace/ExceptionShorteningProcessor.java @@ -0,0 +1,114 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.stacktrace; + +import io.opentelemetry.sdk.trace.data.EventData; +import io.opentelemetry.semconv.ExceptionAttributes; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.stream.Collectors; +import org.apache.commons.lang3.StringUtils; +import org.springframework.util.CollectionUtils; + +/** + * Shortens exception stack traces in span events by keeping only the first line through + * the last line matching tracked packages (e.g. com.nvidia) plus one line of context. + * + *

Example: a stack trace with com.nvidia, spring, and tomcat frames will be shortened + * to keep the exception line plus com.nvidia frames and one additional frame for context. + */ +class ExceptionShorteningProcessor { + + private static final String EXCEPTION_EVENT_NAME = "exception"; + + private final Collection packages; + private final boolean shortenExceptions; + + ExceptionShorteningProcessor(Collection packages, boolean shortenExceptions) { + this.packages = CollectionUtils.isEmpty(packages) + ? Collections.singleton("com.nvidia") : packages; + this.shortenExceptions = shortenExceptions; + } + + boolean shouldProcess(MutableSpanData span) { + return span.getEvents().stream() + .map(EventData::getName) + .anyMatch(EXCEPTION_EVENT_NAME::equals); + } + + void process(MutableSpanData span) { + var events = span.getEvents(); + var exceptionIndex = -1; + for (var i = 0; i < events.size(); i++) { + if (EXCEPTION_EVENT_NAME.equals(events.get(i).getName())) { + exceptionIndex = i; + break; + } + } + if (exceptionIndex < 0) { + return; + } + + var event = events.get(exceptionIndex); + var stack = event.getAttributes().get(ExceptionAttributes.EXCEPTION_STACKTRACE); + if (stack != null) { + var eventAttributesBuilder = event.getAttributes().toBuilder(); + eventAttributesBuilder.put(ExceptionAttributes.EXCEPTION_STACKTRACE, filterStack(stack)); + var modifiedAttributes = eventAttributesBuilder.build(); + var newEvent = EventData.create( + event.getEpochNanos(), event.getName(), modifiedAttributes); + var eventsCopy = new ArrayList(events); + eventsCopy.set(exceptionIndex, newEvent); + span.setEvents(eventsCopy); + } + + var attributesBuilder = span.getAttributes().toBuilder(); + var type = event.getAttributes().get(ExceptionAttributes.EXCEPTION_TYPE); + if (StringUtils.isNotBlank(type)) { + attributesBuilder.put(ExceptionAttributes.EXCEPTION_TYPE, type); + } + var message = event.getAttributes().get(ExceptionAttributes.EXCEPTION_MESSAGE); + if (StringUtils.isNotBlank(message)) { + attributesBuilder.put(ExceptionAttributes.EXCEPTION_MESSAGE, message); + } + span.setAttributes(attributesBuilder.build()); + } + + /** + * Keeps the first line (exception type + message) through the last tracked package's + * line plus one line for context. + */ + String filterStack(String stack) { + if (!shortenExceptions) { + return stack; + } + var lines = stack.lines().collect(Collectors.toList()); + var lastLineIndex = 1; + for (var i = lastLineIndex; i < lines.size(); i++) { + var line = lines.get(i); + if (packages.stream().noneMatch(line::contains)) { + lastLineIndex = i; + break; + } + } + return lines.stream() + .limit(lastLineIndex + 1L) + .collect(Collectors.joining(System.lineSeparator())); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/stacktrace/ExceptionShorteningProperties.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/stacktrace/ExceptionShorteningProperties.java new file mode 100644 index 0000000000..84f8f1440a --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/stacktrace/ExceptionShorteningProperties.java @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.stacktrace; + +import com.datastax.oss.driver.shaded.guava.common.collect.Sets; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Role; + +/** + * Configuration properties for exception shortening in span traces. + */ +@ConfigurationProperties(prefix = "management.tracing.exceptions") +@Role(BeanDefinition.ROLE_INFRASTRUCTURE) +public class ExceptionShorteningProperties { + + // Whether to shorten exception stack traces. + private boolean shorten = true; + + // Package prefixes to include in shortened stack traces (e.g. com.nvidia). + // Stack traces are truncated after the last line matching any of these packages. + private Set packages = Collections.singleton("com.nvidia"); + + public boolean shouldShorten() { + return shorten; + } + + public void setShorten(boolean shorten) { + this.shorten = shorten; + } + + public Set getPackages() { + return new HashSet<>(packages); + } + + public void setPackages(Set packages) { + this.packages = Sets.newHashSet(packages); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/stacktrace/ExceptionShorteningSpanExporter.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/stacktrace/ExceptionShorteningSpanExporter.java new file mode 100644 index 0000000000..99eb4fc7d2 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/stacktrace/ExceptionShorteningSpanExporter.java @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.stacktrace; + +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import java.util.Collection; + +/** + * SpanExporter that wraps a delegate and shortens exception stack traces in span events + * before export. Uses {@link ExceptionShorteningProcessor} to filter stack traces to + * tracked packages (e.g. com.nvidia) plus one line of context. + */ +class ExceptionShorteningSpanExporter implements SpanExporter { + + private final SpanExporter delegate; + private final ExceptionShorteningProcessor processor; + + ExceptionShorteningSpanExporter(SpanExporter delegate, + ExceptionShorteningProcessor processor) { + this.delegate = delegate; + this.processor = processor; + } + + @Override + public CompletableResultCode export(Collection spans) { + var processed = processSpans(spans); + return delegate.export(processed); + } + + @Override + public CompletableResultCode flush() { + return delegate.flush(); + } + + @Override + public CompletableResultCode shutdown() { + return delegate.shutdown(); + } + + private Collection processSpans(Collection spans) { + return spans.stream() + .map(this::processSpan) + .toList(); + } + + private SpanData processSpan(SpanData span) { + if (!processor.shouldProcess(new MutableSpanData(span))) { + return span; + } + var mutable = new MutableSpanData(span); + processor.process(mutable); + return mutable; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/stacktrace/ExceptionShorteningSpanExporterConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/stacktrace/ExceptionShorteningSpanExporterConfiguration.java new file mode 100644 index 0000000000..7ec67fcbb7 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/stacktrace/ExceptionShorteningSpanExporterConfiguration.java @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.stacktrace; + +import io.opentelemetry.sdk.trace.export.SpanExporter; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Role; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; + +/** + * Configuration that wraps SpanExporter beans with ExceptionShorteningSpanExporter + * when exception shortening is enabled. + * + *

Runs after AttributeRedactingSpanExporter (higher order) so exception shortening + * is the outermost wrapper. + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnProperty(name = "management.tracing.exceptions.shorten", havingValue = "true", + matchIfMissing = true) +@EnableConfigurationProperties(ExceptionShorteningProperties.class) +@Role(BeanDefinition.ROLE_INFRASTRUCTURE) +public class ExceptionShorteningSpanExporterConfiguration { + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE + 1) + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + static ExceptionShorteningSpanExporterBeanPostProcessor exceptionShorteningSpanExporterBeanPostProcessor( + ExceptionShorteningProperties properties) { + return new ExceptionShorteningSpanExporterBeanPostProcessor(properties); + } + + public static class ExceptionShorteningSpanExporterBeanPostProcessor implements BeanPostProcessor { + + private final ExceptionShorteningProperties properties; + + ExceptionShorteningSpanExporterBeanPostProcessor(ExceptionShorteningProperties properties) { + this.properties = properties; + } + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) + throws BeansException { + if (bean instanceof SpanExporter && !(bean instanceof ExceptionShorteningSpanExporter)) { + var processor = new ExceptionShorteningProcessor( + properties.getPackages(), + properties.shouldShorten()); + return new ExceptionShorteningSpanExporter((SpanExporter) bean, processor); + } + return bean; + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/stacktrace/MutableSpanData.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/stacktrace/MutableSpanData.java new file mode 100644 index 0000000000..89fef33859 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/java/com/nvidia/boot/observability/tracing/stacktrace/MutableSpanData.java @@ -0,0 +1,107 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.stacktrace; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.sdk.common.InstrumentationLibraryInfo; +import io.opentelemetry.sdk.common.InstrumentationScopeInfo; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.data.EventData; +import io.opentelemetry.sdk.trace.data.LinkData; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.data.StatusData; +import java.util.List; +import lombok.AccessLevel; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * Mutable implementation of SpanData for processing spans before export. + * Used by ExceptionShorteningSpanExporter to modify exception events. + */ +@Data +@NoArgsConstructor(access = AccessLevel.PACKAGE) +class MutableSpanData implements SpanData { + + @Getter(AccessLevel.NONE) + private boolean hasEnded; + private Resource resource; + private InstrumentationScopeInfo instrumentationScopeInfo; + private String name; + private SpanKind kind; + private long startEpochNanos; + private Attributes attributes; + private List events; + private List links; + private StatusData status; + private long endEpochNanos; + private SpanContext spanContext; + private SpanContext parentSpanContext; + + MutableSpanData(SpanData spanData) { + this.resource = spanData.getResource(); + this.instrumentationScopeInfo = spanData.getInstrumentationScopeInfo(); + this.name = spanData.getName(); + this.kind = spanData.getKind(); + this.startEpochNanos = spanData.getStartEpochNanos(); + this.attributes = spanData.getAttributes(); + this.events = spanData.getEvents(); + this.links = spanData.getLinks(); + this.status = spanData.getStatus(); + this.endEpochNanos = spanData.getEndEpochNanos(); + this.hasEnded = spanData.hasEnded(); + this.spanContext = spanData.getSpanContext(); + this.parentSpanContext = spanData.getParentSpanContext(); + } + + @Override + public boolean hasEnded() { + return hasEnded; + } + + @Override + public int getTotalRecordedEvents() { + return events.size(); + } + + @Override + public int getTotalRecordedLinks() { + return links.size(); + } + + @Override + public int getTotalAttributeCount() { + return attributes.size(); + } + + /** + * {@link SpanData} still requires this until OpenTelemetry removes the legacy API. Prefer + * {@link #getInstrumentationScopeInfo()} (stored field copied from the source span). + */ + @Override + @SuppressWarnings("deprecation") + public InstrumentationLibraryInfo getInstrumentationLibraryInfo() { + return InstrumentationLibraryInfo.create( + instrumentationScopeInfo.getName(), + instrumentationScopeInfo.getVersion(), + instrumentationScopeInfo.getSchemaUrl()); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/resources/META-INF/spring.factories b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000000..05cd142ff3 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/resources/META-INF/spring.factories @@ -0,0 +1,3 @@ +# Environment Post Processors +org.springframework.boot.EnvironmentPostProcessor=\ +com.nvidia.boot.observability.logging.LogbackEnvironmentPostProcessor diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/resources/META-INF/spring/org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration.imports b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/resources/META-INF/spring/org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration.imports new file mode 100644 index 0000000000..29d89b962b --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/resources/META-INF/spring/org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration.imports @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +com.nvidia.boot.observability.tracing.actuator.ServletManagementTracingConfiguration diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000000..a1659021c3 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +com.nvidia.boot.observability.ObservabilityAutoConfiguration +com.nvidia.boot.observability.tracing.client.OtelReactiveClientTracingConfiguration +com.nvidia.boot.observability.tracing.cassandra.CassandraTracingAutoConfiguration diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/resources/com/nvidia/boot/observability/logging/logback-boot-observability-defaults.xml b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/resources/com/nvidia/boot/observability/logging/logback-boot-observability-defaults.xml new file mode 100644 index 0000000000..fa46013a13 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/resources/com/nvidia/boot/observability/logging/logback-boot-observability-defaults.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + ${CONSOLE_LOG_PATTERN} + ${CONSOLE_LOG_CHARSET:-${file.encoding:-UTF-8}} + + + + + + diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/resources/com/nvidia/boot/observability/logging/logback-boot-observability.xml b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/resources/com/nvidia/boot/observability/logging/logback-boot-observability.xml new file mode 100644 index 0000000000..6e61bbb785 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/resources/com/nvidia/boot/observability/logging/logback-boot-observability.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/resources/nv-boot-observability-logback-defaults.properties b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/resources/nv-boot-observability-logback-defaults.properties new file mode 100644 index 0000000000..1b90403143 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/main/resources/nv-boot-observability-logback-defaults.properties @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Logback configuration for nv-boot-starter-observability (filtered stack traces) +logging.exception-conversion-word=%bootEx +logging.config=classpath:com/nvidia/boot/observability/logging/logback-boot-observability-defaults.xml diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/ActuatorTracingTestConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/ActuatorTracingTestConfiguration.java new file mode 100644 index 0000000000..8d04e7ce91 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/ActuatorTracingTestConfiguration.java @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability; + +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +/** + * Test configuration that provides an in-memory span exporter for asserting + * that actuator endpoints produce traces when using a separate management port. + */ +@Configuration +public class ActuatorTracingTestConfiguration { + + public static final InMemorySpanExporter SPAN_EXPORTER = InMemorySpanExporter.create(); + + @Bean + @Primary + public SpanExporter inMemorySpanExporter() { + return SPAN_EXPORTER; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/ReactiveActuatorTracingIntegrationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/ReactiveActuatorTracingIntegrationTest.java new file mode 100644 index 0000000000..64d6131c65 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/ReactiveActuatorTracingIntegrationTest.java @@ -0,0 +1,101 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.nvidia.boot.observability.tracing.actuator.ServletManagementTracingConfiguration; +import com.nvidia.boot.observability.tracing.client.OtelReactiveClientRequestObservationConvention; +import com.nvidia.boot.observability.tracing.server.OtelReactiveServerRequestObservationConvention; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalManagementPort; +import org.springframework.context.ApplicationContext; +import org.springframework.http.server.reactive.observation.ServerRequestObservationConvention; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.reactive.function.client.ClientRequestObservationConvention; + +@SpringBootTest( + classes = {TestApplication.class, ActuatorTracingTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "spring.profiles.active=test", + "spring.main.web-application-type=reactive", + "management.server.port=0", + "management.tracing.enabled=true", + "management.endpoints.web.exposure.include=*", + "spring.autoconfigure.exclude=org.springframework.boot.cassandra.autoconfigure.CassandraAutoConfiguration,org.springframework.boot.data.cassandra.autoconfigure.DataCassandraAutoConfiguration,org.springframework.boot.cassandra.autoconfigure.health.CassandraHealthContributorAutoConfiguration" + }) +class ReactiveActuatorTracingIntegrationTest { + + @LocalManagementPort + private int managementPort; + + @Autowired + private ApplicationContext context; + + @Test + @DisplayName("Reactive observation convention is loaded") + void reactiveObservationConventionIsLoaded() { + assertThat(context.getBeansOfType(ServerRequestObservationConvention.class).values()) + .hasAtLeastOneElementOfType(OtelReactiveServerRequestObservationConvention.class); + assertThat(context.getBeansOfType(ClientRequestObservationConvention.class).values()) + .hasAtLeastOneElementOfType(OtelReactiveClientRequestObservationConvention.class); + } + + @Test + @DisplayName("Servlet observation convention is not loaded") + void servletObservationConventionIsNotLoaded() { + assertThat(context.getBeansOfType( + org.springframework.http.server.observation.ServerRequestObservationConvention.class)).isEmpty(); + // not checking client requests because any webserver might use Blocking or Reactive web clients for outgoing traffic + // in the tests both will be present due to the test dependencies + } + + @Test + @DisplayName("Servlet management configuration is not loaded") + void servletManagementConfigurationIsNotLoaded() { + assertThat(context.getBeansOfType(ServletManagementTracingConfiguration.class)).isEmpty(); + } + + private WebTestClient managementClient() { + return WebTestClient.bindToServer() + .baseUrl("http://localhost:" + managementPort) + .build(); + } + + @Test + @DisplayName("Actuator health endpoint is available on management port") + void actuatorHealthEndpointIsAvailable() { + managementClient().get().uri("/actuator/health") + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$.status").isEqualTo("UP"); + } + + @Test + @DisplayName("Actuator metrics endpoint is available on management port") + void actuatorMetricsEndpointIsAvailable() { + managementClient().get().uri("/actuator/metrics") + .exchange() + .expectStatus().isOk(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/ServletActuatorTracingIntegrationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/ServletActuatorTracingIntegrationTest.java new file mode 100644 index 0000000000..9f4b370d0d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/ServletActuatorTracingIntegrationTest.java @@ -0,0 +1,112 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.nvidia.boot.observability.tracing.actuator.ServletManagementTracingConfiguration; +import com.nvidia.boot.observability.tracing.client.OtelBlockingClientRequestObservationConvention; +import com.nvidia.boot.observability.tracing.server.OtelServerRequestObservationConvention; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalManagementPort; +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpStatus; +import org.springframework.http.client.observation.ClientRequestObservationConvention; +import org.springframework.http.server.observation.ServerRequestObservationConvention; + +/** + * Integration tests verifying that actuator endpoints on the management port + * are reachable when using a separate management server (ServletManagementTracingConfiguration). + * + *

Full trace verification (span capture) should be covered by the app's + * integration test which uses a richer application context. + */ +@SpringBootTest( + classes = {TestApplication.class, ActuatorTracingTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "spring.profiles.active=test", + "spring.main.web-application-type=servlet", + "management.server.port=0", + "management.tracing.enabled=true", + "management.endpoints.web.exposure.include=*", + "spring.autoconfigure.exclude=org.springframework.boot.cassandra.autoconfigure.CassandraAutoConfiguration,org.springframework.boot.data.cassandra.autoconfigure.DataCassandraAutoConfiguration,org.springframework.boot.cassandra.autoconfigure.health.CassandraHealthContributorAutoConfiguration" + }) +@AutoConfigureTestRestTemplate +class ServletActuatorTracingIntegrationTest { + + @LocalManagementPort + private int managementPort; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private ApplicationContext context; + + private String actuatorUrl(String path) { + return "http://localhost:" + managementPort + path; + } + + @Test + @DisplayName("Servlet observation convention is loaded") + void servletObservationConventionIsLoaded() { + assertThat(context.getBeansOfType(ServerRequestObservationConvention.class).values()) + .hasAtLeastOneElementOfType(OtelServerRequestObservationConvention.class); + assertThat(context.getBeansOfType(ClientRequestObservationConvention.class).values()) + .hasAtLeastOneElementOfType(OtelBlockingClientRequestObservationConvention.class); + } + + @Test + @DisplayName("Reactive observation convention is not loaded") + void reactiveObservationConventionIsNotLoaded() { + assertThat(context.getBeansOfType( + org.springframework.http.server.reactive.observation.ServerRequestObservationConvention.class)).isEmpty(); + // not checking client requests because any webserver might use Blocking or Reactive web clients for outgoing traffic + // in the tests both will be present due to the test dependencies + } + + @Test + @DisplayName("Servlet management configuration is loaded") + void servletManagementConfigurationIsNotLoaded() { + assertThat(context.getBeansOfType(ServletManagementTracingConfiguration.class)).isNotEmpty(); + } + + @Test + @DisplayName("Actuator health endpoint is available on management port") + void actuatorHealthEndpointIsAvailable() { + var response = testRestTemplate.getForEntity( + actuatorUrl("/actuator/health"), + String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @DisplayName("Actuator metrics endpoint is available on management port") + void actuatorMetricsEndpointIsAvailable() { + var response = testRestTemplate.getForEntity( + actuatorUrl("/actuator/metrics"), + String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/TestApplication.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/TestApplication.java new file mode 100644 index 0000000000..206b398d9c --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/TestApplication.java @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Minimal test application for observability integration tests. + */ +@SpringBootApplication +public class TestApplication { + + public static void main(String[] args) { + SpringApplication.run(TestApplication.class, args); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/logging/LogbackEnvironmentPostProcessorTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/logging/LogbackEnvironmentPostProcessorTest.java new file mode 100644 index 0000000000..f4a4d7b300 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/logging/LogbackEnvironmentPostProcessorTest.java @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.logging; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.SpringApplication; +import org.springframework.core.Ordered; +import org.springframework.core.env.StandardEnvironment; + +class LogbackEnvironmentPostProcessorTest { + + @Test + void loadsLogbackPropertiesWhenPresent() { + var environment = new StandardEnvironment(); + var application = new SpringApplication(); + var processor = new LogbackEnvironmentPostProcessor(); + + processor.postProcessEnvironment(environment, application); + + assertThat(environment.getProperty("logging.config")) + .isEqualTo("classpath:com/nvidia/boot/observability/logging/logback-boot-observability-defaults.xml"); + assertThat(environment.getProperty("logging.exception-conversion-word")).isEqualTo("%bootEx"); + } + + @Test + void hasCorrectOrder() { + var processor = new LogbackEnvironmentPostProcessor(); + assertThat(processor.getOrder()).isEqualTo(Ordered.LOWEST_PRECEDENCE - 10); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/logging/StackFilteringThrowableConverterTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/logging/StackFilteringThrowableConverterTest.java new file mode 100644 index 0000000000..b91d115361 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/logging/StackFilteringThrowableConverterTest.java @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.logging; + +import static org.assertj.core.api.Assertions.assertThat; + +import ch.qos.logback.classic.spi.ThrowableProxy; +import org.junit.jupiter.api.Test; + +class StackFilteringThrowableConverterTest { + + @Test + void producesValidStackTrace() { + var converter = new TestableStackFilteringThrowableConverter(); + converter.start(); + + var throwable = new RuntimeException("test"); + var proxy = new ThrowableProxy(throwable); + + var result = converter.convert(proxy); + + assertThat(result).contains("RuntimeException: test"); + assertThat(result).contains("StackFilteringThrowableConverterTest"); + } + + @Test + void preservesNonFilteredFrames() { + var converter = new TestableStackFilteringThrowableConverter(); + converter.start(); + + var throwable = new RuntimeException("test"); + var proxy = new ThrowableProxy(throwable); + + var result = converter.convert(proxy); + + assertThat(result).contains("RuntimeException: test"); + assertThat(result).contains("StackFilteringThrowableConverterTest"); + } + + @Test + void filtersMatchingFrames() { + var converter = new TestableStackFilteringThrowableConverter(); + converter.start(); + + var throwable = new RuntimeException("test"); + var proxy = new ThrowableProxy(throwable); + + var result = converter.convert(proxy); + + assertThat(result).contains("RuntimeException: test"); + assertThat(result).doesNotContain("sun.reflect."); + assertThat(result).doesNotContain("java.lang.reflect."); + } + + /** + * Test subclass to expose protected throwableProxyToString for unit testing. + */ + private static class TestableStackFilteringThrowableConverter + extends StackFilteringThrowableConverter { + String convert(ch.qos.logback.classic.spi.IThrowableProxy tp) { + return throwableProxyToString(tp); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/metrics/MetricsConfigurationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/metrics/MetricsConfigurationTest.java new file mode 100644 index 0000000000..15825ba38d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/metrics/MetricsConfigurationTest.java @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.metrics; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.boot.micrometer.metrics.autoconfigure.MeterRegistryCustomizer; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class MetricsConfigurationTest { + + @Test + void addsCommonTagsToRegistry() { + var runner = new ApplicationContextRunner() + .withPropertyValues( + "management.metrics.tags.env=prod", + "management.metrics.tags.host_id=host-1", + "management.metrics.tags.host_dc=us-west-2") + .withUserConfiguration(MetricsConfiguration.class); + + runner.run(context -> { + var customizer = context.getBean(MeterRegistryCustomizer.class); + var registry = new SimpleMeterRegistry(); + customizer.customize(registry); + + registry.counter("test.counter").increment(); + + var counter = registry.find("test.counter").counter(); + assertThat(counter).isNotNull(); + assertThat(counter.getId().getTag("env")).isEqualTo("prod"); + assertThat(counter.getId().getTag("host_id")).isEqualTo("host-1"); + assertThat(counter.getId().getTag("host_dc")).isEqualTo("us-west-2"); + }); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/cassandra/CassandraTracingAutoConfigurationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/cassandra/CassandraTracingAutoConfigurationTest.java new file mode 100644 index 0000000000..a167d52067 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/cassandra/CassandraTracingAutoConfigurationTest.java @@ -0,0 +1,143 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.cassandra; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.CqlSessionBuilder; +import io.micrometer.observation.ObservationRegistry; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.cassandra.autoconfigure.CqlSessionBuilderCustomizer; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.data.cassandra.observability.ObservationRequestTracker; + +class CassandraTracingAutoConfigurationTest { + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withUserConfiguration(CassandraTracingAutoConfiguration.class); + + @Test + @DisplayName("Registers CqlSessionBuilderCustomizer when ObservationRequestTracker is on classpath") + void registersCustomizerWhenCassandraOnClasspath() { + runner.run(context -> { + assertThat(context).hasSingleBean(CqlSessionBuilderCustomizer.class); + + var customizer = context.getBean(CqlSessionBuilderCustomizer.class); + var builder = mock(CqlSessionBuilder.class); + when(builder.addRequestTracker(any())).thenReturn(builder); + + customizer.customize(builder); + + verify(builder).addRequestTracker(ObservationRequestTracker.INSTANCE); + }); + } + + @Test + @DisplayName("Wraps CqlSession with ObservableCqlSessionFactory by default (no CqlSessionObservabilityProperties bean declared)") + void wrapsCqlSessionWithObservabilityByDefault() { + var rawSession = mock(CqlSession.class); + var runnerWithSession = new ApplicationContextRunner() + .withUserConfiguration(CassandraTracingAutoConfiguration.class) + .withBean(CqlSession.class, () -> rawSession) + .withBean(ObservationRegistry.class, ObservationRegistry::create); + + runnerWithSession.run(context -> { + assertThat(context).hasBean("observableCqlSession"); + var session = context.getBean(CqlSession.class); + assertThat(session).isNotNull(); + assertThat(session) + .as("Default: observableCqlSession should be a wrapper, not the raw session") + .isNotSameAs(rawSession); + }); + } + + @Test + @DisplayName("Wraps CqlSession when CqlSessionObservabilityProperties bean is declared with enabled=true") + void wrapsWhenPropertiesBeanEnabledTrue() { + var rawSession = mock(CqlSession.class); + var runnerWithSession = new ApplicationContextRunner() + .withUserConfiguration(CassandraTracingAutoConfiguration.class) + .withBean(CqlSession.class, () -> rawSession) + .withBean(ObservationRegistry.class, ObservationRegistry::create) + .withBean( + CqlSessionObservabilityProperties.class, + CassandraTracingAutoConfigurationTest::enabledTrueProperties); + + runnerWithSession.run(context -> { + assertThat(context).hasBean("observableCqlSession"); + var session = context.getBean("observableCqlSession", CqlSession.class); + assertThat(session) + .as("enabled=true: observableCqlSession should still be a wrapper") + .isNotSameAs(rawSession); + }); + } + + @Test + @DisplayName("Returns raw session when CqlSessionObservabilityProperties bean has enabled=false (opt-out)") + void skipsWrapWhenPropertiesBeanEnabledFalse() { + var rawSession = mock(CqlSession.class); + var runnerWithSession = new ApplicationContextRunner() + .withUserConfiguration(CassandraTracingAutoConfiguration.class) + .withBean(CqlSession.class, () -> rawSession) + .withBean(ObservationRegistry.class, ObservationRegistry::create) + .withBean( + CqlSessionObservabilityProperties.class, + CassandraTracingAutoConfigurationTest::enabledFalseProperties); + + runnerWithSession.run(context -> { + assertThat(context).hasBean("observableCqlSession"); + var session = context.getBean("observableCqlSession", CqlSession.class); + assertThat(session) + .as("Opt-out: observableCqlSession should be the raw upstream CqlSession") + .isSameAs(rawSession); + }); + } + + @Test + @DisplayName("Does not create observableCqlSession when cassandraSession (RefreshingCqlSession) exists") + void doesNotCreateObservableCqlSessionWhenCassandraSessionExists() { + var rawSession = mock(CqlSession.class); + var runnerWithCassandraSession = new ApplicationContextRunner() + .withUserConfiguration(CassandraTracingAutoConfiguration.class) + .withBean("cassandraSession", CqlSession.class, () -> rawSession) + .withBean(ObservationRegistry.class, ObservationRegistry::create); + + runnerWithCassandraSession.run(context -> { + assertThat(context).doesNotHaveBean("observableCqlSession"); + assertThat(context.getBean(CqlSession.class)).isSameAs(rawSession); + }); + } + + private static CqlSessionObservabilityProperties enabledTrueProperties() { + var props = new CqlSessionObservabilityProperties(); + props.setEnabled(true); + return props; + } + + private static CqlSessionObservabilityProperties enabledFalseProperties() { + var props = new CqlSessionObservabilityProperties(); + props.setEnabled(false); + return props; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/cassandra/CassandraTracingIntegrationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/cassandra/CassandraTracingIntegrationTest.java new file mode 100644 index 0000000000..825b3a79de --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/cassandra/CassandraTracingIntegrationTest.java @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.cassandra; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.nvidia.boot.observability.TestApplication; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.cassandra.autoconfigure.CqlSessionBuilderCustomizer; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +/** + * Integration test verifying that CassandraTracingAutoConfiguration contributes + * a CqlSessionBuilderCustomizer bean when Spring Data Cassandra is on the classpath. + * + *

Cassandra connection auto-configurations are excluded to avoid requiring a + * real cluster; the test only verifies that our customizer bean is registered. + * + *

Coverage for the observableCqlSession bean (session wrapping) is in + * {@link CassandraTracingAutoConfigurationTest#wrapsCqlSessionWithObservabilityByDefault()}. + */ +@SpringBootTest(classes = TestApplication.class) +@TestPropertySource(properties = { + "spring.autoconfigure.exclude=" + + "org.springframework.boot.cassandra.autoconfigure.CassandraAutoConfiguration," + + "org.springframework.boot.data.cassandra.autoconfigure.DataCassandraAutoConfiguration," + + "org.springframework.boot.cassandra.autoconfigure.health.CassandraHealthContributorAutoConfiguration" +}) +class CassandraTracingIntegrationTest { + + @Autowired(required = false) + private CqlSessionBuilderCustomizer cassandraObservationRequestTrackerCustomizer; + + @Test + @DisplayName("Application context loads with Cassandra tracing customizer when Spring Data Cassandra on classpath") + void contextLoadsWithCassandraTracingCustomizer() { + assertThat(cassandraObservationRequestTrackerCustomizer).isNotNull(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/client/OtelBlockingClientRequestObservationConventionTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/client/OtelBlockingClientRequestObservationConventionTest.java new file mode 100644 index 0000000000..1958c2877b --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/client/OtelBlockingClientRequestObservationConventionTest.java @@ -0,0 +1,116 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.KeyValues; +import java.net.URI; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.client.ClientHttpRequest; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.http.client.observation.ClientRequestObservationContext; + +class OtelBlockingClientRequestObservationConventionTest { + + private final OtelBlockingClientRequestObservationConvention convention = new OtelBlockingClientRequestObservationConvention(); + + @Test + @DisplayName("Low cardinality includes http.request.method, server.address, http.route, http.response.status_code") + void lowCardinalityEmitsOtelKeys() throws Exception { + var request = mock(ClientHttpRequest.class); + when(request.getMethod()).thenReturn(HttpMethod.POST); + when(request.getURI()).thenReturn(URI.create("https://api.example.com:8443/v1/items")); + + var context = new ClientRequestObservationContext(request); + context.setUriTemplate("https://api.example.com:8443/v1/{id}"); + + var response = mock(ClientHttpResponse.class); + when(response.getStatusCode()).thenReturn(HttpStatus.CREATED); + context.setResponse(response); + + var map = toMap(convention.getLowCardinalityKeyValues(context)); + + assertThat(map).containsEntry("http.request.method", "POST"); + assertThat(map).containsEntry("server.address", "api.example.com"); + assertThat(map).containsEntry("server.port", "8443"); + assertThat(map).containsEntry("http.route", "/v1/{id}"); + assertThat(map).containsEntry("http.response.status_code", "201"); + assertThat(map).containsEntry("http.status_code_family", "2xx"); + } + + @Test + @DisplayName("High cardinality includes url.full") + void highCardinalityEmitsUrlFull() { + var request = mock(ClientHttpRequest.class); + when(request.getURI()).thenReturn(URI.create("https://api.example.com/v1/items?q=1")); + + var context = new ClientRequestObservationContext(request); + + var map = toMap(convention.getHighCardinalityKeyValues(context)); + + assertThat(map).containsEntry("url.full", "https://api.example.com/v1/items?q=1"); + } + + @Test + @DisplayName("Low cardinality always emits consistent tag keys even when route/response are absent") + void lowCardinalityAlwaysEmitsConsistentKeys() { + var request = mock(ClientHttpRequest.class); + when(request.getMethod()).thenReturn(HttpMethod.GET); + when(request.getURI()).thenReturn(URI.create("https://api.example.com/v1/items")); + + var context = new ClientRequestObservationContext(request); + + var map = toMap(convention.getLowCardinalityKeyValues(context)); + + var expectedKeys = Set.of( + "http.request.method", "server.address", "server.port", + "http.route", "http.response.status_code", "http.status_code_family", + "exception.type", "outcome"); + assertThat(map.keySet()).containsAll(expectedKeys); + + assertThat(map).containsEntry("http.route", "none"); + assertThat(map).containsEntry("http.response.status_code", "none"); + assertThat(map).containsEntry("http.status_code_family", "none"); + assertThat(map).containsEntry("exception.type", "none"); + } + + @Test + @DisplayName("Contextual name uses METHOD /route when uri template is set") + void contextualNameUsesMethodAndRoute() { + var request = mock(ClientHttpRequest.class); + when(request.getMethod()).thenReturn(HttpMethod.GET); + + var context = new ClientRequestObservationContext(request); + context.setUriTemplate("https://api.example.com/api/health"); + + assertThat(convention.getContextualName(context)).isEqualTo("GET /api/health"); + } + + private static java.util.Map toMap(KeyValues keyValues) { + return keyValues.stream().collect(Collectors.toMap(KeyValue::getKey, KeyValue::getValue)); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/client/OtelBlockingClientTracingConfigurationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/client/OtelBlockingClientTracingConfigurationTest.java new file mode 100644 index 0000000000..f6baa8e687 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/client/OtelBlockingClientTracingConfigurationTest.java @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.client; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.client.observation.ClientRequestObservationConvention; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +@SpringJUnitConfig(OtelBlockingClientTracingConfiguration.class) +class OtelBlockingClientTracingConfigurationTest { + + @Autowired + private ClientRequestObservationConvention clientRequestObservationConvention; + + @Test + void registersOtelBlockingClientConvention() { + assertThat(clientRequestObservationConvention).isInstanceOf(OtelBlockingClientRequestObservationConvention.class); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/client/OtelReactiveClientRequestObservationConventionTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/client/OtelReactiveClientRequestObservationConventionTest.java new file mode 100644 index 0000000000..ca576a8308 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/client/OtelReactiveClientRequestObservationConventionTest.java @@ -0,0 +1,110 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.KeyValues; +import java.net.URI; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.web.reactive.function.client.ClientRequest; +import org.springframework.web.reactive.function.client.ClientRequestObservationContext; +import org.springframework.web.reactive.function.client.ClientResponse; + +class OtelReactiveClientRequestObservationConventionTest { + + private final OtelReactiveClientRequestObservationConvention convention = new OtelReactiveClientRequestObservationConvention(); + + @Test + @DisplayName("Low cardinality includes http.request.method, server.address, http.route, http.response.status_code") + void lowCardinalityEmitsOtelKeys() { + var requestBuilder = ClientRequest.create(HttpMethod.PUT, URI.create("https://upstream.example.net:9000/v2/foo")); + var context = new ClientRequestObservationContext(requestBuilder); + context.setRequest(requestBuilder.build()); + context.setUriTemplate("https://upstream.example.net:9000/v2/{name}"); + + var response = mock(ClientResponse.class); + when(response.statusCode()).thenReturn(HttpStatus.NO_CONTENT); + context.setResponse(response); + + var map = toMap(convention.getLowCardinalityKeyValues(context)); + + assertThat(map).containsEntry("http.request.method", "PUT"); + assertThat(map).containsEntry("server.address", "upstream.example.net"); + assertThat(map).containsEntry("server.port", "9000"); + assertThat(map).containsEntry("http.route", "/v2/{name}"); + assertThat(map).containsEntry("http.response.status_code", "204"); + assertThat(map).containsEntry("http.status_code_family", "2xx"); + } + + @Test + @DisplayName("High cardinality includes url.full") + void highCardinalityEmitsUrlFull() { + var requestBuilder = ClientRequest.create(HttpMethod.GET, URI.create("https://upstream.example.net/path?x=y")); + var context = new ClientRequestObservationContext(requestBuilder); + context.setRequest(requestBuilder.build()); + + var map = toMap(convention.getHighCardinalityKeyValues(context)); + + assertThat(map).containsEntry("url.full", "https://upstream.example.net/path?x=y"); + } + + @Test + @DisplayName("Low cardinality always emits consistent tag keys even when route/response are absent") + void lowCardinalityAlwaysEmitsConsistentKeys() { + var requestBuilder = ClientRequest.create(HttpMethod.GET, URI.create("https://upstream.example.net/v2/foo")); + var context = new ClientRequestObservationContext(requestBuilder); + context.setRequest(requestBuilder.build()); + + var map = toMap(convention.getLowCardinalityKeyValues(context)); + + var expectedKeys = Set.of( + "http.request.method", "server.address", "server.port", + "http.route", "http.response.status_code", "http.status_code_family", + "exception.type", "outcome"); + assertThat(map.keySet()).containsAll(expectedKeys); + + assertThat(map).containsEntry("http.route", "none"); + assertThat(map).containsEntry("http.response.status_code", "none"); + assertThat(map).containsEntry("http.status_code_family", "none"); + assertThat(map).containsEntry("exception.type", "none"); + } + + @Test + @DisplayName("Contextual name uses METHOD /route when uri template is set") + void contextualNameUsesMethodAndRoute() { + var requestBuilder = ClientRequest.create(HttpMethod.DELETE, URI.create("https://upstream.example.net/r")); + var context = new ClientRequestObservationContext(requestBuilder); + context.setRequest(requestBuilder.build()); + context.setUriTemplate("https://upstream.example.net/r/{id}"); + + assertThat(convention.getContextualName(context)).isEqualTo("DELETE /r/{id}"); + } + + private static java.util.Map toMap(KeyValues keyValues) { + return keyValues.stream().collect(Collectors.toMap(KeyValue::getKey, KeyValue::getValue)); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/client/OtelReactiveClientTracingConfigurationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/client/OtelReactiveClientTracingConfigurationTest.java new file mode 100644 index 0000000000..23923201c6 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/client/OtelReactiveClientTracingConfigurationTest.java @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.client; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.web.reactive.function.client.ClientRequestObservationConvention; + +@SpringJUnitConfig(OtelReactiveClientTracingConfiguration.class) +class OtelReactiveClientTracingConfigurationTest { + + @Autowired + private ClientRequestObservationConvention clientRequestObservationConvention; + + @Test + void registersOtelReactiveClientConvention() { + assertThat(clientRequestObservationConvention).isInstanceOf(OtelReactiveClientRequestObservationConvention.class); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/redaction/AttributeRedactingSpanExporterTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/redaction/AttributeRedactingSpanExporterTest.java new file mode 100644 index 0000000000..57c6bf6a88 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/redaction/AttributeRedactingSpanExporterTest.java @@ -0,0 +1,173 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.redaction; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.testing.trace.TestSpanData; +import io.opentelemetry.sdk.trace.data.StatusData; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class AttributeRedactingSpanExporterTest { + + private InMemorySpanExporter delegate; + + @BeforeEach + void setUp() { + delegate = InMemorySpanExporter.create(); + } + + @Test + @DisplayName("Redacts sensitive column values in Cassandra db.query.text") + void redactsCassandraQueryText() { + var exporter = new AttributeRedactingSpanExporter(delegate, + Set.of("password_hash", "api_key")); + + var span = TestSpanData.builder() + .setName("cassandra.query") + .setKind(SpanKind.CLIENT) + .setStartEpochNanos(1000) + .setEndEpochNanos(2000) + .setStatus(StatusData.ok()) + .setHasEnded(true) + .setAttributes(Attributes.builder() + .put("db.system", "cassandra") + .put("db.query.text", "SELECT * FROM users WHERE password_hash = 'secret123' AND api_key = 'key456'") + .build()) + .build(); + + exporter.export(java.util.List.of(span)); + + var exported = delegate.getFinishedSpanItems(); + assertThat(exported).hasSize(1); + var attrs = exported.get(0).getAttributes(); + assertThat(attrs.get(AttributeKey.stringKey("db.query.text"))) + .isEqualTo("SELECT * FROM users WHERE password_hash = ? AND api_key = ?"); + } + + @Test + @DisplayName("Redacts sensitive column values in db.query.parameter.* attributes") + void redactsCassandraQueryParameters() { + var exporter = new AttributeRedactingSpanExporter(delegate, Set.of("secret_data")); + + var span = TestSpanData.builder() + .setName("cassandra.query") + .setKind(SpanKind.CLIENT) + .setStartEpochNanos(1000) + .setEndEpochNanos(2000) + .setStatus(StatusData.ok()) + .setHasEnded(true) + .setAttributes(Attributes.builder() + .put("db.system", "cassandra") + .put("db.query.parameter.1", "secret_data = 'my-secret-value'") + .build()) + .build(); + + exporter.export(java.util.List.of(span)); + + var exported = delegate.getFinishedSpanItems(); + assertThat(exported).hasSize(1); + assertThat(exported.get(0).getAttributes().get(AttributeKey.stringKey("db.query.parameter.1"))) + .isEqualTo("secret_data = ?"); + } + + @Test + @DisplayName("Passes through non-Cassandra spans unchanged") + void passesThroughNonCassandraSpans() { + var exporter = new AttributeRedactingSpanExporter(delegate, Set.of("password_hash")); + + var span = TestSpanData.builder() + .setName("http.get") + .setKind(SpanKind.SERVER) + .setStartEpochNanos(1000) + .setEndEpochNanos(2000) + .setStatus(StatusData.ok()) + .setHasEnded(true) + .setAttributes(Attributes.builder() + .put("http.request.method", "GET") + .put("url.path", "/api/users") + .build()) + .build(); + + exporter.export(java.util.List.of(span)); + + var exported = delegate.getFinishedSpanItems(); + assertThat(exported).hasSize(1); + assertThat(exported.get(0).getAttributes().get(AttributeKey.stringKey("url.path"))) + .isEqualTo("/api/users"); + } + + @Test + @DisplayName("Passes through Cassandra spans unchanged when no sensitive columns configured") + void passesThroughWhenNoSensitiveColumns() { + var exporter = new AttributeRedactingSpanExporter(delegate, Set.of()); + + var span = TestSpanData.builder() + .setName("cassandra.query") + .setKind(SpanKind.CLIENT) + .setStartEpochNanos(1000) + .setEndEpochNanos(2000) + .setStatus(StatusData.ok()) + .setHasEnded(true) + .setAttributes(Attributes.builder() + .put("db.system", "cassandra") + .put("db.query.text", "SELECT * FROM users WHERE id = 'u1'") + .build()) + .build(); + + exporter.export(java.util.List.of(span)); + + var exported = delegate.getFinishedSpanItems(); + assertThat(exported).hasSize(1); + assertThat(exported.get(0).getAttributes().get(AttributeKey.stringKey("db.query.text"))) + .isEqualTo("SELECT * FROM users WHERE id = 'u1'"); + } + + @Test + @DisplayName("Redaction is case-insensitive for column names") + void redactionCaseInsensitive() { + var exporter = new AttributeRedactingSpanExporter(delegate, Set.of("PASSWORD_HASH")); + + var span = TestSpanData.builder() + .setName("cassandra.query") + .setKind(SpanKind.CLIENT) + .setStartEpochNanos(1000) + .setEndEpochNanos(2000) + .setStatus(StatusData.ok()) + .setHasEnded(true) + .setAttributes(Attributes.builder() + .put("db.system", "cassandra") + .put("db.query.text", "SELECT * FROM users WHERE password_hash = 'secret'") + .build()) + .build(); + + exporter.export(java.util.List.of(span)); + + var exported = delegate.getFinishedSpanItems(); + assertThat(exported).hasSize(1); + assertThat(exported.get(0).getAttributes().get(AttributeKey.stringKey("db.query.text"))) + .isEqualTo("SELECT * FROM users WHERE password_hash = ?"); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/redaction/DoNotTraceValueScannerTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/redaction/DoNotTraceValueScannerTest.java new file mode 100644 index 0000000000..29b1b0a8de --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/redaction/DoNotTraceValueScannerTest.java @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.redaction; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class DoNotTraceValueScannerTest { + + private DoNotTraceValueScanner scanner; + + @BeforeEach + void setUp() { + scanner = new DoNotTraceValueScanner(); + } + + @Test + @DisplayName("Returns empty when base package is blank") + void returnsEmptyWhenPackageBlank() { + assertThat(scanner.scan("")).isEmpty(); + assertThat(scanner.scan(" ")).isEmpty(); + assertThat(scanner.scan(null)).isEmpty(); + } + + @Test + @DisplayName("Finds @DoNotTraceValue columns from @Table entities in scan package") + void findsDoNotTraceValueColumns() { + // Scan package containing TestTableEntity (in this test package) + var columns = scanner.scan("com.nvidia.boot.observability.tracing.redaction"); + assertThat(columns).contains("credentials", "secret_token"); + } + + @Test + @DisplayName("Returns empty when package has no @Table classes") + void returnsEmptyWhenNoTableClasses() { + var columns = scanner.scan("java.lang"); + assertThat(columns).isEmpty(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/redaction/RedactionIntegrationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/redaction/RedactionIntegrationTest.java new file mode 100644 index 0000000000..8020626298 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/redaction/RedactionIntegrationTest.java @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.redaction; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.opentelemetry.sdk.trace.export.SpanExporter; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.TestPropertySource; + +/** + * Integration test verifying that the application context loads successfully + * with attribute redaction enabled and that the SpanExporter is wrapped with + * AttributeRedactingSpanExporter when sensitive columns are configured. + */ +@SpringBootTest( + classes = {com.nvidia.boot.observability.TestApplication.class, + RedactionTestConfiguration.class}, + properties = "spring.main.allow-bean-definition-overriding=true") +@TestPropertySource(properties = { + "management.tracing.redaction.enabled=true", + "management.tracing.redaction.cassandra.sensitive-columns=password_hash,api_key", + "spring.autoconfigure.exclude=org.springframework.boot.cassandra.autoconfigure.CassandraAutoConfiguration,org.springframework.boot.data.cassandra.autoconfigure.DataCassandraAutoConfiguration,org.springframework.boot.cassandra.autoconfigure.health.CassandraHealthContributorAutoConfiguration" +}) +class RedactionIntegrationTest { + + @Autowired(required = false) + private SpanExporter spanExporter; + + @Test + @DisplayName("Application context loads with redaction config") + void contextLoadsWithRedactionConfig() { + assertThat(spanExporter).isNotNull(); + // SpanExporter is wrapped by ExceptionShorteningSpanExporter (outer) and + // AttributeRedactingSpanExporter (inner, when sensitive columns configured) + } +} + +@Configuration +class RedactionTestConfiguration { + + @Bean + RedactionVerificationBean redactionVerificationBean() { + return new RedactionVerificationBean(); + } +} + +class RedactionVerificationBean { +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/redaction/TestTableEntity.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/redaction/TestTableEntity.java new file mode 100644 index 0000000000..2e4cd9aa6a --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/redaction/TestTableEntity.java @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.redaction; + +import org.springframework.data.cassandra.core.mapping.Column; +import org.springframework.data.cassandra.core.mapping.PrimaryKey; +import org.springframework.data.cassandra.core.mapping.Table; + +/** + * Test entity for DoNotTraceValueScanner. Must be in a package that gets scanned. + */ +@Table("test_entities") +class TestTableEntity { + + @PrimaryKey("id") + private String id; + + @DoNotTraceValue + @Column("credentials") + private String credentials; + + @DoNotTraceValue + @Column("secret_token") + private String secretToken; + + @Column("public_name") + private String publicName; +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/server/OtelReactiveServerRequestObservationConventionTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/server/OtelReactiveServerRequestObservationConventionTest.java new file mode 100644 index 0000000000..204f9b69f2 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/server/OtelReactiveServerRequestObservationConventionTest.java @@ -0,0 +1,147 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.server; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.KeyValues; +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.http.server.reactive.observation.ServerRequestObservationContext; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.http.server.reactive.MockServerHttpResponse; + +class OtelReactiveServerRequestObservationConventionTest { + + private OtelReactiveServerRequestObservationConvention convention; + private MockServerHttpRequest request; + private ServerHttpResponse response; + private ServerRequestObservationContext context; + + @BeforeEach + void setUp() { + convention = new OtelReactiveServerRequestObservationConvention(); + request = MockServerHttpRequest.get("/api/health").build(); + response = new MockServerHttpResponse(); + context = new ServerRequestObservationContext(request, response, Map.of()); + } + + @Test + @DisplayName("Low cardinality key values include http.request.method") + void lowCardinalityIncludesHttpMethod() { + request = MockServerHttpRequest.get("/api/health").build(); + response = mock(ServerHttpResponse.class); + when(response.getStatusCode()).thenReturn(HttpStatus.OK); + context = new ServerRequestObservationContext(request, response, Map.of()); + + var keyValues = convention.getLowCardinalityKeyValues(context); + var map = toMap(keyValues); + + assertThat(map).containsEntry("http.request.method", "GET"); + assertThat(map).containsEntry("http.response.status_code", "200"); + assertThat(map).containsEntry("http.status_code_family", "2xx"); + } + + @Test + @DisplayName("Low cardinality records status from getStatusCode() even when the response is not yet committed (e.g., empty-body handler with 204)") + void lowCardinalityRecordsStatusForUncommittedResponse() { + request = MockServerHttpRequest.method(HttpMethod.DELETE, "/api/items/1").build(); + response = mock(ServerHttpResponse.class); + when(response.getStatusCode()).thenReturn(HttpStatus.NO_CONTENT); + context = new ServerRequestObservationContext(request, response, Map.of()); + + var keyValues = convention.getLowCardinalityKeyValues(context); + var map = toMap(keyValues); + + assertThat(map).containsEntry("http.response.status_code", "204"); + assertThat(map).containsEntry("http.status_code_family", "2xx"); + } + + @Test + @DisplayName("High cardinality key values include url.path") + void highCardinalityIncludesUrlPath() { + request = MockServerHttpRequest.get("https://example.com/api/health").build(); + response = new MockServerHttpResponse(); + context = new ServerRequestObservationContext(request, response, Map.of()); + + var keyValues = convention.getHighCardinalityKeyValues(context); + var map = toMap(keyValues); + + assertThat(map).containsEntry("url.path", request.getPath().toString()); + assertThat(map).containsEntry("url.scheme", "https"); + } + + @Test + @DisplayName("Contextual name follows OTel Java agent style (METHOD /route), not http lowercase") + void contextualNameMatchesOpenTelemetryHttpSpanNaming() { + request = MockServerHttpRequest.method(HttpMethod.POST, "/v1/nvct/tasks").build(); + response = new MockServerHttpResponse(); + context = new ServerRequestObservationContext(request, response, Collections.emptyMap()); + context.setPathPattern("/v1/nvct/tasks"); + + assertThat(convention.getContextualName(context)).isEqualTo("POST /v1/nvct/tasks"); + } + + @Test + @DisplayName("Contextual name is method only when route is unknown") + void contextualNameMethodOnlyWithoutRoute() { + request = MockServerHttpRequest.get("/raw/path").build(); + response = new MockServerHttpResponse(); + context = new ServerRequestObservationContext(request, response, Collections.emptyMap()); + + assertThat(convention.getContextualName(context)).isEqualTo("GET"); + } + + @Test + @DisplayName("Low cardinality always emits consistent tag keys even when route/status/error are absent") + void lowCardinalityAlwaysEmitsConsistentKeys() { + request = MockServerHttpRequest.get("/raw/path").build(); + response = mock(ServerHttpResponse.class); + when(response.getStatusCode()).thenReturn(null); + context = new ServerRequestObservationContext(request, response, Map.of()); + + var keyValues = convention.getLowCardinalityKeyValues(context); + var map = toMap(keyValues); + + var expectedKeys = Set.of( + "http.request.method", "http.response.status_code", "http.status_code_family", + "http.route", "outcome", "exception.type"); + assertThat(map.keySet()).containsAll(expectedKeys); + + assertThat(map).containsEntry("http.response.status_code", "none"); + assertThat(map).containsEntry("http.status_code_family", "none"); + assertThat(map).containsEntry("http.route", "none"); + assertThat(map).containsEntry("exception.type", "none"); + } + + private static Map toMap(KeyValues keyValues) { + return keyValues.stream() + .collect(Collectors.toMap(KeyValue::getKey, KeyValue::getValue)); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/server/OtelReactiveTracingConfigurationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/server/OtelReactiveTracingConfigurationTest.java new file mode 100644 index 0000000000..10be14f1f8 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/server/OtelReactiveTracingConfigurationTest.java @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.server; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.http.server.reactive.observation.ServerRequestObservationConvention; + +/** + * Ensures {@link OtelReactiveTracingConfiguration} registers {@link OtelReactiveServerRequestObservationConvention} + * in a reactive web application context. + */ +class OtelReactiveTracingConfigurationTest { + + private final ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner() + .withUserConfiguration(OtelReactiveTracingConfiguration.class); + + @Test + void registersOtelReactiveConvention() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(ServerRequestObservationConvention.class); + assertThat(context.getBean(ServerRequestObservationConvention.class)) + .isInstanceOf(OtelReactiveServerRequestObservationConvention.class); + }); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/server/OtelServerRequestObservationConventionTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/server/OtelServerRequestObservationConventionTest.java new file mode 100644 index 0000000000..d29326d449 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/server/OtelServerRequestObservationConventionTest.java @@ -0,0 +1,132 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.server; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.KeyValues; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.server.observation.ServerRequestObservationContext; + +class OtelServerRequestObservationConventionTest { + + private OtelServerRequestObservationConvention convention; + private HttpServletRequest request; + private HttpServletResponse response; + private ServerRequestObservationContext context; + + @BeforeEach + void setUp() { + convention = new OtelServerRequestObservationConvention(); + request = mock(HttpServletRequest.class); + response = mock(HttpServletResponse.class); + context = new ServerRequestObservationContext(request, response); + } + + @Test + @DisplayName("Low cardinality key values include http.request.method") + void lowCardinalityIncludesHttpMethod() { + when(request.getMethod()).thenReturn("GET"); + when(response.getStatus()).thenReturn(200); + + var keyValues = convention.getLowCardinalityKeyValues(context); + var map = toMap(keyValues); + + assertThat(map).containsEntry("http.request.method", "GET"); + assertThat(map).containsEntry("http.response.status_code", "200"); + assertThat(map).containsEntry("http.status_code_family", "2xx"); + } + + @Test + @DisplayName("Low cardinality records status from getStatus() even when the response is not yet committed (e.g., void handler with @ResponseStatus(NO_CONTENT))") + void lowCardinalityRecordsStatusForUncommittedResponse() { + when(request.getMethod()).thenReturn("DELETE"); + when(response.getStatus()).thenReturn(204); + + var keyValues = convention.getLowCardinalityKeyValues(context); + var map = toMap(keyValues); + + assertThat(map).containsEntry("http.response.status_code", "204"); + assertThat(map).containsEntry("http.status_code_family", "2xx"); + } + + @Test + @DisplayName("High cardinality key values include url.path") + void highCardinalityIncludesUrlPath() { + when(request.getRequestURI()).thenReturn("/api/health"); + when(request.getScheme()).thenReturn("https"); + + var keyValues = convention.getHighCardinalityKeyValues(context); + var map = toMap(keyValues); + + assertThat(map).containsEntry("url.path", "/api/health"); + assertThat(map).containsEntry("url.scheme", "https"); + } + + @Test + @DisplayName("Contextual name follows OTel Java agent style (METHOD /route), not http lowercase") + void contextualNameMatchesOpenTelemetryHttpSpanNaming() { + when(request.getMethod()).thenReturn("POST"); + context.setPathPattern("/v1/nvct/tasks"); + + assertThat(convention.getContextualName(context)).isEqualTo("POST /v1/nvct/tasks"); + } + + @Test + @DisplayName("Contextual name is method only when route is unknown") + void contextualNameMethodOnlyWithoutRoute() { + when(request.getMethod()).thenReturn("GET"); + + assertThat(convention.getContextualName(context)).isEqualTo("GET"); + } + + @Test + @DisplayName("Low cardinality always emits consistent tag keys even when route/status/error are absent") + void lowCardinalityAlwaysEmitsConsistentKeys() { + when(request.getMethod()).thenReturn("GET"); + var ctx = new ServerRequestObservationContext(request, null); + + var keyValues = convention.getLowCardinalityKeyValues(ctx); + var map = toMap(keyValues); + + var expectedKeys = Set.of( + "http.request.method", "http.response.status_code", "http.status_code_family", + "http.route", "outcome", "exception.type"); + assertThat(map.keySet()).containsAll(expectedKeys); + + assertThat(map).containsEntry("http.response.status_code", "none"); + assertThat(map).containsEntry("http.status_code_family", "none"); + assertThat(map).containsEntry("http.route", "none"); + assertThat(map).containsEntry("exception.type", "none"); + } + + private static Map toMap(KeyValues keyValues) { + return keyValues.stream() + .collect(Collectors.toMap(KeyValue::getKey, KeyValue::getValue)); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/server/OtelServletTracingConfigurationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/server/OtelServletTracingConfigurationTest.java new file mode 100644 index 0000000000..19ad6fd483 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/server/OtelServletTracingConfigurationTest.java @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.server; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.http.server.observation.ServerRequestObservationConvention; + +/** + * Ensures {@link OtelServletTracingConfiguration} registers {@link OtelServerRequestObservationConvention} + * in a servlet web application context. + */ +class OtelServletTracingConfigurationTest { + + private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner() + .withUserConfiguration(OtelServletTracingConfiguration.class); + + @Test + void registersOtelServletConvention() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(ServerRequestObservationConvention.class); + assertThat(context.getBean(ServerRequestObservationConvention.class)) + .isInstanceOf(OtelServerRequestObservationConvention.class); + }); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/stacktrace/ExceptionShorteningProcessorTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/stacktrace/ExceptionShorteningProcessorTest.java new file mode 100644 index 0000000000..f9ba2a6fc2 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/java/com/nvidia/boot/observability/tracing/stacktrace/ExceptionShorteningProcessorTest.java @@ -0,0 +1,129 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.observability.tracing.stacktrace; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.sdk.testing.trace.TestSpanData; +import io.opentelemetry.sdk.trace.data.EventData; +import io.opentelemetry.sdk.trace.data.StatusData; +import io.opentelemetry.semconv.ExceptionAttributes; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ExceptionShorteningProcessorTest { + + private static final String NEW_LINE = System.lineSeparator(); + private static final String DEFAULT_SHORT = "com.nvidia.exception asdf" + NEW_LINE + + "com.nvidia.exception2 asdf2" + NEW_LINE + + "not.nvidia.exception3 asdf3"; + private static final String DEFAULT_FULL = "com.nvidia.exception asdf" + NEW_LINE + + "com.nvidia.exception2 asdf2" + NEW_LINE + + "not.nvidia.exception3 asdf3" + NEW_LINE + + "really.not.nvidia.exception4" + NEW_LINE; + + @Test + void filterStackDefault() { + var processor = new ExceptionShorteningProcessor(null, true); + assertThat(processor.filterStack(DEFAULT_FULL)).isEqualTo(DEFAULT_SHORT); + } + + @Test + void filterStackDisabled() { + var processor = new ExceptionShorteningProcessor(null, false); + assertThat(processor.filterStack(DEFAULT_FULL)).isEqualTo(DEFAULT_FULL); + } + + @Test + void filterStackMultiplePackages() { + var packages = Arrays.asList("com.nvidia", "not.nvidia"); + var processor = new ExceptionShorteningProcessor(packages, true); + var input = "com.nvidia.exception asdf" + NEW_LINE + + "com.nvidia.exception2 asdf2" + NEW_LINE + + "not.nvidia.exception3 asdf3" + NEW_LINE + + "not2.nvidia.exception4" + NEW_LINE + + "not3.nvidia.exception5" + NEW_LINE; + var expected = "com.nvidia.exception asdf" + NEW_LINE + + "com.nvidia.exception2 asdf2" + NEW_LINE + + "not.nvidia.exception3 asdf3" + NEW_LINE + + "not2.nvidia.exception4"; + assertThat(processor.filterStack(input)).isEqualTo(expected); + } + + @Test + void shouldProcessReturnsFalseWhenNoExceptionEvent() { + var processor = new ExceptionShorteningProcessor(Collections.emptyList(), true); + var span = new MutableSpanData(TestSpanData.builder() + .setName("test") + .setKind(SpanKind.CLIENT) + .setStartEpochNanos(1234) + .setEndEpochNanos(12345) + .setStatus(StatusData.ok()) + .setHasEnded(true) + .build()); + assertThat(processor.shouldProcess(span)).isFalse(); + } + + @Test + void shouldProcessReturnsTrueWhenExceptionEventPresent() { + var processor = new ExceptionShorteningProcessor(Collections.emptyList(), true); + var span = new MutableSpanData(TestSpanData.builder() + .setName("test") + .setKind(SpanKind.CLIENT) + .setStartEpochNanos(1234) + .setEndEpochNanos(12345) + .setStatus(StatusData.ok()) + .setHasEnded(true) + .setEvents(List.of(EventData.create(0, "exception", Attributes.empty()))) + .build()); + assertThat(processor.shouldProcess(span)).isTrue(); + } + + @Test + void processShortensStackAndPromotesToSpanAttributes() { + var span = new MutableSpanData(TestSpanData.builder() + .setName("test") + .setKind(SpanKind.CLIENT) + .setStartEpochNanos(1234) + .setEndEpochNanos(12345) + .setStatus(StatusData.ok()) + .setHasEnded(true) + .setEvents(List.of(EventData.create(0, "exception", + Attributes.builder() + .put(ExceptionAttributes.EXCEPTION_TYPE, "type") + .put(ExceptionAttributes.EXCEPTION_MESSAGE, "message") + .put(ExceptionAttributes.EXCEPTION_STACKTRACE, DEFAULT_FULL) + .build()))) + .build()); + var processor = new ExceptionShorteningProcessor(List.of("com.nvidia"), true); + processor.process(span); + + assertThat(span.getEvents()).hasSize(1); + var event = span.getEvents().get(0); + assertThat(event.getName()).isEqualTo("exception"); + assertThat(event.getAttributes().get(ExceptionAttributes.EXCEPTION_STACKTRACE)) + .isEqualTo(DEFAULT_SHORT); + + assertThat(span.getAttributes().get(ExceptionAttributes.EXCEPTION_TYPE)).isEqualTo("type"); + assertThat(span.getAttributes().get(ExceptionAttributes.EXCEPTION_MESSAGE)).isEqualTo("message"); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/resources/application-test.yaml b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/resources/application-test.yaml new file mode 100644 index 0000000000..2d4ea0f1a7 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-observability/src/test/resources/application-test.yaml @@ -0,0 +1,14 @@ +spring: + main: + allow-bean-definition-overriding: true + +management: + tracing: + enabled: false # Overridden to true in test properties when needed + endpoints: + web: + exposure: + include: "*" + otlp: + tracing: + endpoint: https://dummy:8282 # Dummy endpoint when not using InMemorySpanExporter diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/BUILD.bazel b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/BUILD.bazel new file mode 100644 index 0000000000..733da3cc8d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/BUILD.bazel @@ -0,0 +1,72 @@ +load("//src/libraries/java/nv-boot-parent/tools/bazel:java.bzl", "nv_boot_library", "nv_boot_library_test") + +REGISTRIES_COMPILE_DEPS = [ + "//src/libraries/java/nv-boot-parent/nv-boot-starter-exceptions:nv_boot_starter_exceptions", + "@nv_third_party_deps//:com_fasterxml_jackson_core_jackson_annotations", + "@nv_third_party_deps//:com_github_ben_manes_caffeine_caffeine", + "@nv_third_party_deps//:com_github_ben_manes_caffeine_guava", + "@nv_third_party_deps//:com_google_guava_guava", + "@nv_third_party_deps//:com_nimbusds_nimbus_jose_jwt", + "@nv_third_party_deps//:commons_codec_commons_codec", + "@nv_third_party_deps//:io_micrometer_micrometer_commons", + "@nv_third_party_deps//:io_netty_netty_common", + "@nv_third_party_deps//:io_netty_netty_handler", + "@nv_third_party_deps//:io_netty_netty_transport", + "@nv_third_party_deps//:io_projectreactor_netty_reactor_netty_core", + "@nv_third_party_deps//:io_projectreactor_netty_reactor_netty_http", + "@nv_third_party_deps//:io_projectreactor_reactor_core", + "@nv_third_party_deps//:jakarta_annotation_jakarta_annotation_api", + "@nv_third_party_deps//:jakarta_validation_jakarta_validation_api", + "@nv_third_party_deps//:org_apache_commons_commons_lang3", + "@nv_third_party_deps//:org_apache_logging_log4j_log4j_api", + "@nv_third_party_deps//:org_jspecify_jspecify", + "@nv_third_party_deps//:org_slf4j_slf4j_api", + "@nv_third_party_deps//:org_springframework_boot_spring_boot", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_autoconfigure", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_jackson", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_jackson", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_security_oauth2_client", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_validation", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_webclient", + "@nv_third_party_deps//:org_springframework_cloud_spring_cloud_context", + "@nv_third_party_deps//:org_springframework_security_spring_security_config", + "@nv_third_party_deps//:org_springframework_security_spring_security_core", + "@nv_third_party_deps//:org_springframework_security_spring_security_oauth2_client", + "@nv_third_party_deps//:org_springframework_security_spring_security_oauth2_core", + "@nv_third_party_deps//:org_springframework_security_spring_security_oauth2_jose", + "@nv_third_party_deps//:org_springframework_security_spring_security_web", + "@nv_third_party_deps//:org_springframework_spring_beans", + "@nv_third_party_deps//:org_springframework_spring_context", + "@nv_third_party_deps//:org_springframework_spring_core", + "@nv_third_party_deps//:org_springframework_spring_web", + "@nv_third_party_deps//:org_springframework_spring_webflux", + "@nv_third_party_deps//:software_amazon_awssdk_regions", + "@nv_third_party_deps//:tools_jackson_core_jackson_core", + "@nv_third_party_deps//:tools_jackson_core_jackson_databind", +] + +nv_boot_library( + name = "nv_boot_starter_registries", + srcs = glob(["src/main/java/**/*.java"]), + resource_strip_prefix = "src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/resources", + resources = glob(["src/main/resources/**"]), + visibility = ["//visibility:public"], + deps = REGISTRIES_COMPILE_DEPS, +) + +nv_boot_library_test( + name = "tests", + srcs = glob(["src/test/java/**/*.java"]), + coverage_library = ":nv_boot_starter_registries", + resource_strip_prefix = "src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/resources", + resources = glob(["src/test/resources/**"]), + deps = [ + ":nv_boot_starter_registries", + "//src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test:nv_boot_mock_servers_test", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_webflux_test", + "@nv_third_party_deps//:org_wiremock_wiremock_standalone", + ] + REGISTRIES_COMPILE_DEPS, + size = "medium", + tags = ["exclusive"], + timeout = "moderate", +) diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/README.md b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/README.md new file mode 100644 index 0000000000..2836942d62 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/README.md @@ -0,0 +1,138 @@ +# NV Boot Starter Registries + +Container, Helm, Model, and Resource registry clients for Docker, ECR, NGC, Harbor, ACR (Azure), +OCI, Volcengine, and Artifactory. Validates artifact presence, fetches sizes, and resolves +pre-signed URLs. + +## Adding as a Dependency + +Add to your application's `pom.xml`: + +```xml + + + + com.nvidia.boot + nv-boot-bom + ${nv-boot.version} + pom + import + + + + + + + com.nvidia.boot + nv-boot-starter-registries + + +``` + +## Configuration + +Configure recognized registries under an app-specific path that is provided by +`RegistryConfigPathProvider` (e.g. `nvcf.registries` or `nvct.registries`): + +```yaml +nvcf: + registries: + recognized: + container: + ngc: + name: NGC Private Registry + hostname: nvcr.io + call-timeout: 30s + read-timeout: 30s + write-timeout: 30s + connection-timeout: 10s + credential-validation: + enabled: false # true by default -- impacts all the accounts + artifact-validation: + enabled: false # true by default -- impacts all the accounts + custom: + name: Custom Registry + hostname: custom.io + model: + ngc: + name: NGC Private Registry + hostname: api.ngc.nvidia.com + oauth2: + base-url: https://authn.nvidia.com/token + group-scope: org-id + # Similar for helm, resource +``` + +By default artifacts are validated when a new function/task is created. However, it can be +turned off on per-registry basis as shown above. This impacts all the accounts/tenants +that are using the registry. + +By default credentials are validated when a new registry-credential is created or updated. +However, it can be turned off on per-registry basis as shown above. This impacts all the +accounts/tenants that are using the registry. + +## Required App Provided Beans + +The starter library relies on the app to register the following bean(s) with Spring application +context to be able to use this library: + +| Bean | Type | Description | +|------|------|-------------| +| `RegistryConfigPathProvider` | `RegistryConfigPathProvider` | **Required.** Returns the base config path for registry properties (e.g. `"nvcf.registries"`). | + +Here is a snippet showing how an app can register `RegistryConfigPathProvider` with +Spring application context: + +```java +@Configuration +public class RegistryConfig { + + @Bean + public RegistryConfigPathProvider registryConfigPathProvider() { + return () -> "nvcf.registries"; + } +} +``` + +## Auto Configured Beans - Library Provided + +Using the app registered bean(s) shown above, the library autoconfigures and registers +following beans with the Spring application context: + +| Bean | Type | Description | +|------|------|------------------------------------------------------------------------------------| +| `modelRegistryService` | `ModelRegistryService` | Validates model artifacts, fetches sizes, resolves pre-signed URLs for models. | +| `resourceRegistryService` | `ResourceRegistryService` | Same for resources. | +| `helmRegistryService` | `HelmRegistryService` | Validates Helm charts (OCI, Docker, ECR, NGC, Harbor, ACR, Volcengine, Artifactory). | +| `containerRegistryService` | `ContainerRegistryService` | Validates container images across supported registries. | +| `registryLookupService` | `RegistryLookupService` | Maps registry hostnames to names and vice versa. | +| `registryMapperService` | `RegistryMapperService` | Maps hostnames, encodes/decodes credentials, URL helpers. | +| `registryConfigurationProperties` | `RegistryConfigurationProperties` | Bound registry configuration. | + +The app can then inject the library configured and registered beans as shown below: + +```java +@Service +@RequiredArgsConstructor +public class MyArtifactService { + + private final ModelRegistryService modelRegistryService; + private final ContainerRegistryService containerRegistryService; + private final RegistryLookupService registryLookupService; + + public void validateModel(String artifactUrl, String apiKey) { + modelRegistryService.validateArtifact(artifactUrl, apiKey); + } + + public void validateContainer(String imageUrl, String base64Secret) { + containerRegistryService.validateArtifact(imageUrl, base64Secret); + } + + public String getRegistryName(String hostname) { + return registryLookupService.getModelRegistryNameByHostname(hostname); + } +} +``` + +All service beans are `@RefreshScope` and will refresh when configuration changes +(e.g. via Spring Cloud Config or reloadable properties). diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/pom.xml b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/pom.xml new file mode 100644 index 0000000000..7f4b94ea62 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/pom.xml @@ -0,0 +1,138 @@ + + + + + 4.0.0 + + com.nvidia.boot + nv-boot-parent + 0.0.1-SNAPSHOT + + + nv-boot-starter-registries + jar + NV Boot Starter Registries + Container, Helm, Model, and Resource registry clients (Docker, ECR, NGC, Harbor, + ACR, OCI, Volcengine, Artifactory) + + + 2.40.1 + + + + + com.nvidia.boot + nv-boot-starter-exceptions + ${project.version} + + + + org.springframework + spring-web + + + org.springframework + spring-context + + + org.springframework + spring-webflux + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework.boot + spring-boot-starter-security-oauth2-client + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.cloud + spring-cloud-context + + + org.springframework.boot + spring-boot-starter-jackson + + + org.springframework.boot + spring-boot-webclient + + + com.github.ben-manes.caffeine + guava + + + org.apache.commons + commons-lang3 + + + commons-codec + commons-codec + + + software.amazon.awssdk + regions + ${awssdk-java.version} + + + io.projectreactor.netty + reactor-netty-http + + + org.projectlombok + lombok + true + + + org.apache.logging.log4j + log4j-api + + + org.slf4j + slf4j-api + + + jakarta.annotation + jakarta.annotation-api + + + jakarta.validation + jakarta.validation-api + + + + + org.springframework.boot + spring-boot-starter-webflux-test + test + + + com.nvidia.boot + nv-boot-mock-servers-test + ${project.version} + test + + + diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/ConditionalOnRegistryKey.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/ConditionalOnRegistryKey.java new file mode 100644 index 0000000000..8dff6e7ed0 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/ConditionalOnRegistryKey.java @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.configurations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.context.annotation.Conditional; + +/** + * Conditional annotation that checks if a specific registry key exists in the configuration map, + * to allows dynamic registry config location. + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Conditional(RegistryKeyCondition.class) +@ConditionalOnBean(RegistryConfigurationProperties.class) +public @interface ConditionalOnRegistryKey { + + /** + * The registry type to check (e.g., "container", "model", "resource", "helm") + */ + String registryType(); + + /** + * The registry key to check for (e.g., "ngc", "custom-registry") + */ + String key(); + + /** + * Whether to require that the registry has a hostname configured. + * Defaults to true since a registry without a hostname is typically not usable. + */ + boolean requireHostname() default true; +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/ContainerRegistryAutoConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/ContainerRegistryAutoConfiguration.java new file mode 100644 index 0000000000..964b54dd07 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/ContainerRegistryAutoConfiguration.java @@ -0,0 +1,288 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.configurations; + +import static com.nvidia.boot.registries.util.RegistriesConstants.ACR_REGISTRY_KEY; +import static com.nvidia.boot.registries.util.RegistriesConstants.ARTIFACTORY_REGISTRY_KEY; +import static com.nvidia.boot.registries.util.RegistriesConstants.DOCKER_REGISTRY_KEY; +import static com.nvidia.boot.registries.util.RegistriesConstants.ECR_PRIVATE_REGISTRY_KEY; +import static com.nvidia.boot.registries.util.RegistriesConstants.ECR_PUBLIC_REGISTRY_KEY; +import static com.nvidia.boot.registries.util.RegistriesConstants.HARBOR_REGISTRY_KEY; +import static com.nvidia.boot.registries.util.RegistriesConstants.NGC_PRIVATE_REGISTRY_KEY; +import static com.nvidia.boot.registries.util.RegistriesConstants.VOLCENGINE_REGISTRY_KEY; + +import com.nvidia.boot.registries.configurations.RegistryConfigurationProperties.OAuth2Configuration; +import com.nvidia.boot.registries.service.registry.client.acr.AzureRegistryClient; +import com.nvidia.boot.registries.service.registry.client.artifactory.ArtifactoryClient; +import com.nvidia.boot.registries.service.registry.client.docker.DockerRegistryClient; +import com.nvidia.boot.registries.service.registry.client.ecr.pub.EcrPublicContainerRegistryClient; +import com.nvidia.boot.registries.service.registry.client.ecr.pvt.EcrPrivateContainerRegistryClient; +import com.nvidia.boot.registries.service.registry.client.harbor.HarborRegistryClient; +import com.nvidia.boot.registries.service.registry.client.ngc.NgcContainerRegistryClient; +import com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineContainerRegistryClient; +import com.nvidia.boot.registries.service.registry.container.ContainerRegistry; +import com.nvidia.boot.registries.service.registry.container.acr.AcrContainerRegistry; +import com.nvidia.boot.registries.service.registry.container.artifactory.ArtifactoryContainerRegistry; +import com.nvidia.boot.registries.service.registry.container.custom.CustomContainerRegistry; +import com.nvidia.boot.registries.service.registry.container.docker.DockerContainerRegistry; +import com.nvidia.boot.registries.service.registry.container.ecr.pub.EcrPublicContainerRegistry; +import com.nvidia.boot.registries.service.registry.container.ecr.pvt.EcrPrivateContainerRegistry; +import com.nvidia.boot.registries.service.registry.container.harbor.HarborContainerRegistry; +import com.nvidia.boot.registries.service.registry.container.ngc.NgcContainerRegistry; +import com.nvidia.boot.registries.service.registry.container.volcengine.VolcengineContainerRegistry; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.util.CollectionUtils; +import org.springframework.web.reactive.function.client.WebClient; + +@AutoConfiguration +@AutoConfigureAfter(RegistryConfigurationPropertiesAutoConfiguration.class) +public class ContainerRegistryAutoConfiguration { + + private static final Set KNOWN_CONTAINER_REGISTRIES = + Set.of(NGC_PRIVATE_REGISTRY_KEY, + DOCKER_REGISTRY_KEY, + ECR_PRIVATE_REGISTRY_KEY, + ECR_PUBLIC_REGISTRY_KEY, + ACR_REGISTRY_KEY, + ARTIFACTORY_REGISTRY_KEY, + VOLCENGINE_REGISTRY_KEY, + HARBOR_REGISTRY_KEY); + + @Bean + @RefreshScope + public List customContainerRegistries( + RegistryConfigurationProperties properties) { + var containerRegistriesMap = properties.getRecognized().getContainer(); + var customContainerRegistries = new ArrayList(); + + var entries = containerRegistriesMap.entrySet(); + for (var entry: entries) { + var key = entry.getKey(); + if (KNOWN_CONTAINER_REGISTRIES.contains(key)) { + continue; + } + + entry.getValue().getArtifactValidation().setEnabled(false); + entry.getValue().getCredentialValidation().setEnabled(false); + var registry = new CustomContainerRegistry(entry.getValue().getName(), + entry.getValue().getHostname()); + customContainerRegistries.add(registry); + } + + return customContainerRegistries; + } + + /** + * Consolidated list of all container registries (known + custom) for injection into + * ContainerRegistryService and RegistryLookupService. Spring injects + * {@code List} by collecting individual {@code ContainerRegistry} beans; + * it does not include beans of type {@code List}. This bean merges both + * sources so consumers receive the full set. + * + *

+ * Individual {@code ContainerRegistry} beans for known container registries such as ngc, + * docker, etc. should be in the context so that Spring can collect them and inject a list of + * knownContainerRegistries when calling this method. However, if there are no individual + * {@code ContainerRegistry} registry beans, then Spring cannot build a list. So, Spring + * looks for factory methods with return type {@code List} and the only + * candidates are containerRegistries(the bean currently being created) and + * customContainerRegistries factory methods in this class. If Spring chooses + * containerRegistries factory method to satisfy knownContainerRegistries, it results in the + * following circular dependency - + * Creating containerRegistries → needs knownContainerRegistries → + * resolves to containerRegistries → circular dependency + * and causes BeanCurrentlyInCreationException: the bean being created is requested again + * while it is still in creation. + * This is why we define factory methods to add dummy ContainerRegistry beans in the + * integration tests. + *

+ */ + @Bean + @RefreshScope + @Primary + public List containerRegistries( + List knownContainerRegistries, + @Qualifier("customContainerRegistries") + List customContainerRegistries) { + if (CollectionUtils.isEmpty(customContainerRegistries)) { + return knownContainerRegistries; + } + + var combined = new ArrayList(knownContainerRegistries); + combined.addAll(customContainerRegistries); + return combined; + } + + @Bean + @RefreshScope + @ConditionalOnRegistryKey(registryType = "container", key = NGC_PRIVATE_REGISTRY_KEY) + public ContainerRegistry ngcContainerRegistry( + RegistryConfigurationProperties registryConfigurationProperties, + WebClient.Builder webClientBuilder) { + var ngcConfig = registryConfigurationProperties.getRecognized() + .getContainer() + .get(NGC_PRIVATE_REGISTRY_KEY); + var ngcContainerRegistryClient = new NgcContainerRegistryClient( + webClientBuilder, + ngcConfig.getHostname(), + ngcConfig.getCallTimeout(), + ngcConfig.getReadTimeout(), + ngcConfig.getWriteTimeout(), + ngcConfig.getConnectionTimeout()); + return new NgcContainerRegistry(ngcContainerRegistryClient); + } + + @Bean + @RefreshScope + @ConditionalOnRegistryKey(registryType = "container", key = DOCKER_REGISTRY_KEY) + public ContainerRegistry dockerContainerRegistry( + RegistryConfigurationProperties registryConfigurationProperties, + WebClient.Builder webClientBuilder) { + var dockerConfig = registryConfigurationProperties + .getRecognized() + .getContainer() + .get(DOCKER_REGISTRY_KEY); + var dockerRegistryClient = + new DockerRegistryClient(webClientBuilder, + dockerConfig.getHostname(), + dockerConfig.getCallTimeout(), + dockerConfig.getOauth2().getBaseUrl(), + dockerConfig.getOauth2().getGroupScope()); + return new DockerContainerRegistry(dockerRegistryClient); + } + + @Bean + @RefreshScope + @ConditionalOnRegistryKey(registryType = "container", key = ECR_PRIVATE_REGISTRY_KEY) + public ContainerRegistry ecrPrivateContainerRegistry( + RegistryConfigurationProperties registryConfigurationProperties, + WebClient.Builder webClientBuilder) { + var ecrConfig = registryConfigurationProperties + .getRecognized() + .getContainer() + .get(ECR_PRIVATE_REGISTRY_KEY); + var ecrPrivateContainerRegistryClient = new EcrPrivateContainerRegistryClient( + webClientBuilder, + ecrConfig.getHostname(), + ecrConfig.getCallTimeout()); + return new EcrPrivateContainerRegistry(ecrPrivateContainerRegistryClient); + } + + @Bean + @RefreshScope + @ConditionalOnRegistryKey(registryType = "container", key = ECR_PUBLIC_REGISTRY_KEY) + public ContainerRegistry ecrPublicContainerRegistry( + RegistryConfigurationProperties registryConfigurationProperties, + WebClient.Builder webClientBuilder) { + var ecrConfig = registryConfigurationProperties + .getRecognized() + .getContainer() + .get(ECR_PUBLIC_REGISTRY_KEY); + var ecrPublicContainerRegistryClient = new EcrPublicContainerRegistryClient( + webClientBuilder, + ecrConfig.getHostname(), + ecrConfig.getCallTimeout()); + return new EcrPublicContainerRegistry(ecrPublicContainerRegistryClient); + } + + @Bean + @RefreshScope + @ConditionalOnRegistryKey(registryType = "container", key = VOLCENGINE_REGISTRY_KEY) + public ContainerRegistry volcengineContainerRegistry( + RegistryConfigurationProperties registryConfigurationProperties, + WebClient.Builder webClientBuilder) { + var volcengineConfig = registryConfigurationProperties + .getRecognized() + .getContainer() + .get(VOLCENGINE_REGISTRY_KEY); + var volcengineContainerRegistryClient = new VolcengineContainerRegistryClient( + webClientBuilder, + volcengineConfig.getHostname(), + volcengineConfig.getCallTimeout()); + return new VolcengineContainerRegistry(volcengineContainerRegistryClient); + } + + @Bean + @RefreshScope + @ConditionalOnRegistryKey(registryType = "container", key = ACR_REGISTRY_KEY) + public ContainerRegistry azureContainerRegistry( + RegistryConfigurationProperties registryConfigurationProperties, + WebClient.Builder webClientBuilder) { + var acrConfig = registryConfigurationProperties + .getRecognized() + .getContainer() + .get(ACR_REGISTRY_KEY); + var azureRegistryClient = new AzureRegistryClient( + webClientBuilder, + acrConfig.getHostname(), + acrConfig.getCallTimeout(), + Optional.ofNullable(acrConfig.getOauth2()) + .map(OAuth2Configuration::getBaseUrl) + .orElse(null)); + return new AcrContainerRegistry(azureRegistryClient); + } + + @Bean + @RefreshScope + @ConditionalOnRegistryKey(registryType = "container", key = HARBOR_REGISTRY_KEY) + public ContainerRegistry harborContainerRegistry( + RegistryConfigurationProperties registryConfigurationProperties, + WebClient.Builder webClientBuilder) { + var harborConfig = registryConfigurationProperties + .getRecognized() + .getContainer() + .get(HARBOR_REGISTRY_KEY); + var harborRegistryClient = new HarborRegistryClient( + webClientBuilder, + harborConfig.getHostname(), + harborConfig.getCallTimeout(), + Optional.ofNullable(harborConfig.getOauth2()) + .map(OAuth2Configuration::getBaseUrl) + .orElse(null)); + return new HarborContainerRegistry(harborRegistryClient); + } + + @Bean + @RefreshScope + @ConditionalOnRegistryKey(registryType = "container", key = ARTIFACTORY_REGISTRY_KEY) + public ContainerRegistry artifactoryContainerRegistry( + RegistryConfigurationProperties registryConfigurationProperties, + WebClient.Builder webClientBuilder) { + var artifactoryConfig = registryConfigurationProperties + .getRecognized() + .getContainer() + .get(ARTIFACTORY_REGISTRY_KEY); + var artifactoryClient = new ArtifactoryClient( + webClientBuilder, + artifactoryConfig.getHostname(), + artifactoryConfig.getCallTimeout(), + Optional.ofNullable(artifactoryConfig.getOauth2()) + .map(OAuth2Configuration::getBaseUrl) + .orElse(null)); + return new ArtifactoryContainerRegistry(artifactoryClient); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/HelmRegistryAutoConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/HelmRegistryAutoConfiguration.java new file mode 100644 index 0000000000..bd8aeed993 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/HelmRegistryAutoConfiguration.java @@ -0,0 +1,286 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.configurations; + +import static com.nvidia.boot.registries.util.RegistriesConstants.ACR_REGISTRY_KEY; +import static com.nvidia.boot.registries.util.RegistriesConstants.ARTIFACTORY_REGISTRY_KEY; +import static com.nvidia.boot.registries.util.RegistriesConstants.DOCKER_REGISTRY_KEY; +import static com.nvidia.boot.registries.util.RegistriesConstants.ECR_PRIVATE_REGISTRY_KEY; +import static com.nvidia.boot.registries.util.RegistriesConstants.ECR_PUBLIC_REGISTRY_KEY; +import static com.nvidia.boot.registries.util.RegistriesConstants.HARBOR_REGISTRY_KEY; +import static com.nvidia.boot.registries.util.RegistriesConstants.NGC_PRIVATE_REGISTRY_KEY; +import static com.nvidia.boot.registries.util.RegistriesConstants.VOLCENGINE_REGISTRY_KEY; + +import com.nvidia.boot.registries.configurations.RegistryConfigurationProperties.OAuth2Configuration; +import com.nvidia.boot.registries.service.registry.client.acr.AzureRegistryClient; +import com.nvidia.boot.registries.service.registry.client.artifactory.ArtifactoryClient; +import com.nvidia.boot.registries.service.registry.client.docker.DockerRegistryClient; +import com.nvidia.boot.registries.service.registry.client.ecr.pub.EcrPublicArtifactRegistryClient; +import com.nvidia.boot.registries.service.registry.client.ecr.pvt.EcrPrivateArtifactRegistryClient; +import com.nvidia.boot.registries.service.registry.client.harbor.HarborRegistryClient; +import com.nvidia.boot.registries.service.registry.client.ngc.NgcArtifactRegistryClient; +import com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineArtifactRegistryClient; +import com.nvidia.boot.registries.service.registry.helm.HelmRegistry; +import com.nvidia.boot.registries.service.registry.helm.acr.AcrHelmRegistry; +import com.nvidia.boot.registries.service.registry.helm.artifactory.ArtifactoryHelmRegistry; +import com.nvidia.boot.registries.service.registry.helm.custom.CustomHelmRegistry; +import com.nvidia.boot.registries.service.registry.helm.docker.DockerHelmRegistry; +import com.nvidia.boot.registries.service.registry.helm.ecr.pub.EcrPublicHelmRegistry; +import com.nvidia.boot.registries.service.registry.helm.ecr.pvt.EcrPrivateHelmRegistry; +import com.nvidia.boot.registries.service.registry.helm.harbor.HarborHelmRegistry; +import com.nvidia.boot.registries.service.registry.helm.ngc.NgcHelmRegistry; +import com.nvidia.boot.registries.service.registry.helm.volcengine.VolcengineHelmRegistry; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.util.CollectionUtils; +import org.springframework.web.reactive.function.client.WebClient; + +@AutoConfiguration +@AutoConfigureAfter(RegistryConfigurationPropertiesAutoConfiguration.class) +public class HelmRegistryAutoConfiguration { + + private static final Set KNOWN_HELM_REGISTRIES = + Set.of(NGC_PRIVATE_REGISTRY_KEY, + DOCKER_REGISTRY_KEY, + ECR_PRIVATE_REGISTRY_KEY, + ECR_PUBLIC_REGISTRY_KEY, + ACR_REGISTRY_KEY, + ARTIFACTORY_REGISTRY_KEY, + VOLCENGINE_REGISTRY_KEY, + HARBOR_REGISTRY_KEY); + + @Bean + @RefreshScope + public List customHelmRegistries( + RegistryConfigurationProperties properties) { + var helmRegistriesMap = properties.getRecognized().getHelm(); + var customHelmRegistries = new ArrayList(); + + var entries = helmRegistriesMap.entrySet(); + for (var entry: entries) { + var key = entry.getKey(); + if (KNOWN_HELM_REGISTRIES.contains(key)) { + continue; + } + + entry.getValue().getArtifactValidation().setEnabled(false); + entry.getValue().getCredentialValidation().setEnabled(false); + var registry = new CustomHelmRegistry(entry.getValue().getName(), + entry.getValue().getHostname()); + customHelmRegistries.add(registry); + } + + return customHelmRegistries; + } + + /** + * Consolidated list of all helm registries (known + custom) for injection into + * HelmRegistryService and RegistryLookupService. Spring injects + * {@code List} by collecting individual {@code HelmRegistry} beans; + * it does not include beans of type {@code List}. This bean merges both + * sources so consumers receive the full set. + * + *

+ * Individual {@code HelmRegistry} beans for known helm registries such as ngc, + * docker, etc. should be in the context so that Spring can collect them and inject a list of + * knownHelmRegistries when calling this method. However, if there are no individual + * {@code HelmRegistry} registry beans, then Spring cannot build a list. So, Spring + * looks for factory methods with return type {@code List} and the only + * candidates are helmRegistries(the bean currently being created) and customHelmRegistries + * factory methods in this class. If Spring chooses helmRegistries factory method to + * satisfy knownHelmRegistries, it results in the following circular dependency - + * Creating helmRegistries → needs knownHelmRegistries → + * resolves to helmRegistries → circular dependency + * and causes BeanCurrentlyInCreationException: the bean being created is requested again + * while it is still in creation. + * This is why we define factory methods to add dummy HelmRegistry beans in the + * integration tests. + *

+ */ + @Bean + @RefreshScope + @Primary + public List helmRegistries( + List knownHelmRegistries, + @Qualifier("customHelmRegistries") List customHelmRegistries) { + if (CollectionUtils.isEmpty(customHelmRegistries)) { + return knownHelmRegistries; + } + + var combined = new ArrayList(knownHelmRegistries); + combined.addAll(customHelmRegistries); + return combined; + } + + @Bean + @RefreshScope + @ConditionalOnRegistryKey(registryType = "helm", key = NGC_PRIVATE_REGISTRY_KEY) + public HelmRegistry ngcHelmRegistry( + RegistryConfigurationProperties registryConfigurationProperties, + WebClient.Builder webClientBuilder) { + var ngcConfig = registryConfigurationProperties.getRecognized() + .getHelm().get(NGC_PRIVATE_REGISTRY_KEY); + // We are using the artifact endpoint to validate helm registry, so need to translate + // the helm registry hostname to artifact hostname. + var ngcEndpoint = NgcHelmRegistry + .translateHelmRegistryHostnameToArtifactHostname(ngcConfig.getHostname()); + var ngcHelmArtifactRegistryClient = new NgcArtifactRegistryClient( + webClientBuilder, ngcEndpoint, ngcConfig.getCallTimeout(), ngcConfig.getReadTimeout(), + ngcConfig.getWriteTimeout(), ngcConfig.getConnectionTimeout(), + ngcConfig.getOauth2().getBaseUrl(), ngcConfig.getOauth2().getGroupScope()); + return new NgcHelmRegistry(ngcHelmArtifactRegistryClient); + } + + @Bean + @RefreshScope + @ConditionalOnRegistryKey(registryType = "helm", key = DOCKER_REGISTRY_KEY) + public HelmRegistry dockerHelmRegistry( + RegistryConfigurationProperties registryConfigurationProperties, + WebClient.Builder webClientBuilder) { + var dockerConfig = registryConfigurationProperties.getRecognized() + .getHelm().get(DOCKER_REGISTRY_KEY); + var dockerRegistryClient = new DockerRegistryClient( + webClientBuilder, + dockerConfig.getHostname(), + dockerConfig.getCallTimeout(), + dockerConfig.getOauth2().getBaseUrl(), + dockerConfig.getOauth2().getGroupScope()); + return new DockerHelmRegistry(dockerRegistryClient); + } + + @Bean + @RefreshScope + @ConditionalOnRegistryKey(registryType = "helm", key = ECR_PRIVATE_REGISTRY_KEY) + public HelmRegistry ecrPrivateHelmRegistry( + RegistryConfigurationProperties registryConfigurationProperties, + WebClient.Builder webClientBuilder) { + var ecrConfig = registryConfigurationProperties + .getRecognized() + .getHelm() + .get(ECR_PRIVATE_REGISTRY_KEY); + var ecrPrivateArtifactRegistryClient = + new EcrPrivateArtifactRegistryClient( + webClientBuilder, + ecrConfig.getHostname(), + ecrConfig.getCallTimeout()); + return new EcrPrivateHelmRegistry(ecrPrivateArtifactRegistryClient); + } + + @Bean + @RefreshScope + @ConditionalOnRegistryKey(registryType = "helm", key = ECR_PUBLIC_REGISTRY_KEY) + public HelmRegistry ecrPublicHelmRegistry( + RegistryConfigurationProperties registryConfigurationProperties, + WebClient.Builder webClientBuilder) { + var ecrConfig = registryConfigurationProperties + .getRecognized() + .getHelm() + .get(ECR_PUBLIC_REGISTRY_KEY); + var ecrPublicArtifactRegistryClient = new EcrPublicArtifactRegistryClient( + webClientBuilder, + ecrConfig.getHostname(), + ecrConfig.getCallTimeout() + ); + return new EcrPublicHelmRegistry(ecrPublicArtifactRegistryClient); + } + + @Bean + @RefreshScope + @ConditionalOnRegistryKey(registryType = "helm", key = VOLCENGINE_REGISTRY_KEY) + public HelmRegistry volcengineHelmRegistry( + RegistryConfigurationProperties registryConfigurationProperties, + WebClient.Builder webClientBuilder) { + var volcengineConfig = registryConfigurationProperties + .getRecognized() + .getContainer() + .get(VOLCENGINE_REGISTRY_KEY); + var volcengineArtifactRegistryClient = new VolcengineArtifactRegistryClient( + webClientBuilder, + volcengineConfig.getHostname(), + volcengineConfig.getCallTimeout()); + return new VolcengineHelmRegistry(volcengineArtifactRegistryClient); + } + + @Bean + @RefreshScope + @ConditionalOnRegistryKey(registryType = "helm", key = ACR_REGISTRY_KEY) + public HelmRegistry azureHelmRegistry( + RegistryConfigurationProperties registryConfigurationProperties, + WebClient.Builder webClientBuilder) { + var acrConfig = registryConfigurationProperties + .getRecognized() + .getContainer() + .get(ACR_REGISTRY_KEY); + var azureRegistryClient = new AzureRegistryClient( + webClientBuilder, + acrConfig.getHostname(), + acrConfig.getCallTimeout(), + Optional.ofNullable(acrConfig.getOauth2()) + .map(OAuth2Configuration::getBaseUrl) + .orElse(null)); + return new AcrHelmRegistry(azureRegistryClient); + } + + @Bean + @RefreshScope + @ConditionalOnRegistryKey(registryType = "helm", key = HARBOR_REGISTRY_KEY) + public HelmRegistry harborHelmRegistry( + RegistryConfigurationProperties registryConfigurationProperties, + WebClient.Builder webClientBuilder) { + var harborConfig = registryConfigurationProperties + .getRecognized() + .getContainer() + .get(HARBOR_REGISTRY_KEY); + var harborRegistryClient = new HarborRegistryClient( + webClientBuilder, + harborConfig.getHostname(), + harborConfig.getCallTimeout(), + Optional.ofNullable(harborConfig.getOauth2()) + .map(OAuth2Configuration::getBaseUrl) + .orElse(null)); + return new HarborHelmRegistry(harborRegistryClient); + } + + @Bean + @RefreshScope + @ConditionalOnRegistryKey(registryType = "helm", key = ARTIFACTORY_REGISTRY_KEY) + public HelmRegistry artifactoryHelmRegistry( + RegistryConfigurationProperties registryConfigurationProperties, + WebClient.Builder webClientBuilder) { + var artifactoryConfig = registryConfigurationProperties + .getRecognized() + .getContainer() + .get(ARTIFACTORY_REGISTRY_KEY); + var artifactoryClient = new ArtifactoryClient( + webClientBuilder, + artifactoryConfig.getHostname(), + artifactoryConfig.getCallTimeout(), + Optional.ofNullable(artifactoryConfig.getOauth2()) + .map(OAuth2Configuration::getBaseUrl) + .orElse(null)); + return new ArtifactoryHelmRegistry(artifactoryClient); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/ModelRegistryAutoConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/ModelRegistryAutoConfiguration.java new file mode 100644 index 0000000000..6a61f3d3e8 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/ModelRegistryAutoConfiguration.java @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.configurations; + +import static com.nvidia.boot.registries.util.RegistriesConstants.NGC_PRIVATE_REGISTRY_KEY; + +import com.nvidia.boot.registries.service.registry.client.ngc.NgcArtifactRegistryClient; +import com.nvidia.boot.registries.service.registry.model.ModelRegistry; +import com.nvidia.boot.registries.service.registry.model.ngc.NgcModelRegistry; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.context.annotation.Bean; +import org.springframework.web.reactive.function.client.WebClient; + +@AutoConfiguration +@AutoConfigureAfter(RegistryConfigurationPropertiesAutoConfiguration.class) +public class ModelRegistryAutoConfiguration { + + @Bean + @RefreshScope + @ConditionalOnRegistryKey(registryType = "model", key = NGC_PRIVATE_REGISTRY_KEY) + public ModelRegistry ngcModelRegistry( + RegistryConfigurationProperties registryConfigurationProperties, + WebClient.Builder webClientBuilder) { + var ngcConfig = registryConfigurationProperties.getRecognized().getModel() + .get(NGC_PRIVATE_REGISTRY_KEY); + var ngcModelArtifactRegistryClient = new NgcArtifactRegistryClient( + webClientBuilder, ngcConfig.getHostname(), ngcConfig.getCallTimeout(), ngcConfig.getReadTimeout(), + ngcConfig.getWriteTimeout(), ngcConfig.getConnectionTimeout(), + ngcConfig.getOauth2().getBaseUrl(), ngcConfig.getOauth2().getGroupScope()); + return new NgcModelRegistry(ngcModelArtifactRegistryClient); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/RegistryConfigPathProvider.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/RegistryConfigPathProvider.java new file mode 100644 index 0000000000..ba906ab21d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/RegistryConfigPathProvider.java @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.configurations; + +/** + * Functional interface for providing the registry configuration path. + * Implement this bean to customize how the registry config path is determined. + */ +@FunctionalInterface +public interface RegistryConfigPathProvider { + /** + * @return The base configuration path for registry properties (e.g., "nvcf.registries") + */ + String getConfigPath(); +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/RegistryConfigurationProperties.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/RegistryConfigurationProperties.java new file mode 100644 index 0000000000..26abc4b721 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/RegistryConfigurationProperties.java @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.configurations; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import java.time.Duration; +import java.util.Map; +import lombok.Data; +import org.springframework.validation.annotation.Validated; + +@Data +@Validated +public class RegistryConfigurationProperties { + + private RecognizedRegistryConfiguration recognized; + + @Data + public static class RecognizedRegistryConfiguration { + + @Valid + private Map container; + @Valid + private Map helm; + @Valid + private Map model; + @Valid + private Map resource; + } + + @Data + public static class RegistryConfiguration { + + @NotBlank(message = "Registry name is required") + private String name; + @NotBlank(message = "Registry hostname is required") + private String hostname; + private Duration callTimeout; + private Duration readTimeout; + private Duration writeTimeout; + private Duration connectionTimeout; + private OAuth2Configuration oauth2; + private ValidationProperties credentialValidation = new ValidationProperties(); + private ValidationProperties artifactValidation = new ValidationProperties(); + } + + @Data + public static class ValidationProperties { + + private boolean enabled = true; + } + + @Data + public static class OAuth2Configuration { + + private String baseUrl; + private String groupScope; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/RegistryConfigurationPropertiesAutoConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/RegistryConfigurationPropertiesAutoConfiguration.java new file mode 100644 index 0000000000..b42838d47f --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/RegistryConfigurationPropertiesAutoConfiguration.java @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.configurations; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.bind.validation.ValidationBindHandler; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.context.annotation.Bean; +import org.springframework.core.env.Environment; +import org.springframework.validation.Validator; + +@AutoConfiguration +@Slf4j +@RequiredArgsConstructor +@ConditionalOnBean(RegistryConfigPathProvider.class) +@EnableConfigurationProperties +public class RegistryConfigurationPropertiesAutoConfiguration { + private final RegistryConfigPathProvider configPathProvider; + + @Bean + @RefreshScope + public RegistryConfigurationProperties registryConfigurationProperties( + Environment env, + Validator validator) { + var configPath = configPathProvider.getConfigPath(); + var bindHandler = new ValidationBindHandler(validator); + return Binder.get(env) + .bind(configPath, Bindable.of(RegistryConfigurationProperties.class), bindHandler) + .orElseThrow(() -> new IllegalStateException("Wrong registries configuration")); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/RegistryKeyCondition.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/RegistryKeyCondition.java new file mode 100644 index 0000000000..0a91fced8b --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/RegistryKeyCondition.java @@ -0,0 +1,108 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.configurations; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.boot.autoconfigure.condition.ConditionOutcome; +import org.springframework.boot.autoconfigure.condition.SpringBootCondition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.annotation.AnnotationAttributes; +import org.springframework.core.type.AnnotatedTypeMetadata; + +/** + * Condition implementation that checks if a specific registry key exists in the configuration map. + */ +@Slf4j +public class RegistryKeyCondition extends SpringBootCondition { + + @Override + public ConditionOutcome getMatchOutcome( + ConditionContext context, + AnnotatedTypeMetadata metadata) { + var attributes = AnnotationAttributes.fromMap( + metadata.getAnnotationAttributes(ConditionalOnRegistryKey.class.getName())); + if (attributes == null) { + return ConditionOutcome.noMatch( + "No @ConditionalOnRegistryKey annotation found"); + } + + var registryType = attributes.getString("registryType"); + var key = attributes.getString("key"); + var requireHostname = attributes.getBoolean("requireHostname"); + + try { + // Get the configuration prefix from RegistryConfigPathProvider. + // This is required - if not available, registry beans won't be created. + String configPrefix; + try { + var beanFactory = context.getBeanFactory(); + if (beanFactory == null) { + return ConditionOutcome.noMatch( + "BeanFactory not available during condition evaluation"); + } + + // Check if RegistryConfigPathProvider bean exists (without forcing creation). + var providerBeans = beanFactory.getBeanNamesForType( + RegistryConfigPathProvider.class, false, false); + if (providerBeans.length == 0) { + return ConditionOutcome.noMatch( + "RegistryConfigPathProvider bean not found -" + + " registry beans will not be created"); + } + + // Get the provider bean to read the configured prefix. + var provider = beanFactory.getBean(RegistryConfigPathProvider.class); + configPrefix = provider.getConfigPath(); + log.debug("Using configuration prefix '{}' from RegistryConfigPathProvider", + configPrefix); + + } catch (Exception e) { + return ConditionOutcome.noMatch( + "Failed to get RegistryConfigPathProvider: " + e.getMessage()); + } + + var environment = context.getEnvironment(); + var propertyPrefix = configPrefix + ".recognized." + + registryType.toLowerCase() + "." + key; + + // Check if the registry key exists by checking for the hostname property. + var hostnameProperty = propertyPrefix + ".hostname"; + var hostname = environment.getProperty(hostnameProperty); + + if (hostname == null) { + return ConditionOutcome.noMatch( + "Registry key '" + key + "' not found in " + registryType + " registries " + + "(no property found at " + hostnameProperty + ")"); + } + + if (requireHostname && StringUtils.isBlank(hostname)) { + return ConditionOutcome.noMatch( + "Registry key '" + key + "' found but hostname is not configured"); + } + + return ConditionOutcome.match( + "Registry key '" + key + "' found in " + registryType + " registries" + + (requireHostname ? " with hostname configured" : "")); + + } catch (Exception e) { + return ConditionOutcome.noMatch( + "Failed to check registry configuration: " + e.getMessage()); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/RegistryServiceAutoConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/RegistryServiceAutoConfiguration.java new file mode 100644 index 0000000000..350ee5b3de --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/RegistryServiceAutoConfiguration.java @@ -0,0 +1,139 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.configurations; + +import static com.nvidia.boot.registries.util.RegistriesConstants.NGC_ARTIFACT_REGISTRY_PROD_HOSTNAME; +import static com.nvidia.boot.registries.util.RegistriesConstants.NGC_CONTAINER_REGISTRY_PROD_HOSTNAME; +import static com.nvidia.boot.registries.util.RegistriesConstants.NGC_HELM_REGISTRY_PROD_HOSTNAME; +import static com.nvidia.boot.registries.util.RegistriesConstants.NGC_PRIVATE_REGISTRY_KEY; + +import com.nvidia.boot.registries.service.registry.RegistryLookupService; +import com.nvidia.boot.registries.service.registry.RegistryMapperService; +import com.nvidia.boot.registries.service.registry.RegistryValidationService; +import com.nvidia.boot.registries.service.registry.container.ContainerRegistry; +import com.nvidia.boot.registries.service.registry.container.ContainerRegistryService; +import com.nvidia.boot.registries.service.registry.helm.HelmRegistry; +import com.nvidia.boot.registries.service.registry.helm.HelmRegistryService; +import com.nvidia.boot.registries.service.registry.model.ModelRegistry; +import com.nvidia.boot.registries.service.registry.model.ModelRegistryService; +import com.nvidia.boot.registries.service.registry.resource.ResourceRegistry; +import com.nvidia.boot.registries.service.registry.resource.ResourceRegistryService; +import java.util.List; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.context.annotation.Bean; + +// Spring Boot only scans for components (@Service, @Component, @Repository, etc.) within +// the application's package and its sub-packages by default. +// Since the dependency library classes may be in a different package hierarchy +// (com.nvidia.boot.registries.* vs com.nvidia.nvct.*), Spring Boot's component scanner +// doesn't find them. With that the users don't need to setup component scanning manually. +@AutoConfiguration +@ConditionalOnBean(RegistryConfigurationProperties.class) +public class RegistryServiceAutoConfiguration { + + @Bean + @RefreshScope + public ModelRegistryService modelRegistryService( + List modelRegistries, + RegistryMapperService registryMapperService, + RegistryValidationService registryValidationService) { + return new ModelRegistryService(modelRegistries, registryMapperService, + registryValidationService); + } + + @Bean + @RefreshScope + public ResourceRegistryService resourceRegistryService( + List resourceRegistries, + RegistryMapperService registryMapperService, + RegistryValidationService registryValidationService) { + return new ResourceRegistryService(resourceRegistries, registryMapperService, + registryValidationService); + } + + @Bean + @RefreshScope + public HelmRegistryService helmRegistryService( + @Qualifier("helmRegistries") List helmRegistries, + RegistryMapperService registryMapperService, + RegistryValidationService registryValidationService) { + return new HelmRegistryService(helmRegistries, registryMapperService, + registryValidationService); + } + + @Bean + @RefreshScope + public ContainerRegistryService containerRegistryService( + @Qualifier("containerRegistries") List containerRegistries, + RegistryMapperService registryMapperService, + RegistryValidationService registryValidationService) { + return new ContainerRegistryService(containerRegistries, registryMapperService, + registryValidationService); + } + + @Bean + @RefreshScope + public RegistryLookupService registryLookupService( + List resourceRegistries, + List modelRegistries, + @Qualifier("containerRegistries") List containerRegistries, + @Qualifier("helmRegistries") List helmRegistries, + RegistryConfigurationProperties registryConfigurationProperties, + RegistryMapperService registryMapperService) { + return new RegistryLookupService( + resourceRegistries, + modelRegistries, + helmRegistries, + containerRegistries, + registryConfigurationProperties.getRecognized(), + registryMapperService); + } + + @Bean + @RefreshScope + public RegistryMapperService registryMapperService( + RegistryConfigurationProperties registryConfigurationProperties) { + var registryConfig = registryConfigurationProperties.getRecognized(); + + var helmRegistryHostname = registryConfig.getHelm().containsKey(NGC_PRIVATE_REGISTRY_KEY) ? + registryConfig.getHelm().get(NGC_PRIVATE_REGISTRY_KEY).getHostname() : + NGC_HELM_REGISTRY_PROD_HOSTNAME; + var containerRegistryHostname = + registryConfig.getContainer().containsKey(NGC_PRIVATE_REGISTRY_KEY) ? + registryConfig.getContainer().get(NGC_PRIVATE_REGISTRY_KEY).getHostname() : + NGC_CONTAINER_REGISTRY_PROD_HOSTNAME; + // Same for NGC model and resource registry + var artifactRegistryHostname = + registryConfig.getModel().containsKey(NGC_PRIVATE_REGISTRY_KEY) ? + registryConfig.getModel().get(NGC_PRIVATE_REGISTRY_KEY).getHostname() : + NGC_ARTIFACT_REGISTRY_PROD_HOSTNAME; + + return new RegistryMapperService(containerRegistryHostname, artifactRegistryHostname, + helmRegistryHostname); + } + + @Bean + @RefreshScope + public RegistryValidationService registryValidationService( + RegistryLookupService registryLookupService) { + return new RegistryValidationService(registryLookupService); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/ResourceRegistryAutoConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/ResourceRegistryAutoConfiguration.java new file mode 100644 index 0000000000..6fe26b6e97 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/configurations/ResourceRegistryAutoConfiguration.java @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.configurations; + +import static com.nvidia.boot.registries.util.RegistriesConstants.NGC_PRIVATE_REGISTRY_KEY; + +import com.nvidia.boot.registries.service.registry.client.ngc.NgcArtifactRegistryClient; +import com.nvidia.boot.registries.service.registry.resource.ResourceRegistry; +import com.nvidia.boot.registries.service.registry.resource.ngc.NgcResourceRegistry; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.context.annotation.Bean; +import org.springframework.web.reactive.function.client.WebClient; + +@AutoConfiguration +@AutoConfigureAfter(RegistryConfigurationPropertiesAutoConfiguration.class) +public class ResourceRegistryAutoConfiguration { + @Bean + @RefreshScope + @ConditionalOnRegistryKey(registryType = "resource", key = NGC_PRIVATE_REGISTRY_KEY) + public ResourceRegistry ngcResourceRegistry( + RegistryConfigurationProperties registryConfigurationProperties, + WebClient.Builder webClientBuilder) { + var ngcConfig = registryConfigurationProperties.getRecognized().getResource().get( + NGC_PRIVATE_REGISTRY_KEY); + var ngcResourceArtifactRegistryClient = new NgcArtifactRegistryClient( + webClientBuilder, ngcConfig.getHostname(), ngcConfig.getCallTimeout(), ngcConfig.getReadTimeout(), + ngcConfig.getWriteTimeout(), ngcConfig.getConnectionTimeout(), + ngcConfig.getOauth2().getBaseUrl(), ngcConfig.getOauth2().getGroupScope()); + return new NgcResourceRegistry(ngcResourceArtifactRegistryClient); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/RegistryLookupService.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/RegistryLookupService.java new file mode 100644 index 0000000000..9a4a88fb23 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/RegistryLookupService.java @@ -0,0 +1,248 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry; + +import com.google.common.annotations.VisibleForTesting; +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.registries.configurations.RegistryConfigurationProperties.RecognizedRegistryConfiguration; +import com.nvidia.boot.registries.configurations.RegistryConfigurationProperties.RegistryConfiguration; +import com.nvidia.boot.registries.service.registry.container.ContainerRegistry; +import com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum; +import com.nvidia.boot.registries.service.registry.helm.HelmRegistry; +import com.nvidia.boot.registries.service.registry.model.ModelRegistry; +import com.nvidia.boot.registries.service.registry.resource.ResourceRegistry; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.CollectionUtils; + + +@Slf4j +@Getter +public class RegistryLookupService { + + private static final String MESG_UNSUPPORTED_REGISTRY_TYPE = + "Unsupported registry: no %s registries configured"; + private static final String MESG_UNSUPPORTED_REGISTRY_HOSTNAME = + "Unsupported registry hostname %s for registry type %s"; + + private final Map resourceRegistryNameToHostname; + private final Map modelRegistryNameToHostname; + private final Map containerRegistryNameToHostname; + private final Map helmRegistryNameToHostname; + private final Map resourceRegistryHostnameToName; + private final Map modelRegistryHostnameToName; + private final Map containerRegistryHostnameToName; + private final Map helmRegistryHostnameToName; + private final Map containerRegistryConfigMap; + private final Map helmRegistryConfigMap; + private final Map modelRegistryConfigMap; + private final Map resourceRegistryConfigMap; + private final RegistryMapperService registryMapperService; + + public RegistryLookupService( + List resourceRegistries, + List modelRegistries, + List helmRegistries, + List containerRegistries, + RecognizedRegistryConfiguration recognizedRegistryConfig, + RegistryMapperService registryMapperService) { + this.resourceRegistryNameToHostname = resourceRegistries.stream().collect( + Collectors.toMap(ResourceRegistry::getName, ResourceRegistry::getHostname)); + this.modelRegistryNameToHostname = modelRegistries.stream().collect( + Collectors.toMap(ModelRegistry::getName, ModelRegistry::getHostname)); + this.containerRegistryNameToHostname = containerRegistries.stream().collect( + Collectors.toMap(ContainerRegistry::getName, ContainerRegistry::getHostname)); + this.helmRegistryNameToHostname = helmRegistries.stream().collect( + Collectors.toMap(HelmRegistry::getName, HelmRegistry::getHostname)); + this.resourceRegistryHostnameToName = resourceRegistries.stream().collect( + Collectors.toMap(ResourceRegistry::getHostname, ResourceRegistry::getName)); + this.modelRegistryHostnameToName = modelRegistries.stream().collect( + Collectors.toMap(ModelRegistry::getHostname, ModelRegistry::getName)); + this.containerRegistryHostnameToName = containerRegistries.stream().collect( + Collectors.toMap(ContainerRegistry::getHostname, ContainerRegistry::getName)); + this.helmRegistryHostnameToName = helmRegistries.stream().collect( + Collectors.toMap(HelmRegistry::getHostname, HelmRegistry::getName)); + this.registryMapperService = registryMapperService; + + this.containerRegistryConfigMap = recognizedRegistryConfig != null + ? buildRegistryConfigMap(recognizedRegistryConfig.getContainer()) : Map.of(); + this.helmRegistryConfigMap = recognizedRegistryConfig != null + ? buildRegistryConfigMap(recognizedRegistryConfig.getHelm()) : Map.of(); + this.modelRegistryConfigMap = recognizedRegistryConfig != null + ? buildRegistryConfigMap(recognizedRegistryConfig.getModel()) : Map.of(); + this.resourceRegistryConfigMap = recognizedRegistryConfig != null + ? buildRegistryConfigMap(recognizedRegistryConfig.getResource()) : Map.of(); + } + + private static Map buildRegistryConfigMap( + Map configByRegistryKey) { + if (CollectionUtils.isEmpty(configByRegistryKey)) { + return Map.of(); + } + return configByRegistryKey.values().stream() + .filter(Objects::nonNull) + .filter(config -> config.getHostname() != null) + .collect(Collectors.toMap( + RegistryConfiguration::getHostname, config -> config, + (existing, duplicate) -> existing, HashMap::new)); + } + + public RegistryConfiguration getRegistryConfig(ArtifactTypeEnum type, String hostname) { + var normalizedHostname = + registryMapperService.toNormalizedRecognizedRegistryHostname(hostname); + var configMap = switch (type) { + case CONTAINER -> containerRegistryConfigMap; + case HELM -> helmRegistryConfigMap; + case MODEL -> modelRegistryConfigMap; + case RESOURCE -> resourceRegistryConfigMap; + }; + if (CollectionUtils.isEmpty(configMap)) { + var msg = MESG_UNSUPPORTED_REGISTRY_TYPE.formatted(type); + log.error(msg); + throw new BadRequestException(msg); + } + var config = configMap.get(normalizedHostname); + if (config == null) { + var msg = MESG_UNSUPPORTED_REGISTRY_HOSTNAME.formatted(normalizedHostname, type); + log.error(msg); + throw new BadRequestException(msg); + } + return config; + } + + public String getResourceRegistryHostnameByName(String registryName) { + return resourceRegistryNameToHostname.get(registryName); + } + + public String getModelRegistryHostnameByName(String registryName) { + return modelRegistryNameToHostname.get(registryName); + } + + public String getContainerRegistryHostnameByName(String registryName) { + return containerRegistryNameToHostname.get(registryName); + } + + public String getHelmRegistryHostnameByName(String registryName) { + return helmRegistryNameToHostname.get(registryName); + } + + public String getResourceRegistryNameByHostName(String hostName) { + return resourceRegistryHostnameToName.get(hostName); + } + + public String getModelRegistryNameByHostName(String hostName) { + return modelRegistryHostnameToName.get(hostName); + } + + public String getContainerRegistryNameByHostName(String hostName) { + return containerRegistryHostnameToName.get(hostName); + } + + public String getHelmRegistryNameByHostName(String hostName) { + return helmRegistryHostnameToName.get(hostName); + } + + @VisibleForTesting + public void updateHelmRegistryMap(String name, String hostName) { + helmRegistryHostnameToName.put(hostName, name); + helmRegistryNameToHostname.put(name, hostName); + } + + @VisibleForTesting + public void updateResourceRegistryMap(String name, String hostName) { + resourceRegistryHostnameToName.put(hostName, name); + resourceRegistryNameToHostname.put(name, hostName); + } + + @VisibleForTesting + public void updateModelRegistryMap(String name, String hostName) { + modelRegistryHostnameToName.put(hostName, name); + modelRegistryNameToHostname.put(name, hostName); + } + + @VisibleForTesting + public void updateContainerRegistryMap(String name, String hostName) { + containerRegistryHostnameToName.put(hostName, name); + containerRegistryNameToHostname.put(name, hostName); + } + + @VisibleForTesting + public void updateContainerRegistryConfigMap(String oldHostName, String newHostName) { + var config = containerRegistryConfigMap.get(oldHostName); + if (config != null) { + containerRegistryConfigMap.put(newHostName, config); + } + } + + @VisibleForTesting + public void updateHelmRegistryConfigMap(String oldHostName, String newHostName) { + var config = helmRegistryConfigMap.get(oldHostName); + if (config != null) { + helmRegistryConfigMap.put(newHostName, config); + } + } + + @VisibleForTesting + public void updateModelRegistryConfigMap(String oldHostName, String newHostName) { + var config = modelRegistryConfigMap.get(oldHostName); + if (config != null) { + modelRegistryConfigMap.put(newHostName, config); + } + } + + @VisibleForTesting + public void updateResourceRegistryConfigMap(String oldHostName, String newHostName) { + var config = resourceRegistryConfigMap.get(oldHostName); + if (config != null) { + resourceRegistryConfigMap.put(newHostName, config); + } + } + + @VisibleForTesting + public void removeHelmRegistryMap(String name) { + var hostName = helmRegistryNameToHostname.get(name); + helmRegistryHostnameToName.remove(hostName); + helmRegistryNameToHostname.remove(name); + } + + @VisibleForTesting + public void removeResourceRegistryMap(String name) { + var hostName = resourceRegistryNameToHostname.get(name); + resourceRegistryHostnameToName.remove(hostName); + resourceRegistryNameToHostname.remove(name); + } + + @VisibleForTesting + public void removeModelRegistryMap(String name) { + var hostName = modelRegistryNameToHostname.get(name); + modelRegistryHostnameToName.remove(hostName); + modelRegistryNameToHostname.remove(name); + } + + @VisibleForTesting + public void removeContainerRegistryMap(String name) { + var hostName = containerRegistryNameToHostname.get(name); + containerRegistryHostnameToName.remove(hostName); + containerRegistryNameToHostname.remove(name); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/RegistryMapperService.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/RegistryMapperService.java new file mode 100644 index 0000000000..d1619bb25e --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/RegistryMapperService.java @@ -0,0 +1,280 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry; + +import static com.nvidia.boot.registries.service.registry.client.acr.AzureRegistryClient.AZURE_REGISTRY_GLOBAL_HOSTNAME; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrRegistryUtils.ECR_PRIVATE_HOSTNAME_PATTERN; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrRegistryUtils.ECR_PRIVATE_REGISTRY_GLOBAL_HOSTNAME; +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineRegistryUtils.VOLCENGINE_HOSTNAME_PATTERN; +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineRegistryUtils.VOLCENGINE_REGISTRY_GLOBAL_HOSTNAME; + +import com.fasterxml.jackson.annotation.JsonInclude; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.json.JsonMapper; +import com.google.common.annotations.VisibleForTesting; +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.registries.service.registry.dto.AccessKeyCredentials; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Base64; +import java.util.Map; +import java.util.regex.Pattern; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.util.CollectionUtils; +import org.springframework.web.util.UriComponentsBuilder; + +@Slf4j +public class RegistryMapperService { + + public static final String HELM_REGISTRY_CANARY_HOSTNAME = "helm.canary.ngc.nvidia.com"; + public static final String CONTAINER_REGISTRY_CANARY_HOSTNAME = "canary.nvcr.io"; + public static final String ARTIFACT_REGISTRY_CANARY_HOSTNAME = "api.canary.ngc.nvidia.com"; + public static final JsonMapper JSON_MAPPER = JsonMapper.builder() + .changeDefaultPropertyInclusion(v -> v.withValueInclusion(JsonInclude.Include.NON_NULL)) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .build(); + + private static final Pattern LOCALHOST_REGISTRY_PATTERN = + Pattern.compile("localhost-[^:]+:"); + private static final String ISO8601_BASIC_FORMAT = "yyyyMMdd'T'HHmmss'Z'"; + private static final String SHORT_DATE_FORMAT = "yyyyMMdd"; + + private static final String MESG_FAILED_TO_SERIALIZE_OBJECT = + "Failed serialize object to json: %s"; + private static final String MESG_SECRET_NULL_OR_EMPTY = + "Secret cannot be null or empty"; + private static final String MESG_INVALID_SECRET_FORMAT = + "Invalid secret format. Expected format: base64(username:passwd)"; + private static final String MESG_INVALID_BASE64_ENCODING = + "Invalid base64 encoding in secret: %s"; + private static final String MESG_EMPTY_BASE_URL = + "Base url cannot be null or empty"; + + private String ngcArtifactRegistryHostname; + private String ngcContainerRegistryHostname; + private String ngcHelmRegistryHostname; + + public RegistryMapperService(String ngcContainerRegistryHostname, + String ngcArtifactRegistryHostname, + String ngcHelmRegistryHostname) { + this.ngcContainerRegistryHostname = ngcContainerRegistryHostname; + this.ngcArtifactRegistryHostname = ngcArtifactRegistryHostname; + this.ngcHelmRegistryHostname = ngcHelmRegistryHostname; + } + + public static boolean isCanaryHostname(String hostname) { + // Handle NGC UI's use of canary hostnames that are just aliased to the prod hostnames. + return CONTAINER_REGISTRY_CANARY_HOSTNAME.equals(hostname) + || HELM_REGISTRY_CANARY_HOSTNAME.equals(hostname) + || ARTIFACT_REGISTRY_CANARY_HOSTNAME.equals(hostname); + } + + public String toNormalizedHostname(String hostname) { + // Handle NGC UI's use of canary hostnames that are just aliased to the prod hostnames. + if (CONTAINER_REGISTRY_CANARY_HOSTNAME.equals(hostname)) { + return ngcContainerRegistryHostname; + } else if (HELM_REGISTRY_CANARY_HOSTNAME.equals(hostname)) { + return ngcHelmRegistryHostname; + } else if (ARTIFACT_REGISTRY_CANARY_HOSTNAME.equals(hostname)) { + return ngcArtifactRegistryHostname; + } + + return hostname; + } + + public String toNormalizedRecognizedRegistryHostname(String hostname) { + + // Handle NGC UI's use of canary hostnames that are just aliased to the prod hostnames. + if (CONTAINER_REGISTRY_CANARY_HOSTNAME.equals(hostname)) { + return ngcContainerRegistryHostname; + } else if (HELM_REGISTRY_CANARY_HOSTNAME.equals(hostname)) { + return ngcHelmRegistryHostname; + } else if (ARTIFACT_REGISTRY_CANARY_HOSTNAME.equals(hostname)) { + return ngcArtifactRegistryHostname; + } + + // Map ECR private hostname containing AWS account-id and region to a generic hostname + // used in the recognized registry configuration. + if (StringUtils.isNotBlank(hostname) + && ECR_PRIVATE_HOSTNAME_PATTERN.matcher(hostname).matches()) { + return ECR_PRIVATE_REGISTRY_GLOBAL_HOSTNAME; + } + + // Map VolcEngine registry hostname containing registry id and region to a generic hostname + // used in the recognized registry configuration. + if (StringUtils.isNotBlank(hostname) + && VOLCENGINE_HOSTNAME_PATTERN.matcher(hostname).matches()) { + return VOLCENGINE_REGISTRY_GLOBAL_HOSTNAME; + } + + // Map dynamic azure container registry hostname to a generic hostname + // used in the recognized registry configuration. + if (StringUtils.isNotBlank(hostname) + && hostname.contains(AZURE_REGISTRY_GLOBAL_HOSTNAME)) { + return AZURE_REGISTRY_GLOBAL_HOSTNAME; + } + + return hostname; + } + + public String toCanaryHostname(String hostname) { + // Handle Downstream Spot use of canary hostnames when consuming registry secrets. + if (ngcContainerRegistryHostname.equals(hostname)) { + return CONTAINER_REGISTRY_CANARY_HOSTNAME; + } else if (ngcHelmRegistryHostname.equals(hostname)) { + return HELM_REGISTRY_CANARY_HOSTNAME; + } else if (ngcArtifactRegistryHostname.equals(hostname)) { + return ARTIFACT_REGISTRY_CANARY_HOSTNAME; + } + + return hostname; + } + + @VisibleForTesting + public void updateNgcArtifactRegistryHostname(String newNgcArtifactRegistryHostname) { + this.ngcArtifactRegistryHostname = newNgcArtifactRegistryHostname; + } + + @VisibleForTesting + public void updateNgcHelmRegistryHostname(String newNgcHelmRegistryHostname) { + this.ngcHelmRegistryHostname = newNgcHelmRegistryHostname; + } + + @VisibleForTesting + public void updateNgcContainerRegistryHostname(String newNgcContainerRegistryHostname) { + this.ngcContainerRegistryHostname = newNgcContainerRegistryHostname; + } + + public static String toCanonicalRequest( + String method, + String canonicalUri, + String canonicalQueryString, + Map headers, + String hashedPayload) { + var canonicalHeaders = new StringBuilder(); + for (Map.Entry entry : headers.entrySet()) { + canonicalHeaders.append(entry.getKey()).append(":").append(entry.getValue()) + .append("\n"); + } + var signedHeaders = String.join(";", headers.keySet()); + return method + "\n" + + canonicalUri + "\n" + + canonicalQueryString + "\n" + + canonicalHeaders + "\n" + + signedHeaders + "\n" + + hashedPayload; + } + + public static String toIsoBasicDateFormat(Instant instant) { + return DateTimeFormatter + .ofPattern(ISO8601_BASIC_FORMAT) + .withZone(ZoneOffset.UTC) + .format(instant); + } + + public static String toShortDateFormat(Instant instant) { + return DateTimeFormatter + .ofPattern(SHORT_DATE_FORMAT) + .withZone(ZoneOffset.UTC) + .format(instant); + } + + public static String toJson(T obj) { + try { + return JSON_MAPPER.writeValueAsString(obj); + } catch (JacksonException e) { + var mesg = MESG_FAILED_TO_SERIALIZE_OBJECT.formatted(e.getMessage()); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + } + + // Expected format: base64(username:passwd) + public static AccessKeyCredentials toAccessKeyCredentials( + String base64Secret) { + if (StringUtils.isEmpty(base64Secret)) { + log.error(MESG_SECRET_NULL_OR_EMPTY); + throw new BadRequestException(MESG_SECRET_NULL_OR_EMPTY); + } + + try { + var decodedBytes = Base64.getDecoder().decode(base64Secret); + var decodedSecret = new String(decodedBytes, StandardCharsets.UTF_8); + + var parts = decodedSecret.split(":", 2); + if (parts.length != 2) { + log.error(MESG_INVALID_SECRET_FORMAT); + throw new BadRequestException(MESG_INVALID_SECRET_FORMAT); + } + return new AccessKeyCredentials(parts[0], parts[1]); + } catch (IllegalArgumentException e) { + var msg = MESG_INVALID_BASE64_ENCODING.formatted(e.getMessage()); + log.error(msg); + throw new BadRequestException(msg); + } + } + + public static String normalizeUrl(String hostname) { + return hostname.startsWith("http") ? hostname : "https://" + hostname; + } + + public static String buildUrlWithQueryParams(String baseUrl, Map queryParams) { + if (CollectionUtils.isEmpty(queryParams)) { + return baseUrl; + } + + var builder = UriComponentsBuilder.fromUriString(baseUrl); + queryParams.forEach(builder::queryParam); + return builder.build().toUriString(); + } + + public static String toBaseAuthUrl(String authBaseUrl, String registryHost) { + return StringUtils.isNotBlank(authBaseUrl) ? + authBaseUrl : normalizeUrl(registryHost); + } + + public static String toRegistryBaseUrl(String registryHost, String overrideBaseUrl) { + if (StringUtils.isBlank(overrideBaseUrl) || !overrideBaseUrl.contains("localhost")) { + return "https://" + registryHost; + } else { + return toRegistryBaseUrl(overrideBaseUrl); + } + } + + public static String toRegistryBaseUrl(String baseUrl) { + if (StringUtils.isEmpty(baseUrl)) { + throw new IllegalArgumentException(MESG_EMPTY_BASE_URL); + } + + // Design for 3rd Party Registry requires hostnames to be unique. However, when + // integration tests involving multiple registries are being executed, this + // becomes an issue as all the registries use "localhost" as the hostname in the + // baseUrl. To make the hostnames unique in the application-test.yaml files of + // apps such as NVCF API and NVCT API, we use localhost-: + // as the baseUrl. For example, localhost-ngc:, localhost-docker:, + // etc. When using the baseUrl, we remove the `-` part so that + // the client can communicate with the registry-specific mock server. + return baseUrl.contains("localhost-") + ? LOCALHOST_REGISTRY_PATTERN.matcher(baseUrl).replaceAll("localhost:") + : baseUrl; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/RegistryValidationService.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/RegistryValidationService.java new file mode 100644 index 0000000000..01a556f885 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/RegistryValidationService.java @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry; + +import com.nvidia.boot.registries.configurations.RegistryConfigurationProperties.ValidationProperties; +import com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class RegistryValidationService { + + private final RegistryLookupService registryLookupService; + + public boolean isCredentialValidationEnabled(ArtifactTypeEnum type, String hostname) { + return Optional.ofNullable(registryLookupService.getRegistryConfig(type, hostname) + .getCredentialValidation()) + .map(ValidationProperties::isEnabled) + .orElse(true); + } + + public boolean isArtifactValidationEnabled(ArtifactTypeEnum type, String hostname) { + return Optional.ofNullable(registryLookupService.getRegistryConfig(type, hostname) + .getArtifactValidation()) + .map(ValidationProperties::isEnabled) + .orElse(true); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/auth/AuthServiceStub.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/auth/AuthServiceStub.java new file mode 100644 index 0000000000..7ff61cc9ad --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/auth/AuthServiceStub.java @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.auth; + +import tools.jackson.databind.PropertyNamingStrategies; +import tools.jackson.databind.annotation.JsonNaming; +import lombok.Data; +import org.springframework.http.HttpHeaders; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.service.annotation.PostExchange; + +public interface AuthServiceStub { + + @PostExchange("/token") + Oauth2Token fetchToken( + @RequestHeader(HttpHeaders.AUTHORIZATION) String authorization, + @RequestBody MultiValueMap formData); + + @Data + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + class Oauth2Token { + + private String accessToken; + private Long expiresIn; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/auth/DockerRegistryAuthServiceStubService.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/auth/DockerRegistryAuthServiceStubService.java new file mode 100644 index 0000000000..a7552c69c7 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/auth/DockerRegistryAuthServiceStubService.java @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.auth; + +import tools.jackson.databind.PropertyNamingStrategies; +import tools.jackson.databind.annotation.JsonNaming; +import lombok.Data; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.service.annotation.GetExchange; + +public interface DockerRegistryAuthServiceStubService { + + @GetExchange("token?service=registry.docker.io&scope=repository:{namespace}/{repository}:{scope}") + DockerRegistryAuthResponse proxyAuth(@PathVariable("namespace") String namespace, + @PathVariable("repository") String repository, + @PathVariable("scope") String scope, + @RequestHeader("Authorization") String authorization); + + @GetExchange("/token") + DockerRegistryAuthResponse fetchToken(@RequestHeader("Authorization") String authorization, + @RequestParam("service") String service); + + @Data + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + class DockerRegistryAuthResponse { + private String token; + private Integer expiresIn; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/WebClientUtils.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/WebClientUtils.java new file mode 100644 index 0000000000..319389d35e --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/WebClientUtils.java @@ -0,0 +1,291 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client; + +import static org.springframework.http.HttpStatus.CONFLICT; +import static org.springframework.http.HttpStatus.FORBIDDEN; +import static org.springframework.http.HttpStatus.NOT_FOUND; +import static org.springframework.http.HttpStatus.TOO_MANY_REQUESTS; +import static org.springframework.http.HttpStatus.UNAUTHORIZED; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.BootResponseException; +import com.nvidia.boot.exceptions.ConflictException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.exceptions.TooManyRequestsException; +import com.nvidia.boot.exceptions.UnauthorizedException; +import com.nvidia.boot.exceptions.UpstreamException; +import io.netty.channel.ChannelOption; +import io.netty.handler.timeout.WriteTimeoutHandler; +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientRequestException; +import org.springframework.web.reactive.function.client.support.WebClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; +import reactor.core.Exceptions; +import reactor.core.publisher.Mono; +import reactor.netty.http.client.HttpClient; +import reactor.util.retry.Retry; + +/** + * Shared WebClient utilities for registry clients. Provides standard WebClient + * construction with boot-exception error handlers and HttpServiceProxyFactory + * stub creation. + */ +@Slf4j +@UtilityClass +public class WebClientUtils { + + private static final String MESG_4XX_RESPONSE = + "Registry response with 4xx error %d"; + private static final String MESG_5XX_RESPONSE = + "Registry response with 5xx error %d"; + private static final String MESG_RETRY_NOT_SUCCESSFUL = + "Retryable request {} is not successful - {}: {}"; + private static final String MESG_RETRY_FAILED = + "Retryable request retries failed"; + private static final String MESG_429_RETRY = + "{} responded with 429 (rate limited) at {}, retrying (attempt {})"; + + private static final Duration MIN_BACKOFF_429 = Duration.ofSeconds(1); + + /** + * Creates a {@link WebClient.Builder} for tests and manual wiring (Jackson 3 default codecs). + */ + public static WebClient.Builder builder() { + return WebClient.builder(); + } + + /** + * Creates a WebClient with standard boot-exception status handlers and + * a timeout filter using an injected builder. + */ + public static WebClient createWebClient(WebClient.Builder webClientBuilder, + String baseUrl, + Duration timeout) { + return webClientBuilder + .baseUrl(baseUrl) + .defaultStatusHandler(HttpStatusCode::is4xxClientError, + WebClientUtils::handle4xxError) + .defaultStatusHandler(HttpStatusCode::is5xxServerError, + WebClientUtils::handle5xxError) + .filter((request, next) -> next.exchange(request).timeout(timeout)) + .build(); + } + + /** + * Creates a WebClient with granular Reactor Netty timeout configuration and + * optional retry on 5xx / IO errors. Each retry attempt gets its own + * per-attempt timeout (exchangeTimeout). 4xx errors are never retried. + * + * @param webClientBuilder injected WebClient builder + * @param baseUrl base URL for all requests + * @param exchangeTimeout end-to-end deadline for the entire HTTP exchange + * (connect + write + server processing + read); + * applied via Reactor {@code .timeout()} operator + * @param connectTimeout max time to establish a TCP connection; + * applied via Netty {@code CONNECT_TIMEOUT_MILLIS} + * @param responseTimeout max idle duration between network-level read + * operations while reading a response; + * applied via {@link HttpClient#responseTimeout} + * @param writeTimeout max time for an individual write operation to + * complete; applied via Netty {@code WriteTimeoutHandler} + * @param maxTries total number of attempts including the initial request + * (e.g. 3 = 1 initial + 2 retries; 0 or 1 = no retry) + */ + public static WebClient createWebClient( + WebClient.Builder webClientBuilder, + String baseUrl, + Duration exchangeTimeout, + Duration connectTimeout, + Duration responseTimeout, + Duration writeTimeout, + int maxTries) { + var httpClient = HttpClient.create() + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, (int) connectTimeout.toMillis()) + .responseTimeout(responseTimeout) + .doOnConnected(conn -> conn.addHandlerLast( + new WriteTimeoutHandler(writeTimeout.toSeconds(), TimeUnit.SECONDS))); + + var builder = webClientBuilder + .baseUrl(baseUrl) + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .defaultStatusHandler(HttpStatusCode::is4xxClientError, + WebClientUtils::handle4xxError) + .defaultStatusHandler(HttpStatusCode::is5xxServerError, + WebClientUtils::handle5xxError); + + if (maxTries > 1) { + builder.filter(retryOn5xxAndIoError(maxTries)); + builder.filter(retryOn429WithBackoff(maxTries, URI.create(baseUrl).getHost())); + } + + return builder + .filter((request, next) -> next.exchange(request).timeout(exchangeTimeout)) + .build(); + } + + /** + * Creates a typed HTTP service proxy backed by the given WebClient. + */ + public static S createStubService(WebClient webClient, Class serviceType) { + var adapter = WebClientAdapter.create(webClient); + var factory = HttpServiceProxyFactory.builderFor(adapter).build(); + return factory.createClient(serviceType); + } + + /** + * Retries up to {@code maxTries} total attempts on 5xx and IO/timeout errors. + *
    + *
  • On the last attempt, a 5xx response passes through to the status handler + * (so the response body is included in the exception)
  • + *
  • If all attempts fail with IO/timeout errors (no response), + * throws {@link UpstreamException} with "retryable request retries failed"
  • + *
+ */ + private static ExchangeFilterFunction retryOn5xxAndIoError(int maxTries) { + return (request, next) -> { + var attempts = new AtomicInteger(0); + return next.exchange(request) + .flatMap(response -> { + int attempt = attempts.incrementAndGet(); + if (response.statusCode().is5xxServerError() && attempt < maxTries) { + return response.releaseBody() + .then(Mono.error( + new ServerRetryException( + response.statusCode().value()))); + } + return Mono.just(response); + }) + .retryWhen(Retry.max((long) maxTries - 1) + .filter(WebClientUtils::isRetryable) + .doBeforeRetry(signal -> log.warn( + MESG_RETRY_NOT_SUCCESSFUL, + request.url(), + signal.totalRetries() + 1, + signal.failure().getMessage()))) + .onErrorMap(Exceptions::isRetryExhausted, + ex -> new UpstreamException(MESG_RETRY_FAILED)); + }; + } + + private static boolean isRetryable(Throwable t) { + return t instanceof ServerRetryException + || t instanceof IOException + || t instanceof TimeoutException + || t instanceof WebClientRequestException; + } + + private static class ServerRetryException extends RuntimeException { + + ServerRetryException(int statusCode) { + super("Server error " + statusCode); + } + } + + /** + * Retries up to {@code maxTries} total attempts when NGC responds with 429 Too Many Requests. + * Uses exponential backoff with jitter so retries respect rate-limiting semantics. + * Logs an INFO message before each retry attempt. On the final attempt, a 429 response + * passes through to the status handler, which converts it to {@link TooManyRequestsException}. + */ + private static ExchangeFilterFunction retryOn429WithBackoff(int maxTries, String hostname) { + return (request, next) -> { + var attempts = new AtomicInteger(0); + return next.exchange(request) + .flatMap(response -> { + int attempt = attempts.incrementAndGet(); + var statusCode = response.statusCode(); + if (statusCode.isSameCodeAs(TOO_MANY_REQUESTS) && attempt < maxTries) { + return response.releaseBody() + .then(Mono.error(new RateLimitRetryException())); + } + return Mono.just(response); + }) + .retryWhen(Retry.backoff((long) maxTries - 1, MIN_BACKOFF_429) + .jitter(0.5) + .filter(RateLimitRetryException.class::isInstance) + .doBeforeRetry(signal -> log.info( + MESG_429_RETRY, + hostname, + request.url(), + signal.totalRetries() + 1))); + }; + } + + private static class RateLimitRetryException extends RuntimeException { + + RateLimitRetryException() { + super("Rate limited (429)"); + } + } + + private static Mono handle4xxError(ClientResponse response) { + var status = response.statusCode(); + log.error(MESG_4XX_RESPONSE.formatted(response.statusCode().value())); + + if (status.isSameCodeAs(UNAUTHORIZED)) { + return response.bodyToMono(String.class) + .defaultIfEmpty("Unauthorized") + .flatMap(body -> Mono.error(new UnauthorizedException(body))); + } + if (status.isSameCodeAs(FORBIDDEN)) { + return response.bodyToMono(String.class) + .defaultIfEmpty("Forbidden") + .flatMap(body -> Mono.error(new ForbiddenException(body))); + } + if (status.isSameCodeAs(NOT_FOUND)) { + return response.bodyToMono(String.class) + .defaultIfEmpty("Not Found") + .flatMap(body -> Mono.error(new NotFoundException(body))); + } + if (status.isSameCodeAs(TOO_MANY_REQUESTS)) { + return response.bodyToMono(String.class) + .defaultIfEmpty("Too Many Requests") + .flatMap(body -> Mono.error(new TooManyRequestsException(body))); + } + if (status.isSameCodeAs(CONFLICT)) { + return response.bodyToMono(String.class) + .defaultIfEmpty("Conflict") + .flatMap(body -> Mono.error(new ConflictException(body))); + } + return response.bodyToMono(String.class) + .defaultIfEmpty("Bad Request") + .flatMap(body -> Mono.error(new BadRequestException(body))); + } + + private static Mono handle5xxError(ClientResponse response) { + var errorMsg = MESG_5XX_RESPONSE.formatted(response.statusCode().value()); + log.error(errorMsg); + return response.bodyToMono(String.class) + .switchIfEmpty(Mono.error(new UpstreamException(errorMsg))) + .flatMap(body -> Mono.error(new UpstreamException(errorMsg + " - " + body))); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/acr/AzureRegistryAuthClient.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/acr/AzureRegistryAuthClient.java new file mode 100644 index 0000000000..6fb1917a63 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/acr/AzureRegistryAuthClient.java @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.acr; + +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.buildUrlWithQueryParams; +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.toRegistryBaseUrl; + +import com.nvidia.boot.registries.service.registry.client.oci.OciRegistryAuthClient; +import java.time.Duration; +import java.util.LinkedHashMap; +import lombok.Getter; +import org.apache.commons.lang3.StringUtils; +import org.springframework.web.reactive.function.client.WebClient; + +public class AzureRegistryAuthClient extends OciRegistryAuthClient { + + @Getter + private final String authBaseUrl; + + public AzureRegistryAuthClient( + WebClient.Builder webClientBuilder, // Prototype-scoped - Safe to mutate. + String authBaseUrl, + Duration callTimeout) { + super(webClientBuilder, callTimeout); + this.authBaseUrl = authBaseUrl; + } + + @Override + protected String getCanonicalAuthTokenUrl(String registryHost, String name) { + var baseUrl = toRegistryBaseUrl(registryHost, authBaseUrl) + "/oauth2/token"; + + var queryParams = new LinkedHashMap(); + queryParams.put("service", registryHost); + + if (StringUtils.isNotBlank(name)) { + queryParams.put("scope", "repository:" + name + ":pull"); + } + + return buildUrlWithQueryParams(baseUrl, queryParams); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/acr/AzureRegistryClient.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/acr/AzureRegistryClient.java new file mode 100644 index 0000000000..d4317ae058 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/acr/AzureRegistryClient.java @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.acr; + +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.normalizeUrl; +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.toBaseAuthUrl; +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.toRegistryBaseUrl; + +import com.nvidia.boot.registries.service.registry.client.oci.OciRegistryClient; +import java.net.URI; +import java.time.Duration; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.reactive.function.client.WebClient; + +@Slf4j +public class AzureRegistryClient extends OciRegistryClient { + + public static final String AZURE_REGISTRY_GLOBAL_HOSTNAME = "azurecr.io"; + + @Getter + private final String hostname; + private final String baseUrl; + + public AzureRegistryClient( + WebClient.Builder webClientBuilder, // Prototype-scoped - Safe to mutate. + String hostname, + Duration callTimeout, + String authBaseUrl) { + super(webClientBuilder.clone(), + callTimeout, + new AzureRegistryAuthClient(webClientBuilder.clone(), + toBaseAuthUrl(authBaseUrl, hostname), + callTimeout)); + var registryUrl = normalizeUrl(hostname); + this.hostname = URI.create(registryUrl).getHost(); + this.baseUrl = registryUrl; + } + + @Override + protected String getRegistryBaseUrl(String registryHost) { + return toRegistryBaseUrl(registryHost, baseUrl); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/artifactory/ArtifactoryAuthClient.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/artifactory/ArtifactoryAuthClient.java new file mode 100644 index 0000000000..5d071ad792 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/artifactory/ArtifactoryAuthClient.java @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.artifactory; + +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.buildUrlWithQueryParams; +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.toRegistryBaseUrl; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.registries.service.registry.client.oci.OciRegistryAuthClient; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.web.reactive.function.client.WebClient; + +@Slf4j +public class ArtifactoryAuthClient extends OciRegistryAuthClient { + + // Pattern to parse: / + private static final Pattern NAME_PATTERN = Pattern.compile("^([^/]+)/(.+)$"); + private static final String MESG_INVALID_IMAGE_NAME_FORMAT = + "Invalid image name format: %s."; + + @Getter + private final String authBaseUrl; + + public ArtifactoryAuthClient( + WebClient.Builder webClientBuilder, // Prototype-scoped - Safe to mutate. + String authBaseUrl, + Duration callTimeout) { + super(webClientBuilder, callTimeout); + this.authBaseUrl = authBaseUrl; + } + + @Override + protected String getCanonicalAuthTokenUrl(String registryHost, String name) { + String baseUrl = toRegistryBaseUrl(registryHost, authBaseUrl) + "/v2/token"; + var queryParams = new LinkedHashMap(); + queryParams.put("service", registryHost); + + if (StringUtils.isNotBlank(name)) { + // Extract repository and image name from the full name (format: repository/image-name) + Matcher matcher = NAME_PATTERN.matcher(name); + if (matcher.matches()) { + var imageName = matcher.group(2); + queryParams.put("scope", "repository:" + imageName + ":pull"); + } else { + var message = String.format(MESG_INVALID_IMAGE_NAME_FORMAT, name); + log.error(message); + throw new BadRequestException(message); + } + } + + return buildUrlWithQueryParams(baseUrl, queryParams); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/artifactory/ArtifactoryClient.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/artifactory/ArtifactoryClient.java new file mode 100644 index 0000000000..d55e107673 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/artifactory/ArtifactoryClient.java @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.artifactory; + +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.normalizeUrl; +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.toBaseAuthUrl; +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.toRegistryBaseUrl; + +import com.nvidia.boot.registries.service.registry.client.oci.OciRegistryClient; +import java.net.URI; +import java.time.Duration; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.reactive.function.client.WebClient; + +@Slf4j +public class ArtifactoryClient extends OciRegistryClient { + + @Getter + private final String hostname; + private final String baseUrl; + + public ArtifactoryClient( + WebClient.Builder webClientBuilder, // Prototype-scoped - Safe to mutate. + String hostname, + Duration callTimeout, + String authBaseUrl) { + super(webClientBuilder.clone(), + callTimeout, + new ArtifactoryAuthClient(webClientBuilder.clone(), + toBaseAuthUrl(authBaseUrl, hostname), + callTimeout)); + var registryUrl = normalizeUrl(hostname); + this.hostname = URI.create(registryUrl).getHost(); + this.baseUrl = registryUrl; + } + + @Override + protected String getRegistryBaseUrl(String registryHost) { + return toRegistryBaseUrl(registryHost, baseUrl); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/docker/DockerRegistryClient.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/docker/DockerRegistryClient.java new file mode 100644 index 0000000000..4658c3dd17 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/docker/DockerRegistryClient.java @@ -0,0 +1,372 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.docker; + +import static org.springframework.http.HttpStatus.CONFLICT; +import static org.springframework.http.HttpStatus.FORBIDDEN; +import static org.springframework.http.HttpStatus.NOT_FOUND; +import static org.springframework.http.HttpStatus.TOO_MANY_REQUESTS; +import static org.springframework.http.HttpStatus.UNAUTHORIZED; + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Expiry; +import com.github.benmanes.caffeine.cache.LoadingCache; +import com.github.benmanes.caffeine.cache.Scheduler; +import com.google.common.annotations.VisibleForTesting; +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.BootResponseException; +import com.nvidia.boot.exceptions.ConflictException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.exceptions.TooManyRequestsException; +import com.nvidia.boot.exceptions.UnauthorizedException; +import com.nvidia.boot.exceptions.UpstreamException; +import com.nvidia.boot.registries.service.registry.auth.DockerRegistryAuthServiceStubService; +import com.nvidia.boot.registries.service.registry.auth.DockerRegistryAuthServiceStubService.DockerRegistryAuthResponse; +import jakarta.validation.constraints.NotNull; +import java.net.URI; +import java.time.Duration; +import java.util.Optional; +import java.util.regex.Pattern; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatusCode; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.support.WebClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; +import reactor.core.publisher.Mono; + +/** + * Client for interacting with Docker Registry. + * Handles container/helm image URL parsing and validation. + */ +@Slf4j +public class DockerRegistryClient { + + private static final String DOCKER_REGISTRY_AUTH_SERVICE_PARAMETER = "registry.docker.io"; + + private static final String MESG_INVALID_DOCKER_IMAGE_URL_FORMAT_RESPONSE = + "Invalid Docker image url format."; + private static final String MESG_EMPTY_CONTAINER_HELM_URL_RESPONSE = + "Container/helm image URL cannot be null or empty"; + private static final String MESG_NULL_RESPONSE = + "Null response from getting bearer token from registry %s"; + private static final String MESG_DOCKER_5XX_RESPONSE = "Docker response with 5xx error %s"; + private static final String MESG_DOCKER_AUTH_5XX_RESPONSE = "Docker Auth response with 5xx " + + "error %s"; + private static final String MESG_MANIFEST_VALIDATION_SUCCESSFUL = + "Manifest validation successful for image: {}"; + private static final String MESG_DOCKER_RESPONSE_STATUS_CODE = + "Docker Response Status Code '%d'"; + private static final Pattern CONTAINER_HELM_IMAGE_URL_PATTERN = Pattern.compile( + "^(?:oci://)?(?[^/]+)/(?[^/]+)/(?[^/:@]+)(?::" + + "(?[^@]+)|@(?sha256:[a-fA-F0-9]{64}))?$"); + private static final String DEFAULT_IMAGE_TAG = "latest"; + + private final DockerRegistryStubService dockerRegistryStub; + private final DockerRegistryAuthServiceStubService dockerRegistryAuthServiceStub; + private final String groupScope; + private final String baseUrl; + @Getter + private String hostname; + + private record RegistryAuthKey(String namespace, String repository, String scope, String apiKey) { + } + + private static final int AUTH_TOKEN_CACHE_SIZE = 2048; + private final LoadingCache registryAuthCache = + Caffeine.newBuilder() + .maximumSize(AUTH_TOKEN_CACHE_SIZE) + .expireAfter(new AuthTokenExpiry()) + .scheduler(Scheduler.systemScheduler()) + .build(this::fetchAuthToken); + + /** + * Record to hold the components of a container/helm image URL. + */ + public record DockerImageComponents( + String registryHost, + String namespace, + String repository, + String tag, + String digest + ) { + } + + public DockerRegistryClient( + WebClient.Builder webClientBuilder, // Prototype-scoped - Safe to mutate. + String dockerHostname, + Duration callTimeout, + String oauth2BaseUrl, + String oauth2GroupScope) { + var registryUrl = dockerHostname.startsWith("http") + ? dockerHostname : "https://" + dockerHostname; + this.hostname = URI.create(registryUrl).getHost(); + this.baseUrl = registryUrl; + this.groupScope = oauth2GroupScope; + var authWebClient = webClientBuilder.clone() + .baseUrl(oauth2BaseUrl) + .defaultStatusHandler(HttpStatusCode::is4xxClientError, + DockerRegistryClient::get4xxException) + .defaultStatusHandler(HttpStatusCode::is5xxServerError, resp -> { + var errorMesg = MESG_DOCKER_AUTH_5XX_RESPONSE + resp.statusCode(); + log.error(errorMesg); + return resp.bodyToMono(String.class) + .switchIfEmpty(Mono.error(new UpstreamException(errorMesg))) + .flatMap(body -> { + var mesg = errorMesg + " - " + body; + log.error(mesg); + return Mono.error(new UpstreamException(mesg)); + }); + }) + .filter((request, next) -> + next.exchange(request).timeout(callTimeout)) + .build(); + var adapter = WebClientAdapter.create(authWebClient); + var factory = HttpServiceProxyFactory.builderFor(adapter).build(); + this.dockerRegistryAuthServiceStub = + factory.createClient(DockerRegistryAuthServiceStubService.class); + var webClient = webClientBuilder.clone() + .baseUrl(transformBaseUrlForDockerRegistry(baseUrl)) + .defaultStatusHandler(HttpStatusCode::is4xxClientError, + DockerRegistryClient::get4xxException) + .defaultStatusHandler(HttpStatusCode::is5xxServerError, resp -> { + var errorMesg = MESG_DOCKER_5XX_RESPONSE + resp.statusCode(); + log.error(errorMesg); + return resp.bodyToMono(String.class) + .switchIfEmpty(Mono.error(new UpstreamException(errorMesg))) + .flatMap(body -> { + var mesg = errorMesg + " - " + body; + log.error(mesg); + return Mono.error(new UpstreamException(mesg)); + }); + }) + .filter((request, next) -> + next.exchange(request).timeout(callTimeout)) + .build(); + adapter = WebClientAdapter.create(webClient); + factory = HttpServiceProxyFactory.builderFor(adapter).build(); + this.dockerRegistryStub = + factory.createClient(DockerRegistryStubService.class); + } + + @VisibleForTesting + public void setHostname(String hostname) { + this.hostname = hostname; + } + + public void resetAuthTokenCache() { + this.registryAuthCache.invalidateAll(); + } + + // Transforms the base URL for Docker registry usage. + // For testing: removes "-docker" suffix from localhost URLs + // For production: transforms "docker.io" to "registry-1.docker.io" + private static String transformBaseUrlForDockerRegistry(String baseUrl) { + // Design for 3rd Party Registry requires hostnames to be unique. However, when + // integration tests involving multiple registries are being executed, this + // becomes an issue as all the registries use "localhost" as the hostname in the + // baseUrl. To make the hostnames unique in the application-test.yaml files of + // apps such as NVCF API and NVCT API, we use localhost-: + // as the baseUrl. For example, localhost-ngc:, localhost-docker:, + // etc. When using the baseUrl, we remove the `-` part so that + // the client can communicate with the registry-specific mock server. + if (baseUrl.contains("localhost")) { + return baseUrl.replace("-docker", ""); + } + + // For real world usage, we need to call registry-1.docker.io api endpoint. + // In the application.yaml, we specify the docker host name as "docker.io". This + // way users can refer to their docker images more naturally. But to validate + // whether the image exists, we need to make calls to + // "registry-1.docker.io" + if (baseUrl.contains("docker.io")) { + return baseUrl.replace("docker.io", "registry-1.docker.io"); + } + + return baseUrl; + } + + /** + * Validates a container/helm image by checking its existence and accessibility in the Docker + * registry. + * + * @param imageUrl The container/helm image URL to validate + * @param base64ApiKey The base64 encoded API key in format "username:password" for + * authentication + * @throws BadRequestException if the image is invalid or inaccessible + */ + public void validateImage(String imageUrl, String base64ApiKey) { + var components = parseImageUrl(imageUrl); + String bearerToken = authenticateWithRegistry(components, base64ApiKey); + validateImageManifest(components, bearerToken); + } + + /** + * Parses a container/helm image URL into its components. + * Expected format: docker.io/{namespace}/{repositories}:{tags} + * or docker.io/{namespace}/{repositories}:{digest} + * or docker.io/{namespace}/{repositories} + * + * @param imageUrl The full container/helm image URL to parse + * @return ContainerImageComponents containing the parsed components + * @throws BadRequestException if the URL format is invalid + */ + @VisibleForTesting + public static DockerImageComponents parseImageUrl(String imageUrl) { + if (imageUrl == null || imageUrl.isBlank()) { + throw new BadRequestException(MESG_EMPTY_CONTAINER_HELM_URL_RESPONSE); + } + + var matcher = CONTAINER_HELM_IMAGE_URL_PATTERN.matcher(imageUrl); + if (!matcher.matches()) { + throw new BadRequestException(MESG_INVALID_DOCKER_IMAGE_URL_FORMAT_RESPONSE); + } + + var registryHost = matcher.group("registryHost"); + var namespace = matcher.group("namespace"); + var repository = matcher.group("repository"); + var tag = matcher.group("tag"); + var digest = matcher.group("digest"); + if (tag == null && digest == null) { + tag = DEFAULT_IMAGE_TAG; + } + + return new DockerImageComponents(registryHost, namespace, repository, tag, digest); + } + + public String validateCredential(String registryHost, String base64encodedSecret) { + var authResponse = dockerRegistryAuthServiceStub + .fetchToken("Basic " + base64encodedSecret, DOCKER_REGISTRY_AUTH_SERVICE_PARAMETER); + return Optional.ofNullable(authResponse) + .map(DockerRegistryAuthResponse::getToken) + .orElseThrow(() -> { + var mesg = MESG_NULL_RESPONSE + .formatted(registryHost); + log.error(mesg); + return new ForbiddenException(mesg); + }); + } + + private String authenticateWithRegistry(DockerImageComponents components, + String base64ApiKey) { + var authKey = + new RegistryAuthKey(components.namespace(), components.repository(), this.groupScope, base64ApiKey); + return registryAuthCache.get(authKey).getToken(); + } + + private void validateImageManifest(DockerImageComponents components, String bearerToken) { + // do not care about the response body + dockerRegistryStub + .getManifest(components.namespace(), + components.repository(), + components.digest() != null ? components.digest() : components.tag(), + "Bearer " + bearerToken); + // If we reach here, the call succeeded (2xx status code) + log.debug(MESG_MANIFEST_VALIDATION_SUCCESSFUL, + components.namespace() + "/" + components.repository()); + } + + private DockerRegistryAuthResponse fetchAuthToken(RegistryAuthKey authKey) { + var authResponse = dockerRegistryAuthServiceStub + .proxyAuth(authKey.namespace(), + authKey.repository(), + authKey.scope(), + "Basic " + authKey.apiKey()); + return Optional.ofNullable(authResponse) + .orElseThrow(() -> { + var mesg = MESG_NULL_RESPONSE + .formatted(authKey.repository()); + log.error(mesg); + return new ForbiddenException(mesg); + }); + } + + @NotNull + private static Mono get4xxException(ClientResponse response) { + var status = response.statusCode(); + var mesg = MESG_DOCKER_RESPONSE_STATUS_CODE.formatted(response.statusCode().value()); + log.error(mesg); + + if (status.isSameCodeAs(UNAUTHORIZED)) { + return response.bodyToMono(String.class) + .defaultIfEmpty("Unauthorized") + .flatMap(body -> Mono.error(new UnauthorizedException(body))); + } + + if (status.isSameCodeAs(FORBIDDEN)) { + return response.bodyToMono(String.class) + .defaultIfEmpty("Forbidden") + .flatMap(body -> Mono.error(new ForbiddenException(body))); + } + + if (status.isSameCodeAs(NOT_FOUND)) { + return response.bodyToMono(String.class) + .defaultIfEmpty("Not Found") + .flatMap(body -> Mono.error(new NotFoundException(body))); + } + + if (status.isSameCodeAs(CONFLICT)) { + return response.bodyToMono(String.class) + .defaultIfEmpty("Conflict") + .flatMap(body -> Mono.error(new ConflictException(body))); + } + + if (status.isSameCodeAs(TOO_MANY_REQUESTS)) { + return response.bodyToMono(String.class) + .defaultIfEmpty("Too Many Requests") + .flatMap(body -> Mono.error(new TooManyRequestsException(body))); + } + + return response.bodyToMono(String.class) + .defaultIfEmpty("Bad Request") + .flatMap(body -> Mono.error(new BadRequestException(body))); + } + + private static class AuthTokenExpiry + implements Expiry { + private static final Duration DEFAULT_EXPIRY = Duration.ofMinutes(5); + + @Override + public long expireAfterCreate(RegistryAuthKey key, + DockerRegistryAuthResponse value, + long currentTime) { + if (value.getExpiresIn() != null) { + // Expire at 3/4 of the token's actual expiration time + return Duration.ofSeconds(value.getExpiresIn()).toNanos() * 3 / 4; + } + // Default to 5 minutes if no expiry info is provided + return DEFAULT_EXPIRY.toNanos(); + } + + @Override + public long expireAfterUpdate(RegistryAuthKey key, + DockerRegistryAuthResponse value, + long currentTime, long currentDuration) { + return currentDuration; + } + + @Override + public long expireAfterRead(RegistryAuthKey key, + DockerRegistryAuthResponse value, + long currentTime, long currentDuration) { + return currentDuration; + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/docker/DockerRegistryStubService.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/docker/DockerRegistryStubService.java new file mode 100644 index 0000000000..32ee6c6dd7 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/docker/DockerRegistryStubService.java @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.docker; + +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.service.annotation.GetExchange; + +public interface DockerRegistryStubService { + @GetExchange("v2/{namespace}/{repository}/manifests/{tagOrDigest}") + void getManifest(@PathVariable("namespace") String namespace, + @PathVariable("repository") String repository, + @PathVariable("tagOrDigest") String tagOrDigest, + @RequestHeader("Authorization") String authorization); + +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/AwsSignatureUtils.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/AwsSignatureUtils.java new file mode 100644 index 0000000000..e87c97cb3a --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/AwsSignatureUtils.java @@ -0,0 +1,89 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.ecr; + +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.toCanonicalRequest; +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.toIsoBasicDateFormat; +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.toShortDateFormat; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrRegistryUtils.AMZ_CONTENT_TYPE; +import static com.nvidia.boot.registries.util.HashUtils.getSignatureKey; +import static com.nvidia.boot.registries.util.HashUtils.hmacSha256Hex; +import static com.nvidia.boot.registries.util.HashUtils.toSha256Hex; + +import com.nvidia.boot.registries.service.registry.client.ecr.dto.AwsAuthHeaders; +import java.time.Instant; +import java.util.TreeMap; +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@UtilityClass +public class AwsSignatureUtils { + + private static final String CREDENTIAL = "Credential"; + private static final String SIGNED_HEADERS = "SignedHeaders"; + private static final String SIGNATURE = "Signature"; + private static final String AWS_SIG_ALGORITHM = "AWS4-HMAC-SHA256"; + private static final String AWS_REQUEST_TYPE = "aws4_request"; + private static final String HOST_HEADER = "host"; + private static final String X_AMZ_DATE_HEADER = "x-amz-date"; + private static final String X_AMZ_CONTENT_SHA256_HEADER = "x-amz-content-sha256"; + private static final String X_AMZ_TARGET_HEADER = "x-amz-target"; + private static final String CONTENT_TYPE_HEADER = "content-type"; + + private static final String ECR_POST_METHOD = "POST"; + private static final String ECR_ROOT_PATH = "/"; + + public static AwsAuthHeaders signRequest( + String region, + String service, + String accessKeyId, + String secretAccessKey, + String requestBody, + String target, + String host) { + var now = Instant.now(); + var amzDate = toIsoBasicDateFormat(now); + var dateStamp = toShortDateFormat(now); + var amzContentSha256 = toSha256Hex(requestBody); + + var headers = new TreeMap(); + headers.put(CONTENT_TYPE_HEADER, AMZ_CONTENT_TYPE); + headers.put(HOST_HEADER, host); + headers.put(X_AMZ_CONTENT_SHA256_HEADER, amzContentSha256); + headers.put(X_AMZ_DATE_HEADER, amzDate); + headers.put(X_AMZ_TARGET_HEADER, target); + + var signedHeaders = String.join(";", headers.keySet()); + var canonicalRequest = toCanonicalRequest(ECR_POST_METHOD, ECR_ROOT_PATH, "", + headers, amzContentSha256); + var credentialScope = dateStamp + "/" + region + "/" + service + "/" + AWS_REQUEST_TYPE; + var stringToSign = AWS_SIG_ALGORITHM + "\n" + + amzDate + "\n" + + credentialScope + "\n" + + toSha256Hex(canonicalRequest); + var signingKey = getSignatureKey("AWS4" + secretAccessKey, dateStamp, region, + service, AWS_REQUEST_TYPE); + var signature = hmacSha256Hex(signingKey, stringToSign); + var authorization = AWS_SIG_ALGORITHM + " " + + CREDENTIAL + "=" + accessKeyId + "/" + credentialScope + ", " + + SIGNED_HEADERS + "=" + signedHeaders + ", " + + SIGNATURE + "=" + signature; + return new AwsAuthHeaders(authorization, amzDate, amzContentSha256); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/EcrArtifactRegistryStubService.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/EcrArtifactRegistryStubService.java new file mode 100644 index 0000000000..c1c64208ac --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/EcrArtifactRegistryStubService.java @@ -0,0 +1,145 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.ecr; + +import java.net.URI; +import java.util.List; +import lombok.Builder; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.service.annotation.PostExchange; + +public interface EcrArtifactRegistryStubService { + + @PostExchange(value = "/") + DescribeImagesResponse describePrivateArtifacts( + URI baseUrl, + @RequestHeader("Authorization") String authorization, + @RequestHeader("Content-Type") String contentType, + @RequestHeader("X-Amz-Target") String xAmzTarget, + @RequestHeader("X-Amz-Date") String xAmzDate, + @RequestHeader("X-Amz-Content-Sha256") String xAmzContentSha256, + @RequestBody DescribeImagesRequest request + ); + + @PostExchange(value = "/") + DescribeImagesResponse describePublicArtifacts( + @RequestHeader("Authorization") String authorization, + @RequestHeader("Content-Type") String contentType, + @RequestHeader("X-Amz-Target") String xAmzTarget, + @RequestHeader("X-Amz-Date") String xAmzDate, + @RequestHeader("X-Amz-Content-Sha256") String xAmzContentSha256, + @RequestBody DescribeImagesRequest request + ); + + @PostExchange(value = "/") + PrivateAuthorizationTokenResponse getEcrPrivateAuthorizationToken( + URI baseUrl, + @RequestHeader("Authorization") String authorization, + @RequestHeader("Content-Type") String contentType, + @RequestHeader("X-Amz-Target") String xAmzTarget, + @RequestHeader("X-Amz-Date") String xAmzDate, + @RequestHeader("X-Amz-Content-Sha256") String xAmzContentSha256, + @RequestBody String request + ); + + @PostExchange(value = "/") + PublicAuthorizationTokenResponse getEcrPublicAuthorizationToken( + @RequestHeader("Authorization") String authorization, + @RequestHeader("Content-Type") String contentType, + @RequestHeader("X-Amz-Target") String xAmzTarget, + @RequestHeader("X-Amz-Date") String xAmzDate, + @RequestHeader("X-Amz-Content-Sha256") String xAmzContentSha256, + @RequestBody String request + ); + + @Value + @Jacksonized + @Builder + class DescribeImagesRequest { + + String repositoryName; + String registryId; + List imageIds; + + @Value + @Jacksonized + @Builder + public static class ImageId { + + String imageTag; + String imageDigest; + } + } + + @Value + @Jacksonized + @Builder + class DescribeImagesResponse { + + List imageDetails; + + @Value + @Jacksonized + @Builder + public static class ImageDetail { + + String imageDigest; + String imageManifestMediaType; + List imageTags; + String registryId; + String repositoryName; + } + } + + @Value + @Jacksonized + @Builder + class PrivateAuthorizationTokenResponse { + + List authorizationData; + + @Value + @Jacksonized + @Builder + public static class AuthorizationData { + + String authorizationToken; + Double expiresAt; + } + } + + @Value + @Jacksonized + @Builder + class PublicAuthorizationTokenResponse { + + AuthorizationData authorizationData; + + @Value + @Jacksonized + @Builder + public static class AuthorizationData { + + String authorizationToken; + Double expiresAt; + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/EcrArtifactValidationService.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/EcrArtifactValidationService.java new file mode 100644 index 0000000000..91c41d5008 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/EcrArtifactValidationService.java @@ -0,0 +1,146 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.ecr; + +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.toAccessKeyCredentials; +import static com.nvidia.boot.registries.service.registry.client.ecr.AwsSignatureUtils.signRequest; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrRegistryUtils.AMZ_CONTENT_TYPE; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrRegistryUtils.ECR_PUBLIC_REGISTRY_BASE_URI; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.registries.service.registry.RegistryMapperService; +import com.nvidia.boot.registries.service.registry.client.ecr.EcrArtifactRegistryStubService.DescribeImagesRequest; +import com.nvidia.boot.registries.service.registry.client.ecr.EcrArtifactRegistryStubService.DescribeImagesRequest.ImageId; +import com.nvidia.boot.registries.service.registry.client.ecr.EcrArtifactRegistryStubService.DescribeImagesResponse; +import com.nvidia.boot.registries.service.registry.client.ecr.dto.EcrArtifactComponents; +import io.micrometer.common.util.StringUtils; +import java.net.URI; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +@Slf4j +@Service +public class EcrArtifactValidationService { + + private static final String MESG_ARTIFACT_NOT_FOUND = + "Artifact '%s' not found in ECR registry"; + private static final String MESG_SUCCESSFULLY_VALIDATED_ECR_IMAGE = + "Successfully validated ECR image: %s"; + + private static final String ECR_SERVICE = "ecr"; + private static final String AMZ_ECR_TARGET = + "AmazonEC2ContainerRegistry_V20150921.DescribeImages"; + private static final String ECR_PUBLIC_SERVICE = "ecr-public"; + private static final String AMZ_ECR_PUBLIC_TARGET = + "SpencerFrontendService.DescribeImages"; + private static final String ECR_PUBLIC_REGION = "us-east-1"; + + public static void validatePrivateArtifact( + EcrArtifactRegistryStubService ecrStubService, + String ecrEndpointUrl, + EcrArtifactComponents components, + String base64ApiKey) { + + var accessKeyCredentials = toAccessKeyCredentials(base64ApiKey); + var accessKeyId = accessKeyCredentials.accessKeyId(); + var secretAccessKey = accessKeyCredentials.secretAccessKey(); + + var request = DescribeImagesRequest.builder() + .registryId(components.registryId()) + .repositoryName(components.repositoryName()) + .imageIds(List.of(getArtifactId(components))) + .build(); + var requestBody = RegistryMapperService.toJson(request); + var awsAuthHeaders = signRequest( + components.region(), + ECR_SERVICE, + accessKeyId, + secretAccessKey, + requestBody, + AMZ_ECR_TARGET, + "ecr." + components.region() + ".amazonaws.com" + ); + + var response = ecrStubService.describePrivateArtifacts( + URI.create(ecrEndpointUrl), + awsAuthHeaders.authorization(), + AMZ_CONTENT_TYPE, + AMZ_ECR_TARGET, + awsAuthHeaders.xAmzDate(), + awsAuthHeaders.xAmzContentSha256(), + request); + handleResponse(response, components); + } + + public static void validatePublicArtifact( + EcrArtifactRegistryStubService ecrStubService, + EcrArtifactComponents components, + String base64ApiKey) { + var accessKeyCredentials = toAccessKeyCredentials(base64ApiKey); + var accessKeyId = accessKeyCredentials.accessKeyId(); + var secretAccessKey = accessKeyCredentials.secretAccessKey(); + + var request = DescribeImagesRequest.builder() + .repositoryName(components.repositoryName()) + .imageIds(List.of(getArtifactId(components))) + .build(); + var requestBody = RegistryMapperService.toJson(request); + var awsAuthHeaders = signRequest( + ECR_PUBLIC_REGION, + ECR_PUBLIC_SERVICE, + accessKeyId, + secretAccessKey, + requestBody, + AMZ_ECR_PUBLIC_TARGET, + URI.create(ECR_PUBLIC_REGISTRY_BASE_URI).getHost() + ); + + var response = ecrStubService.describePublicArtifacts( + awsAuthHeaders.authorization(), + AMZ_CONTENT_TYPE, + AMZ_ECR_PUBLIC_TARGET, + awsAuthHeaders.xAmzDate(), + awsAuthHeaders.xAmzContentSha256(), + request); + handleResponse(response, components); + } + + private static ImageId getArtifactId(EcrArtifactComponents components) { + if (StringUtils.isNotEmpty(components.digest())) { + return ImageId.builder().imageDigest(components.digest()).build(); + } else { + return ImageId.builder().imageTag(components.tag()).build(); + } + } + + private static void handleResponse( + DescribeImagesResponse response, + EcrArtifactComponents components) { + if (response == null || CollectionUtils.isEmpty(response.getImageDetails())) { + var msg = MESG_ARTIFACT_NOT_FOUND.formatted( + RegistryMapperService.toJson(components)); + log.error(msg); + throw new BadRequestException(msg); + } + var msg = MESG_SUCCESSFULLY_VALIDATED_ECR_IMAGE + .formatted(RegistryMapperService.toJson(components)); + log.info(msg); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/EcrCredentialValidationService.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/EcrCredentialValidationService.java new file mode 100644 index 0000000000..82b8d6d1cc --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/EcrCredentialValidationService.java @@ -0,0 +1,101 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.ecr; + +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.toAccessKeyCredentials; +import static com.nvidia.boot.registries.service.registry.client.ecr.AwsSignatureUtils.signRequest; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrRegistryUtils.AMZ_CONTENT_TYPE; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrRegistryUtils.ECR_PUBLIC_REGISTRY_BASE_URI; + +import com.nvidia.boot.registries.service.registry.client.ecr.EcrArtifactRegistryStubService.PrivateAuthorizationTokenResponse; +import com.nvidia.boot.registries.service.registry.client.ecr.EcrArtifactRegistryStubService.PublicAuthorizationTokenResponse; +import java.net.URI; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class EcrCredentialValidationService { + + private static final String ECR_SERVICE = "ecr"; + private static final String AMZ_ECR_GET_AUTH_TOKEN_TARGET = + "AmazonEC2ContainerRegistry_V20150921.GetAuthorizationToken"; + private static final String ECR_PUBLIC_SERVICE = "ecr-public"; + private static final String AMZ_ECR_PUBLIC_GET_AUTH_TOKEN_TARGET = + "SpencerFrontendService.GetAuthorizationToken"; + private static final String ECR_PUBLIC_REGION = "us-east-1"; + + public static PrivateAuthorizationTokenResponse getEcrPrivateAuthorizationToken( + EcrArtifactRegistryStubService ecrStubService, + String ecrEndpointUrl, + String region, + String base64EncodedSecret) { + + var accessKeyCredentials = toAccessKeyCredentials(base64EncodedSecret); + var accessKeyId = accessKeyCredentials.accessKeyId(); + var secretAccessKey = accessKeyCredentials.secretAccessKey(); + + var requestBody = "{}"; + var awsAuthHeaders = signRequest( + region, + ECR_SERVICE, + accessKeyId, + secretAccessKey, + requestBody, + AMZ_ECR_GET_AUTH_TOKEN_TARGET, + "ecr." + region + ".amazonaws.com" + ); + + return ecrStubService.getEcrPrivateAuthorizationToken( + URI.create(ecrEndpointUrl), + awsAuthHeaders.authorization(), + AMZ_CONTENT_TYPE, + AMZ_ECR_GET_AUTH_TOKEN_TARGET, + awsAuthHeaders.xAmzDate(), + awsAuthHeaders.xAmzContentSha256(), + requestBody); + } + + public static PublicAuthorizationTokenResponse getEcrPublicAuthorizationToken( + EcrArtifactRegistryStubService ecrStubService, + String base64EncodedSecret) { + + var accessKeyCredentials = toAccessKeyCredentials(base64EncodedSecret); + var accessKeyId = accessKeyCredentials.accessKeyId(); + var secretAccessKey = accessKeyCredentials.secretAccessKey(); + + var requestBody = "{}"; + var awsAuthHeaders = signRequest( + ECR_PUBLIC_REGION, + ECR_PUBLIC_SERVICE, + accessKeyId, + secretAccessKey, + requestBody, + AMZ_ECR_PUBLIC_GET_AUTH_TOKEN_TARGET, + URI.create(ECR_PUBLIC_REGISTRY_BASE_URI).getHost() + ); + + return ecrStubService.getEcrPublicAuthorizationToken( + awsAuthHeaders.authorization(), + AMZ_CONTENT_TYPE, + AMZ_ECR_PUBLIC_GET_AUTH_TOKEN_TARGET, + awsAuthHeaders.xAmzDate(), + awsAuthHeaders.xAmzContentSha256(), + requestBody); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/EcrRegistryUtils.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/EcrRegistryUtils.java new file mode 100644 index 0000000000..ff0737baff --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/EcrRegistryUtils.java @@ -0,0 +1,281 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.ecr; + +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.JSON_MAPPER; +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.FORBIDDEN; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.BootResponseException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.UpstreamException; +import com.nvidia.boot.registries.service.registry.client.ecr.dto.EcrArtifactComponents; +import io.micrometer.common.util.StringUtils; +import java.time.Duration; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.codec.json.JacksonJsonDecoder; +import org.springframework.http.codec.json.JacksonJsonEncoder; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import software.amazon.awssdk.regions.Region; + +@Slf4j +@UtilityClass +public class EcrRegistryUtils { + + public static final String AMZ_CONTENT_TYPE = "application/x-amz-json-1.1"; + public static final String ECR_PRIVATE_REGISTRY_GLOBAL_HOSTNAME = "dkr.ecr.amazonaws.com"; + public static final String ECR_PUBLIC_REGISTRY_HOSTNAME = "public.ecr.aws"; + public static final String ECR_PUBLIC_REGISTRY_BASE_URI = + "https://api.ecr-public.us-east-1.amazonaws.com"; + // Pattern for ECR private hostname: {aws-account-id}.dkr.ecr.{region}.amazonaws.com + public static final Pattern ECR_PRIVATE_HOSTNAME_PATTERN = + buildEcrPrivateHostnamePattern(); + // Pattern for parsing ECR private hostname with named region group + public static final Pattern ECR_PRIVATE_HOSTNAME_REGION_PATTERN = + Pattern.compile("^\\d{12}\\.dkr\\.ecr\\.(?[a-z0-9-]+)\\.amazonaws\\.com$"); + + private static final JacksonJsonDecoder JSON_DECODER_AMZ = + new JacksonJsonDecoder(JSON_MAPPER, + MediaType.APPLICATION_JSON, + MediaType.parseMediaType(AMZ_CONTENT_TYPE)); + private static final JacksonJsonEncoder JSON_ENCODER_AMZ = + new JacksonJsonEncoder(JSON_MAPPER, + MediaType.APPLICATION_JSON, + MediaType.parseMediaType(AMZ_CONTENT_TYPE)); + private static final String DEFAULT_TAG = "latest"; + + // Constants for error messages. + private static final String MESG_EMPTY_ARTIFACT_URL = + "ECR artifact URL cannot be null or empty"; + private static final String MESG_INVALID_ECR_ARTIFACT_URL_FORMAT = + "Invalid ECR artifact URL '%s' format"; + private static final String MESG_EMPTY_ECR_API_BASE_URL = + "ECR registry base url cannot be null or empty"; + private static final String MESG_INVALID_ECR_API_BASE_URL = + "Invalid ECR registry base url '%s'"; + private static final String MESG_MISSING_REGION_ECR_API_BASE_URL = + "Missing region for ECR registry base url '%s'"; + private static final String MESG_ECR_5XX_RESPONSE = + "ECR response with 5xx error %s"; + private static final String MESG_ECR_RESPONSE_STATUS_CODE = + "ECR response status code '%d'"; + private static final String INVALID_ECR_PRIVATE_HOSTNAME_FORMAT = + "Invalid ECR private hostname format"; + + public static WebClient.Builder getEcrWebClientBuilder(WebClient.Builder webClientBuilder, + Duration timeout) { + return webClientBuilder + .codecs(configurer -> { + configurer.defaultCodecs().jacksonJsonDecoder(JSON_DECODER_AMZ); + configurer.defaultCodecs().jacksonJsonEncoder(JSON_ENCODER_AMZ); + }) + .defaultStatusHandler(HttpStatusCode::is4xxClientError, + EcrRegistryUtils::get4xxException) + .defaultStatusHandler(HttpStatusCode::is5xxServerError, + EcrRegistryUtils::get5xxException) + .filter((request, next) -> next.exchange(request).timeout(timeout)); + } + + public static EcrArtifactComponents parsePrivateArtifactUrl( + String artifactUrl, + Pattern pattern) { + var matcher = validateArtifactUrl(artifactUrl, pattern); + var registryId = matcher.group("registryId"); + var region = matcher.group("region"); + var repository = matcher.group("repository"); + var tag = matcher.group("tag"); + var digest = matcher.group("digest"); + + if (StringUtils.isEmpty(tag) && StringUtils.isEmpty(digest)) { + tag = DEFAULT_TAG; + } + + return new EcrArtifactComponents(registryId, region, repository, tag, digest); + } + + public static EcrArtifactComponents parsePublicArtifactUrl( + String artifactUrl, + Pattern pattern) { + var matcher = validateArtifactUrl(artifactUrl, pattern); + var repository = matcher.group("repository"); + var tag = matcher.group("tag"); + var digest = matcher.group("digest"); + + if (StringUtils.isEmpty(tag) && StringUtils.isEmpty(digest)) { + tag = DEFAULT_TAG; + } + + return new EcrArtifactComponents(null, null, repository, tag, digest); + } + + private static Matcher validateArtifactUrl( + String artifactUrl, + Pattern pattern) { + + if (StringUtils.isEmpty(artifactUrl)) { + log.error(MESG_EMPTY_ARTIFACT_URL); + throw new BadRequestException(MESG_EMPTY_ARTIFACT_URL); + } + + var matcher = pattern.matcher(artifactUrl); + if (!matcher.matches()) { + var mesg = MESG_INVALID_ECR_ARTIFACT_URL_FORMAT.formatted(artifactUrl); + log.error(mesg); + throw new BadRequestException(mesg); + } + + return matcher; + } + + // expected final format: https://ecr.{region}.amazonaws.com + public static String toBaseUrlForEcrPrivateRegistry(String baseUrl, String region) { + if (StringUtils.isEmpty(baseUrl)) { + log.error(MESG_EMPTY_ECR_API_BASE_URL); + throw new IllegalArgumentException(MESG_EMPTY_ECR_API_BASE_URL); + } + + if (StringUtils.isEmpty(region)) { + var mesg = MESG_MISSING_REGION_ECR_API_BASE_URL.formatted(baseUrl); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + + // Design for 3rd Party Registry requires hostnames to be unique. However, when + // integration tests involving multiple registries are being executed, this + // becomes an issue as all the registries use "localhost" as the hostname in the + // baseUrl. To make the hostnames unique in the application-test.yaml files of + // apps such as NVCF API and NVCT API, we use localhost-: + // as the baseUrl. For example, localhost-ngc:, localhost-docker:, + // etc. When using the baseUrl, we remove the `-` part so that + // the client can communicate with the registry-specific mock server. + if (baseUrl.contains("localhost")) { + return baseUrl.replace("-ecr", ""); + } + + if (baseUrl.contains(ECR_PRIVATE_REGISTRY_GLOBAL_HOSTNAME)) { + return "https://ecr.%s.amazonaws.com".formatted(region); + } else { + var mesg = MESG_INVALID_ECR_API_BASE_URL.formatted(baseUrl); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + } + + public static String toBaseUrlForEcrPublicRegistry(String baseUrl) { + if (StringUtils.isEmpty(baseUrl)) { + log.error(MESG_EMPTY_ECR_API_BASE_URL); + throw new IllegalArgumentException(MESG_EMPTY_ECR_API_BASE_URL); + } + + // Design for 3rd Party Registry requires hostnames to be unique. However, when + // integration tests involving multiple registries are being executed, this + // becomes an issue as all the registries use "localhost" as the hostname in the + // baseUrl. To make the hostnames unique in the application-test.yaml files of + // apps such as NVCF API and NVCT API, we use localhost-: + // as the baseUrl. For example, localhost-ngc:, localhost-docker:, + // etc. When using the baseUrl, we remove the `-` part so that + // the client can communicate with the registry-specific mock server. + if (baseUrl.contains("localhost")) { + return baseUrl.replace("-ecr-public", ""); + } + + if (baseUrl.contains(ECR_PUBLIC_REGISTRY_HOSTNAME)) { + return ECR_PUBLIC_REGISTRY_BASE_URI; + } else { + var mesg = MESG_INVALID_ECR_API_BASE_URL.formatted(baseUrl); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + } + + private static Mono get4xxException(ClientResponse response) { + var status = response.statusCode(); + var msg = MESG_ECR_RESPONSE_STATUS_CODE.formatted(response.statusCode().value()); + log.error(msg); + + if (status.isSameCodeAs(FORBIDDEN)) { + return response.bodyToMono(String.class) + .defaultIfEmpty("Forbidden") + .flatMap(body -> Mono.error(new ForbiddenException(body))); + } + + if (status.isSameCodeAs(BAD_REQUEST)) { + return response.bodyToMono(String.class) + .defaultIfEmpty("Bad Request") + .flatMap(body -> Mono.error(new BadRequestException(body))); + } + + return response.bodyToMono(String.class) + .defaultIfEmpty("Bad Request") + .flatMap(body -> Mono.error(new BadRequestException(body))); + } + + private static Mono get5xxException(ClientResponse response) { + var errorMsg = MESG_ECR_5XX_RESPONSE.formatted(response.statusCode()); + log.error(errorMsg); + + return response.bodyToMono(String.class) + .switchIfEmpty(Mono.error(new UpstreamException(errorMsg))) + .flatMap(body -> { + var mesg = errorMsg + " - " + body; + log.error(mesg); + return Mono.error(new UpstreamException(mesg)); + }); + } + + private static Pattern buildEcrPrivateHostnamePattern() { + String regionsAlternation = Region.regions().stream() + .map(Region::id) + .map(Pattern::quote) // Escape special regex characters in region names + .collect(Collectors.joining("|")); + + // Build pattern: {aws-account-id}.dkr.ecr.{region}.amazonaws.com + String patternString = String.format( + "\\d{12}\\.dkr\\.ecr\\.(%s)\\.amazonaws\\.com", + regionsAlternation + ); + + return Pattern.compile(patternString); + } + + /** + * Parses the AWS region from an ECR private hostname. + * + * @param hostname The ECR private hostname (format: {account-id}.dkr.ecr.{region}.amazonaws.com) + * @return The AWS region extracted from the hostname + * @throws BadRequestException if the hostname format is invalid + */ + public static String parseRegionFromEcrPrivateHostname(String hostname) { + var matcher = ECR_PRIVATE_HOSTNAME_REGION_PATTERN.matcher(hostname); + + if (!matcher.matches()) { + throw new BadRequestException(INVALID_ECR_PRIVATE_HOSTNAME_FORMAT); + } + + return matcher.group("region"); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/dto/AwsAuthHeaders.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/dto/AwsAuthHeaders.java new file mode 100644 index 0000000000..a9ec0ae04e --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/dto/AwsAuthHeaders.java @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.ecr.dto; + +public record AwsAuthHeaders( + String authorization, + String xAmzDate, + String xAmzContentSha256) { + +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/dto/EcrArtifactComponents.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/dto/EcrArtifactComponents.java new file mode 100644 index 0000000000..9128df04c0 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/dto/EcrArtifactComponents.java @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.ecr.dto; + +public record EcrArtifactComponents( + String registryId, + String region, + String repositoryName, + String tag, + String digest) { + +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/pub/EcrPublicArtifactRegistryClient.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/pub/EcrPublicArtifactRegistryClient.java new file mode 100644 index 0000000000..627eacc2ea --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/pub/EcrPublicArtifactRegistryClient.java @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.ecr.pub; + +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrArtifactValidationService.validatePublicArtifact; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrCredentialValidationService.getEcrPublicAuthorizationToken; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrRegistryUtils.getEcrWebClientBuilder; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrRegistryUtils.parsePublicArtifactUrl; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrRegistryUtils.toBaseUrlForEcrPublicRegistry; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.registries.service.registry.client.ecr.EcrArtifactRegistryStubService; +import java.net.URI; +import java.time.Duration; +import java.util.regex.Pattern; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.support.WebClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +/** + * Client for interacting with ECR Public Artifact Registry. + * Handles artifact image URL parsing, signature generation, and validation. + */ +@Slf4j +public class EcrPublicArtifactRegistryClient { + + /** + * Expected format: oci://public.ecr.aws/{registry_alias}/{repository}:{tag} + * or oci://public.ecr.aws/{registry_alias}/{repository}@{digest} + */ + public static final Pattern ECR_PUBLIC_HELM_CHART_URL_PATTERN = Pattern.compile( + "^oci://public\\.ecr\\.aws/(?[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?)/(?[a-z0-9](?:[a-z0-9._/-]*[a-z0-9])?)(?::(?[A-Za-z0-9._-]+))?(?:@(?sha256:[a-f0-9]{64}))?$" + ); + + private final EcrArtifactRegistryStubService ecrPublicStubService; + @Getter + private final String hostname; + + public EcrPublicArtifactRegistryClient( + WebClient.Builder webClientBuilder, // Prototype-scoped - Safe to mutate. + String hostname, + Duration callTimeout) { + var artifactRegistryUrl = hostname.startsWith("http") + ? hostname : "https://" + hostname; + this.hostname = URI.create(artifactRegistryUrl).getHost(); + var timeout = callTimeout != null ? callTimeout : Duration.ofSeconds(30); + var webClient = getEcrWebClientBuilder(webClientBuilder, timeout) + .baseUrl(toBaseUrlForEcrPublicRegistry(artifactRegistryUrl)) + .build(); + var adapter = WebClientAdapter.create(webClient); + var factory = HttpServiceProxyFactory.builderFor(adapter).build(); + this.ecrPublicStubService = factory.createClient(EcrArtifactRegistryStubService.class); + } + + /** + * Validates a helm chart by checking its existence in the ECR Public registry. + * + * @param helmChartUrl The helm chart URL to validate (format: oci://public.ecr.aws/{repository}:{tag}) + * @param base64ApiKey Base64 encoded credentials (accessKeyId:secretAccessKey) + * @throws BadRequestException if the chart is invalid or inaccessible + */ + public void validateHelmChart(String helmChartUrl, String base64ApiKey) { + var components = parsePublicArtifactUrl(helmChartUrl, ECR_PUBLIC_HELM_CHART_URL_PATTERN); + validatePublicArtifact(ecrPublicStubService, components, base64ApiKey); + } + + public void validateCredential(String hostname, String base64EncodedSecret) { + getEcrPublicAuthorizationToken(ecrPublicStubService, base64EncodedSecret); + log.info("Successfully validated ECR public credentials for hostname: {}", hostname); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/pub/EcrPublicContainerRegistryClient.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/pub/EcrPublicContainerRegistryClient.java new file mode 100644 index 0000000000..de42523304 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/pub/EcrPublicContainerRegistryClient.java @@ -0,0 +1,89 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.ecr.pub; + +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrArtifactValidationService.validatePublicArtifact; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrCredentialValidationService.getEcrPublicAuthorizationToken; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrRegistryUtils.getEcrWebClientBuilder; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrRegistryUtils.parsePublicArtifactUrl; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrRegistryUtils.toBaseUrlForEcrPublicRegistry; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.registries.service.registry.client.ecr.EcrArtifactRegistryStubService; +import java.net.URI; +import java.time.Duration; +import java.util.regex.Pattern; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.support.WebClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +/** + * Client for interacting with Amazon ECR Public Registry. + * Handles container image URL parsing and validation using AWS ECR Public DescribeImages API. + */ +@Slf4j +public class EcrPublicContainerRegistryClient { + + /** + * Expected format: public.ecr.aws/{registry_alias}/{repository}:{tag} + * or public.ecr.aws/{registry_alias}/{repository}@{digest} + */ + public static final Pattern ECR_PUBLIC_CONTAINER_IMAGE_URL_PATTERN = Pattern.compile( + "^public\\.ecr\\.aws/(?[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?)/(?[a-z0-9](?:[a-z0-9._/-]*[a-z0-9])?)(?::(?[A-Za-z0-9._-]+))?(?:@(?sha256:[a-f0-9]{64}))?$" + ); + + private final EcrArtifactRegistryStubService ecrPublicStubService; + @Getter + private final String hostname; + + public EcrPublicContainerRegistryClient( + WebClient.Builder webClientBuilder, // Prototype-scoped - Safe to mutate. + String hostname, + Duration callTimeout) { + var containerRegistryUrl = hostname.startsWith("http") + ? hostname : "https://" + hostname; + this.hostname = URI.create(containerRegistryUrl).getHost(); + var timeout = callTimeout != null ? callTimeout : Duration.ofSeconds(30); + var webClient = getEcrWebClientBuilder(webClientBuilder, timeout) + .baseUrl(toBaseUrlForEcrPublicRegistry(containerRegistryUrl)) + .build(); + var adapter = WebClientAdapter.create(webClient); + var factory = HttpServiceProxyFactory.builderFor(adapter).build(); + this.ecrPublicStubService = factory.createClient(EcrArtifactRegistryStubService.class); + } + + /** + * Validates a container image by checking its existence in the ECR Public registry using DescribeImages API. + * + * @param containerImageUrl The container image URL to validate (format: public.ecr.aws/{registry_alias}/{repository}:{tag}) + * @param base64ApiKey Base64 encoded credentials (accessKeyId:secretAccessKey) + * @throws BadRequestException if the image is invalid or inaccessible + */ + public void validateContainerImage(String containerImageUrl, String base64ApiKey) { + var components = + parsePublicArtifactUrl(containerImageUrl, ECR_PUBLIC_CONTAINER_IMAGE_URL_PATTERN); + validatePublicArtifact(ecrPublicStubService, components, base64ApiKey); + } + + public void validateCredential(String hostname, String base64EncodedSecret) { + getEcrPublicAuthorizationToken(ecrPublicStubService, base64EncodedSecret); + log.info("Successfully validated ECR public credentials for hostname: {}", hostname); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/pvt/EcrPrivateArtifactRegistryClient.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/pvt/EcrPrivateArtifactRegistryClient.java new file mode 100644 index 0000000000..186843c88f --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/pvt/EcrPrivateArtifactRegistryClient.java @@ -0,0 +1,93 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.ecr.pvt; + +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrArtifactValidationService.validatePrivateArtifact; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrCredentialValidationService.getEcrPrivateAuthorizationToken; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrRegistryUtils.getEcrWebClientBuilder; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrRegistryUtils.parsePrivateArtifactUrl; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrRegistryUtils.parseRegionFromEcrPrivateHostname; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrRegistryUtils.toBaseUrlForEcrPrivateRegistry; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.registries.service.registry.client.ecr.EcrArtifactRegistryStubService; +import java.net.URI; +import java.time.Duration; +import java.util.regex.Pattern; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.support.WebClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +/** + * Client for interacting with ECR Artifact Registry. + * Handles artifact image URL parsing, signature generation, and validation. + */ +@Slf4j +public class EcrPrivateArtifactRegistryClient { + + /** + * Expected format: oci://{aws_account_id}.dkr.ecr.{region}.amazonaws.com/{repository}:{tag} + * or oci://{aws_account_id}.dkr.ecr.{region}.amazonaws.com/{repository}@{digest} + */ + public static final Pattern ECR_HELM_CHART_URL_PATTERN = Pattern.compile( + "^oci://(?\\d{12})\\.dkr\\.ecr\\.(?[a-z0-9-]+)\\.amazonaws\\.com/(?[a-z0-9][a-z0-9._/-]*[a-z0-9])(?::(?[A-Za-z0-9._-]+))?(?:@(?sha256:[a-f0-9]{64}))?$"); + + private final EcrArtifactRegistryStubService ecrStubService; + @Getter + private String hostname; + private final String baseUrl; + + public EcrPrivateArtifactRegistryClient( + WebClient.Builder webClientBuilder, // Prototype-scoped - Safe to mutate. + String hostname, + Duration callTimeout) { + var artifactRegistryUrl = hostname.startsWith("http") + ? hostname : "https://" + hostname; + this.hostname = URI.create(artifactRegistryUrl).getHost(); + this.baseUrl = artifactRegistryUrl; + var timeout = callTimeout != null ? callTimeout : Duration.ofSeconds(30); + var webClient = getEcrWebClientBuilder(webClientBuilder, timeout).build(); + var adapter = WebClientAdapter.create(webClient); + var factory = HttpServiceProxyFactory.builderFor(adapter).build(); + this.ecrStubService = factory.createClient(EcrArtifactRegistryStubService.class); + } + + /** + * Validates a helm chart by checking its existence in the ECR registry. + * + * @param helmChartUrl The helm chart URL to validate + * @throws BadRequestException if the chart is invalid or inaccessible + */ + public void validateHelmChart(String helmChartUrl, String base64ApiKey) { + var components = parsePrivateArtifactUrl(helmChartUrl, ECR_HELM_CHART_URL_PATTERN); + validatePrivateArtifact(ecrStubService, + toBaseUrlForEcrPrivateRegistry(baseUrl, components.region()), + components, + base64ApiKey); + } + + public void validateCredential(String hostname, String base64EncodedSecret) { + var region = parseRegionFromEcrPrivateHostname(hostname); + var ecrEndpointUrl = toBaseUrlForEcrPrivateRegistry(baseUrl, region); + + getEcrPrivateAuthorizationToken(ecrStubService, ecrEndpointUrl, region, base64EncodedSecret); + log.info("Successfully validated ECR private credentials for hostname: {}", hostname); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/pvt/EcrPrivateContainerRegistryClient.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/pvt/EcrPrivateContainerRegistryClient.java new file mode 100644 index 0000000000..9cfd05d4d0 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ecr/pvt/EcrPrivateContainerRegistryClient.java @@ -0,0 +1,95 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.ecr.pvt; + +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrArtifactValidationService.validatePrivateArtifact; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrCredentialValidationService.getEcrPrivateAuthorizationToken; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrRegistryUtils.getEcrWebClientBuilder; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrRegistryUtils.parsePrivateArtifactUrl; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrRegistryUtils.parseRegionFromEcrPrivateHostname; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrRegistryUtils.toBaseUrlForEcrPrivateRegistry; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.registries.service.registry.client.ecr.EcrArtifactRegistryStubService; +import java.net.URI; +import java.time.Duration; +import java.util.regex.Pattern; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.support.WebClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +/** + * Client for interacting with Amazon ECR. + * Handles container image URL parsing and validation using AWS ECR DescribeImages API. + */ +@Slf4j +public class EcrPrivateContainerRegistryClient { + + /** + * Expected format: {aws_account_id}.dkr.ecr.{region}.amazonaws.com/{repository}:{tag} + * or {aws_account_id}.dkr.ecr.{region}.amazonaws.com/{repository}@{digest} + */ + public static final Pattern ECR_CONTAINER_IMAGE_URL_PATTERN = Pattern.compile( + "^(?\\d{12})\\.dkr\\.ecr\\.(?[a-z0-9-]+)\\.amazonaws\\.com/(?[a-z0-9][a-z0-9._/-]*[a-z0-9])(?::(?[A-Za-z0-9._-]+))?(?:@(?sha256:[a-f0-9]{64}))?$"); + + private final EcrArtifactRegistryStubService ecrStubService; + @Getter + private String hostname; + private final String baseUrl; + + public EcrPrivateContainerRegistryClient( + WebClient.Builder webClientBuilder, // Prototype-scoped - Safe to mutate. + String hostname, + Duration callTimeout) { + var containerRegistryUrl = hostname.startsWith("http") + ? hostname : "https://" + hostname; + this.hostname = URI.create(containerRegistryUrl).getHost(); + this.baseUrl = containerRegistryUrl; + var timeout = callTimeout != null ? callTimeout : Duration.ofSeconds(30); + var webClient = getEcrWebClientBuilder(webClientBuilder, timeout).build(); + var adapter = WebClientAdapter.create(webClient); + var factory = HttpServiceProxyFactory.builderFor(adapter).build(); + this.ecrStubService = factory.createClient(EcrArtifactRegistryStubService.class); + } + + /** + * Validates a container image by checking its existence in the ECR registry using DescribeImages API. + * + * @param containerImageUrl The container image URL to validate + * @param base64ApiKey Base64 encoded credentials (accessKeyId:secretAccessKey) + * @throws BadRequestException if the image is invalid or inaccessible + */ + public void validateContainerImage(String containerImageUrl, String base64ApiKey) { + var components = parsePrivateArtifactUrl(containerImageUrl, + ECR_CONTAINER_IMAGE_URL_PATTERN); + validatePrivateArtifact(ecrStubService, + toBaseUrlForEcrPrivateRegistry(baseUrl, components.region()), + components, + base64ApiKey); + } + + public void validateCredential(String hostname, String base64EncodedSecret) { + var region = parseRegionFromEcrPrivateHostname(hostname); + var ecrEndpointUrl = toBaseUrlForEcrPrivateRegistry(baseUrl, region); + + getEcrPrivateAuthorizationToken(ecrStubService, ecrEndpointUrl, region, base64EncodedSecret); + log.info("Successfully validated ECR private credentials for hostname: {}", hostname); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/harbor/HarborRegistryAuthClient.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/harbor/HarborRegistryAuthClient.java new file mode 100644 index 0000000000..08d40f7c6e --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/harbor/HarborRegistryAuthClient.java @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.harbor; + +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.buildUrlWithQueryParams; +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.toRegistryBaseUrl; + +import com.nvidia.boot.registries.service.registry.client.oci.OciRegistryAuthClient; +import java.time.Duration; +import java.util.LinkedHashMap; +import lombok.Getter; +import org.apache.commons.lang3.StringUtils; +import org.springframework.web.reactive.function.client.WebClient; + +public class HarborRegistryAuthClient extends OciRegistryAuthClient { + + @Getter + private final String authBaseUrl; + private static final String AUTH_SERVICE = "harbor-registry"; + + public HarborRegistryAuthClient( + WebClient.Builder webClientBuilder, // Prototype-scoped - Safe to mutate. + String authBaseUrl, + Duration callTimeout) { + super(webClientBuilder, callTimeout); + this.authBaseUrl = authBaseUrl; + } + + @Override + protected String getCanonicalAuthTokenUrl(String registryHost, String name) { + var baseUrl = toRegistryBaseUrl(registryHost, authBaseUrl) + "/service/token"; + + var queryParams = new LinkedHashMap(); + queryParams.put("service", AUTH_SERVICE); + + if (StringUtils.isNotBlank(name)) { + queryParams.put("scope", "repository:" + name + ":pull"); + } + + return buildUrlWithQueryParams(baseUrl, queryParams); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/harbor/HarborRegistryClient.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/harbor/HarborRegistryClient.java new file mode 100644 index 0000000000..4aa0380f6d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/harbor/HarborRegistryClient.java @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.harbor; + +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.normalizeUrl; +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.toBaseAuthUrl; +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.toRegistryBaseUrl; + +import com.nvidia.boot.registries.service.registry.client.oci.OciRegistryClient; +import java.net.URI; +import java.time.Duration; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.reactive.function.client.WebClient; + +@Slf4j +public class HarborRegistryClient extends OciRegistryClient { + + @Getter + private final String hostname; + private final String baseUrl; + + public HarborRegistryClient( + WebClient.Builder webClientBuilder, // Prototype-scoped - Safe to mutate. + String hostname, + Duration callTimeout, + String authBaseUrl) { + super(webClientBuilder.clone(), + callTimeout, + new HarborRegistryAuthClient(webClientBuilder.clone(), + toBaseAuthUrl(authBaseUrl, hostname), + callTimeout)); + var registryUrl = normalizeUrl(hostname); + this.hostname = URI.create(registryUrl).getHost(); + this.baseUrl = registryUrl; + } + + @Override + protected String getRegistryBaseUrl(String registryHost) { + return toRegistryBaseUrl(registryHost, baseUrl); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcArtifactRegistryClient.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcArtifactRegistryClient.java new file mode 100644 index 0000000000..bcb02575d6 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcArtifactRegistryClient.java @@ -0,0 +1,325 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.ngc; + +import static com.nvidia.boot.registries.service.registry.client.ngc.NgcRegistryUtils.convertArtifactFileUrlToSizeUrl; +import static com.nvidia.boot.registries.service.registry.client.ngc.NgcRegistryUtils.removeArtifactHostName; + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Expiry; +import com.github.benmanes.caffeine.cache.LoadingCache; +import com.github.benmanes.caffeine.cache.Scheduler; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.Streams; +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.UpstreamException; +import com.nvidia.boot.registries.service.registry.auth.AuthServiceStub; +import com.nvidia.boot.registries.service.registry.client.WebClientUtils; +import com.nvidia.boot.registries.service.registry.dto.ArtifactFile; +import com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum; +import jakarta.validation.constraints.PositiveOrZero; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; +import java.util.List; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.NotImplementedException; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@Slf4j +public class NgcArtifactRegistryClient { + + private static final String MESG_UNKNOWN_ARTIFACT_URL_TYPE = + "Unknown Artifact URL type '%s'"; + private static final String MESG_NULL_RESPONSE = + "Null response from Artifact Registry for artifact path '%s'"; + private static final String MESG_INVALID_OAUTH_SERVER_RESPONSE = + "Invalid response from Artifact Registry's OAuth Server"; + private static final String MESG_INVALID_NGC_HELM_CHART_URL_FORMAT_RESPONSE = + "Invalid NGC helm chart url format"; + private static final String MESG_NULL_TOKEN_RESPONSE = + "Null response from getting token from registry %s"; + private static final int PAGE_SIZE = 100; + private static final long OAUTH_TTL_IN_SECONDS = 900; // 15 minutes + // The Regex is inspired from NGC helm service implementation + // https://gitlab-master.nvidia.com/ngc/cloud/helmchart-registry/-/blob/main/internal/proxy/helm_registry_proxy.go?ref_type=heads#L75 + private static final String NGC_HELM_CHART_VERSION_URL_REGEX = + "^(?:/api)?/(?[^/]+(?:/[^/]+)?)/charts/(?[a-zA-Z0-9-_]+)-(?v?\\d+(\\.\\d+){0,2}([-+][\\w.+-]+)?)\\.tgz(?:\\.prov)?$"; + private static final Pattern HELM_CHART_VERSION_URL_REGEX_PATTERN = + Pattern.compile(NGC_HELM_CHART_VERSION_URL_REGEX); + + private final LoadingCache authCache = + Caffeine.newBuilder() + .expireAfter(new Oauth2TokenExpiry()) + .scheduler(Scheduler.systemScheduler()) + .build(this::fetchAuthToken); + + private final NgcArtifactRegistryStub ngcArtifactRegistryStub; + private final AuthServiceStub authServiceStub; + private final String groupScope; + private final String artifactBaseUrl; + private String hostname; + + public NgcArtifactRegistryClient( + WebClient.Builder webClientBuilder, // Prototype-scoped - Safe to mutate. + String hostname, + Duration exchangeTimeout, + Duration responseTimeout, + Duration writeTimeout, + Duration connectTimeout, + String oauth2BaseUrl, + String oauth2GroupScope) { + var baseUrl = hostname.startsWith("http") ? hostname : "https://" + hostname; + this.hostname = URI.create(baseUrl).getHost(); + // Design for 3rd Party Registry requires hostnames to be unique. However, when + // integration tests involving multiple registries are being executed, this + // becomes an issue as all the registries use "localhost" as the hostname in the + // baseUrl. To make the hostnames unique in the application-test.yaml files of + // apps such as NVCF API and NVCT API, we use localhost-: + // as the baseUrl. For example, localhost-ngc:, localhost-docker:, + // etc. When using the baseUrl, we remove the `-` part so that + // the client can communicate with the registry-specific mock server. + this.artifactBaseUrl = baseUrl.replace("-ngc", ""); + + var authWebClient = WebClientUtils.createWebClient( + webClientBuilder.clone(), + oauth2BaseUrl, Duration.ofSeconds(5)); + this.authServiceStub = WebClientUtils.createStubService( + authWebClient, AuthServiceStub.class); + + log.info("NgcArtifactRegistryClient init for hostname {} with exchangeTimeout: {}," + + " connectTimeout: {}, responseTimeout: {}, writeTimeout: {}", + hostname, exchangeTimeout, connectTimeout, responseTimeout, writeTimeout); + var artifactWebClient = WebClientUtils.createWebClient( + webClientBuilder.clone(), + artifactBaseUrl, exchangeTimeout, connectTimeout, responseTimeout, writeTimeout, 3); + this.ngcArtifactRegistryStub = WebClientUtils.createStubService( + artifactWebClient, NgcArtifactRegistryStub.class); + this.groupScope = oauth2GroupScope; + } + + public String getHostname() { + return this.hostname; + } + + @VisibleForTesting + public void setHostname(String hostname) { + this.hostname = hostname; + } + + public void resetAuthTokenCache() { + authCache.invalidateAll(); + } + + public long fetchModelSize(String artifactUrl, String apiKey) { + var artifactSizeUrl = convertArtifactFileUrlToSizeUrl(artifactUrl); + var artifactPath = removeArtifactHostName(artifactSizeUrl); + if (artifactPath == null) { + return 0L; + } + var authorization = "Bearer " + authCache.get(apiKey).getAccessToken(); + var response = ngcArtifactRegistryStub.getModelMetadata( + URI.create(artifactBaseUrl + artifactPath), authorization); + if (response.getModelVersion() == null) { + var mesg = MESG_NULL_RESPONSE.formatted(artifactPath); + throw new UpstreamException(mesg); + } + return response.getModelVersion().getTotalSizeInBytes(); + } + + public long fetchResourceSize(String artifactUrl, String apiKey) { + var artifactSizeUrl = convertArtifactFileUrlToSizeUrl(artifactUrl); + var artifactPath = removeArtifactHostName(artifactSizeUrl); + if (artifactPath == null) { + return 0L; + } + var authorization = "Bearer " + authCache.get(apiKey).getAccessToken(); + var response = ngcArtifactRegistryStub.getResourceMetadata( + URI.create(artifactBaseUrl + artifactPath), authorization); + if (response == null || response.getRecipeVersion() == null) { + var mesg = MESG_NULL_RESPONSE.formatted(artifactPath); + throw new UpstreamException(mesg); + } + return response.getRecipeVersion().getTotalSizeInBytes(); + } + + public List getPreSignedArtifactURLs(String artifactUrl, String apiKey) { + if (artifactUrl.endsWith("/files")) { + return getPreSignedArtifactFilesURLs(artifactUrl, apiKey); + } + var mesg = MESG_UNKNOWN_ARTIFACT_URL_TYPE.formatted(artifactUrl); + log.error(mesg); + throw new NotImplementedException(mesg); + } + + public void validateArtifact(String artifactUrl, String apiKey, ArtifactTypeEnum artifactType) { + NgcArtifactUriValidator.validate(artifactUrl, artifactType); + var artifactSizeUrl = convertArtifactFileUrlToSizeUrl(artifactUrl); + var artifactPath = removeArtifactHostName(artifactSizeUrl); + if (artifactPath == null) { + throw new BadRequestException("Artifact path is empty"); + } + var authorization = "Bearer " + authCache.get(apiKey).getAccessToken(); + ngcArtifactRegistryStub.validateArtifact( + URI.create(artifactBaseUrl + artifactPath), authorization); + } + + // NGC store helm chart as artifact in their backend, so we translate the helm chart url to + // artifact url and leverage same way to validate it. + public void validateHelmChart(String helmChartUrl, String apiKey) { + var helmChartPath = removeArtifactHostName(helmChartUrl); + if (helmChartPath == null) { + throw new BadRequestException("Helm Chart path is empty"); + } + var artifactPath = translateHelmPathToArtifactPath(helmChartPath); + var authorization = "Bearer " + authCache.get(apiKey).getAccessToken(); + ngcArtifactRegistryStub.validateArtifact( + URI.create(artifactBaseUrl + artifactPath), authorization); + } + + public String validateCredential(String registryHost, String apiKey) { + var oauth2Token = fetchAuthToken(apiKey); + return Optional.ofNullable(oauth2Token) + .map(AuthServiceStub.Oauth2Token::getAccessToken) + .orElseThrow(() -> { + var mesg = MESG_NULL_TOKEN_RESPONSE.formatted(registryHost); + log.error(mesg); + return new ForbiddenException(mesg); + }); + } + + @VisibleForTesting + public static String translateHelmPathToArtifactPath(String helmChartPath) { + Matcher matcher = HELM_CHART_VERSION_URL_REGEX_PATTERN.matcher(helmChartPath); + if (!matcher.matches()) { + throw new BadRequestException(MESG_INVALID_NGC_HELM_CHART_URL_FORMAT_RESPONSE); + } + String namespace = matcher.group("namespace"); + String chartName = matcher.group("chartName"); + String version = matcher.group("version"); + + String[] helmPathSegments = namespace.split("/"); + String helmPath = helmPathSegments.length > 1 ? + "org/%s/team/%s".formatted(helmPathSegments[0], helmPathSegments[1]) : + "org/%s".formatted(helmPathSegments[0]); + + // We expect the path will be in the format + // /v2/org/0539907589386975/team/mega-dev/helm-charts/mega-simulation-app/versions/0.6.0+mr.626af78b/files + return "/v2/%s/helm-charts/%s/versions/%s" + .formatted(helmPath, chartName, version); + } + + private List getPreSignedArtifactFilesURLs(String artifactUrl, String apiKey) { + var artifactPath = removeArtifactHostName(artifactUrl); + var authorization = "Bearer " + authCache.get(apiKey).getAccessToken(); + + return ngcArtifactRegistryStub.getArtifactFiles( + buildArtifactFilesUri(artifactPath, null, PAGE_SIZE), authorization) + .switchIfEmpty(Mono.error(() -> + new UpstreamException( + MESG_NULL_RESPONSE.formatted( + artifactPath)))) + .flatMap(firstPage -> { + var allPages = Flux.just(firstPage); + if (firstPage.getPaginationInfo() != null + && firstPage.getPaginationInfo().getTotalPages() > 1) { + // flatMapSequential mirrors the original RestClientUtils behavior: + // all page requests are fired concurrently, but results are emitted + // in page order (same as firing all CompletableFutures then joining in order). + var remainingPages = Flux.range(1, + firstPage.getPaginationInfo() + .getTotalPages() - 1) + .flatMapSequential(i -> ngcArtifactRegistryStub.getArtifactFiles( + buildArtifactFilesUri(artifactPath, i, PAGE_SIZE), + authorization)); + allPages = allPages.concatWith(remainingPages); + } + return allPages + .flatMapIterable(page -> Streams.zip( + page.getFilepath().stream(), + page.getUrls().stream(), + ArtifactFile::new).toList()) + .collectList(); + }) + .block(); + } + + private URI buildArtifactFilesUri(String path, Integer pageNumber, int pageSize) { + var sb = new StringBuilder(artifactBaseUrl).append(path) + .append("?page-size=").append(pageSize); + if (pageNumber != null) { + sb.append("&page-number=").append(pageNumber); + } + return URI.create(sb.toString()); + } + + private AuthServiceStub.Oauth2Token fetchAuthToken(String key) { + if (key.startsWith("nvapi-")) { + // Special handling for account credentials that can be SAKs till all the legacy AuthN + // ApiKeys are migrated to SAKs. + var oauth2Token = new AuthServiceStub.Oauth2Token(); + oauth2Token.setAccessToken(key); + oauth2Token.setExpiresIn(OAUTH_TTL_IN_SECONDS); + return oauth2Token; + } + + var formData = new LinkedMultiValueMap(); + formData.add("grant_type", "client_credentials"); + formData.add("scope", "ngc:group/" + groupScope); + var basicAuth = "Basic " + Base64.getEncoder().encodeToString( + ("$oauthtoken:" + key).getBytes(StandardCharsets.UTF_8)); + AuthServiceStub.Oauth2Token body = authServiceStub.fetchToken(basicAuth, formData); + if (body == null || body.getAccessToken() == null + || body.getExpiresIn() == null) { + log.error(MESG_INVALID_OAUTH_SERVER_RESPONSE); + throw new UpstreamException(MESG_INVALID_OAUTH_SERVER_RESPONSE); + } + return body; + } + + private static class Oauth2TokenExpiry implements Expiry { + + @Override + public long expireAfterCreate(String key, AuthServiceStub.Oauth2Token value, + long currentTime) { + return Duration.ofSeconds(value.getExpiresIn()).toNanos() * 3 / 4; + } + + @Override + public long expireAfterUpdate( + String key, AuthServiceStub.Oauth2Token value, long currentTime, + @PositiveOrZero long currentDuration) { + return currentDuration; + } + + @Override + public long expireAfterRead( + String key, AuthServiceStub.Oauth2Token value, long currentTime, + @PositiveOrZero long currentDuration) { + return currentDuration; + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcArtifactRegistryStub.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcArtifactRegistryStub.java new file mode 100644 index 0000000000..453d0e3190 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcArtifactRegistryStub.java @@ -0,0 +1,112 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.ngc; + +import java.net.URI; +import java.util.List; +import lombok.Data; +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.service.annotation.GetExchange; +import reactor.core.publisher.Mono; + +/** + * HTTP exchange stub for NGC Artifact Registry, backed by {@link + * org.springframework.web.reactive.function.client.WebClient} via + * {@link org.springframework.web.service.invoker.HttpServiceProxyFactory}. + * + *

Return type convention: + *

    + *
  • {@code Mono} — used where the caller needs reactive composition (pagination). + *
  • Synchronous types ({@code T}, {@code void}) — used where the caller is blocking; + * {@code HttpServiceProxyFactory} will subscribe and block the calling thread automatically. + *
+ */ +public interface NgcArtifactRegistryStub { + + /** + * expected as /v2/org/:org-name(/team/:team-name)?/models/:model-name/:version-id/files + * OR + * /v2/org/:org-name(/team/:team-name)?/resources/:resource-name/:version-id/files + */ + @GetExchange + Mono getArtifactFiles( + URI url, + @RequestHeader(HttpHeaders.AUTHORIZATION) String authorization); + + @GetExchange + ModelMetadataResponse getModelMetadata( + URI url, + @RequestHeader(HttpHeaders.AUTHORIZATION) String authorization); + + @GetExchange + ResourceMetadataResponse getResourceMetadata( + URI url, + @RequestHeader(HttpHeaders.AUTHORIZATION) String authorization); + + @GetExchange + void validateArtifact( + URI url, + @RequestHeader(HttpHeaders.AUTHORIZATION) String authorization); + + @Data + class ArtifactRegistryFilesResponse { + + private PaginationInfo paginationInfo; + private RequestStatus requestStatus; + private List urls; + private List filepath; + } + + @Data + class PaginationInfo { + + private int totalPages; + private int index; + private int totalResults; + private String nextPage; + private int size; + } + + @Data + class RequestStatus { + + private String serverID; + private String statusCode; + private String statusDescription; + private String requestID; + } + + @Data + class ModelMetadataResponse { + + private ArtifactDetails modelVersion; + } + + @Data + class ResourceMetadataResponse { + + private ArtifactDetails recipeVersion; + } + + @Data + class ArtifactDetails { + + private long totalSizeInBytes; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcArtifactUriValidator.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcArtifactUriValidator.java new file mode 100644 index 0000000000..d7ba13f5b0 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcArtifactUriValidator.java @@ -0,0 +1,93 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.ngc; + +import static com.nvidia.boot.registries.util.RegistriesConstants.CAS_DIRECT_MODELS_REGISTRY_PATH; +import static com.nvidia.boot.registries.util.RegistriesConstants.CAS_DIRECT_RESOURCES_REGISTRY_PATH; +import static com.nvidia.boot.registries.util.RegistriesConstants.CAS_MODELS_VERSIONS_PATH; +import static com.nvidia.boot.registries.util.RegistriesConstants.CAS_RESOURCES_VERSIONS_PATH; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum; +import java.util.regex.Pattern; +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; + +/** + * Utility class for validating NGC artifact URIs (models and resources). + * + *

Valid URIs must be fully qualified URLs matching the NGC Registry format: + *

+ * https://api[.stg|.canary].ngc.nvidia.com/v2/org/:org-name[/team/:team-name]/{models|resources}/:name/:version-id/files
+ * 
+ * + *

URIs with "/versions/" in the path (extra info format) are rejected as they have + * a different response format than expected. + */ +@UtilityClass +@Slf4j +public class NgcArtifactUriValidator { + + private static final String MESG_INVALID_ARTIFACT_URI = """ + Invalid request: Specified URI '%s' does not match NGC Model or Resource Registry + files path format - + https://api(.stg|canary).ngc.nvidia.com/v2/org/:org-name(/team/:team-name)?/{models|resources}/:name/:version-id/files" + """; + + /** + * Validates a single artifact URI. + * + * @param uri the URI to validate + * @param artifactType the type of artifact (MODEL or RESOURCE) + * @throws IllegalArgumentException if artifactType is not MODEL or RESOURCE + */ + public static void validate(String uri, ArtifactTypeEnum artifactType) { + var patterns = getPatterns(artifactType); + if (!isValidUri(uri, patterns)) { + var mesg = MESG_INVALID_ARTIFACT_URI.formatted(uri); + log.error(mesg); + throw new BadRequestException(mesg); + } + } + + private static ValidationPatterns getPatterns(ArtifactTypeEnum artifactType) { + return switch (artifactType) { + case MODEL -> new ValidationPatterns(CAS_DIRECT_MODELS_REGISTRY_PATH, + CAS_MODELS_VERSIONS_PATH); + case RESOURCE -> new ValidationPatterns(CAS_DIRECT_RESOURCES_REGISTRY_PATH, + CAS_RESOURCES_VERSIONS_PATH); + default -> throw new IllegalArgumentException( + "Unsupported artifact type: " + artifactType); + }; + } + + private static boolean isValidUri(String uri, ValidationPatterns patterns) { + if (StringUtils.isBlank(uri)) { + return false; + } + var matchesFilesPath = patterns.directPath().matcher(uri).matches(); + // The "versions" path has extra info and a different response format than expected + var matchesVersionsPath = patterns.versionsPath().matcher(uri).matches(); + return matchesFilesPath && !matchesVersionsPath; + } + + private record ValidationPatterns(Pattern directPath, Pattern versionsPath) { + + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcContainerRegistryClient.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcContainerRegistryClient.java new file mode 100644 index 0000000000..6de91a76cb --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcContainerRegistryClient.java @@ -0,0 +1,248 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.ngc; + +import static com.nvidia.boot.registries.service.registry.client.oci.OciRegistryClient.IMAGE_MEDIA_TYPES; + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Expiry; +import com.github.benmanes.caffeine.cache.LoadingCache; +import com.github.benmanes.caffeine.cache.Scheduler; +import com.google.common.annotations.VisibleForTesting; +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.registries.service.registry.client.WebClientUtils; +import java.net.URI; +import java.time.Duration; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * Client for interacting with NGC Container Registry. + * Handles container image URL parsing and validation. + */ +@Slf4j +public class NgcContainerRegistryClient { + + private static final String DEFAULT_IMAGE_TAG = "latest"; + + private static final String MESG_INVALID_NGC_DOCKER_IMAGE_URL_FORMAT_RESPONSE = + "Invalid NGC docker image url format."; + private static final String MESG_EMPTY_CONTAINER_URL_RESPONSE = + "Container image URL cannot be null or empty"; + private static final String MESG_NULL_RESPONSE = + "Null response from getting bearer token from registry %s"; + + private final NgcContainerRegistryStub ngcContainerRegistryStub; + private final String containerBaseUrl; + + private record RegistryAuthKey(String repository, String imageName, String apiKey) { + + } + + // Base on prod metrics, there are around 1000 request for function/deployment + // creation or updates per hour. We doubled the number to allow extremely cases. + private static final int AUTH_TOKEN_CACHE_SIZE = 2048; + private final LoadingCache + registryAuthCache = + Caffeine.newBuilder() + .maximumSize(AUTH_TOKEN_CACHE_SIZE) + .expireAfter(new AuthTokenExpiry()) + .scheduler(Scheduler.systemScheduler()) + .build(this::fetchAuthToken); + + @Getter + private String hostname; + + /** + * Record to hold the components of a container image URL. + */ + public record ContainerImageComponents( + String registryHost, + String repository, + String imageName, + String tag, + String digest + ) { + + } + + private static final Pattern CONTAINER_IMAGE_URL_PATTERN = Pattern.compile( + "^(?[^/]+)/(?(?:[^/]+/)*)(?[^/:@]+)(?::(?[^@]+))?(?:@(?.+))?$"); + + public NgcContainerRegistryClient( + WebClient.Builder webClientBuilder, // Prototype-scoped - Safe to mutate. + String containerHostname, + Duration exchangeTimeout, + Duration responseTimeout, + Duration writeTimeout, + Duration connectTimeout) { + var containerRegistryUrl = containerHostname.startsWith("http") + ? containerHostname : "https://" + containerHostname; + // Normalized again. + this.hostname = URI.create(containerRegistryUrl).getHost(); + + // Design for 3rd Party Registry requires hostnames to be unique. However, when + // integration tests involving multiple registries are being executed, this + // becomes an issue as all the registries use "localhost" as the hostname in the + // baseUrl. To make the hostnames unique in the application-test.yaml files of + // apps such as NVCF API and NVCT API, we use localhost-: + // as the baseUrl. For example, localhost-ngc:, localhost-docker:, + // etc. When using the baseUrl, we remove the `-` part so that + // the client can communicate with the registry-specific mock server. + log.info("NgcContainerRegistryClient init for hostname {} with exchangeTimeout: {}," + + " connectTimeout: {}, responseTimeout: {}, writeTimeout: {}", + hostname, exchangeTimeout, connectTimeout, responseTimeout, writeTimeout); + this.containerBaseUrl = containerRegistryUrl.replace("-ngc", ""); + var webClient = WebClientUtils.createWebClient( + webClientBuilder, + containerBaseUrl, exchangeTimeout, connectTimeout, responseTimeout, writeTimeout, + 0); + this.ngcContainerRegistryStub = WebClientUtils.createStubService( + webClient, NgcContainerRegistryStub.class); + } + + @VisibleForTesting + public void setHostname(String hostname) { + this.hostname = hostname; + } + + public void resetAuthTokenCache() { + this.registryAuthCache.invalidateAll(); + } + + /** + * Parses a container image URL into its components. + * Expected format: [registry-host]/[repository]/[image-name]:[tag] or [registry-host]/[repository]/[image-name]@[digest] + * + * @param containerImageUrl The full container image URL to parse + * @return ContainerImageComponents containing the parsed components + * @throws BadRequestException if the URL format is invalid + */ + public static ContainerImageComponents parseContainerImageUrl(String containerImageUrl) { + validateContainerImageUrl(containerImageUrl); + return extractContainerImageComponents(containerImageUrl); + } + + private static void validateContainerImageUrl(String containerImageUrl) { + if (containerImageUrl == null || containerImageUrl.isBlank()) { + throw new BadRequestException(MESG_EMPTY_CONTAINER_URL_RESPONSE); + } + + // Check for multiple colons or @ symbols before regex matching + if (containerImageUrl.chars().filter(ch -> ch == ':').count() > 1 || + containerImageUrl.chars().filter(ch -> ch == '@').count() > 1) { + throw new BadRequestException(MESG_INVALID_NGC_DOCKER_IMAGE_URL_FORMAT_RESPONSE); + } + } + + private static ContainerImageComponents extractContainerImageComponents( + String containerImageUrl) { + Matcher matcher = CONTAINER_IMAGE_URL_PATTERN.matcher(containerImageUrl); + if (!matcher.matches()) { + throw new BadRequestException(MESG_INVALID_NGC_DOCKER_IMAGE_URL_FORMAT_RESPONSE); + } + + String registryHost = matcher.group("registryHost"); + String repository = matcher.group("repository").replaceAll("/$", ""); + String imageName = matcher.group("imageName"); + String tag = matcher.group("tag"); + String digest = matcher.group("digest"); + + if (tag == null && digest == null) { + tag = DEFAULT_IMAGE_TAG; + } + + return new ContainerImageComponents(registryHost, repository, imageName, tag, digest); + } + + /** + * Validates a container image by checking its existence and accessibility in the NGC registry. + * + * @param containerImageUrl The container image URL to validate + * @param base64ApiKey The base64 encoded API key in format "username:password" for authentication + * @throws BadRequestException if the image is invalid or inaccessible + */ + public void validateContainerImage(String containerImageUrl, String base64ApiKey) { + ContainerImageComponents components = parseContainerImageUrl(containerImageUrl); + RegistryAuthKey authKey = + new RegistryAuthKey(components.repository(), components.imageName(), base64ApiKey); + String bearerToken = registryAuthCache.get(authKey).getToken(); + validateImageManifest(components, bearerToken); + } + + public String validateCredential(String registryHost, String base64EncodedSecret) { + var authResponse = ngcContainerRegistryStub.proxyAuth( + "Basic " + base64EncodedSecret, "$oauthtoken", null); + return Optional.ofNullable(authResponse) + .map(NgcContainerRegistryStub.NgcRegistryAuthResponse::getToken) + .orElseThrow(() -> { + var mesg = MESG_NULL_RESPONSE + .formatted(registryHost); + log.error(mesg); + return new ForbiddenException(mesg); + }); + } + + private NgcContainerRegistryStub.NgcRegistryAuthResponse fetchAuthToken( + RegistryAuthKey authKey) { + String base64ApiKey = authKey.apiKey(); + String scope = + "repository:%s:pull".formatted(authKey.repository() + "/" + authKey.imageName()); + log.info("authenticate registry scope: {}", scope); + return ngcContainerRegistryStub.proxyAuth( + "Basic " + base64ApiKey, "$oauthtoken", scope); + } + + private void validateImageManifest(ContainerImageComponents components, String bearerToken) { + String tag = components.digest() != null ? components.digest() : components.tag(); + String imagePath = components.repository() + "/" + components.imageName(); + var uri = URI.create(containerBaseUrl + "/v2/" + imagePath + "/manifests/" + tag); + ngcContainerRegistryStub.validateManifest( + uri, "Bearer " + bearerToken, IMAGE_MEDIA_TYPES); + } + + private static class AuthTokenExpiry + implements Expiry { + + @Override + public long expireAfterCreate(RegistryAuthKey key, + NgcContainerRegistryStub.NgcRegistryAuthResponse value, + long currentTime) { + return Duration.ofSeconds(value.getExpiresIn()).toNanos() * 3 / 4; + } + + @Override + public long expireAfterUpdate(RegistryAuthKey key, + NgcContainerRegistryStub.NgcRegistryAuthResponse value, + long currentTime, long currentDuration) { + return currentDuration; + } + + @Override + public long expireAfterRead(RegistryAuthKey key, + NgcContainerRegistryStub.NgcRegistryAuthResponse value, + long currentTime, long currentDuration) { + return currentDuration; + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcContainerRegistryStub.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcContainerRegistryStub.java new file mode 100644 index 0000000000..5fe0de7dbd --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcContainerRegistryStub.java @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.ngc; + +import tools.jackson.databind.PropertyNamingStrategies; +import tools.jackson.databind.annotation.JsonNaming; +import java.net.URI; +import lombok.Data; +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.service.annotation.GetExchange; + +public interface NgcContainerRegistryStub { + + @GetExchange("proxy_auth") + NgcRegistryAuthResponse proxyAuth( + @RequestHeader(HttpHeaders.AUTHORIZATION) String basic, + @RequestParam("account") String account, + @RequestParam(value = "scope", required = false) String scope); + + @GetExchange + void validateManifest( + URI url, + @RequestHeader(HttpHeaders.AUTHORIZATION) String bearer, + @RequestHeader(HttpHeaders.ACCEPT) String imageMediaTypes); + + @Data + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + class NgcRegistryAuthResponse { + + private int expiresIn; + private String token; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcRegistryUtils.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcRegistryUtils.java new file mode 100644 index 0000000000..4b1982f3c0 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcRegistryUtils.java @@ -0,0 +1,81 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.ngc; + +import static com.nvidia.boot.registries.util.RegistriesConstants.CAS_MODELS_VERSIONS_PATH; +import static com.nvidia.boot.registries.util.RegistriesConstants.CAS_RESOURCES_VERSIONS_PATH; + +import java.net.URI; +import java.util.Base64; +import lombok.experimental.UtilityClass; + +@UtilityClass +public class NgcRegistryUtils { + + public static final String MESG_FETCHED_SIZE_ARTIFACT_URLS = + "Fetched size of artifact urls for '{}': {}"; + public static final String MESG_FETCHED_PRESIGNED_ARTIFACT_URLS = + "Fetched pre-signed artifact urls for '{}': {}"; + private static final String MESG_UNEXPECTED_SECRET_FORMAT = + "Unexpected format secret get. It should be 'username:password' with base64 encoded "; + + // To get artifact file size for allocate instance cache and other usage, we need to call + // the endpoint to get artifact metadata. The function can help convert user provide artifact + // file url to metadata endpoint which providing artifact size info. + // The following artifact file url endpoints + // `/v2/org/{orgName}/models/{modelName}/{modelVersion}/files` + // `/v2/org/{orgName}/resources/{resourceName}/{resourceVersion}/files` + // will be translated to + // `/v2/org/{orgName}/models/{modelName}/versions/{modelVersion}` + // `/v2/org/{orgName}/resources/{resourceName}/versions/{resourceVersion}` + public static String convertArtifactFileUrlToSizeUrl(String path) { + var match = CAS_MODELS_VERSIONS_PATH.matcher(path).matches(); + + int index = path.lastIndexOf("/files"); + if (match) { + return path.substring(0, index); + } + match = CAS_RESOURCES_VERSIONS_PATH.matcher(path).matches(); + if (match) { + return path.substring(0, index); + } + + var versionPath = path.substring(0, index); + index = versionPath.lastIndexOf('/'); + return versionPath.substring(0, index) + + "/versions/" + + versionPath.substring(index + 1); + } + + public static String removeArtifactHostName(String artifactPath) { + var newArtifactPath = artifactPath; + if (!artifactPath.startsWith("http")) { + newArtifactPath = "https://" + artifactPath; + } + var uri = URI.create(newArtifactPath); + return uri.getPath(); + } + + public static String getApiKey(String secret) { + var secretText = new String(Base64.getDecoder().decode(secret)); + if (!secretText.contains(":") || secretText.indexOf(":") == secretText.length() - 1) { + throw new IllegalArgumentException(MESG_UNEXPECTED_SECRET_FORMAT); + } + return secretText.substring(secretText.indexOf(":") + 1); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/oci/OciAuthStubService.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/oci/OciAuthStubService.java new file mode 100644 index 0000000000..6e0c968a7a --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/oci/OciAuthStubService.java @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.oci; + +import tools.jackson.databind.PropertyNamingStrategies; +import tools.jackson.databind.annotation.JsonNaming; +import java.net.URI; +import lombok.Data; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.service.annotation.GetExchange; + +public interface OciAuthStubService { + + @GetExchange() + OciAuthTokenResponse fetchToken( + URI url, + @RequestHeader("Authorization") String authorization); + + @Data + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + class OciAuthTokenResponse { + + private String token; + private String accessToken; + private Long expiresIn; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/oci/OciRegistryAuthClient.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/oci/OciRegistryAuthClient.java new file mode 100644 index 0000000000..28f70d30de --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/oci/OciRegistryAuthClient.java @@ -0,0 +1,202 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.oci; + +import static com.nvidia.boot.registries.service.registry.client.oci.OciRegistryClient.getOciRegistryWebClient; + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Expiry; +import com.github.benmanes.caffeine.cache.LoadingCache; +import com.github.benmanes.caffeine.cache.Scheduler; +import com.google.common.annotations.VisibleForTesting; +import com.nimbusds.jwt.SignedJWT; +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.registries.service.registry.client.oci.OciAuthStubService.OciAuthTokenResponse; +import com.nvidia.boot.registries.service.registry.client.oci.dto.OciArtifactComponents; +import com.nvidia.boot.registries.service.registry.client.oci.dto.OciAuthKey; +import com.nvidia.boot.registries.service.registry.client.oci.dto.OciAuthToken; +import java.net.URI; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.util.Strings; +import org.springframework.web.reactive.function.client.WebClient; + +@Slf4j +public abstract class OciRegistryAuthClient { + + // Based on prod metrics, there are around ~ 1000 request for function creation + // and function deployment creation/update per hour. + // Double the number to allow extreme cases. + private static final int AUTH_TOKEN_CACHE_SIZE = 2048; + + private static final String MESG_REGISTRY_CREDENTIALS_MISSING = + "Registry credentials are required"; + private static final String MESG_OCI_AUTH_RESPONSE_TOKEN_MISSING = + "Oci auth response must contain either access_token or token field"; + + private final LoadingCache registryAuthCache; + private final OciAuthStubService ociAuthStubService; + + protected OciRegistryAuthClient( + WebClient.Builder webClientBuilder, // Prototype-scoped - Safe to mutate. + Duration callTimeout) { + var timeout = callTimeout != null ? callTimeout : Duration.ofSeconds(30); + this.ociAuthStubService = getOciRegistryWebClient(webClientBuilder, + timeout, + OciAuthStubService.class); + this.registryAuthCache = Caffeine.newBuilder() + .maximumSize(AUTH_TOKEN_CACHE_SIZE) + .expireAfter(new AuthTokenExpiry()) + .scheduler(Scheduler.systemScheduler()) + .build(this::fetchToken); + } + + protected abstract String getCanonicalAuthTokenUrl(String registryHost, String name); + + public OciAuthToken getToken(OciArtifactComponents components, + String base64EncodedSecret) { + if (StringUtils.isBlank(base64EncodedSecret)) { + log.error(MESG_REGISTRY_CREDENTIALS_MISSING); + throw new BadRequestException(MESG_REGISTRY_CREDENTIALS_MISSING); + } + var authKey = new OciAuthKey(components.registryHost(), + components.name(), + base64EncodedSecret); + + return registryAuthCache.get(authKey); + } + + public OciAuthToken validateCredential(String registryHost, + String base64EncodedSecret) { + var url = getCanonicalAuthTokenUrl(registryHost, Strings.EMPTY); + var tokenResponse = ociAuthStubService.fetchToken( + URI.create(url), + "Basic " + base64EncodedSecret); + + return extractAuthToken(tokenResponse); + } + + @VisibleForTesting + OciAuthToken fetchToken(OciAuthKey authKey) { + var url = getCanonicalAuthTokenUrl(authKey.registryHost(), authKey.name()); + var tokenResponse = ociAuthStubService.fetchToken( + URI.create(url), + "Basic " + authKey.base64Secret()); + + return extractAuthToken(tokenResponse); + } + + protected OciAuthToken extractAuthToken(OciAuthTokenResponse response) { + String tokenValue = null; + if (StringUtils.isNotBlank(response.getAccessToken())) { + tokenValue = response.getAccessToken(); + } else if (StringUtils.isNotBlank(response.getToken())) { + tokenValue = response.getToken(); + } + + if (StringUtils.isBlank(tokenValue)) { + log.error(MESG_OCI_AUTH_RESPONSE_TOKEN_MISSING); + throw new IllegalArgumentException(MESG_OCI_AUTH_RESPONSE_TOKEN_MISSING); + } + + Duration expiresIn; + if (response.getExpiresIn() != null && response.getExpiresIn() > 0) { + expiresIn = Duration.ofSeconds(response.getExpiresIn()); + } else { + expiresIn = getExpClaimFromJwt(tokenValue) + .orElse(Duration.ofSeconds(60)); + } + + return new OciAuthToken(tokenValue, expiresIn); + } + + public void invalidateCache(OciAuthKey authKey) { + registryAuthCache.invalidate(authKey); + } + + public void invalidateCache() { + registryAuthCache.invalidateAll(); + } + + @VisibleForTesting + long getCacheSize() { + return registryAuthCache.estimatedSize(); + } + + @VisibleForTesting + static Optional getExpClaimFromJwt(String accessToken) { + if (StringUtils.isEmpty(accessToken)) { + return Optional.empty(); + } + + try { + var signedJWT = SignedJWT.parse(accessToken); + var claimsSet = signedJWT.getJWTClaimsSet(); + + var expirationDate = claimsSet.getExpirationTime(); + if (expirationDate == null) { + log.warn("No 'exp' claim found in JWT"); + return Optional.empty(); + } + + var expirationTime = expirationDate.toInstant(); + var currentTime = Instant.now(); + + if (expirationTime.isAfter(currentTime)) { + return Optional.of(Duration.between(currentTime, expirationTime)); + } else { + log.warn("JWT token has already expired at {}", expirationTime); + return Optional.of(Duration.ZERO); + } + + } catch (Exception e) { + log.warn("Failed to parse JWT: {}", e.getMessage()); + return Optional.empty(); + } + } + + private static class AuthTokenExpiry implements + Expiry { + + @Override + public long expireAfterCreate(OciAuthKey key, + OciAuthToken value, + long currentTime) { + return value.expiresIn().toNanos() * 3 / 4; + } + + @Override + public long expireAfterUpdate(OciAuthKey key, + OciAuthToken value, + long currentTime, + long currentDuration) { + return currentDuration; + } + + @Override + public long expireAfterRead(OciAuthKey key, + OciAuthToken value, + long currentTime, + long currentDuration) { + return currentDuration; + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/oci/OciRegistryClient.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/oci/OciRegistryClient.java new file mode 100644 index 0000000000..fd02587ec4 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/oci/OciRegistryClient.java @@ -0,0 +1,252 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.oci; + +import static org.springframework.http.HttpStatus.FORBIDDEN; +import static org.springframework.http.HttpStatus.NOT_FOUND; +import static org.springframework.http.HttpStatus.TOO_MANY_REQUESTS; +import static org.springframework.http.HttpStatus.UNAUTHORIZED; + +import com.google.common.annotations.VisibleForTesting; +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.BootResponseException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.exceptions.TooManyRequestsException; +import com.nvidia.boot.exceptions.UnauthorizedException; +import com.nvidia.boot.exceptions.UpstreamException; +import com.nvidia.boot.registries.service.registry.client.oci.dto.OciArtifactComponents; +import java.net.URI; +import java.time.Duration; +import java.util.regex.Pattern; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpStatusCode; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.support.WebClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; +import reactor.core.publisher.Mono; + +@Slf4j +public abstract class OciRegistryClient { + + // Ref: + // 1. https://github.com/distribution/distribution/blob/main/docs/content/spec/manifest-v2-2.md + // 2. https://github.com/opencontainers/image-spec/blob/v1.0.0/media-types.md#compatibility-matrix + public static final String IMAGE_MEDIA_TYPES = + // Most common format, which may build from Docker 1.10 or later versions. + "application/vnd.docker.distribution.manifest.v2+json," + // Use to describe a list of image manifests for different platforms. + + "application/vnd.docker.distribution.manifest.list.v2+json," + // Equivalent to Docker's manifest list, used for multi-platform images + // in the OCI ecosystem + + "application/vnd.oci.image.index.v1+json," + // OCI image specification (v1.0 and v1.1), which may build by podman. + + "application/vnd.oci.image.manifest.v1+json"; + + private static final String DEFAULT_TAG = "latest"; + + private static final String MESG_EMPTY_ARTIFACT_URL = + "OCI artifact URL cannot be null or empty"; + private static final String MESG_INVALID_OCI_ARTIFACT_URL_FORMAT = + "Invalid OCI artifact URL format: %s"; + private static final String MESG_OCI_4XX_RESPONSE = "OCI registry response with 4xx error %s"; + private static final String MESG_OCI_5XX_RESPONSE = "OCI registry response with 5xx error %s"; + private static final String MESG_MANIFEST_VALIDATION_SUCCESSFUL = + "OCI manifest validation successful for artifact: %s"; + + /** + * Regex pattern for OCI artifact URLs supporting the following formats: + * - {registryHost}/{name}:{tag} or {registryHost}/{name}@{digest} + * - oci://{registryHost}/{name}:{tag} or oci://{registryHost}/{name}@{digest} + */ + private static final Pattern OCI_ARTIFACT_URL_PATTERN = Pattern.compile( + "^(?:oci://)?(?[^/.]+(?:\\.[^/.]+)*)/(?(?:[^/:@]+/)*[^/:@]+)(?::(?[^@]+)|@(?sha256:[a-f0-9]{64}))?$" + ); + + private final OciRegistryStubService ociRegistryStubService; + @Getter + private final OciRegistryAuthClient ociRegistryAuthClient; + + protected OciRegistryClient( + WebClient.Builder webClientBuilder, // Prototype-scoped - Safe to mutate. + Duration callTimeout, + OciRegistryAuthClient ociRegistryAuthClient) { + var timeout = callTimeout != null ? callTimeout : Duration.ofSeconds(30); + this.ociRegistryStubService = + getOciRegistryWebClient(webClientBuilder, timeout, OciRegistryStubService.class); + this.ociRegistryAuthClient = ociRegistryAuthClient; + } + + protected abstract String getRegistryBaseUrl(String registryHost); + + protected String getRegistryAuthScheme() { + return "Bearer"; + } + + public void validateArtifact( + String artifactUrl, + String base64EncodedSecret) { + validateArtifact(artifactUrl, base64EncodedSecret, IMAGE_MEDIA_TYPES); + } + + // Media types are not strictly required for HEAD requests, but are recommended. + // If not specified for the registry, IMAGE_MEDIA_TYPES is used by default to cover common cases. + // Please verify requirements with the specific registry. + public void validateArtifact( + String artifactUrl, + String base64EncodedSecret, + String mediaTypes) { + var components = parseArtifactUrl(artifactUrl); + var authToken = ociRegistryAuthClient.getToken(components, base64EncodedSecret); + validateArtifactManifest(getRegistryBaseUrl(components.registryHost()), + components, + getRegistryAuthScheme(), + authToken.token(), + StringUtils.defaultIfBlank(mediaTypes, IMAGE_MEDIA_TYPES)); + } + + private void validateArtifactManifest( + String baseUrl, + OciArtifactComponents components, + String scheme, + String token, + String manifestMediaTypes) { + var authorization = scheme + " " + token; + var path = String.format("/v2/%s/manifests/%s", components.name(), components.reference()); + + ociRegistryStubService.doesManifestExist( + URI.create(baseUrl + path), authorization, manifestMediaTypes); + var artifactPath = components.name() + ":" + components.reference(); + var mesg = MESG_MANIFEST_VALIDATION_SUCCESSFUL.formatted(artifactPath); + log.debug(mesg); + } + + public static S getOciRegistryWebClient(WebClient.Builder webClientBuilder, + Duration timeout, + Class serviceType) { + var webClient = getOciRegistryWebClientBuilder(webClientBuilder, timeout).build(); + var adapter = WebClientAdapter.create(webClient); + var factory = HttpServiceProxyFactory.builderFor(adapter).build(); + return factory.createClient(serviceType); + } + + /** + * Creates a WebClient.Builder for OCI registry operations using default Spring WebFlux Jackson codecs. + * Supports content types: application/json, application/*+json, text/json + * This covers OCI-specific types like application/vnd.oci.image.manifest.v1+json, + * application/vnd.oci.image.index.v1+json, application/vnd.oci.image.config.v1+json. + */ + private static WebClient.Builder getOciRegistryWebClientBuilder(WebClient.Builder webClientBuilder, + Duration timeout) { + return webClientBuilder + .defaultStatusHandler(HttpStatusCode::is4xxClientError, + OciRegistryClient::get4xxException) + .defaultStatusHandler(HttpStatusCode::is5xxServerError, + OciRegistryClient::get5xxException) + .filter((request, next) + -> next.exchange(request).timeout(timeout)); + } + + @VisibleForTesting + static OciArtifactComponents parseArtifactUrl(String artifactUrl) { + if (StringUtils.isEmpty(artifactUrl)) { + log.error(MESG_EMPTY_ARTIFACT_URL); + throw new BadRequestException(MESG_EMPTY_ARTIFACT_URL); + } + + var matcher = OCI_ARTIFACT_URL_PATTERN.matcher(artifactUrl); + if (!matcher.matches()) { + var mesg = MESG_INVALID_OCI_ARTIFACT_URL_FORMAT.formatted(artifactUrl); + log.error(mesg); + throw new BadRequestException(mesg); + } + + var registryHost = matcher.group("registryHost"); + var name = matcher.group("name"); + var tag = matcher.group("tag"); + var digest = matcher.group("digest"); + + if (StringUtils.isEmpty(registryHost)) { + var mesg = MESG_INVALID_OCI_ARTIFACT_URL_FORMAT.formatted( + artifactUrl + " (missing registryHost)"); + log.error(mesg); + throw new BadRequestException(mesg); + } + + if (StringUtils.isEmpty(name)) { + var mesg = MESG_INVALID_OCI_ARTIFACT_URL_FORMAT.formatted( + artifactUrl + " (missing imageName)"); + log.error(mesg); + throw new BadRequestException(mesg); + } + + var reference = StringUtils.firstNonBlank(tag, digest, DEFAULT_TAG); + + return new OciArtifactComponents(registryHost, name, reference); + } + + private static Mono get4xxException(ClientResponse response) { + var status = response.statusCode(); + var msg = MESG_OCI_4XX_RESPONSE.formatted(response.statusCode().value()); + log.error(msg); + + if (status.isSameCodeAs(UNAUTHORIZED)) { + return response.bodyToMono(String.class) + .defaultIfEmpty("Unauthorized") + .flatMap(body -> Mono.error(new UnauthorizedException(body))); + } + + if (status.isSameCodeAs(FORBIDDEN)) { + return response.bodyToMono(String.class) + .defaultIfEmpty("Forbidden") + .flatMap(body -> Mono.error(new ForbiddenException(body))); + } + + if (status.isSameCodeAs(NOT_FOUND)) { + return response.bodyToMono(String.class) + .defaultIfEmpty("Not Found") + .flatMap(body -> Mono.error(new NotFoundException(body))); + } + + if (status.isSameCodeAs(TOO_MANY_REQUESTS)) { + return response.bodyToMono(String.class) + .defaultIfEmpty("Too Many Requests") + .flatMap(body -> Mono.error(new TooManyRequestsException(body))); + } + + return response.bodyToMono(String.class) + .defaultIfEmpty("Bad Request") + .flatMap(body -> Mono.error(new BadRequestException(body))); + } + + private static Mono get5xxException(ClientResponse response) { + var errorMsg = MESG_OCI_5XX_RESPONSE.formatted(response.statusCode()); + log.error(errorMsg); + + return response.bodyToMono(String.class) + .switchIfEmpty(Mono.error(new UpstreamException(errorMsg))) + .flatMap(body -> { + var mesg = errorMsg + " - " + body; + log.error(mesg); + return Mono.error(new UpstreamException(mesg)); + }); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/oci/OciRegistryStubService.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/oci/OciRegistryStubService.java new file mode 100644 index 0000000000..6210679f0a --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/oci/OciRegistryStubService.java @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.oci; + +import java.net.URI; +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.service.annotation.HttpExchange; + +public interface OciRegistryStubService { + + @HttpExchange(method = "HEAD") + void doesManifestExist( + URI url, + @RequestHeader(HttpHeaders.AUTHORIZATION) String authorization, + @RequestHeader(HttpHeaders.ACCEPT) String imageMediaTypes); +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/oci/dto/OciArtifactComponents.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/oci/dto/OciArtifactComponents.java new file mode 100644 index 0000000000..9e88288c7f --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/oci/dto/OciArtifactComponents.java @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.oci.dto; + +public record OciArtifactComponents( + String registryHost, + String name, + String reference +) { + +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/oci/dto/OciAuthKey.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/oci/dto/OciAuthKey.java new file mode 100644 index 0000000000..0e60ae7f1a --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/oci/dto/OciAuthKey.java @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.oci.dto; + +public record OciAuthKey( + String registryHost, + String name, // the repository, including the namespace. + String base64Secret +) { + +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/oci/dto/OciAuthToken.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/oci/dto/OciAuthToken.java new file mode 100644 index 0000000000..e3e8b07db1 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/oci/dto/OciAuthToken.java @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.oci.dto; + +import java.time.Duration; + +public record OciAuthToken( + String token, + Duration expiresIn +) { +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/VolcengineArtifactRegistryClient.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/VolcengineArtifactRegistryClient.java new file mode 100644 index 0000000000..8235f3a62d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/VolcengineArtifactRegistryClient.java @@ -0,0 +1,95 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.volcengine; + +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineArtifactValidationService.validateArtifact; +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineCredentialValidationService.getAuthorizationToken; +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineRegistryUtils.createVolcengineWebClient; +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineRegistryUtils.parseArtifactUrl; +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineRegistryUtils.parseHostname; +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineRegistryUtils.toBaseUrl; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.registries.service.registry.client.volcengine.dto.VolcengineArtifactComponents; +import com.nvidia.boot.registries.service.registry.client.volcengine.dto.VolcengineArtifactType; +import java.net.URI; +import java.time.Duration; +import java.util.regex.Pattern; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.support.WebClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +/** + * Client for interacting with Volcengine Artifact Registry. + * Handles artifact image URL parsing, signature generation, and validation. + */ +@Slf4j +public class VolcengineArtifactRegistryClient { + + public static final Pattern HELM_CHART_URL_PATTERN = Pattern.compile( + "^oci://(?[\\w-]+)-(?(?:cn|ap)-[\\w-]+)\\.cr\\.volces\\.com/(?[\\w._-]+)/(?[\\w._/-]+)(?::(?[\\w._-]+))?$" + ); + + private final VolcengineArtifactRegistryStubService volcengineStubService; + @Getter + private String hostname; + + public VolcengineArtifactRegistryClient( + WebClient.Builder webClientBuilder, // Prototype-scoped - Safe to mutate. + String hostname, + Duration callTimeout) { + var artifactRegistryUrl = hostname.startsWith("http") + ? hostname : "https://" + hostname; + this.hostname = URI.create(artifactRegistryUrl).getHost(); + + var timeout = callTimeout != null ? callTimeout : Duration.ofSeconds(30); + var webClient = createVolcengineWebClient(webClientBuilder, toBaseUrl(artifactRegistryUrl), timeout); + var adapter = WebClientAdapter.create(webClient); + var factory = HttpServiceProxyFactory.builderFor(adapter).build(); + this.volcengineStubService = + factory.createClient(VolcengineArtifactRegistryStubService.class); + } + + /** + * Validates a helm chart by checking its existence in the Volcengine registry. + * + * @param helmChartUrl The helm chart URL to validate + * @throws BadRequestException if the chart is invalid or inaccessible + */ + public void validateHelmChart(String helmChartUrl, String base64ApiKey) { + VolcengineArtifactComponents components = parseArtifactUrl( + helmChartUrl, + HELM_CHART_URL_PATTERN, + VolcengineArtifactType.CHART); + validateArtifact(volcengineStubService, components, base64ApiKey); + } + + /** + * Validates credentials for a Volcengine registry by retrieving an authorization token. + * + * @param hostname The Volcengine registry hostname + * @param base64EncodedSecret The base64-encoded credentials + */ + public void validateCredential(String hostname, String base64EncodedSecret) { + var registryInfo = parseHostname(hostname); + getAuthorizationToken(volcengineStubService, registryInfo.registry(), + registryInfo.region(), base64EncodedSecret); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/VolcengineArtifactRegistryStubService.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/VolcengineArtifactRegistryStubService.java new file mode 100644 index 0000000000..4047c7a687 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/VolcengineArtifactRegistryStubService.java @@ -0,0 +1,168 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.volcengine; + +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineSignatureUtils.ACTION_PARAM; +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineSignatureUtils.VERSION_PARAM; + +import tools.jackson.databind.PropertyNamingStrategies; +import tools.jackson.databind.annotation.JsonNaming; +import java.util.List; +import lombok.Builder; +import lombok.Data; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.service.annotation.PostExchange; + +public interface VolcengineArtifactRegistryStubService { + + @PostExchange(value = "/") + ListTagsResponse listTags( + @RequestParam(ACTION_PARAM) String action, + @RequestParam(VERSION_PARAM) String version, + @RequestHeader(HttpHeaders.AUTHORIZATION) String authorization, + @RequestHeader(HttpHeaders.CONTENT_TYPE) String contentType, + @RequestHeader("X-Content-Sha256") String xContentSha256, + @RequestHeader("X-Date") String xDate, + @RequestBody ListTagsRequest request + ); + + @PostExchange(value = "/") + AuthorizationTokenResponse getAuthorizationToken( + @RequestParam(ACTION_PARAM) String action, + @RequestParam(VERSION_PARAM) String version, + @RequestHeader(HttpHeaders.AUTHORIZATION) String authorization, + @RequestHeader(HttpHeaders.CONTENT_TYPE) String contentType, + @RequestHeader("X-Content-Sha256") String xContentSha256, + @RequestHeader("X-Date") String xDate, + @RequestBody AuthorizationTokenRequest request + ); + + @Builder + @Data + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + class ListTagsRequest { + + private String registry; + private String namespace; + private String repository; + private TagFilter filter; + } + + @Builder + @Data + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + class TagFilter { + + private List names; + private List types; + } + + @Data + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + class ListTagsResponse { + + private ResponseMetadata responseMetadata; + private Result result; + } + + @Data + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + class ResponseMetadata { + + private String requestId; + private String action; + private String version; + private String service; + private String region; + } + + @Data + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + class Result { + + private String registry; + private String namespace; + private String repository; + private List items; + } + + @Value + @Jacksonized + @Builder + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + class Tag { + + String name; + String type; + String digest; + List imageAttributes; + ChartAttribute chartAttribute; + } + + @Value + @Jacksonized + @Builder + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + class ImageAttribute { + + String architecture; + String os; + String digest; + } + + @Value + @Jacksonized + @Builder + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + class ChartAttribute { + + String apiVersion; + String name; + String version; + } + + @Builder + @Data + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + class AuthorizationTokenRequest { + + private String registry; + } + + @Data + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + class AuthorizationTokenResponse { + + private ResponseMetadata responseMetadata; + private AuthTokenResult result; + } + + @Data + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + class AuthTokenResult { + + private String token; + private String username; + private String expireTime; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/VolcengineArtifactValidationService.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/VolcengineArtifactValidationService.java new file mode 100644 index 0000000000..d13bfda5b8 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/VolcengineArtifactValidationService.java @@ -0,0 +1,81 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.volcengine; + +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.toAccessKeyCredentials; +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineRegistryUtils.VOLCENGINE_REGISTRY_BASE_URI; +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineRegistryUtils.constructListTagsRequest; +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineSignatureUtils.API_VERSION; +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineSignatureUtils.LIST_TAGS_ACTION; +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineSignatureUtils.signRequest; + +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.registries.service.registry.RegistryMapperService; +import com.nvidia.boot.registries.service.registry.client.volcengine.dto.VolcengineArtifactComponents; +import java.net.URI; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +@Slf4j +@Service +public class VolcengineArtifactValidationService { + + private static final String MESG_ARTIFACT_NOT_FOUND = + "Artifact '%s' not found in Volcengine registry"; + private static final String MESG_SUCCESSFULLY_VALIDATED_VOLCENGINE_IMAGE = + "Successfully validated Volcengine registry image: %s"; + + public static void validateArtifact( + VolcengineArtifactRegistryStubService volcengineStubService, + VolcengineArtifactComponents components, + String base64ApiKey) { + + var accessKeyCredentials = toAccessKeyCredentials(base64ApiKey); + var accessKeyId = accessKeyCredentials.accessKeyId(); + var secretAccessKey = accessKeyCredentials.secretAccessKey(); + + var request = constructListTagsRequest(components); + var requestBody = RegistryMapperService.toJson(request); + var hostname = URI.create(VOLCENGINE_REGISTRY_BASE_URI).getHost(); + var authHeaders = + signRequest(hostname, components.region(), accessKeyId, secretAccessKey, + requestBody); + + var response = volcengineStubService.listTags( + LIST_TAGS_ACTION, + API_VERSION, + authHeaders.authorization(), + MediaType.APPLICATION_JSON_VALUE, + authHeaders.xContentSha256(), + authHeaders.xDate(), + request + ); + if (response == null || response.getResult() == null + || CollectionUtils.isEmpty(response.getResult().getItems())) { + var msg = MESG_ARTIFACT_NOT_FOUND.formatted( + RegistryMapperService.toJson(components)); + log.error(msg); + throw new NotFoundException(msg); + } + var msg = MESG_SUCCESSFULLY_VALIDATED_VOLCENGINE_IMAGE.formatted( + RegistryMapperService.toJson(components)); + log.info(msg); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/VolcengineContainerRegistryClient.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/VolcengineContainerRegistryClient.java new file mode 100644 index 0000000000..820331eba0 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/VolcengineContainerRegistryClient.java @@ -0,0 +1,97 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.volcengine; + +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineArtifactValidationService.validateArtifact; +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineCredentialValidationService.getAuthorizationToken; +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineRegistryUtils.createVolcengineWebClient; +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineRegistryUtils.parseArtifactUrl; +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineRegistryUtils.parseHostname; +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineRegistryUtils.toBaseUrl; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.registries.service.registry.client.volcengine.dto.VolcengineArtifactComponents; +import com.nvidia.boot.registries.service.registry.client.volcengine.dto.VolcengineArtifactType; +import java.net.URI; +import java.time.Duration; +import java.util.regex.Pattern; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.support.WebClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +/** + * Client for interacting with Volcengine Container Registry. + * Handles container image URL parsing, signature generation, and validation. + */ +@Slf4j +public class VolcengineContainerRegistryClient { + + /** + * Expected format: [registry]-[region].cr.volces.com/[namespace]/[repository]:[tag] + */ + public static final Pattern CONTAINER_IMAGE_URL_PATTERN = Pattern.compile( + "^(?[\\w-]+)-(?(?:cn|ap)-[\\w-]+)\\.cr\\.volces\\.com/(?[\\w._-]+)/(?[\\w._/-]+)(?::(?[\\w._-]+))?$" + ); + + private final VolcengineArtifactRegistryStubService volcengineStubService; + @Getter + private String hostname; + + public VolcengineContainerRegistryClient( + WebClient.Builder webClientBuilder, // Prototype-scoped - Safe to mutate. + String hostname, + Duration callTimeout) { + var containerRegistryUrl = hostname.startsWith("http") + ? hostname : "https://" + hostname; + this.hostname = URI.create(containerRegistryUrl).getHost(); + var timeout = callTimeout != null ? callTimeout : Duration.ofSeconds(30); + var webClient = createVolcengineWebClient(webClientBuilder, toBaseUrl(containerRegistryUrl), timeout); + var adapter = WebClientAdapter.create(webClient); + var factory = HttpServiceProxyFactory.builderFor(adapter).build(); + this.volcengineStubService = + factory.createClient(VolcengineArtifactRegistryStubService.class); + } + + /** + * Validates a container image by checking its existence in the Volcengine registry. + * + * @param containerImageUrl The container image URL to validate + * @throws BadRequestException if the image is invalid or inaccessible + */ + public void validateContainerImage(String containerImageUrl, String base64ApiKey) { + VolcengineArtifactComponents components = parseArtifactUrl( + containerImageUrl, + CONTAINER_IMAGE_URL_PATTERN, + VolcengineArtifactType.IMAGE); + validateArtifact(volcengineStubService, components, base64ApiKey); + } + + /** + * Validates credentials for a Volcengine registry by retrieving an authorization token. + * + * @param hostname The Volcengine registry hostname + * @param base64EncodedSecret The base64-encoded credentials + */ + public void validateCredential(String hostname, String base64EncodedSecret) { + var registryInfo = parseHostname(hostname); + getAuthorizationToken(volcengineStubService, registryInfo.registry(), + registryInfo.region(), base64EncodedSecret); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/VolcengineCredentialValidationService.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/VolcengineCredentialValidationService.java new file mode 100644 index 0000000000..51b04709aa --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/VolcengineCredentialValidationService.java @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.volcengine; + +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.toAccessKeyCredentials; +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.toJson; +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineRegistryUtils.VOLCENGINE_REGISTRY_BASE_URI; +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineSignatureUtils.API_VERSION; +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineSignatureUtils.GET_AUTHORIZATION_TOKEN_ACTION; +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineSignatureUtils.signRequest; + +import com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineArtifactRegistryStubService.AuthorizationTokenRequest; +import com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineArtifactRegistryStubService.AuthorizationTokenResponse; +import java.net.URI; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class VolcengineCredentialValidationService { + + public static AuthorizationTokenResponse getAuthorizationToken( + VolcengineArtifactRegistryStubService volcengineStubService, + String registry, + String region, + String base64EncodedSecret) { + + var accessKeyCredentials = toAccessKeyCredentials(base64EncodedSecret); + var accessKeyId = accessKeyCredentials.accessKeyId(); + var secretAccessKey = accessKeyCredentials.secretAccessKey(); + + var request = AuthorizationTokenRequest.builder() + .registry(registry) + .build(); + var requestBody = toJson(request); + var hostname = URI.create(VOLCENGINE_REGISTRY_BASE_URI).getHost(); + var authHeaders = signRequest(hostname, region, accessKeyId, secretAccessKey, + requestBody, GET_AUTHORIZATION_TOKEN_ACTION); + + return volcengineStubService.getAuthorizationToken( + GET_AUTHORIZATION_TOKEN_ACTION, + API_VERSION, + authHeaders.authorization(), + MediaType.APPLICATION_JSON_VALUE, + authHeaders.xContentSha256(), + authHeaders.xDate(), + request + ); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/VolcengineRegistryUtils.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/VolcengineRegistryUtils.java new file mode 100644 index 0000000000..084c547279 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/VolcengineRegistryUtils.java @@ -0,0 +1,262 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.volcengine; + +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.JSON_MAPPER; +import static org.springframework.http.HttpStatus.CONFLICT; +import static org.springframework.http.HttpStatus.FORBIDDEN; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.BootResponseException; +import com.nvidia.boot.exceptions.ConflictException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.exceptions.UpstreamException; +import com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineArtifactRegistryStubService.ListTagsRequest; +import com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineArtifactRegistryStubService.TagFilter; +import com.nvidia.boot.registries.service.registry.client.volcengine.dto.VolcengineArtifactComponents; +import com.nvidia.boot.registries.service.registry.client.volcengine.dto.VolcengineArtifactType; +import com.nvidia.boot.registries.service.registry.client.volcengine.dto.VolcengineRegistryInfo; +import java.time.Duration; +import java.util.List; +import java.util.regex.Pattern; +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.codec.json.JacksonJsonDecoder; +import org.springframework.http.codec.json.JacksonJsonEncoder; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@Slf4j +@UtilityClass +public class VolcengineRegistryUtils { + + public static final String VOLCENGINE_REGISTRY_GLOBAL_HOSTNAME = "cr.volces.com"; + public static final String VOLCENGINE_REGISTRY_BASE_URI = "https://open.volcengineapi.com"; + + // Valid VolcEngine regions + // Reference: https://www.volcengine.com/docs/6405/73694 + public static final List VALID_VOLCENGINE_REGIONS = List.of( + "cn-beijing", + "cn-shanghai", + "cn-guangzhou", + "cn-hangzhou", + "cn-hongkong", + "ap-southeast-1", + "ap-southeast-3" + ); + + // Pattern for VolcEngine hostname: {registry-name}-{region}.cr.volces.com + public static final Pattern VOLCENGINE_HOSTNAME_PATTERN = buildVolcengineHostnamePattern(); + + // Pattern for parsing registry and region from hostname + public static final Pattern VOLCENGINE_HOSTNAME_PARSE_PATTERN = Pattern.compile( + "^(?[\\w-]+)-(?(?:cn|ap)-[\\w-]+)\\.cr\\.volces\\.com$" + ); + + private static final JacksonJsonDecoder JSON_DECODER = + new JacksonJsonDecoder(JSON_MAPPER, MediaType.APPLICATION_JSON); + private static final JacksonJsonEncoder JSON_ENCODER = + new JacksonJsonEncoder(JSON_MAPPER, MediaType.APPLICATION_JSON); + private static final String DEFAULT_TAG = "latest"; + + // Constants for error messages. + private static final String MESG_EMPTY_ARTIFACT_URL = + "Volcengine registry artifact URL cannot be null or empty"; + private static final String INVALID_VOLCENGINE_ARTIFACT_URL_FORMAT = + "Invalid Volcengine artifact URL '%s' format"; + private static final String MESG_EMPTY_VOLCENGINE_API_BASE_URL = + "Volcengine registry base url cannot be null or empty"; + private static final String MESG_VOLCENGINE_5XX_RESPONSE = + "Volcengine response with 5xx error %s"; + private static final String MESG_VOLCENGINE_RESPONSE_STATUS_CODE = + "Volcengine response status code '%d'"; + private static final String MESG_VOLCENGINE_EMPTY_HOSTNAME = + "Volcengine hostname cannot be null or empty"; + private static final String INVALID_VOLCENGINE_HOSTNAME_FORMAT = + "Invalid Volcengine hostname format: %s"; + + private static Pattern buildVolcengineHostnamePattern() { + String regionsAlternation = VALID_VOLCENGINE_REGIONS.stream() + .map(Pattern::quote) + .collect(java.util.stream.Collectors.joining("|")); + + // Build pattern: {registry-name}-{region}.cr.volces.com + String patternString = String.format( + "[\\w-]+-(%s)\\.cr\\.volces\\.com", + regionsAlternation + ); + + return Pattern.compile(patternString); + } + + public static WebClient createVolcengineWebClient(WebClient.Builder webClientBuilder, + String baseUrl, + Duration timeout) { + return webClientBuilder + .baseUrl(baseUrl) + .codecs(configurer -> { + configurer.defaultCodecs().jacksonJsonDecoder(JSON_DECODER); + configurer.defaultCodecs().jacksonJsonEncoder(JSON_ENCODER); + }) + .defaultStatusHandler(HttpStatusCode::is4xxClientError, + VolcengineRegistryUtils::get4xxException) + .defaultStatusHandler(HttpStatusCode::is5xxServerError, + VolcengineRegistryUtils::get5xxException) + .filter((request, next) + -> next.exchange(request).timeout(timeout)) + .build(); + } + + public static VolcengineArtifactComponents parseArtifactUrl( + String artifactUrl, + Pattern pattern, + VolcengineArtifactType artifactType) { + if (StringUtils.isEmpty(artifactUrl)) { + log.error(MESG_EMPTY_ARTIFACT_URL); + throw new BadRequestException(MESG_EMPTY_ARTIFACT_URL); + } + + var matcher = pattern.matcher(artifactUrl); + if (!matcher.matches()) { + var mesg = INVALID_VOLCENGINE_ARTIFACT_URL_FORMAT.formatted(artifactUrl); + log.error(mesg); + throw new BadRequestException(mesg); + } + + var registry = matcher.group("registry"); + var region = matcher.group("region"); + var namespace = matcher.group("namespace"); + var repository = matcher.group("repository"); + var tag = matcher.group("tag"); + + if (StringUtils.isEmpty(tag)) { + tag = DEFAULT_TAG; + } + + return new VolcengineArtifactComponents(registry, region, namespace, repository, + tag, null, artifactType); + } + + public static String toBaseUrl(String baseUrl) { + if (StringUtils.isEmpty(baseUrl)) { + log.error(MESG_EMPTY_VOLCENGINE_API_BASE_URL); + throw new IllegalArgumentException(MESG_EMPTY_VOLCENGINE_API_BASE_URL); + } + + // Design for 3rd Party Registry requires hostnames to be unique. However, when + // integration tests involving multiple registries are being executed, this + // becomes an issue as all the registries use "localhost" as the hostname in the + // baseUrl. To make the hostnames unique in the application-test.yaml files of + // apps such as NVCF API and NVCT API, we use localhost-: + // as the baseUrl. For example, localhost-ngc:, localhost-docker:, + // etc. When using the baseUrl, we remove the `-` part so that + // the client can communicate with the registry-specific mock server. + if (baseUrl.contains("localhost")) { + return baseUrl.replace("-volcengine", ""); + } + + if (baseUrl.contains(VOLCENGINE_REGISTRY_GLOBAL_HOSTNAME)) { + return VOLCENGINE_REGISTRY_BASE_URI; + } else { + var mesg = INVALID_VOLCENGINE_ARTIFACT_URL_FORMAT.formatted(baseUrl); + log.error(mesg); + throw new IllegalArgumentException(mesg); + } + } + + public static ListTagsRequest constructListTagsRequest( + VolcengineArtifactComponents components) { + + var filterBuilder = TagFilter.builder() + .types(List.of(components.type().toString())) + .names(List.of(components.tag())); + + return ListTagsRequest.builder() + .registry(components.registry()) + .namespace(components.namespace()) + .repository(components.repository()) + .filter(filterBuilder.build()) + .build(); + } + + public static VolcengineRegistryInfo parseHostname(String hostname) { + if (StringUtils.isEmpty(hostname)) { + log.error(MESG_VOLCENGINE_EMPTY_HOSTNAME); + throw new BadRequestException(MESG_VOLCENGINE_EMPTY_HOSTNAME); + } + + var matcher = VOLCENGINE_HOSTNAME_PARSE_PATTERN.matcher(hostname); + if (!matcher.matches()) { + var mesg = String.format(INVALID_VOLCENGINE_HOSTNAME_FORMAT, hostname); + log.error(mesg); + throw new BadRequestException(mesg); + } + + return new VolcengineRegistryInfo( + matcher.group("registry"), + matcher.group("region") + ); + } + + private static Mono get4xxException(ClientResponse response) { + var status = response.statusCode(); + var msg = MESG_VOLCENGINE_RESPONSE_STATUS_CODE.formatted(response.statusCode().value()); + log.error(msg); + + if (status.isSameCodeAs(FORBIDDEN)) { + return response.bodyToMono(String.class) + .defaultIfEmpty("Forbidden") + .flatMap(body -> Mono.error(new ForbiddenException(body))); + } + + if (status.isSameCodeAs(NOT_FOUND)) { + return response.bodyToMono(String.class) + .defaultIfEmpty("Not Found") + .flatMap(body -> Mono.error(new NotFoundException(body))); + } + + if (status.isSameCodeAs(CONFLICT)) { + return response.bodyToMono(String.class) + .defaultIfEmpty("Conflict") + .flatMap(body -> Mono.error(new ConflictException(body))); + } + + return response.bodyToMono(String.class) + .defaultIfEmpty("Bad Request") + .flatMap(body -> Mono.error(new BadRequestException(body))); + } + + private static Mono get5xxException(ClientResponse response) { + var errorMsg = MESG_VOLCENGINE_5XX_RESPONSE.formatted(response.statusCode()); + log.error(errorMsg); + + return response.bodyToMono(String.class) + .switchIfEmpty(Mono.error(new UpstreamException(errorMsg))) + .flatMap(body -> { + var mesg = errorMsg + " - " + body; + log.error(mesg); + return Mono.error(new UpstreamException(mesg)); + }); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/VolcengineSignatureUtils.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/VolcengineSignatureUtils.java new file mode 100644 index 0000000000..74cdeb2379 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/VolcengineSignatureUtils.java @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.volcengine; + +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.toCanonicalRequest; +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.toIsoBasicDateFormat; +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.toShortDateFormat; +import static com.nvidia.boot.registries.util.HashUtils.getSignatureKey; +import static com.nvidia.boot.registries.util.HashUtils.hmacSha256Hex; +import static com.nvidia.boot.registries.util.HashUtils.toSha256Hex; + +import com.nvidia.boot.registries.service.registry.client.volcengine.dto.VolcengineAuthHeaders; +import java.time.Instant; +import java.util.TreeMap; +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@UtilityClass +public class VolcengineSignatureUtils { + + public static final String ACTION_PARAM = "Action"; + public static final String VERSION_PARAM = "Version"; + public static final String API_VERSION = "2022-05-12"; + public static final String LIST_TAGS_ACTION = "ListTags"; + public static final String GET_AUTHORIZATION_TOKEN_ACTION = "GetAuthorizationToken"; + + private static final String CREDENTIAL = "Credential"; + private static final String SIGNED_HEADERS = "SignedHeaders"; + private static final String SIGNATURE = "Signature"; + private static final String VOC_SIG_ALGORITHM = "HMAC-SHA256"; + private static final String HOST_HEADER = "host"; + private static final String X_DATE_HEADER = "x-date"; + private static final String X_CONTENT_SHA256_HEADER = "x-content-sha256"; + private static final String VOC_CR_SERVICE_NAME = "cr"; + private static final String VOC_REQUEST_TYPE = "request"; + private static final String POST_METHOD = "POST"; + private static final String ROOT_PATH = "/"; + + public static VolcengineAuthHeaders signRequest( + String hostname, + String region, + String accessKeyId, + String secretAccessKey, + String requestBody) { + return signRequest(hostname, region, accessKeyId, secretAccessKey, requestBody, + LIST_TAGS_ACTION); + } + + public static VolcengineAuthHeaders signRequest( + String hostname, + String region, + String accessKeyId, + String secretAccessKey, + String requestBody, + String action) { + var now = Instant.now(); + var volXDate = toIsoBasicDateFormat(now); + var volShortXDate = toShortDateFormat(now); + var contentSha256 = toSha256Hex(requestBody); + + var headers = new TreeMap(); + headers.put(HOST_HEADER, hostname); + headers.put(X_CONTENT_SHA256_HEADER, contentSha256); + headers.put(X_DATE_HEADER, volXDate); + + var queryString = ACTION_PARAM + "=" + action + + "&" + VERSION_PARAM + "=" + API_VERSION; + var signedHeaders = String.join(";", headers.keySet()); + var canonicalRequest = toCanonicalRequest(POST_METHOD, ROOT_PATH, queryString, + headers, contentSha256); + var credentialScope = + volShortXDate + "/" + region + "/" + VOC_CR_SERVICE_NAME + "/" + VOC_REQUEST_TYPE; + var stringToSign = VOC_SIG_ALGORITHM + "\n" + + volXDate + "\n" + + credentialScope + "\n" + + toSha256Hex(canonicalRequest); + var signingKey = getSignatureKey(secretAccessKey, volShortXDate, region, + VOC_CR_SERVICE_NAME, VOC_REQUEST_TYPE); + var signature = hmacSha256Hex(signingKey, stringToSign); + var authorization = VOC_SIG_ALGORITHM + " " + + CREDENTIAL + "=" + accessKeyId + "/" + credentialScope + ", " + + SIGNED_HEADERS + "=" + signedHeaders + ", " + + SIGNATURE + "=" + signature; + return new VolcengineAuthHeaders(authorization, contentSha256, volXDate); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/dto/VolcengineArtifactComponents.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/dto/VolcengineArtifactComponents.java new file mode 100644 index 0000000000..b235e1a888 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/dto/VolcengineArtifactComponents.java @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.volcengine.dto; + +public record VolcengineArtifactComponents( + String registry, + String region, + String namespace, + String repository, + String tag, + String digest, + VolcengineArtifactType type) { + +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/dto/VolcengineArtifactType.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/dto/VolcengineArtifactType.java new file mode 100644 index 0000000000..c9ef95b1b6 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/dto/VolcengineArtifactType.java @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.volcengine.dto; + +public enum VolcengineArtifactType { + IMAGE("Image"), + CHART("Chart"); + + private final String name; + + VolcengineArtifactType(String name) { + this.name = name; + } + + @Override + public String toString() { + return this.name; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/dto/VolcengineAuthHeaders.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/dto/VolcengineAuthHeaders.java new file mode 100644 index 0000000000..8c93552e7e --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/dto/VolcengineAuthHeaders.java @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.volcengine.dto; + +public record VolcengineAuthHeaders( + String authorization, + String xContentSha256, + String xDate) { + +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/dto/VolcengineRegistryInfo.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/dto/VolcengineRegistryInfo.java new file mode 100644 index 0000000000..6f7ec34a49 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/client/volcengine/dto/VolcengineRegistryInfo.java @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.volcengine.dto; + +public record VolcengineRegistryInfo( + String registry, + String region) { + +} + diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/ContainerRegistry.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/ContainerRegistry.java new file mode 100644 index 0000000000..338228d9c0 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/ContainerRegistry.java @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.container; + + +public interface ContainerRegistry { + String getName(); + + String getHostname(); + + void validateArtifact(String containerImageUrl, String secret); + + void validateCredential(String hostname, String secret); + + void invalidateCache(); +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/ContainerRegistryService.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/ContainerRegistryService.java new file mode 100644 index 0000000000..fd4662e158 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/ContainerRegistryService.java @@ -0,0 +1,170 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.container; + +import static com.nvidia.boot.registries.util.RegistriesConstants.MESG_ARTIFACT_VALIDATED; +import static com.nvidia.boot.registries.util.RegistriesConstants.MESG_ARTIFACT_VALIDATION_FAILED; +import static com.nvidia.boot.registries.util.RegistriesConstants.MESG_ARTIFACT_VALIDATION_FAILED_UNKNOWN_REASON; +import static com.nvidia.boot.registries.util.RegistriesConstants.MESG_REGISTRY_CREDENTIALS_VALIDATION_FAILED; +import static com.nvidia.boot.registries.util.RegistriesConstants.MESG_REGISTRY_CREDENTIALS_VALIDATION_FAILED_UNKNOWN_REASON; +import static com.nvidia.boot.registries.util.RegistriesConstants.MESG_UNSUPPORTED_HOSTNAME; + +import com.google.common.annotations.VisibleForTesting; +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.exceptions.TooManyRequestsException; +import com.nvidia.boot.exceptions.UnauthorizedException; +import com.nvidia.boot.registries.service.registry.RegistryMapperService; +import com.nvidia.boot.registries.service.registry.RegistryValidationService; +import com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class ContainerRegistryService { + + private static final String MESG_ARTIFACT_VALIDATION_DISABLED = + "Artifact validation disabled for container registry hostname: {}"; + private static final String MESG_CREDENTIAL_VALIDATION_DISABLED = + "Credential validation disabled for container registry hostname: {}"; + + private final Map containerRegistryMap; + private final RegistryMapperService registryMapperService; + private final RegistryValidationService registryValidationService; + + public ContainerRegistryService( + List containerRegistries, + RegistryMapperService registryMapperService, + RegistryValidationService registryValidationService) { + this.containerRegistryMap = containerRegistries.stream() + .collect(Collectors.toMap(ContainerRegistry::getHostname, c -> c)); + this.registryMapperService = registryMapperService; + this.registryValidationService = registryValidationService; + } + + // When we run the tests locally, we set ngc base url to stg. + // However, the test artifacts can be from stage, prod or canary materials, that means we + // won't be able to map them back to the stg agent during lookup. The function is served for + // overwriting the map during test. + @VisibleForTesting + public void overwriteRegistryHostnameMap(String originalHost, String newHost) { + this.containerRegistryMap.put(newHost, this.containerRegistryMap.get(originalHost)); + } + + private ContainerRegistry lookupRegistry(String hostname) { + var normalizedHostname = + registryMapperService.toNormalizedRecognizedRegistryHostname(hostname); + if (containerRegistryMap.containsKey(normalizedHostname)) { + return containerRegistryMap.get(normalizedHostname); + } else { + throw new IllegalArgumentException(MESG_UNSUPPORTED_HOSTNAME.formatted(hostname)); + } + } + + @SneakyThrows + private String extractRegistryHostname(String containerImage) { + String containerImageUrl = !containerImage.startsWith("http") + ? "https://" + containerImage + : containerImage; + return new URI(containerImageUrl).getHost(); + } + + @SneakyThrows + public void validateArtifact(String containerImage, List secrets) { + var registryHostname = extractRegistryHostname(containerImage); + if (!registryValidationService.isArtifactValidationEnabled( + ArtifactTypeEnum.CONTAINER, registryHostname)) { + log.info(MESG_ARTIFACT_VALIDATION_DISABLED, registryHostname); + return; + } + + Exception lastException = null; + + for (String secret : secrets) { + try { + // Validate the artifact and let exception bubble. + lookupRegistry(registryHostname).validateArtifact(containerImage, secret); + + // If we get here, validation succeeded using at least one of the secrets, just + // return. + log.info(MESG_ARTIFACT_VALIDATED, containerImage); + return; + } catch (BadRequestException + | UnauthorizedException + | ForbiddenException + | NotFoundException + | TooManyRequestsException + | IllegalArgumentException e) { + // Remember the exception, then try the next secret. + lastException = e; + log.warn(MESG_ARTIFACT_VALIDATION_FAILED, containerImage, + e.getMessage()); // We don't have the secret name to log. + } + } + + // No secret succeeded. Just use the last saved one. + if (lastException != null) { + log.error(MESG_ARTIFACT_VALIDATION_FAILED, containerImage, lastException.getMessage()); + throw lastException; + } + + // Should not get here. + throw new IllegalStateException(MESG_ARTIFACT_VALIDATION_FAILED_UNKNOWN_REASON + .formatted(containerImage)); + } + + @SneakyThrows + public void validateCredentials(String registryHostname, List secrets) { + if (!registryValidationService.isCredentialValidationEnabled( + ArtifactTypeEnum.CONTAINER, registryHostname)) { + log.info(MESG_CREDENTIAL_VALIDATION_DISABLED, registryHostname); + return; + } + + Exception lastException = null; + + for (String secret : secrets) { + try { + lookupRegistry(registryHostname).validateCredential(registryHostname, secret); + return; + } catch (BadRequestException + | UnauthorizedException + | ForbiddenException + | NotFoundException + | TooManyRequestsException + | IllegalArgumentException e) { + // Remember the exception, then try the next secret. + lastException = e; + log.warn(MESG_REGISTRY_CREDENTIALS_VALIDATION_FAILED, e.getMessage()); + } + } + + // No secret succeeded. + if (lastException != null) { + log.error(MESG_REGISTRY_CREDENTIALS_VALIDATION_FAILED, lastException.getMessage()); + throw lastException; + } + + throw new IllegalStateException(MESG_REGISTRY_CREDENTIALS_VALIDATION_FAILED_UNKNOWN_REASON); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/acr/AcrContainerRegistry.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/acr/AcrContainerRegistry.java new file mode 100644 index 0000000000..d5624c7fb0 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/acr/AcrContainerRegistry.java @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.container.acr; + +import static com.nvidia.boot.registries.util.RegistriesConstants.ACR_REGISTRY_NAME; + +import com.nvidia.boot.registries.service.registry.client.acr.AzureRegistryClient; +import com.nvidia.boot.registries.service.registry.container.ContainerRegistry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class AcrContainerRegistry implements ContainerRegistry { + + private final AzureRegistryClient azureRegistryClient; + + @Override + public String getName() { + return ACR_REGISTRY_NAME; + } + + @Override + public String getHostname() { + return azureRegistryClient.getHostname(); + } + + @Override + public void validateArtifact(String containerImageUrl, String secret) { + azureRegistryClient.validateArtifact(containerImageUrl, secret); + } + + @Override + public void validateCredential(String hostname, String secret) { + azureRegistryClient.getOciRegistryAuthClient().validateCredential(hostname, secret); + } + + @Override + public void invalidateCache() { + azureRegistryClient.getOciRegistryAuthClient().invalidateCache(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/artifactory/ArtifactoryContainerRegistry.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/artifactory/ArtifactoryContainerRegistry.java new file mode 100644 index 0000000000..c0124f3896 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/artifactory/ArtifactoryContainerRegistry.java @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.container.artifactory; + +import static com.nvidia.boot.registries.util.RegistriesConstants.ARTIFACTORY_REGISTRY_NAME; + +import com.nvidia.boot.registries.service.registry.client.artifactory.ArtifactoryClient; +import com.nvidia.boot.registries.service.registry.container.ContainerRegistry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class ArtifactoryContainerRegistry implements ContainerRegistry { + + private final ArtifactoryClient artifactoryClient; + + @Override + public String getName() { + return ARTIFACTORY_REGISTRY_NAME; + } + + @Override + public String getHostname() { + return artifactoryClient.getHostname(); + } + + @Override + public void validateArtifact(String containerImageUrl, String secret) { + artifactoryClient.validateArtifact(containerImageUrl, secret); + } + + @Override + public void validateCredential(String hostname, String secret) { + artifactoryClient.getOciRegistryAuthClient().validateCredential(hostname, secret); + } + + @Override + public void invalidateCache() { + artifactoryClient.getOciRegistryAuthClient().invalidateCache(); + } +} + diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/custom/CustomContainerRegistry.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/custom/CustomContainerRegistry.java new file mode 100644 index 0000000000..1421445165 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/custom/CustomContainerRegistry.java @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.container.custom; + +import com.nvidia.boot.registries.service.registry.container.ContainerRegistry; +import lombok.extern.slf4j.Slf4j; + +// NCPs can add container registries under nvcf.registries.recognized or +// nvct.registries.recognized that are not known to NVCF. For such registries, +// artifact validation and credential validation will not be supported. As a +// result, the fail-fast will not be supported. Also, NVCF/NVCT will not send +// any credentials to the Worker as the NCP is expected to configure +// image-pull-secrets on their own. +@Slf4j +public class CustomContainerRegistry implements ContainerRegistry { + + private static final String MESG_UNSUPPORTED_OPERATION = + "Unsupported operation for container registry '%s' with hostname '%s'"; + + private final String name; + private final String hostname; + + public CustomContainerRegistry(String name, String hostname) { + this.name = name; + this.hostname = hostname; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getHostname() { + return hostname; + } + + @Override + public void validateArtifact(String containerImageUrl, String secret) { + log.warn(MESG_UNSUPPORTED_OPERATION.formatted(name, hostname)); + } + + @Override + public void validateCredential(String hostname, String secret) { + log.warn(MESG_UNSUPPORTED_OPERATION.formatted(name, hostname)); + } + + @Override + public void invalidateCache() { + log.warn(MESG_UNSUPPORTED_OPERATION.formatted(name, hostname)); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/docker/DockerContainerRegistry.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/docker/DockerContainerRegistry.java new file mode 100644 index 0000000000..aadd57c09f --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/docker/DockerContainerRegistry.java @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.container.docker; + +import static com.nvidia.boot.registries.util.RegistriesConstants.DOCKER_REGISTRY_NAME; + +import com.nvidia.boot.registries.service.registry.client.docker.DockerRegistryClient; +import com.nvidia.boot.registries.service.registry.container.ContainerRegistry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Slf4j +public class DockerContainerRegistry implements ContainerRegistry { + private final DockerRegistryClient dockerRegistryClient; + + @Override + public String getName() { + return DOCKER_REGISTRY_NAME; + } + + @Override + public String getHostname() { + return dockerRegistryClient.getHostname(); + } + + @Override + public void validateArtifact(String containerImageUrl, String secret) { + dockerRegistryClient.validateImage(containerImageUrl, + secret); + } + + @Override + public void validateCredential(String hostname, String secret) { + dockerRegistryClient.validateCredential(hostname, secret); + } + + @Override + public void invalidateCache() { + dockerRegistryClient.resetAuthTokenCache(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/ecr/pub/EcrPublicContainerRegistry.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/ecr/pub/EcrPublicContainerRegistry.java new file mode 100644 index 0000000000..bf09b89967 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/ecr/pub/EcrPublicContainerRegistry.java @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.container.ecr.pub; + +import static com.nvidia.boot.registries.util.RegistriesConstants.ECR_PUBLIC_REGISTRY_NAME; + +import com.nvidia.boot.registries.service.registry.client.ecr.pub.EcrPublicContainerRegistryClient; +import com.nvidia.boot.registries.service.registry.container.ContainerRegistry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class EcrPublicContainerRegistry implements ContainerRegistry { + + private final EcrPublicContainerRegistryClient ecrPublicContainerRegistryClient; + + @Override + public String getName() { + return ECR_PUBLIC_REGISTRY_NAME; + } + + @Override + public String getHostname() { + return ecrPublicContainerRegistryClient.getHostname(); + } + + @Override + public void validateArtifact(String containerImageUrl, String secret) { + ecrPublicContainerRegistryClient.validateContainerImage(containerImageUrl, secret); + } + + @Override + public void validateCredential(String hostname, String secret) { + ecrPublicContainerRegistryClient.validateCredential(hostname, secret); + } + + @Override + public void invalidateCache() { + + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/ecr/pvt/EcrPrivateContainerRegistry.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/ecr/pvt/EcrPrivateContainerRegistry.java new file mode 100644 index 0000000000..adc32c182c --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/ecr/pvt/EcrPrivateContainerRegistry.java @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.container.ecr.pvt; + +import static com.nvidia.boot.registries.util.RegistriesConstants.ECR_PRIVATE_REGISTRY_NAME; + +import com.nvidia.boot.registries.service.registry.client.ecr.pvt.EcrPrivateContainerRegistryClient; +import com.nvidia.boot.registries.service.registry.container.ContainerRegistry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Slf4j +public class EcrPrivateContainerRegistry implements ContainerRegistry { + + private final EcrPrivateContainerRegistryClient ecrPrivateContainerRegistryClient; + + @Override + public String getName() { + return ECR_PRIVATE_REGISTRY_NAME; + } + + @Override + public String getHostname() { + return ecrPrivateContainerRegistryClient.getHostname(); + } + + @Override + public void validateArtifact(String containerImageUrl, String secret) { + ecrPrivateContainerRegistryClient.validateContainerImage(containerImageUrl, secret); + } + + @Override + public void validateCredential(String hostname, String secret) { + ecrPrivateContainerRegistryClient.validateCredential(hostname, secret); + } + + @Override + public void invalidateCache() { + + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/harbor/HarborContainerRegistry.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/harbor/HarborContainerRegistry.java new file mode 100644 index 0000000000..ca95225723 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/harbor/HarborContainerRegistry.java @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.container.harbor; + +import static com.nvidia.boot.registries.util.RegistriesConstants.HARBOR_REGISTRY_NAME; + +import com.nvidia.boot.registries.service.registry.client.harbor.HarborRegistryClient; +import com.nvidia.boot.registries.service.registry.container.ContainerRegistry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class HarborContainerRegistry implements ContainerRegistry { + + private final HarborRegistryClient harborRegistryClient; + + @Override + public String getName() { + return HARBOR_REGISTRY_NAME; + } + + @Override + public String getHostname() { + return harborRegistryClient.getHostname(); + } + + @Override + public void validateArtifact(String containerImageUrl, String secret) { + harborRegistryClient.validateArtifact(containerImageUrl, secret); + } + + @Override + public void validateCredential(String hostname, String secret) { + harborRegistryClient.getOciRegistryAuthClient().validateCredential(hostname, secret); + } + + @Override + public void invalidateCache() { + harborRegistryClient.getOciRegistryAuthClient().invalidateCache(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/ngc/NgcContainerRegistry.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/ngc/NgcContainerRegistry.java new file mode 100644 index 0000000000..df5cdb9403 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/ngc/NgcContainerRegistry.java @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.container.ngc; + +import static com.nvidia.boot.registries.util.RegistriesConstants.NGC_PRIVATE_REGISTRY_NAME; + +import com.nvidia.boot.registries.service.registry.client.ngc.NgcContainerRegistryClient; +import com.nvidia.boot.registries.service.registry.container.ContainerRegistry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Slf4j +public class NgcContainerRegistry implements ContainerRegistry { + private final NgcContainerRegistryClient ngcContainerRegistryClient; + + @Override + public String getName() { + return NGC_PRIVATE_REGISTRY_NAME; + } + + @Override + public String getHostname() { + return ngcContainerRegistryClient.getHostname(); + } + + @Override + public void validateArtifact(String containerImageUrl, String secret) { + ngcContainerRegistryClient.validateContainerImage(containerImageUrl, secret); + } + + @Override + public void validateCredential(String hostname, String secret) { + ngcContainerRegistryClient.validateCredential(hostname, secret); + } + + @Override + public void invalidateCache() { + ngcContainerRegistryClient.resetAuthTokenCache(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/volcengine/VolcengineContainerRegistry.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/volcengine/VolcengineContainerRegistry.java new file mode 100644 index 0000000000..551ce74ea7 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/container/volcengine/VolcengineContainerRegistry.java @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.container.volcengine; + +import static com.nvidia.boot.registries.util.RegistriesConstants.VOLCENGINE_REGISTRY_NAME; + +import com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineContainerRegistryClient; +import com.nvidia.boot.registries.service.registry.container.ContainerRegistry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Slf4j +public class VolcengineContainerRegistry implements ContainerRegistry { + + private final VolcengineContainerRegistryClient volcengineContainerRegistryClient; + + @Override + public String getName() { + return VOLCENGINE_REGISTRY_NAME; + } + + @Override + public String getHostname() { + return volcengineContainerRegistryClient.getHostname(); + } + + @Override + public void validateArtifact(String containerImageUrl, String secret) { + volcengineContainerRegistryClient.validateContainerImage(containerImageUrl, secret); + } + + @Override + public void validateCredential(String hostname, String secret) { + volcengineContainerRegistryClient.validateCredential(hostname, secret); + } + + @Override + public void invalidateCache() { + + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/dto/AccessKeyCredentials.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/dto/AccessKeyCredentials.java new file mode 100644 index 0000000000..4481582af4 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/dto/AccessKeyCredentials.java @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.dto; + +// Used by both AWS ECR and Volcano Engine implementations. +public record AccessKeyCredentials( + String accessKeyId, + String secretAccessKey) { + +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/dto/Artifact.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/dto/Artifact.java new file mode 100644 index 0000000000..01b85925ac --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/dto/Artifact.java @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.dto; + +import java.util.List; + +public record Artifact(String name, String version, + ArtifactTypeEnum artifactType, + List files) { +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/dto/ArtifactDetails.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/dto/ArtifactDetails.java new file mode 100644 index 0000000000..11a1bbeab7 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/dto/ArtifactDetails.java @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.dto; + +public record ArtifactDetails(String name, String version, String url) { +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/dto/ArtifactFile.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/dto/ArtifactFile.java new file mode 100644 index 0000000000..909b24fc78 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/dto/ArtifactFile.java @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import jakarta.annotation.Nullable; + +public record ArtifactFile(@Nullable @JsonInclude(Include.NON_NULL) String path, String url) { +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/dto/ArtifactKindEnum.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/dto/ArtifactKindEnum.java new file mode 100644 index 0000000000..ffc41de675 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/dto/ArtifactKindEnum.java @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.dto; + +import jakarta.validation.constraints.NotNull; + +public enum ArtifactKindEnum { + MODEL, + RESOURCE; + + public static ArtifactKindEnum fromText(@NotNull String val) { + return ArtifactKindEnum.valueOf(val.toUpperCase()); + } +} + diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/dto/ArtifactTypeEnum.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/dto/ArtifactTypeEnum.java new file mode 100644 index 0000000000..ddd95f7d62 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/dto/ArtifactTypeEnum.java @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.dto; + +import com.fasterxml.jackson.annotation.JsonValue; + +public enum ArtifactTypeEnum { + CONTAINER("CONTAINER"), + HELM("HELM"), + MODEL("MODEL"), + RESOURCE("RESOURCE"); + + @JsonValue + private final String name; + + ArtifactTypeEnum(String name) { + this.name = name; + } + + @Override + public String toString() { + return this.name; + } + + public static ArtifactTypeEnum fromText(String val) { + return ArtifactTypeEnum.valueOf(val.toUpperCase()); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/HelmRegistry.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/HelmRegistry.java new file mode 100644 index 0000000000..5bcb23f582 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/HelmRegistry.java @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.helm; + +public interface HelmRegistry { + String getName(); + + String getHostname(); + + void validateArtifact(String helmChartUrl, String secret); + + void validateCredential(String hostname, String secret); + + void invalidateCache(); +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/HelmRegistryService.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/HelmRegistryService.java new file mode 100644 index 0000000000..49b45096a4 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/HelmRegistryService.java @@ -0,0 +1,168 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.helm; + +import static com.nvidia.boot.registries.util.RegistriesConstants.MESG_ARTIFACT_VALIDATED; +import static com.nvidia.boot.registries.util.RegistriesConstants.MESG_ARTIFACT_VALIDATION_FAILED; +import static com.nvidia.boot.registries.util.RegistriesConstants.MESG_ARTIFACT_VALIDATION_FAILED_UNKNOWN_REASON; +import static com.nvidia.boot.registries.util.RegistriesConstants.MESG_REGISTRY_CREDENTIALS_VALIDATION_FAILED; +import static com.nvidia.boot.registries.util.RegistriesConstants.MESG_REGISTRY_CREDENTIALS_VALIDATION_FAILED_UNKNOWN_REASON; +import static com.nvidia.boot.registries.util.RegistriesConstants.MESG_UNSUPPORTED_HOSTNAME; + +import com.google.common.annotations.VisibleForTesting; +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.exceptions.TooManyRequestsException; +import com.nvidia.boot.exceptions.UnauthorizedException; +import com.nvidia.boot.registries.service.registry.RegistryMapperService; +import com.nvidia.boot.registries.service.registry.RegistryValidationService; +import com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class HelmRegistryService { + private static final String MESG_ARTIFACT_VALIDATION_DISABLED = + "Artifact validation disabled for helm registry hostname: {}"; + private static final String MESG_CREDENTIAL_VALIDATION_DISABLED = + "Credential validation disabled for helm registry hostname: {}"; + + private final Map helmRegistryMap; + private final RegistryMapperService registryMapperService; + private final RegistryValidationService registryValidationService; + + public HelmRegistryService(List helmRegistries, + RegistryMapperService registryMapperService, + RegistryValidationService registryValidationService) { + this.helmRegistryMap = helmRegistries.stream() + .collect(Collectors.toMap(HelmRegistry::getHostname, h -> h)); + this.registryMapperService = registryMapperService; + this.registryValidationService = registryValidationService; + } + + // When we run the tests locally, the hostname will be a localhost domain. + // However, the test artifacts are staging or canary materials, that means we won't be able to + // map them back to the localhost agent during lookup. The function is served for overwriting + // the map during test. + @VisibleForTesting + public void overwriteRegistryHostnameMap(String originalHost, String newHost) { + this.helmRegistryMap.put(newHost, this.helmRegistryMap.get(originalHost)); + } + + private HelmRegistry lookupRegistry(String hostname) { + String normalizedHostname = + registryMapperService.toNormalizedRecognizedRegistryHostname(hostname); + if (helmRegistryMap.containsKey(normalizedHostname)) { + return helmRegistryMap.get(normalizedHostname); + } else { + throw new IllegalArgumentException(MESG_UNSUPPORTED_HOSTNAME.formatted(hostname)); + } + } + + @SneakyThrows + private String extractRegistryHostname(String helmChartUrl) { + return new URI(helmChartUrl).getHost(); + } + + @SneakyThrows + public void validateArtifact(String helmChartUrl, + List secrets) { + var registryHostname = extractRegistryHostname(helmChartUrl); + if (!registryValidationService.isArtifactValidationEnabled( + ArtifactTypeEnum.HELM, registryHostname)) { + log.info(MESG_ARTIFACT_VALIDATION_DISABLED, registryHostname); + return; + } + + Exception lastException = null; + + for (String secret : secrets) { + try { + // Validate the artifact and let exception bubble. + lookupRegistry(registryHostname).validateArtifact(helmChartUrl, secret); + + // If we get here, validation succeeded using at least one of the secrets, just + // return. + log.info(MESG_ARTIFACT_VALIDATED, helmChartUrl); + return; + } catch (BadRequestException + | UnauthorizedException + | ForbiddenException + | NotFoundException + | TooManyRequestsException + | IllegalArgumentException e) { + // Remember the exception, then try the next secret. + lastException = e; + log.warn(MESG_ARTIFACT_VALIDATION_FAILED, helmChartUrl, + e.getMessage()); // We don't have the secret name to log. + } + } + + // No secret succeeded. Just use the last saved one. + if (lastException != null) { + log.error(MESG_ARTIFACT_VALIDATION_FAILED, helmChartUrl, lastException.getMessage()); + throw lastException; + } + + // Should not get here. + throw new IllegalStateException(MESG_ARTIFACT_VALIDATION_FAILED_UNKNOWN_REASON + .formatted(helmChartUrl)); + } + + @SneakyThrows + public void validateCredentials( + String registryHostname, + List secrets) { + if (!registryValidationService.isCredentialValidationEnabled( + ArtifactTypeEnum.HELM, registryHostname)) { + log.info(MESG_CREDENTIAL_VALIDATION_DISABLED, registryHostname); + return; + } + + Exception lastException = null; + + for (String secret : secrets) { + try { + lookupRegistry(registryHostname).validateCredential(registryHostname, secret); + return; + } catch (BadRequestException + | UnauthorizedException + | ForbiddenException + | NotFoundException + | TooManyRequestsException + | IllegalArgumentException e) { + // Remember the exception, then try the next secret. + lastException = e; + log.warn(MESG_REGISTRY_CREDENTIALS_VALIDATION_FAILED, e.getMessage()); + } + } + + // No secret succeeded. Just use the last saved one. + if (lastException != null) { + log.error(MESG_REGISTRY_CREDENTIALS_VALIDATION_FAILED, lastException.getMessage()); + throw lastException; + } + + throw new IllegalStateException(MESG_REGISTRY_CREDENTIALS_VALIDATION_FAILED_UNKNOWN_REASON); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/acr/AcrHelmRegistry.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/acr/AcrHelmRegistry.java new file mode 100644 index 0000000000..74ca598772 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/acr/AcrHelmRegistry.java @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.helm.acr; + +import static com.nvidia.boot.registries.util.RegistriesConstants.ACR_REGISTRY_NAME; + +import com.nvidia.boot.registries.service.registry.client.acr.AzureRegistryClient; +import com.nvidia.boot.registries.service.registry.helm.HelmRegistry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class AcrHelmRegistry implements HelmRegistry { + + private final AzureRegistryClient azureRegistryClient; + + @Override + public String getName() { + return ACR_REGISTRY_NAME; + } + + @Override + public String getHostname() { + return azureRegistryClient.getHostname(); + } + + @Override + public void validateArtifact(String helmChartUrl, String secret) { + azureRegistryClient.validateArtifact(helmChartUrl, secret); + } + + @Override + public void validateCredential(String hostname, String secret) { + azureRegistryClient.getOciRegistryAuthClient().validateCredential(hostname, secret); + } + + @Override + public void invalidateCache() { + azureRegistryClient.getOciRegistryAuthClient().invalidateCache(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/artifactory/ArtifactoryHelmRegistry.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/artifactory/ArtifactoryHelmRegistry.java new file mode 100644 index 0000000000..71eb13cb8b --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/artifactory/ArtifactoryHelmRegistry.java @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.helm.artifactory; + +import static com.nvidia.boot.registries.util.RegistriesConstants.ARTIFACTORY_REGISTRY_NAME; + +import com.nvidia.boot.registries.service.registry.client.artifactory.ArtifactoryClient; +import com.nvidia.boot.registries.service.registry.helm.HelmRegistry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class ArtifactoryHelmRegistry implements HelmRegistry { + + private final ArtifactoryClient artifactoryClient; + + @Override + public String getName() { + return ARTIFACTORY_REGISTRY_NAME; + } + + @Override + public String getHostname() { + return artifactoryClient.getHostname(); + } + + @Override + public void validateArtifact(String helmChartUrl, String secret) { + artifactoryClient.validateArtifact(helmChartUrl, secret); + } + + @Override + public void validateCredential(String hostname, String secret) { + artifactoryClient.getOciRegistryAuthClient().validateCredential(hostname, secret); + } + + @Override + public void invalidateCache() { + artifactoryClient.getOciRegistryAuthClient().invalidateCache(); + } +} + diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/custom/CustomHelmRegistry.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/custom/CustomHelmRegistry.java new file mode 100644 index 0000000000..f275ab76e2 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/custom/CustomHelmRegistry.java @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.helm.custom; + +import com.nvidia.boot.registries.service.registry.helm.HelmRegistry; +import lombok.extern.slf4j.Slf4j; + +// NCPs can add helm registries under nvcf.registries.recognized or +// nvct.registries.recognized that are not known to NVCF. For such registries, +// artifact validation and credential validation will not be supported. As a +// result, the fail-fast will not be supported. Also, NVCF/NVCT will not send +// any credentials to the Worker as the NCP is expected to configure +// image-pull-secrets on their own. +@Slf4j +public class CustomHelmRegistry implements HelmRegistry { + + private static final String MESG_UNSUPPORTED_OPERATION = + "Unsupported operation for helm registry '%s' with hostname '%s'"; + + private final String name; + private final String hostname; + + public CustomHelmRegistry(String name, String hostname) { + this.name = name; + this.hostname = hostname; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getHostname() { + return hostname; + } + + @Override + public void validateArtifact(String containerImageUrl, String secret) { + log.warn(MESG_UNSUPPORTED_OPERATION.formatted(name, hostname)); + } + + @Override + public void validateCredential(String hostname, String secret) { + log.warn(MESG_UNSUPPORTED_OPERATION.formatted(name, hostname)); + } + + @Override + public void invalidateCache() { + log.warn(MESG_UNSUPPORTED_OPERATION.formatted(name, hostname)); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/docker/DockerHelmRegistry.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/docker/DockerHelmRegistry.java new file mode 100644 index 0000000000..76f0f335d9 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/docker/DockerHelmRegistry.java @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.helm.docker; + +import static com.nvidia.boot.registries.util.RegistriesConstants.DOCKER_REGISTRY_NAME; + +import com.nvidia.boot.registries.service.registry.client.docker.DockerRegistryClient; +import com.nvidia.boot.registries.service.registry.helm.HelmRegistry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Slf4j +public class DockerHelmRegistry implements HelmRegistry { + private final DockerRegistryClient dockerClient; + + @Override + public String getName() { + return DOCKER_REGISTRY_NAME; + } + + @Override + public String getHostname() { + return dockerClient.getHostname(); + } + + @Override + public void validateArtifact(String helmChartUrl, String secret) { + dockerClient.validateImage(helmChartUrl, secret); + } + + @Override + public void validateCredential(String hostname, String secret) { + dockerClient.validateCredential(hostname, secret); + } + + @Override + public void invalidateCache() { + dockerClient.resetAuthTokenCache(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/ecr/pub/EcrPublicHelmRegistry.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/ecr/pub/EcrPublicHelmRegistry.java new file mode 100644 index 0000000000..bf8b23b16c --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/ecr/pub/EcrPublicHelmRegistry.java @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.helm.ecr.pub; + +import static com.nvidia.boot.registries.util.RegistriesConstants.ECR_PUBLIC_REGISTRY_NAME; + +import com.nvidia.boot.registries.service.registry.client.ecr.pub.EcrPublicArtifactRegistryClient; +import com.nvidia.boot.registries.service.registry.helm.HelmRegistry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class EcrPublicHelmRegistry implements HelmRegistry { + + private final EcrPublicArtifactRegistryClient ecrPublicArtifactRegistryClient; + + @Override + public String getName() { + return ECR_PUBLIC_REGISTRY_NAME; + } + + @Override + public String getHostname() { + return ecrPublicArtifactRegistryClient.getHostname(); + } + + @Override + public void validateArtifact(String helmChartUrl, String secret) { + ecrPublicArtifactRegistryClient.validateHelmChart(helmChartUrl, secret); + } + + @Override + public void validateCredential(String hostname, String secret) { + ecrPublicArtifactRegistryClient.validateCredential(hostname, secret); + } + + @Override + public void invalidateCache() { + + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/ecr/pvt/EcrPrivateHelmRegistry.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/ecr/pvt/EcrPrivateHelmRegistry.java new file mode 100644 index 0000000000..66441c28ef --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/ecr/pvt/EcrPrivateHelmRegistry.java @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.helm.ecr.pvt; + +import static com.nvidia.boot.registries.util.RegistriesConstants.ECR_PRIVATE_REGISTRY_NAME; + +import com.nvidia.boot.registries.service.registry.client.ecr.pvt.EcrPrivateArtifactRegistryClient; +import com.nvidia.boot.registries.service.registry.helm.HelmRegistry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Slf4j +public class EcrPrivateHelmRegistry implements HelmRegistry { + + private final EcrPrivateArtifactRegistryClient ecrPrivateArtifactRegistryClient; + + @Override + public String getName() { + return ECR_PRIVATE_REGISTRY_NAME; + } + + @Override + public String getHostname() { + return ecrPrivateArtifactRegistryClient.getHostname(); + } + + @Override + public void validateArtifact(String helmChartUrl, String secret) { + ecrPrivateArtifactRegistryClient.validateHelmChart(helmChartUrl, secret); + } + + @Override + public void validateCredential(String hostname, String secret) { + ecrPrivateArtifactRegistryClient.validateCredential(hostname, secret); + } + + @Override + public void invalidateCache() { + + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/harbor/HarborHelmRegistry.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/harbor/HarborHelmRegistry.java new file mode 100644 index 0000000000..9e2d85ba51 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/harbor/HarborHelmRegistry.java @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.helm.harbor; + +import static com.nvidia.boot.registries.util.RegistriesConstants.HARBOR_REGISTRY_NAME; + +import com.nvidia.boot.registries.service.registry.client.harbor.HarborRegistryClient; +import com.nvidia.boot.registries.service.registry.helm.HelmRegistry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class HarborHelmRegistry implements HelmRegistry { + + private final HarborRegistryClient harborRegistryClient; + + @Override + public String getName() { + return HARBOR_REGISTRY_NAME; + } + + @Override + public String getHostname() { + return harborRegistryClient.getHostname(); + } + + @Override + public void validateArtifact(String helmChartUrl, String secret) { + harborRegistryClient.validateArtifact(helmChartUrl, secret); + } + + @Override + public void validateCredential(String hostname, String secret) { + harborRegistryClient.getOciRegistryAuthClient().validateCredential(hostname, secret); + } + + @Override + public void invalidateCache() { + harborRegistryClient.getOciRegistryAuthClient().invalidateCache(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/ngc/NgcHelmRegistry.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/ngc/NgcHelmRegistry.java new file mode 100644 index 0000000000..0be432b8ef --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/ngc/NgcHelmRegistry.java @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.helm.ngc; + +import static com.nvidia.boot.registries.util.RegistriesConstants.NGC_PRIVATE_REGISTRY_NAME; + +import com.nvidia.boot.registries.service.registry.client.ngc.NgcArtifactRegistryClient; +import com.nvidia.boot.registries.service.registry.client.ngc.NgcRegistryUtils; +import com.nvidia.boot.registries.service.registry.helm.HelmRegistry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Slf4j +public class NgcHelmRegistry implements HelmRegistry { + private final NgcArtifactRegistryClient ngcArtifactRegistryClient; + + @Override + public String getName() { + return NGC_PRIVATE_REGISTRY_NAME; + } + + @Override + public String getHostname() { + // We are using the artifact endpoint to validate helm registry, so need to translate + // the helm registry hostname to artifact hostname. + return translateArtifactHostnameToHelmRegistryHostname( + ngcArtifactRegistryClient.getHostname()); + } + + @Override + public void validateArtifact(String helmChartUrl, String secret) { + ngcArtifactRegistryClient.validateHelmChart(helmChartUrl, + NgcRegistryUtils.getApiKey(secret)); + } + + @Override + public void validateCredential(String hostname, String secret) { + ngcArtifactRegistryClient.validateCredential(hostname, + NgcRegistryUtils.getApiKey(secret)); + } + + @Override + public void invalidateCache() { + ngcArtifactRegistryClient.resetAuthTokenCache(); + } + + public static String translateHelmRegistryHostnameToArtifactHostname(String helmHostname) { + return helmHostname.replace("helm", "api"); + } + + public static String translateArtifactHostnameToHelmRegistryHostname(String artifactHostname) { + return artifactHostname.replace("api", "helm"); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/volcengine/VolcengineHelmRegistry.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/volcengine/VolcengineHelmRegistry.java new file mode 100644 index 0000000000..ae3648ece3 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/helm/volcengine/VolcengineHelmRegistry.java @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.helm.volcengine; + +import static com.nvidia.boot.registries.util.RegistriesConstants.VOLCENGINE_REGISTRY_NAME; + +import com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineArtifactRegistryClient; +import com.nvidia.boot.registries.service.registry.helm.HelmRegistry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Slf4j +public class VolcengineHelmRegistry implements HelmRegistry { + + private final VolcengineArtifactRegistryClient volcengineArtifactRegistryClient; + + @Override + public String getName() { + return VOLCENGINE_REGISTRY_NAME; + } + + @Override + public String getHostname() { + return volcengineArtifactRegistryClient.getHostname(); + } + + @Override + public void validateArtifact(String helmChartUrl, String secret) { + volcengineArtifactRegistryClient.validateHelmChart(helmChartUrl, secret); + } + + @Override + public void validateCredential(String hostname, String secret) { + volcengineArtifactRegistryClient.validateCredential(hostname, secret); + } + + @Override + public void invalidateCache() { + + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/model/ModelRegistry.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/model/ModelRegistry.java new file mode 100644 index 0000000000..f3fff22c12 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/model/ModelRegistry.java @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.model; + +import com.nvidia.boot.registries.service.registry.dto.Artifact; +import com.nvidia.boot.registries.service.registry.dto.ArtifactDetails; + +public interface ModelRegistry { + String getName(); + + String getHostname(); + + long fetchSize(ArtifactDetails artifact, String secret); + + Artifact fetchArtifact(ArtifactDetails artifact, String secret); + + void validateArtifact(ArtifactDetails artifact, String secret); + + void invalidateCache(); +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/model/ModelRegistryService.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/model/ModelRegistryService.java new file mode 100644 index 0000000000..a28f5848aa --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/model/ModelRegistryService.java @@ -0,0 +1,184 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.model; + +import static com.nvidia.boot.registries.util.RegistriesConstants.MESG_ARTIFACT_VALIDATED; +import static com.nvidia.boot.registries.util.RegistriesConstants.MESG_ARTIFACT_VALIDATION_FAILED; +import static com.nvidia.boot.registries.util.RegistriesConstants.MESG_ARTIFACT_VALIDATION_FAILED_UNKNOWN_REASON; + +import com.google.common.annotations.VisibleForTesting; +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.exceptions.UnauthorizedException; +import com.nvidia.boot.registries.service.registry.RegistryMapperService; +import com.nvidia.boot.registries.service.registry.RegistryValidationService; +import com.nvidia.boot.registries.service.registry.dto.Artifact; +import com.nvidia.boot.registries.service.registry.dto.ArtifactDetails; +import com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum; +import java.net.URI; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.apache.logging.log4j.util.Strings; + + +@Slf4j +public class ModelRegistryService { + private static final String MESG_INVALID_MODEL_ARTIFACT = + "Model artifact either does not exist or not accessible with the account model " + + "registry secrets"; + private static final String MESG_MISSING_MODEL_ARTIFACT_SECRET = + "Missing secret for model artifact: {}"; + private static final String MESG_UNSUPPORTED_HOSTNAME = "Unsupported registry hostname: %s"; + private static final String MESG_ARTIFACT_VALIDATION_DISABLED = + "Artifact validation disabled for model registry hostname: {}"; + + private final Map modelRegistryMap; + private final RegistryMapperService registryMapperService; + private final RegistryValidationService registryValidationService; + + public ModelRegistryService(List modelRegistries, + RegistryMapperService registryMapperService, + RegistryValidationService registryValidationService) { + this.modelRegistryMap = modelRegistries.stream() + .collect(Collectors.toMap(ModelRegistry::getHostname, m -> m)); + this.registryMapperService = registryMapperService; + this.registryValidationService = registryValidationService; + } + + // When we run the tests locally, the hostname will be a localhost domain. + // However, the test artifacts are staging or canary materials, that means we won't be able to + // map them back to the localhost agent during lookup. The function is served for overwriting + // the map during test. + @VisibleForTesting + public void overwriteRegistryHostnameMap(String originalHost, String newHost) { + this.modelRegistryMap.put(newHost, this.modelRegistryMap.get(originalHost)); + } + + private ModelRegistry lookupRegistry(String hostname) { + var normalizedHostname = + registryMapperService.toNormalizedRecognizedRegistryHostname(hostname); + return Optional.ofNullable(modelRegistryMap.get(normalizedHostname)) + .orElseThrow(() -> new IllegalArgumentException( + MESG_UNSUPPORTED_HOSTNAME.formatted(hostname))); + } + + @SneakyThrows + private String extractRegistryHostname(String modelRegistryUrl) { + var registryHostName = new URI(modelRegistryUrl).getHost(); + if (Strings.isBlank(registryHostName)) { + throw new BadRequestException(MESG_INVALID_MODEL_ARTIFACT); + } + return registryMapperService.toNormalizedHostname(registryHostName); + } + + @SneakyThrows + private T processModelWithSecrets( + ArtifactDetails model, + String registryHostName, + List secrets, + ModelProcessor processor) { + if (secrets == null) { + throw new BadRequestException(MESG_MISSING_MODEL_ARTIFACT_SECRET); + } + Exception lastException = null; + for (var secret : secrets) { + try { + var retval = processor.process(lookupRegistry(registryHostName), model, secret); + log.info(MESG_ARTIFACT_VALIDATED, model.url()); + return retval; + } catch (BadRequestException | UnauthorizedException | + ForbiddenException | NotFoundException e) { + // Remember the exception, then try the next secret. + lastException = e; + log.debug(e.getMessage(), model.url()); + } + } + // No secret succeeded. Just use the last saved one. + if (lastException != null) { + log.error(MESG_ARTIFACT_VALIDATION_FAILED, model.url(), lastException.getMessage()); + throw lastException; + } + // Should not get here. + throw new IllegalStateException(MESG_ARTIFACT_VALIDATION_FAILED_UNKNOWN_REASON + .formatted(model.url())); + } + + public long fetchSize( + Collection models, + Map> registrySecrets) { + + return models.stream() + .mapToLong(model -> { + var registryHostName = extractRegistryHostname(model.url()); + return processModelWithSecrets( + model, + registryHostName, + registrySecrets.get(registryHostName), + ModelRegistry::fetchSize); + }) + .sum(); + } + + public List fetchArtifact( + Collection models, + Map> registrySecrets) { + return models.stream() + .map(model -> { + var registryHostName = extractRegistryHostname(model.url()); + return processModelWithSecrets( + model, + registryHostName, + registrySecrets.get(registryHostName), + ModelRegistry::fetchArtifact); + }) + .toList(); + } + + @SneakyThrows + public void validateArtifacts( + Collection models, + Map> registrySecrets) { + for (var model : models) { + var registryHostName = extractRegistryHostname(model.url()); + if (!registryValidationService.isArtifactValidationEnabled( + ArtifactTypeEnum.MODEL, registryHostName)) { + log.info(MESG_ARTIFACT_VALIDATION_DISABLED, registryHostName); + continue; + } + processModelWithSecrets( + model, + registryHostName, + registrySecrets.get(registryHostName), + (registry, m, secret) -> { + registry.validateArtifact(m, secret); + return null; + }); + } + } + + @FunctionalInterface + private interface ModelProcessor { + T process(ModelRegistry registry, ArtifactDetails model, String secret); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/model/ngc/NgcModelRegistry.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/model/ngc/NgcModelRegistry.java new file mode 100644 index 0000000000..64aa744c57 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/model/ngc/NgcModelRegistry.java @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.model.ngc; + +import static com.nvidia.boot.registries.service.registry.client.ngc.NgcRegistryUtils.MESG_FETCHED_PRESIGNED_ARTIFACT_URLS; +import static com.nvidia.boot.registries.service.registry.client.ngc.NgcRegistryUtils.MESG_FETCHED_SIZE_ARTIFACT_URLS; +import static com.nvidia.boot.registries.util.RegistriesConstants.NGC_PRIVATE_REGISTRY_NAME; + +import com.nvidia.boot.registries.service.registry.client.ngc.NgcArtifactRegistryClient; +import com.nvidia.boot.registries.service.registry.client.ngc.NgcRegistryUtils; +import com.nvidia.boot.registries.service.registry.dto.Artifact; +import com.nvidia.boot.registries.service.registry.dto.ArtifactDetails; +import com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum; +import com.nvidia.boot.registries.service.registry.model.ModelRegistry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Slf4j +public class NgcModelRegistry implements ModelRegistry { + private final NgcArtifactRegistryClient ngcArtifactRegistryClient; + + @Override + public String getName() { + return NGC_PRIVATE_REGISTRY_NAME; + } + + @Override + public String getHostname() { + return ngcArtifactRegistryClient.getHostname(); + } + + + @Override + public long fetchSize(ArtifactDetails model, String secret) { + var apiKey = NgcRegistryUtils.getApiKey(secret); + var size = ngcArtifactRegistryClient.fetchModelSize(model.url(), apiKey); + log.trace(MESG_FETCHED_SIZE_ARTIFACT_URLS, model.name(), model.url()); + return size; + } + + @Override + public Artifact fetchArtifact(ArtifactDetails model, String secret) { + var apiKey = NgcRegistryUtils.getApiKey(secret); + var preSignedModelURLs = + ngcArtifactRegistryClient.getPreSignedArtifactURLs(model.url(), apiKey); + log.trace(MESG_FETCHED_PRESIGNED_ARTIFACT_URLS, model.name(), preSignedModelURLs); + return new Artifact(model.name(), + model.version(), + ArtifactTypeEnum.MODEL, + preSignedModelURLs); + } + + @Override + public void validateArtifact(ArtifactDetails model, String secret) { + ngcArtifactRegistryClient.validateArtifact(model.url(), + NgcRegistryUtils.getApiKey(secret), + ArtifactTypeEnum.MODEL); + } + + @Override + public void invalidateCache() { + ngcArtifactRegistryClient.resetAuthTokenCache(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/resource/ResourceRegistry.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/resource/ResourceRegistry.java new file mode 100644 index 0000000000..5677b09bcf --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/resource/ResourceRegistry.java @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.resource; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.UnauthorizedException; +import com.nvidia.boot.registries.service.registry.dto.Artifact; +import com.nvidia.boot.registries.service.registry.dto.ArtifactDetails; + + +public interface ResourceRegistry { + String getName(); + + String getHostname(); + + long fetchSize(ArtifactDetails artifact, String secret); + + Artifact fetchArtifact(ArtifactDetails artifact, String secret); + + void validateArtifact(ArtifactDetails artifact, String secret) throws BadRequestException, + UnauthorizedException, ForbiddenException; + + void invalidateCache(); +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/resource/ResourceRegistryService.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/resource/ResourceRegistryService.java new file mode 100644 index 0000000000..b685dcf00f --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/resource/ResourceRegistryService.java @@ -0,0 +1,183 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.resource; + +import static com.nvidia.boot.registries.util.RegistriesConstants.MESG_ARTIFACT_VALIDATED; +import static com.nvidia.boot.registries.util.RegistriesConstants.MESG_ARTIFACT_VALIDATION_FAILED; +import static com.nvidia.boot.registries.util.RegistriesConstants.MESG_ARTIFACT_VALIDATION_FAILED_UNKNOWN_REASON; + +import com.google.common.annotations.VisibleForTesting; +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.exceptions.UnauthorizedException; +import com.nvidia.boot.registries.service.registry.RegistryMapperService; +import com.nvidia.boot.registries.service.registry.RegistryValidationService; +import com.nvidia.boot.registries.service.registry.dto.Artifact; +import com.nvidia.boot.registries.service.registry.dto.ArtifactDetails; +import com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum; +import java.net.URI; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.apache.logging.log4j.util.Strings; + +@Slf4j +public class ResourceRegistryService { + private static final String MESG_INVALID_RESOURCE_ARTIFACT = + "Resource artifact either does not exist or not accessible with account resource " + + "registry secrets"; + private static final String MESG_UNSUPPORTED_HOSTNAME = + "Unsupported registry hostname: %s"; + private static final String MESG_MISSING_RESOURCE_ARTIFACT_SECRET = + "Missing secret for resource artifact: %s"; + private static final String MESG_ARTIFACT_VALIDATION_DISABLED = + "Artifact validation disabled for resource registry hostname: {}"; + + private final Map resourceRegistryMap; + private final RegistryMapperService registryMapperService; + private final RegistryValidationService registryValidationService; + + public ResourceRegistryService(List resourceRegistries, + RegistryMapperService registryMapperService, + RegistryValidationService registryValidationService) { + this.resourceRegistryMap = resourceRegistries.stream() + .collect(Collectors.toMap(ResourceRegistry::getHostname, r -> r)); + this.registryMapperService = registryMapperService; + this.registryValidationService = registryValidationService; + } + + // When we run the tests locally, the hostname will be a localhost domain. + // However, the test artifacts are staging or canary materials, that means we won't be able to + // map them back to the localhost agent during lookup. The function is served for overwriting + // the map during test. + @VisibleForTesting + public void overwriteRegistryHostnameMap(String originalHost, String newHost) { + this.resourceRegistryMap.put(newHost, this.resourceRegistryMap.get(originalHost)); + } + + private ResourceRegistry lookupRegistry(String hostname) { + var normalizedHostname = + registryMapperService.toNormalizedRecognizedRegistryHostname(hostname); + return Optional.ofNullable(resourceRegistryMap.get(normalizedHostname)) + .orElseThrow( + () -> new NotFoundException(MESG_UNSUPPORTED_HOSTNAME.formatted(hostname))); + } + + @SneakyThrows + private String extractRegistryHostname(String modelRegistryUrl) { + var registryHostName = new URI(modelRegistryUrl).getHost(); + if (Strings.isBlank(registryHostName)) { + throw new BadRequestException(MESG_INVALID_RESOURCE_ARTIFACT); + } + return registryMapperService.toNormalizedHostname(registryHostName); + } + + @SneakyThrows + private T processResourceWithSecrets( + ArtifactDetails resource, + String registryHostName, + List secrets, + ResourceProcessor processor) { + if (secrets == null) { + throw new BadRequestException( + MESG_MISSING_RESOURCE_ARTIFACT_SECRET.formatted(registryHostName)); + } + Exception lastException = null; + for (var secret : secrets) { + try { + var retval = processor.process(lookupRegistry(registryHostName), resource, secret); + log.info(MESG_ARTIFACT_VALIDATED, resource.url()); + return retval; + } catch (BadRequestException | UnauthorizedException | + ForbiddenException | NotFoundException e) { + // Remember the exception, then try the next secret. + lastException = e; + log.debug(e.getMessage(), e); + } + } + // No secret succeeded. Just use the last saved one. + if (lastException != null) { + log.error(MESG_ARTIFACT_VALIDATION_FAILED, resource.url(), lastException.getMessage()); + throw lastException; + } + // Should not get here. + throw new IllegalStateException(MESG_ARTIFACT_VALIDATION_FAILED_UNKNOWN_REASON + .formatted(resource.url())); + } + + public long fetchSize( + Collection resources, + Map> registrySecrets) { + return resources.stream() + .mapToLong(resource -> { + var registryHostName = extractRegistryHostname(resource.url()); + return processResourceWithSecrets( + resource, + registryHostName, + registrySecrets.get(registryHostName), + ResourceRegistry::fetchSize); + }) + .sum(); + } + + public List fetchArtifact( + Collection resources, + Map> registrySecrets) { + return resources.stream() + .map(resource -> { + var registryHostName = extractRegistryHostname(resource.url()); + return processResourceWithSecrets( + resource, + registryHostName, + registrySecrets.get(registryHostName), + ResourceRegistry::fetchArtifact); + }) + .toList(); + } + + public void validateArtifacts( + Collection resources, + Map> registrySecrets) { + for (var resource : resources) { + var registryHostName = extractRegistryHostname(resource.url()); + if (!registryValidationService.isArtifactValidationEnabled( + ArtifactTypeEnum.RESOURCE, registryHostName)) { + log.info(MESG_ARTIFACT_VALIDATION_DISABLED, registryHostName); + continue; + } + processResourceWithSecrets( + resource, + registryHostName, + registrySecrets.get(registryHostName), + (registry, r, secret) -> { + registry.validateArtifact(r, secret); + return null; + }); + } + } + + @FunctionalInterface + private interface ResourceProcessor { + T process(ResourceRegistry registry, ArtifactDetails resource, String secret); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/resource/ngc/NgcResourceRegistry.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/resource/ngc/NgcResourceRegistry.java new file mode 100644 index 0000000000..76ab8d911a --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/service/registry/resource/ngc/NgcResourceRegistry.java @@ -0,0 +1,81 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.resource.ngc; + +import static com.nvidia.boot.registries.service.registry.client.ngc.NgcRegistryUtils.MESG_FETCHED_PRESIGNED_ARTIFACT_URLS; +import static com.nvidia.boot.registries.service.registry.client.ngc.NgcRegistryUtils.MESG_FETCHED_SIZE_ARTIFACT_URLS; +import static com.nvidia.boot.registries.util.RegistriesConstants.NGC_PRIVATE_REGISTRY_NAME; + +import com.nvidia.boot.registries.service.registry.client.ngc.NgcArtifactRegistryClient; +import com.nvidia.boot.registries.service.registry.client.ngc.NgcRegistryUtils; +import com.nvidia.boot.registries.service.registry.dto.Artifact; +import com.nvidia.boot.registries.service.registry.dto.ArtifactDetails; +import com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum; +import com.nvidia.boot.registries.service.registry.resource.ResourceRegistry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Slf4j +public class NgcResourceRegistry implements ResourceRegistry { + private final NgcArtifactRegistryClient ngcArtifactRegistryClient; + + @Override + public String getName() { + return NGC_PRIVATE_REGISTRY_NAME; + } + + @Override + public String getHostname() { + return ngcArtifactRegistryClient.getHostname(); + } + + + @Override + public long fetchSize(ArtifactDetails resource, String secret) { + var apiKey = NgcRegistryUtils.getApiKey(secret); + var size = ngcArtifactRegistryClient.fetchResourceSize(resource.url(), apiKey); + log.trace(MESG_FETCHED_SIZE_ARTIFACT_URLS, resource.name(), resource.url()); + return size; + + } + + @Override + public Artifact fetchArtifact(ArtifactDetails resource, String secret) { + var apiKey = NgcRegistryUtils.getApiKey(secret); + var preSignedModelURLs = + ngcArtifactRegistryClient.getPreSignedArtifactURLs(resource.url(), apiKey); + log.trace(MESG_FETCHED_PRESIGNED_ARTIFACT_URLS, resource.name(), preSignedModelURLs); + return new Artifact(resource.name(), + resource.version(), + ArtifactTypeEnum.RESOURCE, + preSignedModelURLs); + } + + @Override + public void validateArtifact(ArtifactDetails resource, String secret) { + ngcArtifactRegistryClient.validateArtifact(resource.url(), + NgcRegistryUtils.getApiKey(secret), + ArtifactTypeEnum.RESOURCE); + } + + @Override + public void invalidateCache() { + ngcArtifactRegistryClient.resetAuthTokenCache(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/util/HashUtils.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/util/HashUtils.java new file mode 100644 index 0000000000..5f09b1ea17 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/util/HashUtils.java @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.util; + +import java.nio.charset.StandardCharsets; +import lombok.experimental.UtilityClass; +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.codec.digest.HmacAlgorithms; +import org.apache.commons.codec.digest.HmacUtils; + +@UtilityClass +public class HashUtils { + + public static String toSha256Hex(String content) { + return DigestUtils.sha256Hex(content); + } + + public static byte[] hmacSha256(byte[] key, String content) { + return new HmacUtils(HmacAlgorithms.HMAC_SHA_256, key).hmac(content); + } + + public static String hmacSha256Hex(byte[] key, String content) { + return new HmacUtils(HmacAlgorithms.HMAC_SHA_256, key).hmacHex(content); + } + + public static byte[] getSignatureKey( + String key, + String date, + String region, + String service, + String requestType) { + byte[] dateKey = hmacSha256(key.getBytes(StandardCharsets.UTF_8), date); + byte[] regionKey = hmacSha256(dateKey, region); + byte[] serviceKey = hmacSha256(regionKey, service); + return hmacSha256(serviceKey, requestType); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/util/RegistriesConstants.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/util/RegistriesConstants.java new file mode 100644 index 0000000000..ddb520c4b1 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/java/com/nvidia/boot/registries/util/RegistriesConstants.java @@ -0,0 +1,76 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.util; + +import lombok.experimental.UtilityClass; + +@UtilityClass +public final class RegistriesConstants { + public static final java.util.regex.Pattern CAS_MODELS_VERSIONS_PATH = + java.util.regex.Pattern.compile( + "^https://api(\\.(stg|canary))?\\.ngc\\.nvidia\\.com/v2/org/[a-zA-Z_\\-0-9]+(/team/[a-zA-Z_\\-0-9]+)?/models/.+/versions/.+/files"); + public static final java.util.regex.Pattern CAS_DIRECT_MODELS_REGISTRY_PATH = + java.util.regex.Pattern.compile( + "^https://api(\\.(stg|canary))?\\.ngc\\.nvidia\\.com/v2/org/[a-zA-Z0-9_-]+(/team/[a-zA-Z0-9_-]+)?/models/.+/.+/files"); + public static final java.util.regex.Pattern CAS_RESOURCES_VERSIONS_PATH = + java.util.regex.Pattern.compile( + "^https://api(\\.(stg|canary))?\\.ngc\\.nvidia\\.com/v2/org/[a-zA-Z0-9_-]+(/team/[a-zA-Z0-9_-]+)?/resources/.+/versions/.+/files"); + public static final java.util.regex.Pattern CAS_DIRECT_RESOURCES_REGISTRY_PATH = + java.util.regex.Pattern.compile( + "^https://api(\\.(stg|canary))?\\.ngc\\.nvidia\\.com/v2/org/[a-zA-Z0-9_-]+(/team/[a-zA-Z0-9_-]+)?/resources/.+/.+/files"); + + public static final String MESG_ARTIFACT_VALIDATED = + "Artifact '{}' presence validated"; + public static final String MESG_UNSUPPORTED_HOSTNAME = + "Unsupported registry registryHostname: %s"; + public static final String MESG_ARTIFACT_VALIDATION_FAILED = + "Artifact '{}' presence validation failed: {}"; + public static final String MESG_ARTIFACT_VALIDATION_FAILED_UNKNOWN_REASON = + "Artifact '%s' presence validation failed for unknown reason"; + public static final String MESG_REGISTRY_CREDENTIALS_VALIDATION_FAILED = + "Registry credentials validation failed: {}"; + public static final String MESG_REGISTRY_CREDENTIALS_VALIDATION_FAILED_UNKNOWN_REASON = + "Registry credentials validation failed for unknown reason"; + + public static final String NGC_PRIVATE_REGISTRY_KEY = "ngc"; + public static final String NGC_PRIVATE_REGISTRY_NAME = "NGC Private Registry"; + public static final String NGC_ARTIFACT_REGISTRY_PROD_HOSTNAME = "api.ngc.nvidia.com"; + public static final String NGC_CONTAINER_REGISTRY_PROD_HOSTNAME = "nvcr.io"; + public static final String NGC_HELM_REGISTRY_PROD_HOSTNAME = "helm.ngc.nvidia.com"; + + public static final String DOCKER_REGISTRY_KEY = "docker"; + public static final String DOCKER_REGISTRY_NAME = "Docker Hub Registry"; + + public static final String ECR_PRIVATE_REGISTRY_KEY = "ecr"; + public static final String ECR_PRIVATE_REGISTRY_NAME = "AWS ECR Private Registry"; + + public static final String ECR_PUBLIC_REGISTRY_KEY = "ecr-public"; + public static final String ECR_PUBLIC_REGISTRY_NAME = "AWS ECR Public Registry"; + + public static final String VOLCENGINE_REGISTRY_KEY = "volcengine"; + public static final String VOLCENGINE_REGISTRY_NAME = "Volcano Engine Registry"; + + public static final String ACR_REGISTRY_KEY = "acr"; + public static final String ACR_REGISTRY_NAME = "Azure Container Registry"; + + public static final String HARBOR_REGISTRY_KEY = "harbor"; + public static final String HARBOR_REGISTRY_NAME = "Harbor Registry"; + + public static final String ARTIFACTORY_REGISTRY_KEY = "artifactory"; + public static final String ARTIFACTORY_REGISTRY_NAME = "JFrog Artifactory Registry"; +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000000..3db1d293d1 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +com.nvidia.boot.registries.configurations.RegistryConfigurationPropertiesAutoConfiguration +com.nvidia.boot.registries.configurations.RegistryServiceAutoConfiguration +com.nvidia.boot.registries.configurations.ModelRegistryAutoConfiguration +com.nvidia.boot.registries.configurations.ResourceRegistryAutoConfiguration +com.nvidia.boot.registries.configurations.ContainerRegistryAutoConfiguration +com.nvidia.boot.registries.configurations.HelmRegistryAutoConfiguration diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/NvBootRegistriesIntegrationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/NvBootRegistriesIntegrationTest.java new file mode 100644 index 0000000000..bf33c0ec0d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/NvBootRegistriesIntegrationTest.java @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.nvidia.boot.registries.configurations.RegistryConfigurationProperties; +import com.nvidia.boot.registries.service.registry.RegistryLookupService; +import com.nvidia.boot.registries.service.registry.RegistryMapperService; +import com.nvidia.boot.registries.service.registry.container.ContainerRegistryService; +import com.nvidia.boot.registries.service.registry.helm.HelmRegistryService; +import com.nvidia.boot.registries.service.registry.model.ModelRegistryService; +import com.nvidia.boot.registries.service.registry.resource.ResourceRegistryService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +/** + * Integration test to verify registry auto-configurations are loaded when the app starts. + */ +@SpringBootTest(classes = TestRegistryApplication.class) +@ActiveProfiles("test") +class NvBootRegistriesIntegrationTest { + + @Autowired + private RegistryConfigurationProperties registryConfigurationProperties; + + @Autowired + private RegistryMapperService registryMapperService; + + @Autowired + private RegistryLookupService registryLookupService; + + @Autowired + private ModelRegistryService modelRegistryService; + + @Autowired + private ResourceRegistryService resourceRegistryService; + + @Autowired + private HelmRegistryService helmRegistryService; + + @Autowired + private ContainerRegistryService containerRegistryService; + + @Test + void registryConfigurationsAreLoaded() { + assertThat(registryConfigurationProperties).isNotNull(); + assertThat(registryConfigurationProperties.getRecognized()).isNotNull(); + } + + @Test + void registryServiceBeansAreInjected() { + assertThat(registryMapperService).isNotNull(); + assertThat(registryLookupService).isNotNull(); + assertThat(modelRegistryService).isNotNull(); + assertThat(resourceRegistryService).isNotNull(); + assertThat(helmRegistryService).isNotNull(); + assertThat(containerRegistryService).isNotNull(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/TestRegistryApplication.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/TestRegistryApplication.java new file mode 100644 index 0000000000..68d7ce78eb --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/TestRegistryApplication.java @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries; + +import com.nvidia.boot.registries.configurations.RegistryConfigPathProvider; +import com.nvidia.boot.registries.service.registry.container.ContainerRegistry; +import com.nvidia.boot.registries.service.registry.helm.HelmRegistry; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Minimal test application for registry integration tests. + * Provides RegistryConfigPathProvider required by the registry auto-configurations. + */ +@SpringBootApplication +public class TestRegistryApplication { + + public static void main(String[] args) { + SpringApplication.run(TestRegistryApplication.class, args); + } + + @Configuration(proxyBeanMethods = false) + static class TestRegistryConfig { + + @Bean + RegistryConfigPathProvider registryConfigPathProvider() { + return () -> "test.registries"; + } + + /** + * Provides dummy ContainerRegistry and HelmRegistry beans so Spring resolves + * List<ContainerRegistry> knownContainerRegistries and + * List<HelmRegistry> knownHelmRegistries by collecting individual beans + * rather than falling back to the consolidated list beans (which would cause a + * circular dependency when no individual beans exist or during RefreshScope init). + */ + @Bean + ContainerRegistry testKnownContainerRegistry() { + return new ContainerRegistry() { + @Override + public String getName() { + return "test-container"; + } + + @Override + public String getHostname() { + return "test.example.com"; + } + + @Override + public void validateArtifact(String containerImageUrl, String secret) {} + + @Override + public void validateCredential(String hostname, String secret) {} + + @Override + public void invalidateCache() {} + }; + } + + @Bean + HelmRegistry testKnownHelmRegistry() { + return new HelmRegistry() { + @Override + public String getName() { + return "test-helm"; + } + + @Override + public String getHostname() { + return "test.example.com"; + } + + @Override + public void validateArtifact(String helmChartUrl, String secret) {} + + @Override + public void validateCredential(String hostname, String secret) {} + + @Override + public void invalidateCache() {} + }; + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/configurations/ContainerRegistryConfigurationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/configurations/ContainerRegistryConfigurationTest.java new file mode 100644 index 0000000000..bee92369fb --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/configurations/ContainerRegistryConfigurationTest.java @@ -0,0 +1,175 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.configurations; + +import static com.nvidia.boot.registries.util.RegistriesConstants.NGC_PRIVATE_REGISTRY_KEY; +import static com.nvidia.boot.registries.util.TestUtils.registryConfig; +import static org.assertj.core.api.Assertions.assertThat; + +import com.nvidia.boot.registries.service.registry.container.ContainerRegistry; +import com.nvidia.boot.registries.service.registry.container.custom.CustomContainerRegistry; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.CustomScopeConfigurer; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.cloud.context.scope.refresh.RefreshScope; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.DependsOn; + +class ContainerRegistryConfigurationTest { + + private static final String CUSTOM_REGISTRY_KEY = "custom-registry"; + private static final String CUSTOM_REGISTRY_NAME = "Custom Registry"; + private static final String CUSTOM_REGISTRY_HOSTNAME = "custom.registry.example.com"; + + private static ApplicationContextRunner baseRunner() { + return new ApplicationContextRunner() + .withUserConfiguration(RefreshScopeConfig.class) + .withUserConfiguration(TestKnownRegistryConfig.class) + .withUserConfiguration(ContainerRegistryAutoConfiguration.class); + } + + @Test + void customContainerRegistriesCreatesCustomRegistryForCustomKeys() { + baseRunner().withUserConfiguration(TestPropertiesWithCustomRegistryConfig.class) + .run(context -> { + var customList = context.getBean("customContainerRegistries", List.class); + assertThat(customList).hasSize(1); + var registry = (CustomContainerRegistry) customList.get(0); + assertThat(registry.getName()).isEqualTo(CUSTOM_REGISTRY_NAME); + assertThat(registry.getHostname()).isEqualTo(CUSTOM_REGISTRY_HOSTNAME); + }); + } + + @Test + void containerRegistriesMergesKnownAndCustomRegistries() { + baseRunner().withUserConfiguration(TestPropertiesWithCustomRegistryConfig.class) + .run(context -> { + var combined = context.getBean("containerRegistries", List.class); + assertThat(combined).isNotEmpty(); + var customCount = combined.stream() + .filter(CustomContainerRegistry.class::isInstance) + .count(); + assertThat(customCount).isEqualTo(1); + }); + } + + @Test + void containerRegistriesReturnsOnlyKnownWhenNoCustomRegistries() { + baseRunner().withUserConfiguration(TestPropertiesConfig.class) + .run(context -> { + var customList = context.getBean("customContainerRegistries", List.class); + assertThat(customList).isEmpty(); + + var combined = context.getBean("containerRegistries", List.class); + var customCount = combined.stream() + .filter(CustomContainerRegistry.class::isInstance) + .count(); + assertThat(customCount).isZero(); + }); + } + + /** + * Provides a dummy ContainerRegistry bean so Spring resolves + * List<ContainerRegistry> knownContainerRegistries by collecting individual beans + * rather than falling back to the containerRegistries bean (which would cause a circular + * dependency when no individual beans exist). + */ + @Configuration + static class TestKnownRegistryConfig { + @Bean + ContainerRegistry testKnownContainerRegistry() { + return new ContainerRegistry() { + @Override + public String getName() { + return "NGC"; + } + + @Override + public String getHostname() { + return "nvcr.io"; + } + + @Override + public void validateArtifact(String containerImageUrl, String secret) {} + + @Override + public void validateCredential(String hostname, String secret) {} + + @Override + public void invalidateCache() {} + }; + } + } + + @Configuration + static class RefreshScopeConfig { + @Bean + RefreshScope refreshScope() { + return new RefreshScope(); + } + + @Bean + @DependsOn("refreshScope") + CustomScopeConfigurer customScopeConfigurer(RefreshScope refreshScope) { + var configurer = new CustomScopeConfigurer(); + configurer.addScope("refresh", refreshScope); + return configurer; + } + } + + @Configuration + static class TestPropertiesConfig { + @Bean + RegistryConfigurationProperties registryConfigurationProperties() { + var recognized = new RegistryConfigurationProperties.RecognizedRegistryConfiguration(); + recognized.setContainer(Map.of( + NGC_PRIVATE_REGISTRY_KEY, + registryConfig("NGC", "nvcr.io"))); + recognized.setHelm(Map.of()); + recognized.setModel(Map.of()); + recognized.setResource(Map.of()); + + var props = new RegistryConfigurationProperties(); + props.setRecognized(recognized); + return props; + } + } + + @Configuration + static class TestPropertiesWithCustomRegistryConfig { + @Bean + RegistryConfigurationProperties registryConfigurationProperties() { + var recognized = new RegistryConfigurationProperties.RecognizedRegistryConfiguration(); + recognized.setContainer(Map.of( + NGC_PRIVATE_REGISTRY_KEY, + registryConfig("NGC", "nvcr.io"), + CUSTOM_REGISTRY_KEY, + registryConfig(CUSTOM_REGISTRY_NAME, CUSTOM_REGISTRY_HOSTNAME))); + recognized.setHelm(Map.of()); + recognized.setModel(Map.of()); + recognized.setResource(Map.of()); + + var props = new RegistryConfigurationProperties(); + props.setRecognized(recognized); + return props; + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/configurations/HelmRegistryConfigurationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/configurations/HelmRegistryConfigurationTest.java new file mode 100644 index 0000000000..fcff302488 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/configurations/HelmRegistryConfigurationTest.java @@ -0,0 +1,175 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.configurations; + +import static com.nvidia.boot.registries.util.RegistriesConstants.NGC_PRIVATE_REGISTRY_KEY; +import static com.nvidia.boot.registries.util.TestUtils.registryConfig; +import static org.assertj.core.api.Assertions.assertThat; + +import com.nvidia.boot.registries.service.registry.helm.HelmRegistry; +import com.nvidia.boot.registries.service.registry.helm.custom.CustomHelmRegistry; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.CustomScopeConfigurer; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.cloud.context.scope.refresh.RefreshScope; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.DependsOn; + +class HelmRegistryConfigurationTest { + + private static final String CUSTOM_REGISTRY_KEY = "custom-helm-registry"; + private static final String CUSTOM_REGISTRY_NAME = "Custom Helm Registry"; + private static final String CUSTOM_REGISTRY_HOSTNAME = "helm.registry.example.com"; + + private static ApplicationContextRunner baseRunner() { + return new ApplicationContextRunner() + .withUserConfiguration(RefreshScopeConfig.class) + .withUserConfiguration(TestKnownRegistryConfig.class) + .withUserConfiguration(HelmRegistryAutoConfiguration.class); + } + + @Test + void customHelmRegistriesCreatesCustomRegistryForCustomKeys() { + baseRunner().withUserConfiguration(TestPropertiesWithCustomRegistryConfig.class) + .run(context -> { + var customList = context.getBean("customHelmRegistries", List.class); + assertThat(customList).hasSize(1); + var registry = (CustomHelmRegistry) customList.get(0); + assertThat(registry.getName()).isEqualTo(CUSTOM_REGISTRY_NAME); + assertThat(registry.getHostname()).isEqualTo(CUSTOM_REGISTRY_HOSTNAME); + }); + } + + @Test + void helmRegistriesMergesKnownAndCustomRegistries() { + baseRunner().withUserConfiguration(TestPropertiesWithCustomRegistryConfig.class) + .run(context -> { + var combined = context.getBean("helmRegistries", List.class); + assertThat(combined).isNotEmpty(); + var customCount = combined.stream() + .filter(CustomHelmRegistry.class::isInstance) + .count(); + assertThat(customCount).isEqualTo(1); + }); + } + + @Test + void helmRegistriesReturnsOnlyKnownWhenNoCustomRegistries() { + baseRunner().withUserConfiguration(TestPropertiesConfig.class) + .run(context -> { + var customList = context.getBean("customHelmRegistries", List.class); + assertThat(customList).isEmpty(); + + var combined = context.getBean("helmRegistries", List.class); + var customCount = combined.stream() + .filter(CustomHelmRegistry.class::isInstance) + .count(); + assertThat(customCount).isZero(); + }); + } + + /** + * Provides a dummy HelmRegistry bean so Spring resolves + * List<HelmRegistry> knownHelmRegistries by collecting individual beans + * rather than falling back to the helmRegistries bean (which would cause a circular + * dependency when no individual beans exist). + */ + @Configuration + static class TestKnownRegistryConfig { + @Bean + HelmRegistry testKnownHelmRegistry() { + return new HelmRegistry() { + @Override + public String getName() { + return "NGC"; + } + + @Override + public String getHostname() { + return "nvcr.io"; + } + + @Override + public void validateArtifact(String helmChartUrl, String secret) {} + + @Override + public void validateCredential(String hostname, String secret) {} + + @Override + public void invalidateCache() {} + }; + } + } + + @Configuration + static class RefreshScopeConfig { + @Bean + RefreshScope refreshScope() { + return new RefreshScope(); + } + + @Bean + @DependsOn("refreshScope") + CustomScopeConfigurer customScopeConfigurer(RefreshScope refreshScope) { + var configurer = new CustomScopeConfigurer(); + configurer.addScope("refresh", refreshScope); + return configurer; + } + } + + @Configuration + static class TestPropertiesConfig { + @Bean + RegistryConfigurationProperties registryConfigurationProperties() { + var recognized = new RegistryConfigurationProperties.RecognizedRegistryConfiguration(); + recognized.setContainer(Map.of()); + recognized.setHelm(Map.of( + NGC_PRIVATE_REGISTRY_KEY, + registryConfig("NGC", "nvcr.io"))); + recognized.setModel(Map.of()); + recognized.setResource(Map.of()); + + var props = new RegistryConfigurationProperties(); + props.setRecognized(recognized); + return props; + } + } + + @Configuration + static class TestPropertiesWithCustomRegistryConfig { + @Bean + RegistryConfigurationProperties registryConfigurationProperties() { + var recognized = new RegistryConfigurationProperties.RecognizedRegistryConfiguration(); + recognized.setContainer(Map.of()); + recognized.setHelm(Map.of( + NGC_PRIVATE_REGISTRY_KEY, + registryConfig("NGC", "nvcr.io"), + CUSTOM_REGISTRY_KEY, + registryConfig(CUSTOM_REGISTRY_NAME, CUSTOM_REGISTRY_HOSTNAME))); + recognized.setModel(Map.of()); + recognized.setResource(Map.of()); + + var props = new RegistryConfigurationProperties(); + props.setRecognized(recognized); + return props; + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/configurations/RegistryConfigurationIntegrationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/configurations/RegistryConfigurationIntegrationTest.java new file mode 100644 index 0000000000..21b2886d89 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/configurations/RegistryConfigurationIntegrationTest.java @@ -0,0 +1,215 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.configurations; + +import static com.nvidia.boot.registries.util.RegistriesConstants.NGC_PRIVATE_REGISTRY_KEY; +import static com.nvidia.boot.registries.util.TestUtils.registryConfig; +import static org.assertj.core.api.Assertions.assertThat; + +import com.nvidia.boot.registries.service.registry.container.ContainerRegistry; +import com.nvidia.boot.registries.service.registry.container.custom.CustomContainerRegistry; +import com.nvidia.boot.registries.service.registry.helm.HelmRegistry; +import com.nvidia.boot.registries.service.registry.helm.custom.CustomHelmRegistry; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.CustomScopeConfigurer; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.cloud.context.scope.refresh.RefreshScope; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.DependsOn; + +/** + * Integration tests verifying that the consolidated registry lists (containerRegistries, + * helmRegistries) correctly merge known and custom registries. These @Primary beans are + * the ones injected into ContainerRegistryService and HelmRegistryService in production. + */ +class RegistryConfigurationIntegrationTest { + + private static final String CUSTOM_CONTAINER_KEY = "custom-container-registry"; + private static final String CUSTOM_CONTAINER_NAME = "Custom Container Registry"; + private static final String CUSTOM_CONTAINER_HOSTNAME = "custom.container.example.com"; + + private static final String CUSTOM_HELM_KEY = "custom-helm-registry"; + private static final String CUSTOM_HELM_NAME = "Custom Helm Registry"; + private static final String CUSTOM_HELM_HOSTNAME = "custom.helm.example.com"; + + private static ApplicationContextRunner baseRunner() { + return new ApplicationContextRunner() + .withUserConfiguration(RefreshScopeConfig.class) + .withUserConfiguration(IntegrationTestConfig.class) + .withUserConfiguration(ContainerRegistryAutoConfiguration.class) + .withUserConfiguration(HelmRegistryAutoConfiguration.class); + } + + @Test + void containerRegistriesIncludesCustomRegistryForServiceInjection() { + baseRunner().withUserConfiguration(IntegrationTestPropertiesConfig.class) + .run(context -> { + @SuppressWarnings("unchecked") + var containerRegistries = + (List) context.getBean("containerRegistries", List.class); + var customRegistry = containerRegistries.stream() + .filter(CustomContainerRegistry.class::isInstance) + .map(CustomContainerRegistry.class::cast) + .filter(r -> CUSTOM_CONTAINER_HOSTNAME.equals(r.getHostname())) + .findFirst(); + assertThat(customRegistry).isPresent(); + assertThat(customRegistry.get().getName()).isEqualTo(CUSTOM_CONTAINER_NAME); + assertThat(customRegistry.get().getHostname()) + .isEqualTo(CUSTOM_CONTAINER_HOSTNAME); + }); + } + + @Test + void helmRegistriesIncludesCustomRegistryForServiceInjection() { + baseRunner().withUserConfiguration(IntegrationTestPropertiesConfig.class) + .run(context -> { + @SuppressWarnings("unchecked") + var helmRegistries = + (List) context.getBean("helmRegistries", List.class); + var customRegistry = helmRegistries.stream() + .filter(CustomHelmRegistry.class::isInstance) + .map(CustomHelmRegistry.class::cast) + .filter(r -> CUSTOM_HELM_HOSTNAME.equals(r.getHostname())) + .findFirst(); + assertThat(customRegistry).isPresent(); + assertThat(customRegistry.get().getName()).isEqualTo(CUSTOM_HELM_NAME); + assertThat(customRegistry.get().getHostname()) + .isEqualTo(CUSTOM_HELM_HOSTNAME); + }); + } + + @Test + void bothConsolidatedListsIncludeKnownAndCustomRegistries() { + baseRunner().withUserConfiguration(IntegrationTestPropertiesConfig.class) + .run(context -> { + var containerRegistries = + context.getBean("containerRegistries", List.class); + var helmRegistries = context.getBean("helmRegistries", List.class); + + var containerCustomCount = containerRegistries.stream() + .filter(CustomContainerRegistry.class::isInstance) + .count(); + var helmCustomCount = helmRegistries.stream() + .filter(CustomHelmRegistry.class::isInstance) + .count(); + + assertThat(containerCustomCount).isEqualTo(1); + assertThat(helmCustomCount).isEqualTo(1); + assertThat(containerRegistries).hasSizeGreaterThan(1); + assertThat(helmRegistries).hasSizeGreaterThan(1); + }); + } + + @Configuration + static class RefreshScopeConfig { + @Bean + RefreshScope refreshScope() { + return new RefreshScope(); + } + + @Bean + @DependsOn("refreshScope") + CustomScopeConfigurer customScopeConfigurer(RefreshScope refreshScope) { + var configurer = new CustomScopeConfigurer(); + configurer.addScope("refresh", refreshScope); + return configurer; + } + } + + /** + * Provides known registry beans so Spring resolves List parameters by collecting + * individual beans, avoiding circular dependency with the consolidated list beans. + */ + @Configuration + static class IntegrationTestConfig { + @Bean + ContainerRegistry testKnownContainerRegistry() { + return new ContainerRegistry() { + @Override + public String getName() { + return "NGC"; + } + + @Override + public String getHostname() { + return "nvcr.io"; + } + + @Override + public void validateArtifact(String containerImageUrl, String secret) {} + + @Override + public void validateCredential(String hostname, String secret) {} + + @Override + public void invalidateCache() {} + }; + } + + @Bean + HelmRegistry testKnownHelmRegistry() { + return new HelmRegistry() { + @Override + public String getName() { + return "NGC"; + } + + @Override + public String getHostname() { + return "nvcr.io"; + } + + @Override + public void validateArtifact(String helmChartUrl, String secret) {} + + @Override + public void validateCredential(String hostname, String secret) {} + + @Override + public void invalidateCache() {} + }; + } + } + + @Configuration + static class IntegrationTestPropertiesConfig { + @Bean + RegistryConfigurationProperties registryConfigurationProperties() { + var recognized = new RegistryConfigurationProperties.RecognizedRegistryConfiguration(); + recognized.setContainer(Map.of( + NGC_PRIVATE_REGISTRY_KEY, + registryConfig("NGC", "nvcr.io"), + CUSTOM_CONTAINER_KEY, + registryConfig(CUSTOM_CONTAINER_NAME, CUSTOM_CONTAINER_HOSTNAME))); + recognized.setHelm(Map.of( + NGC_PRIVATE_REGISTRY_KEY, + registryConfig("NGC", "nvcr.io"), + CUSTOM_HELM_KEY, + registryConfig(CUSTOM_HELM_NAME, CUSTOM_HELM_HOSTNAME))); + recognized.setModel(Map.of()); + recognized.setResource(Map.of()); + + var props = new RegistryConfigurationProperties(); + props.setRecognized(recognized); + return props; + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/configurations/RegistryConfigurationPropertiesValidationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/configurations/RegistryConfigurationPropertiesValidationTest.java new file mode 100644 index 0000000000..9929a1ca59 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/configurations/RegistryConfigurationPropertiesValidationTest.java @@ -0,0 +1,159 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.configurations; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +import java.util.Map; +import java.util.Objects; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.BindException; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.bind.validation.BindValidationException; +import org.springframework.boot.context.properties.bind.validation.ValidationBindHandler; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +class RegistryConfigurationPropertiesValidationTest { + + private static final String CONFIG_PREFIX = "test.registries"; + + private static String getValidationMessage(Throwable throwable) { + var cause = throwable.getCause(); + if (cause instanceof BindValidationException bve) { + return bve.getValidationErrors().getAllErrors().stream() + .map(org.springframework.validation.ObjectError::getDefaultMessage) + .filter(Objects::nonNull) + .reduce("", (a, b) -> a + " " + b); + } + return throwable.getMessage() != null ? throwable.getMessage() : ""; + } + + @Nested + class BindingValidation { + + @Test + void bindingFailsWhenNameIsMissing() { + var env = new StandardEnvironment(); + env.getPropertySources().addFirst(new MapPropertySource("test", Map.of( + CONFIG_PREFIX + ".recognized.container.ngc.hostname", "nvcr.io"))); + + var validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + var bindHandler = new ValidationBindHandler(validator); + var binder = Binder.get(env); + + var throwable = catchThrowable(() -> binder + .bind(CONFIG_PREFIX, + Bindable.of(RegistryConfigurationProperties.class), + bindHandler) + .orElseThrow(() -> new IllegalStateException("Expected binding to fail"))); + assertThat(throwable).isInstanceOf(BindException.class); + assertThat(getValidationMessage(throwable)).contains("Registry name is required"); + } + + @Test + void bindingFailsWhenHostnameIsMissing() { + var env = new StandardEnvironment(); + env.getPropertySources().addFirst(new MapPropertySource("test", Map.of( + CONFIG_PREFIX + ".recognized.container.ngc.name", "NGC"))); + + var validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + var bindHandler = new ValidationBindHandler(validator); + var binder = Binder.get(env); + + var throwable = catchThrowable(() -> binder + .bind(CONFIG_PREFIX, + Bindable.of(RegistryConfigurationProperties.class), + bindHandler) + .orElseThrow(() -> new IllegalStateException("Expected binding to fail"))); + assertThat(throwable).isInstanceOf(BindException.class); + assertThat(getValidationMessage(throwable)).contains("Registry hostname is required"); + } + + @Test + void bindingFailsWhenNameIsBlank() { + var env = new StandardEnvironment(); + env.getPropertySources().addFirst(new MapPropertySource("test", Map.of( + CONFIG_PREFIX + ".recognized.container.ngc.name", " ", + CONFIG_PREFIX + ".recognized.container.ngc.hostname", "nvcr.io"))); + + var validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + var bindHandler = new ValidationBindHandler(validator); + var binder = Binder.get(env); + + var throwable = catchThrowable(() -> binder + .bind(CONFIG_PREFIX, + Bindable.of(RegistryConfigurationProperties.class), + bindHandler) + .orElseThrow(() -> new IllegalStateException("Expected binding to fail"))); + assertThat(throwable).isInstanceOf(BindException.class); + assertThat(getValidationMessage(throwable)).contains("Registry name is required"); + } + + @Test + void bindingFailsWhenHostnameIsBlank() { + var env = new StandardEnvironment(); + env.getPropertySources().addFirst(new MapPropertySource("test", Map.of( + CONFIG_PREFIX + ".recognized.container.ngc.name", "NGC", + CONFIG_PREFIX + ".recognized.container.ngc.hostname", ""))); + + var validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + var bindHandler = new ValidationBindHandler(validator); + var binder = Binder.get(env); + + var throwable = catchThrowable(() -> binder + .bind(CONFIG_PREFIX, + Bindable.of(RegistryConfigurationProperties.class), + bindHandler) + .orElseThrow(() -> new IllegalStateException("Expected binding to fail"))); + assertThat(throwable).isInstanceOf(BindException.class); + assertThat(getValidationMessage(throwable)).contains("Registry hostname is required"); + } + + @Test + void bindingSucceedsWhenNameAndHostnameArePresent() { + var env = new StandardEnvironment(); + env.getPropertySources().addFirst(new MapPropertySource("test", Map.of( + CONFIG_PREFIX + ".recognized.container.ngc.name", "NGC", + CONFIG_PREFIX + ".recognized.container.ngc.hostname", "nvcr.io"))); + + var validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + var bindHandler = new ValidationBindHandler(validator); + var binder = Binder.get(env); + + var result = binder.bind(CONFIG_PREFIX, + Bindable.of(RegistryConfigurationProperties.class), + bindHandler); + + assertThat(result.isBound()).isTrue(); + var config = result.get(); + var ngcConfig = config.getRecognized().getContainer().get("ngc"); + assertThat(ngcConfig.getName()).isEqualTo("NGC"); + assertThat(ngcConfig.getHostname()).isEqualTo("nvcr.io"); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/configurations/RegistryKeyConditionTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/configurations/RegistryKeyConditionTest.java new file mode 100644 index 0000000000..4986e146fd --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/configurations/RegistryKeyConditionTest.java @@ -0,0 +1,402 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.configurations; + +import static com.nvidia.boot.registries.util.RegistriesConstants.NGC_PRIVATE_REGISTRY_KEY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.boot.autoconfigure.condition.ConditionOutcome; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.env.Environment; +import org.springframework.core.type.AnnotatedTypeMetadata; + +@ExtendWith(MockitoExtension.class) +class RegistryKeyConditionTest { + + private static final String TEST_CONFIG_PREFIX = "test.registries"; + + @Mock + private ConditionContext context; + + @Mock + private AnnotatedTypeMetadata metadata; + + @Mock + private ConfigurableListableBeanFactory beanFactory; + + @Mock + private RegistryConfigPathProvider configPathProvider; + + @Mock + private Environment environment; + + private RegistryKeyCondition registryKeyCondition; + private RegistryConfigurationProperties registryProps; + + @BeforeEach + void setUp() { + registryKeyCondition = new RegistryKeyCondition(); + registryProps = new RegistryConfigurationProperties(); + + // Mock BeanFactory and Environment + lenient().when(context.getBeanFactory()).thenReturn(beanFactory); + lenient().when(context.getEnvironment()).thenReturn(environment); + + // Mock RegistryConfigPathProvider setup + lenient().when(beanFactory.getBeanNamesForType( + RegistryConfigPathProvider.class, false, false)) + .thenReturn(new String[]{"registryConfigPathProvider"}); + lenient().when(beanFactory.getBean(RegistryConfigPathProvider.class)) + .thenReturn(configPathProvider); + lenient().when(configPathProvider.getConfigPath()) + .thenReturn(TEST_CONFIG_PREFIX); + + // Keep existing bean setup for backwards compatibility + lenient().when(beanFactory.getBean(RegistryConfigurationProperties.class)) + .thenReturn(registryProps); + } + + @Test + @DisplayName("Should return no match when annotation is not found") + void shouldReturnNoMatchWhenAnnotationNotFound() { + // Given + when(metadata.getAnnotationAttributes(ConditionalOnRegistryKey.class.getName())) + .thenReturn(null); + + // When + ConditionOutcome result = registryKeyCondition.getMatchOutcome(context, metadata); + + // Then + assertThat(result.isMatch()).isFalse(); + assertThat(result.getMessage()).isEqualTo("No @ConditionalOnRegistryKey annotation found"); + } + + @Test + @DisplayName("Should return no match when RegistryConfigPathProvider is not found") + void shouldReturnNoMatchWhenProviderNotFound() { + // Given + Map annotationAttributes = + createAnnotationAttributes("container", NGC_PRIVATE_REGISTRY_KEY, true); + when(metadata.getAnnotationAttributes(ConditionalOnRegistryKey.class.getName())) + .thenReturn(annotationAttributes); + when(beanFactory.getBeanNamesForType(RegistryConfigPathProvider.class, false, false)) + .thenReturn(new String[]{}); + + // When + ConditionOutcome result = registryKeyCondition.getMatchOutcome(context, metadata); + + // Then + assertThat(result.isMatch()).isFalse(); + assertThat(result.getMessage()).contains("RegistryConfigPathProvider bean not found"); + } + + @Test + @DisplayName("Should return no match for unknown registry types") + void shouldReturnNoMatchForUnknownRegistryType() { + // Given + var registryType = "invalid"; + Map annotationAttributes = + createAnnotationAttributes(registryType, NGC_PRIVATE_REGISTRY_KEY, true); + when(metadata.getAnnotationAttributes(ConditionalOnRegistryKey.class.getName())) + .thenReturn(annotationAttributes); + + setupRecognizedRegistries(); + + // When + ConditionOutcome result = registryKeyCondition.getMatchOutcome(context, metadata); + + // Then + assertThat(result.isMatch()).isFalse(); + assertThat(result.getMessage()).contains("(no property found at"); + } + + @ParameterizedTest + @MethodSource("provideValidRegistryTypes") + @DisplayName("Should return no match when registry key is not found") + void shouldReturnNoMatchWhenRegistryKeyNotFound(String registryType) { + // Given + String unknownKey = "unknown-registry"; + Map annotationAttributes = + createAnnotationAttributes(registryType, unknownKey, true); + when(metadata.getAnnotationAttributes(ConditionalOnRegistryKey.class.getName())) + .thenReturn(annotationAttributes); + + setupRecognizedRegistries(); + + // When + ConditionOutcome result = registryKeyCondition.getMatchOutcome(context, metadata); + + // Then + assertThat(result.isMatch()).isFalse(); + assertThat(result.getMessage()).contains( + "Registry key '" + unknownKey + "' not found in " + registryType + " registries"); + } + + @ParameterizedTest + @MethodSource("provideValidRegistryTypes") + @DisplayName("Should return no match when hostname is required but not configured") + void shouldReturnNoMatchWhenHostnameRequiredButNotConfigured(String registryType) { + // Given + String key = NGC_PRIVATE_REGISTRY_KEY; + Map annotationAttributes = + createAnnotationAttributes(registryType, key, true); + when(metadata.getAnnotationAttributes(ConditionalOnRegistryKey.class.getName())) + .thenReturn(annotationAttributes); + + setupRecognizedRegistriesWithoutHostname(); + + // When + ConditionOutcome result = registryKeyCondition.getMatchOutcome(context, metadata); + + // Then + assertThat(result.isMatch()).isFalse(); + assertThat(result.getMessage()).isEqualTo( + "Registry key '" + key + "' found but hostname is not configured"); + } + + @ParameterizedTest + @MethodSource("provideValidRegistryTypes") + @DisplayName("Should return match when registry key is found with hostname configured") + void shouldReturnMatchWhenRegistryKeyFoundWithHostname(String registryType) { + // Given + String key = NGC_PRIVATE_REGISTRY_KEY; + Map annotationAttributes = + createAnnotationAttributes(registryType, key, true); + when(metadata.getAnnotationAttributes(ConditionalOnRegistryKey.class.getName())) + .thenReturn(annotationAttributes); + + setupRecognizedRegistries(); + + // When + ConditionOutcome result = registryKeyCondition.getMatchOutcome(context, metadata); + + // Then + assertThat(result.isMatch()).isTrue(); + assertThat(result.getMessage()).isEqualTo( + "Registry key '" + key + "' found in " + registryType + + " registries with hostname configured"); + } + + @ParameterizedTest + @MethodSource("provideValidRegistryTypes") + @DisplayName("Should return match when registry key is found and hostname not required") + void shouldReturnMatchWhenRegistryKeyFoundAndHostnameNotRequired(String registryType) { + // Given + String key = NGC_PRIVATE_REGISTRY_KEY; + Map annotationAttributes = + createAnnotationAttributes(registryType, key, false); + when(metadata.getAnnotationAttributes(ConditionalOnRegistryKey.class.getName())) + .thenReturn(annotationAttributes); + + setupRecognizedRegistriesWithoutHostname(); + + // When + ConditionOutcome result = registryKeyCondition.getMatchOutcome(context, metadata); + + // Then + assertThat(result.isMatch()).isTrue(); + assertThat(result.getMessage()).isEqualTo( + "Registry key '" + key + "' found in " + registryType + " registries"); + } + + @Test + @DisplayName("Should handle exception when getting registry configuration provider") + void shouldHandleExceptionWhenGettingRegistryBean() { + // Given + Map annotationAttributes = + createAnnotationAttributes("container", NGC_PRIVATE_REGISTRY_KEY, true); + when(metadata.getAnnotationAttributes(ConditionalOnRegistryKey.class.getName())) + .thenReturn(annotationAttributes); + + when(beanFactory.getBean(RegistryConfigPathProvider.class)) + .thenThrow(new RuntimeException("Bean not found")); + + // When + ConditionOutcome result = registryKeyCondition.getMatchOutcome(context, metadata); + + // Then + assertThat(result.isMatch()).isFalse(); + assertThat(result.getMessage()).contains("Failed to get RegistryConfigPathProvider"); + } + + @Test + @DisplayName("Should handle case-insensitive registry types") + void shouldHandleCaseInsensitiveRegistryTypes() { + // Given + Map annotationAttributes = + createAnnotationAttributes("CONTAINER", NGC_PRIVATE_REGISTRY_KEY, true); + when(metadata.getAnnotationAttributes(ConditionalOnRegistryKey.class.getName())) + .thenReturn(annotationAttributes); + + setupRecognizedRegistries(); + + // When + ConditionOutcome result = registryKeyCondition.getMatchOutcome(context, metadata); + + // Then + assertThat(result.isMatch()).isTrue(); + assertThat(result.getMessage()).contains("CONTAINER"); + } + + @ParameterizedTest + @MethodSource("provideComplexRegistryScenarios") + @DisplayName("Should handle complex registry configuration scenarios") + void shouldHandleComplexRegistryScenarios(String registryType, String key, + boolean requireHostname, boolean hasHostname, + boolean expectedMatch) { + // Given + Map annotationAttributes = + createAnnotationAttributes(registryType, key, requireHostname); + when(metadata.getAnnotationAttributes(ConditionalOnRegistryKey.class.getName())) + .thenReturn(annotationAttributes); + + if (hasHostname) { + setupRecognizedRegistries(); + } else { + setupRecognizedRegistriesWithoutHostname(); + } + + // When + ConditionOutcome result = registryKeyCondition.getMatchOutcome(context, metadata); + + // Then + assertThat(result.isMatch()).isEqualTo(expectedMatch); + } + + private static Stream provideValidRegistryTypes() { + return Stream.of( + Arguments.of("container"), + Arguments.of("model"), + Arguments.of("resource"), + Arguments.of("helm")); + } + + private static Stream provideComplexRegistryScenarios() { + return Stream.of( + // registryType, key, requireHostname, hasHostname, expectedMatch + Arguments.of("container", NGC_PRIVATE_REGISTRY_KEY, true, true, true), + Arguments.of("container", NGC_PRIVATE_REGISTRY_KEY, true, false, false), + Arguments.of("container", NGC_PRIVATE_REGISTRY_KEY, false, false, true), + Arguments.of("container", NGC_PRIVATE_REGISTRY_KEY, false, true, true), + Arguments.of("model", "custom", true, true, true), + Arguments.of("resource", "custom", false, false, true), + Arguments.of("helm", "custom", true, false, false)); + } + + private Map createAnnotationAttributes(String registryType, String key, + boolean requireHostname) { + Map attributes = new HashMap<>(); + attributes.put("registryType", registryType); + attributes.put("key", key); + attributes.put("requireHostname", requireHostname); + return attributes; + } + + private void setupRecognizedRegistries() { + // Setup environment properties for container registries + lenient().when(environment.getProperty(eq(TEST_CONFIG_PREFIX + ".recognized.container." + + NGC_PRIVATE_REGISTRY_KEY + ".hostname"))) + .thenReturn("nvcr.io"); + lenient().when(environment.getProperty(eq(TEST_CONFIG_PREFIX + ".recognized.container.custom.hostname"))) + .thenReturn("custom.registry.com"); + + // Setup environment properties for model registries + lenient().when(environment.getProperty(eq(TEST_CONFIG_PREFIX + ".recognized.model." + + NGC_PRIVATE_REGISTRY_KEY + ".hostname"))) + .thenReturn("api.ngc.nvidia.com"); + lenient().when(environment.getProperty(eq(TEST_CONFIG_PREFIX + ".recognized.model.custom.hostname"))) + .thenReturn("custom.model.com"); + + // Setup environment properties for resource registries + lenient().when(environment.getProperty(eq(TEST_CONFIG_PREFIX + ".recognized.resource." + + NGC_PRIVATE_REGISTRY_KEY + ".hostname"))) + .thenReturn("api.ngc.nvidia.com"); + lenient().when(environment.getProperty(eq(TEST_CONFIG_PREFIX + ".recognized.resource.custom.hostname"))) + .thenReturn("custom.resource.com"); + + // Setup environment properties for helm registries + lenient().when(environment.getProperty(eq(TEST_CONFIG_PREFIX + ".recognized.helm." + + NGC_PRIVATE_REGISTRY_KEY + ".hostname"))) + .thenReturn("helm.ngc.nvidia.com"); + lenient().when(environment.getProperty(eq(TEST_CONFIG_PREFIX + ".recognized.helm.custom.hostname"))) + .thenReturn("custom.helm.com"); + } + + private void setupRecognizedRegistriesWithoutHostname() { + // Setup environment properties without hostnames (empty strings) + lenient().when(environment.getProperty(eq(TEST_CONFIG_PREFIX + ".recognized.container." + + NGC_PRIVATE_REGISTRY_KEY + ".hostname"))) + .thenReturn(""); + lenient().when(environment.getProperty(eq(TEST_CONFIG_PREFIX + ".recognized.container.custom.hostname"))) + .thenReturn(""); + + lenient().when(environment.getProperty(eq(TEST_CONFIG_PREFIX + ".recognized.model." + + NGC_PRIVATE_REGISTRY_KEY + ".hostname"))) + .thenReturn(""); + lenient().when(environment.getProperty(eq(TEST_CONFIG_PREFIX + ".recognized.model.custom.hostname"))) + .thenReturn(""); + + lenient().when(environment.getProperty(eq(TEST_CONFIG_PREFIX + ".recognized.resource." + + NGC_PRIVATE_REGISTRY_KEY + ".hostname"))) + .thenReturn(""); + lenient().when(environment.getProperty(eq(TEST_CONFIG_PREFIX + ".recognized.resource.custom.hostname"))) + .thenReturn(""); + + lenient().when(environment.getProperty(eq(TEST_CONFIG_PREFIX + ".recognized.helm." + + NGC_PRIVATE_REGISTRY_KEY + ".hostname"))) + .thenReturn(""); + lenient().when(environment.getProperty(eq(TEST_CONFIG_PREFIX + ".recognized.helm.custom.hostname"))) + .thenReturn(""); + } + + private RegistryConfigurationProperties.RegistryConfiguration createRegistryConfig(String name, + String hostname) { + RegistryConfigurationProperties.RegistryConfiguration config = + new RegistryConfigurationProperties.RegistryConfiguration(); + config.setName(name); + config.setHostname(hostname); + config.setCallTimeout(Duration.parse("PT10S")); + + if (hostname != null && hostname.contains(NGC_PRIVATE_REGISTRY_KEY)) { + RegistryConfigurationProperties.OAuth2Configuration oauth2 = + new RegistryConfigurationProperties.OAuth2Configuration(); + oauth2.setBaseUrl("https://authn.nvidia.com"); + oauth2.setGroupScope(NGC_PRIVATE_REGISTRY_KEY); + config.setOauth2(oauth2); + } + + return config; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/RegistryLookupServiceTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/RegistryLookupServiceTest.java new file mode 100644 index 0000000000..91d0846c57 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/RegistryLookupServiceTest.java @@ -0,0 +1,111 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry; + +import static com.nvidia.boot.registries.util.TestConstants.TEST_CONTAINER_REGISTRY_HOST_NAME_1; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_ARTIFACT_REGISTRY_PROD; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_CONTAINER_REGISTRY_PROD; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_HELM_REGISTRY_PROD; +import static com.nvidia.boot.registries.util.TestConstants.TEST_RECOGNIZED_CONTAINER_REGISTRY_KEY_1; +import static com.nvidia.boot.registries.util.TestUtils.registryConfig; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.registries.configurations.RegistryConfigurationProperties.RecognizedRegistryConfiguration; +import com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum; +import java.util.Collections; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class RegistryLookupServiceTest { + + private RegistryLookupService service; + + @BeforeEach + void setUp() { + var recognizedRegistryConfig = new RecognizedRegistryConfiguration(); + recognizedRegistryConfig.setContainer(Map.of( + TEST_RECOGNIZED_CONTAINER_REGISTRY_KEY_1, + registryConfig(TEST_CONTAINER_REGISTRY_HOST_NAME_1, true, true))); + recognizedRegistryConfig.setHelm(Map.of()); + recognizedRegistryConfig.setModel(Map.of()); + recognizedRegistryConfig.setResource(Map.of()); + + var registryMapperService = new RegistryMapperService( + TEST_NGC_CONTAINER_REGISTRY_PROD, + TEST_NGC_ARTIFACT_REGISTRY_PROD, + TEST_NGC_HELM_REGISTRY_PROD); + + service = new RegistryLookupService( + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList(), + recognizedRegistryConfig, + registryMapperService); + } + + @Test + void getRegistryConfig_KnownHostname_ReturnsConfig() { + var config = service.getRegistryConfig(ArtifactTypeEnum.CONTAINER, + TEST_CONTAINER_REGISTRY_HOST_NAME_1); + + assertThat(config).isNotNull(); + assertThat(config.getHostname()).isEqualTo(TEST_CONTAINER_REGISTRY_HOST_NAME_1); + assertThat(config.getCredentialValidation()).isNotNull(); + assertThat(config.getCredentialValidation().isEnabled()).isTrue(); + assertThat(config.getArtifactValidation()).isNotNull(); + assertThat(config.getArtifactValidation().isEnabled()).isTrue(); + } + + @Test + void getRegistryConfig_UnknownHostname_ThrowsBadRequestException() { + assertThrows(BadRequestException.class, () -> service.getRegistryConfig( + ArtifactTypeEnum.CONTAINER, "unknown.registry.com")); + } + + @Test + void getRegistryConfig_EmptyMapForType_ThrowsBadRequestException() { + var recognizedRegistryConfig = new RecognizedRegistryConfiguration(); + recognizedRegistryConfig.setContainer(Map.of()); + recognizedRegistryConfig.setHelm(Map.of()); + recognizedRegistryConfig.setModel(Map.of()); + recognizedRegistryConfig.setResource(Map.of()); + + var lookupService = new RegistryLookupService( + Collections.emptyList(), Collections.emptyList(), + Collections.emptyList(), Collections.emptyList(), + recognizedRegistryConfig, new RegistryMapperService("c", "a", "h")); + + assertThrows(BadRequestException.class, () -> lookupService.getRegistryConfig( + ArtifactTypeEnum.CONTAINER, "any.host")); + } + + @Test + void getRegistryConfig_RecognizedMissing_ThrowsBadRequestException() { + var lookupService = new RegistryLookupService( + Collections.emptyList(), Collections.emptyList(), + Collections.emptyList(), Collections.emptyList(), + null, new RegistryMapperService("c", "a", "h")); + + assertThrows(BadRequestException.class, + () -> lookupService.getRegistryConfig(ArtifactTypeEnum.CONTAINER, "any.host")); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/RegistryMapperServiceTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/RegistryMapperServiceTest.java new file mode 100644 index 0000000000..5936eae450 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/RegistryMapperServiceTest.java @@ -0,0 +1,340 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry; + +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.ARTIFACT_REGISTRY_CANARY_HOSTNAME; +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.CONTAINER_REGISTRY_CANARY_HOSTNAME; +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.HELM_REGISTRY_CANARY_HOSTNAME; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrRegistryUtils.ECR_PRIVATE_REGISTRY_GLOBAL_HOSTNAME; +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineRegistryUtils.VOLCENGINE_REGISTRY_GLOBAL_HOSTNAME; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_ARTIFACT_REGISTRY_PROD; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_CONTAINER_REGISTRY_PROD; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_HELM_REGISTRY_PROD; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +class RegistryMapperServiceTest { + + private static RegistryMapperService registryMapperService; + + @BeforeAll + static void beforeAll() { + registryMapperService = new RegistryMapperService(TEST_NGC_CONTAINER_REGISTRY_PROD, + TEST_NGC_ARTIFACT_REGISTRY_PROD, + TEST_NGC_HELM_REGISTRY_PROD); + } + + @ParameterizedTest + @ValueSource(strings = { + CONTAINER_REGISTRY_CANARY_HOSTNAME, + HELM_REGISTRY_CANARY_HOSTNAME, + ARTIFACT_REGISTRY_CANARY_HOSTNAME + }) + void isCanaryHostname_WithCanaryHostnames_ReturnsTrue(String canaryHostname) { + assertThat(RegistryMapperService.isCanaryHostname(canaryHostname)).isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = { + "nvcr.io", + "helm.ngc.nvidia.com", + "api.ngc.nvidia.com", + "docker.io", + "registry.k8s.io", + "unknown.registry.com", + "" + }) + void isCanaryHostname_WithNonCanaryHostnames_ReturnsFalse(String hostname) { + assertThat(RegistryMapperService.isCanaryHostname(hostname)).isFalse(); + } + + @ParameterizedTest + @MethodSource("provideCanaryToNormalizedHostnameMappings") + void toNormalizedHostname_WithCanaryHostnames_ReturnsProductionHostnames(String canaryHostname, + String expectedNormalizedHostname) { + assertThat(registryMapperService.toNormalizedHostname(canaryHostname)).isEqualTo( + expectedNormalizedHostname); + } + + @ParameterizedTest + @ValueSource(strings = { + "nvcr.io", + "helm.ngc.nvidia.com", + "api.ngc.nvidia.com", + "docker.io", + "registry.k8s.io", + "unknown.registry.com" + }) + void toNormalizedHostname_WithNonCanaryHostnames_ReturnsSameHostname(String hostname) { + assertThat(registryMapperService.toNormalizedHostname(hostname)).isEqualTo(hostname); + } + + @Test + void toNormalizedHostname_WithEmptyString_ReturnsEmptyString() { + assertThat(registryMapperService.toNormalizedHostname("")).isEqualTo(""); + } + + @ParameterizedTest + @MethodSource("provideNormalizedToCanaryHostnameMappings") + void toCanaryHostname_WithProductionHostnames_ReturnsCanaryHostnames(String productionHostname, + String expectedCanaryHostname) { + assertThat(registryMapperService.toCanaryHostname(productionHostname)).isEqualTo( + expectedCanaryHostname); + } + + @ParameterizedTest + @ValueSource(strings = { + "docker.io", + "registry.k8s.io", + "unknown.registry.com", + "custom.registry.io" + }) + void toCanaryHostname_WithUnknownHostnames_ReturnsSameHostname(String hostname) { + assertThat(registryMapperService.toCanaryHostname(hostname)).isEqualTo(hostname); + } + + @Test + void toCanaryHostname_WithEmptyString_ReturnsEmptyString() { + assertThat(registryMapperService.toCanaryHostname("")).isEmpty(); + } + + @Test + void isCanaryHostname_WithNull_ReturnsFalse() { + assertThat(RegistryMapperService.isCanaryHostname(null)).isFalse(); + } + + @Test + void toNormalizedHostname_WithNull_ReturnsNull() { + assertThat(registryMapperService.toNormalizedHostname(null)).isNull(); + } + + @Test + void toCanaryHostname_WithNull_ReturnsNull() { + assertThat(registryMapperService.toCanaryHostname(null)).isNull(); + } + + @ParameterizedTest + @MethodSource("provideEcrPrivateHostnames") + void toNormalizedRecognizedRegistryHostname_WithEcrPrivateHostnames_ReturnsGlobalHostname(String ecrHostname) { + String result = registryMapperService.toNormalizedRecognizedRegistryHostname(ecrHostname); + assertThat(result).isEqualTo(ECR_PRIVATE_REGISTRY_GLOBAL_HOSTNAME); + } + + private static Stream provideEcrPrivateHostnames() { + return Stream.of( + // Standard AWS regions + "123456789012.dkr.ecr.us-east-1.amazonaws.com", + "123456789012.dkr.ecr.us-west-2.amazonaws.com", + "123456789012.dkr.ecr.eu-west-1.amazonaws.com", + "123456789012.dkr.ecr.eu-central-1.amazonaws.com", + "123456789012.dkr.ecr.ap-southeast-1.amazonaws.com", + "123456789012.dkr.ecr.ap-northeast-1.amazonaws.com", + "123456789012.dkr.ecr.ca-central-1.amazonaws.com", + "123456789012.dkr.ecr.sa-east-1.amazonaws.com", + "987654321098.dkr.ecr.ap-south-1.amazonaws.com", + // AWS GovCloud regions + "123456789012.dkr.ecr.us-gov-east-1.amazonaws.com", + "123456789012.dkr.ecr.us-gov-west-1.amazonaws.com" + ); + } + + @ParameterizedTest + @MethodSource("provideVolcengineHostnames") + void toNormalizedRecognizedRegistryHostname_WithVolcengineHostnames_ReturnsGlobalHostname(String volcengineHostname) { + String result = registryMapperService.toNormalizedRecognizedRegistryHostname(volcengineHostname); + assertThat(result).isEqualTo(VOLCENGINE_REGISTRY_GLOBAL_HOSTNAME); + } + + private static Stream provideVolcengineHostnames() { + return Stream.of( + // Valid VolcEngine regions + "test-registry-cn-beijing.cr.volces.com", + "test-registry-cn-shanghai.cr.volces.com", + "test-registry-cn-guangzhou.cr.volces.com", + "test-registry-cn-hangzhou.cr.volces.com", + "test-registry-cn-hongkong.cr.volces.com", + "test-registry-ap-southeast-1.cr.volces.com", + "test-registry-ap-southeast-3.cr.volces.com", + // With alphanumeric and hyphens in registry name + "my-registry-123-cn-beijing.cr.volces.com" + ); + } + + @ParameterizedTest + @ValueSource(strings = { + "ghcr.io", + "quay.io", + "unknown.registry.com", + "123456789012.dkr.ecr.invalid-region.amazonaws.com", // Invalid ECR region + "12345.dkr.ecr.us-east-1.amazonaws.com", // Invalid account ID (not 12 digits) + "test-registry-invalid-region.cr.volces.com" // Invalid VolcEngine region + }) + void toNormalizedRecognizedRegistryHostname_WithUnknownHostnames_ReturnsSameHostname(String hostname) { + String result = registryMapperService.toNormalizedRecognizedRegistryHostname(hostname); + assertThat(result).isEqualTo(hostname); + } + + @ParameterizedTest + @MethodSource("provideCanaryToRecognizedRegistryHostnameMappings") + void toNormalizedRecognizedRegistryHostname_WithCanaryHostnames_ReturnsProductionHostnames( + String canaryHostname, + String expectedNormalizedHostname) { + assertThat(registryMapperService.toNormalizedRecognizedRegistryHostname(canaryHostname)) + .isEqualTo(expectedNormalizedHostname); + } + + private static Stream provideCanaryToRecognizedRegistryHostnameMappings() { + return Stream.of( + Arguments.of(CONTAINER_REGISTRY_CANARY_HOSTNAME, TEST_NGC_CONTAINER_REGISTRY_PROD), + Arguments.of(HELM_REGISTRY_CANARY_HOSTNAME, TEST_NGC_HELM_REGISTRY_PROD), + Arguments.of(ARTIFACT_REGISTRY_CANARY_HOSTNAME, TEST_NGC_ARTIFACT_REGISTRY_PROD)); + } + + private static Stream provideCanaryToNormalizedHostnameMappings() { + return Stream.of( + Arguments.of(CONTAINER_REGISTRY_CANARY_HOSTNAME, TEST_NGC_CONTAINER_REGISTRY_PROD), + Arguments.of(HELM_REGISTRY_CANARY_HOSTNAME, TEST_NGC_HELM_REGISTRY_PROD), + Arguments.of(ARTIFACT_REGISTRY_CANARY_HOSTNAME, TEST_NGC_ARTIFACT_REGISTRY_PROD)); + } + + private static Stream provideNormalizedToCanaryHostnameMappings() { + return Stream.of( + Arguments.of(TEST_NGC_CONTAINER_REGISTRY_PROD, CONTAINER_REGISTRY_CANARY_HOSTNAME), + Arguments.of(TEST_NGC_HELM_REGISTRY_PROD, HELM_REGISTRY_CANARY_HOSTNAME), + Arguments.of(TEST_NGC_ARTIFACT_REGISTRY_PROD, ARTIFACT_REGISTRY_CANARY_HOSTNAME)); + } + + static Stream normalizeUrlTestCases() { + return Stream.of( + Arguments.of("https://registry.io", "https://registry.io"), + Arguments.of("http://registry.io", "http://registry.io"), + Arguments.of("registry.io", "https://registry.io"), + Arguments.of("registry.io:8080", "https://registry.io:8080"), + Arguments.of("localhost:9000", "https://localhost:9000") + ); + } + + @ParameterizedTest + @MethodSource("normalizeUrlTestCases") + void normalizeUrl_ParameterizedTests(String input, String expected) { + String result = RegistryMapperService.normalizeUrl(input); + assertEquals(expected, result); + } + + static Stream toBaseAuthUrlTestCases() { + return Stream.of( + Arguments.of("https://auth.registry.io", "registry.io", "https://auth.registry.io"), + Arguments.of("", "registry.io", "https://registry.io"), + Arguments.of(null, "registry.io", "https://registry.io"), + Arguments.of(" ", "registry.io", "https://registry.io"), + Arguments.of(null, "https://registry.io", "https://registry.io"), + Arguments.of("", "http://registry.io", "http://registry.io") + ); + } + + @ParameterizedTest + @MethodSource("toBaseAuthUrlTestCases") + void toBaseAuthUrl_ParameterizedTests(String authBaseUrl, String registryHost, + String expected) { + String result = RegistryMapperService.toBaseAuthUrl(authBaseUrl, registryHost); + assertEquals(expected, result); + } + + + @Test + void toRegistryBaseUrl_SingleParam_WithEmptyString_ThrowsException() { + assertThrows(IllegalArgumentException.class, () -> { + RegistryMapperService.toRegistryBaseUrl(""); + }); + } + + @Test + void toRegistryBaseUrl_SingleParam_WithNull_ThrowsException() { + assertThrows(IllegalArgumentException.class, () -> { + RegistryMapperService.toRegistryBaseUrl((String) null); + }); + } + + static Stream toRegistryBaseUrlSingleParamTestCases() { + return Stream.of( + // Normal URLs + Arguments.of("https://registry.io", "https://registry.io"), + + // Localhost patterns + Arguments.of("localhost-ngc:9100", "localhost:9100"), + Arguments.of("http://localhost-docker:9105", "http://localhost:9105"), + Arguments.of("localhost-acr:9200", "localhost:9200"), + Arguments.of("localhost-test-123:8080", "localhost:8080"), + Arguments.of("https://localhost-acr:9200", "https://localhost:9200"), + Arguments.of("localhost-ngc:9100/path/localhost-docker:9105", + "localhost:9100/path/localhost:9105"), + Arguments.of("localhost:9100", "localhost:9100"), + Arguments.of("https://localhost-registry:8080/api/localhost/test", + "https://localhost:8080/api/localhost/test") + ); + } + + @ParameterizedTest + @MethodSource("toRegistryBaseUrlSingleParamTestCases") + void toRegistryBaseUrl_SingleParam_ParameterizedTests(String input, String expected) { + String result = RegistryMapperService.toRegistryBaseUrl(input); + assertEquals(expected, result); + } + + static Stream toRegistryBaseUrlTwoParamTestCases() { + return Stream.of( + Arguments.of("registry.io", "https://prod-registry.com", "https://registry.io"), + Arguments.of("registry.io", "", "https://registry.io"), + Arguments.of("registry.io", null, "https://registry.io") + ); + } + + @ParameterizedTest + @MethodSource("toRegistryBaseUrlTwoParamTestCases") + void toRegistryBaseUrl_TwoParam_ParameterizedTests(String registryHost, + String overrideBaseUrl, + String expected) { + String result = RegistryMapperService.toRegistryBaseUrl(registryHost, overrideBaseUrl); + assertEquals(expected, result); + } + + static Stream localhostUrlFormats() { + return Stream.of( + Arguments.of("localhost-ngc:9100", "localhost:9100"), + Arguments.of("http://localhost-docker:9105", "http://localhost:9105"), + Arguments.of("localhost-acr:9200", "localhost:9200"), + Arguments.of("localhost-test-123:8080", "localhost:8080") + ); + } + + @ParameterizedTest + @MethodSource("localhostUrlFormats") + void toRegistryBaseUrl_ParameterizedLocalhostTests(String input, String expected) { + String result = RegistryMapperService.toRegistryBaseUrl("registry.io", input); + assertEquals(expected, result); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/RegistryValidationServiceTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/RegistryValidationServiceTest.java new file mode 100644 index 0000000000..4f16a19a98 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/RegistryValidationServiceTest.java @@ -0,0 +1,216 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry; + +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrRegistryUtils.ECR_PRIVATE_REGISTRY_GLOBAL_HOSTNAME; +import static com.nvidia.boot.registries.util.RegistriesConstants.DOCKER_REGISTRY_KEY; +import static com.nvidia.boot.registries.util.RegistriesConstants.ECR_PRIVATE_REGISTRY_KEY; +import static com.nvidia.boot.registries.util.RegistriesConstants.NGC_PRIVATE_REGISTRY_KEY; +import static com.nvidia.boot.registries.util.TestConstants.TEST_CONTAINER_REGISTRY_HOST_NAME_1; +import static com.nvidia.boot.registries.util.TestConstants.TEST_ECR_PRIVATE_REGISTRY_HOST_NAME; +import static com.nvidia.boot.registries.util.TestConstants.TEST_HELM_REGISTRY_HOST_NAME_1; +import static com.nvidia.boot.registries.util.TestConstants.TEST_MODEL_REGISTRY_HOST_NAME_1; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_ARTIFACT_REGISTRY_PROD; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_CONTAINER_REGISTRY_CANARY; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_CONTAINER_REGISTRY_PROD; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_HELM_REGISTRY_PROD; +import static com.nvidia.boot.registries.util.TestConstants.TEST_RECOGNIZED_CONTAINER_REGISTRY_KEY_1; +import static com.nvidia.boot.registries.util.TestConstants.TEST_RECOGNIZED_HELM_REGISTRY_KEY_1; +import static com.nvidia.boot.registries.util.TestConstants.TEST_RECOGNIZED_MODEL_REGISTRY_KEY_1; +import static com.nvidia.boot.registries.util.TestConstants.TEST_RECOGNIZED_RESOURCE_REGISTRY_KEY_1; +import static com.nvidia.boot.registries.util.TestConstants.TEST_RESOURCE_REGISTRY_HOST_NAME_1; +import static com.nvidia.boot.registries.util.TestUtils.registryConfig; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.registries.configurations.RegistryConfigurationProperties.RecognizedRegistryConfiguration; +import com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum; +import java.util.Collections; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class RegistryValidationServiceTest { + + private RecognizedRegistryConfiguration recognizedRegistryConfig; + private RegistryMapperService registryMapperService; + + @BeforeEach + void setUp() { + recognizedRegistryConfig = new RecognizedRegistryConfiguration(); + recognizedRegistryConfig.setContainer(Map.of( + TEST_RECOGNIZED_CONTAINER_REGISTRY_KEY_1, + registryConfig(TEST_CONTAINER_REGISTRY_HOST_NAME_1, true, true))); + recognizedRegistryConfig.setHelm(Map.of( + TEST_RECOGNIZED_HELM_REGISTRY_KEY_1, + registryConfig(TEST_HELM_REGISTRY_HOST_NAME_1, true, true))); + recognizedRegistryConfig.setModel(Map.of( + TEST_RECOGNIZED_MODEL_REGISTRY_KEY_1, + registryConfig(TEST_MODEL_REGISTRY_HOST_NAME_1, true, true))); + recognizedRegistryConfig.setResource(Map.of( + TEST_RECOGNIZED_RESOURCE_REGISTRY_KEY_1, + registryConfig(TEST_RESOURCE_REGISTRY_HOST_NAME_1, true, true))); + + registryMapperService = new RegistryMapperService( + TEST_NGC_CONTAINER_REGISTRY_PROD, + TEST_NGC_ARTIFACT_REGISTRY_PROD, + TEST_NGC_HELM_REGISTRY_PROD); + } + + @Test + void isCredentialValidationEnabled_NoMatchingHostname_ThrowsBadRequestException() { + var service = createService(); + + assertThrows(BadRequestException.class, () -> service.isCredentialValidationEnabled( + ArtifactTypeEnum.CONTAINER, "unknown.registry.com")); + } + + @Test + void isArtifactValidationEnabled_NoMatchingHostname_ThrowsBadRequestException() { + var service = createService(); + + assertThrows(BadRequestException.class, () -> service.isArtifactValidationEnabled( + ArtifactTypeEnum.HELM, "unknown.registry.com")); + } + + @Test + void isCredentialValidationEnabled_Disabled_ReturnsFalse() { + recognizedRegistryConfig.setContainer( + Map.of(DOCKER_REGISTRY_KEY, registryConfig("docker.io", false, true))); + + var service = createService(); + + assertThat(service.isCredentialValidationEnabled( + ArtifactTypeEnum.CONTAINER, "docker.io")).isFalse(); + } + + @Test + void isCredentialValidationEnabled_Enabled_ReturnsTrue() { + var service = createService(); + + assertThat(service.isCredentialValidationEnabled( + ArtifactTypeEnum.CONTAINER, TEST_CONTAINER_REGISTRY_HOST_NAME_1)).isTrue(); + } + + @Test + void isCredentialValidationEnabled_ValidationConfigMissing_ReturnsTrue() { + recognizedRegistryConfig.setContainer( + Map.of(TEST_RECOGNIZED_CONTAINER_REGISTRY_KEY_1, + registryConfig(TEST_CONTAINER_REGISTRY_HOST_NAME_1))); + + var service = createService(); + + assertThat(service.isCredentialValidationEnabled( + ArtifactTypeEnum.CONTAINER, TEST_CONTAINER_REGISTRY_HOST_NAME_1)).isTrue(); + } + + @Test + void isCredentialValidationEnabled_EcrPrivateHostname_UsesGlobalRecognizedHostname() { + recognizedRegistryConfig.setContainer( + Map.of(ECR_PRIVATE_REGISTRY_KEY, + registryConfig(ECR_PRIVATE_REGISTRY_GLOBAL_HOSTNAME, false, true))); + + var service = createService(); + + assertThat(service.isCredentialValidationEnabled( + ArtifactTypeEnum.CONTAINER, TEST_ECR_PRIVATE_REGISTRY_HOST_NAME)).isFalse(); + } + + @Test + void isCredentialValidationEnabled_CanaryHostname_UsesNormalizedRecognizedHostname() { + recognizedRegistryConfig.setContainer( + Map.of(NGC_PRIVATE_REGISTRY_KEY, + registryConfig(TEST_NGC_CONTAINER_REGISTRY_PROD, false, true))); + + var service = createService(); + + assertThat(service.isCredentialValidationEnabled( + ArtifactTypeEnum.CONTAINER, TEST_NGC_CONTAINER_REGISTRY_CANARY)).isFalse(); + } + + @Test + void isArtifactValidationEnabled_Disabled_ReturnsFalse() { + recognizedRegistryConfig.setHelm( + Map.of(TEST_RECOGNIZED_HELM_REGISTRY_KEY_1, + registryConfig(TEST_HELM_REGISTRY_HOST_NAME_1, true, false))); + + var service = createService(); + + assertThat(service.isArtifactValidationEnabled( + ArtifactTypeEnum.HELM, TEST_HELM_REGISTRY_HOST_NAME_1)).isFalse(); + } + + @Test + void isArtifactValidationEnabled_Enabled_ReturnsTrue() { + var service = createService(); + + assertThat(service.isArtifactValidationEnabled( + ArtifactTypeEnum.MODEL, TEST_MODEL_REGISTRY_HOST_NAME_1)).isTrue(); + } + + @Test + void isArtifactValidationEnabled_ValidationConfigMissing_ReturnsTrue() { + recognizedRegistryConfig.setResource( + Map.of(TEST_RECOGNIZED_RESOURCE_REGISTRY_KEY_1, + registryConfig(TEST_RESOURCE_REGISTRY_HOST_NAME_1))); + + var service = createService(); + + assertThat(service.isArtifactValidationEnabled( + ArtifactTypeEnum.RESOURCE, TEST_RESOURCE_REGISTRY_HOST_NAME_1)).isTrue(); + } + + @Test + void isArtifactValidationEnabled_EachArtifactTypeRoutesToCorrectMap() { + recognizedRegistryConfig.setContainer( + Map.of(TEST_RECOGNIZED_CONTAINER_REGISTRY_KEY_1, + registryConfig(TEST_CONTAINER_REGISTRY_HOST_NAME_1, true, false))); + recognizedRegistryConfig.setHelm( + Map.of(TEST_RECOGNIZED_HELM_REGISTRY_KEY_1, + registryConfig(TEST_HELM_REGISTRY_HOST_NAME_1, true, false))); + recognizedRegistryConfig.setModel( + Map.of(TEST_RECOGNIZED_MODEL_REGISTRY_KEY_1, + registryConfig(TEST_MODEL_REGISTRY_HOST_NAME_1, true, true))); + recognizedRegistryConfig.setResource( + Map.of(TEST_RECOGNIZED_RESOURCE_REGISTRY_KEY_1, + registryConfig(TEST_RESOURCE_REGISTRY_HOST_NAME_1, true, true))); + + var service = createService(); + + assertThat(service.isArtifactValidationEnabled( + ArtifactTypeEnum.CONTAINER, TEST_CONTAINER_REGISTRY_HOST_NAME_1)).isFalse(); + assertThat(service.isArtifactValidationEnabled( + ArtifactTypeEnum.HELM, TEST_HELM_REGISTRY_HOST_NAME_1)).isFalse(); + assertThat(service.isArtifactValidationEnabled( + ArtifactTypeEnum.MODEL, TEST_MODEL_REGISTRY_HOST_NAME_1)).isTrue(); + assertThat(service.isArtifactValidationEnabled( + ArtifactTypeEnum.RESOURCE, TEST_RESOURCE_REGISTRY_HOST_NAME_1)).isTrue(); + } + + private RegistryValidationService createService() { + var lookupService = new RegistryLookupService( + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList(), + recognizedRegistryConfig, + registryMapperService); + return new RegistryValidationService(lookupService); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/acr/AzureRegistryAuthClientTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/acr/AzureRegistryAuthClientTest.java new file mode 100644 index 0000000000..40a8e4d6d4 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/acr/AzureRegistryAuthClientTest.java @@ -0,0 +1,159 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.acr; + +import com.nvidia.boot.registries.service.registry.client.WebClientUtils; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ACR_CREDENTIALS; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_AZURE_REGISTRY_AUTH_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_AZURE_REGISTRY_CLIENT_CALL_TIMEOUT; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import com.nvidia.boot.mock.azure.MockAcrAuthServer; +import com.nvidia.boot.registries.service.registry.client.oci.dto.OciArtifactComponents; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.web.reactive.function.client.WebClient; + +class AzureRegistryAuthClientTest { + + private static final String TEST_ACR_AUTH_BASE_URL = "https://testregistry.azurecr.io"; + + private static AzureRegistryAuthClient azureRegistryAuthClient; + + @BeforeAll + static void beforeAll() { + azureRegistryAuthClient = new AzureRegistryAuthClient(WebClientUtils.builder(), + MOCK_AZURE_REGISTRY_AUTH_URL, + MOCK_AZURE_REGISTRY_CLIENT_CALL_TIMEOUT); + + MockAcrAuthServer.start(MOCK_AZURE_REGISTRY_AUTH_URL); + } + + @AfterAll + static void afterAll() { + MockAcrAuthServer.stop(); + } + + @Test + void constructor_WithValidHostname_Success() { + assertDoesNotThrow(() -> new AzureRegistryAuthClient(WebClientUtils.builder(), + TEST_ACR_AUTH_BASE_URL, + MOCK_AZURE_REGISTRY_CLIENT_CALL_TIMEOUT)); + } + + @Test + void getAuthBaseUrl_ReturnsCorrectValue() { + var authClient = new AzureRegistryAuthClient(WebClientUtils.builder(), + TEST_ACR_AUTH_BASE_URL, + MOCK_AZURE_REGISTRY_CLIENT_CALL_TIMEOUT); + assertEquals(TEST_ACR_AUTH_BASE_URL, authClient.getAuthBaseUrl()); + } + + @Test + void getCanonicalAuthTokenUrl_ReturnsCorrectUrl() { + var authClient = new AzureRegistryAuthClient(WebClientUtils.builder(), + "https://testregistry-1.azurecr.io", + MOCK_AZURE_REGISTRY_CLIENT_CALL_TIMEOUT); + + var tokenUrl = + authClient.getCanonicalAuthTokenUrl("testregistry.azurecr.io", "myrepo/myimage"); + + assertEquals( + "https://testregistry.azurecr.io/oauth2/token?service=testregistry.azurecr.io&scope=repository:myrepo/myimage:pull", + tokenUrl); + } + + @Test + void getCanonicalAuthTokenUrl_WithEmptyName_ReturnsUrlWithoutScope() { + var authClient = new AzureRegistryAuthClient(WebClientUtils.builder(), + "https://testregistry-1.azurecr.io", + MOCK_AZURE_REGISTRY_CLIENT_CALL_TIMEOUT); + + var tokenUrl = authClient.getCanonicalAuthTokenUrl("testregistry.azurecr.io", ""); + + assertEquals( + "https://testregistry.azurecr.io/oauth2/token?service=testregistry.azurecr.io", + tokenUrl); + } + + @Test + void getCanonicalAuthTokenUrl_WithNullName_ReturnsUrlWithoutScope() { + var authClient = new AzureRegistryAuthClient(WebClientUtils.builder(), + "https://testregistry-1.azurecr.io", + MOCK_AZURE_REGISTRY_CLIENT_CALL_TIMEOUT); + + var tokenUrl = authClient.getCanonicalAuthTokenUrl("testregistry.azurecr.io", null); + + assertEquals( + "https://testregistry.azurecr.io/oauth2/token?service=testregistry.azurecr.io", + tokenUrl); + } + + // ===== INTEGRATION TESTS WITH MOCK SERVER ===== + + @Test + void fetchToken_WithValidInputs_Success() { + var components = new OciArtifactComponents( + "testregistry.azurecr.io", + "myrepo/myimage", + "1.0.0" + ); + + var result = azureRegistryAuthClient + .getToken(components, + "dGVzdC11c2VyOnRlc3QtcGFzcw=="); // base64 for "test-user:test-pass" + + assertNotNull(result); + assertNotNull(result.token()); + assertNotNull(result.expiresIn()); + } + + @Test + void fetchToken_DifferentRepositories() { + var credentials = MOCK_ACR_CREDENTIALS; + + var components1 = new OciArtifactComponents( + "localhost-acr", "repo1/image", "v1.0.0"); + var components2 = new OciArtifactComponents( + "localhost-acr", "repo2/image", "v2.0.0"); + + assertDoesNotThrow(() -> { + var token1 = azureRegistryAuthClient.getToken(components1, credentials); + var token2 = azureRegistryAuthClient.getToken(components2, credentials); + + assertNotNull(token1); + assertNotNull(token2); + assertNotNull(token1.token()); + assertNotNull(token2.token()); + }); + } + + @Test + void validateCredential_WithMockServer_Success() { + // Test validateCredential using the mock server + var credentials = MOCK_ACR_CREDENTIALS; + + var token = azureRegistryAuthClient.validateCredential("localhost-acr", credentials); + assertNotNull(token); + assertNotNull(token.token()); + assertNotNull(token.expiresIn()); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/acr/AzureRegistryClientTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/acr/AzureRegistryClientTest.java new file mode 100644 index 0000000000..2469cffe8b --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/acr/AzureRegistryClientTest.java @@ -0,0 +1,200 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.acr; + +import com.nvidia.boot.registries.service.registry.client.WebClientUtils; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ACR_CONTAINER_IMAGE_NOT_EXISTS; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ACR_CONTAINER_IMAGE_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ACR_CONTAINER_IMAGE_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ACR_CONTAINER_IMAGE_WITH_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ACR_HELM_CHART_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ACR_HELM_CHART_WITH_TAG; +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.normalizeUrl; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ACR_CREDENTIALS; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_AZURE_REGISTRY_AUTH_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_AZURE_REGISTRY_CLIENT_CALL_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_AZURE_REGISTRY_URL; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.mock.azure.MockAcrAuthServer; +import com.nvidia.boot.mock.oci.MockOciRegistryServer; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.web.reactive.function.client.WebClient; + +class AzureRegistryClientTest { + + private static final String TEST_ACR_HOSTNAME = "testregistry.azurecr.io"; + private static final String TEST_ACR_REGISTRY_BASE_URL = "https://testregistry.azurecr.io"; + + private static AzureRegistryClient azureRegistryClient; + private static MockOciRegistryServer mockAcrRegistryServer; + + @BeforeAll + static void beforeAll() { + azureRegistryClient = new AzureRegistryClient( + WebClientUtils.builder(), + MOCK_AZURE_REGISTRY_URL, + MOCK_AZURE_REGISTRY_CLIENT_CALL_TIMEOUT, + MOCK_AZURE_REGISTRY_AUTH_URL); + + MockAcrAuthServer.start(MOCK_AZURE_REGISTRY_AUTH_URL); + mockAcrRegistryServer = new MockOciRegistryServer(); + mockAcrRegistryServer.start(MOCK_AZURE_REGISTRY_URL); + } + + @AfterAll + static void afterAll() { + MockAcrAuthServer.stop(); + mockAcrRegistryServer.stop(); + } + + @Test + void constructor_WithValidHostname_Success() { + assertDoesNotThrow(() -> new AzureRegistryClient(WebClientUtils.builder(), + TEST_ACR_HOSTNAME, + MOCK_AZURE_REGISTRY_CLIENT_CALL_TIMEOUT, + TEST_ACR_REGISTRY_BASE_URL)); + } + + @Test + void constructor_WithHttpsHostname_Success() { + assertDoesNotThrow(() -> new AzureRegistryClient(WebClientUtils.builder(), + TEST_ACR_REGISTRY_BASE_URL, + MOCK_AZURE_REGISTRY_CLIENT_CALL_TIMEOUT, + TEST_ACR_REGISTRY_BASE_URL)); + } + + static Stream validAcrHostnames() { + return Stream.of( + Arguments.of("myregistry.azurecr.io", "myregistry.azurecr.io"), + Arguments.of("https://myregistry.azurecr.io", "myregistry.azurecr.io"), + Arguments.of("http://localhost:8080", "localhost"), + Arguments.of("https://custom-registry.example.com", "custom-registry.example.com"), + Arguments.of("https://registry.io:443", "registry.io"), + Arguments.of("http://localhost-acr:9200", "localhost-acr") + ); + } + + @ParameterizedTest + @MethodSource("validAcrHostnames") + void constructor_ExtractsHostnameCorrectly(String inputHostname, String expectedHostname) { + var client = + new AzureRegistryClient(WebClientUtils.builder(), inputHostname, MOCK_AZURE_REGISTRY_CLIENT_CALL_TIMEOUT, + normalizeUrl(inputHostname)); + assertEquals(expectedHostname, client.getHostname()); + } + + @Test + void getHostname_ReturnsCorrectHostname() { + var client = + new AzureRegistryClient(WebClientUtils.builder(), TEST_ACR_HOSTNAME, MOCK_AZURE_REGISTRY_CLIENT_CALL_TIMEOUT, + TEST_ACR_REGISTRY_BASE_URL); + assertEquals("testregistry.azurecr.io", client.getHostname()); + } + + @Test + void getHostname_WithHttpsUrl_ExtractsHostnameOnly() { + var client = new AzureRegistryClient(WebClientUtils.builder(), TEST_ACR_REGISTRY_BASE_URL, + MOCK_AZURE_REGISTRY_CLIENT_CALL_TIMEOUT, + TEST_ACR_REGISTRY_BASE_URL); + assertEquals("testregistry.azurecr.io", client.getHostname()); + } + + @Test + void getRegistryBaseUrl_ReturnsCorrectUrl() { + var client = + new AzureRegistryClient(WebClientUtils.builder(), + TEST_ACR_HOSTNAME, + MOCK_AZURE_REGISTRY_CLIENT_CALL_TIMEOUT, + TEST_ACR_REGISTRY_BASE_URL); + String baseUrl = client.getRegistryBaseUrl("testregistry-2.azurecr.io"); + assertEquals("https://testregistry-2.azurecr.io", baseUrl); + } + + @Test + void azureRegistryGlobalHostname_HasCorrectValue() { + assertEquals("azurecr.io", AzureRegistryClient.AZURE_REGISTRY_GLOBAL_HOSTNAME); + } + + // ===== INTEGRATION TESTS WITH MOCK SERVER ===== + + @ParameterizedTest + @ValueSource(strings = { + "", // empty string + "invalid", // no slashes + "localhost:9110", // only registry + "localhost:9110/", // trailing slash + "https://localhost:9110/namespace/repo:tag", // wrong protocol prefix + }) + void validateArtifact_WithInvalidImageUrls_ThrowsBadRequestException(String invalidUrl) { + assertThrows(BadRequestException.class, () -> + azureRegistryClient.validateArtifact(invalidUrl, MOCK_ACR_CREDENTIALS)); + } + + @Test + void validateArtifact_WithNullImageUrl_ThrowsBadRequestException() { + assertThrows(BadRequestException.class, () -> + azureRegistryClient.validateArtifact(null, MOCK_ACR_CREDENTIALS)); + } + + static Stream validAcrImageUrls() { + return Stream.of( + Arguments.of(TEST_ACR_CONTAINER_IMAGE_WITH_TAG.toString()), + Arguments.of(TEST_ACR_CONTAINER_IMAGE_WITH_DIGEST.toString()), + Arguments.of(TEST_ACR_HELM_CHART_WITH_TAG.toString()), + Arguments.of(TEST_ACR_HELM_CHART_WITH_DIGEST.toString()) + ); + } + + @ParameterizedTest + @MethodSource("validAcrImageUrls") + void validateArtifact_WithValidUrls_Success(String imageUrl) { + assertDoesNotThrow( + () -> azureRegistryClient.validateArtifact(imageUrl, MOCK_ACR_CREDENTIALS)); + } + + @Test + void validateArtifact_WithPermissionDeniedImage_ThrowsForbiddenException() { + var imageUrl = TEST_ACR_CONTAINER_IMAGE_PERMISSION_DENIED.toString(); + assertThrows(ForbiddenException.class, () -> + azureRegistryClient.validateArtifact( + imageUrl, + MOCK_ACR_CREDENTIALS)); + } + + @Test + void validateArtifact_WithNonExistentImage_ThrowsNotFoundException() { + var imageUrl = TEST_ACR_CONTAINER_IMAGE_NOT_EXISTS.toString(); + assertThrows(NotFoundException.class, () -> + azureRegistryClient.validateArtifact( + imageUrl, + MOCK_ACR_CREDENTIALS)); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/artifactory/ArtifactoryAuthClientTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/artifactory/ArtifactoryAuthClientTest.java new file mode 100644 index 0000000000..7f731d4c0c --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/artifactory/ArtifactoryAuthClientTest.java @@ -0,0 +1,202 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.artifactory; + +import com.nvidia.boot.registries.service.registry.client.WebClientUtils; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ARTIFACTORY_CREDENTIALS; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ARTIFACTORY_REGISTRY_AUTH_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ARTIFACTORY_REGISTRY_CLIENT_CALL_TIMEOUT; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.mock.artifactory.MockArtifactoryAuthServer; +import com.nvidia.boot.registries.service.registry.client.oci.dto.OciArtifactComponents; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.web.reactive.function.client.WebClient; + +class ArtifactoryAuthClientTest { + + private static final String TEST_ARTIFACTORY_AUTH_BASE_URL = + "https://artifactoryRegistryTest.jfrog.io"; + + private static ArtifactoryAuthClient artifactoryAuthClient; + + @BeforeAll + static void beforeAll() { + artifactoryAuthClient = new ArtifactoryAuthClient( + WebClientUtils.builder(), + MOCK_ARTIFACTORY_REGISTRY_AUTH_URL, + MOCK_ARTIFACTORY_REGISTRY_CLIENT_CALL_TIMEOUT); + + MockArtifactoryAuthServer.start(MOCK_ARTIFACTORY_REGISTRY_AUTH_URL); + } + + @AfterAll + static void afterAll() { + MockArtifactoryAuthServer.stop(); + } + + @Test + void constructor_WithValidHostname_Success() { + assertDoesNotThrow(() -> new ArtifactoryAuthClient( + WebClientUtils.builder(), + TEST_ARTIFACTORY_AUTH_BASE_URL, + MOCK_ARTIFACTORY_REGISTRY_CLIENT_CALL_TIMEOUT)); + } + + @Test + void getAuthBaseUrl_ReturnsCorrectValue() { + var authClient = new ArtifactoryAuthClient( + WebClientUtils.builder(), + TEST_ARTIFACTORY_AUTH_BASE_URL, + MOCK_ARTIFACTORY_REGISTRY_CLIENT_CALL_TIMEOUT); + assertEquals(TEST_ARTIFACTORY_AUTH_BASE_URL, authClient.getAuthBaseUrl()); + } + + @Test + void getCanonicalAuthTokenUrl_ReturnsCorrectUrl() { + var authClient = new ArtifactoryAuthClient( + WebClientUtils.builder(), + "https://testregistry-1.jfrog.io", + MOCK_ARTIFACTORY_REGISTRY_CLIENT_CALL_TIMEOUT); + + var tokenUrl = authClient.getCanonicalAuthTokenUrl( + "testregistry.jfrog.io", + "nvcf-test-oci/test-image-1"); + + assertEquals( + "https://testregistry.jfrog.io/v2/token?service=testregistry.jfrog.io&scope=repository:test-image-1:pull", + tokenUrl); + } + + @Test + void getCanonicalAuthTokenUrl_WithComplexImageName_ReturnsCorrectUrl() { + var authClient = new ArtifactoryAuthClient( + WebClientUtils.builder(), + "https://testregistry.jfrog.io", + MOCK_ARTIFACTORY_REGISTRY_CLIENT_CALL_TIMEOUT); + + var tokenUrl = authClient.getCanonicalAuthTokenUrl( + "testregistry.jfrog.io", + "myrepo/path/to/image"); + + assertEquals( + "https://testregistry.jfrog.io/v2/token?service=testregistry.jfrog.io&scope=repository:path/to/image:pull", + tokenUrl); + } + + @Test + void getCanonicalAuthTokenUrl_WithInvalidName_ThrowsBadRequestException() { + var authClient = new ArtifactoryAuthClient( + WebClientUtils.builder(), + "https://testregistry.jfrog.io", + MOCK_ARTIFACTORY_REGISTRY_CLIENT_CALL_TIMEOUT); + + assertThrows(BadRequestException.class, () -> + authClient.getCanonicalAuthTokenUrl( + "testregistry.jfrog.io", + "myrepo")); + } + + @Test + void getCanonicalAuthTokenUrl_WithEmptyName_ReturnsUrlWithoutScope() { + var authClient = new ArtifactoryAuthClient( + WebClientUtils.builder(), + "https://testregistry.jfrog.io", + MOCK_ARTIFACTORY_REGISTRY_CLIENT_CALL_TIMEOUT); + + var tokenUrl = authClient.getCanonicalAuthTokenUrl( + "testregistry.jfrog.io", + ""); + + assertEquals( + "https://testregistry.jfrog.io/v2/token?service=testregistry.jfrog.io", + tokenUrl); + } + + @Test + void getCanonicalAuthTokenUrl_WithNullName_ReturnsUrlWithoutScope() { + var authClient = new ArtifactoryAuthClient( + WebClientUtils.builder(), + "https://testregistry.jfrog.io", + MOCK_ARTIFACTORY_REGISTRY_CLIENT_CALL_TIMEOUT); + + var tokenUrl = authClient.getCanonicalAuthTokenUrl( + "testregistry.jfrog.io", + null); + + assertEquals( + "https://testregistry.jfrog.io/v2/token?service=testregistry.jfrog.io", + tokenUrl); + } + + // ===== INTEGRATION TESTS WITH MOCK SERVER ===== + + @Test + void fetchToken_WithValidInputs_Success() { + var components = new OciArtifactComponents( + "testregistry.jfrog.io", + "nvcf-test-oci/myimage", + "1.0.0" + ); + + var result = artifactoryAuthClient + .getToken(components, + "dGVzdC11c2VyOnRlc3QtcGFzcw=="); // base64 for "test-user:test-pass" + + assertNotNull(result); + assertNotNull(result.token()); + assertNotNull(result.expiresIn()); + } + + @Test + void fetchToken_DifferentRepositories() { + var credentials = MOCK_ARTIFACTORY_CREDENTIALS; + + var components1 = new OciArtifactComponents( + "localhost-jfrog", "repo1/image", "v1.0.0"); + var components2 = new OciArtifactComponents( + "localhost-jfrog", "repo2/image", "v2.0.0"); + + assertDoesNotThrow(() -> { + var token1 = artifactoryAuthClient.getToken(components1, credentials); + var token2 = artifactoryAuthClient.getToken(components2, credentials); + + assertNotNull(token1); + assertNotNull(token2); + assertNotNull(token1.token()); + assertNotNull(token2.token()); + }); + } + + @Test + void validateCredential_WithMockServer_Success() { + // Test validateCredential using the mock server + var credentials = MOCK_ARTIFACTORY_CREDENTIALS; + + var token = artifactoryAuthClient.validateCredential("localhost-jfrog", credentials); + assertNotNull(token); + assertNotNull(token.token()); + assertNotNull(token.expiresIn()); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/artifactory/ArtifactoryClientTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/artifactory/ArtifactoryClientTest.java new file mode 100644 index 0000000000..4c1c103ed4 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/artifactory/ArtifactoryClientTest.java @@ -0,0 +1,205 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.artifactory; + +import com.nvidia.boot.registries.service.registry.client.WebClientUtils; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ARTIFACTORY_CONTAINER_IMAGE_NOT_EXISTS; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ARTIFACTORY_CONTAINER_IMAGE_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ARTIFACTORY_CONTAINER_IMAGE_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ARTIFACTORY_CONTAINER_IMAGE_WITH_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ARTIFACTORY_HELM_CHART_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ARTIFACTORY_HELM_CHART_WITH_TAG; +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.normalizeUrl; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ARTIFACTORY_CREDENTIALS; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ARTIFACTORY_REGISTRY_AUTH_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ARTIFACTORY_REGISTRY_CLIENT_CALL_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ARTIFACTORY_REGISTRY_URL; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.mock.artifactory.MockArtifactoryAuthServer; +import com.nvidia.boot.mock.oci.MockOciRegistryServer; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.web.reactive.function.client.WebClient; + +class ArtifactoryClientTest { + + private static final String TEST_ARTIFACTORY_HOSTNAME = "testregistry.jfrog.io"; + private static final String TEST_ARTIFACTORY_REGISTRY_BASE_URL = + "https://testregistry.jfrog.io"; + + private static ArtifactoryClient artifactoryClient; + private static MockOciRegistryServer mockArtifactoryRegistryServer; + + @BeforeAll + static void beforeAll() { + artifactoryClient = new ArtifactoryClient( + WebClientUtils.builder(), + MOCK_ARTIFACTORY_REGISTRY_URL, + MOCK_ARTIFACTORY_REGISTRY_CLIENT_CALL_TIMEOUT, + MOCK_ARTIFACTORY_REGISTRY_AUTH_URL); + + MockArtifactoryAuthServer.start(MOCK_ARTIFACTORY_REGISTRY_AUTH_URL); + mockArtifactoryRegistryServer = new MockOciRegistryServer(); + mockArtifactoryRegistryServer.start(MOCK_ARTIFACTORY_REGISTRY_URL); + } + + @AfterAll + static void afterAll() { + MockArtifactoryAuthServer.stop(); + mockArtifactoryRegistryServer.stop(); + } + + @Test + void constructor_WithValidHostname_Success() { + assertDoesNotThrow(() -> new ArtifactoryClient( + WebClientUtils.builder(), + TEST_ARTIFACTORY_HOSTNAME, + MOCK_ARTIFACTORY_REGISTRY_CLIENT_CALL_TIMEOUT, + TEST_ARTIFACTORY_REGISTRY_BASE_URL)); + } + + @Test + void constructor_WithHttpsHostname_Success() { + assertDoesNotThrow(() -> new ArtifactoryClient( + WebClientUtils.builder(), + TEST_ARTIFACTORY_REGISTRY_BASE_URL, + MOCK_ARTIFACTORY_REGISTRY_CLIENT_CALL_TIMEOUT, + TEST_ARTIFACTORY_REGISTRY_BASE_URL)); + } + + static Stream validArtifactoryHostnames() { + return Stream.of( + Arguments.of("myregistry.jfrog.io", "myregistry.jfrog.io"), + Arguments.of("https://myregistry.jfrog.io", "myregistry.jfrog.io"), + Arguments.of("http://localhost:8080", "localhost"), + Arguments.of("https://custom-registry.example.com", "custom-registry.example.com"), + Arguments.of("https://registry.io:443", "registry.io"), + Arguments.of("http://localhost-jfrog:9200", "localhost-jfrog") + ); + } + + @ParameterizedTest + @MethodSource("validArtifactoryHostnames") + void constructor_ExtractsHostnameCorrectly(String inputHostname, String expectedHostname) { + var client = new ArtifactoryClient( + WebClientUtils.builder(), + inputHostname, + MOCK_ARTIFACTORY_REGISTRY_CLIENT_CALL_TIMEOUT, + normalizeUrl(inputHostname)); + assertEquals(expectedHostname, client.getHostname()); + } + + @Test + void getHostname_ReturnsCorrectHostname() { + var client = new ArtifactoryClient( + WebClientUtils.builder(), + TEST_ARTIFACTORY_HOSTNAME, + MOCK_ARTIFACTORY_REGISTRY_CLIENT_CALL_TIMEOUT, + TEST_ARTIFACTORY_REGISTRY_BASE_URL); + assertEquals("testregistry.jfrog.io", client.getHostname()); + } + + @Test + void getHostname_WithHttpsUrl_ExtractsHostnameOnly() { + var client = new ArtifactoryClient( + WebClientUtils.builder(), + TEST_ARTIFACTORY_REGISTRY_BASE_URL, + MOCK_ARTIFACTORY_REGISTRY_CLIENT_CALL_TIMEOUT, + TEST_ARTIFACTORY_REGISTRY_BASE_URL); + assertEquals("testregistry.jfrog.io", client.getHostname()); + } + + @Test + void getRegistryBaseUrl_ReturnsCorrectUrl() { + var client = new ArtifactoryClient( + WebClientUtils.builder(), + TEST_ARTIFACTORY_HOSTNAME, + MOCK_ARTIFACTORY_REGISTRY_CLIENT_CALL_TIMEOUT, + TEST_ARTIFACTORY_REGISTRY_BASE_URL); + String baseUrl = client.getRegistryBaseUrl("testregistry-2.jfrog.io"); + assertEquals("https://testregistry-2.jfrog.io", baseUrl); + } + + // ===== INTEGRATION TESTS WITH MOCK SERVER ===== + + @ParameterizedTest + @ValueSource(strings = { + "", // empty string + "invalid", // no slashes + "localhost:9110", // only registry + "localhost:9110/", // trailing slash + "https://localhost:9110/namespace/repo:tag", // wrong protocol prefix + }) + void validateArtifact_WithInvalidImageUrls_ThrowsBadRequestException(String invalidUrl) { + assertThrows(BadRequestException.class, () -> + artifactoryClient.validateArtifact(invalidUrl, MOCK_ARTIFACTORY_CREDENTIALS)); + } + + @Test + void validateArtifact_WithNullImageUrl_ThrowsBadRequestException() { + assertThrows(BadRequestException.class, () -> + artifactoryClient.validateArtifact(null, MOCK_ARTIFACTORY_CREDENTIALS)); + } + + static Stream validArtifactoryImageUrls() { + return Stream.of( + Arguments.of(TEST_ARTIFACTORY_CONTAINER_IMAGE_WITH_TAG.toString()), + Arguments.of(TEST_ARTIFACTORY_CONTAINER_IMAGE_WITH_DIGEST.toString()), + Arguments.of(TEST_ARTIFACTORY_HELM_CHART_WITH_TAG.toString()), + Arguments.of(TEST_ARTIFACTORY_HELM_CHART_WITH_DIGEST.toString()) + ); + } + + @ParameterizedTest + @MethodSource("validArtifactoryImageUrls") + void validateArtifact_WithValidUrls_Success(String imageUrl) { + assertDoesNotThrow( + () -> artifactoryClient.validateArtifact(imageUrl, + MOCK_ARTIFACTORY_CREDENTIALS)); + } + + @Test + void validateArtifact_WithPermissionDeniedImage_ThrowsForbiddenException() { + var imageUrl = TEST_ARTIFACTORY_CONTAINER_IMAGE_PERMISSION_DENIED.toString(); + assertThrows(ForbiddenException.class, () -> + artifactoryClient.validateArtifact( + imageUrl, + MOCK_ARTIFACTORY_CREDENTIALS)); + } + + @Test + void validateArtifact_WithNonExistentImage_ThrowsNotFoundException() { + var imageUrl = TEST_ARTIFACTORY_CONTAINER_IMAGE_NOT_EXISTS.toString(); + assertThrows(NotFoundException.class, () -> + artifactoryClient.validateArtifact( + imageUrl, + MOCK_ARTIFACTORY_CREDENTIALS)); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/docker/DockerRegistryClientTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/docker/DockerRegistryClientTest.java new file mode 100644 index 0000000000..e1930a57b8 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/docker/DockerRegistryClientTest.java @@ -0,0 +1,228 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.docker; + +import com.nvidia.boot.registries.service.registry.client.WebClientUtils; +import static com.nvidia.boot.mock.BootTestConstants.TEST_DOCKER_CONTAINER_IMAGE; +import static com.nvidia.boot.mock.BootTestConstants.TEST_DOCKER_CONTAINER_IMAGE_NOT_EXISTS; +import static com.nvidia.boot.mock.BootTestConstants.TEST_DOCKER_CONTAINER_IMAGE_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_DOCKER_CONTAINER_IMAGE_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_CONTAINER_HASH; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_DOCKER_NAMESPACE_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_DOCKER_REPO_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_DOCKER_TAG_NAME; +import static com.nvidia.boot.registries.service.registry.client.docker.DockerRegistryClient.parseImageUrl; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_DOCKER_CONTAINER_REGISTRY_CRED; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_DOCKER_REGISTRY_CLIENT_CALL_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_DOCKER_REGISTRY_OAUTH2_GROUP_SCOPE; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_DOCKER_REGISTRY_OAUTH2_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_DOCKER_REGISTRY_URL; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.mock.docker.MockDockerRegistryAuthServer; +import com.nvidia.boot.mock.docker.MockDockerRegistryServer; +import com.nvidia.boot.registries.service.registry.client.docker.DockerRegistryClient.DockerImageComponents; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.web.reactive.function.client.WebClient; + +class DockerRegistryClientTest { + private static DockerRegistryClient dockerRegistryClient; + + @BeforeAll + static void beforeAll() { + dockerRegistryClient = new DockerRegistryClient( + WebClientUtils.builder(), + MOCK_DOCKER_REGISTRY_URL, + MOCK_DOCKER_REGISTRY_CLIENT_CALL_TIMEOUT, + MOCK_DOCKER_REGISTRY_OAUTH2_URL, + MOCK_DOCKER_REGISTRY_OAUTH2_GROUP_SCOPE + ); + MockDockerRegistryServer.start(MOCK_DOCKER_REGISTRY_URL); + MockDockerRegistryAuthServer.start(MOCK_DOCKER_REGISTRY_OAUTH2_URL); + } + + @AfterAll + static void cleanup() { + MockDockerRegistryServer.stop(); + MockDockerRegistryAuthServer.stop(); + } + + @AfterEach + void reset() { + dockerRegistryClient.resetAuthTokenCache(); + } + + static Stream createValidImageUrl() { + return Stream.of( + Arguments.of(TEST_DOCKER_CONTAINER_IMAGE.toString(), + TEST_VALID_DOCKER_NAMESPACE_NAME, + TEST_VALID_DOCKER_REPO_NAME, + TEST_VALID_DOCKER_TAG_NAME, + null), + Arguments.of(TEST_DOCKER_CONTAINER_IMAGE_WITH_DIGEST.toString(), + TEST_VALID_DOCKER_NAMESPACE_NAME, + TEST_VALID_DOCKER_REPO_NAME, + null, + TEST_VALID_CONTAINER_HASH), + Arguments.of("docker.io/test-docker-namespace/test-docker-repo:latest", + TEST_VALID_DOCKER_NAMESPACE_NAME, + TEST_VALID_DOCKER_REPO_NAME, + "latest", + null), + + // helm version of the url, basically just add https:// in front + Arguments.of("oci://" + TEST_DOCKER_CONTAINER_IMAGE, + TEST_VALID_DOCKER_NAMESPACE_NAME, + TEST_VALID_DOCKER_REPO_NAME, + TEST_VALID_DOCKER_TAG_NAME, + null), + Arguments.of("oci://" + TEST_DOCKER_CONTAINER_IMAGE_WITH_DIGEST, + TEST_VALID_DOCKER_NAMESPACE_NAME, + TEST_VALID_DOCKER_REPO_NAME, + null, + TEST_VALID_CONTAINER_HASH), + Arguments.of("oci://docker.io/test-docker-namespace/test-docker-repo:latest", + TEST_VALID_DOCKER_NAMESPACE_NAME, + TEST_VALID_DOCKER_REPO_NAME, + "latest", + null) + ); + } + + @ParameterizedTest + @MethodSource("createValidImageUrl") + void parseContainerImageUrl_WithValidFormat_Success(String imageUrl, + String namespace, + String repository, + String tag, + String digest) { + // When + DockerImageComponents components = parseImageUrl(imageUrl); + + // Then + assertNotNull(components); + assertEquals(namespace, components.namespace()); + assertEquals(repository, components.repository()); + assertEquals(tag, components.tag()); + assertEquals(digest, components.digest()); + } + + @MethodSource("createValidImageUrl") + @ParameterizedTest + void validateContainerImage_Success(String containerUrl) { + dockerRegistryClient + .validateImage(containerUrl, + MOCK_DOCKER_CONTAINER_REGISTRY_CRED); + } + + @Test + void validateContainerImage_PermissionDenied_Fail() { + assertThrows(ForbiddenException.class, () -> { + dockerRegistryClient.validateImage( + TEST_DOCKER_CONTAINER_IMAGE_PERMISSION_DENIED.toString(), + MOCK_DOCKER_CONTAINER_REGISTRY_CRED); + }); + } + + @Test + void validateContainerImage_NotExist_Fail() { + assertThrows(NotFoundException.class, () -> { + dockerRegistryClient.validateImage( + TEST_DOCKER_CONTAINER_IMAGE_NOT_EXISTS.toString(), + MOCK_DOCKER_CONTAINER_REGISTRY_CRED); + }); + } + + @Test + void validateCredential_WithValidCredentials_Success() { + // This test validates credentials without checking any specific image + dockerRegistryClient.validateCredential("docker.io", + MOCK_DOCKER_CONTAINER_REGISTRY_CRED); + } + + @ParameterizedTest + @ValueSource(strings = { + "", // empty string + "invalid", // no slashes + "docker.io", // only registry + "docker.io/", // trailing slash + "docker.io/mynamespace/myrepo/tag", // wrong format, slash instead of colon + "docker.io/myrepo:tag", // no namespace + "docker.io/mynamespace:tag", // no repo + "https://docker.io/mynamespace/myrepo:tag", // wrong helm prefix + "oci:/docker.io/mynamespace/myrepo:tag", // wrong helm host + }) + void parseContainerImageUrl_WithInvalidFormats_Fail(String invalidUrl) { + // When/Then + assertThrows(BadRequestException.class, () -> parseImageUrl(invalidUrl)); + } + + @Test + void validateImage_RepeatedCalls_Success() { + String imageUrl = TEST_DOCKER_CONTAINER_IMAGE.toString(); + String apiKey = MOCK_DOCKER_CONTAINER_REGISTRY_CRED; + + // First call - should fetch token and cache it + dockerRegistryClient.validateImage(imageUrl, apiKey); + + // Second call - should use cached token + dockerRegistryClient.validateImage(imageUrl, apiKey); + + // Third call - should still work with cached token + dockerRegistryClient.validateImage(imageUrl, apiKey); + } + + @Test + void validateImage_DifferentImageWithSameCredentials_Success() { + String imageUrl1 = TEST_DOCKER_CONTAINER_IMAGE.toString(); + String imageUrl2 = TEST_DOCKER_CONTAINER_IMAGE_WITH_DIGEST.toString(); + String apiKey = MOCK_DOCKER_CONTAINER_REGISTRY_CRED; + + // Both images use same namespace/repo, so should share cache entry + dockerRegistryClient.validateImage(imageUrl1, apiKey); + dockerRegistryClient.validateImage(imageUrl2, apiKey); + } + + @Test + void resetAuthTokenCache_ClearsCacheSuccessfully() { + String imageUrl = TEST_DOCKER_CONTAINER_IMAGE.toString(); + String apiKey = MOCK_DOCKER_CONTAINER_REGISTRY_CRED; + + // Populate cache + dockerRegistryClient.validateImage(imageUrl, apiKey); + + // Reset cache + dockerRegistryClient.resetAuthTokenCache(); + + // Should still work after cache reset (will fetch new token) + dockerRegistryClient.validateImage(imageUrl, apiKey); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/ecr/pub/EcrPublicArtifactRegistryClientTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/ecr/pub/EcrPublicArtifactRegistryClientTest.java new file mode 100644 index 0000000000..549990563d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/ecr/pub/EcrPublicArtifactRegistryClientTest.java @@ -0,0 +1,242 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.ecr.pub; + +import com.nvidia.boot.registries.service.registry.client.WebClientUtils; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_PUBLIC_HELM_CHART_DIGEST_NOT_FOUND; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_PUBLIC_HELM_CHART_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_PUBLIC_HELM_CHART_TAG_NOT_FOUND; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_PUBLIC_HELM_CHART_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_PUBLIC_HELM_CHART_WITH_TAG; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrRegistryUtils.parsePublicArtifactUrl; +import static com.nvidia.boot.registries.service.registry.client.ecr.pub.EcrPublicArtifactRegistryClient.ECR_PUBLIC_HELM_CHART_URL_PATTERN; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ECR_PUBLIC_REGISTRY_API_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ECR_PUBLIC_REGISTRY_CLIENT_CALL_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ECR_PUBLIC_REGISTRY_CRED; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_INVALID_ECR_PUBLIC_REGISTRY_CRED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.mock.ecr.MockEcrPublicRegistryServer; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.web.reactive.function.client.WebClient; + +class EcrPublicArtifactRegistryClientTest { + + private static EcrPublicArtifactRegistryClient ecrPublicArtifactRegistryClient; + + // Test ECR Public Helm chart URLs for integration testing + private static final String TEST_ECR_PUBLIC_HELM_CHART_WITH_TAG_1 = + "oci://public.ecr.aws/test-alias/helm-charts/my-chart:v1.0.0"; + private static final String TEST_ECR_PUBLIC_HELM_CHART_WITH_DIGEST_1 = + "oci://public.ecr.aws/test-alias/helm-charts/my-chart@sha256:abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab"; + private static final String TEST_ECR_PUBLIC_HELM_CHART_WITHOUT_TAG_1 = + "oci://public.ecr.aws/test-alias/helm-charts/my-chart"; + private static final String TEST_ECR_PUBLIC_HELM_CHART_MULTI_NAMESPACE_1 = + "oci://public.ecr.aws/test-alias/namespace/sub-namespace/helm-charts/my-chart:latest"; + + @BeforeAll + static void beforeAll() { + ecrPublicArtifactRegistryClient = new EcrPublicArtifactRegistryClient( + WebClientUtils.builder(), + MOCK_ECR_PUBLIC_REGISTRY_API_URL, + MOCK_ECR_PUBLIC_REGISTRY_CLIENT_CALL_TIMEOUT); + MockEcrPublicRegistryServer.start(MOCK_ECR_PUBLIC_REGISTRY_API_URL); + } + + @AfterAll + static void cleanup() { + MockEcrPublicRegistryServer.stop(); + } + + static Stream createValidHelmChartUrls() { + return Stream.of( + Arguments.of(TEST_ECR_PUBLIC_HELM_CHART_WITH_TAG_1, + "helm-charts/my-chart", "v1.0.0", null), + Arguments.of(TEST_ECR_PUBLIC_HELM_CHART_WITH_DIGEST_1, + "helm-charts/my-chart", null, + "sha256:abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab"), + Arguments.of(TEST_ECR_PUBLIC_HELM_CHART_WITHOUT_TAG_1, + "helm-charts/my-chart", "latest", null), + Arguments.of(TEST_ECR_PUBLIC_HELM_CHART_MULTI_NAMESPACE_1, + "namespace/sub-namespace/helm-charts/my-chart", "latest", + null) + ); + } + + @ParameterizedTest + @MethodSource("createValidHelmChartUrls") + void parseHelmChartUrl_WithValidFormat_Success(String helmChartUrl, + String expectedRepository, + String expectedTag, + String expectedDigest) { + + var components = parsePublicArtifactUrl(helmChartUrl, ECR_PUBLIC_HELM_CHART_URL_PATTERN); + + assertNotNull(components); + assertEquals(expectedRepository, components.repositoryName()); + assertEquals(expectedTag, components.tag()); + assertEquals(expectedDigest, components.digest()); + assertThat(components.registryId()).isNull(); + assertThat(components.region()).isNull(); + } + + @ParameterizedTest + @ValueSource(strings = { + "", // empty string + " ", // whitespace only + "invalid-url", // not ECR Public OCI format + "oci://public.ecr.com/alias/chart:tag", // wrong domain + "oci://public.ecr.aws//chart:tag", // empty registry alias + "oci://public.ecr.aws/alias/:tag", // empty repository + "oci://public.ecr.aws/alias/chart@invalid-digest", // invalid digest format + "https://helm.example.com/charts/my-chart:latest", // not ECR Public URL + "public.ecr.aws/alias/chart:tag", // missing 'oci://' prefix + "oci://123456789012.dkr.ecr.us-west-2.amazonaws.com/chart:tag", // ECR private format + "oci://public.ecr.aws/alias-with_invalid_chars!/chart:tag", // invalid registry alias + "oci://public.ecr.aws/alias/chart:tag:extra", // multiple colons + }) + void parseHelmChartUrl_WithInvalidFormats_Fail(String invalidUrl) { + assertThrows(BadRequestException.class, + () -> parsePublicArtifactUrl(invalidUrl, ECR_PUBLIC_HELM_CHART_URL_PATTERN)); + } + + @ParameterizedTest + @ValueSource(strings = { + "simple-chart", + "namespace/chart", + "deep/namespace/structure/chart", + "chart-with-dashes", + "chart_with_underscores", + "chart.with.dots", + "123numeric-chart", + "helm-charts/my-chart", + "charts/stable/nginx" + }) + void parseHelmChartUrl_VariousRepositoryFormats_Success(String repositoryName) { + var helmChartUrl = + String.format("oci://public.ecr.aws/test-alias/%s:latest", repositoryName); + assertDoesNotThrow( + () -> parsePublicArtifactUrl(helmChartUrl, ECR_PUBLIC_HELM_CHART_URL_PATTERN)); + } + + @ParameterizedTest + @ValueSource(strings = { + "test-alias", + "simple", + "alias-with-dashes", + "alias_with_underscores", + "alias.with.dots", + "123numeric-alias", + "a", // minimum length + "very-long-alias-name-that-is-still-valid" + }) + void parseHelmChartUrl_VariousRegistryAliases_Success(String registryAlias) { + var helmChartUrl = + String.format("oci://public.ecr.aws/%s/helm-charts/my-chart:latest", registryAlias); + assertDoesNotThrow( + () -> parsePublicArtifactUrl(helmChartUrl, ECR_PUBLIC_HELM_CHART_URL_PATTERN)); + } + + @ParameterizedTest + @ValueSource(strings = { + "latest", + "v1.0.0", + "1.2.3", + "0.1.0-alpha", + "2.0.0-beta.1", + "1.0.0-rc.1", + "dev-branch", + "feature_branch", + "release-2023-12-01", + "chart-v1.2.3" + }) + void parseHelmChartUrl_VariousVersionFormats_Success(String version) { + var helmChartUrl = + String.format("oci://public.ecr.aws/test-alias/helm-charts/my-chart:%s", version); + assertDoesNotThrow( + () -> parsePublicArtifactUrl(helmChartUrl, ECR_PUBLIC_HELM_CHART_URL_PATTERN)); + } + + static Stream createValidHelmCharts() { + return Stream.of( + Arguments.of(TEST_ECR_PUBLIC_HELM_CHART_WITH_TAG.toString()), + Arguments.of(TEST_ECR_PUBLIC_HELM_CHART_WITH_DIGEST.toString()) + ); + } + + @ParameterizedTest + @MethodSource("createValidHelmCharts") + void validateHelmChart_Success(String helmChartUrl) { + assertDoesNotThrow(() -> ecrPublicArtifactRegistryClient + .validateHelmChart(helmChartUrl, + MOCK_ECR_PUBLIC_REGISTRY_CRED)); + } + + @Test + void validateHelmChart_PermissionDenied_Fail() { + assertThrows(ForbiddenException.class, () -> + ecrPublicArtifactRegistryClient.validateHelmChart( + TEST_ECR_PUBLIC_HELM_CHART_PERMISSION_DENIED.toString(), + MOCK_ECR_PUBLIC_REGISTRY_CRED)); + } + + @Test + void validateHelmChart_NotFound_Fail() { + assertThrows(BadRequestException.class, () -> + ecrPublicArtifactRegistryClient.validateHelmChart( + TEST_ECR_PUBLIC_HELM_CHART_TAG_NOT_FOUND.toString(), + MOCK_ECR_PUBLIC_REGISTRY_CRED)); + } + + @Test + void validateHelmChart_DigestNotFound_Fail() { + assertThrows(BadRequestException.class, () -> + ecrPublicArtifactRegistryClient.validateHelmChart( + TEST_ECR_PUBLIC_HELM_CHART_DIGEST_NOT_FOUND.toString(), + MOCK_ECR_PUBLIC_REGISTRY_CRED)); + } + + @Test + void validateCredential_ValidHostnameAndCredentials_Success() { + String hostname = "public.ecr.aws"; + assertDoesNotThrow(() -> + ecrPublicArtifactRegistryClient.validateCredential(hostname, + MOCK_ECR_PUBLIC_REGISTRY_CRED)); + } + + @Test + void validateCredential_InvalidCredentials_Fail() { + String hostname = "public.ecr.aws"; + assertThrows(BadRequestException.class, () -> + ecrPublicArtifactRegistryClient.validateCredential(hostname, + MOCK_INVALID_ECR_PUBLIC_REGISTRY_CRED)); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/ecr/pub/EcrPublicContainerRegistryClientTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/ecr/pub/EcrPublicContainerRegistryClientTest.java new file mode 100644 index 0000000000..c9290b4204 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/ecr/pub/EcrPublicContainerRegistryClientTest.java @@ -0,0 +1,237 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.ecr.pub; + +import com.nvidia.boot.registries.service.registry.client.WebClientUtils; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_PUBLIC_CONTAINER_IMAGE_DIGEST_NOT_FOUND; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_PUBLIC_CONTAINER_IMAGE_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_PUBLIC_CONTAINER_IMAGE_TAG_NOT_FOUND; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_PUBLIC_CONTAINER_IMAGE_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_PUBLIC_CONTAINER_IMAGE_WITH_TAG; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrRegistryUtils.parsePublicArtifactUrl; +import static com.nvidia.boot.registries.service.registry.client.ecr.pub.EcrPublicContainerRegistryClient.ECR_PUBLIC_CONTAINER_IMAGE_URL_PATTERN; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ECR_PUBLIC_REGISTRY_API_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ECR_PUBLIC_REGISTRY_CLIENT_CALL_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ECR_PUBLIC_REGISTRY_CRED; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_INVALID_ECR_PUBLIC_REGISTRY_CRED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.mock.ecr.MockEcrPublicRegistryServer; +import com.nvidia.boot.registries.service.registry.client.ecr.dto.EcrArtifactComponents; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.web.reactive.function.client.WebClient; + +class EcrPublicContainerRegistryClientTest { + + private static EcrPublicContainerRegistryClient ecrPublicContainerRegistryClient; + + // Test ECR Public URLs for integration testing + private static final String TEST_ECR_PUBLIC_CONTAINER_IMAGE_WITH_TAG_1 = + "public.ecr.aws/test-alias/test-repo:v1.0.0"; + private static final String TEST_ECR_PUBLIC_CONTAINER_IMAGE_WITH_DIGEST_1 = + "public.ecr.aws/test-alias/test-repo@sha256:abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab"; + private static final String TEST_ECR_PUBLIC_CONTAINER_IMAGE_WITHOUT_TAG_1 = + "public.ecr.aws/test-alias/test-repo"; + private static final String TEST_ECR_PUBLIC_CONTAINER_IMAGE_MULTI_NAMESPACE_1 = + "public.ecr.aws/test-alias/namespace/sub-namespace/test-repo:latest"; + + @BeforeAll + static void beforeAll() { + ecrPublicContainerRegistryClient = new EcrPublicContainerRegistryClient( + WebClientUtils.builder(), + MOCK_ECR_PUBLIC_REGISTRY_API_URL, + MOCK_ECR_PUBLIC_REGISTRY_CLIENT_CALL_TIMEOUT); + MockEcrPublicRegistryServer.start(MOCK_ECR_PUBLIC_REGISTRY_API_URL); + } + + @AfterAll + static void cleanup() { + MockEcrPublicRegistryServer.stop(); + } + + static Stream createValidImageUrls() { + return Stream.of( + Arguments.of(TEST_ECR_PUBLIC_CONTAINER_IMAGE_WITH_TAG_1, + "test-alias", "test-repo", "v1.0.0", null), + Arguments.of(TEST_ECR_PUBLIC_CONTAINER_IMAGE_WITH_DIGEST_1, + "test-alias", "test-repo", null, + "sha256:abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab"), + Arguments.of(TEST_ECR_PUBLIC_CONTAINER_IMAGE_WITHOUT_TAG_1, + "test-alias", "test-repo", "latest", null), + Arguments.of(TEST_ECR_PUBLIC_CONTAINER_IMAGE_MULTI_NAMESPACE_1, + "test-alias", "namespace/sub-namespace/test-repo", "latest", null) + ); + } + + @ParameterizedTest + @MethodSource("createValidImageUrls") + void parseContainerImageUrl_WithValidFormat_Success(String imageUrl, + String expectedRegistryAlias, + String expectedRepository, + String expectedTag, + String expectedDigest) { + + EcrArtifactComponents components = parsePublicArtifactUrl(imageUrl, + ECR_PUBLIC_CONTAINER_IMAGE_URL_PATTERN); + + assertNotNull(components); + assertEquals(expectedRepository, components.repositoryName()); + assertEquals(expectedTag, components.tag()); + assertEquals(expectedDigest, components.digest()); + assertThat(components.registryId()).isNull(); + assertThat(components.region()).isNull(); + } + + @ParameterizedTest + @ValueSource(strings = { + "", // empty string + " ", // whitespace only + "invalid-url", // not ECR Public format + "public.ecr.com/alias/repo:tag", // wrong domain + "public.ecr.aws//repo:tag", // empty registry alias + "public.ecr.aws/alias/:tag", // empty repository + "public.ecr.aws/alias/repo@invalid-digest", // invalid digest format + "123456789012.dkr.ecr.us-west-2.amazonaws.com/repo:tag", // ECR private format + "docker.io/library/nginx:latest", // Docker Hub format + "public.ecr.aws/alias-with_invalid_chars!/repo:tag", // invalid registry alias + "public.ecr.aws/alias/repo:tag:extra", // multiple colons + }) + void parseContainerImageUrl_WithInvalidFormats_Fail(String invalidUrl) { + assertThrows(BadRequestException.class, + () -> parsePublicArtifactUrl(invalidUrl, + ECR_PUBLIC_CONTAINER_IMAGE_URL_PATTERN)); + } + + @ParameterizedTest + @ValueSource(strings = { + "simple-repo", + "namespace/repo", + "deep/namespace/structure/repo", + "repo-with-dashes", + "repo_with_underscores", + "repo.with.dots", + "123numeric-repo" + }) + void parseContainerImageUrl_VariousRepositoryFormats_Success(String repositoryName) { + String imageUrl = String.format("public.ecr.aws/test-alias/%s:latest", repositoryName); + assertDoesNotThrow( + () -> parsePublicArtifactUrl(imageUrl, ECR_PUBLIC_CONTAINER_IMAGE_URL_PATTERN)); + } + + @ParameterizedTest + @ValueSource(strings = { + "test-alias", + "simple", + "alias-with-dashes", + "alias_with_underscores", + "alias.with.dots", + "123numeric-alias", + "a", // minimum length + "very-long-alias-name-that-is-still-valid" + }) + void parseContainerImageUrl_VariousRegistryAliases_Success(String registryAlias) { + String imageUrl = String.format("public.ecr.aws/%s/test-repo:latest", registryAlias); + assertDoesNotThrow( + () -> parsePublicArtifactUrl(imageUrl, ECR_PUBLIC_CONTAINER_IMAGE_URL_PATTERN)); + } + + @ParameterizedTest + @ValueSource(strings = { + "latest", + "v1.0.0", + "1.2.3", + "dev-branch", + "feature_branch", + "release-2023-12-01", + "SHA-abc123def" + }) + void parseContainerImageUrl_VariousTagFormats_Success(String tag) { + String imageUrl = String.format("public.ecr.aws/test-alias/test-repo:%s", tag); + assertDoesNotThrow( + () -> parsePublicArtifactUrl(imageUrl, ECR_PUBLIC_CONTAINER_IMAGE_URL_PATTERN)); + } + + static Stream createValidContainerImages() { + return Stream.of( + Arguments.of(TEST_ECR_PUBLIC_CONTAINER_IMAGE_WITH_TAG.toString()), + Arguments.of(TEST_ECR_PUBLIC_CONTAINER_IMAGE_WITH_DIGEST.toString()) + ); + } + + @ParameterizedTest + @MethodSource("createValidContainerImages") + void validateContainerImage_Success(String containerImageUrl) { + assertDoesNotThrow( + () -> ecrPublicContainerRegistryClient + .validateContainerImage(containerImageUrl, + MOCK_ECR_PUBLIC_REGISTRY_CRED)); + } + + @Test + void validateContainerImage_PermissionDenied_Fail() { + assertThrows(ForbiddenException.class, () -> + ecrPublicContainerRegistryClient.validateContainerImage( + TEST_ECR_PUBLIC_CONTAINER_IMAGE_PERMISSION_DENIED.toString(), + MOCK_ECR_PUBLIC_REGISTRY_CRED)); + } + + @Test + void validateContainerImage_NotFound_Fail() { + assertThrows(BadRequestException.class, () -> + ecrPublicContainerRegistryClient.validateContainerImage( + TEST_ECR_PUBLIC_CONTAINER_IMAGE_TAG_NOT_FOUND.toString(), + MOCK_ECR_PUBLIC_REGISTRY_CRED)); + } + + @Test + void validateContainerImage_DigestNotFound_Fail() { + assertThrows(BadRequestException.class, () -> + ecrPublicContainerRegistryClient.validateContainerImage( + TEST_ECR_PUBLIC_CONTAINER_IMAGE_DIGEST_NOT_FOUND.toString(), + MOCK_ECR_PUBLIC_REGISTRY_CRED)); + } + + @Test + void validateCredential_ValidHostnameAndCredentials_Success() { + String hostname = "public.ecr.aws"; + assertDoesNotThrow(() -> + ecrPublicContainerRegistryClient.validateCredential(hostname, + MOCK_ECR_PUBLIC_REGISTRY_CRED)); + } + + @Test + void validateCredential_InvalidCredentials_Fail() { + String hostname = "public.ecr.aws"; + assertThrows(BadRequestException.class, () -> + ecrPublicContainerRegistryClient.validateCredential(hostname, + MOCK_INVALID_ECR_PUBLIC_REGISTRY_CRED)); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/ecr/pvt/EcrPrivateArtifactRegistryClientTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/ecr/pvt/EcrPrivateArtifactRegistryClientTest.java new file mode 100644 index 0000000000..e65c70bdfb --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/ecr/pvt/EcrPrivateArtifactRegistryClientTest.java @@ -0,0 +1,291 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.ecr.pvt; + +import com.nvidia.boot.registries.service.registry.client.WebClientUtils; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_HELM_CHART_DIGEST_NOT_FOUND; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_HELM_CHART_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_HELM_CHART_TAG_NOT_FOUND; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_HELM_CHART_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_HELM_CHART_WITH_TAG; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrRegistryUtils.parsePrivateArtifactUrl; +import static com.nvidia.boot.registries.service.registry.client.ecr.pvt.EcrPrivateArtifactRegistryClient.ECR_HELM_CHART_URL_PATTERN; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ECR_REGISTRY_API_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ECR_REGISTRY_CLIENT_CALL_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ECR_REGISTRY_CRED; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_INVALID_ECR_REGISTRY_CRED; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.mock.ecr.MockEcrPrivateRegistryServer; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.web.reactive.function.client.WebClient; + +class EcrPrivateArtifactRegistryClientTest { + + private static EcrPrivateArtifactRegistryClient ecrPrivateArtifactRegistryClient; + + // Test ECR Helm chart URLs for integration testing + private static final String TEST_ECR_HELM_CHART_WITH_TAG_1 = + "oci://123456789012.dkr.ecr.us-west-2.amazonaws.com/helm-charts/my-chart:v1.0.0"; + private static final String TEST_ECR_HELM_CHART_WITH_DIGEST_1 = + "oci://123456789012.dkr.ecr.us-west-2.amazonaws.com/helm-charts/my-chart@sha256:abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab"; + private static final String TEST_ECR_HELM_CHART_WITHOUT_TAG_1 = + "oci://123456789012.dkr.ecr.us-west-2.amazonaws.com/helm-charts/my-chart"; + private static final String TEST_ECR_HELM_CHART_MULTI_NAMESPACE_1 = + "oci://123456789012.dkr.ecr.us-east-1.amazonaws.com/namespace/sub-namespace/helm-charts/my-chart:latest"; + + @BeforeAll + static void beforeAll() { + ecrPrivateArtifactRegistryClient = new EcrPrivateArtifactRegistryClient( + WebClientUtils.builder(), + MOCK_ECR_REGISTRY_API_URL, + MOCK_ECR_REGISTRY_CLIENT_CALL_TIMEOUT); + MockEcrPrivateRegistryServer.start(MOCK_ECR_REGISTRY_API_URL); + } + + @AfterAll + static void cleanup() { + MockEcrPrivateRegistryServer.stop(); + } + + static Stream createValidHelmChartUrls() { + return Stream.of( + Arguments.of(TEST_ECR_HELM_CHART_WITH_TAG_1, + "123456789012", "us-west-2", "helm-charts/my-chart", "v1.0.0", null), + Arguments.of(TEST_ECR_HELM_CHART_WITH_DIGEST_1, + "123456789012", "us-west-2", "helm-charts/my-chart", null, + "sha256:abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab"), + Arguments.of(TEST_ECR_HELM_CHART_WITHOUT_TAG_1, + "123456789012", "us-west-2", "helm-charts/my-chart", "latest", null), + Arguments.of(TEST_ECR_HELM_CHART_MULTI_NAMESPACE_1, + "123456789012", "us-east-1", + "namespace/sub-namespace/helm-charts/my-chart", "latest", null) + ); + } + + @ParameterizedTest + @MethodSource("createValidHelmChartUrls") + void parseHelmChartUrl_WithValidFormat_Success(String helmChartUrl, + String expectedRegistryId, + String expectedRegion, + String expectedRepository, + String expectedTag, + String expectedDigest) { + + var components = + parsePrivateArtifactUrl(helmChartUrl, ECR_HELM_CHART_URL_PATTERN); + + assertNotNull(components); + assertEquals(expectedRegistryId, components.registryId()); + assertEquals(expectedRegion, components.region()); + assertEquals(expectedRepository, components.repositoryName()); + assertEquals(expectedTag, components.tag()); + assertEquals(expectedDigest, components.digest()); + } + + @ParameterizedTest + @ValueSource(strings = { + "", // empty string + " ", // whitespace only + "invalid-url", // not ECR OCI format + "oci://123.dkr.ecr.us-west-2.amazonaws.com/chart:tag", + // invalid registry ID (too short) + "oci://1234567890123.dkr.ecr.us-west-2.amazonaws.com/chart:tag", + // invalid registry ID (too long) + "oci://123456789012.ecr.us-west-2.amazonaws.com/chart:tag", // missing 'dkr' + "oci://123456789012.dkr.ecr..amazonaws.com/chart:tag", // empty region + "oci://123456789012.dkr.ecr.us-west-2.amazonaws.com/", // empty repository + "oci://123456789012.dkr.ecr.us-west-2.amazonaws.com/:tag", // empty repository with tag + "https://helm.example.com/charts/my-chart:latest", // not ECR URL + "123456789012.dkr.ecr.us-west-2.amazonaws.com/chart:tag", // missing 'oci://' prefix + "oci://123456789012.dkr.ecr.us-west-2.amazonaws.com/chart@invalid-digest" + // invalid digest format + }) + void parseHelmChartUrl_WithInvalidFormats_Fail(String invalidUrl) { + assertThrows(BadRequestException.class, + () -> parsePrivateArtifactUrl(invalidUrl, ECR_HELM_CHART_URL_PATTERN)); + } + + @ParameterizedTest + @ValueSource(strings = { + "simple-chart", + "namespace/chart", + "deep/namespace/structure/chart", + "chart-with-dashes", + "chart_with_underscores", + "chart.with.dots", + "123numeric-chart", + "helm-charts/my-chart", + "charts/stable/nginx" + }) + void parseHelmChartUrl_VariousRepositoryFormats_Success(String repositoryName) { + var helmChartUrl = + String.format("oci://123456789012.dkr.ecr.us-west-2.amazonaws.com/%s:latest", + repositoryName); + + assertDoesNotThrow(() -> parsePrivateArtifactUrl(helmChartUrl, ECR_HELM_CHART_URL_PATTERN)); + } + + + @ParameterizedTest + @ValueSource(strings = { + "us-east-1", + "us-west-2", + "eu-west-1", + "eu-central-1", + "ap-southeast-1", + "ap-northeast-1", + "ca-central-1", + "sa-east-1" + }) + void parseHelmChartUrl_VariousRegions_Success(String region) { + + var helmChartUrl = String.format( + "oci://123456789012.dkr.ecr.%s.amazonaws.com/helm-charts/my-chart:latest", region); + + assertDoesNotThrow(() -> parsePrivateArtifactUrl(helmChartUrl, ECR_HELM_CHART_URL_PATTERN)); + } + + @ParameterizedTest + @ValueSource(strings = { + "latest", + "v1.0.0", + "1.2.3", + "0.1.0-alpha", + "2.0.0-beta.1", + "1.0.0-rc.1", + "dev-branch", + "feature_branch", + "release-2023-12-01", + "chart-v1.2.3" + }) + void parseHelmChartUrl_VariousVersionFormats_Success(String version) { + + var helmChartUrl = String.format( + "oci://123456789012.dkr.ecr.us-west-2.amazonaws.com/helm-charts/my-chart:%s", + version); + + assertDoesNotThrow(() -> parsePrivateArtifactUrl(helmChartUrl, ECR_HELM_CHART_URL_PATTERN)); + } + + static Stream createValidHelmCharts() { + return Stream.of( + Arguments.of(TEST_ECR_HELM_CHART_WITH_TAG.toString()), + Arguments.of(TEST_ECR_HELM_CHART_WITH_DIGEST.toString()) + ); + } + + @ParameterizedTest + @MethodSource("createValidHelmCharts") + void validateHelmChart_Success(String helmChartUrl) { + assertDoesNotThrow(() -> + ecrPrivateArtifactRegistryClient.validateHelmChart(helmChartUrl, + MOCK_ECR_REGISTRY_CRED)); + } + + @Test + void validateHelmChart_PermissionDenied_Fail() { + assertThrows(ForbiddenException.class, () -> + ecrPrivateArtifactRegistryClient.validateHelmChart( + TEST_ECR_HELM_CHART_PERMISSION_DENIED.toString(), + MOCK_ECR_REGISTRY_CRED)); + } + + @Test + void validateHelmChart_NotExist_Fail() { + assertThrows(BadRequestException.class, () -> + ecrPrivateArtifactRegistryClient.validateHelmChart( + TEST_ECR_HELM_CHART_TAG_NOT_FOUND.toString(), + MOCK_ECR_REGISTRY_CRED)); + } + + @Test + void validateHelmChart_DigestNotFound_Fail() { + assertThrows(BadRequestException.class, () -> + ecrPrivateArtifactRegistryClient.validateHelmChart( + TEST_ECR_HELM_CHART_DIGEST_NOT_FOUND.toString(), + MOCK_ECR_REGISTRY_CRED)); + } + + @ParameterizedTest + @ValueSource(strings = { + "", // empty credentials + "invalid-base64", // invalid base64 + "dGVzdA==", // valid base64 but no colon (decodes to "test") + }) + void validateHelmChart_InvalidCredentials_Fail(String invalidCredentials) { + assertThrows(BadRequestException.class, () -> + ecrPrivateArtifactRegistryClient.validateHelmChart( + TEST_ECR_HELM_CHART_WITH_TAG_1, + invalidCredentials)); + } + + @Test + void validateCredential_ValidHostnameAndCredentials_Success() { + String hostname = "123456789012.dkr.ecr.us-west-2.amazonaws.com"; + assertDoesNotThrow(() -> + ecrPrivateArtifactRegistryClient.validateCredential(hostname, + MOCK_ECR_REGISTRY_CRED)); + } + + @ParameterizedTest + @ValueSource(strings = { + "123456789012.dkr.ecr.us-east-1.amazonaws.com", + "123456789012.dkr.ecr.eu-west-1.amazonaws.com", + "987654321098.dkr.ecr.ap-southeast-1.amazonaws.com" + }) + void validateCredential_VariousValidHostnames_Success(String hostname) { + assertDoesNotThrow(() -> + ecrPrivateArtifactRegistryClient.validateCredential(hostname, + MOCK_ECR_REGISTRY_CRED)); + } + + @ParameterizedTest + @ValueSource(strings = { + "invalid-hostname", + "123.dkr.ecr.us-west-2.amazonaws.com", // invalid account ID + "123456789012.ecr.us-west-2.amazonaws.com", // missing 'dkr' + "public.ecr.aws", // ECR public hostname + "" // empty hostname + }) + void validateCredential_InvalidHostname_Fail(String hostname) { + assertThrows(BadRequestException.class, () -> + ecrPrivateArtifactRegistryClient.validateCredential(hostname, + MOCK_ECR_REGISTRY_CRED)); + } + + @Test + void validateCredential_InvalidCredentials_Fail() { + String hostname = "123456789012.dkr.ecr.us-west-2.amazonaws.com"; + assertThrows(BadRequestException.class, () -> + ecrPrivateArtifactRegistryClient.validateCredential(hostname, + MOCK_INVALID_ECR_REGISTRY_CRED)); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/ecr/pvt/EcrPrivateContainerRegistryClientTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/ecr/pvt/EcrPrivateContainerRegistryClientTest.java new file mode 100644 index 0000000000..7f43e8dc41 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/ecr/pvt/EcrPrivateContainerRegistryClientTest.java @@ -0,0 +1,285 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.ecr.pvt; + +import com.nvidia.boot.registries.service.registry.client.WebClientUtils; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_CONTAINER_IMAGE_DIGEST_NOT_FOUND; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_CONTAINER_IMAGE_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_CONTAINER_IMAGE_TAG_NOT_FOUND; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_CONTAINER_IMAGE_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_CONTAINER_IMAGE_WITH_TAG; +import static com.nvidia.boot.registries.service.registry.client.ecr.EcrRegistryUtils.parsePrivateArtifactUrl; +import static com.nvidia.boot.registries.service.registry.client.ecr.pvt.EcrPrivateContainerRegistryClient.ECR_CONTAINER_IMAGE_URL_PATTERN; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ECR_REGISTRY_API_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ECR_REGISTRY_CLIENT_CALL_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ECR_REGISTRY_CRED; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_INVALID_ECR_REGISTRY_CRED; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.mock.ecr.MockEcrPrivateRegistryServer; +import com.nvidia.boot.registries.service.registry.client.ecr.dto.EcrArtifactComponents; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.web.reactive.function.client.WebClient; + +class EcrPrivateContainerRegistryClientTest { + + private static EcrPrivateContainerRegistryClient ecrPrivateContainerRegistryClient; + + // Test ECR URLs for integration testing + private static final String TEST_ECR_CONTAINER_IMAGE_WITH_TAG_1 = + "123456789012.dkr.ecr.us-west-2.amazonaws.com/test-repo:v1.0.0"; + private static final String TEST_ECR_CONTAINER_IMAGE_WITH_DIGEST_1 = + "123456789012.dkr.ecr.us-west-2.amazonaws.com/test-repo@sha256:abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab"; + private static final String TEST_ECR_CONTAINER_IMAGE_WITHOUT_TAG_1 = + "123456789012.dkr.ecr.us-west-2.amazonaws.com/test-repo"; + private static final String TEST_ECR_CONTAINER_IMAGE_MULTI_NAMESPACE_1 = + "123456789012.dkr.ecr.us-east-1.amazonaws.com/namespace/sub-namespace/test-repo:latest"; + + @BeforeAll + static void beforeAll() { + ecrPrivateContainerRegistryClient = new EcrPrivateContainerRegistryClient( + WebClientUtils.builder(), + MOCK_ECR_REGISTRY_API_URL, + MOCK_ECR_REGISTRY_CLIENT_CALL_TIMEOUT); + MockEcrPrivateRegistryServer.start(MOCK_ECR_REGISTRY_API_URL); + } + + @AfterAll + static void cleanup() { + MockEcrPrivateRegistryServer.stop(); + } + + static Stream createValidImageUrls() { + return Stream.of( + Arguments.of(TEST_ECR_CONTAINER_IMAGE_WITH_TAG_1, + "123456789012", "us-west-2", "test-repo", "v1.0.0", null), + Arguments.of(TEST_ECR_CONTAINER_IMAGE_WITH_DIGEST_1, + "123456789012", "us-west-2", "test-repo", null, + "sha256:abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab"), + Arguments.of(TEST_ECR_CONTAINER_IMAGE_WITHOUT_TAG_1, + "123456789012", "us-west-2", "test-repo", "latest", null), + Arguments.of(TEST_ECR_CONTAINER_IMAGE_MULTI_NAMESPACE_1, + "123456789012", "us-east-1", "namespace/sub-namespace/test-repo", + "latest", null) + ); + } + + @ParameterizedTest + @MethodSource("createValidImageUrls") + void parseContainerImageUrl_WithValidFormat_Success(String imageUrl, + String expectedRegistryId, + String expectedRegion, + String expectedRepository, + String expectedTag, + String expectedDigest) { + + EcrArtifactComponents components = parsePrivateArtifactUrl(imageUrl, + ECR_CONTAINER_IMAGE_URL_PATTERN); + + assertNotNull(components); + assertEquals(expectedRegistryId, components.registryId()); + assertEquals(expectedRegion, components.region()); + assertEquals(expectedRepository, components.repositoryName()); + assertEquals(expectedTag, components.tag()); + assertEquals(expectedDigest, components.digest()); + } + + @ParameterizedTest + @ValueSource(strings = { + "", // empty string + " ", // whitespace only + "invalid-url", // not ECR format + "123.dkr.ecr.us-west-2.amazonaws.com/repo:tag", // invalid registry ID (too short) + "1234567890123.dkr.ecr.us-west-2.amazonaws.com/repo:tag", + // invalid registry ID (too long) + "123456789012.ecr.us-west-2.amazonaws.com/repo:tag", // missing 'dkr' + "123456789012.dkr.ecr..amazonaws.com/repo:tag", // empty region + "123456789012.dkr.ecr.us-west-2.amazonaws.com/", // empty repository + "123456789012.dkr.ecr.us-west-2.amazonaws.com/:tag", // empty repository with tag + "docker.io/library/nginx:latest", // not ECR URL + "123456789012.dkr.ecr.us-west-2.amazonaws.com/repo@invalid-digest" + // invalid digest format + }) + void parseContainerImageUrl_WithInvalidFormats_Fail(String invalidUrl) { + assertThrows(BadRequestException.class, + () -> parsePrivateArtifactUrl(invalidUrl, ECR_CONTAINER_IMAGE_URL_PATTERN)); + } + + @ParameterizedTest + @ValueSource(strings = { + "simple-repo", + "namespace/repo", + "deep/namespace/structure/repo", + "repo-with-dashes", + "repo_with_underscores", + "repo.with.dots", + "123numeric-repo" + }) + void parseContainerImageUrl_VariousRepositoryFormats_Success(String repositoryName) { + String imageUrl = String.format("123456789012.dkr.ecr.us-west-2.amazonaws.com/%s:latest", + repositoryName); + assertDoesNotThrow( + () -> parsePrivateArtifactUrl(imageUrl, ECR_CONTAINER_IMAGE_URL_PATTERN)); + } + + @ParameterizedTest + @ValueSource(strings = { + "us-east-1", + "us-west-2", + "eu-west-1", + "eu-central-1", + "ap-southeast-1", + "ap-northeast-1", + "ca-central-1", + "sa-east-1" + }) + void parseContainerImageUrl_VariousRegions_Success(String region) { + // Given + String imageUrl = + String.format("123456789012.dkr.ecr.%s.amazonaws.com/test-repo:latest", region); + + assertDoesNotThrow( + () -> parsePrivateArtifactUrl(imageUrl, ECR_CONTAINER_IMAGE_URL_PATTERN)); + } + + @ParameterizedTest + @ValueSource(strings = { + "latest", + "v1.0.0", + "1.2.3", + "dev-branch", + "feature_branch", + "release-2023-12-01", + "SHA-abc123def" + }) + void parseContainerImageUrl_VariousTagFormats_Success(String tag) { + // Given + String imageUrl = + String.format("123456789012.dkr.ecr.us-west-2.amazonaws.com/test-repo:%s", tag); + + assertDoesNotThrow( + () -> parsePrivateArtifactUrl(imageUrl, ECR_CONTAINER_IMAGE_URL_PATTERN)); + } + + static Stream createValidContainerImages() { + return Stream.of( + Arguments.of(TEST_ECR_CONTAINER_IMAGE_WITH_TAG.toString()), + Arguments.of(TEST_ECR_CONTAINER_IMAGE_WITH_DIGEST.toString()) + ); + } + + @ParameterizedTest + @MethodSource("createValidContainerImages") + void validateContainerImage_Success(String containerImageUrl) { + assertDoesNotThrow( + () -> ecrPrivateContainerRegistryClient.validateContainerImage(containerImageUrl, + MOCK_ECR_REGISTRY_CRED)); + } + + @Test + void validateContainerImage_PermissionDenied_Fail() { + assertThrows(ForbiddenException.class, () -> + ecrPrivateContainerRegistryClient.validateContainerImage( + TEST_ECR_CONTAINER_IMAGE_PERMISSION_DENIED.toString(), + MOCK_ECR_REGISTRY_CRED)); + } + + @Test + void validateContainerImage_NotExist_Fail() { + assertThrows(BadRequestException.class, () -> + ecrPrivateContainerRegistryClient.validateContainerImage( + TEST_ECR_CONTAINER_IMAGE_TAG_NOT_FOUND.toString(), + MOCK_ECR_REGISTRY_CRED)); + } + + @Test + void validateContainerImage_DigestNotFound_Fail() { + assertThrows(BadRequestException.class, () -> + ecrPrivateContainerRegistryClient.validateContainerImage( + TEST_ECR_CONTAINER_IMAGE_DIGEST_NOT_FOUND.toString(), + MOCK_ECR_REGISTRY_CRED)); + } + + @ParameterizedTest(name = "Should reject invalid credentials: {0}") + @ValueSource(strings = { + "", // empty credentials + "invalid-base64", // invalid base64 + "dGVzdA==", // valid base64 but no colon (decodes to "test") + }) + void validateContainerImage_InvalidCredentials_Fail(String invalidCredentials) { + // When & Then + assertThrows(BadRequestException.class, () -> + ecrPrivateContainerRegistryClient.validateContainerImage( + TEST_ECR_CONTAINER_IMAGE_WITH_TAG.toString(), + invalidCredentials)); + } + + @Test + void validateCredential_ValidHostnameAndCredentials_Success() { + String hostname = "123456789012.dkr.ecr.us-west-2.amazonaws.com"; + assertDoesNotThrow(() -> + ecrPrivateContainerRegistryClient.validateCredential(hostname, + MOCK_ECR_REGISTRY_CRED)); + } + + @ParameterizedTest + @ValueSource(strings = { + "123456789012.dkr.ecr.us-east-1.amazonaws.com", + "123456789012.dkr.ecr.eu-west-1.amazonaws.com", + "987654321098.dkr.ecr.ap-southeast-1.amazonaws.com" + }) + void validateCredential_VariousValidHostnames_Success(String hostname) { + assertDoesNotThrow(() -> + ecrPrivateContainerRegistryClient.validateCredential(hostname, + MOCK_ECR_REGISTRY_CRED)); + } + + @ParameterizedTest + @ValueSource(strings = { + "invalid-hostname", + "123.dkr.ecr.us-west-2.amazonaws.com", // invalid account ID + "123456789012.ecr.us-west-2.amazonaws.com", // missing 'dkr' + "public.ecr.aws", // ECR public hostname + "" // empty hostname + }) + void validateCredential_InvalidHostname_Fail(String hostname) { + assertThrows(BadRequestException.class, () -> + ecrPrivateContainerRegistryClient.validateCredential(hostname, + MOCK_ECR_REGISTRY_CRED)); + } + + @Test + void validateCredential_InvalidCredentials_Fail() { + String hostname = "123456789012.dkr.ecr.us-west-2.amazonaws.com"; + assertThrows(BadRequestException.class, () -> + ecrPrivateContainerRegistryClient.validateCredential(hostname, + MOCK_INVALID_ECR_REGISTRY_CRED)); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/harbor/HarborRegistryAuthClientTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/harbor/HarborRegistryAuthClientTest.java new file mode 100644 index 0000000000..e5717af56b --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/harbor/HarborRegistryAuthClientTest.java @@ -0,0 +1,147 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.harbor; + +import com.nvidia.boot.registries.service.registry.client.WebClientUtils; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_HARBOR_CREDENTIALS; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_HARBOR_REGISTRY_AUTH_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_HARBOR_REGISTRY_CLIENT_CALL_TIMEOUT; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import com.nvidia.boot.mock.harbor.MockHarborAuthServer; +import com.nvidia.boot.registries.service.registry.client.oci.dto.OciArtifactComponents; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.web.reactive.function.client.WebClient; + +class HarborRegistryAuthClientTest { + + private static final String TEST_HARBOR_AUTH_BASE_URL = "https://demo.goharbor.io"; + + private static HarborRegistryAuthClient harborRegistryAuthClient; + + @BeforeAll + static void beforeAll() { + harborRegistryAuthClient = new HarborRegistryAuthClient(WebClientUtils.builder(), + MOCK_HARBOR_REGISTRY_AUTH_URL, + MOCK_HARBOR_REGISTRY_CLIENT_CALL_TIMEOUT); + + MockHarborAuthServer.start(MOCK_HARBOR_REGISTRY_AUTH_URL); + } + + @AfterAll + static void afterAll() { + MockHarborAuthServer.stop(); + } + + @Test + void constructor_WithValidHostname_Success() { + assertDoesNotThrow(() -> new HarborRegistryAuthClient(WebClientUtils.builder(), + MOCK_HARBOR_REGISTRY_AUTH_URL, + MOCK_HARBOR_REGISTRY_CLIENT_CALL_TIMEOUT)); + } + + @Test + void getAuthBaseUrl_ReturnsCorrectValue() { + var authClient = new HarborRegistryAuthClient(WebClientUtils.builder(), + TEST_HARBOR_AUTH_BASE_URL, + MOCK_HARBOR_REGISTRY_CLIENT_CALL_TIMEOUT); + assertEquals(TEST_HARBOR_AUTH_BASE_URL, authClient.getAuthBaseUrl()); + } + + @Test + void getCanonicalAuthTokenUrl_ReturnsCorrectUrl() { + var authClient = new HarborRegistryAuthClient(WebClientUtils.builder(), + "https://demo.goharbor.io", + MOCK_HARBOR_REGISTRY_CLIENT_CALL_TIMEOUT); + + var tokenUrl = authClient.getCanonicalAuthTokenUrl("demo.goharbor.io", "myrepo/myimage"); + assertEquals( + "https://demo.goharbor.io/service/token?service=harbor-registry&scope=repository:myrepo/myimage:pull", + tokenUrl); + } + + @Test + void getCanonicalAuthTokenUrl_WithEmptyName_ReturnsUrlWithoutScope() { + var authClient = new HarborRegistryAuthClient(WebClientUtils.builder(), + "https://demo.goharbor.io", + MOCK_HARBOR_REGISTRY_CLIENT_CALL_TIMEOUT); + + var tokenUrl = authClient.getCanonicalAuthTokenUrl("demo.goharbor.io", ""); + assertEquals( + "https://demo.goharbor.io/service/token?service=harbor-registry", + tokenUrl); + } + + @Test + void getCanonicalAuthTokenUrl_WithNullName_ReturnsUrlWithoutScope() { + var authClient = new HarborRegistryAuthClient(WebClientUtils.builder(), + "https://demo.goharbor.io", + MOCK_HARBOR_REGISTRY_CLIENT_CALL_TIMEOUT); + + var tokenUrl = authClient.getCanonicalAuthTokenUrl("demo.goharbor.io", null); + assertEquals( + "https://demo.goharbor.io/service/token?service=harbor-registry", + tokenUrl); + } + + // ===== INTEGRATION TESTS WITH MOCK SERVER ===== + + @Test + void fetchToken_WithValidInputs_Success() { + var components = new OciArtifactComponents( + "localhost-harbor", "test/image", "latest"); + var result = harborRegistryAuthClient.getToken(components, MOCK_HARBOR_CREDENTIALS); + + assertNotNull(result); + assertNotNull(result.token()); + assertNotNull(result.expiresIn()); + } + + @Test + void fetchToken_DifferentRepositories() { + var component1 = new OciArtifactComponents( + "localhost-harbor", "repo1/image", "v1.0.0"); + var component2 = new OciArtifactComponents( + "localhost-harbor", "repo2/image", "v2.0.0"); + + assertDoesNotThrow(() -> { + var token1 = harborRegistryAuthClient.getToken(component1, MOCK_HARBOR_CREDENTIALS); + var token2 = harborRegistryAuthClient.getToken(component2, MOCK_HARBOR_CREDENTIALS); + + assertNotNull(token1); + assertNotNull(token2); + assertNotNull(token1.token()); + assertNotNull(token2.token()); + }); + } + + @Test + void validateCredential_WithMockServer_Success() { + // Test validateCredential using the mock server + var credentials = MOCK_HARBOR_CREDENTIALS; + + var token = harborRegistryAuthClient.validateCredential("localhost-harbor", credentials); + assertNotNull(token); + assertNotNull(token.token()); + assertNotNull(token.expiresIn()); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/harbor/HarborRegistryClientTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/harbor/HarborRegistryClientTest.java new file mode 100644 index 0000000000..3c2f881d5c --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/harbor/HarborRegistryClientTest.java @@ -0,0 +1,187 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.harbor; + +import com.nvidia.boot.registries.service.registry.client.WebClientUtils; +import static com.nvidia.boot.mock.BootTestConstants.TEST_HARBOR_CONTAINER_IMAGE_NOT_EXISTS; +import static com.nvidia.boot.mock.BootTestConstants.TEST_HARBOR_CONTAINER_IMAGE_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_HARBOR_CONTAINER_IMAGE_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_HARBOR_CONTAINER_IMAGE_WITH_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_HARBOR_HELM_CHART_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_HARBOR_HELM_CHART_WITH_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_HARBOR_REGISTRY; +import static com.nvidia.boot.registries.service.registry.RegistryMapperService.normalizeUrl; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_HARBOR_CREDENTIALS; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_HARBOR_REGISTRY_AUTH_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_HARBOR_REGISTRY_CLIENT_CALL_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_HARBOR_REGISTRY_URL; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.mock.harbor.MockHarborAuthServer; +import com.nvidia.boot.mock.oci.MockOciRegistryServer; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.web.reactive.function.client.WebClient; + +class HarborRegistryClientTest { + + private static HarborRegistryClient harborRegistryClient; + private static MockOciRegistryServer mockHarborRegistryServer; + private static final String TEST_HARBOR_REGISTRY_BASE_URL = + "https://" + TEST_HARBOR_REGISTRY; + + @BeforeAll + static void beforeAll() { + mockHarborRegistryServer = new MockOciRegistryServer(); + MockHarborAuthServer.start(MOCK_HARBOR_REGISTRY_AUTH_URL); + mockHarborRegistryServer.start(MOCK_HARBOR_REGISTRY_URL); + harborRegistryClient = new HarborRegistryClient(WebClientUtils.builder(), + MOCK_HARBOR_REGISTRY_URL, + MOCK_HARBOR_REGISTRY_CLIENT_CALL_TIMEOUT, + MOCK_HARBOR_REGISTRY_AUTH_URL); + } + + @AfterAll + static void afterAll() { + MockHarborAuthServer.stop(); + mockHarborRegistryServer.stop(); + } + + @Test + void constructor_WithValidHostname_Success() { + assertDoesNotThrow(() -> new HarborRegistryClient(WebClientUtils.builder(), + TEST_HARBOR_REGISTRY, + MOCK_HARBOR_REGISTRY_CLIENT_CALL_TIMEOUT, + TEST_HARBOR_REGISTRY_BASE_URL)); + } + + @Test + void constructor_WithHttpsHostname_Success() { + assertDoesNotThrow(() -> new HarborRegistryClient(WebClientUtils.builder(), + TEST_HARBOR_REGISTRY_BASE_URL, + MOCK_HARBOR_REGISTRY_CLIENT_CALL_TIMEOUT, + TEST_HARBOR_REGISTRY_BASE_URL)); + } + + static Stream validHarborHostnames() { + return Stream.of( + Arguments.of("https://harbor.demo.com", "harbor.demo.com"), + Arguments.of("https://harbor.demo.com:443", "harbor.demo.com"), + Arguments.of("http://localhost:8080", "localhost")); + } + + @ParameterizedTest + @MethodSource("validHarborHostnames") + void constructor_ExtractsHostnameCorrectly(String inputHostname, String expectedHostname) { + var client = new HarborRegistryClient(WebClientUtils.builder(), + inputHostname, + MOCK_HARBOR_REGISTRY_CLIENT_CALL_TIMEOUT, + normalizeUrl(inputHostname)); + assertEquals(expectedHostname, client.getHostname()); + } + + @Test + void getHostname_ReturnsCorrectHostname() { + var client = new HarborRegistryClient(WebClientUtils.builder(), + TEST_HARBOR_REGISTRY, + MOCK_HARBOR_REGISTRY_CLIENT_CALL_TIMEOUT, + TEST_HARBOR_REGISTRY_BASE_URL); + assertEquals("demo.goharbor.io", client.getHostname()); + } + + @Test + void getHostname_WithHttpsUrl_ExtractsHostnameOnly() { + var client = new HarborRegistryClient(WebClientUtils.builder(), + TEST_HARBOR_REGISTRY_BASE_URL, + MOCK_HARBOR_REGISTRY_CLIENT_CALL_TIMEOUT, + TEST_HARBOR_REGISTRY_BASE_URL); + assertEquals("demo.goharbor.io", client.getHostname()); + } + + @Test + void getRegistryBaseUrl_ReturnsCorrectUrl() { + var client = new HarborRegistryClient(WebClientUtils.builder(), + TEST_HARBOR_REGISTRY, + MOCK_HARBOR_REGISTRY_CLIENT_CALL_TIMEOUT, + TEST_HARBOR_REGISTRY_BASE_URL); + + String baseUrl = client.getRegistryBaseUrl("demo.goharbor.io"); + assertEquals("https://demo.goharbor.io", baseUrl); + } + + @ParameterizedTest + @ValueSource(strings = { + "", // empty string + "invalid", // no slashes + "localhost:9110", // only registry + "localhost:9110/", // trailing slash + "https://localhost:9110/namespace/repo:tag", // wrong protocol prefix + }) + void validateArtifact_WithInvalidImageUrls_ThrowsBadRequestException(String invalidUrl) { + assertThrows(BadRequestException.class, () -> + harborRegistryClient.validateArtifact(invalidUrl, MOCK_HARBOR_CREDENTIALS)); + } + + @Test + void validateArtifact_WithNullImageUrl_ThrowsBadRequestException() { + assertThrows(BadRequestException.class, () -> + harborRegistryClient.validateArtifact(null, MOCK_HARBOR_CREDENTIALS)); + } + + static Stream validHarborImageUrls() { + return Stream.of( + Arguments.of(TEST_HARBOR_CONTAINER_IMAGE_WITH_TAG.toString()), + Arguments.of(TEST_HARBOR_CONTAINER_IMAGE_WITH_DIGEST.toString()), + Arguments.of(TEST_HARBOR_HELM_CHART_WITH_TAG.toString()), + Arguments.of(TEST_HARBOR_HELM_CHART_WITH_DIGEST.toString())); + } + + @ParameterizedTest + @MethodSource("validHarborImageUrls") + void validateArtifact_WithParameterizedValidUrls_Success(String imageUrl) { + assertDoesNotThrow(() -> harborRegistryClient.validateArtifact(imageUrl, + MOCK_HARBOR_CREDENTIALS)); + } + + @Test + void validateArtifact_WithPermissionDeniedImage_ThrowsForbiddenException() { + assertThrows(ForbiddenException.class, () -> + harborRegistryClient.validateArtifact( + TEST_HARBOR_CONTAINER_IMAGE_PERMISSION_DENIED.toString(), + MOCK_HARBOR_CREDENTIALS)); + } + + @Test + void validateArtifact_WithNonExistentImage_ThrowsNotFoundException() { + assertThrows(NotFoundException.class, () -> + harborRegistryClient.validateArtifact( + TEST_HARBOR_CONTAINER_IMAGE_NOT_EXISTS.toString(), + MOCK_HARBOR_CREDENTIALS)); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcArtifactRegistryClientTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcArtifactRegistryClientTest.java new file mode 100644 index 0000000000..7f84e392ea --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcArtifactRegistryClientTest.java @@ -0,0 +1,274 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.ngc; + +import com.nvidia.boot.registries.service.registry.client.WebClientUtils; +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.stubbing.Scenario.STARTED; +import static com.nvidia.boot.mock.ngc.MockCasServer.MODEL_FILES_NOT_EXIST_URL; +import static com.nvidia.boot.mock.ngc.MockCasServer.MODEL_FILES_PAGINATED_429_URL; +import static com.nvidia.boot.mock.ngc.MockCasServer.MODEL_FILES_RATE_LIMITED_URL; +import static com.nvidia.boot.mock.ngc.MockCasServer.MODEL_FILES_URL; +import static com.nvidia.boot.mock.ngc.MockCasServer.MODEL_FILE_PERMISSION_DENIED_URL; +import static com.nvidia.boot.mock.ngc.MockCasServer.RESOURCE_FILES_NOT_EXISTS_URL; +import static com.nvidia.boot.mock.ngc.MockCasServer.RESOURCE_FILES_URL; +import static com.nvidia.boot.mock.ngc.MockCasServer.RESOURCE_FILE_PERMISSION_DENIED_URL_WITH_TEAM; +import static com.nvidia.boot.registries.util.TestConstants.BASE_NGC_ARTIFACT_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_API_KEY; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_BASE_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_CLIENT_CALL_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_CLIENT_CONNECT_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_CLIENT_READ_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_CLIENT_WRITE_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_OAUTH2_BASE_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_OAUTH2_GROUP_SCOPE; +import static com.nvidia.boot.registries.util.TestConstants.TEST_HELM_CHART_NOT_EXISTS; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_HELM_CHART; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_HELM_CHART_PERMISSION_DENIED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.springframework.http.HttpHeaders.CONTENT_TYPE; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.exceptions.TooManyRequestsException; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.boot.registries.service.registry.dto.ArtifactFile; +import com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.WebClient; + +class NgcArtifactRegistryClientTest { + private static NgcArtifactRegistryClient ngcArtifactRegistryClient; + + @BeforeAll + static void beforeAll() { + ngcArtifactRegistryClient = new NgcArtifactRegistryClient( + WebClientUtils.builder(), + MOCK_NGC_REGISTRY_BASE_URL, + MOCK_NGC_REGISTRY_CLIENT_CALL_TIMEOUT, + MOCK_NGC_REGISTRY_CLIENT_READ_TIMEOUT, + MOCK_NGC_REGISTRY_CLIENT_WRITE_TIMEOUT, + MOCK_NGC_REGISTRY_CLIENT_CONNECT_TIMEOUT, + MOCK_NGC_REGISTRY_OAUTH2_BASE_URL, + MOCK_NGC_REGISTRY_OAUTH2_GROUP_SCOPE + ); + MockCasServer.start(MOCK_NGC_REGISTRY_OAUTH2_BASE_URL, MOCK_NGC_REGISTRY_BASE_URL); + } + + @AfterAll + static void cleanup() { + MockCasServer.stop(); + } + + @AfterEach + void resetScenarios() { + MockCasServer.resetScenarios(); + } + + @Test + void fetchModelSize_Success() { + var url = MOCK_NGC_REGISTRY_BASE_URL + MODEL_FILES_URL; + var modelSize = ngcArtifactRegistryClient.fetchModelSize(url, MOCK_NGC_API_KEY); + assertEquals(46484790292L, modelSize); + } + + @Test + void fetchModelSize_NotExist_Fail() { + var url = MOCK_NGC_REGISTRY_BASE_URL + MODEL_FILES_NOT_EXIST_URL; + assertThrows(NotFoundException.class, + () -> ngcArtifactRegistryClient.fetchModelSize(url, MOCK_NGC_API_KEY)); + } + + @Test + void fetchModelSize_PermissionDenied_Fail() { + var url = MOCK_NGC_REGISTRY_BASE_URL + MODEL_FILE_PERMISSION_DENIED_URL; + assertThrows(ForbiddenException.class, + () -> ngcArtifactRegistryClient.fetchModelSize(url, MOCK_NGC_API_KEY)); + } + + @Test + void fetchResourceSize_Success() { + var url = MOCK_NGC_REGISTRY_BASE_URL + RESOURCE_FILES_URL; + var resourceSize = ngcArtifactRegistryClient.fetchResourceSize(url, MOCK_NGC_API_KEY); + assertEquals(69753543449L, resourceSize); + } + + @Test + void fetchResourceSize_NotExist_Fail() { + var url = MOCK_NGC_REGISTRY_BASE_URL + RESOURCE_FILES_NOT_EXISTS_URL; + assertThrows(NotFoundException.class, + () -> ngcArtifactRegistryClient.fetchResourceSize(url, MOCK_NGC_API_KEY)); + } + + @Test + void fetchResourceSize_PermissionDenied_Fail() { + var url = MOCK_NGC_REGISTRY_BASE_URL + RESOURCE_FILE_PERMISSION_DENIED_URL_WITH_TEAM; + assertThrows(ForbiddenException.class, + () -> ngcArtifactRegistryClient.fetchResourceSize(url, MOCK_NGC_API_KEY)); + } + + @Test + void getPreSignedArtifactURLs_Success() { + var url = MOCK_NGC_REGISTRY_BASE_URL + MODEL_FILES_URL; + var artifactFiles = + ngcArtifactRegistryClient.getPreSignedArtifactURLs(url, MOCK_NGC_API_KEY); + assertThat(artifactFiles).hasSize(2); + assertThat(artifactFiles).contains( + new ArtifactFile("/file1", "https://api.stg.ngc.nvidia.com/file1")); + assertThat(artifactFiles).contains( + new ArtifactFile("/file2", "https://api.stg.ngc.nvidia.com/file2")); + } + + @Test + void validateArtifact_Success() { + var url = BASE_NGC_ARTIFACT_URL + MODEL_FILES_URL; + ngcArtifactRegistryClient.validateArtifact(url, MOCK_NGC_API_KEY, ArtifactTypeEnum.MODEL); + } + + @Test + void validateArtifact_NotExist_Fail() { + var url = BASE_NGC_ARTIFACT_URL + MODEL_FILES_NOT_EXIST_URL; + assertThrows(NotFoundException.class, + () -> ngcArtifactRegistryClient.validateArtifact(url, MOCK_NGC_API_KEY, + ArtifactTypeEnum.MODEL)); + } + + @Test + void validateArtifact_PermissionDenied_Fail() { + var url = BASE_NGC_ARTIFACT_URL + MODEL_FILE_PERMISSION_DENIED_URL; + assertThrows(ForbiddenException.class, + () -> ngcArtifactRegistryClient.validateArtifact(url, MOCK_NGC_API_KEY, + ArtifactTypeEnum.MODEL)); + } + + @Test + void validateHelmChart_Success() { + var url = TEST_NGC_HELM_CHART.toString(); + ngcArtifactRegistryClient.validateHelmChart(url, MOCK_NGC_API_KEY); + } + + @Test + void validateHelmChart_NotExistsChart_Fail() { + var url = TEST_HELM_CHART_NOT_EXISTS.toString(); + assertThrows(NotFoundException.class, + () -> ngcArtifactRegistryClient.validateHelmChart(url, MOCK_NGC_API_KEY)); + } + + @Test + void validateHelmChart_PermissionDenied_Fail() { + var url = TEST_NGC_HELM_CHART_PERMISSION_DENIED.toString(); + assertThrows(ForbiddenException.class, + () -> ngcArtifactRegistryClient.validateHelmChart(url, MOCK_NGC_API_KEY)); + } + + @Test + void getPreSignedArtifactURLs_RetryOn429_Success() { + // Simulate NGC returning 429 on the first attempt, then 200 on retry + MockCasServer.stubFor(get(urlPathEqualTo(MODEL_FILES_URL)) + .inScenario("model-files-429-retry") + .whenScenarioStateIs(STARTED) + .willReturn(aResponse().withStatus(429)) + .willSetStateTo("retry-success")); + MockCasServer.stubFor(get(urlPathEqualTo(MODEL_FILES_URL)) + .inScenario("model-files-429-retry") + .whenScenarioStateIs("retry-success") + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(MockCasServer.getModelFileResponse()))); + + var url = MOCK_NGC_REGISTRY_BASE_URL + MODEL_FILES_URL; + var artifactFiles = ngcArtifactRegistryClient.getPreSignedArtifactURLs(url, MOCK_NGC_API_KEY); + assertThat(artifactFiles).hasSize(2); + assertThat(artifactFiles).contains(new ArtifactFile("/file1", + "https://api.stg.ngc.nvidia.com/file1")); + } + + @Test + void getPreSignedArtifactURLs_ExhaustedRetries429_Fail() { + // MODEL_FILES_RATE_LIMITED_URL always returns 429 — retries should be exhausted + var url = MOCK_NGC_REGISTRY_BASE_URL + MODEL_FILES_RATE_LIMITED_URL; + assertThrows(TooManyRequestsException.class, + () -> ngcArtifactRegistryClient.getPreSignedArtifactURLs(url, MOCK_NGC_API_KEY)); + } + + @Test + void getPreSignedArtifactURLs_Paginated_RetryOn429_Success() { + // First page is already stubbed in MockCasServer.start() with totalPages=2. + // Stub the second page (page-number=1) to return 429 once, then 200. + MockCasServer.stubFor(get(urlPathEqualTo(MODEL_FILES_PAGINATED_429_URL)) + .withQueryParam("page-number", equalTo("1")) + .inScenario("paginated-429-retry") + .whenScenarioStateIs(STARTED) + .willReturn(aResponse().withStatus(429)) + .willSetStateTo("page2-retry-success")); + MockCasServer.stubFor(get(urlPathEqualTo(MODEL_FILES_PAGINATED_429_URL)) + .withQueryParam("page-number", equalTo("1")) + .inScenario("paginated-429-retry") + .whenScenarioStateIs("page2-retry-success") + .willReturn(aResponse().withStatus(200) + .withHeader(CONTENT_TYPE, + APPLICATION_JSON_VALUE) + .withBody(MockCasServer.getModelFilesSecondPageResponse()))); + + var url = MOCK_NGC_REGISTRY_BASE_URL + MODEL_FILES_PAGINATED_429_URL; + var artifactFiles = ngcArtifactRegistryClient.getPreSignedArtifactURLs(url, MOCK_NGC_API_KEY); + assertThat(artifactFiles).hasSize(4); + assertThat(artifactFiles).contains(new ArtifactFile("/file1", + "https://api.stg.ngc.nvidia.com/file1")); + assertThat(artifactFiles).contains(new ArtifactFile("/file3", + "https://api.stg.ngc.nvidia.com/file3")); + } + + @Test + void validateCredential_WithLegacyAPIKey_Success() { + // Legacy API keys (not starting with "nvapi-") should go through AuthN endpoint + // Format: base64($oauthtoken:api-key) + String registryHost = "helm.stg.ngc.nvidia.com"; + String apiKey = "legacy-api-key-test-1"; + + String token = ngcArtifactRegistryClient.validateCredential(registryHost, apiKey); + + assertThat(token).isNotNull(); + assertThat(token).isEqualTo("token"); + } + + @Test + void validateCredential_WithAPIKey_Success() { + // API keys starting with "nvapi-" should skip AuthN + // Format: base64($oauthtoken:nvapi-xxx) + String registryHost = "helm.stg.ngc.nvidia.com"; + String apiKey = "nvapi-helm-registry-key-test-1"; + + String token = ngcArtifactRegistryClient.validateCredential(registryHost, apiKey); + + assertThat(token).isNotNull(); + assertThat(token).isEqualTo(apiKey); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcArtifactUriValidatorTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcArtifactUriValidatorTest.java new file mode 100644 index 0000000000..558a058f01 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcArtifactUriValidatorTest.java @@ -0,0 +1,174 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.ngc; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +class NgcArtifactUriValidatorTest { + + // ==================== Valid Model Strings ==================== + + static Stream validModelUris() { + return Stream.of( + // Production URLs + "https://api.ngc.nvidia.com/v2/org/whw3rcpsilnj/models/playground_llama2_trt_l40g/0.1/files", + "https://api.ngc.nvidia.com/v2/org/whw3rcpsilnj/team/jeff/models/playground_llama2_trt_l40g/0.1/files", + // Staging URLs + "https://api.stg.ngc.nvidia.com/v2/org/whw3rcpsilnj/models/playground_llama2_trt_l40g/0.1/files", + "https://api.stg.ngc.nvidia.com/v2/org/whw3rcpsilnj/team/jeff/models/playground_llama2_trt_l40g/0.1/files", + // Canary URLs + "https://api.canary.ngc.nvidia.com/v2/org/whw3rcpsilnj/models/playground_llama2_trt_l40g/0.1/files", + "https://api.canary.ngc.nvidia.com/v2/org/whw3rcpsilnj/team/jeff/models/playground_llama2_trt_l40g/0.1/files", + // Various org/team name formats + "https://api.ngc.nvidia.com/v2/org/org-with-dash/models/model-name/v1.2.3/files", + "https://api.ngc.nvidia.com/v2/org/org_underscore/team/team_name/models/my_model/1.0/files"); + } + + // ==================== Invalid Model Strings ==================== + + static Stream invalidModelUris() { + return Stream.of( + // Invalid path format + "https://api.stg.ngc.nvidia.com/some-file-location/files", + "https://api.stg.ngc.nvidia.com/v2/org/whw3rcpsilnj/team//models/playground_llama2_trt_l40g/0.1/files", + "https://api.stg.ngc.nvidia.com/v3/org/whw3rcpsilnj/models/playground_llama2_trt_l40g/0.1/files", + // String contains "versions" (extra info path) + "https://api.stg.ngc.nvidia.com/v2/org/whw3rcpsilnj/models/playground_llama2_trt_l40g/versions/0.1/files", + "https://api.stg.ngc.nvidia.com/v2/org/whw3rcpsilnj/team/jeff/models/playground_llama2_trt_l40g/versions/0.1/files", + "https://api.ngc.nvidia.com/v2/org/whw3rcpsilnj/models/playground_llama2_trt_l40g/versions/0.1/files", + "https://api.canary.ngc.nvidia.com/v2/org/whw3rcpsilnj/models/playground_llama2_trt_l40g/versions/0.1/files", + // Missing or invalid protocol prefix + "api.ngc.nvidia.com/v2/org/whw3rcpsilnj/team/jeff/models/playground_llama2_trt_l40g/0.1/files", + "api.stg.ngc.nvidia.com/v2/org/whw3rcpsilnj/team/jeff/models/playground_llama2_trt_l40g/0.1/files", + // Invalid subdomain + "https://api.abc.ngc.nvidia.com/v2/org/whw3rcpsilnj/models/playground_llama2_trt_l40g/0.1/files", + // Non-fully qualified URLs (path only) - not supported + "/v2/org/whw3rcpsilnj/models/playground_llama2_trt_l40g/0.1/files", + "/v2/org/whw3rcpsilnj/team/jeff/models/playground_llama2_trt_l40g/0.1/files", + "/v2/org/whw3rcpsilnj/team//models/playground_llama2_trt_l40g/0.1/files", + "/v3/org/whw3rcpsilnj/models/playground_llama2_trt_l40g/0.1/files", + "/v2/org/whw3rcpsilnj/models/playground_llama2_trt_l40g/versions/0.1/files", + "/v2/org/whw3rcpsilnj/team/jeff/models/playground_llama2_trt_l40g/versions/0.1/files", + "/some-file-location/files", + "https://api.ngc.nvidia.com/v2/org/org_underscore/team/team_name/models/my_model/1.0/files?anyQueryParams=123"); + } + + // ==================== Valid Resource Strings ==================== + + static Stream validResourceUris() { + return Stream.of( + // Production URLs + "https://api.ngc.nvidia.com/v2/org/whw3rcpsilnj/resources/my_resource/0.1/files", + "https://api.ngc.nvidia.com/v2/org/whw3rcpsilnj/team/jeff/resources/my_resource/0.1/files", + // Staging URLs + "https://api.stg.ngc.nvidia.com/v2/org/whw3rcpsilnj/resources/my_resource/0.1/files", + "https://api.stg.ngc.nvidia.com/v2/org/whw3rcpsilnj/team/jeff/resources/my_resource/0.1/files", + // Canary URLs + "https://api.canary.ngc.nvidia.com/v2/org/whw3rcpsilnj/resources/my_resource/0.1/files", + "https://api.canary.ngc.nvidia.com/v2/org/whw3rcpsilnj/team/jeff/resources/my_resource/0.1/files"); + } + + // ==================== Invalid Resource Strings ==================== + + static Stream invalidResourceUris() { + return Stream.of( + // Invalid path format + "https://api.stg.ngc.nvidia.com/some-file-location/files", + "https://api.stg.ngc.nvidia.com/v2/org/whw3rcpsilnj/team//resources/my_resource/0.1/files", + "https://api.stg.ngc.nvidia.com/v3/org/whw3rcpsilnj/resources/my_resource/0.1/files", + // String contains "versions" (extra info path) + "https://api.stg.ngc.nvidia.com/v2/org/whw3rcpsilnj/resources/my_resource/versions/0.1/files", + "https://api.stg.ngc.nvidia.com/v2/org/whw3rcpsilnj/team/jeff/resources/my_resource/versions/0.1/files", + "https://api.ngc.nvidia.com/v2/org/whw3rcpsilnj/resources/my_resource/versions/0.1/files", + "https://api.canary.ngc.nvidia.com/v2/org/whw3rcpsilnj/resources/my_resource/versions/0.1/files", + // Missing or invalid protocol prefix + "api.ngc.nvidia.com/v2/org/whw3rcpsilnj/team/jeff/resources/my_resource/0.1/files", + "api.stg.ngc.nvidia.com/v2/org/whw3rcpsilnj/team/jeff/resources/my_resource/0.1/files", + // Invalid subdomain + "https://api.abc.ngc.nvidia.com/v2/org/whw3rcpsilnj/resources/my_resource/0.1/files", + // Non-fully qualified URLs (path only) - not supported + "/v2/org/whw3rcpsilnj/resources/my_resource/0.1/files", + "/v2/org/whw3rcpsilnj/team/jeff/resources/my_resource/0.1/files", + "/v2/org/whw3rcpsilnj/team//resources/my_resource/0.1/files", + "/v3/org/whw3rcpsilnj/resources/my_resource/0.1/files", + "/v2/org/whw3rcpsilnj/resources/my_resource/versions/0.1/files", + "/v2/org/whw3rcpsilnj/team/jeff/resources/my_resource/versions/0.1/files", + "/some-file-location/files"); + } + + // ==================== Single String Validation Tests ==================== + + @ParameterizedTest + @MethodSource("validModelUris") + void isValid_shouldAcceptValidModelUri(String uri) { + NgcArtifactUriValidator.validate(uri, ArtifactTypeEnum.MODEL); + } + + @ParameterizedTest + @MethodSource("invalidModelUris") + void isValid_shouldRejectInvalidModelUri(String uri) { + assertThrows(BadRequestException.class, + () -> NgcArtifactUriValidator.validate(uri, ArtifactTypeEnum.MODEL)); + } + + @ParameterizedTest + @MethodSource("validResourceUris") + void isValid_shouldAcceptValidResourceUri(String uri) { + NgcArtifactUriValidator.validate(uri, ArtifactTypeEnum.RESOURCE); + } + + @ParameterizedTest + @MethodSource("invalidResourceUris") + void isValid_shouldRejectInvalidResourceUri(String uri) { + assertThrows(BadRequestException.class, + () -> NgcArtifactUriValidator.validate(uri, ArtifactTypeEnum.RESOURCE)); + } + + @Test + void isValid_shouldRejectNullUri() { + assertThrows(BadRequestException.class, + () -> NgcArtifactUriValidator.validate(null, ArtifactTypeEnum.MODEL)); + assertThrows(BadRequestException.class, + () -> NgcArtifactUriValidator.validate(null, + ArtifactTypeEnum.RESOURCE)); + } + + + // ==================== Unsupported Artifact Type Tests ==================== + + @Test + void isValid_shouldThrowExceptionForContainerType() { + var uri = "https://api.ngc.nvidia.com/v2/org/myorg/models/mymodel/1.0/files"; + assertThrows(IllegalArgumentException.class, + () -> NgcArtifactUriValidator.validate(uri, ArtifactTypeEnum.CONTAINER)); + } + + @Test + void isValid_shouldThrowExceptionForHelmType() { + var uri = "https://api.ngc.nvidia.com/v2/org/myorg/models/mymodel/1.0/files"; + assertThrows(IllegalArgumentException.class, + () -> NgcArtifactUriValidator.validate(uri, ArtifactTypeEnum.HELM)); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcContainerRegistryClientTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcContainerRegistryClientTest.java new file mode 100644 index 0000000000..7487ed8793 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/ngc/NgcContainerRegistryClientTest.java @@ -0,0 +1,278 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.ngc; + +import com.nvidia.boot.registries.service.registry.client.WebClientUtils; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_CONTAINER_HASH; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_CONTAINER_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_CONTAINER_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_ORG_NAME; +import static com.nvidia.boot.registries.service.registry.client.ngc.NgcContainerRegistryClient.parseContainerImageUrl; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_CONTAINER_REGISTRY_CRED; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_CONTAINER_REGISTRY_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_CLIENT_CALL_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_CLIENT_CONNECT_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_CLIENT_READ_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_CLIENT_WRITE_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_CONTAINER_IMAGE; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_CONTAINER_IMAGE_2; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_CONTAINER_IMAGE_NOT_EXISTS; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_CONTAINER_IMAGE_PERMISSION_DENIED; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_CONTAINER_IMAGE_UNKNOWN_ORG; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_CONTAINER_IMAGE_WITH_DIGEST; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_CONTAINER_IMAGE_WITH_INVALID_TAG; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_CONTAINER_REGISTRY; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.mock.ngc.MockNgcContainerRegistryServer; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.web.reactive.function.client.WebClient; + +class NgcContainerRegistryClientTest { + private static NgcContainerRegistryClient ngcContainerRegistryClient; + + @BeforeAll + static void beforeAll() { + ngcContainerRegistryClient = new NgcContainerRegistryClient( + WebClientUtils.builder(), + MOCK_NGC_CONTAINER_REGISTRY_URL, + MOCK_NGC_REGISTRY_CLIENT_CALL_TIMEOUT, + MOCK_NGC_REGISTRY_CLIENT_READ_TIMEOUT, + MOCK_NGC_REGISTRY_CLIENT_WRITE_TIMEOUT, + MOCK_NGC_REGISTRY_CLIENT_CONNECT_TIMEOUT + ); + MockNgcContainerRegistryServer.start(MOCK_NGC_CONTAINER_REGISTRY_URL); + } + + @AfterAll + static void cleanup() { + MockNgcContainerRegistryServer.stop(); + } + + @AfterEach + void reset() { + ngcContainerRegistryClient.resetAuthTokenCache(); + MockNgcContainerRegistryServer.getNgcContainerRegistryMockServer().resetRequests(); + } + + + static Stream createValidImageUrl() { + return Stream.of( + Arguments.of(TEST_NGC_CONTAINER_IMAGE.toString(), + TEST_NGC_CONTAINER_REGISTRY, TEST_VALID_ORG_NAME, + TEST_VALID_CONTAINER_NAME, TEST_VALID_CONTAINER_TAG, null), + Arguments.of(TEST_NGC_CONTAINER_IMAGE_WITH_DIGEST.toString(), + TEST_NGC_CONTAINER_REGISTRY, TEST_VALID_ORG_NAME, + TEST_VALID_CONTAINER_NAME, null, + TEST_VALID_CONTAINER_HASH), + Arguments.of("docker.io/test-container-image:latest", + "docker.io", "", TEST_VALID_CONTAINER_NAME, TEST_VALID_CONTAINER_TAG, + null)); + } + + @ParameterizedTest + @MethodSource("createValidImageUrl") + void parseContainerImageUrl_WithValidFormat_Success(String imageUrl, + String registryHost, + String repository, + String imageName, + String tag, + String digest) { + // When + NgcContainerRegistryClient.ContainerImageComponents components = + parseContainerImageUrl(imageUrl); + + // Then + assertNotNull(components); + assertEquals(registryHost, components.registryHost()); + assertEquals(repository, components.repository()); + assertEquals(imageName, components.imageName()); + assertEquals(tag, components.tag()); + assertEquals(digest, components.digest()); + } + + @MethodSource("createValidImageUrl") + @ParameterizedTest + void validateContainerImage_Success(String containerUrl) { + if (containerUrl.startsWith("stg.nvcr.io")) { + ngcContainerRegistryClient.validateContainerImage(containerUrl, + MOCK_NGC_CONTAINER_REGISTRY_CRED); + } + } + + @Test + void validateContainerImage_PermissionDenied_Fail() { + assertThrows(ForbiddenException.class, () -> { + ngcContainerRegistryClient.validateContainerImage( + TEST_NGC_CONTAINER_IMAGE_PERMISSION_DENIED.toString(), + MOCK_NGC_CONTAINER_REGISTRY_CRED); + }); + } + + @Test + void validateContainerImage_NotExist_Fail() { + assertThrows(NotFoundException.class, () -> { + ngcContainerRegistryClient.validateContainerImage( + TEST_NGC_CONTAINER_IMAGE_NOT_EXISTS.toString(), + MOCK_NGC_CONTAINER_REGISTRY_CRED); + }); + } + + @Test + void validateContainerImage_InvalidTag_Fail() { + assertThrows(BadRequestException.class, () -> { + ngcContainerRegistryClient.validateContainerImage( + TEST_NGC_CONTAINER_IMAGE_WITH_INVALID_TAG.toString(), + MOCK_NGC_CONTAINER_REGISTRY_CRED); + }); + } + + @Test + void validateContainerImage_CachesBearerToken_Success() { + String containerUrl = TEST_NGC_CONTAINER_IMAGE.toString(); + String apiKey = MOCK_NGC_CONTAINER_REGISTRY_CRED; + String targetUrl = + "/proxy_auth?account=%24oauthtoken&scope=repository%3Awhw3rcpsilnj%2Ftest-container-image%3Apull"; + ngcContainerRegistryClient.validateContainerImage(containerUrl, apiKey); + MockNgcContainerRegistryServer.getNgcContainerRegistryMockServer(). + verify(1, getRequestedFor(urlEqualTo(targetUrl))); + + ngcContainerRegistryClient.validateContainerImage(containerUrl, apiKey); + // Should use cached authentication token without additional API call + MockNgcContainerRegistryServer.getNgcContainerRegistryMockServer(). + verify(1, getRequestedFor(urlEqualTo(targetUrl))); + } + + @Test + void validateContainerImage_DifferentApiKeys_SeparateCacheEntries() { + String containerUrl = TEST_NGC_CONTAINER_IMAGE.toString(); + String apiKey1 = MOCK_NGC_CONTAINER_REGISTRY_CRED; + String apiKey2 = "different-" + MOCK_NGC_CONTAINER_REGISTRY_CRED; + String targetUrl = + "/proxy_auth?account=%24oauthtoken&scope=repository%3Awhw3rcpsilnj%2Ftest-container-image%3Apull"; + ngcContainerRegistryClient.validateContainerImage(containerUrl, apiKey1); + MockNgcContainerRegistryServer.getNgcContainerRegistryMockServer(). + verify(1, getRequestedFor(urlEqualTo(targetUrl))); + ngcContainerRegistryClient.validateContainerImage(containerUrl, + apiKey2); + MockNgcContainerRegistryServer.getNgcContainerRegistryMockServer(). + verify(2, getRequestedFor(urlEqualTo(targetUrl))); + + } + + @Test + void validateContainerImage_DifferentContainerImageTag_CachesBearerToken_Success() { + String containerUrl1 = TEST_NGC_CONTAINER_IMAGE.toString(); + String containerUrl2 = TEST_NGC_CONTAINER_IMAGE_WITH_DIGEST.toString(); + String apiKey = MOCK_NGC_CONTAINER_REGISTRY_CRED; + String targetUrl = + "/proxy_auth?account=%24oauthtoken&scope=repository%3Awhw3rcpsilnj%2Ftest-container-image%3Apull"; + + ngcContainerRegistryClient.validateContainerImage(containerUrl1, apiKey); + MockNgcContainerRegistryServer.getNgcContainerRegistryMockServer(). + verify(1, getRequestedFor(urlEqualTo(targetUrl))); + + ngcContainerRegistryClient.validateContainerImage(containerUrl2, apiKey); + MockNgcContainerRegistryServer.getNgcContainerRegistryMockServer(). + verify(1, getRequestedFor(urlEqualTo(targetUrl))); + } + + @Test + void validateContainerImage_DifferentContainerImageName_SeparateCacheEntries() { + String containerUrl1 = TEST_NGC_CONTAINER_IMAGE.toString(); + String containerUrl2 = TEST_NGC_CONTAINER_IMAGE_2.toString(); + String apiKey = MOCK_NGC_CONTAINER_REGISTRY_CRED; + String targetUrl1 = + "/proxy_auth?account=%24oauthtoken&scope=repository%3Awhw3rcpsilnj%2Ftest-container-image%3Apull"; + ngcContainerRegistryClient.validateContainerImage(containerUrl1, apiKey); + MockNgcContainerRegistryServer.getNgcContainerRegistryMockServer(). + verify(1, getRequestedFor(urlEqualTo(targetUrl1))); + + String targetUrl2 = + "/proxy_auth?account=%24oauthtoken&scope=repository%3Awhw3rcpsilnj%2Ftest-container-image-2%3Apull"; + assertThrows(NotFoundException.class, () -> + ngcContainerRegistryClient.validateContainerImage(containerUrl2, apiKey)); + MockNgcContainerRegistryServer.getNgcContainerRegistryMockServer(). + verify(1, getRequestedFor(urlEqualTo(targetUrl1))); + MockNgcContainerRegistryServer.getNgcContainerRegistryMockServer(). + verify(1, getRequestedFor(urlEqualTo(targetUrl2))); + } + + @Test + void validateContainerImage_DifferentOrg_SeparateCacheEntries() { + String containerUrl1 = TEST_NGC_CONTAINER_IMAGE.toString(); + String containerUrl2 = TEST_NGC_CONTAINER_IMAGE_UNKNOWN_ORG.toString(); + String apiKey = MOCK_NGC_CONTAINER_REGISTRY_CRED; + + String targetUrl1 = + "/proxy_auth?account=%24oauthtoken&scope=repository%3Awhw3rcpsilnj%2Ftest-container-image%3Apull"; + ngcContainerRegistryClient.validateContainerImage(containerUrl1, apiKey); + MockNgcContainerRegistryServer.getNgcContainerRegistryMockServer(). + verify(1, getRequestedFor(urlEqualTo(targetUrl1))); + + String targetUrl2 = + "/proxy_auth?account=%24oauthtoken&scope=repository%3Asomeone-org%2Ftest-container-image%3Apull"; + assertThrows(NotFoundException.class, () -> + ngcContainerRegistryClient.validateContainerImage(containerUrl2, apiKey)); + MockNgcContainerRegistryServer.getNgcContainerRegistryMockServer(). + verify(1, getRequestedFor(urlEqualTo(targetUrl1))); + MockNgcContainerRegistryServer.getNgcContainerRegistryMockServer(). + verify(1, getRequestedFor(urlEqualTo(targetUrl2))); + } + + @ParameterizedTest + @ValueSource(strings = { + "", // empty string + "invalid", // no slashes + "nvcr.io", // only registry + "nvcr.io/", // trailing slash + "nvcr.io/example:", // empty tag + "nvcr.io/example@", // empty digest + "nvcr.io/example:tag:extra", // multiple colons + "nvcr.io/example@digest@extra" // multiple @ symbols + }) + void parseContainerImageUrl_WithInvalidFormats_Fail(String invalidUrl) { + // When/Then + assertThrows(BadRequestException.class, () -> parseContainerImageUrl(invalidUrl)); + } + + @Test + void validateCredential_Success() { + String registryHost = TEST_NGC_CONTAINER_REGISTRY; + String apiKey = MOCK_NGC_CONTAINER_REGISTRY_CRED; + + String token = ngcContainerRegistryClient.validateCredential(registryHost, apiKey); + assertNotNull(token); + assertEquals("mockBearerToken", token); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/oci/OciRegistryAuthClientTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/oci/OciRegistryAuthClientTest.java new file mode 100644 index 0000000000..5e0b5d1fac --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/oci/OciRegistryAuthClientTest.java @@ -0,0 +1,676 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.oci; + +import com.nvidia.boot.registries.service.registry.client.WebClientUtils; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ACR_CREDENTIALS; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_AZURE_REGISTRY_AUTH_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_AZURE_REGISTRY_CLIENT_CALL_TIMEOUT; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.nvidia.boot.mock.azure.MockAcrAuthServer; +import com.nvidia.boot.registries.service.registry.client.oci.dto.OciArtifactComponents; +import com.nvidia.boot.registries.service.registry.client.oci.dto.OciAuthKey; +import java.net.URI; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.Optional; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.reactive.function.client.WebClient; + +@ExtendWith(MockitoExtension.class) +class OciRegistryAuthClientTest { + + @Mock + private OciAuthStubService mockOciAuthStubService; + + private static TestOciRegistryAuthClient ociAuthClient; + + private static final String AUTH_TOKEN_RESPONSE = """ + { + "access_token": "%s" + } + """; + + @BeforeAll + static void beforeAll() throws JOSEException { + MockAcrAuthServer.start(MOCK_AZURE_REGISTRY_AUTH_URL); + MockAcrAuthServer.setResponse("/oauth2/token", + AUTH_TOKEN_RESPONSE.formatted( + createValidJWT(Instant.now().plusSeconds(3600))) + .getBytes()); + + ociAuthClient = new TestOciRegistryAuthClient(MOCK_AZURE_REGISTRY_CLIENT_CALL_TIMEOUT); + ociAuthClient.setAuthBaseUrl(MOCK_AZURE_REGISTRY_AUTH_URL); + } + + @AfterAll + static void afterAll() { + MockAcrAuthServer.stop(); + } + + @BeforeEach + void beforeEach() { + if (ociAuthClient != null) { + ociAuthClient.invalidateCache(); + } + } + + // Create a concrete test implementation of the abstract OciRegistryAuthClient + private static class TestOciRegistryAuthClient extends OciRegistryAuthClient { + + private String authBaseUrl; + + public TestOciRegistryAuthClient(Duration callTimeout) { + super(WebClientUtils.builder(), callTimeout); + } + + @Override + protected String getCanonicalAuthTokenUrl(String registryHost, String name) { + var baseUrl = getRegistryBaseUrl(registryHost) + "/oauth2/token"; + return String.format("%s?service=%s&scope=repository:%s:pull", baseUrl, registryHost, + name); + } + + private String getRegistryBaseUrl(String registryHost) { + // Use the same logic as AzureRegistryAuthClient for URL replacement + if (authBaseUrl != null && authBaseUrl.contains("localhost")) { + return authBaseUrl.replaceAll("localhost-[^:]+:", "localhost:"); + } + return authBaseUrl != null ? authBaseUrl : "https://" + registryHost; + } + + private void setAuthBaseUrl(String authBaseUrl) { + this.authBaseUrl = authBaseUrl; + } + } + + @Test + void constructor_WithValidTimeout_Success() { + var authClient = new TestOciRegistryAuthClient(Duration.ofSeconds(30)); + assertNotNull(authClient); + } + + @Test + void constructor_WithNullTimeout_UsesDefaultTimeout() { + var authClient = new TestOciRegistryAuthClient(null); + assertNotNull(authClient); + } + + @Test + void invalidateCache_WithValidAuthKey_DoesNotThrow() { + var authClient = new TestOciRegistryAuthClient(Duration.ofSeconds(30)); + var authKey = new OciAuthKey("registry.io", "test/image", "credentials"); + assertDoesNotThrow(() -> authClient.invalidateCache(authKey)); + } + + @Test + void invalidateAllCache_DoesNotThrow() { + var authClient = new TestOciRegistryAuthClient(Duration.ofSeconds(30)); + assertDoesNotThrow(() -> authClient.invalidateCache()); + } + + @Test + void fetchToken_WithValidAuthKey_ReturnsOciAuthToken() throws Exception { + var authClient = new TestOciRegistryAuthClient(Duration.ofSeconds(30)); + injectMockStubService(authClient, mockOciAuthStubService); + + var authKey = new OciAuthKey("registry.io", "test/image", "dGVzdDpzZWNyZXQ="); + var expectedUrl = URI.create( + "https://registry.io/oauth2/token?service=registry.io&scope=repository:test/image:pull"); + var expectedAuth = "Basic dGVzdDpzZWNyZXQ="; + + // Create mock response with valid JWT + var mockResponse = new OciAuthStubService.OciAuthTokenResponse(); + String validJwt = createValidJWT(Instant.now().plusSeconds(3600)); + mockResponse.setAccessToken(validJwt); + + when(mockOciAuthStubService.fetchToken(eq(expectedUrl), eq(expectedAuth))) + .thenReturn(mockResponse); + + var result = authClient.fetchToken(authKey); + + assertNotNull(result); + assertEquals(validJwt, result.token()); + assertNotNull(result.expiresIn()); + assertTrue(result.expiresIn().toSeconds() > 3590 && result.expiresIn().toSeconds() <= 3600); + } + + @Test + void extractAuthToken_WithAccessToken_ReturnsAuthToken() throws Exception { + var authClient = new TestOciRegistryAuthClient(Duration.ofSeconds(30)); + injectMockStubService(authClient, mockOciAuthStubService); + + var response = new OciAuthStubService.OciAuthTokenResponse(); + String validJwt = createValidJWT(Instant.now().plusSeconds(3600)); + response.setAccessToken(validJwt); + + var result = authClient.extractAuthToken(response); + + assertNotNull(result); + assertEquals(validJwt, result.token()); + assertNotNull(result.expiresIn()); + assertTrue(result.expiresIn().toSeconds() > 3590 && result.expiresIn().toSeconds() <= 3600); + } + + @Test + void extractAuthToken_WithTokenField_ReturnsAuthToken() throws Exception { + var authClient = new TestOciRegistryAuthClient(Duration.ofSeconds(30)); + injectMockStubService(authClient, mockOciAuthStubService); + + var response = new OciAuthStubService.OciAuthTokenResponse(); + String validJwt = createValidJWT(Instant.now().plusSeconds(1800)); + response.setToken(validJwt); + + var result = authClient.extractAuthToken(response); + + assertNotNull(result); + assertEquals(validJwt, result.token()); + assertNotNull(result.expiresIn()); + assertTrue(result.expiresIn().toSeconds() > 1790 && result.expiresIn().toSeconds() <= 1800); + } + + @Test + void extractAuthToken_WithExpiresInField_UsesExpiresIn() throws Exception { + var authClient = new TestOciRegistryAuthClient(Duration.ofSeconds(30)); + injectMockStubService(authClient, mockOciAuthStubService); + + var response = new OciAuthStubService.OciAuthTokenResponse(); + response.setAccessToken("test-token"); + response.setExpiresIn(7200L); // 2 hours + + var result = authClient.extractAuthToken(response); + + assertNotNull(result); + assertEquals("test-token", result.token()); + assertEquals(Duration.ofSeconds(7200), result.expiresIn()); + } + + @Test + void extractAuthToken_WithZeroExpiresIn_FallsBackToJWT() throws Exception { + var authClient = new TestOciRegistryAuthClient(Duration.ofSeconds(30)); + injectMockStubService(authClient, mockOciAuthStubService); + + var response = new OciAuthStubService.OciAuthTokenResponse(); + String validJwt = createValidJWT(Instant.now().plusSeconds(900)); + response.setAccessToken(validJwt); + response.setExpiresIn(0L); // Zero should fallback to JWT parsing + + var result = authClient.extractAuthToken(response); + + assertNotNull(result); + assertEquals(validJwt, result.token()); + assertTrue(result.expiresIn().toSeconds() > 890 && result.expiresIn().toSeconds() <= 900); + } + + @Test + void extractAuthToken_WithNegativeExpiresIn_FallsBackToJWT() throws Exception { + var authClient = new TestOciRegistryAuthClient(Duration.ofSeconds(30)); + injectMockStubService(authClient, mockOciAuthStubService); + + var response = new OciAuthStubService.OciAuthTokenResponse(); + String validJwt = createValidJWT(Instant.now().plusSeconds(600)); + response.setAccessToken(validJwt); + response.setExpiresIn(-1L); // Negative should fallback to JWT parsing + + var result = authClient.extractAuthToken(response); + + assertNotNull(result); + assertEquals(validJwt, result.token()); + assertTrue(result.expiresIn().toSeconds() > 590 && result.expiresIn().toSeconds() <= 600); + } + + @Test + void extractAuthToken_WithInvalidJWT_UsesDefaultExpiry() throws Exception { + var authClient = new TestOciRegistryAuthClient(Duration.ofSeconds(30)); + injectMockStubService(authClient, mockOciAuthStubService); + + var response = new OciAuthStubService.OciAuthTokenResponse(); + response.setAccessToken("invalid-jwt-token"); + + var result = authClient.extractAuthToken(response); + + assertNotNull(result); + assertEquals("invalid-jwt-token", result.token()); + assertEquals(Duration.ofSeconds(60), result.expiresIn()); // Default fallback + } + + @Test + void extractAuthToken_WithEmptyAccessTokenAndToken_ThrowsException() throws Exception { + var authClient = new TestOciRegistryAuthClient(Duration.ofSeconds(30)); + injectMockStubService(authClient, mockOciAuthStubService); + + var response = new OciAuthStubService.OciAuthTokenResponse(); + // Leave both accessToken and token empty + + assertThrows(IllegalArgumentException.class, () -> { + authClient.extractAuthToken(response); + }); + } + + @Test + void extractAuthToken_WithNullAccessTokenAndToken_ThrowsException() throws Exception { + var authClient = new TestOciRegistryAuthClient(Duration.ofSeconds(30)); + injectMockStubService(authClient, mockOciAuthStubService); + + var response = new OciAuthStubService.OciAuthTokenResponse(); + response.setAccessToken(null); + response.setToken(null); + + assertThrows(IllegalArgumentException.class, () -> { + authClient.extractAuthToken(response); + }); + } + + @Test + void extractAuthToken_WithBlankAccessToken_ThrowsException() throws Exception { + var authClient = new TestOciRegistryAuthClient(Duration.ofSeconds(30)); + injectMockStubService(authClient, mockOciAuthStubService); + + var response = new OciAuthStubService.OciAuthTokenResponse(); + response.setAccessToken(" "); + + assertThrows(IllegalArgumentException.class, () -> { + authClient.extractAuthToken(response); + }); + } + + @Test + void extractAuthToken_PreferAccessTokenOverToken() throws Exception { + var authClient = new TestOciRegistryAuthClient(Duration.ofSeconds(30)); + injectMockStubService(authClient, mockOciAuthStubService); + + var response = new OciAuthStubService.OciAuthTokenResponse(); + String accessTokenJwt = createValidJWT(Instant.now().plusSeconds(3600)); + String tokenJwt = createValidJWT(Instant.now().plusSeconds(1800)); + + response.setAccessToken(accessTokenJwt); + response.setToken(tokenJwt); + + var result = authClient.extractAuthToken(response); + + assertNotNull(result); + assertEquals(accessTokenJwt, result.token()); // Should prefer accessToken + assertTrue(result.expiresIn().toSeconds() > 3590); // Should use accessToken expiry + } + + // ===== UTILITY TESTS ===== + + @Test + void getExpiresInFromJWS_WithValidToken_ReturnsExpiresIn() throws JOSEException { + String jwt = createValidJWT(Instant.now().plusSeconds(3600)); + + var result = OciRegistryAuthClient.getExpClaimFromJwt(jwt); + + assertTrue(result.isPresent() && + result.get().toSeconds() > 3590 && result.get().toSeconds() <= 3600); + } + + @Test + void getExpClaimFromJwt_WithExpiredToken_ReturnsZero() throws JOSEException { + String jwt = createValidJWT(Instant.now().minusSeconds(3600)); + + var result = OciRegistryAuthClient.getExpClaimFromJwt(jwt); + assertEquals(Optional.of(Duration.ZERO), result); + } + + @Test + void getExpClaimFromJwt_WithMissingExpClaim_ReturnsNull() throws JOSEException { + String jwt = createJWTWithoutExpClaim(); + + var result = OciRegistryAuthClient.getExpClaimFromJwt(jwt); + assertThat(result).isEmpty(); + } + + @Test + void getExpClaimFromJwt_WithNull_ReturnsNull() { + assertThat(OciRegistryAuthClient.getExpClaimFromJwt(null)).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = { + "", + "invalid-jwt", + "only.one.part", // less than 3 parts + "header.invalid-base64.signature", // invalid base64 payload + "header.e30.signature" // valid base64 but empty JSON object + }) + void getExpClaimFromJwt_WithInvalidToken_ReturnsNull(String invalidJwt) { + var result = OciRegistryAuthClient.getExpClaimFromJwt(invalidJwt); + assertThat(result).isEmpty(); + } + + private void injectMockStubService(TestOciRegistryAuthClient authClient, + OciAuthStubService mockService) throws Exception { + var field = OciRegistryAuthClient.class.getDeclaredField("ociAuthStubService"); + field.setAccessible(true); + field.set(authClient, mockService); + } + + private static String createValidJWT(Instant expirationTime) throws JOSEException { + var claimsSet = new JWTClaimsSet.Builder() + .issuer("test-issuer") + .subject("test-subject") + .expirationTime(Date.from(expirationTime)) + .build(); + + var signedJWT = new SignedJWT( + new JWSHeader(JWSAlgorithm.HS256), + claimsSet); + + var signer = new MACSigner("test-secret-key-that-is-at-least-256-bits-long"); + signedJWT.sign(signer); + + return signedJWT.serialize(); + } + + private String createJWTWithoutExpClaim() throws JOSEException { + var claimsSet = new JWTClaimsSet.Builder() + .issuer("test-issuer") + .subject("test-subject") + .build(); + + var signedJWT = new SignedJWT( + new JWSHeader(JWSAlgorithm.HS256), + claimsSet); + + var signer = new MACSigner("test-secret-key-that-is-at-least-256-bits-long"); + signedJWT.sign(signer); + + return signedJWT.serialize(); + } + + // ===== CACHE BEHAVIOR TESTS ===== + + @Test + void cacheSize_InitiallyEmpty() { + // Cache should be empty at start of each test due to @BeforeEach cleanup + assertEquals(0, ociAuthClient.getCacheSize()); + + // Verify new instances also see the empty shared cache + var authClient = new TestOciRegistryAuthClient(Duration.ofSeconds(30)); + assertEquals(0, authClient.getCacheSize()); + } + + @Test + void cacheSize_IncreasesAfterTokenFetch() { + assertEquals(0, ociAuthClient.getCacheSize()); + + var components = new OciArtifactComponents("localhost-acr", "cache/test1", "latest"); + assertDoesNotThrow(() -> { + var token = ociAuthClient.getToken(components, MOCK_ACR_CREDENTIALS); + assertNotNull(token); + }); + + assertEquals(1, ociAuthClient.getCacheSize()); + } + + @Test + void cacheSize_RemainsConstantForSameKey() { + var components = new OciArtifactComponents("localhost-acr", "cache/same-key", "v1.0.0"); + + // First fetch + assertDoesNotThrow(() -> { + var token = ociAuthClient.getToken(components, MOCK_ACR_CREDENTIALS); + assertNotNull(token); + }); + long sizeAfterFirst = ociAuthClient.getCacheSize(); + + // Second fetch with same key - should use cache + assertDoesNotThrow(() -> { + var token = ociAuthClient.getToken(components, MOCK_ACR_CREDENTIALS); + assertNotNull(token); + }); + long sizeAfterSecond = ociAuthClient.getCacheSize(); + + // Cache size should remain the same + assertEquals(sizeAfterFirst, sizeAfterSecond); + } + + @Test + void cacheSize_IncreasesForDifferentKeys() { + assertEquals(0, ociAuthClient.getCacheSize()); + + // Fetch tokens with different repository names (different cache keys) + var components1 = new OciArtifactComponents("localhost-acr", "cache/repo1", "latest"); + var components2 = new OciArtifactComponents("localhost-acr", "cache/repo2", "latest"); + var components3 = new OciArtifactComponents("localhost-acr", "cache/repo3", "v1.0.0"); + + assertDoesNotThrow(() -> { + ociAuthClient.getToken(components1, MOCK_ACR_CREDENTIALS); + }); + assertEquals(1, ociAuthClient.getCacheSize()); + + assertDoesNotThrow(() -> { + ociAuthClient.getToken(components2, MOCK_ACR_CREDENTIALS); + }); + assertEquals(2, ociAuthClient.getCacheSize()); + + assertDoesNotThrow(() -> { + ociAuthClient.getToken(components3, MOCK_ACR_CREDENTIALS); + }); + assertEquals(3, ociAuthClient.getCacheSize()); + } + + @Test + void cacheSize_SameRepositoryDifferentReferences_SharesCacheEntry() { + assertEquals(0, ociAuthClient.getCacheSize()); + + var repoName = "cache/same-repo"; + + // Same repository, different references (tag vs digest) should use same cache entry + var components1 = new OciArtifactComponents("localhost-acr", repoName, "latest"); + var components2 = new OciArtifactComponents("localhost-acr", repoName, "v1.0.0"); + var digest = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; + var components3 = new OciArtifactComponents("localhost-acr", repoName, digest); + + // First fetch + assertDoesNotThrow(() -> { + ociAuthClient.getToken(components1, MOCK_ACR_CREDENTIALS); + }); + assertEquals(1, ociAuthClient.getCacheSize()); + + // Second fetch with different tag - should use same cache entry + assertDoesNotThrow(() -> { + ociAuthClient.getToken(components2, MOCK_ACR_CREDENTIALS); + }); + assertEquals(1, ociAuthClient.getCacheSize()); // Still 1 entry + + // Third fetch with digest - should still use same cache entry + assertDoesNotThrow(() -> { + ociAuthClient.getToken(components3, MOCK_ACR_CREDENTIALS); + }); + assertEquals(1, ociAuthClient.getCacheSize()); // Still 1 entry + } + + @Test + void cacheSize_IncreasesForDifferentCredentials() { + assertEquals(0, ociAuthClient.getCacheSize()); + + var components = new OciArtifactComponents("localhost-acr", "cache/creds-test", "latest"); + + // Fetch with first credentials + assertDoesNotThrow(() -> { + var token = ociAuthClient.getToken(components, MOCK_ACR_CREDENTIALS); + assertNotNull(token); + }); + assertEquals(1, ociAuthClient.getCacheSize()); + + // Fetch with different credentials (should create new cache entry) + var differentCredentials = "ZGlmZmVyZW50OnNlY3JldA=="; // different:secret in base64 + assertDoesNotThrow(() -> { + var token = ociAuthClient.getToken(components, differentCredentials); + assertNotNull(token); + }); + + assertEquals(2, ociAuthClient.getCacheSize()); + } + + @Test + void cacheInvalidation_ReducesCacheSize_IntegrationTest() { + // Cache starts empty due to @BeforeEach cleanup + assertEquals(0, ociAuthClient.getCacheSize()); + + var components = + new OciArtifactComponents("localhost-acr", "cache/invalidation-test", "v1.0.0"); + var authKey = + new OciAuthKey(components.registryHost(), components.name(), MOCK_ACR_CREDENTIALS); + + // Populate cache + assertDoesNotThrow(() -> { + var token = ociAuthClient.getToken(components, MOCK_ACR_CREDENTIALS); + assertNotNull(token); + }); + assertEquals(1, ociAuthClient.getCacheSize()); + + // Invalidate specific entry + ociAuthClient.invalidateCache(authKey); + + // Cache should be empty again + assertEquals(0, ociAuthClient.getCacheSize()); + } + + @Test + void cacheInvalidateAll_ClearsCacheCompletely() { + // Cache starts empty due to @BeforeEach cleanup + assertEquals(0, ociAuthClient.getCacheSize()); + + // Populate cache with multiple entries + var components1 = new OciArtifactComponents("localhost-acr", "cache/clear1", "latest"); + var components2 = new OciArtifactComponents("localhost-acr", "cache/clear2", "latest"); + + assertDoesNotThrow(() -> { + ociAuthClient.getToken(components1, MOCK_ACR_CREDENTIALS); + ociAuthClient.getToken(components2, MOCK_ACR_CREDENTIALS); + }); + + assertEquals(2, ociAuthClient.getCacheSize()); + + // Clear all cache + ociAuthClient.invalidateCache(); + + assertEquals(0, ociAuthClient.getCacheSize()); + } + + @Test + void cacheSize_MonitoringDuringComplexOperations_IntegrationTest() { + assertEquals(0, ociAuthClient.getCacheSize()); + + // Step 1: Add multiple entries + var components1 = new OciArtifactComponents("localhost-acr", "monitor/step1", "latest"); + var components2 = new OciArtifactComponents("localhost-acr", "monitor/step2", "latest"); + + assertDoesNotThrow(() -> { + ociAuthClient.getToken(components1, MOCK_ACR_CREDENTIALS); + ociAuthClient.getToken(components2, MOCK_ACR_CREDENTIALS); + }); + assertEquals(2, ociAuthClient.getCacheSize()); + + // Step 2: Fetch same keys (should not increase cache) + assertDoesNotThrow(() -> { + ociAuthClient.getToken(components1, MOCK_ACR_CREDENTIALS); + ociAuthClient.getToken(components2, MOCK_ACR_CREDENTIALS); + }); + assertEquals(2, ociAuthClient.getCacheSize()); // Still 2 + + // Step 3: Invalidate one entry + var authKey1 = new OciAuthKey(components1.registryHost(), components1.name(), + MOCK_ACR_CREDENTIALS); + ociAuthClient.invalidateCache(authKey1); + assertEquals(1, ociAuthClient.getCacheSize()); + + // Step 4: Add new entry + var components3 = new OciArtifactComponents("localhost-acr", "monitor/step3", "latest"); + assertDoesNotThrow(() -> { + ociAuthClient.getToken(components3, MOCK_ACR_CREDENTIALS); + }); + assertEquals(2, ociAuthClient.getCacheSize()); // Back to 2 + } + + @Test + void cache_IsIsolatedBetweenDifferentRegistryInstances() { + // Create two separate registry auth client instances + var authClient1 = new TestOciRegistryAuthClient(Duration.ofSeconds(30)); + var authClient2 = new TestOciRegistryAuthClient(Duration.ofSeconds(30)); + + authClient1.setAuthBaseUrl(MOCK_AZURE_REGISTRY_AUTH_URL); + authClient2.setAuthBaseUrl(MOCK_AZURE_REGISTRY_AUTH_URL); + + // Both should start with empty caches + assertEquals(0, authClient1.getCacheSize()); + assertEquals(0, authClient2.getCacheSize()); + + // Add entries to first client's cache + var components1 = new OciArtifactComponents("localhost-acr", "isolation/test1", "latest"); + var components2 = new OciArtifactComponents("localhost-acr", "isolation/test2", "latest"); + + assertDoesNotThrow(() -> { + authClient1.getToken(components1, MOCK_ACR_CREDENTIALS); + authClient1.getToken(components2, MOCK_ACR_CREDENTIALS); + }); + + // First client should have 2 entries, second client should still have 0 + assertEquals(2, authClient1.getCacheSize()); + assertEquals(0, authClient2.getCacheSize()); + + // Add entries to second client's cache + var components3 = new OciArtifactComponents("localhost-acr", "isolation/test3", "latest"); + + assertDoesNotThrow(() -> { + authClient2.getToken(components3, MOCK_ACR_CREDENTIALS); + }); + + // Each client should have independent cache sizes + assertEquals(2, authClient1.getCacheSize()); + assertEquals(1, authClient2.getCacheSize()); + + // Clear first client's cache - should not affect second client + authClient1.invalidateCache(); + + assertEquals(0, authClient1.getCacheSize()); + assertEquals(1, authClient2.getCacheSize()); // Should remain unchanged + + // Clear second client's cache + authClient2.invalidateCache(); + + assertEquals(0, authClient1.getCacheSize()); + assertEquals(0, authClient2.getCacheSize()); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/oci/OciRegistryClientTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/oci/OciRegistryClientTest.java new file mode 100644 index 0000000000..b2e52544c2 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/oci/OciRegistryClientTest.java @@ -0,0 +1,308 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.oci; + +import com.nvidia.boot.registries.service.registry.client.WebClientUtils; +import static com.nvidia.boot.registries.service.registry.client.oci.OciRegistryClient.IMAGE_MEDIA_TYPES; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.registries.service.registry.client.oci.dto.OciArtifactComponents; +import com.nvidia.boot.registries.service.registry.client.oci.dto.OciAuthToken; +import java.net.URI; +import java.time.Duration; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.reactive.function.client.WebClient; + +@ExtendWith(MockitoExtension.class) +class OciRegistryClientTest { + + private static final String VALID_DIGEST = + "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; + + @Mock + private OciRegistryStubService mockRegistryStubService; + + @Mock + private OciRegistryAuthClient mockOciRegistryAuthClient; + + // Create a concrete test implementation of the abstract OciRegistryClient + private static class TestOciRegistryClient extends OciRegistryClient { + + public TestOciRegistryClient(Duration callTimeout, OciRegistryAuthClient authClient) { + super(WebClientUtils.builder(), callTimeout, authClient); + } + + @Override + protected String getRegistryBaseUrl(String registryHost) { + return "https://" + registryHost; + } + } + + @Test + void ociRegistryClient_WithAuthService_CanBeCreated() { + var client = new TestOciRegistryClient(Duration.ofSeconds(30), mockOciRegistryAuthClient); + + assertNotNull(client); + assertEquals("https://registry.io", client.getRegistryBaseUrl("registry.io")); + } + + static Stream validValidateArtifactScenarios() { + return Stream.of( + Arguments.of("registry.io/test/image:v1.0.0", + "https://registry.io/v2/test/image/manifests/v1.0.0"), + Arguments.of("registry.io/test/image@" + VALID_DIGEST, + "https://registry.io/v2/test/image/manifests/" + VALID_DIGEST), + Arguments.of("registry.io/test/image", + "https://registry.io/v2/test/image/manifests/latest") + ); + } + + @ParameterizedTest + @MethodSource("validValidateArtifactScenarios") + void validateArtifact_WithValidInput_CompletesSuccessfully(String imageUrl, + String expectedManifestUrl) + throws Exception { + var client = new TestOciRegistryClient(Duration.ofSeconds(30), mockOciRegistryAuthClient); + injectMockField(client, "ociRegistryStubService", mockRegistryStubService); + + String base64Secret = "base64secret"; + + when(mockOciRegistryAuthClient.getToken(any(OciArtifactComponents.class), + any(String.class))) + .thenReturn(new OciAuthToken("test-token", Duration.ofSeconds(3600))); + + doNothing().when(mockRegistryStubService).doesManifestExist( + any(URI.class), + eq("Bearer test-token"), + eq(IMAGE_MEDIA_TYPES) + ); + + assertDoesNotThrow(() -> { + client.validateArtifact(imageUrl, base64Secret); + }); + + verify(mockRegistryStubService).doesManifestExist( + URI.create(expectedManifestUrl), + "Bearer test-token", + IMAGE_MEDIA_TYPES + ); + } + + @ParameterizedTest + @ValueSource(strings = {"invalid-url", "", "image:tag", "registry.io", "registry.io/"}) + void validateArtifact_WithInvalidUrl_ThrowsBadRequestException(String invalidUrl) + throws Exception { + var client = new TestOciRegistryClient(Duration.ofSeconds(30), mockOciRegistryAuthClient); + injectMockField(client, "ociRegistryStubService", mockRegistryStubService); + + String base64Secret = "base64secret"; + + assertThrows(BadRequestException.class, () -> { + client.validateArtifact(invalidUrl, base64Secret); + }); + } + + @Test + void validateArtifact_WithNullUrl_ThrowsBadRequestException() throws Exception { + var client = new TestOciRegistryClient(Duration.ofSeconds(30), mockOciRegistryAuthClient); + injectMockField(client, "ociRegistryStubService", mockRegistryStubService); + + String base64Secret = "base64secret"; + + assertThrows(BadRequestException.class, () -> { + client.validateArtifact(null, base64Secret); + }); + } + + static Stream mediaTypeScenarios() { + return Stream.of( + Arguments.of("application/vnd.oci.image.manifest.v1+json", + "application/vnd.oci.image.manifest.v1+json"), + Arguments.of(null, IMAGE_MEDIA_TYPES), + Arguments.of("", IMAGE_MEDIA_TYPES) + ); + } + + @ParameterizedTest + @MethodSource("mediaTypeScenarios") + void validateArtifact_WithMediaTypes_CompletesSuccessfully(String inputMediaTypes, + String expectedMediaTypes) + throws Exception { + var client = new TestOciRegistryClient(Duration.ofSeconds(30), mockOciRegistryAuthClient); + injectMockField(client, "ociRegistryStubService", mockRegistryStubService); + + String imageUrl = "registry.io/test/image:v1.0.0"; + String base64Secret = "base64secret"; + + when(mockOciRegistryAuthClient.getToken(any(OciArtifactComponents.class), + any(String.class))) + .thenReturn(new OciAuthToken("test-token", Duration.ofSeconds(3600))); + + doNothing().when(mockRegistryStubService).doesManifestExist( + any(URI.class), + eq("Bearer test-token"), + eq(expectedMediaTypes) + ); + + assertDoesNotThrow(() -> { + client.validateArtifact(imageUrl, base64Secret, inputMediaTypes); + }); + + verify(mockRegistryStubService).doesManifestExist( + URI.create("https://registry.io/v2/test/image/manifests/v1.0.0"), + "Bearer test-token", + expectedMediaTypes + ); + } + + // ===== UTILITY TESTS ===== + + static Stream validArtifactUrls() { + return Stream.of( + // Format: (url, expectedHost, expectedName, expectedReference) + Arguments.of("registry.io/image:tag", "registry.io", "image", "tag"), + Arguments.of("registry.io/namespace/image:v1.0.0", "registry.io", "namespace/image", + "v1.0.0"), + Arguments.of("registry.io/image@" + VALID_DIGEST, "registry.io", "image", + VALID_DIGEST), + Arguments.of("oci://registry.io/image:tag", "registry.io", "image", "tag"), + Arguments.of("oci://registry.io/image@" + VALID_DIGEST, "registry.io", "image", + VALID_DIGEST), + Arguments.of("registry.io/image", "registry.io", "image", "latest"), + // defaults to latest + Arguments.of("example.com/deep/nested/path/image:stable", "example.com", + "deep/nested/path/image", "stable"), + Arguments.of("example-test-1.com/deep/nested/path/image:1.0.0", + "example-test-1.com", "deep/nested/path/image", "1.0.0") + ); + } + + @ParameterizedTest + @MethodSource("validArtifactUrls") + void parseArtifactUrl_WithValidUrls_Success( + String url, + String expectedRegistry, + String expectedName, + String expectedReference) { + var components = OciRegistryClient.parseArtifactUrl(url); + + assertNotNull(components); + assertEquals(expectedRegistry, components.registryHost()); + assertEquals(expectedName, components.name()); + assertEquals(expectedReference, components.reference()); + } + + @Test + void parseArtifactUrl_WithComplexNamespace_Success() { + String complexUrl = "my-registry.example.com:8080/org/team/project/service:v1.2.3-alpha"; + + var components = OciRegistryClient.parseArtifactUrl(complexUrl); + + assertEquals("my-registry.example.com:8080", components.registryHost()); + assertEquals("org/team/project/service", components.name()); + assertEquals("v1.2.3-alpha", components.reference()); + } + + @Test + void parseArtifactUrl_WithOciPrefixAndComplexPath_Success() { + String ociUrl = "oci://harbor.company.com/library/alpine/base:3.14"; + + var components = OciRegistryClient.parseArtifactUrl(ociUrl); + + assertEquals("harbor.company.com", components.registryHost()); + assertEquals("library/alpine/base", components.name()); + assertEquals("3.14", components.reference()); + } + + @Test + void parseArtifactUrl_WithDigest_ReturnsDigestAsReference() { + String digestUrl = "registry.io/myimage@" + VALID_DIGEST; + + var components = OciRegistryClient.parseArtifactUrl(digestUrl); + + assertEquals("registry.io", components.registryHost()); + assertEquals("myimage", components.name()); + assertEquals(VALID_DIGEST, components.reference()); + } + + @Test + void parseArtifactUrl_WithoutTagOrDigest_DefaultsToLatest() { + String simpleUrl = "registry.io/myimage"; + + var components = OciRegistryClient.parseArtifactUrl(simpleUrl); + + assertEquals("registry.io", components.registryHost()); + assertEquals("myimage", components.name()); + assertEquals("latest", components.reference()); + } + + @ParameterizedTest + @ValueSource(strings = { + "", + "image:tag", // missing registry host + "oci://image:tag", // missing registry host + "registry.io", // missing image name + "registry.io/", // missing image name + "registry.io/image@invalid-digest", // invalid digest format + }) + void parseArtifactUrl_WithInvalidUrls_ThrowsBadRequestException(String invalidUrl) { + assertThrows(BadRequestException.class, + () -> OciRegistryClient.parseArtifactUrl(invalidUrl)); + } + + @Test + void parseArtifactUrl_WithNullUrl_ThrowsBadRequestException() { + assertThrows(BadRequestException.class, () -> OciRegistryClient.parseArtifactUrl(null)); + } + + @Test + void getOciRegistryWebClient_WithValidServiceType_ReturnsClient() { + Duration timeout = Duration.ofSeconds(30); + var client = OciRegistryClient.getOciRegistryWebClient( + WebClientUtils.builder(), timeout, MockStubService.class); + assertNotNull(client); + } + + // Mock stub service interface for testing + private interface MockStubService { + // Empty interface for testing web client creation + } + + private void injectMockField(Object target, String fieldName, Object mock) throws Exception { + var field = target.getClass().getSuperclass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, mock); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/volcengine/VolcengineArtifactRegistryClientTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/volcengine/VolcengineArtifactRegistryClientTest.java new file mode 100644 index 0000000000..e4f03a43c4 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/volcengine/VolcengineArtifactRegistryClientTest.java @@ -0,0 +1,285 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.volcengine; + +import com.nvidia.boot.registries.service.registry.client.WebClientUtils; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VOLCENGINE_HELM_CHART_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VOLCENGINE_HELM_CHART_TAG_NOT_FOUND; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VOLCENGINE_HELM_CHART_WITH_TAG; +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineArtifactRegistryClient.HELM_CHART_URL_PATTERN; +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineRegistryUtils.parseArtifactUrl; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_INVALID_VOLCENGINE_REGISTRY_CRED; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_VOLCENGINE_REGISTRY_API_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_VOLCENGINE_REGISTRY_CLIENT_CALL_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_VOLCENGINE_REGISTRY_CRED; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.mock.volcengine.MockVolcengineRegistryServer; +import com.nvidia.boot.registries.service.registry.client.volcengine.dto.VolcengineArtifactType; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.web.reactive.function.client.WebClient; + +class VolcengineArtifactRegistryClientTest { + + private static VolcengineArtifactRegistryClient volcengineArtifactRegistryClient; + + // Test Volcengine Helm chart URLs for integration testing + private static final String TEST_VOLCENGINE_HELM_CHART_WITH_TAG_1 = + "oci://test-registry-cn-beijing.cr.volces.com/test-namespace/helm-charts/my-chart:v1.0.0"; + private static final String TEST_VOLCENGINE_HELM_CHART_WITHOUT_TAG_1 = + "oci://test-registry-cn-beijing.cr.volces.com/test-namespace/helm-charts/my-chart"; + + @BeforeAll + static void beforeAll() { + volcengineArtifactRegistryClient = new VolcengineArtifactRegistryClient( + WebClientUtils.builder(), + MOCK_VOLCENGINE_REGISTRY_API_URL, + MOCK_VOLCENGINE_REGISTRY_CLIENT_CALL_TIMEOUT); + MockVolcengineRegistryServer.start(MOCK_VOLCENGINE_REGISTRY_API_URL); + } + + @AfterAll + static void cleanup() { + MockVolcengineRegistryServer.stop(); + } + + static Stream createValidHelmChartUrls() { + return Stream.of( + Arguments.of(TEST_VOLCENGINE_HELM_CHART_WITH_TAG_1, + "test-registry", "cn-beijing", "test-namespace", + "helm-charts/my-chart", "v1.0.0"), + Arguments.of(TEST_VOLCENGINE_HELM_CHART_WITHOUT_TAG_1, + "test-registry", "cn-beijing", "test-namespace", + "helm-charts/my-chart", "latest") + ); + } + + @ParameterizedTest + @MethodSource("createValidHelmChartUrls") + void parseHelmChartUrl_WithValidFormat_Success(String helmChartUrl, + String expectedRegistry, + String expectedRegion, + String expectedNamespace, + String expectedRepository, + String expectedTag) { + + var components = + parseArtifactUrl(helmChartUrl, + HELM_CHART_URL_PATTERN, + VolcengineArtifactType.CHART); + + assertNotNull(components); + assertEquals(expectedRegistry, components.registry()); + assertEquals(expectedRegion, components.region()); + assertEquals(expectedNamespace, components.namespace()); + assertEquals(expectedRepository, components.repository()); + assertEquals(expectedTag, components.tag()); + assertEquals(VolcengineArtifactType.CHART, components.type()); + } + + @ParameterizedTest + @ValueSource(strings = { + "", // empty string + " ", // whitespace only + "invalid-url", // not Volcengine OCI format + "oci://test.cr.volces.com/namespace/chart:tag", // invalid registry format + "oci://test-registry.cr.volces.com/namespace/chart:tag", // missing region + "oci://test-registry-invalid-region.cr.volces.com/namespace/chart:tag", + // invalid region + "oci://test-registry-cn-beijing.volces.com/namespace/chart:tag", // missing 'cr' + "oci://test-registry-cn-beijing.cr.volces.com//chart:tag", // empty namespace + "oci://test-registry-cn-beijing.cr.volces.com/namespace/:tag", // empty repository + "oci://test-registry-cn-beijing.cr.volces.com/namespace/chart@invalid-digest", + // invalid digest format + "https://helm.example.com/charts/my-chart:latest", // not Volcengine URL + "test-registry-cn-beijing.cr.volces.com/namespace/chart:tag", + // missing 'oci://' prefix + }) + void parseHelmChartUrl_WithInvalidFormats_Fail(String invalidUrl) { + assertThrows(BadRequestException.class, + () -> parseArtifactUrl(invalidUrl, + HELM_CHART_URL_PATTERN, + VolcengineArtifactType.CHART)); + } + + @ParameterizedTest + @ValueSource(strings = { + "simple-chart", + "namespace/chart", + "deep/namespace/structure/chart", + "chart-with-dashes", + "chart_with_underscores", + "chart.with.dots", + "123numeric-chart", + "helm-charts/my-chart", + "charts/stable/nginx" + }) + void parseHelmChartUrl_VariousRepositoryFormats_Success(String repositoryName) { + var helmChartUrl = + String.format( + "oci://test-registry-cn-beijing.cr.volces.com/test-namespace/%s:latest", + repositoryName); + + assertDoesNotThrow(() -> parseArtifactUrl(helmChartUrl, + HELM_CHART_URL_PATTERN, + VolcengineArtifactType.CHART)); + } + + @ParameterizedTest + @ValueSource(strings = { + "cn-beijing", + "cn-shanghai", + "ap-singapore", + "ap-mumbai", + "cn-north-1", + "ap-southeast-1" + }) + void parseHelmChartUrl_VariousRegions_Success(String region) { + + var helmChartUrl = String.format( + "oci://test-registry-%s.cr.volces.com/test-namespace/helm-charts/my-chart:latest", + region); + + assertDoesNotThrow(() -> parseArtifactUrl(helmChartUrl, + HELM_CHART_URL_PATTERN, + VolcengineArtifactType.CHART)); + } + + @ParameterizedTest + @ValueSource(strings = { + "latest", + "v1.0.0", + "1.2.3", + "0.1.0-alpha", + "2.0.0-beta.1", + "1.0.0-rc.1", + "dev-branch", + "feature_branch", + "release-2023-12-01", + "chart-v1.2.3" + }) + void parseHelmChartUrl_VariousVersionFormats_Success(String version) { + + var helmChartUrl = String.format( + "oci://test-registry-cn-beijing.cr.volces.com/test-namespace/helm-charts/my-chart:%s", + version); + + assertDoesNotThrow(() -> parseArtifactUrl(helmChartUrl, + HELM_CHART_URL_PATTERN, + VolcengineArtifactType.CHART)); + } + + static Stream createValidHelmCharts() { + return Stream.of( + Arguments.of(TEST_VOLCENGINE_HELM_CHART_WITH_TAG.toString()) + ); + } + + @ParameterizedTest + @MethodSource("createValidHelmCharts") + void validateHelmChart_Success(String helmChartUrl) { + assertDoesNotThrow(() -> + volcengineArtifactRegistryClient.validateHelmChart(helmChartUrl, + MOCK_VOLCENGINE_REGISTRY_CRED)); + } + + @Test + void validateHelmChart_PermissionDenied_Fail() { + assertThrows(ForbiddenException.class, () -> + volcengineArtifactRegistryClient.validateHelmChart( + TEST_VOLCENGINE_HELM_CHART_PERMISSION_DENIED.toString(), + MOCK_VOLCENGINE_REGISTRY_CRED)); + } + + @Test + void validateHelmChart_NotExist_Fail() { + assertThrows(NotFoundException.class, () -> + volcengineArtifactRegistryClient.validateHelmChart( + TEST_VOLCENGINE_HELM_CHART_TAG_NOT_FOUND.toString(), + MOCK_VOLCENGINE_REGISTRY_CRED)); + } + + @ParameterizedTest + @ValueSource(strings = { + "", // empty credentials + "invalid-base64", // invalid base64 + "dGVzdA==", // valid base64 but no colon (decodes to "test") + }) + void validateHelmChart_InvalidCredentials_Fail(String invalidCredentials) { + assertThrows(BadRequestException.class, () -> + volcengineArtifactRegistryClient.validateHelmChart( + TEST_VOLCENGINE_HELM_CHART_WITH_TAG_1, + invalidCredentials)); + } + + @Test + void validateCredential_ValidHostnameAndCredentials_Success() { + String hostname = "test-volcengine-registry-cn-beijing.cr.volces.com"; + assertDoesNotThrow(() -> + volcengineArtifactRegistryClient.validateCredential(hostname, + MOCK_VOLCENGINE_REGISTRY_CRED)); + } + + @ParameterizedTest + @ValueSource(strings = { + "test-volcengine-registry-cn-beijing.cr.volces.com", + "test-volcengine-registry-cn-shanghai.cr.volces.com", + "my-registry-ap-southeast-1.cr.volces.com" + }) + void validateCredential_VariousValidHostnames_Success(String hostname) { + assertDoesNotThrow(() -> + volcengineArtifactRegistryClient.validateCredential(hostname, + MOCK_VOLCENGINE_REGISTRY_CRED)); + } + + @ParameterizedTest + @ValueSource(strings = { + "invalid-hostname", + "test.cr.volces.com", // missing region + "test-registry.cr.volces.com", // missing region + "test-registry-invalid-region.cr.volces.com", // invalid region + "public.ecr.aws", // ECR public hostname + "" // empty hostname + }) + void validateCredential_InvalidHostname_Fail(String hostname) { + assertThrows(BadRequestException.class, () -> + volcengineArtifactRegistryClient.validateCredential(hostname, + MOCK_VOLCENGINE_REGISTRY_CRED)); + } + + @Test + void validateCredential_InvalidCredentials_Fail() { + String hostname = "test-volcengine-registry-cn-beijing.cr.volces.com"; + assertThrows(BadRequestException.class, () -> + volcengineArtifactRegistryClient.validateCredential(hostname, + MOCK_INVALID_VOLCENGINE_REGISTRY_CRED)); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/volcengine/VolcengineContainerRegistryClientTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/volcengine/VolcengineContainerRegistryClientTest.java new file mode 100644 index 0000000000..c675106382 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/client/volcengine/VolcengineContainerRegistryClientTest.java @@ -0,0 +1,277 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.client.volcengine; + +import com.nvidia.boot.registries.service.registry.client.WebClientUtils; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VOLCENGINE_CONTAINER_IMAGE_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VOLCENGINE_CONTAINER_IMAGE_TAG_NOT_FOUND; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VOLCENGINE_CONTAINER_IMAGE_WITH_TAG; +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineContainerRegistryClient.CONTAINER_IMAGE_URL_PATTERN; +import static com.nvidia.boot.registries.service.registry.client.volcengine.VolcengineRegistryUtils.parseArtifactUrl; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_INVALID_VOLCENGINE_REGISTRY_CRED; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_VOLCENGINE_REGISTRY_API_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_VOLCENGINE_REGISTRY_CLIENT_CALL_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_VOLCENGINE_REGISTRY_CRED; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.mock.volcengine.MockVolcengineRegistryServer; +import com.nvidia.boot.registries.service.registry.client.volcengine.dto.VolcengineArtifactComponents; +import com.nvidia.boot.registries.service.registry.client.volcengine.dto.VolcengineArtifactType; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.web.reactive.function.client.WebClient; + +class VolcengineContainerRegistryClientTest { + + private static VolcengineContainerRegistryClient volcengineContainerRegistryClient; + + // Test Volcengine URLs for integration testing + private static final String TEST_VOLCENGINE_CONTAINER_IMAGE_WITH_TAG_1 = + "test-registry-cn-beijing.cr.volces.com/test-namespace/test-repo:v1.0.0"; + private static final String TEST_VOLCENGINE_CONTAINER_IMAGE_WITHOUT_TAG_1 = + "test-registry-cn-beijing.cr.volces.com/test-namespace/test-repo"; + private static final String TEST_VOLCENGINE_CONTAINER_IMAGE_MULTI_REPO_1 = + "test-registry-ap-singapore.cr.volces.com/test-namespace/test-repo/sub-repo:latest"; + + @BeforeAll + static void beforeAll() { + volcengineContainerRegistryClient = new VolcengineContainerRegistryClient( + WebClientUtils.builder(), + MOCK_VOLCENGINE_REGISTRY_API_URL, + MOCK_VOLCENGINE_REGISTRY_CLIENT_CALL_TIMEOUT); + MockVolcengineRegistryServer.start(MOCK_VOLCENGINE_REGISTRY_API_URL); + } + + @AfterAll + static void cleanup() { + MockVolcengineRegistryServer.stop(); + } + + static Stream createValidImageUrls() { + return Stream.of( + Arguments.of(TEST_VOLCENGINE_CONTAINER_IMAGE_WITH_TAG_1, + "test-registry", "cn-beijing", "test-namespace", "test-repo", + "v1.0.0"), + Arguments.of(TEST_VOLCENGINE_CONTAINER_IMAGE_WITHOUT_TAG_1, + "test-registry", "cn-beijing", "test-namespace", "test-repo", + "latest"), + Arguments.of(TEST_VOLCENGINE_CONTAINER_IMAGE_MULTI_REPO_1, + "test-registry", "ap-singapore", "test-namespace", + "test-repo/sub-repo", "latest") + ); + } + + @ParameterizedTest + @MethodSource("createValidImageUrls") + void parseContainerImageUrl_WithValidFormat_Success(String imageUrl, + String expectedRegistry, + String expectedRegion, + String expectedNamespace, + String expectedRepository, + String expectedTag) { + + VolcengineArtifactComponents components = + parseArtifactUrl(imageUrl, CONTAINER_IMAGE_URL_PATTERN, + VolcengineArtifactType.IMAGE); + + assertNotNull(components); + assertEquals(expectedRegistry, components.registry()); + assertEquals(expectedRegion, components.region()); + assertEquals(expectedNamespace, components.namespace()); + assertEquals(expectedRepository, components.repository()); + assertEquals(expectedTag, components.tag()); + assertEquals(VolcengineArtifactType.IMAGE, components.type()); + } + + @ParameterizedTest + @ValueSource(strings = { + "", // empty string + " ", // whitespace only + "invalid-url", // not Volcengine format + "test.cr.volces.com/namespace/repo:tag", // invalid registry format + "test-registry.cr.volces.com/namespace/repo:tag", // missing region + "test-registry-invalid-region.cr.volces.com/namespace/repo:tag", // invalid region + "test-registry-cn-beijing.volces.com/namespace/repo:tag", // missing 'cr' + "test-registry-cn-beijing.cr.volces.com//repo:tag", // empty namespace + "test-registry-cn-beijing.cr.volces.com/namespace/:tag", // empty repository + "docker.io/library/nginx:latest", // not Volcengine URL + }) + void parseContainerImageUrl_WithInvalidFormats_Fail(String invalidUrl) { + assertThrows(BadRequestException.class, + () -> parseArtifactUrl(invalidUrl, + CONTAINER_IMAGE_URL_PATTERN, + VolcengineArtifactType.IMAGE)); + } + + @ParameterizedTest + @ValueSource(strings = { + "simple-repo", + "namespace/repo", + "deep/namespace/structure/repo", + "repo-with-dashes", + "repo_with_underscores", + "repo.with.dots", + "123numeric-repo" + }) + void parseContainerImageUrl_VariousRepositoryFormats_Success(String repositoryName) { + String imageUrl = + String.format("test-registry-cn-beijing.cr.volces.com/test-namespace/%s:latest", + repositoryName); + assertDoesNotThrow( + () -> parseArtifactUrl(imageUrl, + CONTAINER_IMAGE_URL_PATTERN, + VolcengineArtifactType.IMAGE)); + } + + @ParameterizedTest + @ValueSource(strings = { + "cn-beijing", + "cn-shanghai", + "ap-singapore", + "ap-mumbai", + "ap-southeast-1" + }) + void parseContainerImageUrl_VariousRegions_Success(String region) { + String imageUrl = + String.format("test-registry-%s.cr.volces.com/test-namespace/test-repo:latest", + region); + + assertDoesNotThrow( + () -> parseArtifactUrl(imageUrl, + CONTAINER_IMAGE_URL_PATTERN, + VolcengineArtifactType.IMAGE)); + } + + @ParameterizedTest + @ValueSource(strings = { + "latest", + "v1.0.0", + "1.2.3", + "dev-branch", + "feature_branch", + "release-2023-12-01" + }) + void parseContainerImageUrl_VariousTagFormats_Success(String tag) { + String imageUrl = + String.format("test-registry-cn-beijing.cr.volces.com/test-namespace/test-repo:%s", + tag); + + assertDoesNotThrow( + () -> parseArtifactUrl(imageUrl, + CONTAINER_IMAGE_URL_PATTERN, + VolcengineArtifactType.IMAGE)); + } + + static Stream createValidContainerImages() { + return Stream.of( + Arguments.of(TEST_VOLCENGINE_CONTAINER_IMAGE_WITH_TAG.toString()) + ); + } + + @ParameterizedTest + @MethodSource("createValidContainerImages") + void validateContainerImage_Success(String containerImageUrl) { + assertDoesNotThrow( + () -> volcengineContainerRegistryClient.validateContainerImage(containerImageUrl, + MOCK_VOLCENGINE_REGISTRY_CRED)); + } + + @Test + void validateContainerImage_PermissionDenied_Fail() { + assertThrows(ForbiddenException.class, () -> + volcengineContainerRegistryClient.validateContainerImage( + TEST_VOLCENGINE_CONTAINER_IMAGE_PERMISSION_DENIED.toString(), + MOCK_VOLCENGINE_REGISTRY_CRED)); + } + + @Test + void validateContainerImage_NotExist_Fail() { + assertThrows(NotFoundException.class, () -> + volcengineContainerRegistryClient.validateContainerImage( + TEST_VOLCENGINE_CONTAINER_IMAGE_TAG_NOT_FOUND.toString(), + MOCK_VOLCENGINE_REGISTRY_CRED)); + } + + @ParameterizedTest + @ValueSource(strings = { + "", // empty credentials + "invalid-base64", // invalid base64 + "dGVzdA==", // valid base64 but no colon (decodes to "test") + }) + void validateContainerImage_InvalidCredentials_Fail(String invalidCredentials) { + assertThrows(BadRequestException.class, () -> + volcengineContainerRegistryClient.validateContainerImage( + TEST_VOLCENGINE_CONTAINER_IMAGE_WITH_TAG.toString(), + invalidCredentials)); + } + + @Test + void validateCredential_ValidHostnameAndCredentials_Success() { + String hostname = "test-volcengine-registry-cn-beijing.cr.volces.com"; + assertDoesNotThrow(() -> + volcengineContainerRegistryClient.validateCredential(hostname, + MOCK_VOLCENGINE_REGISTRY_CRED)); + } + + @ParameterizedTest + @ValueSource(strings = { + "test-volcengine-registry-cn-beijing.cr.volces.com", + "test-volcengine-registry-cn-shanghai.cr.volces.com", + "my-registry-ap-southeast-1.cr.volces.com" + }) + void validateCredential_VariousValidHostnames_Success(String hostname) { + assertDoesNotThrow(() -> + volcengineContainerRegistryClient.validateCredential(hostname, + MOCK_VOLCENGINE_REGISTRY_CRED)); + } + + @ParameterizedTest + @ValueSource(strings = { + "invalid-hostname", + "test.cr.volces.com", // missing region + "test-registry.cr.volces.com", // missing region + "test-registry-invalid-region.cr.volces.com", // invalid region + "public.ecr.aws", // ECR public hostname + "" // empty hostname + }) + void validateCredential_InvalidHostname_Fail(String hostname) { + assertThrows(BadRequestException.class, () -> + volcengineContainerRegistryClient.validateCredential(hostname, + MOCK_VOLCENGINE_REGISTRY_CRED)); + } + + @Test + void validateCredential_InvalidCredentials_Fail() { + String hostname = "test-volcengine-registry-cn-beijing.cr.volces.com"; + assertThrows(BadRequestException.class, () -> + volcengineContainerRegistryClient.validateCredential(hostname, + MOCK_INVALID_VOLCENGINE_REGISTRY_CRED)); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/container/ContainerRegistryServiceTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/container/ContainerRegistryServiceTest.java new file mode 100644 index 0000000000..564d7edb38 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/container/ContainerRegistryServiceTest.java @@ -0,0 +1,492 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.container; + +import com.nvidia.boot.registries.service.registry.client.WebClientUtils; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ACR_CONTAINER_IMAGE_NOT_EXISTS; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ACR_CONTAINER_IMAGE_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ACR_CONTAINER_IMAGE_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ACR_CONTAINER_IMAGE_WITH_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ARTIFACTORY_CONTAINER_IMAGE_NOT_EXISTS; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ARTIFACTORY_CONTAINER_IMAGE_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ARTIFACTORY_CONTAINER_IMAGE_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ARTIFACTORY_CONTAINER_IMAGE_WITH_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ARTIFACTORY_REGISTRY; +import static com.nvidia.boot.mock.BootTestConstants.TEST_DOCKER_CONTAINER_IMAGE; +import static com.nvidia.boot.mock.BootTestConstants.TEST_DOCKER_CONTAINER_IMAGE_NOT_EXISTS; +import static com.nvidia.boot.mock.BootTestConstants.TEST_DOCKER_CONTAINER_IMAGE_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_DOCKER_CONTAINER_IMAGE_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_CONTAINER_IMAGE_DIGEST_NOT_FOUND; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_CONTAINER_IMAGE_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_CONTAINER_IMAGE_TAG_NOT_FOUND; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_CONTAINER_IMAGE_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_CONTAINER_IMAGE_WITH_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_PUBLIC_CONTAINER_IMAGE_DIGEST_NOT_FOUND; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_PUBLIC_CONTAINER_IMAGE_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_PUBLIC_CONTAINER_IMAGE_TAG_NOT_FOUND; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_PUBLIC_CONTAINER_IMAGE_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_PUBLIC_CONTAINER_IMAGE_WITH_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_HARBOR_CONTAINER_IMAGE_NOT_EXISTS; +import static com.nvidia.boot.mock.BootTestConstants.TEST_HARBOR_CONTAINER_IMAGE_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_HARBOR_CONTAINER_IMAGE_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_HARBOR_CONTAINER_IMAGE_WITH_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_HARBOR_REGISTRY; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ACR_CREDENTIALS; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ARTIFACTORY_CREDENTIALS; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ARTIFACTORY_REGISTRY_AUTH_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ARTIFACTORY_REGISTRY_CLIENT_CALL_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ARTIFACTORY_REGISTRY_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_AZURE_REGISTRY_AUTH_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_AZURE_REGISTRY_CLIENT_CALL_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_AZURE_REGISTRY_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_DOCKER_CONTAINER_REGISTRY_CRED; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_DOCKER_REGISTRY_CLIENT_CALL_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_DOCKER_REGISTRY_OAUTH2_GROUP_SCOPE; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_DOCKER_REGISTRY_OAUTH2_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_DOCKER_REGISTRY_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ECR_PUBLIC_REGISTRY_API_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ECR_PUBLIC_REGISTRY_CLIENT_CALL_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ECR_PUBLIC_REGISTRY_CRED; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ECR_REGISTRY_API_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ECR_REGISTRY_CLIENT_CALL_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ECR_REGISTRY_CRED; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_HARBOR_CREDENTIALS; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_HARBOR_REGISTRY_AUTH_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_HARBOR_REGISTRY_CLIENT_CALL_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_HARBOR_REGISTRY_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_INVALID_ECR_REGISTRY_CRED; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_CONTAINER_REGISTRY_CRED; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_CONTAINER_REGISTRY_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_CLIENT_CALL_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_CLIENT_CONNECT_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_CLIENT_READ_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_CLIENT_WRITE_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.TEST_AZURE_REGISTRY_GLOBAL_HOST_NAME; +import static com.nvidia.boot.registries.util.TestConstants.TEST_CONTAINER_IMAGE_1; +import static com.nvidia.boot.registries.util.TestConstants.TEST_CONTAINER_IMAGE_UNKNOWN_REGISTRY; +import static com.nvidia.boot.registries.util.TestConstants.TEST_CONTAINER_REGISTRY_HOST_NAME_1; +import static com.nvidia.boot.registries.util.TestConstants.TEST_DOCKER_REGISTRY; +import static com.nvidia.boot.registries.util.TestConstants.TEST_ECR_PRIVATE_REGISTRY_HOST_NAME; +import static com.nvidia.boot.registries.util.TestConstants.TEST_ECR_PUBLIC_REGISTRY_HOST_NAME; +import static com.nvidia.boot.registries.util.TestConstants.TEST_ECR_REGISTRY_GLOBAL_HOST_NAME; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_ARTIFACT_REGISTRY; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_CONTAINER_IMAGE; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_CONTAINER_IMAGE_NOT_EXISTS; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_CONTAINER_IMAGE_PERMISSION_DENIED; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_CONTAINER_IMAGE_WITHOUT_TAG; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_CONTAINER_IMAGE_WITH_CANARY_HOST; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_CONTAINER_IMAGE_WITH_DIGEST; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_CONTAINER_IMAGE_WITH_INVALID_TAG; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_CONTAINER_REGISTRY; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_HELM_REGISTRY; +import static com.nvidia.boot.registries.util.TestConstants.TEST_RECOGNIZED_CONTAINER_REGISTRY_KEY_1; +import static com.nvidia.boot.registries.util.TestConstants.TEST_REGISTRY_CONFIG_PROPERTIES; +import static com.nvidia.boot.registries.util.TestUtils.registryConfig; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.mock.artifactory.MockArtifactoryAuthServer; +import com.nvidia.boot.mock.azure.MockAcrAuthServer; +import com.nvidia.boot.mock.docker.MockDockerRegistryAuthServer; +import com.nvidia.boot.mock.docker.MockDockerRegistryServer; +import com.nvidia.boot.mock.ecr.MockEcrPrivateRegistryServer; +import com.nvidia.boot.mock.ecr.MockEcrPublicRegistryServer; +import com.nvidia.boot.mock.harbor.MockHarborAuthServer; +import com.nvidia.boot.mock.ngc.MockNgcContainerRegistryServer; +import com.nvidia.boot.mock.oci.MockOciRegistryServer; +import com.nvidia.boot.registries.configurations.RegistryConfigurationProperties.RecognizedRegistryConfiguration; +import com.nvidia.boot.registries.service.registry.RegistryLookupService; +import com.nvidia.boot.registries.service.registry.RegistryMapperService; +import com.nvidia.boot.registries.service.registry.RegistryValidationService; +import com.nvidia.boot.registries.service.registry.client.acr.AzureRegistryClient; +import com.nvidia.boot.registries.service.registry.client.artifactory.ArtifactoryClient; +import com.nvidia.boot.registries.service.registry.client.docker.DockerRegistryClient; +import com.nvidia.boot.registries.service.registry.client.ecr.pub.EcrPublicContainerRegistryClient; +import com.nvidia.boot.registries.service.registry.client.ecr.pvt.EcrPrivateContainerRegistryClient; +import com.nvidia.boot.registries.service.registry.client.harbor.HarborRegistryClient; +import com.nvidia.boot.registries.service.registry.client.ngc.NgcContainerRegistryClient; +import com.nvidia.boot.registries.service.registry.container.acr.AcrContainerRegistry; +import com.nvidia.boot.registries.service.registry.container.artifactory.ArtifactoryContainerRegistry; +import com.nvidia.boot.registries.service.registry.container.docker.DockerContainerRegistry; +import com.nvidia.boot.registries.service.registry.container.ecr.pub.EcrPublicContainerRegistry; +import com.nvidia.boot.registries.service.registry.container.ecr.pvt.EcrPrivateContainerRegistry; +import com.nvidia.boot.registries.service.registry.container.harbor.HarborContainerRegistry; +import com.nvidia.boot.registries.service.registry.container.ngc.NgcContainerRegistry; +import java.net.URI; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.web.reactive.function.client.WebClient; + +class ContainerRegistryServiceTest { + + private static ContainerRegistryService containerRegistryService; + private static MockOciRegistryServer mockAcrContainerRegistryServer; + private static MockOciRegistryServer mockHarborOciRegistryServer; + private static MockOciRegistryServer mockArtifactoryContainerRegistryServer; + + @BeforeAll + static void beforeAll() { + MockNgcContainerRegistryServer.start(MOCK_NGC_CONTAINER_REGISTRY_URL); + MockDockerRegistryServer.start(MOCK_DOCKER_REGISTRY_URL); + MockDockerRegistryAuthServer.start(MOCK_DOCKER_REGISTRY_OAUTH2_URL); + MockEcrPrivateRegistryServer.start(MOCK_ECR_REGISTRY_API_URL); + MockEcrPublicRegistryServer.start(MOCK_ECR_PUBLIC_REGISTRY_API_URL); + MockAcrAuthServer.start(MOCK_AZURE_REGISTRY_AUTH_URL); + mockAcrContainerRegistryServer = new MockOciRegistryServer(); + mockAcrContainerRegistryServer.start(MOCK_AZURE_REGISTRY_URL); + mockHarborOciRegistryServer = new MockOciRegistryServer(); + mockHarborOciRegistryServer.start(MOCK_HARBOR_REGISTRY_URL); + MockHarborAuthServer.start(MOCK_HARBOR_REGISTRY_AUTH_URL); + MockArtifactoryAuthServer.start(MOCK_ARTIFACTORY_REGISTRY_AUTH_URL); + mockArtifactoryContainerRegistryServer = new MockOciRegistryServer(); + mockArtifactoryContainerRegistryServer.start(MOCK_ARTIFACTORY_REGISTRY_URL); + + var ngcContainerRegistryClient = new NgcContainerRegistryClient( + WebClientUtils.builder(), + MOCK_NGC_CONTAINER_REGISTRY_URL, + MOCK_NGC_REGISTRY_CLIENT_CALL_TIMEOUT, + MOCK_NGC_REGISTRY_CLIENT_READ_TIMEOUT, + MOCK_NGC_REGISTRY_CLIENT_WRITE_TIMEOUT, + MOCK_NGC_REGISTRY_CLIENT_CONNECT_TIMEOUT); + // manually set the host name for test so it's not localhost + ngcContainerRegistryClient.setHostname(TEST_NGC_CONTAINER_REGISTRY); + var dockerRegistryClient = new DockerRegistryClient( + WebClientUtils.builder(), + MOCK_DOCKER_REGISTRY_URL, + MOCK_DOCKER_REGISTRY_CLIENT_CALL_TIMEOUT, + MOCK_DOCKER_REGISTRY_OAUTH2_URL, + MOCK_DOCKER_REGISTRY_OAUTH2_GROUP_SCOPE); + // manually set the host name for test so it's not localhost + dockerRegistryClient.setHostname(TEST_DOCKER_REGISTRY); + + var ecrContainerRegistryClient = new EcrPrivateContainerRegistryClient( + WebClientUtils.builder(), + MOCK_ECR_REGISTRY_API_URL, + MOCK_ECR_REGISTRY_CLIENT_CALL_TIMEOUT); + var ecrPublicContainerRegistryClient = new EcrPublicContainerRegistryClient( + WebClientUtils.builder(), + MOCK_ECR_PUBLIC_REGISTRY_API_URL, + MOCK_ECR_PUBLIC_REGISTRY_CLIENT_CALL_TIMEOUT); + + var azureRegistryClient = new AzureRegistryClient( + WebClientUtils.builder(), + MOCK_AZURE_REGISTRY_URL, + MOCK_AZURE_REGISTRY_CLIENT_CALL_TIMEOUT, + MOCK_AZURE_REGISTRY_AUTH_URL); + + var harborRegistryClient = new HarborRegistryClient( + WebClientUtils.builder(), + MOCK_HARBOR_REGISTRY_URL, + MOCK_HARBOR_REGISTRY_CLIENT_CALL_TIMEOUT, + MOCK_HARBOR_REGISTRY_AUTH_URL); + + var artifactoryClient = new ArtifactoryClient( + WebClientUtils.builder(), + MOCK_ARTIFACTORY_REGISTRY_URL, + MOCK_ARTIFACTORY_REGISTRY_CLIENT_CALL_TIMEOUT, + MOCK_ARTIFACTORY_REGISTRY_AUTH_URL); + + var ngcContainerRegistry = new NgcContainerRegistry(ngcContainerRegistryClient); + var dockerContainerRegistry = new DockerContainerRegistry(dockerRegistryClient); + var ecrContainerRegistry = new EcrPrivateContainerRegistry(ecrContainerRegistryClient); + var ecrPublicContainerRegistry = + new EcrPublicContainerRegistry(ecrPublicContainerRegistryClient); + var acrContainerRegistry = new AcrContainerRegistry(azureRegistryClient); + var harborContainerRegistry = new HarborContainerRegistry(harborRegistryClient); + var artifactoryContainerRegistry = new ArtifactoryContainerRegistry(artifactoryClient); + + var registryMapperService = new RegistryMapperService(TEST_NGC_CONTAINER_REGISTRY, + TEST_NGC_ARTIFACT_REGISTRY, + TEST_NGC_HELM_REGISTRY); + var registryLookupService = new RegistryLookupService( + Collections.emptyList(), Collections.emptyList(), + Collections.emptyList(), Collections.emptyList(), + TEST_REGISTRY_CONFIG_PROPERTIES.getRecognized(), registryMapperService); + var registryValidationService = new RegistryValidationService(registryLookupService); + containerRegistryService = new ContainerRegistryService( + List.of(ngcContainerRegistry, + dockerContainerRegistry, + ecrContainerRegistry, + ecrPublicContainerRegistry, + acrContainerRegistry, + harborContainerRegistry, + artifactoryContainerRegistry), + registryMapperService, + registryValidationService); + containerRegistryService.overwriteRegistryHostnameMap( + URI.create(MOCK_ECR_REGISTRY_API_URL).getHost(), + TEST_ECR_REGISTRY_GLOBAL_HOST_NAME); + containerRegistryService.overwriteRegistryHostnameMap( + URI.create(MOCK_ECR_PUBLIC_REGISTRY_API_URL).getHost(), + TEST_ECR_PUBLIC_REGISTRY_HOST_NAME); + containerRegistryService.overwriteRegistryHostnameMap( + URI.create(MOCK_AZURE_REGISTRY_URL).getHost(), + TEST_AZURE_REGISTRY_GLOBAL_HOST_NAME); + containerRegistryService.overwriteRegistryHostnameMap( + URI.create(MOCK_HARBOR_REGISTRY_URL).getHost(), + TEST_HARBOR_REGISTRY); + containerRegistryService.overwriteRegistryHostnameMap( + URI.create(MOCK_ARTIFACTORY_REGISTRY_URL).getHost(), + TEST_ARTIFACTORY_REGISTRY); + } + + @AfterAll + static void cleanup() { + MockNgcContainerRegistryServer.stop(); + MockDockerRegistryServer.stop(); + MockDockerRegistryAuthServer.stop(); + MockEcrPrivateRegistryServer.stop(); + MockEcrPublicRegistryServer.stop(); + MockAcrAuthServer.stop(); + mockAcrContainerRegistryServer.stop(); + MockHarborAuthServer.stop(); + mockHarborOciRegistryServer.stop(); + MockArtifactoryAuthServer.stop(); + mockArtifactoryContainerRegistryServer.stop(); + } + + static Stream getValidContainerRegistry() { + return Stream.of( + // ngc + Arguments.of(TEST_NGC_CONTAINER_IMAGE.toString(), + MOCK_NGC_CONTAINER_REGISTRY_CRED), + Arguments.of(TEST_NGC_CONTAINER_IMAGE_WITH_DIGEST.toString(), + MOCK_NGC_CONTAINER_REGISTRY_CRED), + Arguments.of(TEST_NGC_CONTAINER_IMAGE_WITH_CANARY_HOST.toString(), + MOCK_NGC_CONTAINER_REGISTRY_CRED), + Arguments.of(TEST_NGC_CONTAINER_IMAGE_WITHOUT_TAG.toString(), + MOCK_NGC_CONTAINER_REGISTRY_CRED), + // docker + Arguments.of(TEST_DOCKER_CONTAINER_IMAGE.toString(), + MOCK_DOCKER_CONTAINER_REGISTRY_CRED), + Arguments.of(TEST_DOCKER_CONTAINER_IMAGE_WITH_DIGEST.toString(), + MOCK_DOCKER_CONTAINER_REGISTRY_CRED), + + // ecr + Arguments.of(TEST_ECR_CONTAINER_IMAGE_WITH_TAG.toString(), + MOCK_ECR_REGISTRY_CRED), + Arguments.of(TEST_ECR_CONTAINER_IMAGE_WITH_DIGEST.toString(), + MOCK_ECR_REGISTRY_CRED), + + // ecr public + Arguments.of(TEST_ECR_PUBLIC_CONTAINER_IMAGE_WITH_TAG.toString(), + MOCK_ECR_PUBLIC_REGISTRY_CRED), + Arguments.of(TEST_ECR_PUBLIC_CONTAINER_IMAGE_WITH_DIGEST.toString(), + MOCK_ECR_PUBLIC_REGISTRY_CRED), + + // acr + Arguments.of(TEST_ACR_CONTAINER_IMAGE_WITH_TAG.toString(), + MOCK_ACR_CREDENTIALS), + Arguments.of(TEST_ACR_CONTAINER_IMAGE_WITH_DIGEST.toString(), + MOCK_ACR_CREDENTIALS), + + // harbor + Arguments.of(TEST_HARBOR_CONTAINER_IMAGE_WITH_TAG.toString(), + MOCK_HARBOR_CREDENTIALS), + Arguments.of(TEST_HARBOR_CONTAINER_IMAGE_WITH_DIGEST.toString(), + MOCK_HARBOR_CREDENTIALS), + + // artifactory + Arguments.of(TEST_ARTIFACTORY_CONTAINER_IMAGE_WITH_TAG.toString(), + MOCK_ARTIFACTORY_CREDENTIALS), + Arguments.of(TEST_ARTIFACTORY_CONTAINER_IMAGE_WITH_DIGEST.toString(), + MOCK_ARTIFACTORY_CREDENTIALS)); + } + + @ParameterizedTest + @MethodSource("getValidContainerRegistry") + void validateArtifact_Success(String imageUrl, String apiKey) { + containerRegistryService.validateArtifact(imageUrl, List.of(apiKey)); + } + + static Stream getInvalidContainerRegistry() { + return Stream.of( + // ngc + Arguments.of(TEST_CONTAINER_IMAGE_UNKNOWN_REGISTRY.toString(), + List.of(MOCK_NGC_CONTAINER_REGISTRY_CRED), + BadRequestException.class), + Arguments.of(TEST_NGC_CONTAINER_IMAGE_NOT_EXISTS.toString(), + List.of(MOCK_NGC_CONTAINER_REGISTRY_CRED), + NotFoundException.class), + Arguments.of(TEST_NGC_CONTAINER_IMAGE_PERMISSION_DENIED.toString(), + List.of(MOCK_NGC_CONTAINER_REGISTRY_CRED), + ForbiddenException.class), + Arguments.of(TEST_NGC_CONTAINER_IMAGE_WITH_INVALID_TAG.toString(), + List.of(MOCK_NGC_CONTAINER_REGISTRY_CRED), + BadRequestException.class), + Arguments.of(TEST_NGC_CONTAINER_IMAGE.toString(), + List.of(), + IllegalStateException.class), + // docker + Arguments.of(TEST_DOCKER_CONTAINER_IMAGE_PERMISSION_DENIED.toString(), + List.of(MOCK_DOCKER_CONTAINER_REGISTRY_CRED), + ForbiddenException.class), + Arguments.of(TEST_DOCKER_CONTAINER_IMAGE_NOT_EXISTS.toString(), + List.of(MOCK_DOCKER_CONTAINER_REGISTRY_CRED), + NotFoundException.class), + Arguments.of(TEST_DOCKER_CONTAINER_IMAGE.toString(), + List.of(), + IllegalStateException.class), + // ecr + Arguments.of(TEST_ECR_CONTAINER_IMAGE_PERMISSION_DENIED.toString(), + List.of(MOCK_ECR_REGISTRY_CRED), + ForbiddenException.class), + Arguments.of(TEST_ECR_CONTAINER_IMAGE_TAG_NOT_FOUND.toString(), + List.of(MOCK_ECR_REGISTRY_CRED), + BadRequestException.class), + Arguments.of(TEST_ECR_CONTAINER_IMAGE_DIGEST_NOT_FOUND.toString(), + List.of(MOCK_ECR_REGISTRY_CRED), + BadRequestException.class), + + // ecr public + Arguments.of(TEST_ECR_PUBLIC_CONTAINER_IMAGE_PERMISSION_DENIED.toString(), + List.of(MOCK_ECR_PUBLIC_REGISTRY_CRED), + ForbiddenException.class), + Arguments.of(TEST_ECR_PUBLIC_CONTAINER_IMAGE_TAG_NOT_FOUND.toString(), + List.of(MOCK_ECR_PUBLIC_REGISTRY_CRED), + BadRequestException.class), + Arguments.of(TEST_ECR_PUBLIC_CONTAINER_IMAGE_DIGEST_NOT_FOUND.toString(), + List.of(MOCK_ECR_PUBLIC_REGISTRY_CRED), + BadRequestException.class), + + // acr + Arguments.of(TEST_ACR_CONTAINER_IMAGE_PERMISSION_DENIED.toString(), + List.of(MOCK_ACR_CREDENTIALS), + ForbiddenException.class), + Arguments.of(TEST_ACR_CONTAINER_IMAGE_NOT_EXISTS.toString(), + List.of(MOCK_ACR_CREDENTIALS), + NotFoundException.class), + + // harbor + Arguments.of(TEST_HARBOR_CONTAINER_IMAGE_PERMISSION_DENIED.toString(), + List.of(MOCK_HARBOR_CREDENTIALS), + ForbiddenException.class), + Arguments.of(TEST_HARBOR_CONTAINER_IMAGE_NOT_EXISTS.toString(), + List.of(MOCK_HARBOR_CREDENTIALS), + NotFoundException.class), + + // artifactory + Arguments.of(TEST_ARTIFACTORY_CONTAINER_IMAGE_PERMISSION_DENIED.toString(), + List.of(MOCK_ARTIFACTORY_CREDENTIALS), + ForbiddenException.class), + Arguments.of(TEST_ARTIFACTORY_CONTAINER_IMAGE_NOT_EXISTS.toString(), + List.of(MOCK_ARTIFACTORY_CREDENTIALS), + NotFoundException.class)); + } + + @ParameterizedTest + @MethodSource("getInvalidContainerRegistry") + void validateArtifact_Fail(String imageUrl, + List apiKeys, + Class expectedException) { + assertThrows(expectedException, + () -> containerRegistryService.validateArtifact(imageUrl, apiKeys)); + } + + static Stream getValidCredentialsForContainerRegistry() { + return Stream.of( + // ECR private + Arguments.of(TEST_ECR_PRIVATE_REGISTRY_HOST_NAME, + List.of(MOCK_ECR_REGISTRY_CRED)), + // ECR public + Arguments.of(TEST_ECR_PUBLIC_REGISTRY_HOST_NAME, + List.of(MOCK_ECR_PUBLIC_REGISTRY_CRED)), + // Multiple credentials with one valid + Arguments.of(TEST_ECR_PRIVATE_REGISTRY_HOST_NAME, + List.of("invalid-cred", MOCK_ECR_REGISTRY_CRED))); + } + + @ParameterizedTest + @MethodSource("getValidCredentialsForContainerRegistry") + void validateCredentials_Success(String hostname, List credentials) { + assertDoesNotThrow(() -> containerRegistryService.validateCredentials(hostname, + credentials)); + } + + static Stream getInvalidCredentialsForContainerRegistry() { + return Stream.of( + // Invalid credentials + Arguments.of(TEST_ECR_PRIVATE_REGISTRY_HOST_NAME, + List.of(MOCK_INVALID_ECR_REGISTRY_CRED), + BadRequestException.class), + // Empty credentials list + Arguments.of(TEST_ECR_PRIVATE_REGISTRY_HOST_NAME, + List.of(), + IllegalStateException.class), + // Unknown hostname + Arguments.of(TEST_CONTAINER_IMAGE_UNKNOWN_REGISTRY.toString(), + List.of(MOCK_ECR_REGISTRY_CRED), + BadRequestException.class)); + } + + @ParameterizedTest + @MethodSource("getInvalidCredentialsForContainerRegistry") + void validateCredentials_Fail(String hostname, + List credentials, + Class expectedException) { + assertThrows(expectedException, + () -> containerRegistryService.validateCredentials(hostname, credentials)); + } + + @Test + void validateArtifact_ArtifactValidationDisabled_ReturnsWithoutValidating() { + var service = createServiceWithValidationDisabled(true, false); + + assertDoesNotThrow(() -> service.validateArtifact( + TEST_CONTAINER_IMAGE_1.toString(), List.of("dummy-cred"))); + } + + @Test + void validateCredentials_CredentialValidationDisabled_ReturnsWithoutValidating() { + var service = createServiceWithValidationDisabled(false, true); + + assertDoesNotThrow(() -> service.validateCredentials( + TEST_CONTAINER_REGISTRY_HOST_NAME_1, List.of("dummy-cred"))); + } + + private static ContainerRegistryService createServiceWithValidationDisabled( + boolean credentialValidation, boolean artifactValidation) { + var recognizedRegistryConfig = new RecognizedRegistryConfiguration(); + recognizedRegistryConfig.setContainer(Map.of( + TEST_RECOGNIZED_CONTAINER_REGISTRY_KEY_1, + registryConfig(TEST_CONTAINER_REGISTRY_HOST_NAME_1, credentialValidation, + artifactValidation))); + + var mapperService = new RegistryMapperService( + TEST_NGC_CONTAINER_REGISTRY, + TEST_NGC_ARTIFACT_REGISTRY, + TEST_NGC_HELM_REGISTRY); + var lookupService = new RegistryLookupService( + Collections.emptyList(), Collections.emptyList(), + Collections.emptyList(), Collections.emptyList(), + recognizedRegistryConfig, mapperService); + var validationService = new RegistryValidationService(lookupService); + return new ContainerRegistryService(List.of(), mapperService, validationService); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/helm/HelmRegistryServiceTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/helm/HelmRegistryServiceTest.java new file mode 100644 index 0000000000..420432b295 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/helm/HelmRegistryServiceTest.java @@ -0,0 +1,463 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.helm; + +import com.nvidia.boot.registries.service.registry.client.WebClientUtils; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ACR_HELM_CHART_NOT_EXISTS; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ACR_HELM_CHART_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ACR_HELM_CHART_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ACR_HELM_CHART_WITH_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ARTIFACTORY_HELM_CHART_NOT_EXISTS; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ARTIFACTORY_HELM_CHART_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ARTIFACTORY_HELM_CHART_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ARTIFACTORY_HELM_CHART_WITH_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ARTIFACTORY_REGISTRY; +import static com.nvidia.boot.mock.BootTestConstants.TEST_DOCKER_CONTAINER_IMAGE; +import static com.nvidia.boot.mock.BootTestConstants.TEST_DOCKER_CONTAINER_IMAGE_NOT_EXISTS; +import static com.nvidia.boot.mock.BootTestConstants.TEST_DOCKER_CONTAINER_IMAGE_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_DOCKER_CONTAINER_IMAGE_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_HELM_CHART_DIGEST_NOT_FOUND; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_HELM_CHART_TAG_NOT_FOUND; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_HELM_CHART_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_HELM_CHART_WITH_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_PUBLIC_HELM_CHART_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_ECR_PUBLIC_HELM_CHART_WITH_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_HARBOR_HELM_CHART_NOT_EXISTS; +import static com.nvidia.boot.mock.BootTestConstants.TEST_HARBOR_HELM_CHART_PERMISSION_DENIED; +import static com.nvidia.boot.mock.BootTestConstants.TEST_HARBOR_HELM_CHART_WITH_DIGEST; +import static com.nvidia.boot.mock.BootTestConstants.TEST_HARBOR_HELM_CHART_WITH_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_HARBOR_REGISTRY; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ACR_CREDENTIALS; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ARTIFACTORY_CREDENTIALS; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ARTIFACTORY_REGISTRY_AUTH_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ARTIFACTORY_REGISTRY_CLIENT_CALL_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ARTIFACTORY_REGISTRY_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_AZURE_REGISTRY_AUTH_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_AZURE_REGISTRY_CLIENT_CALL_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_AZURE_REGISTRY_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_DOCKER_CONTAINER_REGISTRY_CRED; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_DOCKER_REGISTRY_CLIENT_CALL_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_DOCKER_REGISTRY_OAUTH2_GROUP_SCOPE; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_DOCKER_REGISTRY_OAUTH2_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_DOCKER_REGISTRY_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ECR_PUBLIC_REGISTRY_API_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ECR_PUBLIC_REGISTRY_CLIENT_CALL_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ECR_PUBLIC_REGISTRY_CRED; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ECR_REGISTRY_API_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ECR_REGISTRY_CLIENT_CALL_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_ECR_REGISTRY_CRED; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_HARBOR_CREDENTIALS; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_HARBOR_REGISTRY_AUTH_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_HARBOR_REGISTRY_CLIENT_CALL_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_HARBOR_REGISTRY_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_INVALID_ECR_REGISTRY_CRED; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_HELM_REGISTRY_CRED; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_BASE_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_CLIENT_CALL_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_CLIENT_CONNECT_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_CLIENT_READ_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_CLIENT_WRITE_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_OAUTH2_BASE_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_OAUTH2_GROUP_SCOPE; +import static com.nvidia.boot.registries.util.TestConstants.TEST_AZURE_REGISTRY_GLOBAL_HOST_NAME; +import static com.nvidia.boot.registries.util.TestConstants.TEST_DOCKER_REGISTRY; +import static com.nvidia.boot.registries.util.TestConstants.TEST_ECR_PRIVATE_REGISTRY_HOST_NAME; +import static com.nvidia.boot.registries.util.TestConstants.TEST_ECR_PUBLIC_REGISTRY_HOST_NAME; +import static com.nvidia.boot.registries.util.TestConstants.TEST_ECR_REGISTRY_GLOBAL_HOST_NAME; +import static com.nvidia.boot.registries.util.TestConstants.TEST_HELM_CHART_1; +import static com.nvidia.boot.registries.util.TestConstants.TEST_HELM_CHART_NOT_EXISTS; +import static com.nvidia.boot.registries.util.TestConstants.TEST_HELM_CHART_UNKNOWN_REGISTRY; +import static com.nvidia.boot.registries.util.TestConstants.TEST_HELM_REGISTRY_HOST_NAME_1; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_ARTIFACT_REGISTRY; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_CONTAINER_REGISTRY; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_HELM_CHART; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_HELM_CHART_PERMISSION_DENIED; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_HELM_CHART_WITH_CANARY_HOST; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_HELM_REGISTRY; +import static com.nvidia.boot.registries.util.TestConstants.TEST_RECOGNIZED_HELM_REGISTRY_KEY_1; +import static com.nvidia.boot.registries.util.TestConstants.TEST_REGISTRY_CONFIG_PROPERTIES; +import static com.nvidia.boot.registries.util.TestUtils.registryConfig; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.mock.artifactory.MockArtifactoryAuthServer; +import com.nvidia.boot.mock.azure.MockAcrAuthServer; +import com.nvidia.boot.mock.docker.MockDockerRegistryAuthServer; +import com.nvidia.boot.mock.docker.MockDockerRegistryServer; +import com.nvidia.boot.mock.ecr.MockEcrPrivateRegistryServer; +import com.nvidia.boot.mock.ecr.MockEcrPublicRegistryServer; +import com.nvidia.boot.mock.harbor.MockHarborAuthServer; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.boot.mock.oci.MockOciRegistryServer; +import com.nvidia.boot.registries.configurations.RegistryConfigurationProperties.RecognizedRegistryConfiguration; +import com.nvidia.boot.registries.service.registry.RegistryLookupService; +import com.nvidia.boot.registries.service.registry.RegistryMapperService; +import com.nvidia.boot.registries.service.registry.RegistryValidationService; +import com.nvidia.boot.registries.service.registry.client.acr.AzureRegistryClient; +import com.nvidia.boot.registries.service.registry.client.artifactory.ArtifactoryClient; +import com.nvidia.boot.registries.service.registry.client.docker.DockerRegistryClient; +import com.nvidia.boot.registries.service.registry.client.ecr.pub.EcrPublicArtifactRegistryClient; +import com.nvidia.boot.registries.service.registry.client.ecr.pvt.EcrPrivateArtifactRegistryClient; +import com.nvidia.boot.registries.service.registry.client.harbor.HarborRegistryClient; +import com.nvidia.boot.registries.service.registry.client.ngc.NgcArtifactRegistryClient; +import com.nvidia.boot.registries.service.registry.helm.acr.AcrHelmRegistry; +import com.nvidia.boot.registries.service.registry.helm.artifactory.ArtifactoryHelmRegistry; +import com.nvidia.boot.registries.service.registry.helm.docker.DockerHelmRegistry; +import com.nvidia.boot.registries.service.registry.helm.ecr.pub.EcrPublicHelmRegistry; +import com.nvidia.boot.registries.service.registry.helm.ecr.pvt.EcrPrivateHelmRegistry; +import com.nvidia.boot.registries.service.registry.helm.harbor.HarborHelmRegistry; +import com.nvidia.boot.registries.service.registry.helm.ngc.NgcHelmRegistry; +import java.net.URI; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.web.reactive.function.client.WebClient; + +class HelmRegistryServiceTest { + + private static HelmRegistryService helmRegistryService; + private static MockOciRegistryServer mockAcrHelmRegistryServer; + private static MockOciRegistryServer mockHarborHelmRegistryServer; + private static MockOciRegistryServer mockArtifactoryHelmRegistryServer; + + @BeforeAll + static void beforeAll() { + MockCasServer.start(MOCK_NGC_REGISTRY_OAUTH2_BASE_URL, MOCK_NGC_REGISTRY_BASE_URL); + MockDockerRegistryServer.start(MOCK_DOCKER_REGISTRY_URL); + MockDockerRegistryAuthServer.start(MOCK_DOCKER_REGISTRY_OAUTH2_URL); + MockEcrPrivateRegistryServer.start(MOCK_ECR_REGISTRY_API_URL); + MockEcrPublicRegistryServer.start(MOCK_ECR_PUBLIC_REGISTRY_API_URL); + MockAcrAuthServer.start(MOCK_AZURE_REGISTRY_AUTH_URL); + mockAcrHelmRegistryServer = new MockOciRegistryServer(); + mockAcrHelmRegistryServer.start(MOCK_AZURE_REGISTRY_URL); + MockHarborAuthServer.start(MOCK_HARBOR_REGISTRY_AUTH_URL); + mockHarborHelmRegistryServer = new MockOciRegistryServer(); + mockHarborHelmRegistryServer.start(MOCK_HARBOR_REGISTRY_URL); + MockArtifactoryAuthServer.start(MOCK_ARTIFACTORY_REGISTRY_AUTH_URL); + mockArtifactoryHelmRegistryServer = new MockOciRegistryServer(); + mockArtifactoryHelmRegistryServer.start(MOCK_ARTIFACTORY_REGISTRY_URL); + + var ngcArtifactRegistryClient = new NgcArtifactRegistryClient( + WebClientUtils.builder(), + MOCK_NGC_REGISTRY_BASE_URL, + MOCK_NGC_REGISTRY_CLIENT_CALL_TIMEOUT, + MOCK_NGC_REGISTRY_CLIENT_READ_TIMEOUT, + MOCK_NGC_REGISTRY_CLIENT_WRITE_TIMEOUT, + MOCK_NGC_REGISTRY_CLIENT_CONNECT_TIMEOUT, + MOCK_NGC_REGISTRY_OAUTH2_BASE_URL, + MOCK_NGC_REGISTRY_OAUTH2_GROUP_SCOPE); + // manually set the host name for test so it's not localhost + ngcArtifactRegistryClient.setHostname(TEST_NGC_HELM_REGISTRY); + + var ecrArtifactRegistryClient = new EcrPrivateArtifactRegistryClient( + WebClientUtils.builder(), + MOCK_ECR_REGISTRY_API_URL, + MOCK_ECR_REGISTRY_CLIENT_CALL_TIMEOUT); + + EcrPublicArtifactRegistryClient ecrPublicArtifactRegistryClient = + new EcrPublicArtifactRegistryClient( + WebClientUtils.builder(), + MOCK_ECR_PUBLIC_REGISTRY_API_URL, + MOCK_ECR_PUBLIC_REGISTRY_CLIENT_CALL_TIMEOUT); + + var dockerRegistryClient = new DockerRegistryClient( + WebClientUtils.builder(), + MOCK_DOCKER_REGISTRY_URL, + MOCK_DOCKER_REGISTRY_CLIENT_CALL_TIMEOUT, + MOCK_DOCKER_REGISTRY_OAUTH2_URL, + MOCK_DOCKER_REGISTRY_OAUTH2_GROUP_SCOPE); + dockerRegistryClient.setHostname(TEST_DOCKER_REGISTRY); + + var azureRegistryClient = new AzureRegistryClient( + WebClientUtils.builder(), + MOCK_AZURE_REGISTRY_URL, + MOCK_AZURE_REGISTRY_CLIENT_CALL_TIMEOUT, + MOCK_AZURE_REGISTRY_AUTH_URL); + + var harborRegistryClient = new HarborRegistryClient( + WebClientUtils.builder(), + MOCK_HARBOR_REGISTRY_URL, + MOCK_HARBOR_REGISTRY_CLIENT_CALL_TIMEOUT, + MOCK_HARBOR_REGISTRY_AUTH_URL); + + var artifactoryClient = new ArtifactoryClient( + WebClientUtils.builder(), + MOCK_ARTIFACTORY_REGISTRY_URL, + MOCK_ARTIFACTORY_REGISTRY_CLIENT_CALL_TIMEOUT, + MOCK_ARTIFACTORY_REGISTRY_AUTH_URL); + + var ngcHelmRegistry = new NgcHelmRegistry(ngcArtifactRegistryClient); + var dockerHelmRegistry = new DockerHelmRegistry(dockerRegistryClient); + var ecrHelmRegistry = new EcrPrivateHelmRegistry(ecrArtifactRegistryClient); + var ecrPublicHelmRegistry = new EcrPublicHelmRegistry(ecrPublicArtifactRegistryClient); + var acrHelmRegistry = new AcrHelmRegistry(azureRegistryClient); + var harborHelmRegistry = new HarborHelmRegistry(harborRegistryClient); + var artifactoryHelmRegistry = new ArtifactoryHelmRegistry(artifactoryClient); + var registryMapperService = new RegistryMapperService(TEST_NGC_CONTAINER_REGISTRY, + TEST_NGC_ARTIFACT_REGISTRY, + TEST_NGC_HELM_REGISTRY); + var registryLookupService = new RegistryLookupService( + Collections.emptyList(), Collections.emptyList(), + Collections.emptyList(), Collections.emptyList(), + TEST_REGISTRY_CONFIG_PROPERTIES.getRecognized(), registryMapperService); + var registryValidationService = new RegistryValidationService(registryLookupService); + helmRegistryService = new HelmRegistryService( + List.of(ngcHelmRegistry, + dockerHelmRegistry, + ecrHelmRegistry, + ecrPublicHelmRegistry, + acrHelmRegistry, + harborHelmRegistry, + artifactoryHelmRegistry), + registryMapperService, + registryValidationService); + helmRegistryService.overwriteRegistryHostnameMap( + URI.create(MOCK_ECR_REGISTRY_API_URL).getHost(), + TEST_ECR_REGISTRY_GLOBAL_HOST_NAME); + helmRegistryService.overwriteRegistryHostnameMap( + URI.create(MOCK_ECR_PUBLIC_REGISTRY_API_URL).getHost(), + TEST_ECR_PUBLIC_REGISTRY_HOST_NAME); + helmRegistryService.overwriteRegistryHostnameMap( + URI.create(MOCK_AZURE_REGISTRY_URL).getHost(), + TEST_AZURE_REGISTRY_GLOBAL_HOST_NAME); + helmRegistryService.overwriteRegistryHostnameMap( + URI.create(MOCK_HARBOR_REGISTRY_URL).getHost(), + TEST_HARBOR_REGISTRY); + helmRegistryService.overwriteRegistryHostnameMap( + URI.create(MOCK_ARTIFACTORY_REGISTRY_URL).getHost(), + TEST_ARTIFACTORY_REGISTRY); + } + + @AfterAll + static void cleanup() { + MockCasServer.stop(); + MockEcrPrivateRegistryServer.stop(); + MockEcrPublicRegistryServer.stop(); + MockAcrAuthServer.stop(); + mockAcrHelmRegistryServer.stop(); + MockHarborAuthServer.stop(); + mockHarborHelmRegistryServer.stop(); + MockArtifactoryAuthServer.stop(); + mockArtifactoryHelmRegistryServer.stop(); + } + + static Stream getValidHelmRegistry() { + return Stream.of( + // ngc + Arguments.of(TEST_NGC_HELM_CHART.toString(), MOCK_NGC_HELM_REGISTRY_CRED), + Arguments.of(TEST_NGC_HELM_CHART_WITH_CANARY_HOST.toString(), + MOCK_NGC_HELM_REGISTRY_CRED), + + // docker + Arguments.of("oci://" + TEST_DOCKER_CONTAINER_IMAGE, + MOCK_DOCKER_CONTAINER_REGISTRY_CRED), + Arguments.of("oci://" + TEST_DOCKER_CONTAINER_IMAGE_WITH_DIGEST, + MOCK_DOCKER_CONTAINER_REGISTRY_CRED), + + // ecr + Arguments.of(TEST_ECR_HELM_CHART_WITH_TAG.toString(), + MOCK_ECR_REGISTRY_CRED), + Arguments.of(TEST_ECR_HELM_CHART_WITH_DIGEST.toString(), + MOCK_ECR_REGISTRY_CRED), + + // ecr public + Arguments.of(TEST_ECR_PUBLIC_HELM_CHART_WITH_TAG.toString(), + MOCK_ECR_PUBLIC_REGISTRY_CRED), + Arguments.of(TEST_ECR_PUBLIC_HELM_CHART_WITH_DIGEST.toString(), + MOCK_ECR_PUBLIC_REGISTRY_CRED), + + // acr + Arguments.of(TEST_ACR_HELM_CHART_WITH_TAG.toString(), + MOCK_ACR_CREDENTIALS), + Arguments.of(TEST_ACR_HELM_CHART_WITH_DIGEST.toString(), + MOCK_ACR_CREDENTIALS), + + // harbor + Arguments.of(TEST_HARBOR_HELM_CHART_WITH_TAG.toString(), + MOCK_HARBOR_CREDENTIALS), + Arguments.of(TEST_HARBOR_HELM_CHART_WITH_DIGEST.toString(), + MOCK_HARBOR_CREDENTIALS), + + // artifactory + Arguments.of(TEST_ARTIFACTORY_HELM_CHART_WITH_TAG.toString(), + MOCK_ARTIFACTORY_CREDENTIALS), + Arguments.of(TEST_ARTIFACTORY_HELM_CHART_WITH_DIGEST.toString(), + MOCK_ARTIFACTORY_CREDENTIALS)); + } + + @ParameterizedTest + @MethodSource("getValidHelmRegistry") + void validateArtifact_Success(String helmUrl, String apiKey) { + helmRegistryService.validateArtifact(helmUrl, List.of(apiKey)); + } + + static Stream getInvalidHelmRegistry() { + return Stream.of( + Arguments.of(TEST_NGC_HELM_CHART_PERMISSION_DENIED.toString(), + List.of(MOCK_NGC_HELM_REGISTRY_CRED), + ForbiddenException.class), + Arguments.of(TEST_HELM_CHART_UNKNOWN_REGISTRY.toString(), + List.of(MOCK_NGC_HELM_REGISTRY_CRED), + BadRequestException.class), + Arguments.of(TEST_HELM_CHART_NOT_EXISTS.toString(), + List.of(MOCK_NGC_HELM_REGISTRY_CRED), + NotFoundException.class), + Arguments.of(TEST_NGC_HELM_CHART.toString(), + List.of(), + IllegalStateException.class), + // ecr + Arguments.of(TEST_ECR_HELM_CHART_TAG_NOT_FOUND.toString(), + List.of(MOCK_ECR_REGISTRY_CRED), + BadRequestException.class), + Arguments.of(TEST_ECR_HELM_CHART_DIGEST_NOT_FOUND.toString(), + List.of(MOCK_ECR_REGISTRY_CRED), + BadRequestException.class), + + // docker + // docker container image and helm chart urls are same structure + Arguments.of("oci://" + TEST_DOCKER_CONTAINER_IMAGE_NOT_EXISTS, + List.of(MOCK_DOCKER_CONTAINER_REGISTRY_CRED), + NotFoundException.class), + Arguments.of("oci://" + TEST_DOCKER_CONTAINER_IMAGE_PERMISSION_DENIED, + List.of(MOCK_DOCKER_CONTAINER_REGISTRY_CRED), + ForbiddenException.class), + + // acr + Arguments.of(TEST_ACR_HELM_CHART_PERMISSION_DENIED.toString(), + List.of(MOCK_ACR_CREDENTIALS), + ForbiddenException.class), + Arguments.of(TEST_ACR_HELM_CHART_NOT_EXISTS.toString(), + List.of(MOCK_ACR_CREDENTIALS), + NotFoundException.class), + + // harbor + Arguments.of(TEST_HARBOR_HELM_CHART_PERMISSION_DENIED.toString(), + List.of(MOCK_HARBOR_CREDENTIALS), + ForbiddenException.class), + Arguments.of(TEST_HARBOR_HELM_CHART_NOT_EXISTS.toString(), + List.of(MOCK_HARBOR_CREDENTIALS), + NotFoundException.class), + + // artifactory + Arguments.of(TEST_ARTIFACTORY_HELM_CHART_PERMISSION_DENIED.toString(), + List.of(MOCK_ARTIFACTORY_CREDENTIALS), + ForbiddenException.class), + Arguments.of(TEST_ARTIFACTORY_HELM_CHART_NOT_EXISTS.toString(), + List.of(MOCK_ARTIFACTORY_CREDENTIALS), + NotFoundException.class)); + } + + @ParameterizedTest + @MethodSource("getInvalidHelmRegistry") + void validateArtifact_Fail(String helmUrl, + List apiKeys, + Class expectedException) { + assertThrows(expectedException, + () -> helmRegistryService.validateArtifact(helmUrl, apiKeys)); + } + + static Stream getValidCredentialsForHelmRegistry() { + return Stream.of( + // ECR private + Arguments.of(TEST_ECR_PRIVATE_REGISTRY_HOST_NAME, + List.of(MOCK_ECR_REGISTRY_CRED)), + // ECR public + Arguments.of(TEST_ECR_PUBLIC_REGISTRY_HOST_NAME, + List.of(MOCK_ECR_PUBLIC_REGISTRY_CRED)), + // Multiple credentials with one valid + Arguments.of(TEST_ECR_PRIVATE_REGISTRY_HOST_NAME, + List.of("invalid-cred", MOCK_ECR_REGISTRY_CRED))); + } + + @ParameterizedTest + @MethodSource("getValidCredentialsForHelmRegistry") + void validateCredentials_Success(String hostname, List credentials) { + assertDoesNotThrow(() -> helmRegistryService.validateCredentials(hostname, credentials)); + } + + static Stream getInvalidCredentialsForHelmRegistry() { + return Stream.of( + // Invalid credentials + Arguments.of(TEST_ECR_PRIVATE_REGISTRY_HOST_NAME, + List.of(MOCK_INVALID_ECR_REGISTRY_CRED), + BadRequestException.class), + // Empty credentials list + Arguments.of(TEST_ECR_PRIVATE_REGISTRY_HOST_NAME, + List.of(), + IllegalStateException.class), + // Unknown hostname + Arguments.of(TEST_HELM_CHART_UNKNOWN_REGISTRY.toString(), + List.of(MOCK_ECR_REGISTRY_CRED), + BadRequestException.class)); + } + + @ParameterizedTest + @MethodSource("getInvalidCredentialsForHelmRegistry") + void validateCredentials_Fail(String hostname, + List credentials, + Class expectedException) { + assertThrows(expectedException, + () -> helmRegistryService.validateCredentials(hostname, credentials)); + } + + @Test + void validateArtifact_ArtifactValidationDisabled_ReturnsWithoutValidating() { + var service = createServiceWithValidationDisabled(true, false); + + assertDoesNotThrow(() -> service.validateArtifact( + TEST_HELM_CHART_1.toString(), List.of("dummy-cred"))); + } + + @Test + void validateCredentials_CredentialValidationDisabled_ReturnsWithoutValidating() { + var service = createServiceWithValidationDisabled(false, true); + + assertDoesNotThrow(() -> service.validateCredentials( + TEST_HELM_REGISTRY_HOST_NAME_1, List.of("dummy-cred"))); + } + + private static HelmRegistryService createServiceWithValidationDisabled( + boolean credentialValidation, boolean artifactValidation) { + var recognizedRegistryConfig = new RecognizedRegistryConfiguration(); + recognizedRegistryConfig.setHelm(Map.of( + TEST_RECOGNIZED_HELM_REGISTRY_KEY_1, + registryConfig(TEST_HELM_REGISTRY_HOST_NAME_1, credentialValidation, + artifactValidation))); + + var mapperService = new RegistryMapperService( + TEST_NGC_CONTAINER_REGISTRY, + TEST_NGC_ARTIFACT_REGISTRY, + TEST_NGC_HELM_REGISTRY); + var lookupService = new RegistryLookupService( + Collections.emptyList(), Collections.emptyList(), + Collections.emptyList(), Collections.emptyList(), + recognizedRegistryConfig, mapperService); + var validationService = new RegistryValidationService(lookupService); + return new HelmRegistryService(List.of(), mapperService, validationService); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/model/ModelRegistryServiceTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/model/ModelRegistryServiceTest.java new file mode 100644 index 0000000000..151a42a92b --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/model/ModelRegistryServiceTest.java @@ -0,0 +1,241 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.model; + +import com.nvidia.boot.registries.service.registry.client.WebClientUtils; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_MODEL_REGISTRY_CRED; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_BASE_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_CLIENT_CALL_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_CLIENT_CONNECT_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_CLIENT_READ_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_CLIENT_WRITE_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_OAUTH2_BASE_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_OAUTH2_GROUP_SCOPE; +import static com.nvidia.boot.registries.util.TestConstants.TEST_MODEL_REGISTRY_HOST_NAME_1; +import static com.nvidia.boot.registries.util.TestConstants.TEST_MODEL_URL_1; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_ARTIFACT_REGISTRY; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_ARTIFACT_REGISTRY_PROD; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_CONTAINER_REGISTRY; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_HELM_REGISTRY; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_MODEL_URL; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_MODEL_URL_MISSING_PROTOCOL_1; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_MODEL_URL_NOT_EXISTS; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_MODEL_URL_PERMISSION_DENIED_REGISTRY_1; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_MODEL_URL_UNKNOWN_REGISTRY_1; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_MODEL_URL_WITH_CANARY_HOST; +import static com.nvidia.boot.registries.util.TestConstants.TEST_RECOGNIZED_MODEL_REGISTRY_KEY_1; +import static com.nvidia.boot.registries.util.TestConstants.TEST_REGISTRY_CONFIG_PROPERTIES; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.boot.registries.configurations.RegistryConfigurationProperties.RecognizedRegistryConfiguration; +import com.nvidia.boot.registries.service.registry.RegistryLookupService; +import com.nvidia.boot.registries.service.registry.RegistryMapperService; +import com.nvidia.boot.registries.service.registry.RegistryValidationService; +import com.nvidia.boot.registries.service.registry.client.ngc.NgcArtifactRegistryClient; +import com.nvidia.boot.registries.service.registry.dto.Artifact; +import com.nvidia.boot.registries.service.registry.dto.ArtifactDetails; +import com.nvidia.boot.registries.service.registry.dto.ArtifactFile; +import com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum; +import com.nvidia.boot.registries.service.registry.model.ngc.NgcModelRegistry; +import com.nvidia.boot.registries.util.TestUtils; +import java.net.URI; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.web.reactive.function.client.WebClient; + +class ModelRegistryServiceTest { + + private static ModelRegistryService modelRegistryService; + + @BeforeAll + static void beforeAll() { + MockCasServer.start(MOCK_NGC_REGISTRY_OAUTH2_BASE_URL, MOCK_NGC_REGISTRY_BASE_URL); + + NgcArtifactRegistryClient ngcArtifactRegistryClient = new NgcArtifactRegistryClient( + WebClientUtils.builder(), + MOCK_NGC_REGISTRY_BASE_URL, + MOCK_NGC_REGISTRY_CLIENT_CALL_TIMEOUT, + MOCK_NGC_REGISTRY_CLIENT_READ_TIMEOUT, + MOCK_NGC_REGISTRY_CLIENT_WRITE_TIMEOUT, + MOCK_NGC_REGISTRY_CLIENT_CONNECT_TIMEOUT, + MOCK_NGC_REGISTRY_OAUTH2_BASE_URL, + MOCK_NGC_REGISTRY_OAUTH2_GROUP_SCOPE); + + NgcModelRegistry ngcModelRegistry = new NgcModelRegistry(ngcArtifactRegistryClient); + var registryMapperService = new RegistryMapperService(TEST_NGC_CONTAINER_REGISTRY, + TEST_NGC_ARTIFACT_REGISTRY, + TEST_NGC_HELM_REGISTRY); + var registryLookupService = new RegistryLookupService( + Collections.emptyList(), Collections.emptyList(), + Collections.emptyList(), Collections.emptyList(), + TEST_REGISTRY_CONFIG_PROPERTIES.getRecognized(), registryMapperService); + var registryValidationService = new RegistryValidationService(registryLookupService); + modelRegistryService = new ModelRegistryService(List.of(ngcModelRegistry), + registryMapperService, + registryValidationService); + modelRegistryService.overwriteRegistryHostnameMap( + URI.create(MOCK_NGC_REGISTRY_BASE_URL).getHost(), + TEST_NGC_ARTIFACT_REGISTRY); + } + + @AfterAll + static void cleanup() { + MockCasServer.stop(); + } + + static Stream getValidModelUrls() { + var ngcArtifactList = List.of(new Artifact("test1", "v1", ArtifactTypeEnum.MODEL, List.of( + new ArtifactFile("/file1", "https://api.stg.ngc.nvidia.com/file1"), + new ArtifactFile("/file2", "https://api.stg.ngc.nvidia.com/file2")))); + var ngcArtifactSize = 46484790292L; + return Stream.of(Arguments.of(List.of(new ArtifactDetails( + "test1", "v1", TEST_NGC_MODEL_URL)), + MOCK_NGC_MODEL_REGISTRY_CRED, ngcArtifactSize, + ngcArtifactList), + Arguments.of(List.of(new ArtifactDetails( + "test1", "v1", TEST_NGC_MODEL_URL_WITH_CANARY_HOST)), + MOCK_NGC_MODEL_REGISTRY_CRED, ngcArtifactSize, + ngcArtifactList)); + } + + @ParameterizedTest + @MethodSource("getValidModelUrls") + void validateArtifact_Success(List artifacts, String apiKey) { + modelRegistryService.validateArtifacts(artifacts, + Map.of(TEST_NGC_ARTIFACT_REGISTRY, List.of(apiKey), + TEST_NGC_ARTIFACT_REGISTRY_PROD, + List.of(apiKey))); + } + + @ParameterizedTest + @MethodSource("getValidModelUrls") + void fetchSize_Success(List artifacts, String apiKey, long expectedSize) { + var size = modelRegistryService.fetchSize(artifacts, + Map.of(TEST_NGC_ARTIFACT_REGISTRY, + List.of(apiKey), + TEST_NGC_ARTIFACT_REGISTRY_PROD, + List.of(apiKey))); + assertThat(size).isEqualTo(expectedSize); + } + + @ParameterizedTest + @MethodSource("getValidModelUrls") + void fetchArtifact_Success(List artifacts, String apiKey, long expectedSize, + List expectedArtifactList) { + var artifactList = modelRegistryService.fetchArtifact(artifacts, + Map.of(TEST_NGC_ARTIFACT_REGISTRY, + List.of(apiKey), + TEST_NGC_ARTIFACT_REGISTRY_PROD, + List.of(apiKey))); + assertThat(artifactList).isEqualTo(expectedArtifactList); + } + + static Stream getInvalidModelUrls() { + return Stream.of( + Arguments.of(List.of(new ArtifactDetails("test1", "v1", + TEST_NGC_MODEL_URL_MISSING_PROTOCOL_1)), + Map.of(TEST_NGC_ARTIFACT_REGISTRY, + List.of(MOCK_NGC_MODEL_REGISTRY_CRED)), + BadRequestException.class), + Arguments.of(List.of(new ArtifactDetails("test1", "v1", + TEST_NGC_MODEL_URL_UNKNOWN_REGISTRY_1)), + Map.of(TEST_NGC_ARTIFACT_REGISTRY, + List.of(MOCK_NGC_MODEL_REGISTRY_CRED)), + BadRequestException.class), + Arguments.of(List.of(new ArtifactDetails("test1", "v1", + TEST_NGC_MODEL_URL_NOT_EXISTS)), + Map.of(TEST_NGC_ARTIFACT_REGISTRY, + List.of(MOCK_NGC_MODEL_REGISTRY_CRED)), + NotFoundException.class), + Arguments.of(List.of(new ArtifactDetails("test1", "v1", + TEST_NGC_MODEL_URL_PERMISSION_DENIED_REGISTRY_1)), + Map.of(TEST_NGC_ARTIFACT_REGISTRY, + List.of(MOCK_NGC_MODEL_REGISTRY_CRED)), + ForbiddenException.class), + Arguments.of(List.of(new ArtifactDetails("test1", "v1", TEST_NGC_MODEL_URL)), + Map.of(), + BadRequestException.class)); + } + + @ParameterizedTest + @MethodSource("getInvalidModelUrls") + void validateArtifact_Fail(List artifacts, + Map> credentials, + Class expectedException) { + assertThrows(expectedException, + () -> modelRegistryService.validateArtifacts(artifacts, credentials)); + } + + @ParameterizedTest + @MethodSource("getInvalidModelUrls") + void fetchSize_Fail(List artifacts, + Map> credentials, + Class expectedException) { + assertThrows(expectedException, + () -> modelRegistryService.fetchSize(artifacts, credentials)); + } + + @ParameterizedTest + @MethodSource("getInvalidModelUrls") + void fetchArtifact_Fail(List artifacts, + Map> credentials, + Class expectedException) { + assertThrows(expectedException, + () -> modelRegistryService.fetchArtifact(artifacts, credentials)); + } + + @Test + void validateArtifacts_ArtifactValidationDisabled_SkipsValidation() { + var service = createServiceWithArtifactValidationDisabled(); + + assertDoesNotThrow(() -> service.validateArtifacts( + List.of(new ArtifactDetails("n", "v", TEST_MODEL_URL_1)), Map.of())); + } + + private static ModelRegistryService createServiceWithArtifactValidationDisabled() { + var recognizedRegistryConfig = new RecognizedRegistryConfiguration(); + recognizedRegistryConfig.setModel(Map.of( + TEST_RECOGNIZED_MODEL_REGISTRY_KEY_1, + TestUtils.registryConfig(TEST_MODEL_REGISTRY_HOST_NAME_1, true, false))); + + var mapperService = new RegistryMapperService( + TEST_NGC_CONTAINER_REGISTRY, + TEST_NGC_ARTIFACT_REGISTRY, + TEST_NGC_HELM_REGISTRY); + var lookupService = new RegistryLookupService( + Collections.emptyList(), Collections.emptyList(), + Collections.emptyList(), Collections.emptyList(), + recognizedRegistryConfig, mapperService); + var validationService = new RegistryValidationService(lookupService); + return new ModelRegistryService(List.of(), mapperService, validationService); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/resource/ResourceRegistryServiceTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/resource/ResourceRegistryServiceTest.java new file mode 100644 index 0000000000..29d63a8c62 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/service/registry/resource/ResourceRegistryServiceTest.java @@ -0,0 +1,246 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.service.registry.resource; + +import com.nvidia.boot.registries.service.registry.client.WebClientUtils; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_BASE_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_CLIENT_CALL_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_CLIENT_CONNECT_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_CLIENT_READ_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_CLIENT_WRITE_TIMEOUT; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_OAUTH2_BASE_URL; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_REGISTRY_OAUTH2_GROUP_SCOPE; +import static com.nvidia.boot.registries.util.TestConstants.MOCK_NGC_RESOURCE_REGISTRY_CRED; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_ARTIFACT_REGISTRY; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_ARTIFACT_REGISTRY_PROD; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_CONTAINER_REGISTRY; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_HELM_REGISTRY; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_RESOURCE_URL; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_RESOURCE_URL_MISSING_PROTOCOL_1; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_RESOURCE_URL_NOT_EXISTS_1; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_RESOURCE_URL_PERMISSION_DENIED_REGISTRY_1; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_RESOURCE_URL_UNKNOWN_REGISTRY_1; +import static com.nvidia.boot.registries.util.TestConstants.TEST_NGC_RESOURCE_URL_WITH_CANARY_HOST; +import static com.nvidia.boot.registries.util.TestConstants.TEST_RECOGNIZED_RESOURCE_REGISTRY_KEY_1; +import static com.nvidia.boot.registries.util.TestConstants.TEST_REGISTRY_CONFIG_PROPERTIES; +import static com.nvidia.boot.registries.util.TestConstants.TEST_RESOURCE_REGISTRY_HOST_NAME_1; +import static com.nvidia.boot.registries.util.TestConstants.TEST_RESOURCE_URL_1; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.nvidia.boot.exceptions.BadRequestException; +import com.nvidia.boot.exceptions.ForbiddenException; +import com.nvidia.boot.exceptions.NotFoundException; +import com.nvidia.boot.mock.ngc.MockCasServer; +import com.nvidia.boot.registries.configurations.RegistryConfigurationProperties.RecognizedRegistryConfiguration; +import com.nvidia.boot.registries.service.registry.RegistryLookupService; +import com.nvidia.boot.registries.service.registry.RegistryMapperService; +import com.nvidia.boot.registries.service.registry.RegistryValidationService; +import com.nvidia.boot.registries.service.registry.client.ngc.NgcArtifactRegistryClient; +import com.nvidia.boot.registries.service.registry.dto.Artifact; +import com.nvidia.boot.registries.service.registry.dto.ArtifactDetails; +import com.nvidia.boot.registries.service.registry.dto.ArtifactFile; +import com.nvidia.boot.registries.service.registry.dto.ArtifactTypeEnum; +import com.nvidia.boot.registries.service.registry.resource.ngc.NgcResourceRegistry; +import com.nvidia.boot.registries.util.TestConstants; +import com.nvidia.boot.registries.util.TestUtils; +import java.net.URI; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.web.reactive.function.client.WebClient; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class ResourceRegistryServiceTest { + + private static ResourceRegistryService resourceRegistryService; + + @BeforeAll + static void beforeAll() { + MockCasServer.start(MOCK_NGC_REGISTRY_OAUTH2_BASE_URL, MOCK_NGC_REGISTRY_BASE_URL); + + NgcArtifactRegistryClient ngcArtifactRegistryClient = new NgcArtifactRegistryClient( + WebClientUtils.builder(), + MOCK_NGC_REGISTRY_BASE_URL, + MOCK_NGC_REGISTRY_CLIENT_CALL_TIMEOUT, + MOCK_NGC_REGISTRY_CLIENT_READ_TIMEOUT, + MOCK_NGC_REGISTRY_CLIENT_WRITE_TIMEOUT, + MOCK_NGC_REGISTRY_CLIENT_CONNECT_TIMEOUT, + MOCK_NGC_REGISTRY_OAUTH2_BASE_URL, + MOCK_NGC_REGISTRY_OAUTH2_GROUP_SCOPE); + + NgcResourceRegistry ngcResourceRegistry = + new NgcResourceRegistry(ngcArtifactRegistryClient); + var registryMapperService = new RegistryMapperService(TEST_NGC_CONTAINER_REGISTRY, + TEST_NGC_ARTIFACT_REGISTRY, + TEST_NGC_HELM_REGISTRY); + var registryLookupService = new RegistryLookupService( + Collections.emptyList(), Collections.emptyList(), + Collections.emptyList(), Collections.emptyList(), + TEST_REGISTRY_CONFIG_PROPERTIES.getRecognized(), registryMapperService); + var registryValidationService = new RegistryValidationService(registryLookupService); + resourceRegistryService = new ResourceRegistryService(List.of(ngcResourceRegistry), + registryMapperService, + registryValidationService); + resourceRegistryService.overwriteRegistryHostnameMap( + URI.create(MOCK_NGC_REGISTRY_BASE_URL).getHost(), + TestConstants.TEST_NGC_ARTIFACT_REGISTRY); + } + + @AfterAll + static void cleanup() { + MockCasServer.stop(); + } + + static Stream getValidResourceUrls() { + var ngcArtifactList = + List.of(new Artifact("test1", "v1", ArtifactTypeEnum.RESOURCE, List.of( + new ArtifactFile("/image1", "https://api.stg.ngc.nvidia.com/image1"), + new ArtifactFile("/image2", "https://api.stg.ngc.nvidia.com/image2")))); + var ngcArtifactSize = 69753543449L; + return Stream.of( + Arguments.of(List.of(new ArtifactDetails( + "test1", "v1", TEST_NGC_RESOURCE_URL)), + MOCK_NGC_RESOURCE_REGISTRY_CRED, ngcArtifactSize, + ngcArtifactList), + Arguments.of(List.of(new ArtifactDetails( + "test1", "v1", TEST_NGC_RESOURCE_URL_WITH_CANARY_HOST)), + MOCK_NGC_RESOURCE_REGISTRY_CRED, ngcArtifactSize, + ngcArtifactList)); + } + + @ParameterizedTest + @MethodSource("getValidResourceUrls") + void validateArtifact_Success(List artifacts, String apiKey) { + resourceRegistryService.validateArtifacts(artifacts, + Map.of(TestConstants.TEST_NGC_ARTIFACT_REGISTRY, + List.of(apiKey), + TEST_NGC_ARTIFACT_REGISTRY_PROD, + List.of(apiKey))); + } + + @ParameterizedTest + @MethodSource("getValidResourceUrls") + void fetchSize_Success(List artifacts, String apiKey, long expectedSize) { + var size = resourceRegistryService.fetchSize(artifacts, + Map.of(TEST_NGC_ARTIFACT_REGISTRY, + List.of(apiKey), + TEST_NGC_ARTIFACT_REGISTRY_PROD, + List.of(apiKey))); + assertThat(size).isEqualTo(expectedSize); + } + + @ParameterizedTest + @MethodSource("getValidResourceUrls") + void fetchArtifact_Success(List artifacts, String apiKey, long expectedSize, + List expectedArtifactList) { + var artifactList = resourceRegistryService.fetchArtifact(artifacts, + Map.of(TEST_NGC_ARTIFACT_REGISTRY, + List.of(apiKey), + TEST_NGC_ARTIFACT_REGISTRY_PROD, + List.of(apiKey))); + assertThat(artifactList).isEqualTo(expectedArtifactList); + } + + static Stream getInvalidResourceUrls() { + return Stream.of( + Arguments.of(List.of(new ArtifactDetails("test1", "v1", + TEST_NGC_RESOURCE_URL_MISSING_PROTOCOL_1)), + Map.of(TestConstants.TEST_NGC_ARTIFACT_REGISTRY, + List.of(MOCK_NGC_RESOURCE_REGISTRY_CRED)), + BadRequestException.class), + Arguments.of(List.of(new ArtifactDetails("test1", "v1", + TEST_NGC_RESOURCE_URL_UNKNOWN_REGISTRY_1)), + Map.of(TestConstants.TEST_NGC_ARTIFACT_REGISTRY, + List.of(MOCK_NGC_RESOURCE_REGISTRY_CRED)), + BadRequestException.class), + Arguments.of(List.of(new ArtifactDetails("test1", "v1", + TEST_NGC_RESOURCE_URL_NOT_EXISTS_1)), + Map.of(TestConstants.TEST_NGC_ARTIFACT_REGISTRY, + List.of(MOCK_NGC_RESOURCE_REGISTRY_CRED)), + NotFoundException.class), + Arguments.of(List.of(new ArtifactDetails("test1", "v1", + TEST_NGC_RESOURCE_URL_PERMISSION_DENIED_REGISTRY_1)), + Map.of(TestConstants.TEST_NGC_ARTIFACT_REGISTRY, + List.of(MOCK_NGC_RESOURCE_REGISTRY_CRED)), + ForbiddenException.class), + Arguments.of(List.of(new ArtifactDetails("test1", "v1", TEST_NGC_RESOURCE_URL)), + Map.of(), + BadRequestException.class)); + } + + @ParameterizedTest + @MethodSource("getInvalidResourceUrls") + void validateArtifact_Fail(List artifacts, + Map> credentials, + Class expectedException) { + assertThrows(expectedException, + () -> resourceRegistryService.validateArtifacts(artifacts, credentials)); + } + + @ParameterizedTest + @MethodSource("getInvalidResourceUrls") + void fetchSize_Fail(List artifacts, + Map> credentials, + Class expectedException) { + assertThrows(expectedException, + () -> resourceRegistryService.fetchSize(artifacts, credentials)); + } + + @ParameterizedTest + @MethodSource("getInvalidResourceUrls") + void fetchArtifact_Fail(List artifacts, + Map> credentials, + Class expectedException) { + assertThrows(expectedException, + () -> resourceRegistryService.fetchArtifact(artifacts, credentials)); + } + + @Test + void validateArtifacts_ArtifactValidationDisabled_SkipsValidation() { + var service = createServiceWithArtifactValidationDisabled(); + + assertDoesNotThrow(() -> service.validateArtifacts( + List.of(new ArtifactDetails("n", "v", TEST_RESOURCE_URL_1)), Map.of())); + } + + private static ResourceRegistryService createServiceWithArtifactValidationDisabled() { + var recognizedRegistryConfig = new RecognizedRegistryConfiguration(); + recognizedRegistryConfig.setResource(Map.of( + TEST_RECOGNIZED_RESOURCE_REGISTRY_KEY_1, + TestUtils.registryConfig(TEST_RESOURCE_REGISTRY_HOST_NAME_1, true, false))); + + var mapperService = new RegistryMapperService( + TEST_NGC_CONTAINER_REGISTRY, + TEST_NGC_ARTIFACT_REGISTRY, + TEST_NGC_HELM_REGISTRY); + var lookupService = new RegistryLookupService( + Collections.emptyList(), Collections.emptyList(), + Collections.emptyList(), Collections.emptyList(), + recognizedRegistryConfig, mapperService); + var validationService = new RegistryValidationService(lookupService); + return new ResourceRegistryService(List.of(), mapperService, validationService); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/util/TestConstants.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/util/TestConstants.java new file mode 100644 index 0000000000..54ac81fdb2 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/util/TestConstants.java @@ -0,0 +1,377 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.util; + +import static com.nvidia.boot.mock.BootTestConstants.TEST_ARTIFACTORY_REGISTRY; +import static com.nvidia.boot.mock.BootTestConstants.TEST_HARBOR_REGISTRY; +import static com.nvidia.boot.mock.BootTestConstants.TEST_INVALID_ECR_ACCESS_KEY_ID; +import static com.nvidia.boot.mock.BootTestConstants.TEST_INVALID_ECR_PUBLIC_ACCESS_KEY_ID; +import static com.nvidia.boot.mock.BootTestConstants.TEST_INVALID_ECR_PUBLIC_SECRET_ACCESS_KEY; +import static com.nvidia.boot.mock.BootTestConstants.TEST_INVALID_ECR_SECRET_ACCESS_KEY; +import static com.nvidia.boot.mock.BootTestConstants.TEST_INVALID_VOLCENGINE_ACCESS_KEY_ID; +import static com.nvidia.boot.mock.BootTestConstants.TEST_INVALID_VOLCENGINE_SECRET_ACCESS_KEY; +import static com.nvidia.boot.mock.BootTestConstants.TEST_UNKNOWN_HELM_CHART_VERSION; +import static com.nvidia.boot.mock.BootTestConstants.TEST_UNKNOWN_ORG_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_CONTAINER_HASH; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_CONTAINER_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_CONTAINER_NOT_EXIST_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_CONTAINER_PERMISSION_DENIED_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_CONTAINER_TAG; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_ECR_ACCESS_KEY_ID; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_ECR_PUBLIC_ACCESS_KEY_ID; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_ECR_PUBLIC_SECRET_ACCESS_KEY; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_ECR_SECRET_ACCESS_KEY; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_HELM_CHART_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_HELM_CHART_VERSION; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_ORG_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_TEAM_NAME; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_VOLCENGINE_ACCESS_KEY_ID; +import static com.nvidia.boot.mock.BootTestConstants.TEST_VALID_VOLCENGINE_SECRET_ACCESS_KEY; +import static com.nvidia.boot.mock.ngc.MockCasServer.MODEL_FILES_NOT_EXIST_URL; +import static com.nvidia.boot.mock.ngc.MockCasServer.MODEL_FILES_URL_WITH_TEAM; +import static com.nvidia.boot.mock.ngc.MockCasServer.MODEL_FILES_URL_WITH_VERSION; +import static com.nvidia.boot.mock.ngc.MockCasServer.MODEL_FILE_PERMISSION_DENIED_URL; +import static com.nvidia.boot.mock.ngc.MockCasServer.RESOURCE_FILES_NOT_EXISTS_URL; +import static com.nvidia.boot.mock.ngc.MockCasServer.RESOURCE_FILES_URL_WITH_TEAM; +import static com.nvidia.boot.mock.ngc.MockCasServer.RESOURCE_FILE_PERMISSION_DENIED_URL_WITH_TEAM; +import static com.nvidia.boot.registries.configurations.RegistryConfigurationProperties.RecognizedRegistryConfiguration; +import static com.nvidia.boot.registries.util.RegistriesConstants.ACR_REGISTRY_KEY; +import static com.nvidia.boot.registries.util.RegistriesConstants.ARTIFACTORY_REGISTRY_KEY; +import static com.nvidia.boot.registries.util.RegistriesConstants.DOCKER_REGISTRY_KEY; +import static com.nvidia.boot.registries.util.RegistriesConstants.ECR_PRIVATE_REGISTRY_KEY; +import static com.nvidia.boot.registries.util.RegistriesConstants.ECR_PUBLIC_REGISTRY_KEY; +import static com.nvidia.boot.registries.util.RegistriesConstants.HARBOR_REGISTRY_KEY; +import static com.nvidia.boot.registries.util.RegistriesConstants.NGC_PRIVATE_REGISTRY_KEY; +import static com.nvidia.boot.registries.util.TestUtils.registryConfig; +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.nvidia.boot.registries.configurations.RegistryConfigurationProperties; +import java.net.URI; +import java.time.Duration; +import java.util.Base64; +import java.util.Map; + +public class TestConstants { + + // Test NGC Registry Settings + public static final String TEST_NGC_CONTAINER_REGISTRY = "stg.nvcr.io"; + public static final String TEST_NGC_CONTAINER_REGISTRY_PROD = "nvcr.io"; + public static final String TEST_NGC_CONTAINER_REGISTRY_CANARY = "canary.nvcr.io"; + public static final String TEST_NGC_HELM_REGISTRY = "helm.stg.ngc.nvidia.com"; + public static final String TEST_NGC_HELM_REGISTRY_PROD = "helm.ngc.nvidia.com"; + public static final String TEST_NGC_HELM_REGISTRY_CANARY = "helm.canary.ngc.nvidia.com"; + public static final String TEST_NGC_ARTIFACT_REGISTRY = "api.stg.ngc.nvidia.com"; + public static final String TEST_NGC_ARTIFACT_REGISTRY_PROD = "api.ngc.nvidia.com"; + public static final String TEST_NGC_ARTIFACT_REGISTRY_CANARY = "api.canary.ngc.nvidia.com"; + public static final String NGC_REGISTRY_NAME = "NGC Private Registry"; + public static final String TEST_CONTAINER_REGISTRY_HOST_NAME_1 = + "test-container-registry-host-name-1"; + public static final String TEST_HELM_REGISTRY_HOST_NAME_1 = "test-helm-registry-host-name-1"; + public static final String TEST_MODEL_REGISTRY_HOST_NAME_1 = "test-model-registry-host-name-1"; + public static final String TEST_RESOURCE_REGISTRY_HOST_NAME_1 = + "test-resource-registry-host-name-1"; + public static final String TEST_RECOGNIZED_CONTAINER_REGISTRY_KEY_1 = + "test-recognized-container-registry-key-1"; + public static final String TEST_RECOGNIZED_HELM_REGISTRY_KEY_1 = + "test-recognized-helm-registry-key-1"; + public static final String TEST_RECOGNIZED_MODEL_REGISTRY_KEY_1 = + "test-recognized-model-registry-key-1"; + public static final String TEST_RECOGNIZED_RESOURCE_REGISTRY_KEY_1 = + "test-recognized-resource-registry-key-1"; + + // Mock NGC Registry Settings + public static final String MOCK_NGC_CONTAINER_REGISTRY_URL = "http://localhost:9100"; + public static final String MOCK_NGC_REGISTRY_BASE_URL = "http://localhost:9101"; + public static final String MOCK_NGC_REGISTRY_OAUTH2_BASE_URL = "http://localhost:9102"; + public static final String MOCK_NGC_REGISTRY_OAUTH2_GROUP_SCOPE = "ngc-stg"; + public static final Duration MOCK_NGC_REGISTRY_CLIENT_CALL_TIMEOUT = Duration.ofSeconds(30); + public static final Duration MOCK_NGC_REGISTRY_CLIENT_READ_TIMEOUT = Duration.ofSeconds(15); + public static final Duration MOCK_NGC_REGISTRY_CLIENT_WRITE_TIMEOUT = Duration.ofSeconds(15); + public static final Duration MOCK_NGC_REGISTRY_CLIENT_CONNECT_TIMEOUT = Duration.ofSeconds(30); + + // Mock Docker Registry Settings + public static final String TEST_DOCKER_REGISTRY = "docker.io"; + public static final String MOCK_DOCKER_REGISTRY_URL = "http://localhost:9105"; + public static final String MOCK_DOCKER_REGISTRY_OAUTH2_URL = "http://localhost:9106"; + public static final String MOCK_DOCKER_REGISTRY_OAUTH2_GROUP_SCOPE = "pull"; + public static final Duration MOCK_DOCKER_REGISTRY_CLIENT_CALL_TIMEOUT = Duration.ofSeconds(30); + + // Mock ECR Registry Settings + public static final String TEST_ECR_REGISTRY_GLOBAL_HOST_NAME = "dkr.ecr.amazonaws.com"; + public static final String TEST_ECR_PRIVATE_REGISTRY_HOST_NAME = + "123456789012.dkr.ecr.us-west-2.amazonaws.com"; + public static final String MOCK_ECR_REGISTRY_API_URL = "http://localhost-ecr:9107"; + public static final Duration MOCK_ECR_REGISTRY_CLIENT_CALL_TIMEOUT = Duration.ofSeconds(30); + + // Mock ECR Public Registry Settings + public static final String TEST_ECR_PUBLIC_REGISTRY_HOST_NAME = "public.ecr.aws"; + public static final String MOCK_ECR_PUBLIC_REGISTRY_API_URL = + "http://localhost-ecr-public:9108"; + public static final Duration MOCK_ECR_PUBLIC_REGISTRY_CLIENT_CALL_TIMEOUT = + Duration.ofSeconds(30); + + // Mock Volcengine Registry Settings + public static final String TEST_VOLCENGINE_REGISTRY_GLOBAL_HOST_NAME = "cr.volces.com"; + public static final String MOCK_VOLCENGINE_REGISTRY_API_URL = + "http://localhost-volcengine:9109"; + public static final Duration MOCK_VOLCENGINE_REGISTRY_CLIENT_CALL_TIMEOUT = + Duration.ofSeconds(30); + + // Mock Azure Registry Settings + public static final String TEST_AZURE_REGISTRY_GLOBAL_HOST_NAME = "azurecr.io"; + public static final String MOCK_AZURE_REGISTRY_URL = + "http://localhost-acr:9110"; + public static final String MOCK_AZURE_REGISTRY_AUTH_URL = "http://localhost-acr-auth:9111"; + public static final Duration MOCK_AZURE_REGISTRY_CLIENT_CALL_TIMEOUT = + Duration.ofSeconds(30); + + + // Mock Harbor Registry Settings + public static final String MOCK_HARBOR_REGISTRY_URL = + "http://localhost-harbor:9112"; + public static final String MOCK_HARBOR_REGISTRY_AUTH_URL = "http://localhost-harbor-auth:9113"; + public static final Duration MOCK_HARBOR_REGISTRY_CLIENT_CALL_TIMEOUT = Duration.ofSeconds(30); + + // Mock Artifactory Registry Settings + public static final String MOCK_ARTIFACTORY_REGISTRY_URL = + "http://localhost-jfrog:9114"; + public static final String MOCK_ARTIFACTORY_REGISTRY_AUTH_URL = + "http://localhost-jfrog-auth:9115"; + public static final Duration MOCK_ARTIFACTORY_REGISTRY_CLIENT_CALL_TIMEOUT = + Duration.ofSeconds(30); + + public static final RegistryConfigurationProperties TEST_REGISTRY_CONFIG_PROPERTIES; + + static { + var recognized = new RecognizedRegistryConfiguration(); + recognized.setContainer(Map.ofEntries( + Map.entry(NGC_PRIVATE_REGISTRY_KEY, + registryConfig(TEST_NGC_CONTAINER_REGISTRY)), + Map.entry(DOCKER_REGISTRY_KEY, registryConfig(TEST_DOCKER_REGISTRY)), + Map.entry(ECR_PRIVATE_REGISTRY_KEY, + registryConfig(TEST_ECR_REGISTRY_GLOBAL_HOST_NAME)), + Map.entry(ECR_PUBLIC_REGISTRY_KEY, + registryConfig(TEST_ECR_PUBLIC_REGISTRY_HOST_NAME)), + Map.entry(ACR_REGISTRY_KEY, registryConfig(TEST_AZURE_REGISTRY_GLOBAL_HOST_NAME)), + Map.entry(HARBOR_REGISTRY_KEY, registryConfig(TEST_HARBOR_REGISTRY)), + Map.entry(ARTIFACTORY_REGISTRY_KEY, registryConfig(TEST_ARTIFACTORY_REGISTRY)))); + recognized.setHelm(Map.ofEntries( + Map.entry(NGC_PRIVATE_REGISTRY_KEY, + registryConfig(TEST_NGC_HELM_REGISTRY)), + Map.entry(DOCKER_REGISTRY_KEY, registryConfig(TEST_DOCKER_REGISTRY)), + Map.entry(ECR_PRIVATE_REGISTRY_KEY, + registryConfig(TEST_ECR_REGISTRY_GLOBAL_HOST_NAME)), + Map.entry(ECR_PUBLIC_REGISTRY_KEY, + registryConfig(TEST_ECR_PUBLIC_REGISTRY_HOST_NAME)), + Map.entry(ACR_REGISTRY_KEY, registryConfig(TEST_AZURE_REGISTRY_GLOBAL_HOST_NAME)), + Map.entry(HARBOR_REGISTRY_KEY, registryConfig(TEST_HARBOR_REGISTRY)), + Map.entry(ARTIFACTORY_REGISTRY_KEY, registryConfig(TEST_ARTIFACTORY_REGISTRY)))); + recognized.setModel(Map.of( + NGC_PRIVATE_REGISTRY_KEY, + registryConfig(TEST_NGC_ARTIFACT_REGISTRY))); + recognized.setResource(Map.of( + NGC_PRIVATE_REGISTRY_KEY, + registryConfig(TEST_NGC_ARTIFACT_REGISTRY))); + TEST_REGISTRY_CONFIG_PROPERTIES = new RegistryConfigurationProperties(); + TEST_REGISTRY_CONFIG_PROPERTIES.setRecognized(recognized); + } + + // Mock Credentials + public static final String MOCK_RAW_NGC_CONTAINER_CRED = + "$oauthtoken:nvapi-stg-test-container-registry-cred"; + public static final String MOCK_NGC_CONTAINER_REGISTRY_CRED = + Base64.getEncoder().encodeToString(MOCK_RAW_NGC_CONTAINER_CRED.getBytes(UTF_8)); + public static final String MOCK_RAW_NGC_HELM_CRED = + "$oauthtoken:nvapi-stg-test-helm-registry-cred"; + public static final String MOCK_NGC_HELM_REGISTRY_CRED = + Base64.getEncoder().encodeToString(MOCK_RAW_NGC_HELM_CRED.getBytes(UTF_8)); + public static final String MOCK_RAW_NGC_MODEL_CRED = + "$oauthtoken:nvapi-stg-test-model-registry-cred"; + public static final String MOCK_NGC_MODEL_REGISTRY_CRED = + Base64.getEncoder().encodeToString(MOCK_RAW_NGC_MODEL_CRED.getBytes(UTF_8)); + public static final String MOCK_RAW_NGC_RESOURCE_CRED = + "$oauthtoken:nvapi-stg-test-resource-registry-cred"; + public static final String MOCK_NGC_RESOURCE_REGISTRY_CRED = + Base64.getEncoder().encodeToString(MOCK_RAW_NGC_RESOURCE_CRED.getBytes(UTF_8)); + public static final String MOCK_NGC_API_KEY = "mock-ngc-api-key"; + public static final String MOCK_RAW_DOCKER_CONTAINER_CRED = + "username:dckr_pat_exmaple_personal_token_secret"; + public static final String MOCK_DOCKER_CONTAINER_REGISTRY_CRED = + Base64.getEncoder().encodeToString(MOCK_RAW_DOCKER_CONTAINER_CRED.getBytes(UTF_8)); + // ECR Private Credentials - Using constants from NvcfTestConstants + public static final String MOCK_RAW_ECR_CRED = + TEST_VALID_ECR_ACCESS_KEY_ID + ":" + TEST_VALID_ECR_SECRET_ACCESS_KEY; + public static final String MOCK_ECR_REGISTRY_CRED = + Base64.getEncoder().encodeToString(MOCK_RAW_ECR_CRED.getBytes(UTF_8)); + public static final String MOCK_RAW_INVALID_ECR_CRED = + TEST_INVALID_ECR_ACCESS_KEY_ID + ":" + TEST_INVALID_ECR_SECRET_ACCESS_KEY; + public static final String MOCK_INVALID_ECR_REGISTRY_CRED = + Base64.getEncoder().encodeToString(MOCK_RAW_INVALID_ECR_CRED.getBytes(UTF_8)); + + // ECR Public Credentials - Using constants from NvcfTestConstants + public static final String MOCK_RAW_ECR_PUBLIC_CRED = + TEST_VALID_ECR_PUBLIC_ACCESS_KEY_ID + ":" + TEST_VALID_ECR_PUBLIC_SECRET_ACCESS_KEY; + public static final String MOCK_ECR_PUBLIC_REGISTRY_CRED = + Base64.getEncoder().encodeToString(MOCK_RAW_ECR_PUBLIC_CRED.getBytes(UTF_8)); + public static final String MOCK_RAW_INVALID_ECR_PUBLIC_CRED = + TEST_INVALID_ECR_PUBLIC_ACCESS_KEY_ID + ":" + TEST_INVALID_ECR_PUBLIC_SECRET_ACCESS_KEY; + public static final String MOCK_INVALID_ECR_PUBLIC_REGISTRY_CRED = + Base64.getEncoder().encodeToString(MOCK_RAW_INVALID_ECR_PUBLIC_CRED.getBytes(UTF_8)); + public static final String MOCK_RAW_VOLCENGINE_CRED = + TEST_VALID_VOLCENGINE_ACCESS_KEY_ID + ":" + TEST_VALID_VOLCENGINE_SECRET_ACCESS_KEY; + public static final String MOCK_VOLCENGINE_REGISTRY_CRED = + Base64.getEncoder().encodeToString(MOCK_RAW_VOLCENGINE_CRED.getBytes(UTF_8)); + public static final String MOCK_RAW_INVALID_VOLCENGINE_CRED = + TEST_INVALID_VOLCENGINE_ACCESS_KEY_ID + ":" + TEST_INVALID_VOLCENGINE_SECRET_ACCESS_KEY; + public static final String MOCK_INVALID_VOLCENGINE_REGISTRY_CRED = + Base64.getEncoder().encodeToString(MOCK_RAW_INVALID_VOLCENGINE_CRED.getBytes(UTF_8)); + public static final String MOCK_RAW_ACR_CRED = + "acr_client_id_1:acr_client_secret_1"; + public static final String MOCK_ACR_CREDENTIALS = Base64.getEncoder() + .encodeToString(MOCK_RAW_ACR_CRED.getBytes(UTF_8)); + public static final String MOCK_RAW_HARBOR_CRED = + "harbor_robot_account_id_1:harbor_robot_secret_1"; + public static final String MOCK_HARBOR_CREDENTIALS = Base64.getEncoder() + .encodeToString(MOCK_RAW_HARBOR_CRED.getBytes(UTF_8)); + public static final String MOCK_RAW_ARTIFACTORY_CRED = + "artifactory_user_name_1:artifactory_password_1"; + public static final String MOCK_ARTIFACTORY_CREDENTIALS = Base64.getEncoder() + .encodeToString(MOCK_RAW_ARTIFACTORY_CRED.getBytes(UTF_8)); + + // Test Helm Charts + public static final URI TEST_NGC_HELM_CHART = + URI.create("https://%s/%s/%s/charts/%s-%s.tgz".formatted(TEST_NGC_HELM_REGISTRY, + TEST_VALID_ORG_NAME, + TEST_VALID_TEAM_NAME, + TEST_VALID_HELM_CHART_NAME, + TEST_VALID_HELM_CHART_VERSION)); + public static final URI TEST_NGC_HELM_CHART_WITH_CANARY_HOST = + URI.create("https://%s/%s/%s/charts/%s-%s.tgz" + .formatted(TEST_NGC_HELM_REGISTRY_CANARY, + TEST_VALID_ORG_NAME, + TEST_VALID_TEAM_NAME, + TEST_VALID_HELM_CHART_NAME, + TEST_VALID_HELM_CHART_VERSION)); + public static final URI TEST_HELM_CHART_UNKNOWN_REGISTRY = + URI.create( + "https://unknown-registry/%s/%s/charts/%s-%s.tgz" + .formatted(TEST_VALID_ORG_NAME, + TEST_VALID_TEAM_NAME, + TEST_VALID_HELM_CHART_NAME, + TEST_VALID_HELM_CHART_VERSION)); + public static final URI TEST_HELM_CHART_NOT_EXISTS = + URI.create("https://%s/%s/%s/charts/%s-%s.tgz".formatted(TEST_NGC_HELM_REGISTRY, + TEST_VALID_ORG_NAME, + TEST_VALID_TEAM_NAME, + "invalid-helm-chart", + TEST_VALID_HELM_CHART_VERSION)); + public static final URI TEST_NGC_HELM_CHART_PERMISSION_DENIED = + URI.create("https://%s/%s/%s/charts/%s-%s.tgz".formatted(TEST_NGC_HELM_REGISTRY, + TEST_UNKNOWN_ORG_NAME, + TEST_VALID_TEAM_NAME, + TEST_VALID_HELM_CHART_NAME, + TEST_UNKNOWN_HELM_CHART_VERSION)); + + // Test NGC Containers + public static final URI TEST_NGC_CONTAINER_IMAGE = + URI.create(TEST_NGC_CONTAINER_REGISTRY + "/%s/%s:%s".formatted(TEST_VALID_ORG_NAME, + TEST_VALID_CONTAINER_NAME, + TEST_VALID_CONTAINER_TAG)); + public static final URI TEST_NGC_CONTAINER_IMAGE_WITH_CANARY_HOST = + URI.create( + TEST_NGC_CONTAINER_REGISTRY_CANARY + "/%s/%s:%s".formatted(TEST_VALID_ORG_NAME, + TEST_VALID_CONTAINER_NAME, + TEST_VALID_CONTAINER_TAG)); + public static final URI TEST_CONTAINER_IMAGE_UNKNOWN_REGISTRY = + URI.create("not-exits/%s/%s:%s".formatted(TEST_VALID_ORG_NAME, + TEST_VALID_CONTAINER_NAME, + TEST_VALID_CONTAINER_TAG)); + public static final URI TEST_NGC_CONTAINER_IMAGE_WITHOUT_TAG = + URI.create(TEST_NGC_CONTAINER_REGISTRY + "/%s/%s".formatted(TEST_VALID_ORG_NAME, + TEST_VALID_CONTAINER_NAME)); + public static final URI TEST_NGC_CONTAINER_IMAGE_WITH_DIGEST = + URI.create(TEST_NGC_CONTAINER_REGISTRY + "/%s/%s@%s".formatted(TEST_VALID_ORG_NAME, + TEST_VALID_CONTAINER_NAME, + TEST_VALID_CONTAINER_HASH)); + public static final URI TEST_NGC_CONTAINER_IMAGE_NOT_EXISTS = + URI.create(TEST_NGC_CONTAINER_REGISTRY + "/%s/%s:%s".formatted(TEST_VALID_ORG_NAME, + TEST_VALID_CONTAINER_NAME, + TEST_VALID_CONTAINER_NOT_EXIST_TAG)); + public static final URI TEST_NGC_CONTAINER_IMAGE_WITH_INVALID_TAG = + URI.create(TEST_NGC_CONTAINER_REGISTRY + "/%s/%s:%s".formatted(TEST_VALID_ORG_NAME, + TEST_VALID_CONTAINER_NAME, + "latest:latest")); + public static final URI TEST_NGC_CONTAINER_IMAGE_PERMISSION_DENIED = + URI.create(TEST_NGC_CONTAINER_REGISTRY + "/%s/%s:%s".formatted(TEST_VALID_ORG_NAME, + TEST_VALID_CONTAINER_NAME, + TEST_VALID_CONTAINER_PERMISSION_DENIED_TAG)); + public static final URI TEST_NGC_CONTAINER_IMAGE_UNKNOWN_ORG = + URI.create(TEST_NGC_CONTAINER_REGISTRY + "/%s/%s:%s".formatted(TEST_UNKNOWN_ORG_NAME, + TEST_VALID_CONTAINER_NAME, + TEST_VALID_CONTAINER_TAG)); + public static final URI TEST_NGC_CONTAINER_IMAGE_2 = + URI.create(TEST_NGC_CONTAINER_REGISTRY + "/%s/%s:%s".formatted(TEST_VALID_ORG_NAME, + TEST_VALID_CONTAINER_NAME + + + "-2", + TEST_VALID_CONTAINER_TAG)); + // Test Models + public static final String BASE_NGC_ARTIFACT_URL = "https://" + TEST_NGC_ARTIFACT_REGISTRY; + public static final String BASE_NGC_ARTIFACT_URL_CANARY = + "https://" + TEST_NGC_ARTIFACT_REGISTRY_CANARY; + public static final String TEST_NGC_MODEL_URL = + BASE_NGC_ARTIFACT_URL + MODEL_FILES_URL_WITH_TEAM; + public static final String TEST_NGC_MODEL_URL_WITH_CANARY_HOST = + BASE_NGC_ARTIFACT_URL_CANARY + MODEL_FILES_URL_WITH_TEAM; + public static final String TEST_NGC_MODEL_URL_WITH_VERSIONS_1 = + BASE_NGC_ARTIFACT_URL + MODEL_FILES_URL_WITH_VERSION; + public static final String TEST_NGC_MODEL_URL_UNKNOWN_REGISTRY_1 = + "https://not-exists" + MODEL_FILES_URL_WITH_TEAM; + public static final String TEST_NGC_MODEL_URL_MISSING_PROTOCOL_1 = + TEST_NGC_ARTIFACT_REGISTRY + MODEL_FILES_URL_WITH_TEAM; + public static final String TEST_NGC_MODEL_URL_PERMISSION_DENIED_REGISTRY_1 = + BASE_NGC_ARTIFACT_URL + MODEL_FILE_PERMISSION_DENIED_URL; + public static final String TEST_NGC_MODEL_URL_NOT_EXISTS = + BASE_NGC_ARTIFACT_URL + MODEL_FILES_NOT_EXIST_URL; + + // Test Resources + public static final String TEST_NGC_RESOURCE_URL = + BASE_NGC_ARTIFACT_URL + RESOURCE_FILES_URL_WITH_TEAM; + public static final String TEST_NGC_RESOURCE_URL_WITH_CANARY_HOST = + BASE_NGC_ARTIFACT_URL_CANARY + RESOURCE_FILES_URL_WITH_TEAM; + public static final String TEST_NGC_RESOURCE_URL_UNKNOWN_REGISTRY_1 = + "https://not-exists" + RESOURCE_FILES_URL_WITH_TEAM; + public static final String TEST_NGC_RESOURCE_URL_MISSING_PROTOCOL_1 = + TEST_NGC_ARTIFACT_REGISTRY + RESOURCE_FILES_URL_WITH_TEAM; + public static final String TEST_NGC_RESOURCE_URL_PERMISSION_DENIED_REGISTRY_1 = + BASE_NGC_ARTIFACT_URL + RESOURCE_FILE_PERMISSION_DENIED_URL_WITH_TEAM; + public static final String TEST_NGC_RESOURCE_URL_NOT_EXISTS_1 = + BASE_NGC_ARTIFACT_URL + RESOURCE_FILES_NOT_EXISTS_URL; + + public static final URI TEST_CONTAINER_IMAGE_1 = URI + .create(TEST_CONTAINER_REGISTRY_HOST_NAME_1 + "/%s/%s:%s".formatted( + TEST_VALID_ORG_NAME, TEST_VALID_CONTAINER_NAME, TEST_VALID_CONTAINER_TAG)); + public static final URI TEST_HELM_CHART_1 = URI + .create("https://%s/%s/%s/charts/%s-%s.tgz".formatted( + TEST_HELM_REGISTRY_HOST_NAME_1, TEST_VALID_ORG_NAME, TEST_VALID_TEAM_NAME, + TEST_VALID_HELM_CHART_NAME, TEST_VALID_HELM_CHART_VERSION)); + public static final String TEST_MODEL_URL_1 = "https://" + TEST_MODEL_REGISTRY_HOST_NAME_1 + + MODEL_FILES_URL_WITH_TEAM; + public static final String TEST_RESOURCE_URL_1 = "https://" + TEST_RESOURCE_REGISTRY_HOST_NAME_1 + + RESOURCE_FILES_URL_WITH_TEAM; +} \ No newline at end of file diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/util/TestUtils.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/util/TestUtils.java new file mode 100644 index 0000000000..bc76961ea3 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/java/com/nvidia/boot/registries/util/TestUtils.java @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.registries.util; + +import com.nvidia.boot.registries.configurations.RegistryConfigurationProperties.RegistryConfiguration; +import com.nvidia.boot.registries.configurations.RegistryConfigurationProperties.ValidationProperties; +import lombok.experimental.UtilityClass; + +@UtilityClass +public final class TestUtils { + + public static RegistryConfiguration registryConfig(String hostname) { + return registryConfig(hostname, hostname); + } + + public static RegistryConfiguration registryConfig(String name, String hostname) { + var config = new RegistryConfiguration(); + config.setName(name); + config.setHostname(hostname); + return config; + } + + public static RegistryConfiguration registryConfig( + String hostname, boolean credentialValidation, boolean artifactValidation) { + var config = registryConfig(hostname); + var credentialValidationConfig = new ValidationProperties(); + credentialValidationConfig.setEnabled(credentialValidation); + config.setCredentialValidation(credentialValidationConfig); + + var artifactValidationConfig = new ValidationProperties(); + artifactValidationConfig.setEnabled(artifactValidation); + config.setArtifactValidation(artifactValidationConfig); + return config; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/resources/application-test.yaml b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/resources/application-test.yaml new file mode 100644 index 0000000000..f5a78a36c7 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-registries/src/test/resources/application-test.yaml @@ -0,0 +1,39 @@ +spring: + application: + name: test-registries + version: 1.0.0 + +# Minimal registry config for integration test - all maps must be non-null +test: + registries: + recognized: + container: + ngc-private: + name: ngc-private + hostname: nvcr.io + call-timeout: 30s + read-timeout: 30s + write-timeout: 30s + connection-timeout: 10s + helm: + ngc-private: + name: ngc-private + hostname: nvcr.io + call-timeout: 30s + read-timeout: 30s + write-timeout: 30s + connection-timeout: 10s + model: + ngc-private: + name: ngc-private + hostname: api.ngc.nvidia.com + oauth2: + base-url: https://authn.nvidia.com/token + group-scope: org-id + resource: + ngc-private: + name: ngc-private + hostname: api.ngc.nvidia.com + oauth2: + base-url: https://authn.nvidia.com/token + group-scope: org-id diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/BUILD.bazel b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/BUILD.bazel new file mode 100644 index 0000000000..9fd2ad9a6a --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/BUILD.bazel @@ -0,0 +1,53 @@ +load("//src/libraries/java/nv-boot-parent/tools/bazel:java.bzl", "nv_boot_library", "nv_boot_library_test", "nv_boot_workspace_runfiles") + +RELOADABLE_PROPERTIES_COMPILE_DEPS = [ + "@nv_third_party_deps//:com_github_ben_manes_caffeine_guava", + "@nv_third_party_deps//:com_google_guava_guava", + "@nv_third_party_deps//:jakarta_annotation_jakarta_annotation_api", + "@nv_third_party_deps//:org_apache_commons_commons_lang3", + "@nv_third_party_deps//:org_slf4j_slf4j_api", + "@nv_third_party_deps//:org_springframework_boot_spring_boot", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_autoconfigure", + "@nv_third_party_deps//:org_springframework_cloud_spring_cloud_context", + "@nv_third_party_deps//:org_springframework_cloud_spring_cloud_starter_bootstrap", + "@nv_third_party_deps//:org_springframework_spring_beans", + "@nv_third_party_deps//:org_springframework_spring_context", + "@nv_third_party_deps//:org_springframework_spring_core", +] + +filegroup( + name = "test_resource_files", + srcs = glob(["src/test/resources/**"]), +) + +nv_boot_workspace_runfiles( + name = "reloadable_properties_test_resources", + srcs = [":test_resource_files"], + strip_prefix = "src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/", +) + +nv_boot_library( + name = "nv_boot_starter_reloadable_properties", + srcs = glob(["src/main/java/**/*.java"]), + resource_strip_prefix = "src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/resources", + resources = glob(["src/main/resources/**"]), + visibility = ["//visibility:public"], + deps = RELOADABLE_PROPERTIES_COMPILE_DEPS, +) + +nv_boot_library_test( + name = "tests", + srcs = glob(["src/test/java/**/*.java"]), + coverage_library = ":nv_boot_starter_reloadable_properties", + data = [":reloadable_properties_test_resources"], + junit_classpath = ["src/test/resources"], + resource_strip_prefix = "src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources", + resources = glob(["src/test/resources/**"]), + deps = [ + ":nv_boot_starter_reloadable_properties", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_jackson", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_jackson", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_webmvc_test", + "@nv_third_party_deps//:tools_jackson_core_jackson_databind", + ] + RELOADABLE_PROPERTIES_COMPILE_DEPS, +) diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/README.md b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/README.md new file mode 100644 index 0000000000..9daea12d36 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/README.md @@ -0,0 +1,59 @@ +# NV Boot Starter Reloadable Properties + +File-based property reloading with Spring Cloud context refresh. Uses an +`EnvironmentPostProcessor` to load the properties file before PropertySourceLocators run, +allowing other libraries' BootstrapConfigurations to reference properties from the reloadable +file. Polls the file and triggers a context refresh when it changes. + +## Adding as a Dependency + +Add to your application's `pom.xml`: + +```xml + + + + com.nvidia.boot + nv-boot-bom + ${nv-boot.version} + pom + import + + + + + + + com.nvidia.boot + nv-boot-starter-reloadable-properties + + +``` + +## Configuration + +Configure the library in application's `bootstrap.yaml` as shown below: + +```yaml +nv-boot: + reloadable-properties: + enabled: true # Default true + file: file:/vault/secrets.json + poll-duration: 5s # Duration: 5s, PT5S, 300s, 5m, etc. Default: 300s +``` + +| Property | Type | Required | Default | Description | +|----------|------|----------|---------|--------------------------------------------------------------| +| `enabled` | boolean | No | `true` | Whether reloadable properties are enabled | +| `file` | string | Yes (when enabled) | - | Path to the properties file (e.g. `file:/path`, `file:path`) | +| `poll-duration` | Duration | No | `300s` | Poll duration between reload checks (e.g. `5s`, `PT5S`, `5m`) | + +## Auto Configured Beans + +When `nv-boot.reloadable-properties.enabled` is true, the library registers the following +beans with Spring application context: + +| Bean | Type | Description | +|------|------|-------------| +| `reloadablePropertiesConfigurationProvider` | `ReloadablePropertiesConfigurationProvider` | Reads file path and poll duration from `nv-boot.reloadable-properties` properties | +| `fileBasedPropertiesRefresher` | `FileBasedPropertiesRefresher` | Polls the properties file and triggers `ContextRefresher.refresh()` when it changes | diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/pom.xml b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/pom.xml new file mode 100644 index 0000000000..04fa04f1ac --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/pom.xml @@ -0,0 +1,77 @@ + + + + + 4.0.0 + + com.nvidia.boot + nv-boot-parent + 0.0.1-SNAPSHOT + + + nv-boot-starter-reloadable-properties + jar + NV Boot Starter Reloadable Properties + File-based property reloading with context refresh and Spring Cloud + + + + org.springframework.boot + spring-boot + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework.cloud + spring-cloud-context + + + org.springframework.cloud + spring-cloud-starter-bootstrap + + + org.projectlombok + lombok + true + + + org.apache.commons + commons-lang3 + + + com.github.ben-manes.caffeine + guava + + + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + org.springframework.boot + spring-boot-starter-jackson + test + + + diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/java/com/nvidia/boot/properties/ReloadablePropertiesAutoConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/java/com/nvidia/boot/properties/ReloadablePropertiesAutoConfiguration.java new file mode 100644 index 0000000000..dce5b698e9 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/java/com/nvidia/boot/properties/ReloadablePropertiesAutoConfiguration.java @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.properties; + +import static com.nvidia.boot.properties.util.ReloadablePropertiesConstants.ENABLED; + +import com.nvidia.boot.properties.property.ReloadablePropertiesConfigurationProvider; +import com.nvidia.boot.properties.refresher.FileBasedPropertiesRefresher; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cloud.context.refresh.ContextRefresher; +import org.springframework.context.annotation.Bean; +import org.springframework.core.env.Environment; + +/** + * Auto-configuration for reloadable properties. + * Enabled when {@code nv-boot.reloadable-properties.enabled} is true. + */ +@AutoConfiguration +@ConditionalOnProperty(name = ENABLED, havingValue = "true", matchIfMissing = true) +public class ReloadablePropertiesAutoConfiguration { + + @Bean + public ReloadablePropertiesConfigurationProvider reloadablePropertiesConfigurationProvider( + Environment environment) { + return new ReloadablePropertiesConfigurationProvider(environment); + } + + @Bean + public FileBasedPropertiesRefresher fileBasedPropertiesRefresher( + ContextRefresher contextRefresher, + ReloadablePropertiesConfigurationProvider configurationProvider) { + return new FileBasedPropertiesRefresher(contextRefresher, configurationProvider); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/java/com/nvidia/boot/properties/env/ReloadablePropertiesEnvironmentPostProcessor.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/java/com/nvidia/boot/properties/env/ReloadablePropertiesEnvironmentPostProcessor.java new file mode 100644 index 0000000000..8d1493c39a --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/java/com/nvidia/boot/properties/env/ReloadablePropertiesEnvironmentPostProcessor.java @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.properties.env; + +import static com.nvidia.boot.properties.util.ReloadablePropertiesConstants.ENABLED; +import static com.nvidia.boot.properties.util.ReloadablePropertiesConstants.FILE; +import static com.nvidia.boot.properties.util.ReloadablePropertiesConstants.PROPERTY_SOURCE_NAME; + +import com.nvidia.boot.properties.property.YamlPropertySourceFactory; +import org.apache.commons.lang3.StringUtils; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.EnvironmentPostProcessor; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.PropertySource; + +/** + * EnvironmentPostProcessor that adds the reloadable properties file as a PropertySource + * during environment preparation, before PropertySourceLocators run. This allows other + * libraries' BootstrapConfigurations to reference properties from the reloadable file. + *

+ * Configure via bootstrap.yaml under {@code nv-boot.reloadable-properties}. + */ +public class ReloadablePropertiesEnvironmentPostProcessor implements EnvironmentPostProcessor { + + @Override + public void postProcessEnvironment( + ConfigurableEnvironment environment, + SpringApplication application) { + if (shouldSkip(environment)) { + return; + } + + var propertySourceName = PROPERTY_SOURCE_NAME; + var sources = environment.getPropertySources(); + sources.remove(propertySourceName); + sources.addFirst(createPropertySource(environment, propertySourceName)); + } + + private static boolean shouldSkip(ConfigurableEnvironment environment) { + var enabled = environment.getProperty(ENABLED, "true"); + return !"true".equalsIgnoreCase(enabled); + } + + private PropertySource createPropertySource( + ConfigurableEnvironment environment, + String propertySourceName) { + var file = environment.getProperty(FILE); + if (StringUtils.isBlank(file)) { + throw new IllegalStateException( + FILE + " must be set when " + ENABLED + " is true"); + } + + return new YamlPropertySourceFactory().createPropertySource(propertySourceName, file); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/java/com/nvidia/boot/properties/property/FileBasedMapPropertySource.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/java/com/nvidia/boot/properties/property/FileBasedMapPropertySource.java new file mode 100644 index 0000000000..156fbf50b4 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/java/com/nvidia/boot/properties/property/FileBasedMapPropertySource.java @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.properties.property; + +import java.io.File; +import java.util.Map; +import org.springframework.core.env.MapPropertySource; + +/** + * MapPropertySource backed by a file, tracking lastModified for change detection. + */ +public class FileBasedMapPropertySource extends MapPropertySource { + + private final File file; + private final long lastModified; + + public FileBasedMapPropertySource( + String name, + Map source, + File file, + long lastModified) { + super(name, source); + this.file = file; + this.lastModified = lastModified; + } + + public File getFile() { + return file; + } + + public long getLastModified() { + return lastModified; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/java/com/nvidia/boot/properties/property/ReloadablePropertiesConfigurationProvider.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/java/com/nvidia/boot/properties/property/ReloadablePropertiesConfigurationProvider.java new file mode 100644 index 0000000000..57c606a8d5 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/java/com/nvidia/boot/properties/property/ReloadablePropertiesConfigurationProvider.java @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.properties.property; + +import static com.nvidia.boot.properties.util.ReloadablePropertiesConstants.FILE; +import static com.nvidia.boot.properties.util.ReloadablePropertiesConstants.POLL_DURATION; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.boot.convert.DurationStyle; +import org.springframework.core.env.Environment; + +/** + * Reads file path and poll duration from {@code nv-boot.reloadable-properties} properties + * in the Environment. + */ +public class ReloadablePropertiesConfigurationProvider { + + private static final int DEFAULT_DELAY_SECONDS = 300; + + private final Environment environment; + + public ReloadablePropertiesConfigurationProvider(Environment environment) { + this.environment = environment; + } + + public String getPropertiesFilePath() { + var file = environment.getProperty(FILE); + if (StringUtils.isBlank(file)) { + var mesg = "'%s' must be set when reloadable properties are enabled" + .formatted(FILE); + throw new IllegalStateException(mesg); + } + return file; + } + + public int getDelaySeconds() { + var pollDurationStr = environment.getProperty(POLL_DURATION); + if (StringUtils.isBlank(pollDurationStr)) { + return DEFAULT_DELAY_SECONDS; + } + return (int) DurationStyle.detectAndParse(pollDurationStr).getSeconds(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/java/com/nvidia/boot/properties/property/YamlPropertySourceFactory.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/java/com/nvidia/boot/properties/property/YamlPropertySourceFactory.java new file mode 100644 index 0000000000..405580b326 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/java/com/nvidia/boot/properties/property/YamlPropertySourceFactory.java @@ -0,0 +1,143 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.properties.property; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.google.common.io.CharStreams; +import com.nvidia.boot.properties.util.IsPhysicalFilePredicate; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.springframework.boot.env.OriginTrackedMapPropertySource; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.PropertySource; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.FileSystemResourceLoader; +import org.springframework.core.io.Resource; + +/** + * Creates MapPropertySource from YAML/JSON files. + */ +public class YamlPropertySourceFactory { + + /** + * Creates a property source from the given file path. + * + *

+ * To specify an absolute path resource use "file:" prefix. + * To specify a classpath resource use "classpath:" prefix. + * To specify a relative path resource use no prefix. + * + * @param name name of the property source + * @param file path to the file + * @return MapPropertySource (FileBasedMapPropertySource for physical files) + */ + public MapPropertySource createPropertySource(String name, String file) { + try { + var resource = new FileSystemResourceLoader().getResource(file); + + var propertySource = IsPhysicalFilePredicate.test(resource) + ? createFileBasedPropertySource(name, resource) + : createRegularPropertySource(name, resource); + validatePropertySource(propertySource); + return propertySource; + } catch (Exception ex) { + var mesg = "Failed to create property source from [" + file + "]"; + throw new IllegalArgumentException(mesg, ex); + } + } + + private static MapPropertySource createRegularPropertySource( + String name, + Resource resource) throws IOException { + return new MapPropertySource(name, createStorage(name, resource)); + } + + private static FileBasedMapPropertySource createFileBasedPropertySource( + String name, + Resource fileResource) throws IOException { + var lastModifiedBefore = fileResource.lastModified(); + var storage = createStorage(name, fileResource); + var lastModifiedAfter = fileResource.lastModified(); + + // If last modified timestamps are different - there is no guaranteed way to say which one + // was actually read by us. Do one more attempt. + if (lastModifiedBefore != lastModifiedAfter) { + return createFileBasedPropertySource(name, fileResource); + } + + return new FileBasedMapPropertySource( + name, + storage, + fileResource.getFile(), + lastModifiedBefore + ); + } + + private static Map createStorage( + String name, + Resource resource) throws IOException { + try (var reader = new InputStreamReader(resource.getInputStream(), UTF_8)) { + var content = CharStreams.toString(reader); + var storage = new HashMap(); + + loadPropertySources(name, content) + .stream() + .map(OriginTrackedMapPropertySource.class::cast) + .forEach(source -> fillStorage(storage, source)); + + return storage; + } + } + + private static List> loadPropertySources( + String name, + String content) throws IOException { + try { + var resource = new ByteArrayResource(content.getBytes(UTF_8)); + return new YamlPropertySourceLoader().load(name, resource); + } catch (Exception ex) { + throw new RuntimeException("Malformed file content", ex); + } + } + + private static void fillStorage( + Map storage, + OriginTrackedMapPropertySource source) { + for (var propertyName : source.getPropertyNames()) { + if (storage.containsKey(propertyName)) { + throw new IllegalArgumentException("Duplicate key [" + propertyName + "]"); + } + var value = source.getProperty(propertyName); + storage.put(propertyName, Objects.requireNonNull(value)); + } + } + + private static void validatePropertySource(MapPropertySource propertySource) { + var propertyNames = propertySource.getPropertyNames(); + if (propertyNames.length == 0) { + var mesg = "Property source [" + propertySource.getName() + "] is empty."; + throw new IllegalArgumentException(mesg); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/java/com/nvidia/boot/properties/refresher/FileBasedPropertiesRefresher.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/java/com/nvidia/boot/properties/refresher/FileBasedPropertiesRefresher.java new file mode 100644 index 0000000000..7f3adfbd66 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/java/com/nvidia/boot/properties/refresher/FileBasedPropertiesRefresher.java @@ -0,0 +1,123 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.properties.refresher; + +import com.nvidia.boot.properties.property.ReloadablePropertiesConfigurationProvider; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import java.io.File; +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.cloud.context.refresh.ContextRefresher; + +/** + * Polls the properties file and triggers Spring Cloud context refresh when it changes. + */ +@Slf4j +@RequiredArgsConstructor +public class FileBasedPropertiesRefresher { + + private static final String FILE_SCHEME = "file:"; + + private final ScheduledExecutorService executor = Executors + .newSingleThreadScheduledExecutor(r -> { + var t = new Thread(r, "properties-refresher"); + t.setDaemon(true); + return t; + }); + + private final ContextRefresher contextRefresher; + private final ReloadablePropertiesConfigurationProvider configurationProvider; + + private volatile long lastModified = 0; + + @PostConstruct + public void start() { + var filePath = configurationProvider.getPropertiesFilePath(); + if (StringUtils.isNotBlank(filePath) && filePath.startsWith(FILE_SCHEME)) { + filePath = filePath.substring(5); + } + var delaySeconds = configurationProvider.getDelaySeconds(); + var file = new File(filePath); + + if (file.exists()) { + lastModified = file.lastModified(); + log.info("Starting file watcher for: {} (polling every {}s)", filePath, delaySeconds); + + // Initial delay is set to delaySeconds as well to avoid first execution + // happening immediately. + executor.scheduleWithFixedDelay( + this::checkAndRefresh, + delaySeconds, + delaySeconds, + TimeUnit.SECONDS + ); + } else { + log.warn("Properties file not found, file watcher disabled: {}", filePath); + } + } + + @PreDestroy + public void stop() { + log.info("Stopping file watcher"); + executor.shutdown(); + try { + if (!executor.awaitTermination(5, TimeUnit.SECONDS)) { + executor.shutdownNow(); + } + } catch (InterruptedException e) { + executor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + + /** + * Force trigger for testing. + */ + public void forceTrigger(long timeout, TimeUnit timeUnit) throws Exception { + executor.submit(this::checkAndRefresh).get(timeout, timeUnit); + } + + private void checkAndRefresh() { + try { + var filePath = configurationProvider.getPropertiesFilePath(); + if (StringUtils.isNotBlank(filePath) && filePath.startsWith(FILE_SCHEME)) { + filePath = filePath.substring(FILE_SCHEME.length()); + } + var file = new File(filePath); + var currentLastModified = file.lastModified(); + + if (currentLastModified > lastModified) { + log.info("Properties file changed, triggering refresh..."); + Set refreshedKeys = contextRefresher.refresh(); + lastModified = currentLastModified; + log.info("{} refreshed properties successfully. Updated keys: {}", + this.getClass().getSimpleName(), refreshedKeys); + } else { + log.info("{} finished without refresh", this.getClass().getSimpleName()); + } + } catch (Throwable t) { + log.error("{} error during properties refresh", this.getClass().getSimpleName(), t); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/java/com/nvidia/boot/properties/util/IsPhysicalFilePredicate.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/java/com/nvidia/boot/properties/util/IsPhysicalFilePredicate.java new file mode 100644 index 0000000000..6a27d8de64 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/java/com/nvidia/boot/properties/util/IsPhysicalFilePredicate.java @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.properties.util; + +import java.io.File; +import java.io.IOException; +import org.springframework.core.io.Resource; + +/** + * Checks if a resource is a physical file on the filesystem. + */ +public final class IsPhysicalFilePredicate { + + private IsPhysicalFilePredicate() { + } + + /** + * Checks if the given resource is a file. + * Note: {@link Resource#isFile()} does not guarantee {@link Resource#getFile()} will succeed. + */ + public static boolean test(Resource resource) { + try { + File file = resource.getFile(); + return file.isFile() && file.exists(); + } catch (IOException e) { + return false; + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/java/com/nvidia/boot/properties/util/ReloadablePropertiesConstants.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/java/com/nvidia/boot/properties/util/ReloadablePropertiesConstants.java new file mode 100644 index 0000000000..adf6511bcf --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/java/com/nvidia/boot/properties/util/ReloadablePropertiesConstants.java @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.properties.util; + +/** + * Constants for reloadable properties configuration. + */ +public final class ReloadablePropertiesConstants { + + private ReloadablePropertiesConstants() { + } + + // Name of the PropertySource for reloadable properties. + public static final String PROPERTY_SOURCE_NAME = + "nv-boot-starter-reloadable-properties-source"; + + public static final String ENABLED = "nv-boot.reloadable-properties.enabled"; + + // Path to the reloadable properties file. + public static final String FILE = "nv-boot.reloadable-properties.file"; + + // Poll duration as Duration (e.g. 5s, PT5S, 300s). + public static final String POLL_DURATION = "nv-boot.reloadable-properties.poll-duration"; +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/resources/META-INF/spring.factories b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000000..f3cb208105 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/resources/META-INF/spring.factories @@ -0,0 +1,3 @@ +# EnvironmentPostProcessor for reloadable properties (loads before PropertySourceLocators) +org.springframework.boot.EnvironmentPostProcessor=\ +com.nvidia.boot.properties.env.ReloadablePropertiesEnvironmentPostProcessor diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000000..ec0f8c947b --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +com.nvidia.boot.properties.ReloadablePropertiesAutoConfiguration diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/BaseReloadablePropertiesIntegrationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/BaseReloadablePropertiesIntegrationTest.java new file mode 100644 index 0000000000..4a904ddc44 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/BaseReloadablePropertiesIntegrationTest.java @@ -0,0 +1,99 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.properties; + +import static com.nvidia.boot.properties.util.ReloadablePropertiesConstants.ENABLED; +import static com.nvidia.boot.properties.util.ReloadablePropertiesConstants.FILE; +import static com.nvidia.boot.properties.util.ReloadablePropertiesConstants.POLL_DURATION; +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.ObjectNode; +import com.nvidia.boot.properties.refresher.FileBasedPropertiesRefresher; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.nio.file.Files; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.util.FileCopyUtils; + +@SpringBootTest( + classes = TestApplication.class, + properties = { + ENABLED + "=true", + FILE + "=file:target/test-reloadable.json", + POLL_DURATION + "=300s" + }) +@ActiveProfiles("test") +abstract class BaseReloadablePropertiesIntegrationTest { + + protected static File tempFile; + + private static final JsonMapper JSON_MAPPER = JsonMapper.builder().build(); + private static final File TEST_RELOADABLE_FILE = new File("target/test-reloadable.json"); + + @Autowired + protected ApplicationContext context; + + @Autowired + protected TestApplication.TestComponent testComponent; + + @Autowired + protected FileBasedPropertiesRefresher refresher; + + public static void setUp() throws IOException { + TEST_RELOADABLE_FILE.getParentFile().mkdirs(); + FileCopyUtils.copy(new File("src/test/resources/kv_secret_example.json"), + TEST_RELOADABLE_FILE); + tempFile = TEST_RELOADABLE_FILE; + } + + public static void cleanUp() { + if (tempFile != null && tempFile.exists()) { + tempFile.delete(); + } + tempFile = null; + } + + @Test + void verifyReload() throws Exception { + assertThat(testComponent.getUsername()).isEqualTo("myUser"); + + JsonNode root = JSON_MAPPER.readTree(tempFile); + ((ObjectNode) root.get("kv").get("exampleSecretNoHistoryJson") + .get("current").get("value")) + .put("username", "myUser2"); + Files.write(tempFile.toPath(), JSON_MAPPER.writerWithDefaultPrettyPrinter() + .writeValueAsBytes(root)); + + refresher.forceTrigger(10_000, TimeUnit.SECONDS); + assertThat(testComponent.getUsername()).isEqualTo("myUser2"); + } + + @Test + void propertyValueLoadedCorrectly() { + assertThat(testComponent.getUsername()).isEqualTo("myUser"); + assertThat(testComponent.getUsernamePrevious()).isEqualTo("myUser"); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/ReloadablePropertiesPropertySourceLocatorIntegrationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/ReloadablePropertiesPropertySourceLocatorIntegrationTest.java new file mode 100644 index 0000000000..868983c7a4 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/ReloadablePropertiesPropertySourceLocatorIntegrationTest.java @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.properties; + +import static com.nvidia.boot.properties.util.ReloadablePropertiesConstants.ENABLED; +import static com.nvidia.boot.properties.util.ReloadablePropertiesConstants.FILE; +import static com.nvidia.boot.properties.util.ReloadablePropertiesConstants.POLL_DURATION; +import static org.assertj.core.api.Assertions.assertThat; + +import com.nvidia.boot.properties.refresher.FileBasedPropertiesRefresher; +import java.io.IOException; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest( + classes = TestApplication.class, + properties = { + ENABLED + "=true", + FILE + "=file:target/test-reloadable.json", + POLL_DURATION + "=300s" + }) +@ActiveProfiles("test") +class ReloadablePropertiesPropertySourceLocatorIntegrationTest + extends BaseReloadablePropertiesIntegrationTest { + + @BeforeAll + public static void setUp() throws IOException { + BaseReloadablePropertiesIntegrationTest.setUp(); + System.setProperty(FILE, "file:target/test-reloadable.json"); + System.setProperty(ENABLED, "true"); + System.setProperty(POLL_DURATION, "300s"); + } + + @AfterAll + public static void cleanUp() { + BaseReloadablePropertiesIntegrationTest.cleanUp(); + System.clearProperty(FILE); + System.clearProperty(ENABLED); + System.clearProperty(POLL_DURATION); + } + + @Test + void refresherLoaded() { + assertThat(context.getBeansOfType(FileBasedPropertiesRefresher.class)).isNotEmpty(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/TestApplication.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/TestApplication.java new file mode 100644 index 0000000000..c1ffa855b6 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/TestApplication.java @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.properties; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.context.annotation.Bean; + +@SpringBootApplication +public class TestApplication { + + @Getter + @AllArgsConstructor + public static class TestComponent { + private final String username; + private final String usernamePrevious; + } + + @Bean + @RefreshScope + public TestComponent testComponent(@Value("${test.username}") String username, + @Value("${test.usernamePrevious}") String usernamePrevious) { + return new TestComponent(username, usernamePrevious); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/path/AbsolutePathIntegrationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/path/AbsolutePathIntegrationTest.java new file mode 100644 index 0000000000..84cc1ae9fd --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/path/AbsolutePathIntegrationTest.java @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.properties.path; + +import static com.nvidia.boot.properties.util.ReloadablePropertiesConstants.ENABLED; +import static com.nvidia.boot.properties.util.ReloadablePropertiesConstants.FILE; +import static com.nvidia.boot.properties.util.ReloadablePropertiesConstants.POLL_DURATION; +import static org.assertj.core.api.Assertions.assertThat; + +import com.nvidia.boot.properties.TestApplication; +import com.nvidia.boot.properties.refresher.FileBasedPropertiesRefresher; +import java.io.File; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest(classes = TestApplication.class) +@ActiveProfiles("test") +class AbsolutePathIntegrationTest extends PathIntegrationTest { + + @BeforeAll + static void init() { + var absolutePath = new File("src/test/resources/kv_secret_example.json") + .getAbsolutePath(); + System.setProperty(ENABLED, "true"); + System.setProperty(FILE, "file:" + absolutePath); + System.setProperty(POLL_DURATION, "300s"); + } + + @AfterAll + static void cleanUp() { + System.clearProperty(ENABLED); + System.clearProperty(FILE); + System.clearProperty(POLL_DURATION); + } + + @Test + void refresherLoaded() { + assertThat(context.getBeansOfType(FileBasedPropertiesRefresher.class)).isNotEmpty(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/path/ClasspathAndFileIntegrationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/path/ClasspathAndFileIntegrationTest.java new file mode 100644 index 0000000000..27a40667d5 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/path/ClasspathAndFileIntegrationTest.java @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.properties.path; + +import static com.nvidia.boot.properties.util.ReloadablePropertiesConstants.ENABLED; +import static com.nvidia.boot.properties.util.ReloadablePropertiesConstants.FILE; +import static com.nvidia.boot.properties.util.ReloadablePropertiesConstants.POLL_DURATION; +import static org.assertj.core.api.Assertions.assertThat; + +import com.nvidia.boot.properties.TestApplication; +import com.nvidia.boot.properties.refresher.FileBasedPropertiesRefresher; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +/** + * Verifies that when the properties file is on classpath and also accessible as a + * physical file (e.g. from src/test/resources), properties are loaded and + * FileBasedPropertiesRefresher starts polling. + */ +@SpringBootTest(classes = TestApplication.class) +@ActiveProfiles("test") +class ClasspathAndFileIntegrationTest extends PathIntegrationTest { + + @org.junit.jupiter.api.BeforeAll + static void init() { + System.setProperty(ENABLED, "true"); + System.setProperty(FILE, "classpath:kv_secret_example.json"); + System.setProperty(POLL_DURATION, "300s"); + } + + @org.junit.jupiter.api.AfterAll + static void cleanUp() { + System.clearProperty(ENABLED); + System.clearProperty(FILE); + System.clearProperty(POLL_DURATION); + } + + @Test + void refresherLoaded() { + assertThat(context.getBeansOfType(FileBasedPropertiesRefresher.class)).isNotEmpty(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/path/ClasspathJarIntegrationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/path/ClasspathJarIntegrationTest.java new file mode 100644 index 0000000000..c7edcbdb08 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/path/ClasspathJarIntegrationTest.java @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.properties.path; + +import static com.nvidia.boot.properties.util.ReloadablePropertiesConstants.ENABLED; +import static com.nvidia.boot.properties.util.ReloadablePropertiesConstants.FILE; +import static com.nvidia.boot.properties.util.ReloadablePropertiesConstants.POLL_DURATION; +import static org.assertj.core.api.Assertions.assertThat; + +import com.nvidia.boot.properties.TestApplication; +import com.nvidia.boot.properties.refresher.FileBasedPropertiesRefresher; +import com.nvidia.boot.properties.util.TestJarUtils; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +/** + * Verifies that when the properties file is only on classpath (inside a JAR, not on + * filesystem), properties are loaded correctly. FileBasedPropertiesRefresher is still + * registered but does not start polling since the file is not a physical file. + */ +@SpringBootTest(classes = TestApplication.class) +@ActiveProfiles("test") +class ClasspathJarIntegrationTest extends PathIntegrationTest { + + @BeforeAll + static void init() { + System.setProperty(ENABLED, "true"); + System.setProperty(FILE, "classpath:secrets.json"); + System.setProperty(POLL_DURATION, "300s"); + TestJarUtils.loadJar(TestJarUtils.createJar( + "src/test/resources/kv_secret_example.json", + "secrets.json")); + } + + @AfterAll + static void cleanUp() { + System.clearProperty(ENABLED); + System.clearProperty(FILE); + System.clearProperty(POLL_DURATION); + } + + @Test + void refresherLoaded() { + assertThat(context.getBeansOfType(FileBasedPropertiesRefresher.class)).isNotEmpty(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/path/PathIntegrationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/path/PathIntegrationTest.java new file mode 100644 index 0000000000..c930464761 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/path/PathIntegrationTest.java @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.properties.path; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; + +/** + * Base class for integration tests that verify property loading via different path formats: + * file (absolute/relative), classpath. + */ +abstract class PathIntegrationTest { + + protected static final String EXPECTED_VALUE = "myTestValue"; + + @Autowired + protected ApplicationContext context; + + @Value("${kv.testValue.current.value}") + protected String value; + + @Test + void propertyValueLoadedCorrectly() { + assertThat(value).isEqualTo(EXPECTED_VALUE); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/path/RelativePathIntegrationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/path/RelativePathIntegrationTest.java new file mode 100644 index 0000000000..56ed4e39e5 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/path/RelativePathIntegrationTest.java @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.properties.path; + +import static com.nvidia.boot.properties.util.ReloadablePropertiesConstants.ENABLED; +import static com.nvidia.boot.properties.util.ReloadablePropertiesConstants.FILE; +import static com.nvidia.boot.properties.util.ReloadablePropertiesConstants.POLL_DURATION; +import static org.assertj.core.api.Assertions.assertThat; + +import com.nvidia.boot.properties.TestApplication; +import com.nvidia.boot.properties.refresher.FileBasedPropertiesRefresher; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest(classes = TestApplication.class) +@ActiveProfiles("test") +class RelativePathIntegrationTest extends PathIntegrationTest { + + @BeforeAll + static void init() { + System.setProperty(ENABLED, "true"); + System.setProperty(FILE, "src/test/resources/kv_secret_example.json"); + System.setProperty(POLL_DURATION, "300s"); + } + + @org.junit.jupiter.api.AfterAll + static void cleanUp() { + System.clearProperty(ENABLED); + System.clearProperty(FILE); + System.clearProperty(POLL_DURATION); + } + + @Test + void refresherLoaded() { + assertThat(context.getBeansOfType(FileBasedPropertiesRefresher.class)).isNotEmpty(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/property/YamlPropertySourceFactoryTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/property/YamlPropertySourceFactoryTest.java new file mode 100644 index 0000000000..68268e5763 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/property/YamlPropertySourceFactoryTest.java @@ -0,0 +1,221 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.properties.property; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import java.io.File; +import java.util.Arrays; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.util.FileCopyUtils; + +class YamlPropertySourceFactoryTest { + + private static final String EXPECTED_USERNAME = "myUser"; + private static final String EXPECTED_PASSWORD = "myPass"; + private static final String EXPECTED_TEST_VALUE = "myTestValue"; + + private final YamlPropertySourceFactory factory = new YamlPropertySourceFactory(); + + @Test + void createsPropertySourceFromJsonFile() { + var path = "src/test/resources/kv_secret_example.json"; + var source = factory.createPropertySource("test", path); + + assertThat(source).isNotNull(); + assertThat(source.getProperty("kv.testValue.current.value")).isEqualTo(EXPECTED_TEST_VALUE); + assertThat(source.getProperty("kv.exampleSecretNoHistoryJson.current.value.username")) + .isEqualTo(EXPECTED_USERNAME); + } + + @Test + void createsFileBasedPropertySourceForPhysicalFile() { + var path = "src/test/resources/kv_secret_example.json"; + var source = factory.createPropertySource("test", path); + + assertThat(source).isInstanceOf(FileBasedMapPropertySource.class); + var fileBased = (FileBasedMapPropertySource) source; + assertThat(fileBased.getFile()).exists(); + assertThat(fileBased.getLastModified()).isPositive(); + } + + @Test + void createsPropertySourceFromFileAbsolutePath(@TempDir File tempDir) throws Exception { + var sourceFile = new File(tempDir, "secrets.json"); + FileCopyUtils.copy(new File("src/test/resources/kv_secret_example.json"), sourceFile); + + var path = "file:" + sourceFile.getAbsolutePath(); + var source = factory.createPropertySource("test", path); + + assertThat(source).isNotNull(); + assertThat(source.getProperty("kv.testValue.current.value")).isEqualTo(EXPECTED_TEST_VALUE); + assertThat(source.getProperty("kv.exampleSecretNoHistoryJson.current.value.username")) + .isEqualTo(EXPECTED_USERNAME); + } + + @Test + void createsPropertySourceFromFileUriWithThreeSlashes(@TempDir File tempDir) throws Exception { + var sourceFile = new File(tempDir, "secrets.json"); + FileCopyUtils.copy(new File("src/test/resources/kv_secret_example.json"), sourceFile); + + var path = "file://" + sourceFile.getAbsolutePath(); + var source = factory.createPropertySource("test", path); + + assertThat(source).isNotNull(); + assertThat(source.getProperty("kv.testValue.current.value")).isEqualTo(EXPECTED_TEST_VALUE); + assertThat(source.getProperty("kv.exampleSecretNoHistoryJson.current.value.username")) + .isEqualTo(EXPECTED_USERNAME); + } + + @Test + void createsPropertySourceFromFileUriWithLocalhost(@TempDir File tempDir) throws Exception { + var sourceFile = new File(tempDir, "secrets.json"); + FileCopyUtils.copy(new File("src/test/resources/kv_secret_example.json"), sourceFile); + + var path = "file://localhost" + sourceFile.getAbsolutePath(); + var source = factory.createPropertySource("test", path); + + assertThat(source).isNotNull(); + assertThat(source.getProperty("kv.testValue.current.value")).isEqualTo(EXPECTED_TEST_VALUE); + assertThat(source.getProperty("kv.exampleSecretNoHistoryJson.current.value.username")) + .isEqualTo(EXPECTED_USERNAME); + } + + @Test + void createsPropertySourceFromFileRelativePath() { + var path = "file:src/test/resources/kv_secret_example.json"; + var source = factory.createPropertySource("test", path); + + assertThat(source).isNotNull(); + assertThat(source.getProperty("kv.testValue.current.value")).isEqualTo(EXPECTED_TEST_VALUE); + assertThat(source.getProperty("kv.exampleSecretNoHistoryJson.current.value.username")) + .isEqualTo(EXPECTED_USERNAME); + } + + @Test + void createsPropertySourceFromClasspath() { + var path = "classpath:kv_secret_example.json"; + var source = factory.createPropertySource("test", path); + + assertThat(source).isNotNull(); + assertThat(source.getProperty("kv.testValue.current.value")).isEqualTo(EXPECTED_TEST_VALUE); + assertThat(source.getProperty("kv.exampleSecretNoHistoryJson.current.value.username")) + .isEqualTo(EXPECTED_USERNAME); + } + + @Test + void createsPropertySourceFromRelativePathWithoutPrefix() { + var path = "src/test/resources/kv_secret_example.json"; + var source = factory.createPropertySource("test", path); + + assertThat(source).isNotNull(); + assertThat(source.getProperty("kv.testValue.current.value")).isEqualTo(EXPECTED_TEST_VALUE); + assertThat(source.getProperty("kv.exampleSecretNoHistoryJson.current.value.username")) + .isEqualTo(EXPECTED_USERNAME); + } + + @Test + void throwsForInvalidPath() { + assertThatThrownBy(() -> factory.createPropertySource("test", "nonexistent.json")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Failed to create property source"); + } + + // --- Tests using validator fixtures --- + + @ParameterizedTest + @MethodSource("wellFormedPropertySourcesArguments") + void wellFormedPropertySources(String absolutePath) { + var source = factory.createPropertySource("test", "file:" + absolutePath); + + assertSoftly(assertions -> { + assertions.assertThat(source.getProperty("kv.exampleSecret.current.value.username")) + .isEqualTo(EXPECTED_USERNAME); + assertions.assertThat(source.getProperty("kv.exampleSecret.current.value.password")) + .isEqualTo(EXPECTED_PASSWORD); + }); + } + + @ParameterizedTest + @MethodSource("wellFormedPropertySourcesArguments") + void wellFormedPropertySourcesCreatesFileBasedPropertySource(String absolutePath) { + var file = new File(absolutePath); + var source = factory.createPropertySource("test", "file:" + absolutePath); + + assertSoftly(assertions -> { + assertions.assertThat(source).isInstanceOf(FileBasedMapPropertySource.class); + var fileBased = (FileBasedMapPropertySource) source; + assertions.assertThat(fileBased.getFile()).isEqualTo(file); + assertions.assertThat(fileBased.getLastModified()).isEqualTo(file.lastModified()); + }); + } + + @ParameterizedTest + @MethodSource("malformedPropertySourcesArguments") + void malformedPropertySources(String absolutePath) { + assertThatThrownBy(() -> factory.createPropertySource("test", "file:" + absolutePath)) + .isInstanceOf(IllegalArgumentException.class); + } + + @ParameterizedTest + @MethodSource("emptyPropertySourcesArguments") + void emptyPropertySources(String absolutePath) { + assertThatThrownBy(() -> factory.createPropertySource("test", "file:" + absolutePath)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("empty"); + } + + private static Stream wellFormedPropertySourcesArguments() throws Exception { + return fetchFilePaths("classpath*:fixtures/validator/yaml/valid/*"); + } + + private static Stream malformedPropertySourcesArguments() throws Exception { + return fetchFilePaths("classpath*:fixtures/validator/yaml/invalid/*"); + } + + private static Stream emptyPropertySourcesArguments() throws Exception { + return fetchFilePaths("classpath*:fixtures/validator/yaml/empty/*"); + } + + // classpath*: searches all classpath roots (every JAR and directory); classpath: would stop + // at the first match. Using classpath*: ensures we find all fixture files when the same path + // exists in multiple roots, or when the classpath structure varies (e.g. IDE vs Maven). + private static Stream fetchFilePaths(String path) throws Exception { + var resolver = new PathMatchingResourcePatternResolver(); + Resource[] resources = resolver.getResources(path); + return Arrays.stream(resources) + .filter(Resource::isFile) + .map(r -> { + try { + return r.getFile().getAbsolutePath(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }) + .map(Arguments::of); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/refresher/FileBasedPropertiesRefresherTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/refresher/FileBasedPropertiesRefresherTest.java new file mode 100644 index 0000000000..bdd21c8a84 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/refresher/FileBasedPropertiesRefresherTest.java @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.properties.refresher; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.nvidia.boot.properties.property.ReloadablePropertiesConfigurationProvider; +import java.io.File; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.cloud.context.refresh.ContextRefresher; +import org.springframework.util.FileCopyUtils; + +@ExtendWith(OutputCaptureExtension.class) +class FileBasedPropertiesRefresherTest { + + @Test + void refreshOnFileChange(CapturedOutput output) throws Exception { + File tempFile = File.createTempFile("reloadable", ".json"); + tempFile.deleteOnExit(); + FileCopyUtils.copy(new File("src/test/resources/kv_secret_example.json"), tempFile); + + var mockPathProvider = mock(ReloadablePropertiesConfigurationProvider.class); + var mockRefresher = mock(ContextRefresher.class); + + when(mockPathProvider.getPropertiesFilePath()).thenReturn(tempFile.getAbsolutePath()); + when(mockPathProvider.getDelaySeconds()).thenReturn(2); + when(mockRefresher.refresh()).thenReturn(java.util.Set.of("test.key")); + + var refresher = new FileBasedPropertiesRefresher(mockRefresher, mockPathProvider); + refresher.start(); + + // Touch file to trigger change detection + tempFile.setLastModified(System.currentTimeMillis() + 1000); + refresher.forceTrigger(10_000, TimeUnit.SECONDS); + + assertThat(output.getOut()).contains("refreshed properties successfully"); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/util/TestJarUtils.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/util/TestJarUtils.java new file mode 100644 index 0000000000..db09c59e95 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/java/com/nvidia/boot/properties/util/TestJarUtils.java @@ -0,0 +1,84 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.properties.util; + +import static java.nio.file.Files.readAllBytes; + +import java.io.File; +import java.io.FileOutputStream; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Paths; +import java.util.jar.JarOutputStream; +import java.util.zip.ZipEntry; +import lombok.SneakyThrows; + +/** + * Test utilities for creating and loading JARs. Used by integration tests to simulate + * classpath-only resources (e.g. properties file inside a JAR, not on filesystem). + */ +public final class TestJarUtils { + + private TestJarUtils() { + } + + /** + * Loads a JAR into the current thread's context class loader. + * After loading, the JAR's classes and resources can be accessed. + *

+ * Creates a new class loader with the given JAR and the current context class loader + * as parent, then replaces the thread's context class loader. Spring delegates + * resource loading to the thread context class loader by default. + * + * @param file path to the JAR + */ + @SneakyThrows + public static void loadJar(File file) { + var currentThread = Thread.currentThread(); + var originalCL = currentThread.getContextClassLoader(); + currentThread.setContextClassLoader(new TestJarUtilsClassLoader(file, originalCL)); + } + + /** + * Creates a JAR containing the given properties file. + * + * @param inputPropertiesPath path to the source properties file + * @param outputPropertiesPath path of the file inside the JAR + * @return the created JAR file + */ + @SneakyThrows + public static File createJar(String inputPropertiesPath, String outputPropertiesPath) { + var location = File.createTempFile("testJar", ".jar"); + + try (var fileOut = new FileOutputStream(location); + var jarOut = new JarOutputStream(fileOut)) { + jarOut.putNextEntry(new ZipEntry(outputPropertiesPath)); + jarOut.write(readAllBytes(Paths.get(inputPropertiesPath))); + jarOut.closeEntry(); + } + + location.deleteOnExit(); + return location; + } + + static class TestJarUtilsClassLoader extends URLClassLoader { + TestJarUtilsClassLoader(File jarFile, ClassLoader parent) throws Exception { + super(new URL[] {jarFile.toURI().toURL()}, parent); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/application-test.yaml b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/application-test.yaml new file mode 100644 index 0000000000..6c956254bd --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/application-test.yaml @@ -0,0 +1,3 @@ +test: + username: ${kv.exampleSecretNoHistoryJson.current.value.username} + usernamePrevious: ${kv.exampleSecretHistoryJson.previous[0].value.username} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/fixtures/validator/yaml/empty/empty.json b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/fixtures/validator/yaml/empty/empty.json new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/fixtures/validator/yaml/empty/empty.yaml b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/fixtures/validator/yaml/empty/empty.yaml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/fixtures/validator/yaml/empty/empty_with_newlines.json b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/fixtures/validator/yaml/empty/empty_with_newlines.json new file mode 100644 index 0000000000..139597f9cb --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/fixtures/validator/yaml/empty/empty_with_newlines.json @@ -0,0 +1,2 @@ + + diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/fixtures/validator/yaml/empty/empty_with_newlines.yaml b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/fixtures/validator/yaml/empty/empty_with_newlines.yaml new file mode 100644 index 0000000000..139597f9cb --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/fixtures/validator/yaml/empty/empty_with_newlines.yaml @@ -0,0 +1,2 @@ + + diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/fixtures/validator/yaml/invalid/duplicate_fields.json b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/fixtures/validator/yaml/invalid/duplicate_fields.json new file mode 100644 index 0000000000..212243666d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/fixtures/validator/yaml/invalid/duplicate_fields.json @@ -0,0 +1,4 @@ +{ + "field": "value", + "field": "value2" +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/fixtures/validator/yaml/invalid/duplicate_fields.yaml b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/fixtures/validator/yaml/invalid/duplicate_fields.yaml new file mode 100644 index 0000000000..accc1c0c74 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/fixtures/validator/yaml/invalid/duplicate_fields.yaml @@ -0,0 +1,4 @@ +--- +field: value +--- +field: value2 diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/fixtures/validator/yaml/invalid/invalid.json b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/fixtures/validator/yaml/invalid/invalid.json new file mode 100644 index 0000000000..ac007e2ae5 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/fixtures/validator/yaml/invalid/invalid.json @@ -0,0 +1,16 @@ +{ + "kv": { + "exampleSecret": { + "current": { + "id": "2020-08-13T18:04:25+00:00", + "value": { + "username": "myUser", + "password": "myPass" + } + } + } + } +} +{ + +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/fixtures/validator/yaml/invalid/invalid.yaml b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/fixtures/validator/yaml/invalid/invalid.yaml new file mode 100644 index 0000000000..4b586d5f11 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/fixtures/validator/yaml/invalid/invalid.yaml @@ -0,0 +1,4 @@ +reload: + propertiesFile: src/main/resources/lofn.json + delayInSeconds: 10 + invalidField=10 diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/fixtures/validator/yaml/valid/valid.json b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/fixtures/validator/yaml/valid/valid.json new file mode 100644 index 0000000000..bec58989d4 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/fixtures/validator/yaml/valid/valid.json @@ -0,0 +1,12 @@ +{ + "kv": { + "exampleSecret": { + "current": { + "value": { + "username": "myUser", + "password": "myPass" + } + } + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/fixtures/validator/yaml/valid/valid.yaml b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/fixtures/validator/yaml/valid/valid.yaml new file mode 100644 index 0000000000..b5ace10bcf --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/fixtures/validator/yaml/valid/valid.yaml @@ -0,0 +1,6 @@ +kv: + exampleSecret: + current: + value: + username: myUser + password: myPass diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/kv_secret_example.json b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/kv_secret_example.json new file mode 100644 index 0000000000..52be499046 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-reloadable-properties/src/test/resources/kv_secret_example.json @@ -0,0 +1,31 @@ +{ + "kv": { + "testValue": { + "current": { + "value": "myTestValue" + } + }, + "exampleSecretNoHistoryJson": { + "current": { + "id": "2020-08-13T18:04:25+00:00", + "timestamp": "2020-08-13T18:04:25+00:00", + "value": { + "username": "myUser", + "password": null + } + } + }, + "exampleSecretHistoryJson": { + "previous": [ + { + "id": "76a261ba-24c7-4ea4-8999-d4272c21b35a", + "timestamp": "2020-08-13T18:04:25+00:00", + "value": { + "username": "myUser", + "password": "myPass" + } + } + ] + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/BUILD.bazel b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/BUILD.bazel new file mode 100644 index 0000000000..0bf2c4e7f7 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/BUILD.bazel @@ -0,0 +1,52 @@ +load("//src/libraries/java/nv-boot-parent/tools/bazel:java.bzl", "nv_boot_library", "nv_boot_library_test") + +TELEMETRY_COMPILE_DEPS = [ + "@nv_third_party_deps//:io_cloudevents_cloudevents_api", + "@nv_third_party_deps//:io_cloudevents_cloudevents_core", + "@nv_third_party_deps//:io_cloudevents_cloudevents_json_jackson", + "@nv_third_party_deps//:io_netty_netty_common", + "@nv_third_party_deps//:io_netty_netty_transport", + "@nv_third_party_deps//:io_projectreactor_netty_reactor_netty_http", + "@nv_third_party_deps//:io_projectreactor_reactor_core", + "@nv_third_party_deps//:jakarta_validation_jakarta_validation_api", + "@nv_third_party_deps//:org_apache_commons_commons_lang3", + "@nv_third_party_deps//:org_slf4j_slf4j_api", + "@nv_third_party_deps//:org_springframework_boot_spring_boot", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_autoconfigure", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_jackson", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_jackson", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_validation", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_webflux", + "@nv_third_party_deps//:org_springframework_security_spring_security_core", + "@nv_third_party_deps//:org_springframework_spring_beans", + "@nv_third_party_deps//:org_springframework_spring_context", + "@nv_third_party_deps//:org_springframework_spring_core", + "@nv_third_party_deps//:org_springframework_spring_web", + "@nv_third_party_deps//:org_springframework_spring_webflux", + "@nv_third_party_deps//:tools_jackson_core_jackson_databind", +] + +nv_boot_library( + name = "nv_boot_starter_telemetry", + srcs = glob(["src/main/java/**/*.java"]), + resource_strip_prefix = "src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/main/resources", + resources = glob(["src/main/resources/**"]), + visibility = ["//visibility:public"], + deps = TELEMETRY_COMPILE_DEPS, +) + +nv_boot_library_test( + name = "tests", + srcs = glob(["src/test/java/**/*.java"]), + coverage_library = ":nv_boot_starter_telemetry", + deps = [ + ":nv_boot_starter_telemetry", + "@nv_third_party_deps//:org_hibernate_validator_hibernate_validator", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_webflux_test", + "@nv_third_party_deps//:org_springframework_security_spring_security_test", + "@nv_third_party_deps//:org_wiremock_wiremock_standalone", + ] + TELEMETRY_COMPILE_DEPS, + size = "medium", + timeout = "moderate", +) diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/README.md b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/README.md new file mode 100644 index 0000000000..875cfba454 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/README.md @@ -0,0 +1,114 @@ +# NV Boot Starter Telemetry + +Telemetry client for sending [CloudEvents](https://cloudevents.io/) to a Telemetry server. Uses WebClient with +OAuth2 bearer token via ExchangeFilterFunction. + +**Apps must register a `TelemetryProperties` bean in the Spring context.** + +## Adding as a Dependency + +```xml + + + + com.nvidia.boot + nv-boot-bom + ${nv-boot.version} + pom + import + + + + + + + com.nvidia.boot + nv-boot-starter-telemetry + + +``` + +## Registering TelemetryProperties + +Apps must provide a `TelemetryProperties` bean. All of `url`, `pathPrefix`, `source`, +and OAuth2 credentials are required; validation fails fast at startup if any are missing. + +### ConfigurationProperties binding + +Bind from `application.yml` using a custom prefix: + +```java +@Configuration +public class TelemetryConfig { + + @Bean + @RefreshScope + @ConfigurationProperties(prefix = "app.telemetry") + public TelemetryProperties telemetryProperties() { + return new TelemetryProperties(); + } +} +``` + +```yaml +app: + telemetry: + url: https://prod.analytics.nvidiagrid.net + path-prefix: /api/v2/topic + source: my-application + oauth2: + token-uri: ${spring.security.oauth2.client.provider.telemetry.token-uri} + client-id: ${spring.security.oauth2.client.registration.telemetry.client-id} + client-secret: ${spring.security.oauth2.client.registration.telemetry.client-secret} + scope: ${spring.security.oauth2.client.registration.telemetry.scope:} +``` + +## Usage + +```java +@Service +@RequiredArgsConstructor +public class MyService { + + private final TelemetryClient telemetryClient; + private final CloudEventBuilderProvider cloudEventBuilderProvider; + private final JsonMapper jsonMapper; + + public void sendTelemetry(String resourceName, MyEvent event) throws IOException { + var cloudEvent = cloudEventBuilderProvider.getCloudEventBuilder() + .withType("com.example.MyEvent") + .withSource(URI.create("urn:my-app")) + .withData(PojoCloudEventData.wrap(event, jsonMapper::writeValueAsBytes)) + .build(); + + var response = telemetryClient.send(resourceName, List.of(cloudEvent)); + // response.getBodyOptional() for JSON body; response.getStatusCode() for HTTP status + } +} +``` + +`TelemetryClient.send()` returns `TelemetryResponse>` with `statusCode` +and optional `body` (Map for JSON responses, null for 204 No Content). + +## Testing + +Run module tests: + +```bash +mvn -pl nv-boot-starter-telemetry test +``` + +- **Unit tests** cover validation (`TelemetryClient`, `TelemetryProperties`), `TelemetryResponse`, + and `CloudEventBuilderProvider` (with `SecurityContextHolder`). +- **Integration tests** use **WireMock** (`wiremock-standalone`, version from `nv-boot-bom`) + for OAuth2 + token exchange, telemetry POST, and `TelemetryWebClientFactory`; + `TelemetryAutoConfigurationIntegrationTest` loads a minimal Spring context with + `WebEnvironment.NONE`. + +## Auto-configured beans + +| Bean | Type | Condition | +|------|------|-----------| +| `TelemetryClient` | `TelemetryClient` | When app registers `TelemetryProperties` | +| `CloudEventBuilderProvider` | `CloudEventBuilderProvider` | When `TelemetryClient` is configured | diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/pom.xml b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/pom.xml new file mode 100644 index 0000000000..bae50ff741 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/pom.xml @@ -0,0 +1,92 @@ + + + + + 4.0.0 + + + com.nvidia.boot + nv-boot-parent + 0.0.1-SNAPSHOT + + + nv-boot-starter-telemetry + jar + NV Boot Starter Telemetry + Telemetry client for sending CloudEvents to a Telemetry server + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-webflux + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.security + spring-security-core + + + + org.springframework.boot + spring-boot-starter-jackson + + + io.cloudevents + cloudevents-core + + + io.cloudevents + cloudevents-json-jackson + + + org.apache.commons + commons-lang3 + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter-webflux-test + test + + + org.springframework.security + spring-security-test + test + + + org.wiremock + wiremock-standalone + test + + + diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/main/java/com/nvidia/boot/telemetry/TelemetryAutoConfiguration.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/main/java/com/nvidia/boot/telemetry/TelemetryAutoConfiguration.java new file mode 100644 index 0000000000..55a18caeda --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/main/java/com/nvidia/boot/telemetry/TelemetryAutoConfiguration.java @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.telemetry; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.telemetry.client.CloudEventBuilderProvider; +import com.nvidia.boot.telemetry.client.OAuth2BearerFilter; +import com.nvidia.boot.telemetry.client.TelemetryClient; +import com.nvidia.boot.telemetry.client.TelemetryProperties; +import com.nvidia.boot.telemetry.client.TelemetryWebClientFactory; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * Auto-configuration for NV Boot telemetry. Apps must add TelemetryProperties to the Spring + * context (e.g., via {@code @Bean}. + */ +@AutoConfiguration +@ConditionalOnBean(TelemetryProperties.class) +public class TelemetryAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(TelemetryClient.class) + public TelemetryClient telemetryClient( + WebClient.Builder webClientBuilder, // Prototype-scoped + TelemetryProperties properties, + JsonMapper jsonMapper) { + var connectTimeout = properties.getConnectTimeout() != null + ? properties.getConnectTimeout() + : java.time.Duration.ofSeconds(30); + var readTimeout = properties.getReadTimeout() != null + ? properties.getReadTimeout() + : java.time.Duration.ofSeconds(30); + + var tokenWebClient = TelemetryWebClientFactory.createTokenWebClient( + webClientBuilder.clone(), + connectTimeout, + readTimeout); + var oauth2BearerFilter = new OAuth2BearerFilter(properties, tokenWebClient); + + var webClient = TelemetryWebClientFactory.createTelemetryWebClient( + webClientBuilder.clone(), + properties.getUrl(), + connectTimeout, + readTimeout, + oauth2BearerFilter); + + return new TelemetryClient(properties, jsonMapper, webClient); + } + + @Bean + @ConditionalOnMissingBean(CloudEventBuilderProvider.class) + public CloudEventBuilderProvider cloudEventBuilderProvider() { + return new CloudEventBuilderProvider(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/main/java/com/nvidia/boot/telemetry/client/CloudEventBuilderProvider.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/main/java/com/nvidia/boot/telemetry/client/CloudEventBuilderProvider.java new file mode 100644 index 0000000000..36c2e9fa1e --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/main/java/com/nvidia/boot/telemetry/client/CloudEventBuilderProvider.java @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.telemetry.client; + +import io.cloudevents.core.builder.CloudEventBuilder; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.UUID; +import org.apache.commons.lang3.StringUtils; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * Provides a pre-configured CloudEventBuilder for creating CloudEvents. + */ +public class CloudEventBuilderProvider { + + private static final String DEFAULT_SUBJECT = "unknown-subject"; + private static final String CLOUDEVENT_DATA_CONTENT_TYPE = "application/json"; + + /** + * Returns a CloudEventBuilder with common attributes (id, time, + * dataContentType, subject) pre-set. + */ + public CloudEventBuilder getCloudEventBuilder() { + return CloudEventBuilder.v1() + .withId(UUID.randomUUID().toString()) + .withTime(Instant.now().atOffset(ZoneOffset.UTC)) + .withDataContentType(CLOUDEVENT_DATA_CONTENT_TYPE) + .withSubject(getSubject()); + } + + private String getSubject() { + var authentication = SecurityContextHolder.getContext().getAuthentication(); + var name = authentication != null ? authentication.getName() : null; + return StringUtils.isNotEmpty(name) ? name : DEFAULT_SUBJECT; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/main/java/com/nvidia/boot/telemetry/client/OAuth2AuthMethod.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/main/java/com/nvidia/boot/telemetry/client/OAuth2AuthMethod.java new file mode 100644 index 0000000000..9a9f0a3fa4 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/main/java/com/nvidia/boot/telemetry/client/OAuth2AuthMethod.java @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.telemetry.client; + +/** + * OAuth2 client authentication method for the token endpoint. + * + *

    + *
  • {@link #CLIENT_SECRET_POST} — sends {@code client_id} and {@code client_secret} + * as form parameters in the request body (default).
  • + *
  • {@link #CLIENT_SECRET_BASIC} — sends credentials as a Base64-encoded + * {@code Authorization: Basic} header.
  • + *
+ */ +public enum OAuth2AuthMethod { + CLIENT_SECRET_POST, + CLIENT_SECRET_BASIC +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/main/java/com/nvidia/boot/telemetry/client/OAuth2BearerFilter.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/main/java/com/nvidia/boot/telemetry/client/OAuth2BearerFilter.java new file mode 100644 index 0000000000..1ef16da3be --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/main/java/com/nvidia/boot/telemetry/client/OAuth2BearerFilter.java @@ -0,0 +1,129 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.telemetry.client; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.commons.lang3.StringUtils; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.ClientRequest; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +/** + * ExchangeFilterFunction that adds OAuth2 Bearer token to requests. + * Obtains tokens via client credentials from TelemetryProperties. + */ +public final class OAuth2BearerFilter implements ExchangeFilterFunction { + + private final TelemetryProperties.OAuth2Properties oauth2; + private final OAuth2AuthMethod authMethod; + private final WebClient tokenWebClient; + private final AtomicReference cachedToken = new AtomicReference<>(); + + public OAuth2BearerFilter(TelemetryProperties properties, WebClient tokenWebClient) { + this.oauth2 = properties.getOauth2(); + this.authMethod = oauth2.getAuthMethod() != null + ? oauth2.getAuthMethod() + : OAuth2AuthMethod.CLIENT_SECRET_POST; + this.tokenWebClient = tokenWebClient; + } + + @Override + public Mono filter(ClientRequest request, ExchangeFunction next) { + return Mono.fromCallable(this::getAccessToken) + .flatMap(token -> next.exchange(ClientRequest.from(request) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .build())); + } + + private String getAccessToken() { + var holder = cachedToken.get(); + if (holder != null && !holder.isExpired()) { + return holder.getToken(); + } + + var requestSpec = tokenWebClient.post() + .uri(oauth2.getTokenUri()) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE); + + if (authMethod == OAuth2AuthMethod.CLIENT_SECRET_BASIC) { + var credentials = oauth2.getClientId() + ":" + oauth2.getClientSecret(); + var encoded = Base64.getEncoder().encodeToString( + credentials.getBytes(StandardCharsets.UTF_8)); + requestSpec = requestSpec.header(HttpHeaders.AUTHORIZATION, "Basic " + encoded); + } + + var tokenResponse = requestSpec + .bodyValue(buildTokenRequestBody()) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() {}) + .blockOptional() + .orElseThrow(() -> new IllegalStateException( + "Failed to obtain OAuth2 access token for telemetry")); + + var token = (String) tokenResponse.get("access_token"); + if (token == null) { + throw new IllegalStateException("OAuth2 token response missing access_token"); + } + var expiresIn = (Number) tokenResponse.get("expires_in"); + var expiresInSeconds = expiresIn != null ? expiresIn.longValue() : 3600L; + cachedToken.set(new TokenHolder(token, expiresInSeconds)); + return token; + } + + private String buildTokenRequestBody() { + var params = new StringBuilder(); + params.append("grant_type=client_credentials"); + if (authMethod == OAuth2AuthMethod.CLIENT_SECRET_POST) { + params.append("&client_id=").append(oauth2.getClientId()); + params.append("&client_secret=").append(oauth2.getClientSecret()); + } + if (StringUtils.isNotBlank(oauth2.getScope())) { + params.append("&scope=").append(oauth2.getScope()); + } + return params.toString(); + } + + private static final class TokenHolder { + private final String token; + private final long expiresAtSeconds; + + TokenHolder(String token, long expiresInSeconds) { + this.token = token; + var now = System.currentTimeMillis() / 1000; + var buffer = Math.max(0, expiresInSeconds - 60); + this.expiresAtSeconds = now + buffer; + } + + String getToken() { + return token; + } + + boolean isExpired() { + return System.currentTimeMillis() / 1000 >= expiresAtSeconds; + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/main/java/com/nvidia/boot/telemetry/client/TelemetryClient.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/main/java/com/nvidia/boot/telemetry/client/TelemetryClient.java new file mode 100644 index 0000000000..7646fe6b4d --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/main/java/com/nvidia/boot/telemetry/client/TelemetryClient.java @@ -0,0 +1,128 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.telemetry.client; + +import static io.cloudevents.jackson.JsonFormat.CONTENT_TYPE; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; +import io.cloudevents.CloudEvent; +import io.cloudevents.core.format.EventFormat; +import io.cloudevents.core.provider.EventFormatProvider; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.MediaType; +import org.springframework.util.CollectionUtils; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +/** + * Sends CloudEvents to a Telemetry server. Uses WebClient with OAuth2 bearer token + * from ExchangeFilterFunction (configured via TelemetryProperties). + */ +@Slf4j +@RequiredArgsConstructor +public class TelemetryClient { + + private static final String ERR_MISSING_CLOUD_EVENTS = "List of CloudEvents is empty"; + private static final String ERR_MISSING_RESOURCE_NAME = "No Resource Name found"; + private static final String BATCH_CONTENT_TYPE = + "application/cloudevents-batch+json; charset=UTF-8"; + + private final TelemetryProperties properties; + private final JsonMapper jsonMapper; + private final WebClient webClient; + + private final EventFormat eventFormat = + EventFormatProvider.getInstance().resolveFormat(CONTENT_TYPE); + + /** + * Send CloudEvents synchronously to the Telemetry server. + * Returns a generic response with status code and body (Map for JSON, null for empty). + */ + public TelemetryResponse> send( + String resourceName, + List cloudEvents) throws IOException { + validateParameters(resourceName, cloudEvents); + var eventList = serializeCloudEvents(cloudEvents); + var pathPrefix = StringUtils.stripEnd(properties.getPathPrefix(), "/"); + var path = (pathPrefix.startsWith("/") ? pathPrefix : "/" + pathPrefix) + + "/" + resourceName; + + try { + var response = webClient.post() + .uri(path) + .contentType(MediaType.parseMediaType(BATCH_CONTENT_TYPE)) + .bodyValue(eventList) + .retrieve() + .toEntity(new ParameterizedTypeReference>() {}) + .block(); + + return TelemetryResponse.>builder() + .statusCode(response.getStatusCode().value()) + .body(response.getBody()) + .build(); + } catch (WebClientResponseException e) { + var msg = "Telemetry request failed (status: %d) - %s".formatted( + e.getStatusCode().value(), + e.getResponseBodyAsString()); + throw new IllegalStateException(msg, e); + } + } + + /** + * Send CloudEvents asynchronously to the Telemetry server. + */ + public CompletableFuture>> sendAsync( + String resourceName, + List cloudEvents) { + return CompletableFuture.supplyAsync(() -> { + try { + return send(resourceName, cloudEvents); + } catch (IOException e) { + throw new CompletionException(e); + } + }); + } + + private List serializeCloudEvents(List cloudEvents) throws IOException { + var eventList = new ArrayList(); + for (CloudEvent cloudEvent : cloudEvents) { + var serialized = eventFormat.serialize(cloudEvent); + eventList.add(jsonMapper.readTree(serialized)); + } + return eventList; + } + + private void validateParameters(String resourceName, List cloudEvents) { + if (CollectionUtils.isEmpty(cloudEvents)) { + throw new IllegalStateException(ERR_MISSING_CLOUD_EVENTS); + } + if (StringUtils.isBlank(resourceName)) { + throw new IllegalStateException(ERR_MISSING_RESOURCE_NAME); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/main/java/com/nvidia/boot/telemetry/client/TelemetryProperties.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/main/java/com/nvidia/boot/telemetry/client/TelemetryProperties.java new file mode 100644 index 0000000000..c9dfe297d2 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/main/java/com/nvidia/boot/telemetry/client/TelemetryProperties.java @@ -0,0 +1,85 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.telemetry.client; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import java.time.Duration; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.validation.annotation.Validated; + +/** + * Configuration properties for telemetry. Apps must add this bean to the Spring context. + * Load OAuth2 config from {@code spring.security.oauth2.client.registration.telemetry} and + * {@code spring.security.oauth2.client.provider.telemetry.token-uri}, plus Telemetry Server + * url, source, pathPrefix, and timeouts. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Validated +public class TelemetryProperties { + + @NotBlank(message = "Telemetry url is required") + private String url; + + /** + * Path prefix for the endpoint (e.g. {@code /api/v2/topic}). A trailing {@code /} is + * optional; {@link TelemetryClient} strips it before appending {@code /{resourceName}}. + */ + @NotBlank(message = "Telemetry pathPrefix is required (e.g., /api/v2/topic)") + private String pathPrefix; + + @NotBlank(message = "Telemetry source is required") + private String source; + + @Valid + @Builder.Default + private OAuth2Properties oauth2 = new OAuth2Properties(); + + @Builder.Default + private Duration connectTimeout = Duration.ofSeconds(30); + + @Builder.Default + private Duration readTimeout = Duration.ofSeconds(30); + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class OAuth2Properties { + + @NotBlank(message = "OAuth2 token-uri is required for telemetry") + private String tokenUri; + + @NotBlank(message = "OAuth2 client-id is required for telemetry") + private String clientId; + + @NotBlank(message = "OAuth2 client-secret is required for telemetry") + private String clientSecret; + + private String scope; + + @Builder.Default + private OAuth2AuthMethod authMethod = OAuth2AuthMethod.CLIENT_SECRET_POST; + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/main/java/com/nvidia/boot/telemetry/client/TelemetryResponse.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/main/java/com/nvidia/boot/telemetry/client/TelemetryResponse.java new file mode 100644 index 0000000000..7788840473 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/main/java/com/nvidia/boot/telemetry/client/TelemetryResponse.java @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.telemetry.client; + +import java.util.Optional; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Generic response from a Telemetry server after sending CloudEvents. + * Use {@code TelemetryResponse>} for JSON responses, or + * {@code TelemetryResponse} for empty responses (e.g., 204 No Content). + * + * @param the response body type (e.g., Map for JSON, Void for empty) + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TelemetryResponse { + + /** + * HTTP status code from the Telemetry server. + */ + private int statusCode; + + /** + * Response body, or null for empty responses (e.g., 204 No Content). + */ + private T body; + + /** + * Returns the response body as an Optional. + */ + public Optional getBodyOptional() { + return Optional.ofNullable(body); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/main/java/com/nvidia/boot/telemetry/client/TelemetryWebClientFactory.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/main/java/com/nvidia/boot/telemetry/client/TelemetryWebClientFactory.java new file mode 100644 index 0000000000..8029dab10c --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/main/java/com/nvidia/boot/telemetry/client/TelemetryWebClientFactory.java @@ -0,0 +1,114 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.telemetry.client; + +import io.netty.channel.ChannelOption; +import java.time.Duration; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import reactor.netty.http.client.HttpClient; + +/** + * Factory for creating WebClient instances for telemetry operations. + */ +public final class TelemetryWebClientFactory { + + private TelemetryWebClientFactory() { + } + + /** + * Creates a WebClient for sending telemetry requests with OAuth2 bearer token filter. + */ + public static WebClient createTelemetryWebClient( + WebClient.Builder webClientBuilder, + String baseUrl, + Duration connectTimeout, + Duration readTimeout, + ExchangeFilterFunction oauth2BearerFilter + ) { + var normalized = baseUrl.replaceAll("/$", ""); + return webClientBuilder + .clientConnector(clientConnector(connectTimeout)) + .baseUrl(normalized) + .filter(oauth2BearerFilter) + .defaultStatusHandler( + HttpStatusCode::is4xxClientError, + TelemetryWebClientFactory::handle4xx) + .defaultStatusHandler( + HttpStatusCode::is5xxServerError, + TelemetryWebClientFactory::handle5xx) + .filter((req, next) -> next.exchange(req).timeout(readTimeout)) + .build(); + } + + /** + * Creates a WebClient for OAuth2 token requests (no base URL; use full URI per request). + */ + public static WebClient createTokenWebClient( + WebClient.Builder webclientBuilder, + Duration connectTimeout, + Duration readTimeout) { + return webclientBuilder + .clientConnector(clientConnector(connectTimeout)) + .defaultStatusHandler( + HttpStatusCode::is4xxClientError, + TelemetryWebClientFactory::handle4xx) + .defaultStatusHandler( + HttpStatusCode::is5xxServerError, + TelemetryWebClientFactory::handle5xx) + .filter((req, next) -> next.exchange(req).timeout(readTimeout)) + .build(); + } + + /** + * TCP connect timeout for Reactor Netty. {@link WebClient} has no direct connect-timeout API; + * it is set on the underlying {@link HttpClient}. + */ + private static ClientHttpConnector clientConnector(Duration connectTimeout) { + var millis = connectTimeout.toMillis(); + var connectMs = (int) Math.min(Math.max(millis, 1L), Integer.MAX_VALUE); + var httpClient = HttpClient.create() + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, connectMs); + return new ReactorClientHttpConnector(httpClient); + } + + private static Mono handle4xx(ClientResponse response) { + return response.bodyToMono(String.class) + .defaultIfEmpty("") + .flatMap(body -> { + var msg = "Request failed (status: %d) - %s".formatted( + response.statusCode().value(), body); + return Mono.error(new IllegalStateException(msg)); + }); + } + + private static Mono handle5xx(ClientResponse response) { + return response.bodyToMono(String.class) + .defaultIfEmpty("") + .flatMap(body -> { + var msg = "Server error (status: %d) - %s".formatted( + response.statusCode().value(), body); + return Mono.error(new IllegalStateException(msg)); + }); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000000..21cf7533b3 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +com.nvidia.boot.telemetry.TelemetryAutoConfiguration diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/test/java/com/nvidia/boot/telemetry/TelemetryAutoConfigurationIntegrationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/test/java/com/nvidia/boot/telemetry/TelemetryAutoConfigurationIntegrationTest.java new file mode 100644 index 0000000000..f8050c21b3 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/test/java/com/nvidia/boot/telemetry/TelemetryAutoConfigurationIntegrationTest.java @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.telemetry; + +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import com.nvidia.boot.telemetry.client.CloudEventBuilderProvider; +import com.nvidia.boot.telemetry.client.TelemetryClient; +import com.nvidia.boot.telemetry.client.TelemetryProperties; +import com.nvidia.boot.telemetry.client.TelemetryTestFixtures; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * Spring context integration test for {@link TelemetryAutoConfiguration}. + */ +@SpringBootTest( + classes = TelemetryAutoConfigurationIntegrationTest.TestApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE) +class TelemetryAutoConfigurationIntegrationTest { + + @Autowired + private TelemetryClient telemetryClient; + + @Autowired + private CloudEventBuilderProvider cloudEventBuilderProvider; + + @Test + void autoConfiguresTelemetryBeansWhenPropertiesPresent() { + assertThat(telemetryClient).isNotNull(); + assertThat(cloudEventBuilderProvider).isNotNull(); + } + + /** + * {@link TelemetryAutoConfiguration} is {@code @ConditionalOnBean(TelemetryProperties.class)}. + * Register {@link TelemetryProperties} in a separate imported config so it exists before the + * auto-configuration is processed. + */ + @SpringBootConfiguration + @Import({TelemetryPropertiesConfig.class, TelemetryAutoConfiguration.class}) + static class TestApplication { + + /** + * {@link TelemetryAutoConfiguration#telemetryClient} requires {@link WebClient.Builder}. + * A full app picks this up from WebFlux auto-configuration; this test imports only + * {@link TelemetryAutoConfiguration}, so we register the builder explicitly. + */ + @Bean + WebClient.Builder webClientBuilder() { + return WebClient.builder(); + } + + @Bean + JsonMapper jsonMapper() { + return JsonMapper.builder().build(); + } + } + + @Configuration + static class TelemetryPropertiesConfig { + + @Bean + TelemetryProperties telemetryProperties() { + return TelemetryTestFixtures.telemetryProperties( + "http://127.0.0.1:9", + "/oauth/token"); + } + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/test/java/com/nvidia/boot/telemetry/client/CloudEventBuilderProviderTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/test/java/com/nvidia/boot/telemetry/client/CloudEventBuilderProviderTest.java new file mode 100644 index 0000000000..3f02739525 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/test/java/com/nvidia/boot/telemetry/client/CloudEventBuilderProviderTest.java @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.telemetry.client; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URI; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +class CloudEventBuilderProviderTest { + + private final CloudEventBuilderProvider provider = new CloudEventBuilderProvider(); + + @AfterEach + void clearSecurity() { + SecurityContextHolder.clearContext(); + } + + @Test + void subjectUsesAuthenticationNameWhenPresent() { + var auth = new UsernamePasswordAuthenticationToken("alice", "n/a"); + SecurityContextHolder.getContext().setAuthentication(auth); + + var event = provider.getCloudEventBuilder() + .withType("com.test.Event") + .withSource(URI.create("urn:test")) + .build(); + assertThat(event.getSubject()).isEqualTo("alice"); + } + + @Test + void subjectFallsBackWhenNoAuthentication() { + SecurityContextHolder.clearContext(); + + var event = provider.getCloudEventBuilder() + .withType("com.test.Event") + .withSource(URI.create("urn:test")) + .build(); + assertThat(event.getSubject()).isEqualTo("unknown-subject"); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/test/java/com/nvidia/boot/telemetry/client/OAuth2BearerFilterIntegrationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/test/java/com/nvidia/boot/telemetry/client/OAuth2BearerFilterIntegrationTest.java new file mode 100644 index 0000000000..13061843bf --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/test/java/com/nvidia/boot/telemetry/client/OAuth2BearerFilterIntegrationTest.java @@ -0,0 +1,165 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.telemetry.client; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.matching; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; + +import com.github.tomakehurst.wiremock.WireMockServer; +import java.time.Duration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * Integration test: {@link OAuth2BearerFilter} fetches token then forwards request with Bearer + * header. + */ +class OAuth2BearerFilterIntegrationTest { + + private WireMockServer wireMockServer; + + @BeforeEach + void startServer() { + wireMockServer = new WireMockServer(wireMockConfig().dynamicPort()); + wireMockServer.start(); + } + + @AfterEach + void stopServer() { + if (wireMockServer != null) { + wireMockServer.stop(); + } + } + + @Test + void addsBearerTokenFromTokenEndpoint() { + wireMockServer.stubFor(post(urlPathEqualTo("/oauth/token")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\"access_token\":\"test-access-token\",\"expires_in\":3600}"))); + wireMockServer.stubFor(get(urlPathEqualTo("/api/resource")) + .willReturn(aResponse().withBody("payload"))); + + var base = "http://localhost:%d".formatted(wireMockServer.port()); + var props = TelemetryTestFixtures.telemetryProperties(base, "/oauth/token"); + var tokenClient = TelemetryWebClientFactory.createTokenWebClient( + WebClient.builder(), + Duration.ofSeconds(5), + Duration.ofSeconds(5)); + var filter = new OAuth2BearerFilter(props, tokenClient); + + var client = WebClient.builder() + .baseUrl(base) + .filter(filter) + .build(); + + var body = client.get() + .uri("/api/resource") + .retrieve() + .bodyToMono(String.class) + .block(); + + assertThat(body).isEqualTo("payload"); + + wireMockServer.verify(postRequestedFor(urlPathEqualTo("/oauth/token")) + .withRequestBody(containing("grant_type=client_credentials"))); + wireMockServer.verify(getRequestedFor(urlPathEqualTo("/api/resource")) + .withHeader("Authorization", equalTo("Bearer test-access-token"))); + } + + @Test + void addsBearerTokenUsingClientSecretBasic() { + wireMockServer.stubFor(post(urlPathEqualTo("/oauth/token")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\"access_token\":\"basic-token\",\"expires_in\":3600}"))); + wireMockServer.stubFor(get(urlPathEqualTo("/api/resource")) + .willReturn(aResponse().withBody("payload"))); + + var base = "http://localhost:%d".formatted(wireMockServer.port()); + var props = TelemetryTestFixtures.telemetryProperties(base, "/oauth/token"); + props.getOauth2().setAuthMethod(OAuth2AuthMethod.CLIENT_SECRET_BASIC); + + var tokenClient = TelemetryWebClientFactory.createTokenWebClient( + WebClient.builder(), + Duration.ofSeconds(5), + Duration.ofSeconds(5)); + var filter = new OAuth2BearerFilter(props, tokenClient); + + var client = WebClient.builder() + .baseUrl(base) + .filter(filter) + .build(); + + var body = client.get() + .uri("/api/resource") + .retrieve() + .bodyToMono(String.class) + .block(); + + assertThat(body).isEqualTo("payload"); + + wireMockServer.verify(postRequestedFor(urlPathEqualTo("/oauth/token")) + .withHeader("Authorization", matching("Basic .*")) + .withRequestBody(equalTo("grant_type=client_credentials&scope=telemetry"))); + wireMockServer.verify(getRequestedFor(urlPathEqualTo("/api/resource")) + .withHeader("Authorization", equalTo("Bearer basic-token"))); + } + + @Test + void clientSecretPostIsDefaultBehavior() { + wireMockServer.stubFor(post(urlPathEqualTo("/oauth/token")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\"access_token\":\"post-token\",\"expires_in\":3600}"))); + wireMockServer.stubFor(get(urlPathEqualTo("/api/resource")) + .willReturn(aResponse().withBody("ok"))); + + var base = "http://localhost:%d".formatted(wireMockServer.port()); + var props = TelemetryTestFixtures.telemetryProperties(base, "/oauth/token"); + + var tokenClient = TelemetryWebClientFactory.createTokenWebClient( + WebClient.builder(), + Duration.ofSeconds(5), + Duration.ofSeconds(5)); + var filter = new OAuth2BearerFilter(props, tokenClient); + + var client = WebClient.builder() + .baseUrl(base) + .filter(filter) + .build(); + + client.get().uri("/api/resource").retrieve().bodyToMono(String.class).block(); + + wireMockServer.verify(postRequestedFor(urlPathEqualTo("/oauth/token")) + .withRequestBody(containing("client_id=")) + .withRequestBody(containing("client_secret=")) + .withoutHeader("Authorization")); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/test/java/com/nvidia/boot/telemetry/client/TelemetryClientIntegrationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/test/java/com/nvidia/boot/telemetry/client/TelemetryClientIntegrationTest.java new file mode 100644 index 0000000000..006a0e2849 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/test/java/com/nvidia/boot/telemetry/client/TelemetryClientIntegrationTest.java @@ -0,0 +1,119 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.telemetry.client; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; + +import tools.jackson.databind.json.JsonMapper; +import com.github.tomakehurst.wiremock.WireMockServer; +import java.time.Duration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * End-to-end HTTP test: token exchange + telemetry POST with OAuth2 filter. + */ +class TelemetryClientIntegrationTest { + + private WireMockServer wireMockServer; + + @BeforeEach + void startServer() { + wireMockServer = new WireMockServer(wireMockConfig().dynamicPort()); + wireMockServer.start(); + } + + @AfterEach + void stopServer() { + if (wireMockServer != null) { + wireMockServer.stop(); + } + } + + @Test + void sendPostsCloudEventsWithBearerToken() throws Exception { + wireMockServer.stubFor(post(urlPathEqualTo("/oauth/token")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\"access_token\":\"tok-xyz\",\"expires_in\":3600}"))); + wireMockServer.stubFor(post(urlPathEqualTo("/api/v2/topic/my-topic")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"result\":\"ok\"}"))); + + var base = "http://localhost:%d".formatted(wireMockServer.port()); + var props = TelemetryTestFixtures.telemetryProperties(base, "/oauth/token"); + var connect = Duration.ofSeconds(5); + var read = Duration.ofSeconds(10); + var tokenClient = TelemetryWebClientFactory.createTokenWebClient( + WebClient.builder(),connect, read); + var oauthFilter = new OAuth2BearerFilter(props, tokenClient); + var webClient = TelemetryWebClientFactory.createTelemetryWebClient( + WebClient.builder(), base, connect, read, oauthFilter); + var client = new TelemetryClient(props, JsonMapper.builder().build(), webClient); + + var response = client.send("my-topic", TelemetryTestFixtures.sampleCloudEvents()); + + assertThat(response.getStatusCode()).isEqualTo(200); + assertThat(response.getBody()).containsEntry("result", "ok"); + + wireMockServer.verify(postRequestedFor(urlPathEqualTo("/oauth/token"))); + wireMockServer.verify(postRequestedFor(urlPathEqualTo("/api/v2/topic/my-topic")) + .withHeader("Authorization", equalTo("Bearer tok-xyz")) + .withHeader("Content-Type", containing("application/cloudevents-batch"))); + } + + @Test + void sendNormalizesTrailingSlashOnPathPrefix() throws Exception { + wireMockServer.stubFor(post(urlPathEqualTo("/oauth/token")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\"access_token\":\"tok-xyz\",\"expires_in\":3600}"))); + wireMockServer.stubFor(post(urlPathEqualTo("/api/v2/topic/my-topic")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{}"))); + + var base = "http://localhost:%d".formatted(wireMockServer.port()); + var props = TelemetryTestFixtures.telemetryProperties(base, "/oauth/token"); + props.setPathPrefix("/api/v2/topic/"); + var connect = Duration.ofSeconds(5); + var read = Duration.ofSeconds(10); + var tokenClient = TelemetryWebClientFactory.createTokenWebClient( + WebClient.builder(), connect, read); + var oauthFilter = new OAuth2BearerFilter(props, tokenClient); + var webClient = TelemetryWebClientFactory.createTelemetryWebClient( + WebClient.builder(), base, connect, read, oauthFilter); + var client = new TelemetryClient(props, JsonMapper.builder().build(), webClient); + + client.send("my-topic", TelemetryTestFixtures.sampleCloudEvents()); + + wireMockServer.verify(1, postRequestedFor(urlPathEqualTo("/api/v2/topic/my-topic"))); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/test/java/com/nvidia/boot/telemetry/client/TelemetryClientTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/test/java/com/nvidia/boot/telemetry/client/TelemetryClientTest.java new file mode 100644 index 0000000000..979f1d2711 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/test/java/com/nvidia/boot/telemetry/client/TelemetryClientTest.java @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.telemetry.client; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import tools.jackson.databind.json.JsonMapper; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * Unit tests for {@link TelemetryClient} validation (no HTTP). + */ +@ExtendWith(MockitoExtension.class) +class TelemetryClientTest { + + @Mock + private WebClient webClient; + + private final JsonMapper jsonMapper = JsonMapper.builder().build(); + + @Test + void sendRejectsEmptyCloudEvents() { + var props = TelemetryTestFixtures.telemetryProperties("http://localhost", "/oauth/token"); + var client = new TelemetryClient(props, jsonMapper, webClient); + + assertThatThrownBy(() -> client.send("topic", Collections.emptyList())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("CloudEvents"); + } + + @Test + void sendRejectsBlankResourceName() { + var props = TelemetryTestFixtures.telemetryProperties("http://localhost", "/oauth/token"); + var client = new TelemetryClient(props, jsonMapper, webClient); + + assertThatThrownBy(() -> client.send(" ", TelemetryTestFixtures.sampleCloudEvents())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Resource Name"); + } + + @Test + void sendAsyncWrapsValidationFailure() { + var props = TelemetryTestFixtures.telemetryProperties("http://localhost", "/oauth/token"); + var client = new TelemetryClient(props, jsonMapper, webClient); + + assertThatThrownBy(() -> client.sendAsync("r", List.of()).join()) + .hasCauseInstanceOf(IllegalStateException.class); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/test/java/com/nvidia/boot/telemetry/client/TelemetryPropertiesValidationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/test/java/com/nvidia/boot/telemetry/client/TelemetryPropertiesValidationTest.java new file mode 100644 index 0000000000..7ddb590911 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/test/java/com/nvidia/boot/telemetry/client/TelemetryPropertiesValidationTest.java @@ -0,0 +1,117 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.telemetry.client; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Bean validation on {@link TelemetryProperties}. + */ +class TelemetryPropertiesValidationTest { + + private static ValidatorFactory validatorFactory; + private static Validator validator; + + @BeforeAll + static void setUpValidator() { + validatorFactory = Validation.buildDefaultValidatorFactory(); + validator = validatorFactory.getValidator(); + } + + @AfterAll + static void tearDown() { + if (validatorFactory != null) { + validatorFactory.close(); + } + } + + @Test + void validPropertiesPassValidation() { + var props = TelemetryTestFixtures.telemetryProperties("https://example.com", "/token"); + assertThat(validator.validate(props)).isEmpty(); + } + + @Test + void blankUrlFailsValidation() { + var oauth2 = TelemetryProperties.OAuth2Properties.builder() + .tokenUri("https://example.com/token") + .clientId("id") + .clientSecret("secret") + .build(); + var props = TelemetryProperties.builder() + .url(" ") + .pathPrefix("/api/v2/topic") + .source("src") + .oauth2(oauth2) + .build(); + + var violations = validator.validate(props); + assertThat(violations).isNotEmpty(); + assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().equals("url")); + } + + @Test + void blankPathPrefixFailsValidation() { + var oauth2 = TelemetryProperties.OAuth2Properties.builder() + .tokenUri("https://example.com/token") + .clientId("id") + .clientSecret("secret") + .build(); + var props = TelemetryProperties.builder() + .url("https://example.com") + .pathPrefix("") + .source("src") + .oauth2(oauth2) + .build(); + + assertThat(validator.validate(props)).anyMatch( + v -> v.getPropertyPath().toString().equals("pathPrefix")); + } + + @Test + void defaultAuthMethodIsClientSecretPost() { + var props = TelemetryTestFixtures.telemetryProperties("https://example.com", "/token"); + assertThat(props.getOauth2().getAuthMethod()) + .isEqualTo(OAuth2AuthMethod.CLIENT_SECRET_POST); + } + + @Test + void oauth2MissingClientSecretFailsValidation() { + var oauth2 = TelemetryProperties.OAuth2Properties.builder() + .tokenUri("https://example.com/token") + .clientId("id") + .clientSecret("") + .build(); + var props = TelemetryProperties.builder() + .url("https://example.com") + .pathPrefix("/p") + .source("src") + .oauth2(oauth2) + .build(); + + assertThat(validator.validate(props)).anyMatch( + v -> v.getPropertyPath().toString().equals("oauth2.clientSecret")); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/test/java/com/nvidia/boot/telemetry/client/TelemetryResponseTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/test/java/com/nvidia/boot/telemetry/client/TelemetryResponseTest.java new file mode 100644 index 0000000000..dfbf5714b4 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/test/java/com/nvidia/boot/telemetry/client/TelemetryResponseTest.java @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.telemetry.client; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class TelemetryResponseTest { + + @Test + void builderAndGetBodyOptional() { + Map body = Map.of("status", (Object) "ok"); + var response = TelemetryResponse.>builder() + .statusCode(200) + .body(body) + .build(); + + assertThat(response.getStatusCode()).isEqualTo(200); + assertThat(response.getBodyOptional()).contains(body); + } + + @Test + void emptyBodyOptionalWhenBodyNull() { + var response = TelemetryResponse.builder() + .statusCode(204) + .body(null) + .build(); + + assertThat(response.getBodyOptional()).isEmpty(); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/test/java/com/nvidia/boot/telemetry/client/TelemetryTestFixtures.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/test/java/com/nvidia/boot/telemetry/client/TelemetryTestFixtures.java new file mode 100644 index 0000000000..92033ba758 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/test/java/com/nvidia/boot/telemetry/client/TelemetryTestFixtures.java @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.telemetry.client; + +import io.cloudevents.CloudEvent; +import io.cloudevents.core.builder.CloudEventBuilder; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; + +/** + * Shared test data for telemetry tests. + */ +public final class TelemetryTestFixtures { + + private TelemetryTestFixtures() { + } + + public static TelemetryProperties telemetryProperties(String baseUrl, String tokenPath) { + var normalized = baseUrl.replaceAll("/$", ""); + var tokenUri = tokenPath.startsWith("/") + ? normalized + tokenPath + : normalized + "/" + tokenPath; + var oauth2 = TelemetryProperties.OAuth2Properties.builder() + .tokenUri(tokenUri) + .clientId("test-client-id") + .clientSecret("test-client-secret") + .scope("telemetry") + .build(); + return TelemetryProperties.builder() + .url(baseUrl) + .pathPrefix("/api/v2/topic") + .source("test-source") + .oauth2(oauth2) + .connectTimeout(Duration.ofSeconds(5)) + .readTimeout(Duration.ofSeconds(5)) + .build(); + } + + public static CloudEvent sampleCloudEvent() { + var payload = "{\"k\":\"v\"}".getBytes(StandardCharsets.UTF_8); + return CloudEventBuilder.v1() + .withId("evt-1") + .withType("com.nvidia.test.Event") + .withSource(URI.create("urn:test:telemetry")) + .withDataContentType("application/json") + .withData(payload) + .build(); + } + + public static List sampleCloudEvents() { + return List.of(sampleCloudEvent()); + } +} diff --git a/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/test/java/com/nvidia/boot/telemetry/client/TelemetryWebClientFactoryIntegrationTest.java b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/test/java/com/nvidia/boot/telemetry/client/TelemetryWebClientFactoryIntegrationTest.java new file mode 100644 index 0000000000..05a32e9306 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/nv-boot-starter-telemetry/src/test/java/com/nvidia/boot/telemetry/client/TelemetryWebClientFactoryIntegrationTest.java @@ -0,0 +1,108 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.nvidia.boot.telemetry.client; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; + +import com.github.tomakehurst.wiremock.WireMockServer; +import java.time.Duration; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * Smoke test for {@link TelemetryWebClientFactory} against WireMock. + */ +class TelemetryWebClientFactoryIntegrationTest { + + private WireMockServer wireMockServer; + + @BeforeEach + void startServer() { + wireMockServer = new WireMockServer(wireMockConfig().dynamicPort()); + wireMockServer.start(); + } + + @AfterEach + void stopServer() { + if (wireMockServer != null) { + wireMockServer.stop(); + } + } + + @Test + void createTelemetryWebClientSendsRequest() { + wireMockServer.stubFor(get(urlPathEqualTo("/ping")) + .willReturn(aResponse().withBody("pong"))); + + var base = "http://localhost:%d".formatted(wireMockServer.port()); + ExchangeFilterFunction noOp = (request, next) -> next.exchange(request); + var webClient = TelemetryWebClientFactory.createTelemetryWebClient( + WebClient.builder(), + base, + Duration.ofSeconds(5), + Duration.ofSeconds(5), + noOp); + + var body = webClient.get() + .uri("/ping") + .retrieve() + .bodyToMono(String.class) + .block(); + + assertThat(body).isEqualTo("pong"); + wireMockServer.verify(getRequestedFor(urlPathEqualTo("/ping"))); + } + + @Test + void createTokenWebClientPostsToAbsoluteUri() { + wireMockServer.stubFor(post(urlPathEqualTo("/token")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\"access_token\":\"t\",\"expires_in\":60}"))); + + var base = "http://localhost:%d".formatted(wireMockServer.port()); + var tokenUri = base + "/token"; + var webClient = TelemetryWebClientFactory.createTokenWebClient( + WebClient.builder(), + Duration.ofSeconds(5), + Duration.ofSeconds(5)); + + var map = webClient.post() + .uri(tokenUri) + .header("Content-Type", "application/x-www-form-urlencoded") + .bodyValue("grant_type=client_credentials") + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() {}) + .block(); + + assertThat(map).containsEntry("access_token", "t"); + wireMockServer.verify(postRequestedFor(urlPathEqualTo("/token"))); + } +} diff --git a/src/libraries/java/nv-boot-parent/pom.xml b/src/libraries/java/nv-boot-parent/pom.xml new file mode 100644 index 0000000000..96500c2651 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/pom.xml @@ -0,0 +1,439 @@ + + + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.0.7 + + + + com.nvidia.boot + nv-boot-parent + 0.0.1-SNAPSHOT + pom + NV Boot Parent + NV native Spring Boot monorepo: shared libraries and BOM. + + + 25 + UTF-8 + UTF-8 + + + 1.84 + 2.22.0 + 1.4.0 + 1.15.0 + 3.20.0 + 4.1.1 + 7.0.0 + 7.7.0 + 2025.1.2 + 3.0.3 + 3.13.2 + + + 1.7.3 + 0.8.14 + 1.3.0 + 2.7.1 + + + + + https://gitlab-master.nvidia.com/nvcf/nvcf-libraries/java/nv-boot-parent + + + + + + nv-central + https://urm.nvidia.com/artifactory/maven + + false + + + + + google-maven-central + GCS Maven Central mirror + https://maven-central.storage-download.googleapis.com/maven2 + + false + + + + + + + + nv-central + https://urm.nvidia.com/artifactory/maven + + false + + + + + google-maven-central + GCS Maven Central mirror + https://maven-central.storage-download.googleapis.com/maven2 + + false + + + + + + + + nvcf + https://urm.nvidia.com/artifactory/sw-nvcf-maven + + + nvcf + https://urm.nvidia.com/artifactory/sw-nvcf-maven + + + + + nv-boot-bom + nv-boot-mock-servers-test + nv-boot-starter-audit + nv-boot-starter-cassandra + nv-boot-starter-core + nv-boot-starter-exceptions + nv-boot-starter-jwt + nv-boot-starter-observability + nv-boot-starter-registries + nv-boot-starter-reloadable-properties + nv-boot-starter-telemetry + nv-boot-starter-data-migration-notification + + + + + + + net.javacrumbs.shedlock + shedlock-bom + ${shedlock.version} + pom + import + + + + com.google.inject + guice + ${guice.version} + + + + commons-io + commons-io + ${commons-io.version} + + + commons-logging + commons-logging + ${commons-logging.version} + + + org.apache.commons + commons-text + ${commons-text.version} + + + org.apache.commons + commons-lang3 + ${commons-lang3.version} + + + + org.bouncycastle + bcpkix-jdk18on + ${bouncycastle.version} + + + org.bouncycastle + bcprov-jdk18on + ${bouncycastle.version} + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + org.testcontainers + testcontainers-bom + ${testcontainers.version} + pom + import + + + + org.springdoc + springdoc-openapi-starter-webmvc-api + ${springdoc-openapi.version} + + + org.springdoc + springdoc-openapi-starter-webflux-api + ${springdoc-openapi.version} + + + + org.wiremock + wiremock + ${wiremock.version} + + + org.wiremock + wiremock-standalone + ${wiremock.version} + + + + io.cloudevents + cloudevents-core + ${cloudevents.version} + + + io.cloudevents + cloudevents-json-jackson + ${cloudevents.version} + + + + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco-plugin.version} + + + default-prepare-agent + + prepare-agent + + + + default-report + test + + report + + + + + + org.codehaus.mojo + flatten-maven-plugin + ${flatten-maven-plugin.version} + + oss + ${project.build.directory} + + keep + keep + + + + + flatten + process-resources + + flatten + + + + flatten-clean + clean + + clean + + + + + + io.github.git-commit-id + git-commit-id-maven-plugin + + + get-the-git-info + + revision + + initialize + + + + 7 + full + properties + false + true + + ${project.build.outputDirectory}/git.properties + + git + false + + git.*.user.* + + + git.closest.tag.name + git.tags + ^git.commit.id.(abbrev|full)$ + + + true + + + + + org.codehaus.mojo + license-maven-plugin + ${license-maven-plugin.version} + + + NOTICE + ${project.basedir} + + test,provided + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${java.version} + + -Xlint:deprecation + + + + org.projectlombok + lombok + + + + + + org.apache.maven.plugins + maven-source-plugin + + + attach-sources + + jar + + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + @{argLine} + -javaagent:${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar + ${surefireArgLine} + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + @{argLine} + -javaagent:${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar + ${surefireArgLine} + + + + + org.jacoco + jacoco-maven-plugin + + + org.codehaus.mojo + properties-maven-plugin + ${properties-plugin.version} + + + initialize + + write-project-properties + + + + ${project.build.outputDirectory}/maven.properties + + + + + + + org.codehaus.mojo + flatten-maven-plugin + + + + org.codehaus.mojo + license-maven-plugin + + NOTICE + ${project.basedir} + test,provided + + ^com\.nvidia\.boot + + + + + + src/main/java + false + + + src/main/resources + + + + diff --git a/src/libraries/java/nv-boot-parent/tools/bazel/BUILD.bazel b/src/libraries/java/nv-boot-parent/tools/bazel/BUILD.bazel new file mode 100644 index 0000000000..b8494a40fc --- /dev/null +++ b/src/libraries/java/nv-boot-parent/tools/bazel/BUILD.bazel @@ -0,0 +1,58 @@ +load("@rules_java//java:defs.bzl", "java_binary", "java_library", "java_plugin") +load("@rules_python//python:defs.bzl", "py_binary") +load("@rules_shell//shell:sh_test.bzl", "sh_test") + +package(default_visibility = ["//visibility:public"]) + +exports_files([ + "jacoco_test_runner.sh", + "lcov_to_sonar_generic.py", + "lcov_to_sonar_generic_test.sh", + "notice_metadata.json", + "notice_roots.json", +]) + +py_binary( + name = "generate_notice_tool", + srcs = ["generate_notice.py"], + main = "generate_notice.py", + python_version = "3.11", +) + +sh_test( + name = "lcov_to_sonar_generic_test", + srcs = ["lcov_to_sonar_generic_test.sh"], + data = [":lcov_to_sonar_generic.py"], +) + +sh_test( + name = "notice_check_test", + srcs = ["notice_check_test.sh"], + data = [ + ":generate_notice.py", + ":notice_metadata.json", + "//:MODULE.bazel", + "//:NOTICE", + "//:maven_install.json", + ":notice_roots.json", + ], +) + +java_plugin( + name = "lombok_plugin", + generates_api = True, + processor_class = "lombok.launch.AnnotationProcessorHider$AnnotationProcessor", + deps = ["@nv_third_party_deps//:org_projectlombok_lombok"], +) + +java_binary( + name = "jacoco_cli", + main_class = "org.jacoco.cli.internal.Main", + runtime_deps = ["@nv_third_party_deps//:org_jacoco_org_jacoco_cli"], +) + +java_library( + name = "lombok_annotations", + exports = ["@nv_third_party_deps//:org_projectlombok_lombok"], + neverlink = True, +) diff --git a/src/libraries/java/nv-boot-parent/tools/bazel/generate_notice.py b/src/libraries/java/nv-boot-parent/tools/bazel/generate_notice.py new file mode 100644 index 0000000000..bb9e5f3e65 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/tools/bazel/generate_notice.py @@ -0,0 +1,489 @@ +#!/usr/bin/env python3 +"""Generates the third-party NOTICE file from Bazel dependency metadata. + +The build/test path is hermetic: it reads checked-in root manifests, +`maven_install.json`, and generated upstream POM metadata. The update path can +refresh the metadata from local Maven POM cache files or remote Maven +repositories, similar to repinning `maven_install.json`. +""" + +import argparse +import difflib +import json +import os +import pathlib +import re +import sys +import urllib.error +import urllib.request +import xml.etree.ElementTree as ET +import zipfile + + +MAVEN_NS = {"m": "http://maven.apache.org/POM/4.0.0"} +PROJECT_VERSION_PATTERN = re.compile(r"\$\{([^}]+)\}") +NOTICE_LINE_PATTERN = re.compile( + r"\((?P[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+:[^()\s]+) - (?P[^)]*)\)\s*$" +) +UNSAFE_XML_DECLARATION_PATTERN = re.compile( + r" 5: + raise ValueError(f"Invalid dependency coordinate: {value}") + + return { + "group_id": parts[0], + "artifact_id": parts[1], + "version": parts[2] if len(parts) >= 3 else "", + "scope": parts[3] if len(parts) >= 4 and parts[3] else "compile", + "optional": parts[4].lower() == "true" if len(parts) >= 5 else False, + } + + +def coordinate_key(group_id, artifact_id): + return f"{group_id}:{artifact_id}" + + +def versioned_coordinate(group_id, artifact_id, version): + return f"{group_id}:{artifact_id}:{version}" + + +def load_json(path): + return json.loads(path.read_text()) + + +def load_root_manifests(paths): + roots = [] + for path in paths: + data = load_json(path) + roots.extend(data.get("dependencies", [])) + return roots + + +def is_first_party(group_id, first_party_groups): + return any( + group_id == prefix or group_id.startswith(f"{prefix}.") + for prefix in first_party_groups + ) + + +def should_include_root(dep, first_party_groups): + if is_first_party(dep["group_id"], first_party_groups): + return False + return dep["scope"] not in EXCLUDED_SCOPES + + +def resolved_version(maven_install, group_id, artifact_id): + artifact = maven_install["artifacts"].get(coordinate_key(group_id, artifact_id)) + if not artifact: + return "" + return artifact.get("version", "") + + +def dependency_closure(root_dependencies, maven_install, first_party_groups): + included = set() + queue = [] + + for coordinate in root_dependencies: + dep = parse_dependency_coordinate(coordinate) + if not should_include_root(dep, first_party_groups): + continue + version = dep["version"] + if version.startswith("${") or not version: + version = resolved_version(maven_install, dep["group_id"], dep["artifact_id"]) + if not version: + raise ValueError(f"Could not resolve version for NOTICE root {coordinate}") + key = coordinate_key(dep["group_id"], dep["artifact_id"]) + if key not in maven_install["artifacts"]: + raise ValueError(f"NOTICE root {coordinate} is missing from maven_install.json") + queue.append(key) + + while queue: + key = queue.pop(0) + if key in included: + continue + group_id, artifact_id = key.split(":", 1) + if is_first_party(group_id, first_party_groups): + continue + artifact = maven_install["artifacts"].get(key) + if not artifact: + continue + included.add(key) + for child in maven_install.get("dependencies", {}).get(key, []): + if child not in included: + queue.append(child) + + return sorted(included) + + +def runtime_jar_coordinates(paths, maven_install, first_party_groups): + """Returns Maven coordinates for rules_jvm_external jars shipped in app jars.""" + coordinates = set() + prefix = "BOOT-INF/lib/external/rules_jvm_external++maven+" + + for path in paths: + with zipfile.ZipFile(path) as app_jar: + for entry in app_jar.namelist(): + if not entry.startswith(prefix) or not entry.endswith(".jar"): + continue + + # Repository name / group path / artifact / version / jar. + relative = entry[len(prefix):] + parts = relative.split("/") + if len(parts) < 5: + continue + coordinate_parts = parts[1:] + group_id = ".".join(coordinate_parts[:-3]) + artifact_id = coordinate_parts[-3] + version = coordinate_parts[-2] + if not group_id or is_first_party(group_id, first_party_groups): + continue + + key = coordinate_key(group_id, artifact_id) + artifact = maven_install.get("artifacts", {}).get(key) + if artifact is None: + raise ValueError( + f"Runtime jar coordinate {key} from {entry} is missing " + "from maven_install.json" + ) + if artifact["version"] != version: + raise ValueError( + f"Runtime jar {entry} ships {version}, but " + f"maven_install.json pins {artifact['version']} for {key}" + ) + coordinates.add(key) + + return sorted(coordinates) + + +def generated_notice(coordinates, maven_install, metadata): + lines = ["", f"Lists of {len(coordinates)} third-party dependencies."] + missing = [] + incomplete = [] + for key in coordinates: + artifact = maven_install["artifacts"].get(key) + if not artifact: + continue + group_id, artifact_id = key.split(":", 1) + version = artifact["version"] + versioned = versioned_coordinate(group_id, artifact_id, version) + entry = metadata.get("artifacts", {}).get(versioned) + if not entry: + missing.append(versioned) + continue + licenses = entry.get("licenses") or [] + if not licenses or "UNKNOWN" in licenses: + incomplete.append(versioned) + continue + license_text = " ".join(f"({license_name})" for license_name in licenses) + name = entry.get("name") or artifact_id + url = entry.get("url") or "" + lines.append(f" {license_text} {name} ({versioned} - {url})") + + if missing: + raise ValueError( + "Missing NOTICE metadata for:\n" + + "\n".join(f" {coordinate}" for coordinate in missing) + + "\nRun: python3 tools/bazel/generate_notice.py --update-metadata --write" + ) + if incomplete: + raise ValueError( + "Incomplete NOTICE metadata for:\n" + + "\n".join(f" {coordinate}" for coordinate in incomplete) + + "\nFix tools/bazel/notice_metadata.json or rerun metadata generation after POM metadata is available." + ) + lines.append("") + return "\n".join(lines) + + +def compare_or_write(expected, notice_path, write): + if write: + notice_path.write_text(expected) + return "" + actual = notice_path.read_text() if notice_path.exists() else "" + if actual == expected: + return "" + return "".join( + difflib.unified_diff( + actual.splitlines(True), + expected.splitlines(True), + fromfile=str(notice_path), + tofile=f"{notice_path} (generated)", + ) + ) + + +def maven_path_for(group_id, artifact_id, version, extension="pom"): + group_path = group_id.replace(".", "/") + return f"{group_path}/{artifact_id}/{version}/{artifact_id}-{version}.{extension}" + + +def local_maven_repositories(): + repos = [] + local_repo = os.environ.get("MAVEN_REPO") + if local_repo: + repos.append(pathlib.Path(local_repo).expanduser()) + repos.append(pathlib.Path.home() / ".m2" / "repository") + return repos + + +def find_local_pom(group_id, artifact_id, version): + relative = pathlib.Path(maven_path_for(group_id, artifact_id, version)) + for repo in local_maven_repositories(): + candidate = repo / relative + if candidate.exists(): + return candidate + return None + + +def repository_urls(maven_install): + return [repo.rstrip("/") for repo in maven_install.get("repositories", []) if repo.startswith("http")] + + +def fetch_remote_pom(group_id, artifact_id, version, repositories): + relative = maven_path_for(group_id, artifact_id, version) + errors = [] + for repo in repositories: + url = f"{repo}/{relative}" + try: + with urllib.request.urlopen(url, timeout=30) as response: + return response.read().decode("utf-8") + except (urllib.error.URLError, TimeoutError) as error: + errors.append(f"{url}: {error}") + raise FileNotFoundError( + f"Could not find POM for {group_id}:{artifact_id}:{version}\n" + "\n".join(errors) + ) + + +def xml_find(element, path): + found = element.find(path, MAVEN_NS) + if found is not None: + return found + return element.find(path.replace("m:", "")) + + +def xml_findall(element, path): + found = element.findall(path, MAVEN_NS) + if found: + return found + return element.findall(path.replace("m:", "")) + + +def xml_text(element, path): + found = xml_find(element, path) + if found is None or found.text is None: + return "" + return found.text.strip() + + +def pom_parent(root): + parent = xml_find(root, "m:parent") + if parent is None: + return None + group_id = xml_text(parent, "m:groupId") + artifact_id = xml_text(parent, "m:artifactId") + version = xml_text(parent, "m:version") + if not group_id or not artifact_id or not version: + return None + return group_id, artifact_id, version + + +def pom_properties(root, group_id, artifact_id, version, inherited=None): + properties = dict(inherited or {}) + properties.update({ + "project.groupId": group_id, + "project.artifactId": artifact_id, + "project.version": version, + "pom.groupId": group_id, + "pom.artifactId": artifact_id, + "pom.version": version, + }) + properties_node = xml_find(root, "m:properties") + if properties_node is not None: + for child in list(properties_node): + name = child.tag.rsplit("}", 1)[-1] + properties[name] = (child.text or "").strip() + return properties + + +def substitute_properties(value, properties): + if not value: + return "" + + def replace(match): + return properties.get(match.group(1), match.group(0)) + + return PROJECT_VERSION_PATTERN.sub(replace, value) + + +class PomMetadataResolver: + def __init__(self, maven_install): + self.maven_install = maven_install + self.repositories = repository_urls(maven_install) + self.cache = {} + + def pom_text(self, group_id, artifact_id, version): + local = find_local_pom(group_id, artifact_id, version) + if local: + return local.read_text() + return fetch_remote_pom(group_id, artifact_id, version, self.repositories) + + def resolve(self, group_id, artifact_id, version): + key = versioned_coordinate(group_id, artifact_id, version) + if key in self.cache: + return self.cache[key] + + text = self.pom_text(group_id, artifact_id, version) + root = parse_pom_xml(text) + parent_coordinate = pom_parent(root) + parent = {} + inherited_properties = {} + if parent_coordinate: + parent = self.resolve(*parent_coordinate) + inherited_properties = parent.get("properties", {}) + + properties = pom_properties(root, group_id, artifact_id, version, inherited_properties) + name = substitute_properties(xml_text(root, "m:name"), properties) or parent.get("name") or artifact_id + url = substitute_properties(xml_text(root, "m:url"), properties) or parent.get("url") or "" + + licenses = [] + licenses_node = xml_find(root, "m:licenses") + if licenses_node is not None: + for license_node in xml_findall(licenses_node, "m:license"): + license_name = substitute_properties(xml_text(license_node, "m:name"), properties) + if license_name: + licenses.append(license_name) + if not licenses: + licenses = list(parent.get("licenses", [])) + + result = { + "name": name, + "url": url, + "licenses": licenses or ["UNKNOWN"], + "properties": properties, + } + self.cache[key] = result + return result + + +def update_metadata(coordinates, maven_install, existing_metadata): + resolver = PomMetadataResolver(maven_install) + artifacts = dict(existing_metadata.get("artifacts", {})) + for key in coordinates: + artifact = maven_install["artifacts"].get(key) + if not artifact: + continue + group_id, artifact_id = key.split(":", 1) + version = artifact["version"] + versioned = versioned_coordinate(group_id, artifact_id, version) + resolved = resolver.resolve(group_id, artifact_id, version) + artifacts[versioned] = { + "licenses": resolved["licenses"], + "name": resolved["name"], + "url": resolved["url"], + } + return { + "generated_by": "tools/bazel/generate_notice.py --update-metadata", + "artifacts": {key: artifacts[key] for key in sorted(artifacts)}, + } + + +def current_notice_coordinates(path): + if not path.exists(): + return set() + coordinates = set() + for line in path.read_text().splitlines(): + match = NOTICE_LINE_PATTERN.search(line) + if match: + coordinates.add(match.group("coordinate")) + return coordinates + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--maven-install", default="maven_install.json") + parser.add_argument("--metadata", default="tools/bazel/notice_metadata.json") + parser.add_argument("--notice", default="NOTICE") + parser.add_argument("--root-manifest", action="append", default=[]) + parser.add_argument("--runtime-jar", action="append", default=[]) + parser.add_argument("--first-party-group", action="append", default=[]) + parser.add_argument("--output") + parser.add_argument("--check", action="store_true") + parser.add_argument("--write", action="store_true") + parser.add_argument("--update-metadata", action="store_true") + args = parser.parse_args() + + maven_install_path = pathlib.Path(args.maven_install) + metadata_path = pathlib.Path(args.metadata) + notice_path = pathlib.Path(args.notice) + maven_install = load_json(maven_install_path) + metadata = load_json(metadata_path) if metadata_path.exists() else {"artifacts": {}} + + first_party_groups = tuple(FIRST_PARTY_GROUPS) + tuple(args.first_party_group) + roots = load_root_manifests([pathlib.Path(path) for path in args.root_manifest]) + if args.runtime_jar: + coordinates = runtime_jar_coordinates( + [pathlib.Path(path) for path in args.runtime_jar], + maven_install, + first_party_groups, + ) + elif roots: + coordinates = dependency_closure(roots, maven_install, first_party_groups) + else: + raise ValueError("At least one --root-manifest or --runtime-jar is required") + + if args.update_metadata: + metadata = update_metadata(coordinates, maven_install, metadata) + metadata_path.write_text(json.dumps(metadata, indent=2, sort_keys=True) + "\n") + + notice = generated_notice(coordinates, maven_install, metadata) + output_path = pathlib.Path(args.output) if args.output else notice_path + diff = compare_or_write(notice, output_path, args.write and not args.check) + if diff: + sys.stderr.write(diff) + sys.stderr.write( + "\nRun: python3 tools/bazel/generate_notice.py --update-metadata --write " + "with the same --root-manifest or --runtime-jar inputs\n" + ) + return 1 + + if args.check: + generated_coordinates = set() + for key in coordinates: + artifact = maven_install["artifacts"].get(key) + if artifact: + group_id, artifact_id = key.split(":", 1) + generated_coordinates.add(versioned_coordinate(group_id, artifact_id, artifact["version"])) + notice_coordinates = current_notice_coordinates(notice_path) + if notice_coordinates and notice_coordinates != generated_coordinates: + missing = sorted(generated_coordinates - notice_coordinates) + extra = sorted(notice_coordinates - generated_coordinates) + if missing or extra: + sys.stderr.write("NOTICE coordinate set does not match Bazel generated dependency closure\n") + if missing: + sys.stderr.write("Missing from NOTICE:\n" + "\n".join(f" {item}" for item in missing) + "\n") + if extra: + sys.stderr.write("Extra in NOTICE:\n" + "\n".join(f" {item}" for item in extra) + "\n") + return 1 + + print(f"NOTICE generated for {len(coordinates)} third-party dependencies") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/libraries/java/nv-boot-parent/tools/bazel/jacoco_test_runner.sh b/src/libraries/java/nv-boot-parent/tools/bazel/jacoco_test_runner.sh new file mode 100755 index 0000000000..7033aeebb5 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/tools/bazel/jacoco_test_runner.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +if [ "$#" -lt 5 ]; then + echo "Usage: $0 <jacoco-cli> [junit-args...]" >&2 + exit 2 +fi + +absolute_path() { + case "$1" in + /*) printf '%s\n' "$1" ;; + *) printf '%s/%s\n' "${PWD}" "$1" ;; + esac +} + +junit_runner="$(absolute_path "$1")" +classfiles="$(absolute_path "$2")" +source_root="$3" +report_title="$4" +jacoco_cli="$(absolute_path "$5")" +shift 5 + +report_dir="${TEST_UNDECLARED_OUTPUTS_DIR:?TEST_UNDECLARED_OUTPUTS_DIR is required}" +junit_report_dir="${report_dir}/junit" +junit_xml="${junit_report_dir}/TEST-junit-jupiter.xml" +exec_file="${PWD}/jacoco.exec" +sourcefiles="" +if [ -n "${source_root}" ]; then + sourcefiles="${TEST_SRCDIR:?TEST_SRCDIR is required}/${TEST_WORKSPACE:?TEST_WORKSPACE is required}/${source_root}" +fi + +mkdir -p "${report_dir}" "${junit_report_dir}" +rm -f "${exec_file}" + +set +e +"${junit_runner}" "$@" --reports-dir="${junit_report_dir}" +junit_status=$? +set -e + +report_status=0 +if [ ! -s "${junit_xml}" ]; then + echo "ERROR: JUnit did not create ${junit_xml}" >&2 + report_status=1 +fi + +if [ ! -s "${exec_file}" ]; then + echo "ERROR: JaCoCo did not create ${exec_file}" >&2 + report_status=1 +else + cp "${exec_file}" "${report_dir}/jacoco.exec" + report_args=( + report "${report_dir}/jacoco.exec" + --classfiles "${classfiles}" + --html "${report_dir}" + --xml "${report_dir}/jacoco.xml" + --name "${report_title}" + ) + if [ -n "${sourcefiles}" ]; then + report_args+=(--sourcefiles "${sourcefiles}") + fi + + set +e + "${jacoco_cli}" "${report_args[@]}" + jacoco_status=$? + set -e + if [ "${jacoco_status}" -ne 0 ]; then + report_status="${jacoco_status}" + fi +fi + +if [ "${junit_status}" -ne 0 ]; then + exit "${junit_status}" +fi +exit "${report_status}" diff --git a/src/libraries/java/nv-boot-parent/tools/bazel/java.bzl b/src/libraries/java/nv-boot-parent/tools/bazel/java.bzl new file mode 100644 index 0000000000..ef2558f31c --- /dev/null +++ b/src/libraries/java/nv-boot-parent/tools/bazel/java.bzl @@ -0,0 +1,210 @@ +load("@rules_java//java:defs.bzl", _java_binary = "java_binary", _java_library = "java_library") +load("@rules_java//java/common:java_info.bzl", "JavaInfo") +load("@rules_shell//shell:sh_test.bzl", _sh_test = "sh_test") + +NV_JAVA_JAVACOPTS = [ + "-Xlint:deprecation", +] + +NV_LOMBOK_COMPILE_DEPS = [ + "//src/libraries/java/nv-boot-parent/tools/bazel:lombok_annotations", +] + +NV_LOMBOK_PLUGINS = [ + "//src/libraries/java/nv-boot-parent/tools/bazel:lombok_plugin", +] + +NV_JUNIT5_ARGS = [ + "execute", + "--details=flat", + "--disable-ansi-colors", + "--details-theme=ascii", + "--include-classname=.*(Test|IntegrationTest)", + "--fail-if-no-tests", +] + +NV_JUNIT5_RUNTIME_DEPS = [ + "@nv_third_party_deps//:org_junit_platform_junit_platform_console_standalone", +] + +NV_JUNIT5_COMPILE_DEPS = [ + "@nv_third_party_deps//:org_assertj_assertj_core", + "@nv_third_party_deps//:org_junit_jupiter_junit_jupiter_api", + "@nv_third_party_deps//:org_junit_jupiter_junit_jupiter_params", + "@nv_third_party_deps//:org_mockito_mockito_core", + "@nv_third_party_deps//:org_mockito_mockito_junit_jupiter", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_test", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_test_autoconfigure", + "@nv_third_party_deps//:org_springframework_spring_test", +] + +NV_MOCKITO_CORE = "@nv_third_party_deps//:org_mockito_mockito_core" + +NV_MOCKITO_AGENT_DATA = [ + NV_MOCKITO_CORE, +] + +NV_MOCKITO_AGENT_JVM_FLAGS = [ + "-javaagent:$(location %s)" % NV_MOCKITO_CORE, +] + +NV_JACOCO_AGENT = "@nv_third_party_deps//:org_jacoco_org_jacoco_agent_runtime" + +NV_JACOCO_AGENT_DATA = [ + NV_JACOCO_AGENT, +] + +NV_JACOCO_AGENT_JVM_FLAGS = [ + ( + "-javaagent:$(location %s)=destfile=jacoco.exec,append=false," + + "dumponexit=true,includes=com.nvidia.*" + ) % NV_JACOCO_AGENT, +] + +def _nv_boot_runtime_classpath_test_impl(ctx): + runtime_jars = ctx.attr.target[JavaInfo].transitive_runtime_jars.to_list() + leaked = [] + + for jar in runtime_jars: + for artifact in ctx.attr.forbidden_artifacts: + if artifact in jar.basename: + leaked.append(jar.short_path) + break + + if leaked: + fail( + "%s exports Maven-optional/provided runtime jars:\n%s" % ( + ctx.attr.target.label, + "\n".join(sorted(leaked)), + ), + ) + + executable = ctx.actions.declare_file(ctx.label.name + ".sh") + ctx.actions.write( + output = executable, + content = "#!/bin/sh\nexit 0\n", + is_executable = True, + ) + return [DefaultInfo(executable = executable)] + +nv_boot_runtime_classpath_test = rule( + implementation = _nv_boot_runtime_classpath_test_impl, + attrs = { + "forbidden_artifacts": attr.string_list(mandatory = True), + "target": attr.label(mandatory = True, providers = [JavaInfo]), + }, + test = True, +) + +def _nv_boot_workspace_runfiles_impl(ctx): + symlinks = {} + strip_prefix = ctx.attr.strip_prefix + + for src in ctx.files.srcs: + runfiles_path = src.short_path + if strip_prefix: + if not runfiles_path.startswith(strip_prefix): + fail("Expected %s to start with strip_prefix %s" % (runfiles_path, strip_prefix)) + runfiles_path = runfiles_path[len(strip_prefix):] + + if runfiles_path in symlinks: + fail("Duplicate runfiles path: %s" % runfiles_path) + symlinks[runfiles_path] = src + + return [DefaultInfo(runfiles = ctx.runfiles(symlinks = symlinks))] + +nv_boot_workspace_runfiles = rule( + implementation = _nv_boot_workspace_runfiles_impl, + attrs = { + "srcs": attr.label_list(allow_files = True), + "strip_prefix": attr.string(), + }, +) + +def nv_boot_library( + name, + srcs, + deps = [], + resources = [], + runtime_deps = [], + visibility = None, + resource_strip_prefix = ""): + _java_library( + name = name, + srcs = srcs, + deps = deps + NV_LOMBOK_COMPILE_DEPS, + javacopts = NV_JAVA_JAVACOPTS, + plugins = NV_LOMBOK_PLUGINS, + resources = resources, + resource_strip_prefix = resource_strip_prefix, + runtime_deps = runtime_deps, + visibility = visibility, + ) + +def nv_boot_library_test( + name, + srcs, + deps, + coverage_library, + data = [], + junit_classpath = [], + jvm_flags = [], + resources = [], + runtime_deps = [], + size = "small", + tags = [], + timeout = "short", + resource_strip_prefix = ""): + if type(coverage_library) != "string" or not coverage_library.startswith(":"): + fail( + "coverage_library must be the module library target as a local " + + "label starting with ':'", + ) + + coverage_sourcefiles = native.glob(["src/main/java/**/*.java"]) + coverage_source_root = native.package_name() + "/src/main/java" + junit_runner = name + "_junit_runner" + + _java_binary( + name = junit_runner, + srcs = srcs, + data = data + NV_MOCKITO_AGENT_DATA + NV_JACOCO_AGENT_DATA, + deps = deps + NV_LOMBOK_COMPILE_DEPS + NV_JUNIT5_COMPILE_DEPS, + javacopts = NV_JAVA_JAVACOPTS, + jvm_flags = NV_JACOCO_AGENT_JVM_FLAGS + NV_MOCKITO_AGENT_JVM_FLAGS + jvm_flags, + main_class = "org.junit.platform.console.ConsoleLauncher", + plugins = NV_LOMBOK_PLUGINS, + resources = resources, + resource_strip_prefix = resource_strip_prefix, + runtime_deps = runtime_deps + NV_JUNIT5_RUNTIME_DEPS, + tags = ["manual"], + testonly = True, + visibility = ["//visibility:private"], + ) + + _sh_test( + name = name, + srcs = ["//src/libraries/java/nv-boot-parent/tools/bazel:jacoco_test_runner.sh"], + args = [ + "$(location :%s)" % junit_runner, + "$(location %s)" % coverage_library, + coverage_source_root if coverage_sourcefiles else "", + native.package_name(), + "$(location //src/libraries/java/nv-boot-parent/tools/bazel:jacoco_cli)", + ] + NV_JUNIT5_ARGS + [ + "--class-path=$(location :%s.jar)" % junit_runner, + "--scan-classpath=$(location :%s.jar)" % junit_runner, + ] + [ + "--class-path=%s" % path + for path in junit_classpath + ], + data = [ + ":" + junit_runner, + ":%s.jar" % junit_runner, + coverage_library, + "//src/libraries/java/nv-boot-parent/tools/bazel:jacoco_cli", + ] + coverage_sourcefiles, + size = size, + tags = tags, + timeout = timeout, + ) diff --git a/src/libraries/java/nv-boot-parent/tools/bazel/lcov_to_sonar_generic.py b/src/libraries/java/nv-boot-parent/tools/bazel/lcov_to_sonar_generic.py new file mode 100755 index 0000000000..3a1fe48442 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/tools/bazel/lcov_to_sonar_generic.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Convert Bazel LCOV output to SonarQube generic coverage XML.""" + +import argparse +import sys +import xml.etree.ElementTree as ET +from dataclasses import dataclass, field +from pathlib import PurePosixPath +from typing import Dict, Optional, Tuple + + +@dataclass +class LineCoverage: + hits: int = 0 + branches: Dict[Tuple[str, str], bool] = field(default_factory=dict) + + +def normalize_path(path: str) -> str: + normalized = str(PurePosixPath(path)) + if normalized.startswith("./"): + return normalized[2:] + return normalized + + +def parse_lcov(path: str) -> Dict[str, Dict[int, LineCoverage]]: + files: Dict[str, Dict[int, LineCoverage]] = {} + current_file: Optional[str] = None + + with open(path, encoding="utf-8") as lcov: + for raw_line in lcov: + line = raw_line.strip() + if not line: + continue + + if line.startswith("SF:"): + current_file = normalize_path(line[3:]) + files.setdefault(current_file, {}) + continue + + if line == "end_of_record": + current_file = None + continue + + if current_file is None: + continue + + if line.startswith("DA:"): + payload = line[3:] + fields = payload.split(",", 2) + if len(fields) < 2: + raise ValueError(f"Invalid LCOV DA entry: {line}") + line_number = int(fields[0]) + hits = int(fields[1]) + line_coverage = files[current_file].setdefault(line_number, LineCoverage()) + line_coverage.hits += hits + continue + + if line.startswith("BRDA:"): + payload = line[5:] + fields = payload.split(",", 3) + if len(fields) != 4: + raise ValueError(f"Invalid LCOV BRDA entry: {line}") + line_number = int(fields[0]) + branch_key = (fields[1], fields[2]) + taken = 0 if fields[3] == "-" else int(fields[3]) + line_coverage = files[current_file].setdefault(line_number, LineCoverage()) + line_coverage.branches[branch_key] = ( + line_coverage.branches.get(branch_key, False) or taken > 0 + ) + + return files + + +def build_sonar_xml(files: Dict[str, Dict[int, LineCoverage]]) -> ET.ElementTree: + root = ET.Element("coverage", {"version": "1"}) + + for file_path in sorted(files): + file_element = ET.SubElement(root, "file", {"path": file_path}) + for line_number in sorted(files[file_path]): + coverage = files[file_path][line_number] + covered = coverage.hits > 0 or any(coverage.branches.values()) + attributes = { + "lineNumber": str(line_number), + "covered": "true" if covered else "false", + } + if coverage.branches: + attributes["branchesToCover"] = str(len(coverage.branches)) + attributes["coveredBranches"] = str( + sum(1 for branch_covered in coverage.branches.values() if branch_covered) + ) + ET.SubElement(file_element, "lineToCover", attributes) + + if hasattr(ET, "indent"): + ET.indent(root, space=" ") + return ET.ElementTree(root) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Convert Bazel's LCOV coverage report to Sonar generic XML." + ) + parser.add_argument("--input", required=True, help="Input LCOV file.") + parser.add_argument( + "--output", + required=True, + help="Output Sonar generic coverage XML file. Use '-' for stdout.", + ) + args = parser.parse_args() + + tree = build_sonar_xml(parse_lcov(args.input)) + + if args.output == "-": + tree.write(sys.stdout, encoding="unicode", xml_declaration=True) + sys.stdout.write("\n") + else: + tree.write(args.output, encoding="utf-8", xml_declaration=True) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/libraries/java/nv-boot-parent/tools/bazel/lcov_to_sonar_generic_test.sh b/src/libraries/java/nv-boot-parent/tools/bazel/lcov_to_sonar_generic_test.sh new file mode 100755 index 0000000000..32205b2256 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/tools/bazel/lcov_to_sonar_generic_test.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +workspace="${TEST_TMPDIR:-/tmp}/lcov-to-sonar-test" +mkdir -p "${workspace}" + +input="${workspace}/coverage.dat" +output="${workspace}/sonar-coverage.xml" + +cat >"${input}" <<'LCOV' +SF:module/src/main/java/Foo.java +DA:10,1 +DA:11,0 +BRDA:10,0,0,1 +BRDA:10,0,1,- +end_of_record +SF:module/src/main/java/Foo.java +DA:10,2 +DA:12,0 +BRDA:12,0,0,3 +end_of_record +SF:module/src/main/java/Bar.java +DA:7,0 +end_of_record +LCOV + +python3 "${script_dir}/lcov_to_sonar_generic.py" \ + --input "${input}" \ + --output "${output}" + +grep -F '<file path="module/src/main/java/Bar.java">' "${output}" +grep -F '<lineToCover lineNumber="7" covered="false" />' "${output}" +grep -F '<file path="module/src/main/java/Foo.java">' "${output}" +grep -F '<lineToCover lineNumber="10" covered="true" branchesToCover="2" coveredBranches="1" />' "${output}" +grep -F '<lineToCover lineNumber="11" covered="false" />' "${output}" +grep -F '<lineToCover lineNumber="12" covered="true" branchesToCover="1" coveredBranches="1" />' "${output}" diff --git a/src/libraries/java/nv-boot-parent/tools/bazel/notice_check_test.sh b/src/libraries/java/nv-boot-parent/tools/bazel/notice_check_test.sh new file mode 100755 index 0000000000..13033e0b02 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/tools/bazel/notice_check_test.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +set -euo pipefail + +find_workspace_above() { + local candidate="$1" + while [[ "${candidate}" != "/" ]]; do + if [[ -f "${candidate}/MODULE.bazel" ]]; then + printf '%s\n' "${candidate}" + return + fi + candidate="$(dirname "${candidate}")" + done + return 1 +} + +find_workspace() { + if [[ -n "${BUILD_WORKSPACE_DIRECTORY:-}" && -f "${BUILD_WORKSPACE_DIRECTORY}/MODULE.bazel" ]]; then + printf '%s\n' "${BUILD_WORKSPACE_DIRECTORY}" + return + fi + + local candidate + for runfiles_root in "${TEST_SRCDIR:-}" "${RUNFILES_DIR:-}"; do + if [[ -z "${runfiles_root}" ]]; then + continue + fi + for candidate in "${runfiles_root}/_main" "${runfiles_root}/nv_boot_parent"; do + if [[ -f "${candidate}/MODULE.bazel" ]]; then + printf '%s\n' "${candidate}" + return + fi + done + done + + local script_dir + script_dir="$(cd "$(dirname "$0")" && pwd)" + + local workspace + if workspace="$(find_workspace_above "${script_dir}")"; then + printf '%s\n' "${workspace}" + return + fi + + printf 'Could not find MODULE.bazel in test runfiles\n' >&2 + exit 1 +} + +workspace="$(find_workspace)" + +PYTHONPATH="${workspace}/tools/bazel" python3 - <<'PY' +import pathlib +import tempfile +import zipfile + +from generate_notice import dependency_closure, parse_pom_xml, runtime_jar_coordinates + +parse_pom_xml("<project><name>safe</name></project>") +for unsafe_xml in ( + '<!DOCTYPE project SYSTEM "https://example.invalid/pom.dtd"><project/>', + '<!DOCTYPE project [<!ENTITY name "unsafe">]><project>&name;</project>', +): + try: + parse_pom_xml(unsafe_xml) + except ValueError: + continue + raise AssertionError("POM parser accepted a DTD or entity declaration") + +try: + dependency_closure( + ["example:missing:1.0"], + {"artifacts": {}, "dependencies": {}}, + (), + ) +except ValueError: + pass +else: + raise AssertionError("NOTICE closure silently accepted a missing dependency root") + +with tempfile.TemporaryDirectory() as directory: + app_jar = pathlib.Path(directory, "app.jar") + with zipfile.ZipFile(app_jar, "w") as archive: + archive.writestr( + "BOOT-INF/lib/external/rules_jvm_external++maven+nv_third_party_deps/" + "com/example/demo/1.0/demo-1.0.jar", + b"", + ) + try: + runtime_jar_coordinates( + [app_jar], + {"artifacts": {"com.example:demo": {"version": "2.0"}}}, + (), + ) + except ValueError: + pass + else: + raise AssertionError("Runtime NOTICE accepted a stale lockfile version") +PY + +exec python3 "${workspace}/tools/bazel/generate_notice.py" \ + --maven-install "${workspace}/maven_install.json" \ + --metadata "${workspace}/tools/bazel/notice_metadata.json" \ + --notice "${workspace}/NOTICE" \ + --root-manifest "${workspace}/tools/bazel/notice_roots.json" \ + --check diff --git a/src/libraries/java/nv-boot-parent/tools/bazel/notice_metadata.json b/src/libraries/java/nv-boot-parent/tools/bazel/notice_metadata.json new file mode 100644 index 0000000000..a6261780ea --- /dev/null +++ b/src/libraries/java/nv-boot-parent/tools/bazel/notice_metadata.json @@ -0,0 +1,1513 @@ +{ + "artifacts": { + "aopalliance:aopalliance:1.0": { + "licenses": [ + "Public Domain" + ], + "name": "AOP alliance", + "url": "http://aopalliance.sourceforge.net" + }, + "at.yawk.lz4:lz4-java:1.10.3": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "LZ4 Java Compression", + "url": "https://github.com/yawkat/lz4-java" + }, + "ch.qos.logback:logback-classic:1.5.34": { + "licenses": [ + "EPL-2.0", + "LGPL-2.1-only" + ], + "name": "Logback Classic Module", + "url": "http://logback.qos.ch" + }, + "ch.qos.logback:logback-core:1.5.34": { + "licenses": [ + "EPL-2.0", + "LGPL-2.1-only" + ], + "name": "Logback Core Module", + "url": "http://logback.qos.ch" + }, + "com.datastax.oss:native-protocol:1.5.2": { + "licenses": [ + "Apache 2" + ], + "name": "An implementation of the Apache Cassandra\u00ae native protocol", + "url": "https://github.com/datastax/native-protocol" + }, + "com.fasterxml.jackson.core:jackson-annotations:2.21": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "Jackson-annotations", + "url": "https://github.com/FasterXML/jackson" + }, + "com.fasterxml.jackson.core:jackson-core:2.21.4": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "Jackson-core", + "url": "https://github.com/FasterXML/jackson-core" + }, + "com.fasterxml.jackson.core:jackson-databind:2.21.4": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "jackson-databind", + "url": "https://github.com/FasterXML/jackson" + }, + "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.21.4": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "Jackson-dataformat-YAML", + "url": "https://github.com/FasterXML/jackson-dataformats-text" + }, + "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.21.4": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "Jackson datatype: JSR310", + "url": "https://github.com/FasterXML/jackson-modules-java8" + }, + "com.fasterxml:classmate:1.7.3": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "ClassMate", + "url": "https://github.com/FasterXML/java-classmate" + }, + "com.github.ben-manes.caffeine:caffeine:3.2.4": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Caffeine cache", + "url": "https://github.com/ben-manes/caffeine" + }, + "com.github.ben-manes.caffeine:guava:3.2.4": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Caffeine cache", + "url": "https://github.com/ben-manes/caffeine" + }, + "com.github.java-json-tools:btf:1.3": { + "licenses": [ + "Lesser General Public License, version 3 or greater", + "Apache Software License, version 2.0" + ], + "name": "btf", + "url": "https://github.com/java-json-tools/btf" + }, + "com.github.java-json-tools:jackson-coreutils:2.0": { + "licenses": [ + "Lesser General Public License, version 3 or greater", + "Apache Software License, version 2.0" + ], + "name": "jackson-coreutils", + "url": "https://github.com/java-json-tools/jackson-coreutils" + }, + "com.github.java-json-tools:json-patch:1.13": { + "licenses": [ + "Lesser General Public License, version 3 or greater", + "Apache Software License, version 2.0" + ], + "name": "json-patch", + "url": "https://github.com/java-json-tools/json-patch" + }, + "com.github.java-json-tools:msg-simple:1.2": { + "licenses": [ + "Lesser General Public License, version 3 or greater", + "Apache Software License, version 2.0" + ], + "name": "msg-simple", + "url": "https://github.com/java-json-tools/msg-simple" + }, + "com.github.jnr:jffi:1.2.16": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "jffi", + "url": "http://github.com/jnr/jffi" + }, + "com.github.jnr:jnr-constants:0.10.3": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "jnr-constants", + "url": "http://github.com/jnr/jnr-constants" + }, + "com.github.jnr:jnr-ffi:2.1.7": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "jnr-ffi", + "url": "http://github.com/jnr/jnr-ffi" + }, + "com.github.jnr:jnr-posix:3.1.15": { + "licenses": [ + "Eclipse Public License - v 2.0", + "GNU General Public License Version 2", + "GNU Lesser General Public License Version 2.1" + ], + "name": "jnr-posix", + "url": "http://nexus.sonatype.org/oss-repository-hosting.html" + }, + "com.github.jnr:jnr-x86asm:1.0.2": { + "licenses": [ + "MIT License" + ], + "name": "jnr-x86asm", + "url": "http://github.com/jnr/jnr-x86asm" + }, + "com.github.stephenc.jcip:jcip-annotations:1.0-1": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "JCIP Annotations under Apache License", + "url": "http://stephenc.github.com/jcip-annotations" + }, + "com.google.code.findbugs:jsr305:2.0.1": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "FindBugs-jsr305", + "url": "http://findbugs.sourceforge.net/" + }, + "com.google.code.findbugs:jsr305:3.0.2": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "FindBugs-jsr305", + "url": "http://findbugs.sourceforge.net/" + }, + "com.google.errorprone:error_prone_annotations:2.49.0": { + "licenses": [ + "Apache 2.0" + ], + "name": "error-prone annotations", + "url": "https://errorprone.info" + }, + "com.google.errorprone:error_prone_annotations:2.5.1": { + "licenses": [ + "Apache 2.0" + ], + "name": "error-prone annotations", + "url": "http://nexus.sonatype.org/oss-repository-hosting.html" + }, + "com.google.guava:failureaccess:1.0.1": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "Guava InternalFutureFailureAccess and InternalFutures", + "url": "https://github.com/google/guava" + }, + "com.google.guava:failureaccess:1.0.3": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Guava InternalFutureFailureAccess and InternalFutures", + "url": "https://github.com/google/guava" + }, + "com.google.guava:guava:32.0.1-jre": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Guava: Google Core Libraries for Java", + "url": "https://github.com/google/guava" + }, + "com.google.guava:guava:33.6.0-jre": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Guava: Google Core Libraries for Java", + "url": "https://github.com/google/guava" + }, + "com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "Guava ListenableFuture only", + "url": "https://github.com/google/guava" + }, + "com.google.j2objc:j2objc-annotations:2.8": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "J2ObjC Annotations", + "url": "https://github.com/google/j2objc/" + }, + "com.google.j2objc:j2objc-annotations:3.1": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "J2ObjC Annotations", + "url": "https://github.com/google/j2objc/" + }, + "com.nimbusds:content-type:2.3": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "Nimbus Content Type", + "url": "https://bitbucket.org/connect2id/nimbus-content-type" + }, + "com.nimbusds:lang-tag:1.7": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "Nimbus LangTag", + "url": "https://bitbucket.org/connect2id/nimbus-language-tags" + }, + "com.nimbusds:nimbus-jose-jwt:10.4": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "Nimbus JOSE+JWT", + "url": "https://bitbucket.org/connect2id/nimbus-jose-jwt" + }, + "com.nimbusds:oauth2-oidc-sdk:11.26.1": { + "licenses": [ + "Apache License, version 2.0" + ], + "name": "OAuth 2.0 SDK with OpenID Connect extensions", + "url": "https://bitbucket.org/connect2id/oauth-2.0-sdk-with-openid-connect-extensions" + }, + "com.squareup.okhttp3:okhttp-jvm:5.2.1": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "okhttp", + "url": "https://square.github.io/okhttp/" + }, + "com.squareup.okio:okio-jvm:3.16.1": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "okio", + "url": "https://github.com/square/okio/" + }, + "com.typesafe:config:1.4.1": { + "licenses": [ + "Apache-2.0" + ], + "name": "config", + "url": "https://github.com/lightbend/config" + }, + "commons-codec:commons-codec:1.19.0": { + "licenses": [ + "Apache-2.0" + ], + "name": "Apache Commons Codec", + "url": "https://commons.apache.org/proper/commons-codec/" + }, + "commons-logging:commons-logging:1.3.6": { + "licenses": [ + "Apache-2.0" + ], + "name": "Apache Commons Logging", + "url": "https://commons.apache.org/proper/commons-logging/" + }, + "io.cloudevents:cloudevents-api:4.1.1": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "CloudEvents - API", + "url": "https://cloudevents.github.io/sdk-java/" + }, + "io.cloudevents:cloudevents-core:4.1.1": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "CloudEvents - Core", + "url": "https://cloudevents.github.io/sdk-java/" + }, + "io.cloudevents:cloudevents-json-jackson:4.1.1": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "CloudEvents - JSON Jackson", + "url": "https://cloudevents.github.io/sdk-java/" + }, + "io.micrometer:context-propagation:1.2.1": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "context-propagation", + "url": "https://github.com/micrometer-metrics/context-propagation" + }, + "io.micrometer:micrometer-commons:1.16.6": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "micrometer-commons", + "url": "https://github.com/micrometer-metrics/micrometer" + }, + "io.micrometer:micrometer-core:1.16.6": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "micrometer-core", + "url": "https://github.com/micrometer-metrics/micrometer" + }, + "io.micrometer:micrometer-jakarta9:1.16.6": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "micrometer-jakarta9", + "url": "https://github.com/micrometer-metrics/micrometer" + }, + "io.micrometer:micrometer-observation:1.16.6": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "micrometer-observation", + "url": "https://github.com/micrometer-metrics/micrometer" + }, + "io.micrometer:micrometer-tracing-bridge-otel:1.6.6": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "micrometer-tracing-bridge-otel", + "url": "https://github.com/micrometer-metrics/tracing" + }, + "io.micrometer:micrometer-tracing:1.6.6": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "micrometer-tracing", + "url": "https://github.com/micrometer-metrics/tracing" + }, + "io.nats:jnats:2.23.0": { + "licenses": [ + "The Apache License, Version 2.0" + ], + "name": "jnats", + "url": "https://github.com/nats-io/nats.java" + }, + "io.netty:netty-buffer:4.2.15.Final": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Netty/Buffer", + "url": "https://netty.io/" + }, + "io.netty:netty-codec-base:4.2.15.Final": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Netty/Codec/Base", + "url": "https://netty.io/" + }, + "io.netty:netty-codec-classes-quic:4.2.15.Final": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Netty/Codec/Classes/Quic", + "url": "https://netty.io/" + }, + "io.netty:netty-codec-compression:4.2.15.Final": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Netty/Codec/Compression", + "url": "https://netty.io/" + }, + "io.netty:netty-codec-dns:4.2.15.Final": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Netty/Codec/DNS", + "url": "https://netty.io/" + }, + "io.netty:netty-codec-http2:4.2.15.Final": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Netty/Codec/HTTP2", + "url": "https://netty.io/" + }, + "io.netty:netty-codec-http3:4.2.15.Final": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Netty/Codec/Http3", + "url": "https://netty.io/netty-codec-http3/" + }, + "io.netty:netty-codec-http:4.2.15.Final": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Netty/Codec/HTTP", + "url": "https://netty.io/" + }, + "io.netty:netty-codec-socks:4.2.15.Final": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Netty/Codec/Socks", + "url": "https://netty.io/" + }, + "io.netty:netty-common:4.2.15.Final": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Netty/Common", + "url": "https://netty.io/" + }, + "io.netty:netty-handler-proxy:4.2.15.Final": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Netty/Handler/Proxy", + "url": "https://netty.io/" + }, + "io.netty:netty-handler:4.2.15.Final": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Netty/Handler", + "url": "https://netty.io/" + }, + "io.netty:netty-resolver-dns:4.2.15.Final": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Netty/Resolver/DNS", + "url": "https://netty.io/" + }, + "io.netty:netty-resolver:4.2.15.Final": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Netty/Resolver", + "url": "https://netty.io/" + }, + "io.netty:netty-transport-native-unix-common:4.2.15.Final": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Netty/Transport/Native/Unix/Common", + "url": "https://netty.io/" + }, + "io.netty:netty-transport:4.2.15.Final": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Netty/Transport", + "url": "https://netty.io/" + }, + "io.opentelemetry.semconv:opentelemetry-semconv:1.37.0": { + "licenses": [ + "The Apache License, Version 2.0" + ], + "name": "OpenTelemetry Semantic Conventions Java", + "url": "https://github.com/open-telemetry/semantic-conventions-java" + }, + "io.opentelemetry:opentelemetry-api:1.55.0": { + "licenses": [ + "The Apache License, Version 2.0" + ], + "name": "OpenTelemetry Java", + "url": "https://github.com/open-telemetry/opentelemetry-java" + }, + "io.opentelemetry:opentelemetry-common:1.55.0": { + "licenses": [ + "The Apache License, Version 2.0" + ], + "name": "OpenTelemetry Java", + "url": "https://github.com/open-telemetry/opentelemetry-java" + }, + "io.opentelemetry:opentelemetry-context:1.55.0": { + "licenses": [ + "The Apache License, Version 2.0" + ], + "name": "OpenTelemetry Java", + "url": "https://github.com/open-telemetry/opentelemetry-java" + }, + "io.opentelemetry:opentelemetry-exporter-common:1.55.0": { + "licenses": [ + "The Apache License, Version 2.0" + ], + "name": "OpenTelemetry Java", + "url": "https://github.com/open-telemetry/opentelemetry-java" + }, + "io.opentelemetry:opentelemetry-exporter-otlp-common:1.55.0": { + "licenses": [ + "The Apache License, Version 2.0" + ], + "name": "OpenTelemetry Java", + "url": "https://github.com/open-telemetry/opentelemetry-java" + }, + "io.opentelemetry:opentelemetry-exporter-otlp:1.55.0": { + "licenses": [ + "The Apache License, Version 2.0" + ], + "name": "OpenTelemetry Java", + "url": "https://github.com/open-telemetry/opentelemetry-java" + }, + "io.opentelemetry:opentelemetry-exporter-sender-okhttp:1.55.0": { + "licenses": [ + "The Apache License, Version 2.0" + ], + "name": "OpenTelemetry Java", + "url": "https://github.com/open-telemetry/opentelemetry-java" + }, + "io.opentelemetry:opentelemetry-extension-trace-propagators:1.55.0": { + "licenses": [ + "The Apache License, Version 2.0" + ], + "name": "OpenTelemetry Java", + "url": "https://github.com/open-telemetry/opentelemetry-java" + }, + "io.opentelemetry:opentelemetry-sdk-common:1.55.0": { + "licenses": [ + "The Apache License, Version 2.0" + ], + "name": "OpenTelemetry Java", + "url": "https://github.com/open-telemetry/opentelemetry-java" + }, + "io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:1.55.0": { + "licenses": [ + "The Apache License, Version 2.0" + ], + "name": "OpenTelemetry Java", + "url": "https://github.com/open-telemetry/opentelemetry-java" + }, + "io.opentelemetry:opentelemetry-sdk-logs:1.55.0": { + "licenses": [ + "The Apache License, Version 2.0" + ], + "name": "OpenTelemetry Java", + "url": "https://github.com/open-telemetry/opentelemetry-java" + }, + "io.opentelemetry:opentelemetry-sdk-metrics:1.55.0": { + "licenses": [ + "The Apache License, Version 2.0" + ], + "name": "OpenTelemetry Java", + "url": "https://github.com/open-telemetry/opentelemetry-java" + }, + "io.opentelemetry:opentelemetry-sdk-trace:1.55.0": { + "licenses": [ + "The Apache License, Version 2.0" + ], + "name": "OpenTelemetry Java", + "url": "https://github.com/open-telemetry/opentelemetry-java" + }, + "io.opentelemetry:opentelemetry-sdk:1.55.0": { + "licenses": [ + "The Apache License, Version 2.0" + ], + "name": "OpenTelemetry Java", + "url": "https://github.com/open-telemetry/opentelemetry-java" + }, + "io.projectreactor.netty:reactor-netty-core:1.3.6": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "Core functionality for the Reactor Netty library", + "url": "https://github.com/reactor/reactor-netty" + }, + "io.projectreactor.netty:reactor-netty-http:1.3.6": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "HTTP functionality for the Reactor Netty library", + "url": "https://github.com/reactor/reactor-netty" + }, + "io.projectreactor:reactor-core:3.8.6": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Non-Blocking Reactive Foundation for the JVM", + "url": "https://github.com/reactor/reactor-core" + }, + "io.swagger.core.v3:swagger-annotations-jakarta:2.2.47": { + "licenses": [ + "Apache License 2.0" + ], + "name": "swagger-annotations-jakarta", + "url": "https://github.com/swagger-api/swagger-core" + }, + "io.swagger.core.v3:swagger-core-jakarta:2.2.47": { + "licenses": [ + "Apache License 2.0" + ], + "name": "swagger-core-jakarta", + "url": "https://github.com/swagger-api/swagger-core" + }, + "io.swagger.core.v3:swagger-models-jakarta:2.2.47": { + "licenses": [ + "Apache License 2.0" + ], + "name": "swagger-models-jakarta", + "url": "https://github.com/swagger-api/swagger-core" + }, + "jakarta.activation:jakarta.activation-api:2.1.4": { + "licenses": [ + "EDL 1.0" + ], + "name": "Jakarta Activation API", + "url": "https://github.com/jakartaee/jaf-api" + }, + "jakarta.annotation:jakarta.annotation-api:3.0.0": { + "licenses": [ + "EPL 2.0", + "GPL2 w/ CPE" + ], + "name": "Jakarta Annotations API", + "url": "https://projects.eclipse.org/projects/ee4j.ca" + }, + "jakarta.validation:jakarta.validation-api:3.1.1": { + "licenses": [ + "Apache License 2.0" + ], + "name": "Jakarta Validation API", + "url": "https://beanvalidation.org" + }, + "jakarta.xml.bind:jakarta.xml.bind-api:4.0.5": { + "licenses": [ + "Eclipse Distribution License - v 1.0" + ], + "name": "Jakarta XML Binding API", + "url": "https://github.com/jakartaee/jaxb-api" + }, + "net.minidev:accessors-smart:2.6.0": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "ASM based accessors helper used by json-smart", + "url": "https://urielch.github.io/" + }, + "net.minidev:json-smart:2.6.0": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "JSON Small and Fast Parser", + "url": "https://urielch.github.io/" + }, + "org.apache.cassandra:java-driver-core:4.19.3": { + "licenses": [ + "Apache 2" + ], + "name": "Apache Cassandra Java Driver - core", + "url": "https://github.com/datastax/java-driver" + }, + "org.apache.cassandra:java-driver-guava-shaded:4.19.3": { + "licenses": [ + "Apache 2" + ], + "name": "Apache Cassandra Java Driver - guava shaded dep", + "url": "https://github.com/datastax/java-driver" + }, + "org.apache.cassandra:java-driver-metrics-micrometer:4.19.3": { + "licenses": [ + "Apache 2" + ], + "name": "Apache Cassandra Java Driver - Metrics - Micrometer", + "url": "https://github.com/datastax/java-driver" + }, + "org.apache.cassandra:java-driver-query-builder:4.19.3": { + "licenses": [ + "Apache 2" + ], + "name": "Apache Cassandra Java Driver - query builder", + "url": "https://github.com/datastax/java-driver" + }, + "org.apache.commons:commons-lang3:3.20.0": { + "licenses": [ + "Apache-2.0" + ], + "name": "Apache Commons Lang", + "url": "https://commons.apache.org/proper/commons-lang/" + }, + "org.apache.logging.log4j:log4j-api:2.25.4": { + "licenses": [ + "Apache-2.0" + ], + "name": "Apache Log4j API", + "url": "https://logging.apache.org/log4j/2.x/" + }, + "org.apache.logging.log4j:log4j-to-slf4j:2.25.4": { + "licenses": [ + "Apache-2.0" + ], + "name": "Log4j API to SLF4J Adapter", + "url": "https://logging.apache.org/log4j/2.x/" + }, + "org.apache.tomcat.embed:tomcat-embed-el:11.0.22": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "tomcat-embed-el", + "url": "https://tomcat.apache.org/" + }, + "org.bouncycastle:bcprov-jdk18on:1.84": { + "licenses": [ + "Bouncy Castle Licence" + ], + "name": "Bouncy Castle Provider", + "url": "https://www.bouncycastle.org/download/bouncy-castle-java/" + }, + "org.bouncycastle:bcprov-lts8on:2.73.8": { + "licenses": [ + "Bouncy Castle Licence" + ], + "name": "Bouncy Castle Provider (LTS Distribution)", + "url": "https://www.bouncycastle.org/lts-java" + }, + "org.checkerframework:checker-qual:3.33.0": { + "licenses": [ + "The MIT License" + ], + "name": "Checker Qual", + "url": "https://checkerframework.org/" + }, + "org.hdrhistogram:HdrHistogram:2.2.2": { + "licenses": [ + "Public Domain, per Creative Commons CC0", + "BSD-2-Clause" + ], + "name": "HdrHistogram", + "url": "http://hdrhistogram.github.io/HdrHistogram/" + }, + "org.hibernate.validator:hibernate-validator:9.0.1.Final": { + "licenses": [ + "Apache License 2.0" + ], + "name": "Hibernate Validator Engine", + "url": "https://hibernate.org/validator" + }, + "org.jboss.logging:jboss-logging:3.6.3.Final": { + "licenses": [ + "Apache License 2.0" + ], + "name": "JBoss Logging 3", + "url": "https://www.jboss.org" + }, + "org.jetbrains.kotlin:kotlin-stdlib:2.2.21": { + "licenses": [ + "Apache-2.0" + ], + "name": "Kotlin Stdlib", + "url": "https://kotlinlang.org/" + }, + "org.jetbrains:annotations:17.0.0": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "JetBrains Java Annotations", + "url": "https://github.com/JetBrains/java-annotations" + }, + "org.jspecify:jspecify:1.0.0": { + "licenses": [ + "The Apache License, Version 2.0" + ], + "name": "JSpecify annotations", + "url": "http://jspecify.org/" + }, + "org.latencyutils:LatencyUtils:2.0.3": { + "licenses": [ + "Public Domain, per Creative Commons CC0" + ], + "name": "LatencyUtils", + "url": "http://latencyutils.github.io/LatencyUtils/" + }, + "org.ow2.asm:asm-analysis:9.9": { + "licenses": [ + "BSD-3-Clause" + ], + "name": "asm-analysis", + "url": "http://asm.ow2.io/" + }, + "org.ow2.asm:asm-commons:9.9": { + "licenses": [ + "BSD-3-Clause" + ], + "name": "asm-commons", + "url": "http://asm.ow2.io/" + }, + "org.ow2.asm:asm-tree:9.9": { + "licenses": [ + "BSD-3-Clause" + ], + "name": "asm-tree", + "url": "http://asm.ow2.io/" + }, + "org.ow2.asm:asm-util:9.9": { + "licenses": [ + "BSD-3-Clause" + ], + "name": "asm-util", + "url": "http://asm.ow2.io/" + }, + "org.ow2.asm:asm:9.9": { + "licenses": [ + "BSD-3-Clause" + ], + "name": "asm", + "url": "http://asm.ow2.io/" + }, + "org.projectlombok:lombok:1.18.46": { + "licenses": [ + "The MIT License" + ], + "name": "Project Lombok", + "url": "https://projectlombok.org" + }, + "org.reactivestreams:reactive-streams:1.0.4": { + "licenses": [ + "MIT-0" + ], + "name": "reactive-streams", + "url": "http://www.reactive-streams.org/" + }, + "org.slf4j:jul-to-slf4j:2.0.18": { + "licenses": [ + "MIT" + ], + "name": "JUL to SLF4J bridge", + "url": "http://www.slf4j.org" + }, + "org.slf4j:slf4j-api:2.0.18": { + "licenses": [ + "MIT" + ], + "name": "SLF4J API Module", + "url": "http://www.slf4j.org" + }, + "org.springdoc:springdoc-openapi-starter-common:3.0.3": { + "licenses": [ + "The Apache License, Version 2.0" + ], + "name": "springdoc-openapi-starter-common", + "url": "https://springdoc.org/" + }, + "org.springdoc:springdoc-openapi-starter-webflux-api:3.0.3": { + "licenses": [ + "The Apache License, Version 2.0" + ], + "name": "springdoc-openapi-starter-webflux-api", + "url": "https://springdoc.org/" + }, + "org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.3": { + "licenses": [ + "The Apache License, Version 2.0" + ], + "name": "springdoc-openapi-starter-webmvc-api", + "url": "https://springdoc.org/" + }, + "org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-actuator-autoconfigure", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-actuator:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-actuator", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-autoconfigure:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-autoconfigure", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-cassandra:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-cassandra", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-data-cassandra:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-data-cassandra", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-data-commons:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-data-commons", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-health:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-health", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-http-client:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-http-client", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-http-codec:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-http-codec", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-http-converter:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-http-converter", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-jackson:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-jackson", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-micrometer-metrics:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-micrometer-metrics", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-micrometer-observation:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-micrometer-observation", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-micrometer-tracing-opentelemetry:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-micrometer-tracing-opentelemetry", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-micrometer-tracing:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-micrometer-tracing", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-netty:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-netty", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-opentelemetry:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-opentelemetry", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-persistence:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-persistence", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-reactor-netty:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-reactor-netty", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-reactor:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-reactor", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-security-oauth2-client:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-security-oauth2-client", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-security-oauth2-resource-server", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-security:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-security", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-servlet:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-servlet", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-starter-actuator:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-starter-actuator", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-starter-data-cassandra:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-starter-data-cassandra", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-starter-jackson:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-starter-jackson", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-starter-logging:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-starter-logging", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-starter-micrometer-metrics", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-starter-reactor-netty:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-starter-reactor-netty", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-starter-security-oauth2-client:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-starter-security-oauth2-client", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-starter-security-oauth2-resource-server:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-starter-security-oauth2-resource-server", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-starter-security:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-starter-security", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-starter-validation:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-starter-validation", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-starter-webflux:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-starter-webflux", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-starter:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-starter", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-validation:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-validation", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-web-server:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-web-server", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-webclient:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-webclient", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-webflux:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-webflux", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot-webmvc:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot-webmvc", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.boot:spring-boot:4.0.7": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-boot", + "url": "https://spring.io/projects/spring-boot" + }, + "org.springframework.cloud:spring-cloud-commons:5.0.2": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Spring Cloud Commons", + "url": "https://projects.spring.io/spring-cloud/spring-cloud-commons/" + }, + "org.springframework.cloud:spring-cloud-context:5.0.2": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Spring Cloud Context", + "url": "https://projects.spring.io/spring-cloud/spring-cloud-context/" + }, + "org.springframework.cloud:spring-cloud-starter-bootstrap:5.0.2": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-cloud-starter-bootstrap", + "url": "https://projects.spring.io/spring-cloud" + }, + "org.springframework.cloud:spring-cloud-starter:5.0.2": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-cloud-starter", + "url": "https://projects.spring.io/spring-cloud" + }, + "org.springframework.data:spring-data-cassandra:5.0.6": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Spring Data for Apache Cassandra Core", + "url": "https://projects.spring.io/spring-data-cassandra/" + }, + "org.springframework.data:spring-data-commons:4.0.6": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Spring Data Core", + "url": "https://spring.io/projects/spring-data" + }, + "org.springframework.security:spring-security-config:7.0.6": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-security-config", + "url": "https://spring.io/projects/spring-security" + }, + "org.springframework.security:spring-security-core:7.0.6": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-security-core", + "url": "https://spring.io/projects/spring-security" + }, + "org.springframework.security:spring-security-crypto:7.0.6": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-security-crypto", + "url": "https://spring.io/projects/spring-security" + }, + "org.springframework.security:spring-security-oauth2-client:7.0.6": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-security-oauth2-client", + "url": "https://spring.io/projects/spring-security" + }, + "org.springframework.security:spring-security-oauth2-core:7.0.6": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-security-oauth2-core", + "url": "https://spring.io/projects/spring-security" + }, + "org.springframework.security:spring-security-oauth2-jose:7.0.6": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-security-oauth2-jose", + "url": "https://spring.io/projects/spring-security" + }, + "org.springframework.security:spring-security-oauth2-resource-server:7.0.6": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-security-oauth2-resource-server", + "url": "https://spring.io/projects/spring-security" + }, + "org.springframework.security:spring-security-web:7.0.6": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "spring-security-web", + "url": "https://spring.io/projects/spring-security" + }, + "org.springframework:spring-aop:7.0.8": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Spring AOP", + "url": "https://github.com/spring-projects/spring-framework" + }, + "org.springframework:spring-beans:7.0.8": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Spring Beans", + "url": "https://github.com/spring-projects/spring-framework" + }, + "org.springframework:spring-context:7.0.8": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Spring Context", + "url": "https://github.com/spring-projects/spring-framework" + }, + "org.springframework:spring-core:7.0.8": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Spring Core", + "url": "https://github.com/spring-projects/spring-framework" + }, + "org.springframework:spring-expression:7.0.8": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Spring Expression Language (SpEL)", + "url": "https://github.com/spring-projects/spring-framework" + }, + "org.springframework:spring-tx:7.0.8": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Spring Transaction", + "url": "https://github.com/spring-projects/spring-framework" + }, + "org.springframework:spring-web:7.0.8": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Spring Web", + "url": "https://github.com/spring-projects/spring-framework" + }, + "org.springframework:spring-webflux:7.0.8": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Spring WebFlux", + "url": "https://github.com/spring-projects/spring-framework" + }, + "org.springframework:spring-webmvc:7.0.8": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "Spring Web MVC", + "url": "https://github.com/spring-projects/spring-framework" + }, + "org.wiremock:wiremock-standalone:3.13.2": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "WireMock", + "url": "http://wiremock.org" + }, + "org.yaml:snakeyaml:2.5": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "SnakeYAML", + "url": "https://bitbucket.org/snakeyaml/snakeyaml" + }, + "software.amazon.awssdk:annotations:2.40.1": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "AWS Java SDK :: Annotations", + "url": "https://aws.amazon.com/sdkforjava" + }, + "software.amazon.awssdk:checksums-spi:2.40.1": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "AWS Java SDK :: Checksums SPI", + "url": "https://aws.amazon.com/sdkforjava" + }, + "software.amazon.awssdk:checksums:2.40.1": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "AWS Java SDK :: Checksums", + "url": "https://aws.amazon.com/sdkforjava" + }, + "software.amazon.awssdk:endpoints-spi:2.40.1": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "AWS Java SDK :: Endpoints SPI", + "url": "https://aws.amazon.com/sdkforjava" + }, + "software.amazon.awssdk:http-auth-aws:2.40.1": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "AWS Java SDK :: HTTP Auth AWS", + "url": "https://aws.amazon.com/sdkforjava" + }, + "software.amazon.awssdk:http-auth-spi:2.40.1": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "AWS Java SDK :: HTTP Auth SPI", + "url": "https://aws.amazon.com/sdkforjava" + }, + "software.amazon.awssdk:http-client-spi:2.40.1": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "AWS Java SDK :: HTTP Client Interface", + "url": "https://aws.amazon.com/sdkforjava" + }, + "software.amazon.awssdk:identity-spi:2.40.1": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "AWS Java SDK :: Identity SPI", + "url": "https://aws.amazon.com/sdkforjava" + }, + "software.amazon.awssdk:json-utils:2.40.1": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "AWS Java SDK :: Core :: Protocols :: Json Utils", + "url": "https://aws.amazon.com/sdkforjava" + }, + "software.amazon.awssdk:metrics-spi:2.40.1": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "AWS Java SDK :: Metrics SPI", + "url": "https://aws.amazon.com/sdkforjava" + }, + "software.amazon.awssdk:profiles:2.40.1": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "AWS Java SDK :: Profiles", + "url": "https://aws.amazon.com/sdkforjava" + }, + "software.amazon.awssdk:regions:2.40.1": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "AWS Java SDK :: Regions", + "url": "https://aws.amazon.com/sdkforjava" + }, + "software.amazon.awssdk:retries-spi:2.40.1": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "AWS Java SDK :: Retries API", + "url": "https://aws.amazon.com/sdkforjava" + }, + "software.amazon.awssdk:retries:2.40.1": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "AWS Java SDK :: Retries", + "url": "https://aws.amazon.com/sdkforjava" + }, + "software.amazon.awssdk:sdk-core:2.40.1": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "AWS Java SDK :: SDK Core", + "url": "https://aws.amazon.com/sdkforjava" + }, + "software.amazon.awssdk:third-party-jackson-core:2.40.1": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "AWS Java SDK :: Third Party :: Jackson-core", + "url": "https://aws.amazon.com/sdkforjava" + }, + "software.amazon.awssdk:utils:2.40.1": { + "licenses": [ + "Apache License, Version 2.0" + ], + "name": "AWS Java SDK :: Utilities", + "url": "https://aws.amazon.com/sdkforjava" + }, + "tools.jackson.core:jackson-core:3.1.4": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "Jackson-core", + "url": "https://github.com/FasterXML/jackson-core" + }, + "tools.jackson.core:jackson-databind:3.1.4": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "jackson-databind", + "url": "https://github.com/FasterXML/jackson" + }, + "tools.jackson.module:jackson-module-blackbird:3.1.4": { + "licenses": [ + "The Apache Software License, Version 2.0" + ], + "name": "Jackson module: Blackbird", + "url": "https://github.com/FasterXML/jackson-modules-base" + } + }, + "generated_by": "tools/bazel/generate_notice.py --update-metadata" +} diff --git a/src/libraries/java/nv-boot-parent/tools/bazel/notice_roots.json b/src/libraries/java/nv-boot-parent/tools/bazel/notice_roots.json new file mode 100644 index 0000000000..3ba576df64 --- /dev/null +++ b/src/libraries/java/nv-boot-parent/tools/bazel/notice_roots.json @@ -0,0 +1,50 @@ +{ + "dependencies": [ + "at.yawk.lz4:lz4-java", + "com.github.ben-manes.caffeine:guava", + "com.github.java-json-tools:json-patch", + "commons-codec:commons-codec", + "io.cloudevents:cloudevents-core", + "io.cloudevents:cloudevents-json-jackson", + "io.micrometer:micrometer-tracing-bridge-otel", + "io.nats:jnats", + "io.opentelemetry:opentelemetry-api", + "io.opentelemetry:opentelemetry-exporter-otlp", + "io.projectreactor.netty:reactor-netty-http", + "jakarta.annotation:jakarta.annotation-api", + "jakarta.validation:jakarta.validation-api", + "org.apache.cassandra:java-driver-metrics-micrometer", + "org.apache.commons:commons-lang3", + "org.apache.logging.log4j:log4j-api", + "org.bouncycastle:bcprov-jdk18on", + "org.projectlombok:lombok", + "org.slf4j:slf4j-api", + "org.springdoc:springdoc-openapi-starter-webflux-api", + "org.springdoc:springdoc-openapi-starter-webmvc-api", + "org.springframework.boot:spring-boot", + "org.springframework.boot:spring-boot-autoconfigure", + "org.springframework.boot:spring-boot-micrometer-metrics", + "org.springframework.boot:spring-boot-micrometer-tracing", + "org.springframework.boot:spring-boot-micrometer-tracing-opentelemetry", + "org.springframework.boot:spring-boot-starter", + "org.springframework.boot:spring-boot-starter-actuator", + "org.springframework.boot:spring-boot-starter-data-cassandra", + "org.springframework.boot:spring-boot-starter-jackson", + "org.springframework.boot:spring-boot-starter-security-oauth2-client", + "org.springframework.boot:spring-boot-starter-security-oauth2-resource-server", + "org.springframework.boot:spring-boot-starter-validation", + "org.springframework.boot:spring-boot-starter-webflux", + "org.springframework.boot:spring-boot-webclient", + "org.springframework.cloud:spring-cloud-commons", + "org.springframework.cloud:spring-cloud-context", + "org.springframework.cloud:spring-cloud-starter-bootstrap", + "org.springframework.security:spring-security-core", + "org.springframework.security:spring-security-oauth2-jose", + "org.springframework:spring-context", + "org.springframework:spring-web", + "org.springframework:spring-webflux", + "org.wiremock:wiremock-standalone", + "software.amazon.awssdk:regions", + "tools.jackson.module:jackson-module-blackbird" + ] +}