From 0cd959a494d4af8ef5287c1fae1bb638ace65a25 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Tue, 11 Aug 2026 16:49:31 +0900 Subject: [PATCH] feat(httpclient): close the platform review's P0/P1/P2 findings The review found one defect shape repeated across the platform: surfaces that were declared, bound, and documented, but that nothing read. An operator configuring fullUrlRecording, bodyLogging, retry.policy, validatedDnsPinning, timeout.dns, or any of ten declared metric names got a guarantee the code never delivered. Every such surface is now in exactly one of three states -- wired for real, rejected at startup, or registered in a test-enforced gap list with its reason. No silent no-ops remain. P0: - Activate the platform from bootstrap behind app.httpclient.enabled, with a single auto-configuration importing the nine child configurations. - Give the platform a strict, repository-level ENV contract: 74 leaf fields derived from the settings record tree, unknown APP_HTTPCLIENT_* rejected. - Route typed HTTP service clients through the call kernel via KernelHttpExchangeAdapter, so they stop bypassing platform policy. - Pin dynamic-target DNS resolution to the socket for the life of a call, closing the resolve-then-connect TOCTOU / rebinding window. - Actually transmit the idempotency key, and make retry eligibility depend on transmission rather than on merely holding one. - Reject reactive authentication and reactive redirect at startup instead of declaring support that does not function. - Fix the Reactor-only Stable contract row so the lane stops failing. - Stop advertising HTTP/3 on a transport that negotiates HTTP/1. P1 covers execution and retry accounting, redirect security (per-hop target guarding, sensitive-header stripping, 303 body handling), runtime rotation and transport resource ownership keyed by generation, dynamic-target hardening (subdomain matching, global-unicast classification, strict CIDR parsing), protocol intent, pool and timeout wiring, streaming and body limits, observability parity, and OAuth single-flight refresh on a bounded pool with a bounded wait. P2 covers configuration and documentation drift, the Gradle check wiring for the four hermetic lanes, and the CI gate matrix. Two test-quality defects surfaced while closing these: the HTTP/2 stream saturation test ran against cleartext HTTP/1.1 while asserting nothing about the protocol, and an OAuth contention test slept on a latch that could fire before the callers it meant to observe. Both now assert what their names claim. Verification run: :adapter:outbound:httpclient:check and :app-bootstrap:check (checkstyle, spotless, spotbugs, and the four hermetic lanes), verifyCleanArchitectureDependencies, verifyEnvKeys, verifyOneTypePerFile, verifyDependencyLocks, the documentation and gate-matrix verifiers, and the performance lane against a real TLS+ALPN HTTP/2 server. Not executed, and tracked rather than claimed: Docker/Toxiproxy fault injection, JMH, a real QUIC/HTTP3 server, a real Spring Framework 6.2 distribution (now a delegated-pending gate), live OAuth/TLS/proxy/DNS integration, and a whole-repository check. Co-Authored-By: Claude Opus 5 (1M context) --- .github/ci-gate-matrix.yml | 103 +- .github/scripts/verify-gate-matrix.sh | 195 +- .github/scripts/verify-gradle-wrapper.sh | 740 +++++++ .github/workflows/httpclient-contract.yml | 132 ++ .github/workflows/httpclient-release.yml | 70 + docs/httpclient/configuration-reference.md | 228 ++ docs/httpclient/env-fields.yaml | 179 ++ docs/httpclient/migration-guide.md | 64 + docs/httpclient/repository-adaptation.md | 87 + docs/registries/env-keys.yaml | 1956 +++++++++++++++++ ...client-platform-activation-and-env-ssot.md | 1333 +++++++++++ ...08-10-redis-optionality-and-composition.md | 366 +++ infra/redis-sdk/README.md | 148 ++ infra/redis-sdk/acl/all-accounts.acl | 8 + infra/redis-sdk/cluster/compose.yml | 134 ++ infra/redis-sdk/sentinel/compose.yml | 126 ++ scripts/verify-httpclient-docs.py | 135 ++ src/.env | 123 +- src/README.md | 39 +- src/adapter/outbound/cache-redis/CLAUDE.md | 99 +- .../redis/idempotency/IdempotencyScripts.java | 306 +++ .../cache/redis/lease/LeaseScripts.java | 203 ++ .../redis/ratelimit/RateLimitScripts.java | 280 +++ .../admin/LettuceRedisAdminOperations.java | 337 +++ .../redis/sdk/admin/RedisAdminOperations.java | 103 + .../sdk/config/RedisSdkAutoConfiguration.java | 397 ++++ .../redis/sdk/config/RedisSdkSettings.java | 943 ++++++++ .../redis/sdk/config/RedisStartupProbe.java | 142 ++ .../redis-sdk/redis-command-policy.yml | 1406 ++++++++++++ .../RedisEdgeRateLimitAdapterTest.java | 321 +++ .../redis/sdk/RedisTopologyEndpoint.java | 244 ++ .../sdk/config/LiveRedisCompositionTest.java | 176 ++ .../LiveRedisSentinelPromotionTest.java | 437 ++++ src/adapter/outbound/httpclient/CLAUDE.md | 75 +- src/adapter/outbound/httpclient/build.gradle | 233 +- .../outbound/httpclient/gradle.lockfile | 331 +-- .../Http2StreamSaturationTest.java | 104 + .../OAuthRefreshContentionTest.java | 95 + .../PoolSaturationPerformanceTest.java | 94 + .../ApacheBlockingTransportProvider.java | 118 + .../apache/ApacheFailureClassifier.java | 140 ++ .../httpclient/api/body/ObjectBody.java | 118 + .../api/operation/HttpOperation.java | 152 ++ .../httpclient/auth/CredentialRequest.java | 66 + .../auth/OAuth2CredentialProvider.java | 128 ++ .../ReactiveCredentialProviderRegistry.java | 79 + .../auth/SingleFlightTokenLoader.java | 148 ++ .../httpclient/dynamic/CallScopedDnsPin.java | 78 + .../dynamic/DefaultDynamicTargetGateway.java | 228 ++ .../dynamic/DynamicCredentialBinding.java | 79 + .../dynamic/DynamicTargetPolicy.java | 86 + .../dynamic/IpAddressClassifier.java | 215 ++ .../dynamic/ValidatedDnsResolver.java | 89 + .../http3/Http3CapabilityReport.java | 121 + .../http3/JettyHttp3TransportProvider.java | 190 ++ .../jdk/JdkBlockingTransportProvider.java | 83 + .../httpclient/jdk/JdkClientFactory.java | 64 + .../httpclient/jdk/JdkFailureClassifier.java | 120 + .../profile/ClientProfileValidator.java | 291 +++ .../profile/ClientRuntimeRegistry.java | 187 ++ .../httpclient/profile/ProtocolIntent.java | 61 + .../httpclient/profile/TimeoutSettings.java | 46 + .../ReactorConnectionProviderFactory.java | 42 + .../reactor/ReactorFailureClassifier.java | 137 ++ .../reactor/ReactorHttpClientFactory.java | 137 ++ .../ReactorNettyTransportProvider.java | 94 + .../resilience/AttemptResiliencePipeline.java | 135 ++ .../DefaultRetryEligibilityEngine.java | 133 ++ .../ExponentialFullJitterBackoff.java | 93 + .../ResilienceRejectionRecorder.java | 93 + .../httpclient/resilience/RetryContext.java | 77 + .../restclient/BlockingAttemptExecutor.java | 269 +++ .../restclient/BlockingClientRuntime.java | 132 ++ .../BlockingRedirectCoordinator.java | 114 + .../restclient/BlockingStreamingGateway.java | 169 ++ .../restclient/DefaultGenericHttpGateway.java | 304 +++ .../restclient/RestClientResponseReader.java | 206 ++ .../restclient/RestClientRuntimeFactory.java | 144 ++ .../security/PreparedOperation.java | 50 + .../security/SensitiveHeaderStripper.java | 68 + .../TlsRuntimeRotationCoordinator.java | 78 + .../security/TrustedTargetPolicy.java | 176 ++ .../service/DefaultHttpServiceRegistry.java | 115 + .../DefaultReactiveHttpServiceRegistry.java | 108 + .../service/KernelHttpExchangeAdapter.java | 211 ++ .../ReactiveKernelHttpExchangeAdapter.java | 272 +++ .../ReactiveServiceInvocationHandler.java | 79 + .../transport/BlockingTransportProvider.java | 32 + .../transport/ReactiveTransportProvider.java | 28 + .../TransportCapabilityValidator.java | 90 + .../transport/TransportResourceKey.java | 25 + .../webclient/DefaultReactiveHttpGateway.java | 330 +++ .../webclient/DefaultReactiveSseGateway.java | 183 ++ .../webclient/ReactiveAttemptExecutor.java | 184 ++ .../webclient/ReactiveClientRuntime.java | 111 + .../webclient/ReactiveStreamingGateway.java | 107 + .../webclient/WebClientBodyWriter.java | 98 + .../webclient/WebClientRuntimeFactory.java | 123 ++ .../ApacheBlockingTransportProviderTest.java | 76 + .../apache/ApachePoolSaturationTest.java | 93 + .../api/body/ObjectBodyReplayabilityTest.java | 70 + .../PublicApiArchitectureTest.java | 157 ++ .../AllStableTransportsContractTest.java | 217 ++ .../FailureInjectionContractTest.java | 86 + .../NegotiatedProtocolContractTest.java | 175 ++ .../dynamic/DynamicTargetSecurityTest.java | 253 +++ .../httpclient/http3/Http3OptInTest.java | 139 ++ .../jdk/JdkBlockingTransportProviderTest.java | 51 + .../DeclaredMetricsAreEmittedTest.java | 131 ++ .../RetryEligibilityEngineTest.java | 205 ++ .../BlockingRedirectCoordinatorTest.java | 175 ++ .../MutualTlsHandshakeContractTest.java | 172 ++ .../TlsRuntimeRotationCoordinatorTest.java | 114 + .../security/TrustedRequestPolicyTest.java | 161 ++ .../BlockingHttpServiceRegistryTest.java | 76 + ...ockingReactiveSignatureSeparationTest.java | 43 + .../ReactiveHttpServiceRegistryTest.java | 89 + .../TypedClientPlatformPolicyTest.java | 136 ++ .../DynamicTargetSecurityContract.java | 56 + .../httpclient/testkit/RetryContexts.java | 236 ++ src/app-bootstrap/gradle.lockfile | 622 +++--- .../DynamicTargetAutoConfiguration.java | 82 + .../HttpClientActuatorEndpoint.java | 71 + ...ClientAuthenticationAutoConfiguration.java | 88 + .../httpclient/HttpClientEnvironmentKeys.java | 178 ++ ...HttpClientManagementAutoConfiguration.java | 22 + ...ttpClientObservationAutoConfiguration.java | 36 + .../HttpClientPlatformAutoConfiguration.java | 61 + .../HttpClientPlatformSettings.java | 241 ++ .../HttpClientPlatformSettingsBinder.java | 54 + .../HttpClientProfileAutoConfiguration.java | 107 + .../httpclient/HttpClientProfileFactory.java | 153 ++ ...HttpClientResilienceAutoConfiguration.java | 28 + .../HttpClientSecurityAutoConfiguration.java | 30 + .../HttpClientStartupValidator.java | 123 ++ .../HttpClientTransportAutoConfiguration.java | 110 + .../HttpServiceClientAutoConfiguration.java | 90 + ...ot.autoconfigure.AutoConfiguration.imports | 2 + .../src/main/resources/application.yml | 279 ++- .../OptionalAdapterBeanGatingTest.java | 147 +- .../HttpClientAutoConfigurationTest.java | 140 ++ .../HttpClientPlatformActivationTest.java | 253 +++ .../HttpClientPlatformEnvManifestTest.java | 205 ++ .../HttpClientPlatformSettingsTest.java | 124 ++ .../ReactiveAuthenticationContractTest.java | 144 ++ .../UnsafeStartupConfigurationTest.java | 146 ++ ...nalTransportQualificationContractTest.java | 688 ++++++ src/build.gradle | 1919 +++------------- 148 files changed, 26812 insertions(+), 2368 deletions(-) create mode 100755 .github/scripts/verify-gradle-wrapper.sh create mode 100644 .github/workflows/httpclient-contract.yml create mode 100644 .github/workflows/httpclient-release.yml create mode 100644 docs/httpclient/configuration-reference.md create mode 100644 docs/httpclient/env-fields.yaml create mode 100644 docs/httpclient/migration-guide.md create mode 100644 docs/httpclient/repository-adaptation.md create mode 100644 docs/superpowers/plans/2026-08-10-httpclient-platform-activation-and-env-ssot.md create mode 100644 docs/superpowers/plans/2026-08-10-redis-optionality-and-composition.md create mode 100644 infra/redis-sdk/README.md create mode 100644 infra/redis-sdk/acl/all-accounts.acl create mode 100644 infra/redis-sdk/cluster/compose.yml create mode 100644 infra/redis-sdk/sentinel/compose.yml create mode 100755 scripts/verify-httpclient-docs.py create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/idempotency/IdempotencyScripts.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/lease/LeaseScripts.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/ratelimit/RateLimitScripts.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/admin/LettuceRedisAdminOperations.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/admin/RedisAdminOperations.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkAutoConfiguration.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkSettings.java create mode 100644 src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisStartupProbe.java create mode 100644 src/adapter/outbound/cache-redis/src/main/resources/redis-sdk/redis-command-policy.yml create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/ratelimit/RedisEdgeRateLimitAdapterTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/RedisTopologyEndpoint.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/LiveRedisCompositionTest.java create mode 100644 src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/LiveRedisSentinelPromotionTest.java create mode 100644 src/adapter/outbound/httpclient/src/httpClientPerformanceTest/java/dev/caskeleton/adapter/outbound/httpclient/performance/Http2StreamSaturationTest.java create mode 100644 src/adapter/outbound/httpclient/src/httpClientPerformanceTest/java/dev/caskeleton/adapter/outbound/httpclient/performance/OAuthRefreshContentionTest.java create mode 100644 src/adapter/outbound/httpclient/src/httpClientPerformanceTest/java/dev/caskeleton/adapter/outbound/httpclient/performance/PoolSaturationPerformanceTest.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/apache/ApacheBlockingTransportProvider.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/apache/ApacheFailureClassifier.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/api/body/ObjectBody.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/api/operation/HttpOperation.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/auth/CredentialRequest.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/auth/OAuth2CredentialProvider.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/auth/ReactiveCredentialProviderRegistry.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/auth/SingleFlightTokenLoader.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/dynamic/CallScopedDnsPin.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/dynamic/DefaultDynamicTargetGateway.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/dynamic/DynamicCredentialBinding.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/dynamic/DynamicTargetPolicy.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/dynamic/IpAddressClassifier.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/dynamic/ValidatedDnsResolver.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/http3/Http3CapabilityReport.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/http3/JettyHttp3TransportProvider.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/jdk/JdkBlockingTransportProvider.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/jdk/JdkClientFactory.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/jdk/JdkFailureClassifier.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/profile/ClientProfileValidator.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/profile/ClientRuntimeRegistry.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/profile/ProtocolIntent.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/profile/TimeoutSettings.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/reactor/ReactorConnectionProviderFactory.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/reactor/ReactorFailureClassifier.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/reactor/ReactorHttpClientFactory.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/reactor/ReactorNettyTransportProvider.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/AttemptResiliencePipeline.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/DefaultRetryEligibilityEngine.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/ExponentialFullJitterBackoff.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/ResilienceRejectionRecorder.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/RetryContext.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/restclient/BlockingAttemptExecutor.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/restclient/BlockingClientRuntime.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/restclient/BlockingRedirectCoordinator.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/restclient/BlockingStreamingGateway.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/restclient/DefaultGenericHttpGateway.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/restclient/RestClientResponseReader.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/restclient/RestClientRuntimeFactory.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/security/PreparedOperation.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/security/SensitiveHeaderStripper.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/security/TlsRuntimeRotationCoordinator.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/security/TrustedTargetPolicy.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/service/DefaultHttpServiceRegistry.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/service/DefaultReactiveHttpServiceRegistry.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/service/KernelHttpExchangeAdapter.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/service/ReactiveKernelHttpExchangeAdapter.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/service/ReactiveServiceInvocationHandler.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/transport/BlockingTransportProvider.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/transport/ReactiveTransportProvider.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/transport/TransportCapabilityValidator.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/transport/TransportResourceKey.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/webclient/DefaultReactiveHttpGateway.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/webclient/DefaultReactiveSseGateway.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/webclient/ReactiveAttemptExecutor.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/webclient/ReactiveClientRuntime.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/webclient/ReactiveStreamingGateway.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/webclient/WebClientBodyWriter.java create mode 100644 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/webclient/WebClientRuntimeFactory.java create mode 100644 src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/apache/ApacheBlockingTransportProviderTest.java create mode 100644 src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/apache/ApachePoolSaturationTest.java create mode 100644 src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/api/body/ObjectBodyReplayabilityTest.java create mode 100644 src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/architecture/PublicApiArchitectureTest.java create mode 100644 src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/contract/AllStableTransportsContractTest.java create mode 100644 src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/contract/FailureInjectionContractTest.java create mode 100644 src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/contract/NegotiatedProtocolContractTest.java create mode 100644 src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/dynamic/DynamicTargetSecurityTest.java create mode 100644 src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/http3/Http3OptInTest.java create mode 100644 src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/jdk/JdkBlockingTransportProviderTest.java create mode 100644 src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/observation/DeclaredMetricsAreEmittedTest.java create mode 100644 src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/resilience/RetryEligibilityEngineTest.java create mode 100644 src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/restclient/BlockingRedirectCoordinatorTest.java create mode 100644 src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/security/MutualTlsHandshakeContractTest.java create mode 100644 src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/security/TlsRuntimeRotationCoordinatorTest.java create mode 100644 src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/security/TrustedRequestPolicyTest.java create mode 100644 src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/service/BlockingHttpServiceRegistryTest.java create mode 100644 src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/service/BlockingReactiveSignatureSeparationTest.java create mode 100644 src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/service/ReactiveHttpServiceRegistryTest.java create mode 100644 src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/service/TypedClientPlatformPolicyTest.java create mode 100644 src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/DynamicTargetSecurityContract.java create mode 100644 src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/RetryContexts.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/DynamicTargetAutoConfiguration.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientActuatorEndpoint.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientAuthenticationAutoConfiguration.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientEnvironmentKeys.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientManagementAutoConfiguration.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientObservationAutoConfiguration.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformAutoConfiguration.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformSettings.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformSettingsBinder.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientProfileAutoConfiguration.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientProfileFactory.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientResilienceAutoConfiguration.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientSecurityAutoConfiguration.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientStartupValidator.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientTransportAutoConfiguration.java create mode 100644 src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpServiceClientAutoConfiguration.java create mode 100644 src/app-bootstrap/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientAutoConfigurationTest.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformActivationTest.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformEnvManifestTest.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformSettingsTest.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/ReactiveAuthenticationContractTest.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/UnsafeStartupConfigurationTest.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ConditionalTransportQualificationContractTest.java diff --git a/.github/ci-gate-matrix.yml b/.github/ci-gate-matrix.yml index 4369095f..1991f07c 100644 --- a/.github/ci-gate-matrix.yml +++ b/.github/ci-gate-matrix.yml @@ -24,6 +24,13 @@ gates: workflow: ci-quality-gates.yml job: quality-gates execution: check + - id: conditional-transport-qualification + release_blocking: true + mechanism: gradle-custom-task + ref: conditionalTransportQualification + workflow: ci-quality-gates.yml + job: quality-gates + execution: explicit - id: clean-architecture-dependencies release_blocking: true mechanism: gradle-custom-task @@ -101,12 +108,12 @@ gates: workflow: ci-quality-gates.yml job: gate-matrix-lint execution: job - - id: redis-standalone + - id: redis-sdk release_blocking: true mechanism: workflow-job - ref: redis-standalone + ref: redis-sdk workflow: ci-quality-gates.yml - job: redis-standalone + job: redis-sdk execution: job - id: jpa-candidate-evidence release_blocking: true @@ -171,7 +178,7 @@ gates: workflow: object-storage-qualification.yml job: minio-managed-contract execution: explicit - - id: poster-image-v7-migration + - id: poster-image-migration release_blocking: true mechanism: gradle-custom-task ref: posterImageMigrationTest @@ -192,3 +199,91 @@ gates: workflow: object-storage-qualification.yml job: aws-managed-common-subset execution: job + - id: redis-sdk-support-matrix + release_blocking: true + mechanism: contract-test + ref: adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/RedisSupportMatrixTest.java + workflow: ci-quality-gates.yml + job: quality-gates + execution: check + # Promoted from delegated-pending: the workflow is no longer manual-only. A pull request that + # touches the Redis leaf runs the standalone lane, and the full supported-version x topology + # matrix runs nightly and on a release candidate. While it was dispatch-only, a release could + # claim topology evidence that nobody had produced for that commit. + - id: redis-sdk-topology-evidence + release_blocking: conditional + mechanism: workflow-job + ref: topology-evidence + workflow: redis-sdk-topology.yml + job: topology-evidence + execution: job + - id: httpclient-stable-contract + release_blocking: true + mechanism: gradle-custom-task + ref: httpClientStableContractTest + workflow: httpclient-release.yml + job: release-gate + execution: explicit + - id: httpclient-security-suite + release_blocking: true + mechanism: gradle-custom-task + ref: httpClientSecurityTest + workflow: httpclient-release.yml + job: release-gate + execution: explicit + - id: httpclient-fault-injection + release_blocking: true + mechanism: gradle-custom-task + ref: httpClientFailureInjectionTest + workflow: httpclient-release.yml + job: release-gate + execution: explicit + - id: httpclient-performance-certification + release_blocking: true + mechanism: gradle-custom-task + ref: httpClientPerformanceTest + workflow: httpclient-release.yml + job: release-gate + execution: explicit + - id: httpclient-spring62-api-surface + release_blocking: true + mechanism: gradle-custom-task + ref: spring62ApiSurfaceScan + workflow: httpclient-release.yml + job: release-gate + execution: explicit + # The 6.2 API-surface scan above proves the common packages compile against the older surface. It + # does not prove they run on it, and the two were being conflated: a lane called + # "spring62CompatibilityTest" reads as a runtime compatibility proof. The Gradle task is renamed to + # say what it does, and the runtime claim is registered here as its own delegated-pending control + # so the gap is a tracked absence rather than an unstated one. Executing it needs a Spring + # Framework 6.2 distribution resolved into a separate test runtime, which this repository's + # Boot 4.0 baseline does not carry. + - id: httpclient-spring62-runtime + release_blocking: conditional + mechanism: delegated-pending + ref: spring62-runtime-lane + workflow: httpclient-release.yml + job: release-gate + execution: job + - id: httpclient-spring70-compatibility + release_blocking: true + mechanism: gradle-custom-task + ref: spring70CompatibilityTest + workflow: httpclient-release.yml + job: release-gate + execution: explicit + - id: httpclient-documentation-drift + release_blocking: true + mechanism: workflow-job + ref: httpclient-documentation + workflow: httpclient-release.yml + job: httpclient-documentation + execution: job + - id: httpclient-event-loop-blocking + release_blocking: true + mechanism: gradle-custom-task + ref: httpClientBlockHoundTest + workflow: httpclient-release.yml + job: release-gate + execution: explicit diff --git a/.github/scripts/verify-gate-matrix.sh b/.github/scripts/verify-gate-matrix.sh index 0a3e362c..aa488ddd 100644 --- a/.github/scripts/verify-gate-matrix.sh +++ b/.github/scripts/verify-gate-matrix.sh @@ -2,15 +2,34 @@ set -euo pipefail readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" -readonly REPO_ROOT="$(git -C "${SCRIPT_DIR}" rev-parse --show-toplevel)" -readonly EXPECTED_SCRIPT_DIR="$(cd -- "${REPO_ROOT}/.github/scripts" && pwd -P)" -readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml" -readonly EXPECTED_GATE_COUNT=26 - -if [[ "${SCRIPT_DIR}" != "${EXPECTED_SCRIPT_DIR}" ]]; then - printf '::error::gate-matrix-lint: script resolved outside the repository .github/scripts directory\n' >&2 - exit 1 +if (( $# > 1 )); then + printf '::error::gate-matrix-lint: expected zero arguments or one repository root\n' >&2 + exit 2 fi + +if (( $# == 1 )); then + if [[ ! -d "$1" ]]; then + printf '::error::gate-matrix-lint: repository root is not a directory: %s\n' "$1" >&2 + exit 2 + fi + REPO_ROOT="$(cd -- "$1" && pwd -P)" +else + REPO_ROOT="$(git -C "${SCRIPT_DIR}" rev-parse --show-toplevel)" + EXPECTED_SCRIPT_DIR="$(cd -- "${REPO_ROOT}/.github/scripts" && pwd -P)" + if [[ "${SCRIPT_DIR}" != "${EXPECTED_SCRIPT_DIR}" ]]; then + printf '::error::gate-matrix-lint: script location must be repository .github/scripts directory\n' >&2 + exit 1 + fi +fi + +readonly REPO_ROOT +readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml" +# Deliberately a literal: a gate silently appearing or disappearing is the drift this lint exists to +# catch, so growing the matrix is an explicit edit here. 38 as of the HTTP Client platform hardening, +# which registered httpclient-spring62-runtime as a delegated-pending control — the 6.2 *runtime* +# claim, distinct from the API-surface scan that was standing in for it. +readonly EXPECTED_GATE_COUNT=38 + if [[ ! -f "${MATRIX}" ]]; then printf '::error::gate-matrix-lint: missing %s\n' "${MATRIX}" >&2 exit 1 @@ -80,6 +99,146 @@ job_body() { ' "${workflow_file}" } +gradle_command_has_safe_literal_grammar() { + local command="$1" + [[ "${command}" =~ ^\./gradlew([[:space:]]+[A-Za-z0-9_.:/@=,+-]+)+[[:space:]]*$ ]] +} + +gradle_token_suppresses_execution() { + local token="$1" + case "${token}" in + '--dry-run'|'--dry-run='*|'-m'|'-x'|'-x'*|'--exclude-task'|'--exclude-task='*) return 0 ;; + *) return 1 ;; + esac +} + +gradle_token_is_allowed_gate_argument() { + local token="$1" + case "${token}" in + '--no-daemon'|'--stacktrace'|'--warning-mode=fail') return 0 ;; + esac + [[ "${token}" =~ ^:?[A-Za-z0-9_][A-Za-z0-9_.-]*(:[A-Za-z0-9_][A-Za-z0-9_.-]*)*$ ]] +} + +gradle_plugin_is_applied() { + local plugin_id="$1" + grep -RqsF --include='build.gradle' -- "id '${plugin_id}'" "${REPO_ROOT}/src" \ + || grep -RqsF --include='build.gradle' -- "id \"${plugin_id}\"" "${REPO_ROOT}/src" \ + || grep -RqsF --include='build.gradle' -- "apply plugin: '${plugin_id}'" "${REPO_ROOT}/src" \ + || grep -RqsF --include='build.gradle' -- "apply plugin: \"${plugin_id}\"" "${REPO_ROOT}/src" +} + +gradle_custom_task_is_registered_in_build_file() { + local task_name="$1" + local build_file="$2" + if grep -qsE -- "tasks\\.register\\(['\"]${task_name}['\"]" "${build_file}"; then + return 0 + fi + + awk -v required_task="${task_name}" ' + index($0, "registerStrictQualificationTest(") > 0 { inside_registration=1 } + inside_registration && /^[[:space:]]*name:[[:space:]]*/ { + candidate=$0 + sub(/^[[:space:]]*name:[[:space:]]*/, "", candidate) + quote=substr(candidate, 1, 1) + if (quote != "\"" && quote != sprintf("%c", 39)) { + next + } + candidate=substr(candidate, 2) + closing_quote=index(candidate, quote) + if (closing_quote == 0) { + next + } + candidate=substr(candidate, 1, closing_quote - 1) + if (candidate == required_task) { + found=1 + } + } + inside_registration && /\)[[:space:]]*$/ { inside_registration=0 } + END { exit found ? 0 : 1 } + ' "${build_file}" +} + +gradle_custom_task_is_registered() { + local task_name="$1" + local build_file + while IFS= read -r -d '' build_file; do + if gradle_custom_task_is_registered_in_build_file "${task_name}" "${build_file}"; then + return 0 + fi + done < <(find "${REPO_ROOT}/src" -type f -name '*.gradle' -print0) + return 1 +} + +gradle_token_matches_registered_task() { + local token="$1" + local required_task="$2" + local project_path build_file + if [[ "${token}" == "${required_task}" || "${token}" == ":${required_task}" ]]; then + return 0 + fi + if [[ "${token}" != :* || "${token}" != *:"${required_task}" ]]; then + return 1 + fi + project_path="${token%:"${required_task}"}" + project_path="${project_path#:}" + project_path="${project_path%:}" + build_file="${REPO_ROOT}/src/${project_path//:/\/}/build.gradle" + [[ -f "${build_file}" ]] \ + && gradle_custom_task_is_registered_in_build_file "${required_task}" "${build_file}" +} + +job_runs_gradle_task() { + local workflow_file="$1" + local job_id="$2" + local required_task="$3" + local command token + local found_task suppressed + local -a tokens=() + + while IFS= read -r command; do + if ! gradle_command_has_safe_literal_grammar "${command}"; then + continue + fi + read -r -a tokens <<< "${command}" + if (( ${#tokens[@]} < 2 )) || [[ "${tokens[0]}" != './gradlew' ]]; then + continue + fi + found_task=0 + suppressed=0 + for token in "${tokens[@]:1}"; do + case "${token}" in + '&&'|'||'|';'|'|'|'#'*) break ;; + esac + if gradle_token_suppresses_execution "${token}"; then + suppressed=1 + break + fi + if ! gradle_token_is_allowed_gate_argument "${token}"; then + suppressed=1 + break + fi + if gradle_token_matches_registered_task "${token}" "${required_task}"; then + found_task=1 + fi + done + if (( found_task == 1 && suppressed == 0 )); then + return 0 + fi + done < <( + job_body "${workflow_file}" "${job_id}" | awk ' + /^[[:space:]]+(-[[:space:]]+)?run:[[:space:]]+/ { + command=$0 + sub(/^[[:space:]]+(-[[:space:]]+)?run:[[:space:]]+/, "", command) + if (command !~ /^(\||>)/) { + print command + } + } + ' + ) + return 1 +} + while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do [[ -z "${id}" ]] && continue total=$((total + 1)) @@ -114,8 +273,11 @@ while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do case "${mechanism}" in gradle-custom-task) - if ! grep -RqsE -- "tasks\\.register\\(['\"]${ref}['\"]" "${REPO_ROOT}/src" \ - --include='build.gradle'; then + if [[ ! "${ref}" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]; then + failures+=("gate '${id}' has unsafe Gradle custom task ref '${ref}'") + continue + fi + if ! gradle_custom_task_is_registered "${ref}"; then failures+=("gate '${id}' references unregistered Gradle task '${ref}'") continue fi @@ -123,12 +285,13 @@ while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do gradle-plugin-task) plugin="${ref%@*}" task="${ref#*@}" - if [[ "${plugin}" == "${ref}" || -z "${task}" ]]; then - failures+=("gate '${id}' must use plugin@task for gradle-plugin-task") + if [[ "${plugin}" == "${ref}" \ + || ! "${plugin}" =~ ^[A-Za-z][A-Za-z0-9.-]*$ \ + || ! "${task}" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]; then + failures+=("gate '${id}' has unsafe Gradle plugin task ref '${ref}'") continue fi - if ! grep -RqsE -- "(id|apply plugin:)[[:space:]]+['\"]${plugin}['\"]" "${REPO_ROOT}/src" \ - --include='build.gradle'; then + if ! gradle_plugin_is_applied "${plugin}"; then failures+=("gate '${id}' references unapplied Gradle plugin '${plugin}'") continue fi @@ -158,7 +321,7 @@ while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do case "${execution}" in check) - if ! job_body "${workflow_file}" "${job}" | grep -Eqs -- '\./gradlew[[:space:]]+check([[:space:]]|$)'; then + if ! job_runs_gradle_task "${workflow_file}" "${job}" 'check'; then failures+=("gate '${id}' expects Gradle check in job '${job}'") continue fi @@ -170,7 +333,7 @@ while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do fi ;; explicit) - if ! job_body "${workflow_file}" "${job}" | grep -Fqs -- "${ref}"; then + if ! job_runs_gradle_task "${workflow_file}" "${job}" "${ref}"; then failures+=("gate '${id}' task '${ref}' is not explicit in job '${job}'") continue fi diff --git a/.github/scripts/verify-gradle-wrapper.sh b/.github/scripts/verify-gradle-wrapper.sh new file mode 100755 index 00000000..8dd8fe7e --- /dev/null +++ b/.github/scripts/verify-gradle-wrapper.sh @@ -0,0 +1,740 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly EXPECTED_DISTRIBUTION_SUFFIX='/gradle-9.0.0-bin.zip' +readonly EXPECTED_DISTRIBUTION_SHA256='8fad3d78296ca518113f3d29016617c7f9367dc005f932bd9d93bf45ba46072b' +readonly EXPECTED_WRAPPER_JAR_SHA256='76805e32c009c0cf0dd5d206bddc9fb22ea42e84db904b764f3047de095493f3' +readonly EXPECTED_VALIDATION_ACTION='gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6' +readonly EXPECTED_DEPENDENCY_SUBMISSION_ACTION='gradle/actions/dependency-submission@748248ddd2a24f49513d8f472f81c3a07d4d50e1' +readonly EXPECTED_GUARDED_GRADLE_IF="\${{ always() && steps.gradle-wrapper-validation.outcome == 'success' }}" +# Workflow-lock update procedure (only after intentional review of the complete workflow diff): +# find .github/workflows -mindepth 1 -maxdepth 1 \ +# \( -name '*.yml' -o -name '*.yaml' \) ! -type f -print # must print nothing +# find .github/workflows -mindepth 1 -maxdepth 1 -type f \ +# \( -name '*.yml' -o -name '*.yaml' \) -print0 \ +# | LC_ALL=C sort -z | xargs -0 sha256sum +# Replace this entire sorted array in the same reviewed change. Never refresh a single digest +# merely to make this verifier pass. +readonly EXPECTED_WORKFLOW_LOCK=( + 'a5986c6d865e28d6160dc09c513c430c9d9c38d154c67423cb34448cb1e9863c .github/workflows/ci-quality-gates.yml' + '59de260a70c2c0a0d686d97035a189dc0567395977dfa18758f1a2d89d15a00d .github/workflows/dependency-vulnerability.yml' + '1b3220c922f954500f727c6a799b24e4962915845b9248e8e496e5050e829f28 .github/workflows/fileserver-nightly.yml' + '26812e16b8d6e4472543ddd49c7b16ee6b7697834ddbb653fa0424befd71c544 .github/workflows/fileserver-pr.yml' + '86a240c4ce7d0d293616e30de30ed77bcfdc700fedb8916f083eda9567099096 .github/workflows/fileserver-release.yml' + '58e28f3358d794ca08f4aa8df4516e03f50a9ee58488b3f0d2619998e069ef14 .github/workflows/httpclient-contract.yml' + '823bc346e58a58b2c0814cd1e3e55ec90d360c138419ec3d8f05deb59c62c7eb .github/workflows/httpclient-nightly.yml' + 'ad84000efc438ee7439517b8f85819e62b13dab0aa4f94066c2905060f3bb581 .github/workflows/httpclient-release.yml' + '59cb3a0ffc687a15eefe96bc5e3a70d42be78e1cc85d2e7f7880dac6124ca4c7 .github/workflows/jpa-r2-evidence.yml' + '5be7e931db749029d89787da042d6d7cf8e683d60698bd8a2993c29db26355fb .github/workflows/link-check.yml' + '64245586cd5936f1a5647b57f2cd9acd316f96fd75f713b1890decb812e7d5fe .github/workflows/object-storage-qualification.yml' + 'cbc104ea486c746229895e804e3be7716e056a02cce0588c537bce9f442f8b38 .github/workflows/redis-sdk-topology.yml' +) +readonly EXPECTED_WRAPPER_PROPERTIES=( + 'distributionBase=GRADLE_USER_HOME' + 'distributionPath=wrapper/dists' + "distributionUrl=https\://services.gradle.org/distributions${EXPECTED_DISTRIBUTION_SUFFIX}" + "distributionSha256Sum=${EXPECTED_DISTRIBUTION_SHA256}" + 'networkTimeout=10000' + 'validateDistributionUrl=true' + 'zipStoreBase=GRADLE_USER_HOME' + 'zipStorePath=wrapper/dists' +) + +fail() { + printf 'gradle-wrapper-contract: FAIL: %s\n' "$1" >&2 + exit 1 +} + +if [[ $# -ne 1 ]]; then + fail 'expected exactly one repository-root argument' +fi + +readonly REPOSITORY_ROOT=$1 +[[ -d "${REPOSITORY_ROOT}" ]] || fail "repository root is not a directory: ${REPOSITORY_ROOT}" + +readonly WRAPPER_PROPERTIES="${REPOSITORY_ROOT}/src/gradle/wrapper/gradle-wrapper.properties" +readonly WRAPPER_JAR="${REPOSITORY_ROOT}/src/gradle/wrapper/gradle-wrapper.jar" +readonly WORKFLOWS_DIRECTORY="${REPOSITORY_ROOT}/.github/workflows" + +[[ -f "${WRAPPER_PROPERTIES}" ]] || fail "missing wrapper properties: ${WRAPPER_PROPERTIES}" +[[ -f "${WRAPPER_JAR}" ]] || fail "missing wrapper JAR: ${WRAPPER_JAR}" +[[ -d "${WORKFLOWS_DIRECTORY}" ]] || fail "missing workflows directory: ${WORKFLOWS_DIRECTORY}" + +if ! printf '%s\n' "${EXPECTED_WRAPPER_PROPERTIES[@]}" | cmp -s - "${WRAPPER_PROPERTIES}"; then + fail 'wrapper properties must match the exact canonical Gradle 9.0.0 eight-line contract' +fi + +readonly actual_wrapper_jar_sha256=$(sha256sum "${WRAPPER_JAR}" | awk '{print $1}') +[[ "${actual_wrapper_jar_sha256}" == "${EXPECTED_WRAPPER_JAR_SHA256}" ]] \ + || fail "wrapper JAR SHA-256 mismatch: ${actual_wrapper_jar_sha256}" + +workflow_lock_valid=1 +actual_workflow_lock=() +while IFS= read -r -d '' locked_workflow; do + locked_workflow_relative=${locked_workflow#"${REPOSITORY_ROOT}"/} + if [[ -L "${locked_workflow}" || ! -f "${locked_workflow}" ]]; then + locked_workflow_sha256='' + else + locked_workflow_sha256=$(sha256sum -- "${locked_workflow}" | awk '{print $1}') + fi + actual_workflow_lock+=("${locked_workflow_sha256} ${locked_workflow_relative}") +done < <( + find "${WORKFLOWS_DIRECTORY}" -mindepth 1 -maxdepth 1 \ + \( -name '*.yml' -o -name '*.yaml' \) -print0 \ + | LC_ALL=C sort -z +) + +workflow_lock_entry_count=${#EXPECTED_WORKFLOW_LOCK[@]} +if ((${#actual_workflow_lock[@]} > workflow_lock_entry_count)); then + workflow_lock_entry_count=${#actual_workflow_lock[@]} +fi +for ((workflow_lock_index = 0; workflow_lock_index < workflow_lock_entry_count; workflow_lock_index++)); do + expected_workflow_lock_entry=${EXPECTED_WORKFLOW_LOCK[workflow_lock_index]-} + actual_workflow_lock_entry=${actual_workflow_lock[workflow_lock_index]-} + if [[ "${actual_workflow_lock_entry}" != "${expected_workflow_lock_entry}" ]]; then + printf 'gradle-wrapper-contract: workflow lock mismatch: expected %q; actual %q\n' \ + "${expected_workflow_lock_entry}" "${actual_workflow_lock_entry}" >&2 + workflow_lock_valid=0 + fi +done + +workflow_count=0 +gradle_job_count=0 +while IFS= read -r -d '' workflow; do + if ! awk -v workflow="${workflow#"${REPOSITORY_ROOT}"/}" ' + function reset_step(known_field) { + step_active = 0 + run_block = 0 + for (known_field in step_fields) { + delete step_fields[known_field] + } + } + + function reset_job() { + job = "" + in_steps = 0 + steps_count = 0 + reset_step() + } + + function indentation(line, first_non_space) { + if (line ~ /^ *$/) { + return length(line) + } + first_non_space = match(line, /[^ ]/) + return first_non_space - 1 + } + + function trim(value) { + sub(/^[[:space:]]+/, "", value) + sub(/[[:space:]]+$/, "", value) + return value + } + + function grammar_error(message) { + printf "%s: job %s %s\n", workflow, job == "" ? "" : job, message > "/dev/stderr" + invalid = 1 + } + + function workflow_grammar_error(message) { + printf "%s: %s\n", workflow, message > "/dev/stderr" + invalid = 1 + } + + function validate_job_shape() { + if (job != "" && steps_count != 1) { + grammar_error("must contain exactly one canonical steps block") + } + } + + function is_allowed_step_field(field) { + return field == "name" \ + || field == "id" \ + || field == "uses" \ + || field == "run" \ + || field == "if" \ + || field == "shell" \ + || field == "with" \ + || field == "env" \ + || field == "working-directory" \ + || field == "continue-on-error" \ + || field == "timeout-minutes" + } + + function validate_uses_scalar(value, first, quote, closing, index_value, suffix, action, single_quote) { + value = trim(value) + if (value == "" || index(value, "\\") != 0) { + grammar_error("has unsupported uses scalar") + return + } + + first = substr(value, 1, 1) + single_quote = sprintf("%c", 39) + if (first == "\"" || first == single_quote) { + quote = first + closing = 0 + for (index_value = 2; index_value <= length(value); index_value++) { + if (substr(value, index_value, 1) == quote) { + closing = index_value + break + } + } + if (closing == 0) { + grammar_error("has unsupported uses scalar") + return + } + suffix = substr(value, closing + 1) + if (suffix !~ /^[[:space:]]*(#.*)?$/) { + grammar_error("has unsupported uses scalar") + return + } + action = substr(value, 2, closing - 2) + if (index(action, quote) != 0) { + grammar_error("has unsupported uses scalar") + return + } + } else { + action = value + sub(/[[:space:]]+#.*$/, "", action) + action = trim(action) + if (action ~ /["'"'"'\\]/ || action ~ /^[*!&|>]/) { + grammar_error("has unsupported uses scalar") + return + } + } + + if (action !~ /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)*@[A-Za-z0-9_.\/-]+$/ \ + && action !~ /^\.\/[A-Za-z0-9_.\/-]+$/ \ + && action !~ /^docker:\/\/[^[:space:]]+$/) { + grammar_error("has unsupported uses scalar") + } + } + + function validate_run_scalar(value, first) { + value = trim(value) + if (value ~ /^(\||>)[+-]?([[:space:]]+#.*)?$/) { + run_block = 1 + return + } + first = substr(value, 1, 1) + if (value == "" || first == "\"" || first == sprintf("%c", 39) \ + || first ~ /[*&!|>]/ || index(value, "\\") != 0) { + grammar_error("has unsupported run scalar") + } + } + + function validate_step_field(content, field, value, separator) { + content = trim(content) + if (content ~ /^[{[]/) { + grammar_error("contains unsupported flow-style step syntax") + return + } + if (content ~ /^< 8) { + next + } + run_block = 0 + } + + if (raw ~ /^ *#/) { + next + } + if (raw ~ /^ steps:/ || raw ~ /^ "steps":/ \ + || substr(raw, 1, 11) == " " single_quote "steps" single_quote ":") { + if (raw != " steps:") { + grammar_error("steps container must use a canonical block sequence") + next + } + steps_count++ + if (steps_count != 1) { + grammar_error("must contain exactly one canonical steps block") + } + in_steps = 1 + reset_step() + next + } + if (in_steps != 0 && line_indent == 4) { + in_steps = 0 + reset_step() + } + + if (raw ~ /^ *<<:/) { + grammar_error("contains a forbidden merge key") + next + } + + if (in_steps != 0 && raw ~ /^ - /) { + reset_step() + step_active = 1 + content = substr(raw, 9) + validate_step_field(content) + next + } + + if (in_steps != 0 && raw ~ /^ -[[:space:]]*$/) { + grammar_error("contains unsupported empty step syntax") + next + } + + if (in_steps != 0 && step_active != 0 && line_indent == 8) { + content = substr(raw, 9) + validate_step_field(content) + next + } + + if (in_steps != 0 && line_indent == 6 && raw !~ /^ *$/) { + grammar_error("contains unsupported step-list syntax") + } + } + + END { + validate_job_shape() + if (jobs_count != 1) { + workflow_grammar_error("workflow must contain exactly one canonical jobs block") + } + if (invalid) { + exit 1 + } + } + ' "${workflow}"; then + fail "workflow structural validation failed: ${workflow#"${REPOSITORY_ROOT}"/}" + fi + + if ! grep -Fq -- './gradlew' "${workflow}" \ + && ! grep -Fq -- 'gradle/actions/dependency-submission@' "${workflow}"; then + continue + fi + ((workflow_count += 1)) + + if ! jobs_in_workflow=$( + awk -v workflow="${workflow#"${REPOSITORY_ROOT}"/}" \ + -v validation_action="${EXPECTED_VALIDATION_ACTION}" \ + -v dependency_action="${EXPECTED_DEPENDENCY_SUBMISSION_ACTION}" \ + -v guarded_gradle_if="${EXPECTED_GUARDED_GRADLE_IF}" ' + function reset_step(known_field) { + step_active = 0 + run_block = 0 + step_kind = "" + step_name = "" + step_id = "" + step_uses = "" + step_uses_action = "" + step_if = "" + step_if_present = 0 + step_continue_on_error = 0 + step_gradle = 0 + step_gradle_line = 0 + step_unsupported_gradle = 0 + step_field_count = 0 + step_name_line = 0 + step_id_line = 0 + step_uses_line = 0 + step_extra_field = "" + for (known_field in step_fields) { + delete step_fields[known_field] + delete step_field_raw[known_field] + } + } + + function reset_job() { + job = "" + checkout_line = 0 + validation_line = 0 + gradle_line = 0 + in_steps = 0 + unsupported_gradle = 0 + reset_step() + } + + function indentation(line, first_non_space) { + if (line ~ /^ *$/) { + return length(line) + } + first_non_space = match(line, /[^ ]/) + return first_non_space - 1 + } + + function has_gradle_reference(line) { + return index(line, "./gradlew") != 0 \ + || index(line, "gradle/actions/dependency-submission@") != 0 + } + + function trim(value) { + sub(/^[[:space:]]+/, "", value) + sub(/[[:space:]]+$/, "", value) + return value + } + + function normalize_action(value, scalar, first, quote, closing, index_value) { + scalar = trim(value) + first = substr(scalar, 1, 1) + if (first == "\"" || first == single_quote) { + quote = first + closing = index(substr(scalar, 2), quote) + if (closing == 0) { + return "" + } + return substr(scalar, 2, closing - 1) + } + sub(/[[:space:]]+#.*$/, "", scalar) + return trim(scalar) + } + + function record_gradle(line_number) { + step_gradle = 1 + if (step_gradle_line == 0) { + step_gradle_line = line_number + } + if (gradle_line == 0) { + gradle_line = line_number + } + } + + function record_uses(value, line_number, action) { + if (step_kind == "run") { + if (index(value, "gradle/actions/dependency-submission@") != 0) { + step_unsupported_gradle = 1 + } + return + } + step_kind = "uses" + action = normalize_action(value) + step_uses = trim(value) + step_uses_action = action + step_uses_line = line_number + if (checkout_line == 0 && action ~ /^actions\/checkout@/) { + checkout_line = line_number + } + if (action == dependency_action) { + record_gradle(line_number) + } else if (index(action, "gradle/actions/dependency-submission@") != 0) { + record_gradle(line_number) + step_unsupported_gradle = 1 + } + } + + function record_run(value, line_number) { + if (step_kind == "uses") { + if (index(value, "./gradlew") != 0) { + step_unsupported_gradle = 1 + } + return + } + step_kind = "run" + if (value ~ /^(\||>)[+-]?([[:space:]]+#.*)?$/) { + run_block = 1 + } else if (index(value, "./gradlew") != 0) { + record_gradle(line_number) + } + } + + function record_step_field(content, line_number, separator, field, value) { + separator = index(content, ":") + field = substr(content, 1, separator - 1) + value = substr(content, separator + 1) + sub(/^[[:space:]]*/, "", value) + step_fields[field] = 1 + step_field_raw[field] = trim(content) + step_field_count++ + + if (field == "name") { + step_name = trim(value) + step_name_line = line_number + } else if (field == "id") { + step_id = trim(value) + step_id_line = line_number + } else if (field == "uses") { + record_uses(value, line_number) + } else if (field == "run") { + record_run(trim(value), line_number) + } else if (field == "if") { + step_if_present = 1 + step_if = trim(value) + } else if (field == "continue-on-error") { + step_continue_on_error = 1 + } + + if (field != "name" && field != "id" && field != "uses" && step_extra_field == "") { + step_extra_field = step_field_raw[field] + } + } + + function validate_wrapper_step() { + if (step_uses_action != validation_reference) { + return + } + if (step_extra_field != "") { + printf "%s: job %s wrapper validation step contains unsupported field: %s\n", workflow, job, step_extra_field > "/dev/stderr" + invalid = 1 + return + } + if (step_field_count != 3 \ + || step_name != "Validate Gradle wrapper" \ + || step_id != "gradle-wrapper-validation" \ + || step_uses != validation_action \ + || !(step_name_line < step_id_line && step_id_line < step_uses_line)) { + printf "%s: job %s wrapper validation step must contain exact name, id, and uses fields only\n", workflow, job > "/dev/stderr" + invalid = 1 + return + } + if (validation_line == 0) { + validation_line = step_uses_line + } + } + + function validate_gradle_step() { + if (step_gradle == 0 && step_unsupported_gradle == 0) { + return + } + if (step_unsupported_gradle != 0 || ("uses" in step_fields && "run" in step_fields)) { + unsupported_gradle = 1 + } + if (step_if_present != 0 && step_if != guarded_gradle_if) { + printf "%s: job %s has Gradle step with unsupported if condition: %s\n", workflow, job, step_if > "/dev/stderr" + invalid = 1 + } + if (step_continue_on_error != 0) { + printf "%s: job %s has Gradle step with unsupported field: %s\n", workflow, job, step_field_raw["continue-on-error"] > "/dev/stderr" + invalid = 1 + } + } + + function finalize_step() { + if (step_active == 0) { + return + } + validate_wrapper_step() + validate_gradle_step() + } + + function start_step() { + finalize_step() + reset_step() + step_active = 1 + } + + function validate_job() { + finalize_step() + if (job == "" || (gradle_line == 0 && unsupported_gradle == 0)) { + return + } + gradle_jobs++ + if (unsupported_gradle != 0) { + printf "%s: job %s uses a Gradle invocation outside the canonical workflow structure\n", workflow, job > "/dev/stderr" + invalid = 1 + } + if (gradle_line == 0) { + return + } else if (checkout_line == 0) { + printf "%s: job %s invokes Gradle without checkout\n", workflow, job > "/dev/stderr" + invalid = 1 + } else if (validation_line == 0) { + printf "%s: job %s invokes Gradle without the exact pinned wrapper validation action\n", workflow, job > "/dev/stderr" + invalid = 1 + } else if (!(checkout_line < validation_line && validation_line < gradle_line)) { + printf "%s: job %s must order checkout, exact wrapper validation, then Gradle\n", workflow, job > "/dev/stderr" + invalid = 1 + } + } + + BEGIN { + in_jobs = 0 + invalid = 0 + gradle_jobs = 0 + single_quote = sprintf("%c", 39) + validation_reference = validation_action + sub(/[[:space:]]+#.*$/, "", validation_reference) + reset_job() + } + + /^jobs:[[:space:]]*(#.*)?$/ { + in_jobs = 1 + next + } + + in_jobs && /^[^[:space:]#]/ { + validate_job() + reset_job() + in_jobs = 0 + } + + in_jobs && /^ [A-Za-z0-9_.-]+:[[:space:]]*(#.*)?$/ { + validate_job() + reset_job() + job = $0 + sub(/^ /, "", job) + sub(/:.*/, "", job) + next + } + + in_jobs && job != "" { + raw = $0 + line_indent = indentation(raw) + + if (run_block != 0) { + if (raw ~ /^ *$/) { + next + } + if (line_indent > 8) { + if (index(raw, "./gradlew") != 0) { + record_gradle(NR) + } + if (index(raw, "gradle/actions/dependency-submission@") != 0) { + step_unsupported_gradle = 1 + } + next + } + run_block = 0 + } + + if (raw ~ /^ *#/) { + next + } + + if (raw == " steps:") { + in_steps = 1 + reset_step() + next + } + + if (in_steps != 0 && line_indent == 4) { + finalize_step() + in_steps = 0 + reset_step() + } + + if (in_steps != 0 && raw ~ /^ - /) { + start_step() + content = substr(raw, 9) + record_step_field(content, NR) + next + } + + if (in_steps != 0 && step_active != 0 && line_indent == 8) { + content = substr(raw, 9) + record_step_field(content, NR) + next + } + + if (has_gradle_reference(raw)) { + unsupported_gradle = 1 + } + } + + END { + validate_job() + print gradle_jobs + if (invalid) { + exit 1 + } + } + ' "${workflow}" + ); then + fail "workflow validation failed: ${workflow#"${REPOSITORY_ROOT}"/}" + fi + [[ "${jobs_in_workflow}" =~ ^[0-9]+$ ]] \ + || fail "workflow parser returned an invalid Gradle job count: ${workflow#"${REPOSITORY_ROOT}"/}" + ((jobs_in_workflow > 0)) \ + || fail "Gradle-running workflow contains no detected Gradle job: ${workflow#"${REPOSITORY_ROOT}"/}" + ((gradle_job_count += jobs_in_workflow)) +done < <(find "${WORKFLOWS_DIRECTORY}" -type f \( -name '*.yml' -o -name '*.yaml' \) -print0) + +((workflow_count > 0)) || fail 'no Gradle-running workflow was found' +((gradle_job_count > 0)) || fail 'no individual Gradle-running job was found' +((workflow_lock_valid != 0)) \ + || fail 'workflow lock mismatch: workflow set or bytes differ from the reviewed embedded manifest' + +printf 'gradle-wrapper-contract: PASS\n' diff --git a/.github/workflows/httpclient-contract.yml b/.github/workflows/httpclient-contract.yml new file mode 100644 index 00000000..ea19dbe7 --- /dev/null +++ b/.github/workflows/httpclient-contract.yml @@ -0,0 +1,132 @@ +name: httpclient-contract + +# Per-PR gate for the HTTP Client Platform (design §29). Each transport runs the same semantic +# contract in its own job, so a transport that stops satisfying it fails on its own row instead of +# disappearing into an aggregate run. + +on: + workflow_dispatch: + pull_request: + paths: + - 'src/adapter/outbound/httpclient/**' + - 'src/app-bootstrap/src/**/httpclient/**' + - 'docs/httpclient/**' + - 'scripts/verify-httpclient-docs.py' + - '.github/workflows/httpclient-contract.yml' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + httpclient-unit-and-boundaries: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Run the focused module suite and the architecture gate + working-directory: src + run: >- + ./gradlew + :adapter:outbound:httpclient:test + verifyCleanArchitectureDependencies + --no-daemon + --stacktrace + + httpclient-stable-contract: + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + transport: [apache, jdk, reactor] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Certify one transport against the shared contract + working-directory: src + run: >- + ./gradlew + :adapter:outbound:httpclient:httpClientStableContractTest + -Phttpclient.contract.transports=${{ matrix.transport }} + --no-daemon + --stacktrace + + httpclient-security-and-compatibility: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Run the SSRF, cardinality, and Spring compatibility lanes + working-directory: src + run: >- + ./gradlew + :adapter:outbound:httpclient:httpClientSecurityTest + :adapter:outbound:httpclient:httpClientBlockHoundTest + :adapter:outbound:httpclient:spring62ApiSurfaceScan + :adapter:outbound:httpclient:spring70CompatibilityTest + --no-daemon + --stacktrace + + httpclient-composition: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Verify composition and architecture in the bootstrap module + working-directory: src + run: >- + ./gradlew + :app-bootstrap:test --tests '*httpclient*' --tests '*CleanArchitectureTest' + --no-daemon + --stacktrace diff --git a/.github/workflows/httpclient-release.yml b/.github/workflows/httpclient-release.yml new file mode 100644 index 00000000..0ddfdd5a --- /dev/null +++ b/.github/workflows/httpclient-release.yml @@ -0,0 +1,70 @@ +name: httpclient-release + +# Release gate for the HTTP Client Platform (design §38 step 4). Each declared gate runs as its own +# single-line `./gradlew ` step, because .github/scripts/verify-gate-matrix.sh reads these +# commands to prove the gate is actually executed — a folded or flag-laden command would make the +# declaration in .github/ci-gate-matrix.yml unverifiable. + +on: + workflow_dispatch: + push: + tags: + - 'v*' + +permissions: + contents: read + +jobs: + release-gate: + runs-on: ubuntu-latest + timeout-minutes: 60 + defaults: + run: + working-directory: src + env: + # A project property rather than a command-line flag, so each run command stays a plain, + # verifiable task invocation while the machine-dependent bounds are still asserted. + GRADLE_OPTS: -Dorg.gradle.project.performance.assertions.enabled=true + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Focused module tests + run: ./gradlew :adapter:outbound:httpclient:test --no-daemon --stacktrace + - name: Spring 6.2 API surface lane + run: ./gradlew :adapter:outbound:httpclient:spring62ApiSurfaceScan --no-daemon --stacktrace + - name: Spring 7.0 compatibility lane + run: ./gradlew :adapter:outbound:httpclient:spring70CompatibilityTest --no-daemon --stacktrace + - name: Stable cross-transport contract suite + run: ./gradlew :adapter:outbound:httpclient:httpClientStableContractTest --no-daemon --stacktrace + - name: SSRF and cardinality suite + run: ./gradlew :adapter:outbound:httpclient:httpClientSecurityTest --no-daemon --stacktrace + - name: Event-loop blocking suite + run: ./gradlew :adapter:outbound:httpclient:httpClientBlockHoundTest --no-daemon --stacktrace + - name: Toxiproxy fault-injection suite + run: ./gradlew :adapter:outbound:httpclient:httpClientFailureInjectionTest --no-daemon --stacktrace + - name: Resource-bound performance certification + run: ./gradlew :adapter:outbound:httpclient:httpClientPerformanceTest --no-daemon --stacktrace + - name: Architecture dependency gate + run: ./gradlew verifyCleanArchitectureDependencies --no-daemon --stacktrace + + httpclient-documentation: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # actions/setup-python@v5.6.0 + with: + python-version: '3.12' + - name: Verify documentation matches the code + run: python3 scripts/verify-httpclient-docs.py diff --git a/docs/httpclient/configuration-reference.md b/docs/httpclient/configuration-reference.md new file mode 100644 index 00000000..7a7d9369 --- /dev/null +++ b/docs/httpclient/configuration-reference.md @@ -0,0 +1,228 @@ +# HTTP Client Platform — Configuration Reference + +Every outbound call resolves exactly one **Named Client Profile**. The whole capability lives under +the `app.httpclient` prefix: profiles under `app.httpclient.clients[N]`, Dynamic Target policies +under `app.httpclient.dynamic-targets[N]`. + +Design §30.1 forbids a production profile from inheriting large framework defaults. Anything a +production deployment must decide has either no default or an unusable one, and +`HttpClientStartupValidator` fails the context rather than guessing. + +## The master switch + +| Property | Type | Default | Environment | +|---|---|---|---| +| `app.httpclient.enabled` | boolean | `false` | `APP_HTTPCLIENT_ENABLED` | + +Off is the shipped state and it is a structural one. `HttpClientPlatformAutoConfiguration` lives in +a package the composition root's component scan excludes, so while the switch is absent or false the +class is never processed and neither is anything it imports: no property is bound, and no transport +provider, connection pool, TLS context, credential, thread, gateway or actuator endpoint exists. A +malformed HTTP client setting cannot fail the startup of a deployment that never wanted outbound +HTTP. + +Anything that is not exactly `true` — `yes`, `1`, blank — leaves the platform off. Turning it on +with no client declared is a startup failure carrying `HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS`: a +platform with nothing to call still holds transport providers and gateways no caller can reach. + +## Declaring clients from the environment + +Clients are an indexed list carrying their own `name`, not a map keyed by name. A map key becomes a +segment of the environment variable and the relaxed binder normalises it, so `payment-api` and +`payment_api` would arrive as one entry with nothing said about the one that was lost. Both a +duplicate name and a name that collides once normalised fail startup. + +```dotenv +APP_HTTPCLIENT_ENABLED=true + +APP_HTTPCLIENT_CLIENTS_0_NAME=payment +APP_HTTPCLIENT_CLIENTS_0_BASE_URL=https://payment.example +APP_HTTPCLIENT_CLIENTS_0_ALLOWED_HOSTS_0=payment.example +APP_HTTPCLIENT_CLIENTS_0_ALLOWED_PORTS_0=443 +APP_HTTPCLIENT_CLIENTS_0_REQUEST_MAX_BODY_BYTES=1048576 +APP_HTTPCLIENT_CLIENTS_0_TLS_PROFILE_ID=payment + +APP_HTTPCLIENT_DYNAMIC_TARGETS_0_NAME=webhook +APP_HTTPCLIENT_DYNAMIC_TARGETS_0_ALLOWED_SCHEMES_0=https +``` + +`docs/httpclient/env-fields.yaml` is the registry of accepted variable names. It is +derived from the settings record and held to it in both directions, and the platform refuses to +start on an `APP_HTTPCLIENT_` variable that is not in it — so +`APP_HTTPCLIENT_CLIENTS_0_TIMEUOT_TOTAL_CALL` fails startup instead of silently leaving the client +on its default budget. Unknown keys supplied through a configuration file rather than the +environment are refused by strict binding for the same reason. + +Only `APP_HTTPCLIENT_ENABLED` appears in `src/.env` and `docs/registries/env-keys.yaml`. It is the +one key with a deployment-independent value; templating an indexed client in `application.yml` would +materialise a nameless client in every deployment, which the aggregate validation refuses. + +## `app.httpclient.clients[N]` + +| Property | Type | Default | Notes | +|---|---|---|---| +| `name` | string | — | Required, unique, and distinct from every other name once normalised for the environment | +| `mode` | `TRUSTED` \| `DYNAMIC` | `TRUSTED` | A dynamic profile may not carry a default credential | +| `base-url` | URI | — | Required for a trusted profile; no userinfo, no query | +| `allowed-hosts` | list | empty | Required in production | +| `allowed-ports` | list | empty | Compared against the effective port | +| `api` | `REST_CLIENT` \| `WEB_CLIENT` | `REST_CLIENT` | Decides blocking or reactive runtime | +| `transport` | `APACHE` \| `JDK` \| `REACTOR_NETTY` \| `JETTY` \| `SIMPLE` | `APACHE` | `SIMPLE` is rejected in production | +| `protocols` | list | `HTTP_1_1` | The default transport is Apache, whose classic client is HTTP/1.1 only; a profile that wants HTTP/2 declares it together with a transport that can deliver it. `HTTP_3` requires the experimental acknowledgement | +| `experimental-acknowledgement` | string | — | Must equal `I_ACCEPT_HTTP3_EXPERIMENTAL_SEMANTICS` | + +### `pool` + +| Property | Default | Meaning | +|---|---|---| +| `max-total-connections` | `50` | Socket ceiling for the runtime | +| `max-connections-per-route` | `25` | Per-upstream ceiling | +| `max-pending-acquires` | `100` | Waiting-request memory ceiling | +| `pending-acquire-timeout` | `200ms` | Pool or stream wait ceiling | +| `max-idle-time` | `30s` | Idle eviction | +| `max-life-time` | `5m` | Picks up DNS, load-balancer, and certificate changes | +| `validate-after-inactivity` | `5s` | Stale and half-open detection | +| `eviction-interval` | `15s` | Background cleanup | +| `shutdown-timeout` | `5s` | Drain deadline before forced close | +| `requires-route-pool` | `false` | Set when route-scoped limits are mandatory; the JDK transport then refuses the profile | +| `requires-bounded-pending-queue` | `false` | Same, for a bounded pending queue | + +### `timeout` + +| Property | Default | Meaning | +|---|---|---| +| `dns` | `300ms` | Hostname resolution | +| `connect` | `500ms` | Socket connect | +| `tls-handshake` | `1s` | TLS and ALPN | +| `proxy-connect` | `500ms` | Proxy socket or CONNECT | +| `request-write-idle` | `1s` | No progress writing the request | +| `response-header` | `2s` | Until final response headers | +| `read-idle` | `3s` | Between response chunks | +| `total-call` | `4s` | The whole logical call, including retry backoff | +| `streaming-idle` | `30s` | Silence on a long-lived stream | + +`total-call` must not be shorter than `connect` or `response-header`; the validator emits +`INVALID_TIMEOUT_BUDGET` otherwise. + +### `redirect`, `request`, `response` + +| Property | Default | Meaning | +|---|---|---| +| `redirect.enabled` | `false` | Engine redirect handling is always off; the platform follows hops itself | +| `redirect.max-hops` | `0` | Enabling redirects with zero hops is a configuration error | +| `redirect.allow-cross-origin` | `false` | When enabled, credentials are stripped on the hop | +| `request.max-body-bytes` | `0` | Required in production | +| `request.compression` | `false` | | +| `response.max-wire-bytes` | `5242880` | Bytes on the wire | +| `response.max-decoded-bytes` | `10485760` | Bytes after decoding; hard maximum is 64 MiB | +| `response.allowed-content-types` | JSON + problem+json | Empty means "any" | + +### `authentication` + +| Property | Default | Meaning | +|---|---|---| +| `type` | `NONE` | One of the design §20.1 methods | +| `registration-id` | — | Required for OAuth2 | +| `scopes` | empty | Part of the token cache key | +| `audience` | — | Part of the token cache key | +| `header-name` | — | Required for `API_KEY_HEADER`; must be on the allowlist | +| `secret-reference` | — | Resolved by the deployment's secret loader, never a literal | + +### `retry` + +| Property | Default | Meaning | +|---|---|---| +| `policy` | `none` | Named policy for reporting | +| `max-attempts` | `1` | Attempts, not retries | +| `base-backoff` | `50ms` | | +| `max-backoff` | `200ms` | | +| `jitter` | `FULL` | `NONE` \| `FULL` \| `DECORRELATED` | +| `retry-after` | `HONOR` | `HONOR` \| `IGNORE` \| `CAP` | +| `budget` | — | Shared token bucket name | + +### `tls` + +| Property | Default | Meaning | +|---|---|---| +| `profile-id` | — | Required in production; the only TLS identifier the actuator exposes | +| `protocols` | `TLSv1.3, TLSv1.2` | Anything else is rejected | +| `hostname-verification` | `true` | Setting it false fails startup | +| `trust-all` | `false` | Exists only so the unsafe intent is rejectable; nothing acts on `true` | +| `allow-plain-http` | `false` | Plaintext fallback fails startup in production | +| `trust-material-reference` | — | Custom CA, resolved by the secret loader | +| `key-material-reference` | — | Client certificate for mTLS | + +### `proxy` and `observability` + +| Property | Default | Meaning | +|---|---|---| +| `proxy.enabled` | `false` | | +| `proxy.host` / `proxy.port` / `proxy.type` | — / `0` / `HTTP` | | +| `proxy.credential-provider` | — | Proxy authentication is separate from target authentication | +| `proxy.connect-timeout` | `500ms` | Recorded as its own metric | +| `proxy.import-ambient-no-proxy` | `false` | Ambient `NO_PROXY` never widens a validated profile | +| `observability.operation-name-required` | `true` | | +| `observability.full-url-recording` | `false` | | +| `observability.body-logging` | `false` | | + +## `app.httpclient.dynamic-targets[N]` + +| Property | Default | Meaning | +|---|---|---| +| `name` | — | Required, unique, and subject to the same normalisation rule as a client name | +| `allowed-schemes` | `https` | | +| `allowed-ports` | `443` | | +| `allowed-host-suffixes` | empty | | +| `allowed-hosts` | empty | Empty means "any host that survives address validation" | +| `max-redirect-hops` | `0` | Each hop repeats the full validation flow | +| `trace-propagation` | `false` | Off by default for dynamic targets | +| `blocked-cidrs` | empty | Organisation-defined internal ranges | + +## Startup violation codes + +`TRUSTED_BASE_URL_REQUIRED`, `BASE_URL_USERINFO_FORBIDDEN`, `BASE_URL_QUERY_FORBIDDEN`, +`PLAINTEXT_PRODUCTION_TARGET`, `ALLOWED_HOST_MISMATCH`, `ALLOWED_PORT_MISMATCH`, +`REDIRECT_POLICY_INVALID`, `REDIRECT_CROSS_ORIGIN_CREDENTIAL_POLICY_REQUIRED`, +`INVALID_TIMEOUT_BUDGET`, `RESPONSE_HARD_MAXIMUM_EXCEEDED`, `PRODUCTION_SIMPLE_FACTORY_FORBIDDEN`, +`JDK_FINE_GRAINED_POOL_UNSUPPORTED`, `HTTP3_STABLE_FORBIDDEN`, +`DYNAMIC_TARGET_TRANSPORT_UNSUPPORTED`, `DYNAMIC_DEFAULT_CREDENTIAL_FORBIDDEN`, +`OAUTH2_REGISTRATION_REQUIRED`, `API_KEY_HEADER_NAME_REQUIRED`, `TRUST_ALL_FORBIDDEN`, +`HOSTNAME_VERIFICATION_REQUIRED`, `PLAINTEXT_FALLBACK_FORBIDDEN`, `TLS_PROTOCOL_FORBIDDEN`, +`RETRY_BACKOFF_REQUIRED`, `MISSING_PRODUCTION_SETTING`, `DUPLICATE_CLIENT_NAME`, +`HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS`, `DYNAMIC_BASE_URL_REQUIRED`, +`DYNAMIC_TARGET_PROXY_UNSUPPORTED`, `REACTIVE_AUTHENTICATION_UNSUPPORTED`, +`HTTP2_REQUIRED_TRANSPORT_UNSUPPORTED`, `POOL_ROUTE_EXCEEDS_TOTAL`, +`TLS_PROTOCOL_SET_REQUIRED`, `REACTIVE_REDIRECT_UNSUPPORTED`, +`RETRY_POLICY_CONTRADICTS_ATTEMPTS`, `FULL_URL_RECORDING_FORBIDDEN`, `BODY_LOGGING_FORBIDDEN`, +`DNS_TIMEOUT_UNSUPPORTED`, `PROXY_CREDENTIAL_UNSUPPORTED`, `PROXY_AMBIENT_NO_PROXY_UNSUPPORTED`. + +The last three name settings the platform binds but cannot yet honour. Neither the Apache classic +client nor the JDK client exposes a DNS-resolution timeout, and no proxy-credential path exists, so +a non-default value is refused rather than accepted and ignored. Leaving the defaults alone is +unaffected — only a deliberate, unmet request fails. + +Three of these are about a guarantee that used to be silently unmet rather than refused: + +- `HTTP2_REQUIRED_TRANSPORT_UNSUPPORTED` — declaring `protocols: [HTTP_2]` alone states that HTTP/2 + is required. Only `REACTOR_NETTY` can be configured to offer H2 and nothing else; the JDK client + treats it as a preference and negotiates HTTP/1.1, and Apache's classic client is HTTP/1.1 only. +- `POOL_ROUTE_EXCEEDS_TOTAL` — a per-route ceiling above the total is incoherent, and on Reactor, + where the per-route knob is the only one that exists, it silently becomes the effective limit. +- `TLS_PROTOCOL_SET_REQUIRED` — an empty `tls.protocols` used to pass and then let the JVM choose, + so emptying the list to "tighten" a profile loosened it. +- `REACTIVE_REDIRECT_UNSUPPORTED` — engine redirect following is disabled on every transport and + only the blocking stack has a coordinator that follows hops with per-hop re-validation. A + `WEB_CLIENT` profile with `redirect.enabled=true` did not follow redirects; the caller received the + 3xx as an ordinary response. Refused until the reactive coordinator exists. +- `RETRY_POLICY_CONTRADICTS_ATTEMPTS` — `retry.policy` was read by nothing on the execution path, so + the actuator could report `none` for a profile retrying three times. The two settings must now + agree: `policy: none` requires `max-attempts: 1`, and any other policy requires more than one. +- `FULL_URL_RECORDING_FORBIDDEN` / `BODY_LOGGING_FORBIDDEN` — both settings were bindable and inert. + Recording an expanded URL puts path identifiers and query strings into unbounded metric tags; + recording bodies puts someone else's data into logs. Representable so the intent is rejectable, + refused under a production profile. + +`DYNAMIC_TARGET_PROXY_UNSUPPORTED` is worth spelling out: a forward proxy resolves the hostname on +its own side, so the addresses this platform validated and pinned are not the addresses the +connection reaches. The SSRF defence would be present, correct, and bypassed — so the combination is +refused rather than served with a guarantee it cannot keep. diff --git a/docs/httpclient/env-fields.yaml b/docs/httpclient/env-fields.yaml new file mode 100644 index 00000000..9ee4de01 --- /dev/null +++ b/docs/httpclient/env-fields.yaml @@ -0,0 +1,179 @@ +# HTTP Client platform — Java field path to environment variable template. +# +# The SSOT is HttpClientPlatformSettings. HttpClientEnvironmentKeys derives this list from the +# record tree at runtime, HttpClientPlatformEnvManifestTest fails when the two disagree in either +# direction, and the platform refuses to start on an APP_HTTPCLIENT_ variable that is not here. So a +# field added with no entry, an entry whose field was renamed, and a misspelled variable in a +# deployment are all failures rather than silence. +# +# `N` and `M` are list indices, not literals: `N` for the outermost list, `M` for a list inside it. +# `app.httpclient.clients[N].base-url` is set as APP_HTTPCLIENT_CLIENTS_0_BASE_URL for the first +# client, and `clients[N].allowed-hosts[M]` as APP_HTTPCLIENT_CLIENTS_0_ALLOWED_HOSTS_0. +# +# Only APP_HTTPCLIENT_ENABLED is registered in docs/registries/env-keys.yaml and shipped in +# src/.env: it is the only key with a deployment-independent value, and it is the only one the +# three-way verifyEnvKeys gate can express. Everything below is per deployment and is set directly +# in the environment — templating an indexed client in application.yml would materialise a nameless +# client in every deployment, which the settings' aggregate validation refuses. +# +# This file lives beside the HTTP Client documentation rather than in docs/registries, which is a +# fail-closed catalog of exactly eight contract registries with a fixed row schema +# (owner_branch/compatibility_impact/required_test per row). A field-to-variable mapping does not +# have that shape, and admitting it would have meant loosening a gate rather than satisfying one. +# +# Secrets are referenced, never carried: authentication.secret-reference, tls.*-material-reference +# and proxy.credential-provider name material that a secret backend resolves. Putting the material +# itself in one of these variables defeats the indirection they exist for. +fields: + - field: enabled + env: APP_HTTPCLIENT_ENABLED + - field: clients[N].name + env: APP_HTTPCLIENT_CLIENTS_N_NAME + - field: clients[N].mode + env: APP_HTTPCLIENT_CLIENTS_N_MODE + - field: clients[N].base-url + env: APP_HTTPCLIENT_CLIENTS_N_BASE_URL + - field: clients[N].allowed-hosts[M] + env: APP_HTTPCLIENT_CLIENTS_N_ALLOWED_HOSTS_M + - field: clients[N].allowed-ports[M] + env: APP_HTTPCLIENT_CLIENTS_N_ALLOWED_PORTS_M + - field: clients[N].api + env: APP_HTTPCLIENT_CLIENTS_N_API + - field: clients[N].transport + env: APP_HTTPCLIENT_CLIENTS_N_TRANSPORT + - field: clients[N].protocols[M] + env: APP_HTTPCLIENT_CLIENTS_N_PROTOCOLS_M + - field: clients[N].pool.max-total-connections + env: APP_HTTPCLIENT_CLIENTS_N_POOL_MAX_TOTAL_CONNECTIONS + - field: clients[N].pool.max-connections-per-route + env: APP_HTTPCLIENT_CLIENTS_N_POOL_MAX_CONNECTIONS_PER_ROUTE + - field: clients[N].pool.max-pending-acquires + env: APP_HTTPCLIENT_CLIENTS_N_POOL_MAX_PENDING_ACQUIRES + - field: clients[N].pool.pending-acquire-timeout + env: APP_HTTPCLIENT_CLIENTS_N_POOL_PENDING_ACQUIRE_TIMEOUT + - field: clients[N].pool.max-idle-time + env: APP_HTTPCLIENT_CLIENTS_N_POOL_MAX_IDLE_TIME + - field: clients[N].pool.max-life-time + env: APP_HTTPCLIENT_CLIENTS_N_POOL_MAX_LIFE_TIME + - field: clients[N].pool.validate-after-inactivity + env: APP_HTTPCLIENT_CLIENTS_N_POOL_VALIDATE_AFTER_INACTIVITY + - field: clients[N].pool.eviction-interval + env: APP_HTTPCLIENT_CLIENTS_N_POOL_EVICTION_INTERVAL + - field: clients[N].pool.shutdown-timeout + env: APP_HTTPCLIENT_CLIENTS_N_POOL_SHUTDOWN_TIMEOUT + - field: clients[N].pool.requires-route-pool + env: APP_HTTPCLIENT_CLIENTS_N_POOL_REQUIRES_ROUTE_POOL + - field: clients[N].pool.requires-bounded-pending-queue + env: APP_HTTPCLIENT_CLIENTS_N_POOL_REQUIRES_BOUNDED_PENDING_QUEUE + - field: clients[N].timeout.dns + env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_DNS + - field: clients[N].timeout.connect + env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_CONNECT + - field: clients[N].timeout.tls-handshake + env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_TLS_HANDSHAKE + - field: clients[N].timeout.proxy-connect + env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_PROXY_CONNECT + - field: clients[N].timeout.request-write-idle + env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_REQUEST_WRITE_IDLE + - field: clients[N].timeout.response-header + env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_RESPONSE_HEADER + - field: clients[N].timeout.read-idle + env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_READ_IDLE + - field: clients[N].timeout.total-call + env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_TOTAL_CALL + - field: clients[N].timeout.streaming-idle + env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_STREAMING_IDLE + - field: clients[N].redirect.enabled + env: APP_HTTPCLIENT_CLIENTS_N_REDIRECT_ENABLED + - field: clients[N].redirect.max-hops + env: APP_HTTPCLIENT_CLIENTS_N_REDIRECT_MAX_HOPS + - field: clients[N].redirect.allow-cross-origin + env: APP_HTTPCLIENT_CLIENTS_N_REDIRECT_ALLOW_CROSS_ORIGIN + - field: clients[N].request.max-body-bytes + env: APP_HTTPCLIENT_CLIENTS_N_REQUEST_MAX_BODY_BYTES + - field: clients[N].request.compression + env: APP_HTTPCLIENT_CLIENTS_N_REQUEST_COMPRESSION + - field: clients[N].response.max-wire-bytes + env: APP_HTTPCLIENT_CLIENTS_N_RESPONSE_MAX_WIRE_BYTES + - field: clients[N].response.max-decoded-bytes + env: APP_HTTPCLIENT_CLIENTS_N_RESPONSE_MAX_DECODED_BYTES + - field: clients[N].response.allowed-content-types[M] + env: APP_HTTPCLIENT_CLIENTS_N_RESPONSE_ALLOWED_CONTENT_TYPES_M + - field: clients[N].authentication.type + env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_TYPE + - field: clients[N].authentication.registration-id + env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_REGISTRATION_ID + - field: clients[N].authentication.scopes[M] + env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_SCOPES_M + - field: clients[N].authentication.audience + env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_AUDIENCE + - field: clients[N].authentication.header-name + env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_HEADER_NAME + - field: clients[N].authentication.secret-reference + env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_SECRET_REFERENCE + - field: clients[N].retry.policy + env: APP_HTTPCLIENT_CLIENTS_N_RETRY_POLICY + - field: clients[N].retry.max-attempts + env: APP_HTTPCLIENT_CLIENTS_N_RETRY_MAX_ATTEMPTS + - field: clients[N].retry.base-backoff + env: APP_HTTPCLIENT_CLIENTS_N_RETRY_BASE_BACKOFF + - field: clients[N].retry.max-backoff + env: APP_HTTPCLIENT_CLIENTS_N_RETRY_MAX_BACKOFF + - field: clients[N].retry.jitter + env: APP_HTTPCLIENT_CLIENTS_N_RETRY_JITTER + - field: clients[N].retry.retry-after + env: APP_HTTPCLIENT_CLIENTS_N_RETRY_RETRY_AFTER + - field: clients[N].retry.budget + env: APP_HTTPCLIENT_CLIENTS_N_RETRY_BUDGET + - field: clients[N].observability.operation-name-required + env: APP_HTTPCLIENT_CLIENTS_N_OBSERVABILITY_OPERATION_NAME_REQUIRED + - field: clients[N].observability.full-url-recording + env: APP_HTTPCLIENT_CLIENTS_N_OBSERVABILITY_FULL_URL_RECORDING + - field: clients[N].observability.body-logging + env: APP_HTTPCLIENT_CLIENTS_N_OBSERVABILITY_BODY_LOGGING + - field: clients[N].tls.profile-id + env: APP_HTTPCLIENT_CLIENTS_N_TLS_PROFILE_ID + - field: clients[N].tls.protocols[M] + env: APP_HTTPCLIENT_CLIENTS_N_TLS_PROTOCOLS_M + - field: clients[N].tls.hostname-verification + env: APP_HTTPCLIENT_CLIENTS_N_TLS_HOSTNAME_VERIFICATION + - field: clients[N].tls.trust-all + env: APP_HTTPCLIENT_CLIENTS_N_TLS_TRUST_ALL + - field: clients[N].tls.allow-plain-http + env: APP_HTTPCLIENT_CLIENTS_N_TLS_ALLOW_PLAIN_HTTP + - field: clients[N].tls.trust-material-reference + env: APP_HTTPCLIENT_CLIENTS_N_TLS_TRUST_MATERIAL_REFERENCE + - field: clients[N].tls.key-material-reference + env: APP_HTTPCLIENT_CLIENTS_N_TLS_KEY_MATERIAL_REFERENCE + - field: clients[N].proxy.enabled + env: APP_HTTPCLIENT_CLIENTS_N_PROXY_ENABLED + - field: clients[N].proxy.host + env: APP_HTTPCLIENT_CLIENTS_N_PROXY_HOST + - field: clients[N].proxy.port + env: APP_HTTPCLIENT_CLIENTS_N_PROXY_PORT + - field: clients[N].proxy.type + env: APP_HTTPCLIENT_CLIENTS_N_PROXY_TYPE + - field: clients[N].proxy.credential-provider + env: APP_HTTPCLIENT_CLIENTS_N_PROXY_CREDENTIAL_PROVIDER + - field: clients[N].proxy.connect-timeout + env: APP_HTTPCLIENT_CLIENTS_N_PROXY_CONNECT_TIMEOUT + - field: clients[N].proxy.import-ambient-no-proxy + env: APP_HTTPCLIENT_CLIENTS_N_PROXY_IMPORT_AMBIENT_NO_PROXY + - field: clients[N].experimental-acknowledgement + env: APP_HTTPCLIENT_CLIENTS_N_EXPERIMENTAL_ACKNOWLEDGEMENT + - field: dynamic-targets[N].name + env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_NAME + - field: dynamic-targets[N].allowed-schemes[M] + env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_ALLOWED_SCHEMES_M + - field: dynamic-targets[N].allowed-ports[M] + env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_ALLOWED_PORTS_M + - field: dynamic-targets[N].allowed-host-suffixes[M] + env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_ALLOWED_HOST_SUFFIXES_M + - field: dynamic-targets[N].allowed-hosts[M] + env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_ALLOWED_HOSTS_M + - field: dynamic-targets[N].max-redirect-hops + env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_MAX_REDIRECT_HOPS + - field: dynamic-targets[N].trace-propagation + env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_TRACE_PROPAGATION + - field: dynamic-targets[N].blocked-cidrs[M] + env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_BLOCKED_CIDRS_M diff --git a/docs/httpclient/migration-guide.md b/docs/httpclient/migration-guide.md new file mode 100644 index 00000000..d4693ba5 --- /dev/null +++ b/docs/httpclient/migration-guide.md @@ -0,0 +1,64 @@ +# Migrating from `RestTemplate` + +`RestTemplate` is permitted only inside `…httpclient.migration`; `RestTemplateBoundaryTest` enforces +that. New retry, Dynamic Target, and HTTP/3 capabilities are deliberately unreachable from the +migration path — a caller that wants them moves to a Named Client Profile. + +## 1. Audit before changing anything + +```java +RestTemplateInventory inventory = new RestTemplateInventoryScanner().scan(existingTemplate); +``` + +The inventory reports the request factory, message converters, interceptors, error handler, and URI +template handler, plus findings: + +| Code | Severity | Meaning | +|---|---|---| +| `SIMPLE_REQUEST_FACTORY` | blocking | no connection pool; unsupported in production | +| `NO_MESSAGE_CONVERTERS` | blocking | the template cannot encode or decode a body | +| `NO_INTERCEPTORS` | warning | confirm where correlation and timeouts are applied | +| `TIMEOUTS_NOT_INTROSPECTABLE` | informational | declare timeouts explicitly on the target profile | + +## 2. Bridge without changing behaviour + +```java +RestClient client = new RestTemplateToRestClientAdapter().adaptChecked(existingTemplate); +``` + +`adaptChecked` refuses to migrate a template with a blocking finding. The bridge carries the +existing converters, interceptors, error handler, and URI handler across, so this step changes the +API and nothing else. + +## 3. Move to a Named Client Profile + +Turn the platform on with `APP_HTTPCLIENT_ENABLED=true` — it ships off, and while it is off none of +the settings below are bound — then declare the upstream as `app.httpclient.clients[N]` with its +`name` and an explicit base URL, transport, timeouts, pool, body limits, authentication, retry +policy, redirect policy, and TLS profile. Startup validation will tell you exactly which of those is +missing. See `docs/httpclient/configuration-reference.md` for the environment form. + +## 4. Move to a typed client + +```java +@HttpClientProfile("payment") +@HttpExchange("/payments") +public interface PaymentClient { + + @PostExchange + @HttpOperationPolicy( + name = "create-payment", + idempotency = OperationIdempotency.IDEMPOTENCY_KEY_REQUIRED, + retryPolicy = "payment-write") + PaymentResponse create( + @RequestHeader("Idempotency-Key") String idempotencyKey, @RequestBody PaymentRequest request); +} +``` + +The interface fails startup validation unless it declares a profile, gives every method a stable +operation name and an explicit idempotency, supplies a key parameter when the operation requires +one, keeps a single execution model, and does not enable retry on a non-idempotent write. + +## 5. Retire the template + +Once no production package references `RestTemplate`, `RestTemplateBoundaryTest` keeps it that way. diff --git a/docs/httpclient/repository-adaptation.md b/docs/httpclient/repository-adaptation.md new file mode 100644 index 00000000..4cd9064b --- /dev/null +++ b/docs/httpclient/repository-adaptation.md @@ -0,0 +1,87 @@ +# HTTP Client Platform — Repository Adaptation Contract + +**Design source:** `httpclient-superpowers-package/docs/superpowers/specs/2026-08-08-httpclient-platform-design.md` +**Plan source:** `httpclient-superpowers-package/docs/superpowers/plans/2026-08-08-httpclient-platform-implementation-plan.md` + +The design package states its own adaptation rule: + +> 실제 Backend Skeleton 저장소가 제공되지 않았으므로 package 경로와 Gradle 구조는 설계서의 명시적 +> 구현 가정이다. 구현 전 저장소의 기존 convention과 root package에 맞춰 경로만 조정하고 공개 계약과 +> 정책 의미론은 유지한다. + +This file is the single record of *how* the design's assumed layout was mapped onto this repository. +Only paths, build DSL, and composition-root ownership changed. Public contracts, policy order, and +error semantics are implemented exactly as specified. + +## 1. Why the module layout differs + +The design assumes a greenfield library with 19 Gradle projects under `modules/httpclient/`. +This repository is a Clean Architecture template whose **fail-closed registry** +(`src/config/architecture/modules.json`, enforced by `src/settings.gradle` and +`verifyCleanArchitectureDependencies`) declares **exactly 19 leaf identities**. Creating 19 more +Gradle projects would violate HARD-STOP #5 in `AGENTS.md`. + +Therefore the design's 19 library modules become **package boundaries inside the registered leaf** +`:adapter:outbound:httpclient`, with two exceptions driven by this repository's own rules: + +| Design module | Repository home | Reason | +|---|---|---| +| `httpclient-spring-boot-starter` | `:app-bootstrap` (`dev.caskeleton.bootstrap.autoconfigure.httpclient`) | This repository's composition root owns wiring and canonical activation; an adapter leaf must not auto-configure itself. | +| `httpclient-testkit` | `:adapter:outbound:httpclient` `src/test/java/**/testkit` | The design forbids production modules depending on the testkit; a test source set gives the same guarantee without a new Gradle project. | + +The package boundary is enforced by ArchUnit rules (`PublicApiArchitectureTest`, +`HttpClientModuleBoundaryTest`) that reproduce the design's module dependency table. + +## 2. Package mapping + +Root package: `io.backend.skeleton.httpclient` → `dev.caskeleton.adapter.outbound.httpclient`. + +| Design module | Design package | Repository package | +|---|---|---| +| `httpclient-core-api` | `…httpclient.api` (+ `.body`, `.error`, `.operation`, `.result`) | `dev.caskeleton.adapter.outbound.httpclient.api` (+ same subpackages) | +| `httpclient-profile` | `…httpclient.profile` | `…outbound.httpclient.profile` | +| `httpclient-transport-spi` | `…httpclient.transport` | `…outbound.httpclient.transport` | +| `httpclient-transport-apache` | `…httpclient.apache` | `…outbound.httpclient.apache` | +| `httpclient-transport-jdk` | `…httpclient.jdk` | `…outbound.httpclient.jdk` | +| `httpclient-restclient` | `…httpclient.restclient` | `…outbound.httpclient.restclient` | +| `httpclient-resilience` | `…httpclient.resilience` | `…outbound.httpclient.resilience` | +| `httpclient-auth` | `…httpclient.auth` | `…outbound.httpclient.auth` | +| `httpclient-security` | `…httpclient.security` | `…outbound.httpclient.security` | +| `httpclient-observability` | `…httpclient.observation` | `…outbound.httpclient.observation` | +| `httpclient-transport-reactor-netty` | `…httpclient.reactor` | `…outbound.httpclient.reactor` | +| `httpclient-webclient` | `…httpclient.webclient` | `…outbound.httpclient.webclient` | +| `httpclient-service-client` | `…httpclient.service` | `…outbound.httpclient.service` | +| `httpclient-dynamic-target` | `…httpclient.dynamic` | `…outbound.httpclient.dynamic` | +| `httpclient-resttemplate-migration` | `…httpclient.migration` | `…outbound.httpclient.migration` | +| `httpclient-spring7-service-groups` | `…httpclient.spring7` | `…outbound.httpclient.spring7` | +| `httpclient-jetty-http3-experimental` | `…httpclient.http3` | `…outbound.httpclient.http3` | +| `httpclient-spring-boot-starter` | `…httpclient.autoconfigure` | `dev.caskeleton.bootstrap.autoconfigure.httpclient` | +| `httpclient-testkit` | `…httpclient.testkit` | `…outbound.httpclient.testkit` (test source set) | + +## 3. Other deliberate substitutions + +| Design assumption | Repository reality | Adaptation | +|---|---|---| +| Gradle Kotlin DSL, `build-logic` convention plugin | Groovy DSL, root `build.gradle` conventions, `LockMode.STRICT` dependency locking | Dependencies declared in `src/adapter/outbound/httpclient/build.gradle`; `gradle.lockfile` regenerated. | +| Spring Framework 6.2 baseline with 7.0 compatibility | Spring Boot 4.0.0 / Spring Framework 7.0 is the repository baseline | Common code targets the Spring 6.2 **API surface** (no 6.2-only or 7.0-only classes in common packages). The Spring 7 HTTP Service Group integration stays isolated in `…httpclient.spring7`, exactly as the design requires. | +| `settings.gradle.kts` module registration | Fail-closed registry | No registry change; leaf identity, gradle path, allowed dependencies unchanged. | +| Design §6.2 grades Apache HttpClient 5 as HTTP/2-capable | Spring's blocking factory drives Apache's **classic** client, which is HTTP/1.1 only; HTTP/2 lives in Apache's async client | `ApacheBlockingTransportProvider` declares HTTP/1.1 and rejects an HTTP/2 profile at startup. Blocking HTTP/2 is served by the JDK transport, measured by `NegotiatedProtocolContractTest`. | +| Design §28.1 names WireMock for stateful fixtures | WireMock's Jetty modules bind a different Jetty 12 ABI than the Boot-managed one this module already needs for HTTP/3, and fail at server start | `StatefulUpstream` provides path-keyed stateful responses on the existing fixture server; the WireMock dependency was removed rather than worked around with a shaded jar | +| Per-task `git commit` | `AGENTS.md`: commit policy is `human-only` | Implementation is delivered unstaged; commits are the human's action. This is the only plan step intentionally not executed, and it is recorded here. | +| `docs/httpclient/**`, `.github/workflows/httpclient-*.yml`, `scripts/verify-httpclient-docs.py` | Repository already owns `docs/` and `.github/workflows/` | Created at the same repository-relative paths. | + +## 4. What is unchanged from the design + +- H1 / H2 / H3 / H4 exposure rules and the forbidden native-engine signatures. +- `ExecutionEvidence`, `BodyReplayability`, `OperationIdempotency`, `AttemptStage`, `FailureCategory`. +- `HttpOperation`, `HttpCallResult`, `BodySource`, `ResponseType`, `BlockingStreamingResponse`. +- The complete stable exception hierarchy and `HttpFailureMetadata` redaction rules. +- Named Client Profile schema, startup validation codes, and operation override direction. +- Effective deadline formula, attempt budget, and streaming setup/idle split. +- Retry eligibility inputs, the ordered decision table, retry budget, and backoff rules. +- Circuit → Rate Limiter → Bulkhead attempt order and logical admission placement. +- OAuth2 cache key, single-flight refresh, and the 401 replay-at-most-once rule. +- TLS allow/forbid lists and permanent-failure classification. +- Dynamic Target canonicalization → all-answer DNS validation → pinning → redirect revalidation. +- Low-cardinality tag allowlist, forbidden labels, trace and logging rules. +- Runtime generation swap and drain semantics. diff --git a/docs/registries/env-keys.yaml b/docs/registries/env-keys.yaml index 25131cb2..17978fdf 100644 --- a/docs/registries/env-keys.yaml +++ b/docs/registries/env-keys.yaml @@ -985,6 +985,11 @@ env_keys: required_test: redis-session-contract:csrf-enabled - name: APP_SESSION_REDIS_NAMESPACE_ENVIRONMENT + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: string default: local allowed_values: null @@ -997,6 +1002,11 @@ env_keys: required_test: redis-session-contract:key-namespace - name: APP_SESSION_IDLE_TIMEOUT + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: duration default: 30m allowed_values: null @@ -1009,6 +1019,11 @@ env_keys: required_test: redis-session-contract:idle-expiry - name: APP_SESSION_ABSOLUTE_LIFETIME + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: duration default: 8h allowed_values: null @@ -1021,6 +1036,11 @@ env_keys: required_test: redis-session-contract:absolute-expiry - name: APP_SESSION_TOUCH_INTERVAL + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: duration default: 1m allowed_values: null @@ -1033,6 +1053,11 @@ env_keys: required_test: redis-session-contract:bounded-touch - name: APP_SESSION_TOMBSTONE_TTL + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: duration default: 5m allowed_values: null @@ -1045,6 +1070,11 @@ env_keys: required_test: redis-session-contract:logout-tombstone - name: APP_SESSION_MAXIMUM_ENVELOPE_BYTES + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: int default: 32768 allowed_values: null @@ -1057,6 +1087,11 @@ env_keys: required_test: redis-session-contract:serializer-bounded - name: APP_SESSION_MAXIMUM_ATTRIBUTES + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: int default: 64 allowed_values: null @@ -1069,6 +1104,11 @@ env_keys: required_test: redis-session-contract:serializer-bounded - name: APP_SESSION_MAXIMUM_SCALAR_BYTES + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: int default: 8192 allowed_values: null @@ -1197,6 +1237,10 @@ env_keys: required_test: rate-limit-contract:client-ip-mode - name: APP_RATE_LIMIT_REDIS_ENABLED + # DEPRECATED 2026-08-10: the rate limiter no longer owns a Redis client of its own. One client, built by RedisSdkAutoConfiguration, serves every capability, so a second endpoint and a second set of ceilings could only ever disagree with it. + # Replaced by: app.redis.enabled (APP_REDIS_ENABLED). + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: boolean default: false allowed_values: [true, false] @@ -1293,6 +1337,10 @@ env_keys: required_test: rate-limit-contract:key-version-bounded - name: APP_RATE_LIMIT_REDIS_HOST + # DEPRECATED 2026-08-10: the rate limiter no longer owns a Redis client of its own. One client, built by RedisSdkAutoConfiguration, serves every capability, so a second endpoint and a second set of ceilings could only ever disagree with it. + # Replaced by: app.redis.nodes (APP_REDIS_NODES). + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: string default: null allowed_values: null @@ -1305,6 +1353,10 @@ env_keys: required_test: rate-limit-contract:redis-host-required - name: APP_RATE_LIMIT_REDIS_PORT + # DEPRECATED 2026-08-10: the rate limiter no longer owns a Redis client of its own. One client, built by RedisSdkAutoConfiguration, serves every capability, so a second endpoint and a second set of ceilings could only ever disagree with it. + # Replaced by: app.redis.nodes (APP_REDIS_NODES). + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: int default: 6379 allowed_values: null @@ -1329,6 +1381,10 @@ env_keys: required_test: rate-limit-contract:redis-password-no-leak - name: APP_RATE_LIMIT_REDIS_TRUST_PEM + # DEPRECATED 2026-08-10: the rate limiter no longer owns a Redis client of its own. One client, built by RedisSdkAutoConfiguration, serves every capability, so a second endpoint and a second set of ceilings could only ever disagree with it. + # Replaced by: app.redis.tls.trust-material-resource. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: string default: null allowed_values: null @@ -1365,6 +1421,10 @@ env_keys: required_test: redis-contract:session-password-no-leak - name: APP_SESSION_REDIS_TRUST_PEM + # DEPRECATED 2026-08-10: the session generation that bound this was removed, and the replacement does not own a Redis client of its own either. + # Replaced by: app.redis.tls.trust-material-resource. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: string default: null allowed_values: null @@ -1377,6 +1437,11 @@ env_keys: required_test: redis-contract:session-trust-material-no-leak - name: APP_SESSION_REDIS_KEY_HMAC_SECRET + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: string default: null allowed_values: null @@ -1401,6 +1466,10 @@ env_keys: required_test: rate-limit-contract:redis-timeout-bounded - name: APP_RATE_LIMIT_REDIS_MAXIMUM_COMMAND_BYTES + # DEPRECATED 2026-08-10: the rate limiter no longer owns a Redis client of its own. One client, built by RedisSdkAutoConfiguration, serves every capability, so a second endpoint and a second set of ceilings could only ever disagree with it. + # Replaced by: app.redis.limits.max-batch-request-bytes. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: int default: 16384 allowed_values: null @@ -1413,6 +1482,10 @@ env_keys: required_test: rate-limit-contract:redis-command-bytes-bounded - name: APP_RATE_LIMIT_REDIS_MAXIMUM_QUEUED_COMMANDS + # DEPRECATED 2026-08-10: the rate limiter no longer owns a Redis client of its own. One client, built by RedisSdkAutoConfiguration, serves every capability, so a second endpoint and a second set of ceilings could only ever disagree with it. + # Replaced by: app.redis.capacity.maximum-in-flight-commands. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: int default: 32 allowed_values: null @@ -1425,6 +1498,10 @@ env_keys: required_test: rate-limit-contract:redis-queue-bounded - name: APP_RATE_LIMIT_REDIS_MAXIMUM_IN_FLIGHT_BYTES + # DEPRECATED 2026-08-10: the rate limiter no longer owns a Redis client of its own. One client, built by RedisSdkAutoConfiguration, serves every capability, so a second endpoint and a second set of ceilings could only ever disagree with it. + # Replaced by: app.redis.capacity.maximum-in-flight-bytes. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: int default: 1048576 allowed_values: null @@ -1437,6 +1514,10 @@ env_keys: required_test: rate-limit-contract:redis-byte-admission-bounded - name: APP_RATE_LIMIT_REDIS_NAMESPACE_ENVIRONMENT + # DEPRECATED 2026-08-10: the rate limiter no longer renders its own key prefix. Four capabilities each joining two free-form tokens produced four prefixes, and the ACL pattern matched none of them. + # Replaced by: app.redis.namespace.environment (APP_REDIS_NAMESPACE_ENVIRONMENT), shared by every capability. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: string default: local allowed_values: null @@ -1607,6 +1688,10 @@ env_keys: required_test: redis-idempotency-contract:key-hmac-no-leak - name: APP_IDEMPOTENCY_REDIS_NAMESPACE_ENVIRONMENT + # DEPRECATED 2026-08-10: the idempotency store no longer renders its own key prefix. Four capabilities each joining two free-form tokens produced four prefixes, and the ACL pattern matched none of them. + # Replaced by: app.redis.namespace.environment (APP_REDIS_NAMESPACE_ENVIRONMENT), shared by every capability. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: string default: local allowed_values: null @@ -1667,6 +1752,10 @@ env_keys: required_test: redis-lease-contract:key-hmac-no-leak - name: APP_LEASE_REDIS_NAMESPACE_ENVIRONMENT + # DEPRECATED 2026-08-10: the lease no longer renders its own key prefix. Four capabilities each joining two free-form tokens produced four prefixes, and the ACL pattern matched none of them. + # Replaced by: app.redis.namespace.environment (APP_REDIS_NAMESPACE_ENVIRONMENT), shared by every capability. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: string default: local allowed_values: null @@ -1705,9 +1794,1033 @@ env_keys: compatibility_impact: additive required_test: redis-cache:canonical-cache-role-composition + - name: APP_REDIS_ENABLED + # The single global Redis activation switch (property app.redis.enabled). + # False loads no Redis settings, requires no Redis secret, and creates no client, connection, + # thread or health contributor. Role selectors choose which capabilities compose once Redis is + # on; none of them is a second master switch. + property: app.redis.enabled + owner_module: app-bootstrap + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + required_when: always-optional + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: boolean_strict + compatibility_impact: behavior-change + required_test: redis-optionality:global-switch-off-creates-nothing + + + - name: APP_CACHE_REDIS_POSITIVE_HARD_TTL + # How long a cache entry stays usable. The physical Redis TTL equals this and nothing else, so + # an entry can never outlive the deployment's own notion of usability or be discarded early. + type: duration + default: 5m + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability-completion + validation: spring_duration_shorthand_ge_minimum_hard_ttl + compatibility_impact: additive + required_test: redis-capability:cache-region-composition + + - name: APP_CACHE_REDIS_NEGATIVE_TTL + # How long an authoritative absence is cached. Separate from the positive TTL because "the + # source says this does not exist" is a fact with a different shelf life from a value. + type: duration + default: 10s + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability-completion + validation: spring_duration_shorthand_non_zero + compatibility_impact: additive + required_test: redis-capability:cache-region-composition + + - name: APP_IDEMPOTENCY_REDIS_COMMAND_TIMEOUT + # The ceiling on one owner-safe transition. + type: duration + default: 200ms + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability-completion + validation: spring_duration_shorthand_non_zero + compatibility_impact: additive + required_test: redis-capability:idempotency-store-composition + + - name: APP_LEASE_REDIS_COMMAND_TIMEOUT + # The ceiling on one lease operation. + type: duration + default: 200ms + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability-completion + validation: spring_duration_shorthand_non_zero + compatibility_impact: additive + required_test: redis-capability:lease-composition + + - name: APP_LEASE_REDIS_CONTENTION_RETRY_AFTER + # What a contended acquire tells the caller to wait. Distinct from the drift budget: one is + # advice to a caller that lost, the other is how much of its own lease a winner does not trust. + type: duration + default: 50ms + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: redis-production-capability-completion + validation: spring_duration_shorthand_non_zero + compatibility_impact: additive + required_test: redis-capability:lease-composition + + # --- Redis SDK typed settings (app.redis.*) ------------------------------- + # Bound only by RedisSdkAutoConfiguration, which exists only while APP_REDIS_ENABLED + # is true. They are deliberately absent from application.yml and src/.env: putting + # them there would make a Redis-free deployment carry Redis configuration, which is + # the defect the conditional composition root removes. verifyEnvKeys checks them + # against spring-configuration-metadata.json instead. + + - name: APP_REDIS_ACKNOWLEDGED_WRITE_LOSS_ACCEPTED + # Declares that losing acknowledged writes is a deliberate trade. Leave false. + property: app.redis.acknowledged-write-loss-accepted + owner_module: adapter-outbound-cache-redis + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: boolean_strict + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_ADMIN_CREDENTIAL_REFERENCE + property: app.redis.admin.credential-reference + owner_module: adapter-outbound-cache-redis + type: string + default: null + allowed_values: null + classification: sensitive-config + required: false + required_when: app.redis.admin.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: nonblank_when_required + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_ADMIN_ENABLED + property: app.redis.admin.enabled + owner_module: adapter-outbound-cache-redis + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: boolean_strict + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_ADVANCED_ENABLED + property: app.redis.advanced.enabled + owner_module: adapter-outbound-cache-redis + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: boolean_strict + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_ADVANCED_POLICIES + property: app.redis.advanced.policies + owner_module: adapter-outbound-cache-redis + type: csv + default: "" + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: csv_nonempty + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_BLOCKING_MAX_BLOCK + # Hard ceiling on a server-side block; zero would be unbounded. + property: app.redis.blocking.max-block + owner_module: adapter-outbound-cache-redis + type: duration + default: "30s" + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: duration_strict + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_BLOCKING_MAX_CONNECTIONS + property: app.redis.blocking.max-connections + owner_module: adapter-outbound-cache-redis + type: integer + default: 32 + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: positive_integer + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_DATABASE + # Cluster supports database 0 only; a non-zero value fails startup there. + property: app.redis.database + owner_module: adapter-outbound-cache-redis + type: integer + default: 0 + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: positive_integer + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_LIMITS_MAX_BATCH_COMMANDS + property: app.redis.limits.max-batch-commands + owner_module: adapter-outbound-cache-redis + type: integer + default: 500 + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: positive_integer + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_LIMITS_MAX_BATCH_REPLY_BYTES + property: app.redis.limits.max-batch-reply-bytes + owner_module: adapter-outbound-cache-redis + type: integer + default: 16777216 + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: positive_integer + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_LIMITS_MAX_BATCH_REQUEST_BYTES + property: app.redis.limits.max-batch-request-bytes + owner_module: adapter-outbound-cache-redis + type: integer + default: 4194304 + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: positive_integer + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_LIMITS_MAX_BITMAP_OFFSET + property: app.redis.limits.max-bitmap-offset + owner_module: adapter-outbound-cache-redis + type: integer + default: 10000000 + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: positive_integer + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_LIMITS_MAX_COLLECTION_ELEMENTS + property: app.redis.limits.max-collection-elements + owner_module: adapter-outbound-cache-redis + type: integer + default: 1000 + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: positive_integer + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_LIMITS_MAX_HASH_FIELD_VALUE_BYTES + property: app.redis.limits.max-hash-field-value-bytes + owner_module: adapter-outbound-cache-redis + type: integer + default: 524288 + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: positive_integer + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_LIMITS_MAX_KEY_BYTES + property: app.redis.limits.max-key-bytes + owner_module: adapter-outbound-cache-redis + type: integer + default: 512 + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: positive_integer + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_LIMITS_MAX_SCAN_COUNT + property: app.redis.limits.max-scan-count + owner_module: adapter-outbound-cache-redis + type: integer + default: 500 + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: positive_integer + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_LIMITS_MAX_STREAM_PAYLOAD_BYTES + property: app.redis.limits.max-stream-payload-bytes + owner_module: adapter-outbound-cache-redis + type: integer + default: 262144 + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: positive_integer + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_LIMITS_MAX_VALUE_BYTES + property: app.redis.limits.max-value-bytes + owner_module: adapter-outbound-cache-redis + type: integer + default: 1048576 + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: positive_integer + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_LIMITS_OFFLINE_QUEUE_COMMANDS + property: app.redis.limits.offline-queue-commands + owner_module: adapter-outbound-cache-redis + type: integer + default: 1000 + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: positive_integer + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_MODE + # standalone | sentinel | cluster. + property: app.redis.mode + owner_module: adapter-outbound-cache-redis + type: enum + default: "standalone" + allowed_values: [standalone, sentinel, cluster] + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: enum_strict + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_NAMESPACE_DOMAIN + property: app.redis.namespace.domain + owner_module: adapter-outbound-cache-redis + type: string + default: "shared" + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: nonblank_when_required + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_NAMESPACE_ENVIRONMENT + property: app.redis.namespace.environment + owner_module: adapter-outbound-cache-redis + type: string + default: "local" + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: nonblank_when_required + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_NAMESPACE_SERVICE + property: app.redis.namespace.service + owner_module: adapter-outbound-cache-redis + type: string + default: "sample-service" + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: nonblank_when_required + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_NODES + # CSV of host:port. Cluster and Sentinel take every seed node. + property: app.redis.nodes + owner_module: adapter-outbound-cache-redis + type: csv + default: "localhost:6379" + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: csv_nonempty + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_RAW_CREDENTIAL_REFERENCE + property: app.redis.raw.credential-reference + owner_module: adapter-outbound-cache-redis + type: string + default: null + allowed_values: null + classification: sensitive-config + required: false + required_when: app.redis.raw.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: nonblank_when_required + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_RAW_ENABLED + property: app.redis.raw.enabled + owner_module: adapter-outbound-cache-redis + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: boolean_strict + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_RAW_POLICY_RESOURCE + # Classpath resource listing every approved raw command. Absent resource fails startup. + property: app.redis.raw.policy-resource + owner_module: adapter-outbound-cache-redis + type: string + default: "classpath:redis-sdk/raw-command-allowlist.yml" + allowed_values: null + classification: public-config + required: false + required_when: app.redis.raw.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: nonblank_when_required + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_TIMEOUT_ADMIN + property: app.redis.timeout.admin + owner_module: adapter-outbound-cache-redis + type: duration + default: "3s" + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: duration_strict + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_TIMEOUT_BATCH + property: app.redis.timeout.batch + owner_module: adapter-outbound-cache-redis + type: duration + default: "2s" + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: duration_strict + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_TIMEOUT_COLLECTION + property: app.redis.timeout.collection + owner_module: adapter-outbound-cache-redis + type: duration + default: "2s" + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: duration_strict + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_TIMEOUT_FAST + # Single-key command profile; above 5s produces a startup warning. + property: app.redis.timeout.fast + owner_module: adapter-outbound-cache-redis + type: duration + default: "500ms" + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: duration_strict + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_TIMEOUT_SCRIPT + property: app.redis.timeout.script + owner_module: adapter-outbound-cache-redis + type: duration + default: "1s" + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: duration_strict + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + - name: APP_REDIS_TRANSACTION_MAX_CONNECTIONS + property: app.redis.transaction.max-connections + owner_module: adapter-outbound-cache-redis + type: integer + default: 16 + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: positive_integer + compatibility_impact: additive + required_test: redis-sdk:typed-settings-bound-and-validated + + # --- Redis runtime composition settings (app.redis.*) ---------------------- + # Authentication, Sentinel discovery, TLS, lifecycle, Cluster routing, capacity and + # subscription delivery. Same rule as the rest of app.redis.*: bound only while + # APP_REDIS_ENABLED is true, and deliberately absent from application.yml and .env. + + - name: APP_REDIS_AUTHENTICATION_ADVANCED_CREDENTIAL_REFERENCE + property: app.redis.authentication.advanced-credential-reference + owner_module: adapter-outbound-cache-redis + type: string + default: null + allowed_values: null + classification: sensitive-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: nonblank_when_required + compatibility_impact: additive + required_test: redis-sdk:runtime-composition-binds-and-validates + + - name: APP_REDIS_AUTHENTICATION_ANONYMOUS_ACCESS_ACCEPTED + property: app.redis.authentication.anonymous-access-accepted + owner_module: adapter-outbound-cache-redis + # Explicit acknowledgement that Redis runs with no credential. Startup fails without it when + # no credential reference is set, because booting anyway builds an unauthenticated client that + # cannot run a single command on any deployment which disabled the `default` ACL user — the + # failure moves from startup to the first request, where it reads as an outage rather than a + # missing setting. Setting this to true keeps the deployment running and logs the trade. + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + required_when: app.redis.enabled=true and no credential reference is configured + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: boolean_strict + compatibility_impact: additive + required_test: redis-sdk:authentication-required-unless-anonymous-accepted + + - name: APP_REDIS_AUTHENTICATION_CREDENTIAL_REFERENCE + # Pointer to the application ACL account credential. The value lives in the secret manager. + property: app.redis.authentication.credential-reference + owner_module: adapter-outbound-cache-redis + type: string + default: null + allowed_values: null + classification: sensitive-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: nonblank_when_required + compatibility_impact: additive + required_test: redis-sdk:runtime-composition-binds-and-validates + + - name: APP_REDIS_AUTHENTICATION_PUBSUB_CREDENTIAL_REFERENCE + property: app.redis.authentication.pubsub-credential-reference + owner_module: adapter-outbound-cache-redis + type: string + default: null + allowed_values: null + classification: sensitive-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: nonblank_when_required + compatibility_impact: additive + required_test: redis-sdk:runtime-composition-binds-and-validates + + - name: APP_REDIS_CAPACITY_MAXIMUM_IN_FLIGHT_BYTES + property: app.redis.capacity.maximum-in-flight-bytes + owner_module: adapter-outbound-cache-redis + type: integer + default: null + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: positive_integer + compatibility_impact: additive + required_test: redis-sdk:runtime-composition-binds-and-validates + + - name: APP_REDIS_CAPACITY_MAXIMUM_IN_FLIGHT_COMMANDS + property: app.redis.capacity.maximum-in-flight-commands + owner_module: adapter-outbound-cache-redis + type: integer + default: 64 + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: positive_integer + compatibility_impact: additive + required_test: redis-sdk:runtime-composition-binds-and-validates + + - name: APP_REDIS_CAPACITY_MAXIMUM_REPLY_BYTES + property: app.redis.capacity.maximum-reply-bytes + owner_module: adapter-outbound-cache-redis + type: integer + default: null + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: positive_integer + compatibility_impact: additive + required_test: redis-sdk:runtime-composition-binds-and-validates + + - name: APP_REDIS_CAPACITY_REJECT_WHEN_DISCONNECTED + # True refuses commands while disconnected. False restores the driver offline queue, which replays a burst of writes on reconnect in arbitrary order relative to the outage. + property: app.redis.capacity.reject-when-disconnected + owner_module: adapter-outbound-cache-redis + type: boolean + default: true + allowed_values: [true, false] + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: boolean_strict + compatibility_impact: additive + required_test: redis-sdk:runtime-composition-binds-and-validates + + - name: APP_REDIS_CLUSTER_MAXIMUM_REDIRECTS + property: app.redis.cluster.maximum-redirects + owner_module: adapter-outbound-cache-redis + type: integer + default: 5 + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: positive_integer + compatibility_impact: additive + required_test: redis-sdk:runtime-composition-binds-and-validates + + - name: APP_REDIS_CLUSTER_TOPOLOGY_REFRESH_PERIOD + property: app.redis.cluster.topology-refresh-period + owner_module: adapter-outbound-cache-redis + type: duration + default: "30s" + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: duration_strict + compatibility_impact: additive + required_test: redis-sdk:runtime-composition-binds-and-validates + + - name: APP_REDIS_LIFECYCLE_ACQUIRE_TIMEOUT + property: app.redis.lifecycle.acquire-timeout + owner_module: adapter-outbound-cache-redis + type: duration + default: "2s" + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: duration_strict + compatibility_impact: additive + required_test: redis-sdk:runtime-composition-binds-and-validates + + - name: APP_REDIS_LIFECYCLE_CLIENT_NAME + property: app.redis.lifecycle.client-name + owner_module: adapter-outbound-cache-redis + type: string + default: "ca-skeleton" + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: nonblank_when_required + compatibility_impact: additive + required_test: redis-sdk:runtime-composition-binds-and-validates + + - name: APP_REDIS_LIFECYCLE_CONNECT_TIMEOUT + property: app.redis.lifecycle.connect-timeout + owner_module: adapter-outbound-cache-redis + type: duration + default: "2s" + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: duration_strict + compatibility_impact: additive + required_test: redis-sdk:runtime-composition-binds-and-validates + + - name: APP_REDIS_LIFECYCLE_DRAIN_TIMEOUT + # How long shutdown waits for in-flight commands before closing connections. + property: app.redis.lifecycle.drain-timeout + owner_module: adapter-outbound-cache-redis + type: duration + default: "6s" + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: duration_strict + compatibility_impact: additive + required_test: redis-sdk:runtime-composition-binds-and-validates + + - name: APP_REDIS_LIFECYCLE_SHUTDOWN_QUIET_PERIOD + property: app.redis.lifecycle.shutdown-quiet-period + owner_module: adapter-outbound-cache-redis + type: duration + default: "100ms" + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: duration_strict + compatibility_impact: additive + required_test: redis-sdk:runtime-composition-binds-and-validates + + - name: APP_REDIS_LIFECYCLE_SHUTDOWN_TIMEOUT + property: app.redis.lifecycle.shutdown-timeout + owner_module: adapter-outbound-cache-redis + type: duration + default: "3s" + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: duration_strict + compatibility_impact: additive + required_test: redis-sdk:runtime-composition-binds-and-validates + + - name: APP_REDIS_LIFECYCLE_TLS_HANDSHAKE_TIMEOUT + property: app.redis.lifecycle.tls-handshake-timeout + owner_module: adapter-outbound-cache-redis + type: duration + default: "3s" + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: duration_strict + compatibility_impact: additive + required_test: redis-sdk:runtime-composition-binds-and-validates + + - name: APP_REDIS_PUBSUB_BUFFER_CAPACITY + property: app.redis.pubsub.buffer-capacity + owner_module: adapter-outbound-cache-redis + type: integer + default: 1024 + allowed_values: null + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: positive_integer + compatibility_impact: additive + required_test: redis-sdk:runtime-composition-binds-and-validates + + - name: APP_REDIS_PUBSUB_OVERFLOW_POLICY + # error | drop-oldest | drop-latest. + property: app.redis.pubsub.overflow-policy + owner_module: adapter-outbound-cache-redis + type: string + default: "error" + allowed_values: [error, drop-oldest, drop-latest] + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: nonblank_when_required + compatibility_impact: additive + required_test: redis-sdk:runtime-composition-binds-and-validates + + - name: APP_REDIS_SENTINEL_CREDENTIAL_REFERENCE + property: app.redis.sentinel.credential-reference + owner_module: adapter-outbound-cache-redis + type: string + default: null + allowed_values: null + classification: sensitive-config + required: false + required_when: app.redis.mode=sentinel + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: nonblank_when_required + compatibility_impact: additive + required_test: redis-sdk:runtime-composition-binds-and-validates + + - name: APP_REDIS_SENTINEL_MASTER_NAME + property: app.redis.sentinel.master-name + owner_module: adapter-outbound-cache-redis + type: string + default: null + allowed_values: null + classification: public-config + required: false + required_when: app.redis.mode=sentinel + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: nonblank_when_required + compatibility_impact: additive + required_test: redis-sdk:runtime-composition-binds-and-validates + + - name: APP_REDIS_SENTINEL_NODES + property: app.redis.sentinel.nodes + owner_module: adapter-outbound-cache-redis + type: csv + default: null + allowed_values: null + classification: public-config + required: false + required_when: app.redis.mode=sentinel + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: csv_nonempty + compatibility_impact: additive + required_test: redis-sdk:runtime-composition-binds-and-validates + + - name: APP_REDIS_TLS_CLIENT_CERTIFICATE_RESOURCE + property: app.redis.tls.client-certificate-resource + owner_module: adapter-outbound-cache-redis + type: string + default: null + allowed_values: null + classification: public-config + required: false + required_when: app.redis.tls.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: nonblank_when_required + compatibility_impact: additive + required_test: redis-sdk:runtime-composition-binds-and-validates + + - name: APP_REDIS_TLS_CLIENT_KEY_REFERENCE + property: app.redis.tls.client-key-reference + owner_module: adapter-outbound-cache-redis + type: string + default: null + allowed_values: null + classification: sensitive-config + required: false + required_when: app.redis.tls.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: nonblank_when_required + compatibility_impact: additive + required_test: redis-sdk:runtime-composition-binds-and-validates + + - name: APP_REDIS_TLS_ENABLED + property: app.redis.tls.enabled + owner_module: adapter-outbound-cache-redis + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: boolean_strict + compatibility_impact: additive + required_test: redis-sdk:runtime-composition-binds-and-validates + + - name: APP_REDIS_TLS_HOSTNAME_VERIFICATION + # Disabling this accepts any certificate the trust material signs, for any host. + property: app.redis.tls.hostname-verification + owner_module: adapter-outbound-cache-redis + type: boolean + default: true + allowed_values: [true, false] + classification: public-config + required: false + required_when: app.redis.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: boolean_strict + compatibility_impact: additive + required_test: redis-sdk:runtime-composition-binds-and-validates + + - name: APP_REDIS_TLS_TRUST_MATERIAL_RESOURCE + property: app.redis.tls.trust-material-resource + owner_module: adapter-outbound-cache-redis + type: string + default: null + allowed_values: null + classification: public-config + required: false + required_when: app.redis.tls.enabled=true + reload_policy: restart-only + owner_branch: redis-optionality-and-composition + validation: nonblank_when_required + compatibility_impact: additive + required_test: redis-sdk:runtime-composition-binds-and-validates - name: APP_CACHE_REDIS_ENABLED # source: feature-integration-adapter-templates 2026-05-22 # "Redis | disabled optional module | cache consistency" + Adapter Template Defaults 표 + # DEPRECATED 2026-08-10: this was a second Redis master switch, and the settings class that + # bound it was removed with the previous Redis generation, so the key reached nothing. + # APP_REDIS_ENABLED is the sole global activation authority; the cache role is selected by + # ca-skeleton.capabilities.cache.bindings.default. The row is kept rather than deleted so a + # deployment still setting this key can be told what replaced it. + deprecated_orphaned: true + deprecated_alias_for: APP_REDIS_ENABLED + removal_deadline: 2026-11-30 type: boolean default: false allowed_values: [true, false] @@ -1720,6 +2833,11 @@ env_keys: required_test: adapter-contract:redis-disabled-default - name: APP_CACHE_REDIS_CLIENT_MODE + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: enum default: managed allowed_values: [managed, external] @@ -1732,6 +2850,11 @@ env_keys: required_test: adapter-contract:redis-client-mode-explicit - name: APP_CACHE_REDIS_HOST + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 # source: feature-cache-consistency-contract — Redis adapter (활성화 시 endpoint 필요) type: string default: null @@ -1745,6 +2868,11 @@ env_keys: required_test: cache-contract:redis-host-when-enabled - name: APP_CACHE_REDIS_PORT + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 # source: feature-cache-consistency-contract — Redis adapter endpoint type: int default: 6379 @@ -1758,6 +2886,11 @@ env_keys: required_test: cache-contract:redis-port-bound - name: APP_CACHE_REDIS_PASSWORD + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: string default: null allowed_values: null @@ -1770,6 +2903,10 @@ env_keys: required_test: secrets-contract:redis-password-no-leak - name: APP_CACHE_REDIS_TRUST_PEM + # DEPRECATED 2026-08-10: the cache no longer owns a Redis client of its own, so no code reads this. + # Replaced by: app.redis.tls.trust-material-resource (APP_REDIS_TLS_TRUST_MATERIAL_RESOURCE), which the one Redis client uses for every capability. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: string default: null allowed_values: null @@ -1782,6 +2919,11 @@ env_keys: required_test: redis-contract:cache-trust-material-no-leak - name: APP_REDIS_SEMANTIC_PROBE_MINIMUM_INTERVAL + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: duration default: 5s allowed_values: null @@ -1794,6 +2936,11 @@ env_keys: required_test: redis-contract:semantic-probe-cadence-bounded - name: APP_REDIS_SENTINEL_DISCOVERY_REFRESH_PERIOD + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: duration default: 30s allowed_values: null @@ -1806,6 +2953,11 @@ env_keys: required_test: redis-contract:sentinel-discovery-refresh-period-bounded - name: APP_REDIS_SEMANTIC_PROBE_MAXIMUM_STALENESS + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: duration default: 15s allowed_values: null @@ -1818,6 +2970,11 @@ env_keys: required_test: redis-contract:semantic-probe-staleness-bounded - name: APP_CACHE_REDIS_KEY_HMAC_SECRET + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: string default: null allowed_values: null @@ -1830,6 +2987,11 @@ env_keys: required_test: cache-contract:redis-hmac-secret-bounded - name: APP_CACHE_REDIS_COMMAND_TIMEOUT + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: duration default: 2s allowed_values: null @@ -1842,6 +3004,11 @@ env_keys: required_test: cache-contract:redis-command-timeout-bounded - name: APP_CACHE_REDIS_MAXIMUM_QUEUED_COMMANDS + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: int default: 8 allowed_values: null @@ -1854,6 +3021,11 @@ env_keys: required_test: cache-contract:redis-command-queue-bounded - name: APP_CACHE_REDIS_MAXIMUM_IN_FLIGHT_BYTES + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: int default: 16777216 allowed_values: null @@ -1902,6 +3074,14 @@ env_keys: required_test: cache-contract:redis-hard-ttl-minimum - name: APP_CACHE_REDIS_NAMESPACE_ENVIRONMENT + # DEPRECATED 2026-08-10: the cache no longer renders its own key prefix. Four capabilities + # each joining two free-form tokens produced four prefixes, and the ACL pattern that was meant + # to fence the deployment in matched none of them. The row is kept (not deleted) so a + # deployment still setting it can be told what replaced it. + # Replaced by: app.redis.namespace.environment (APP_REDIS_NAMESPACE_ENVIRONMENT), shared by + # every capability. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: string default: local allowed_values: null @@ -1914,6 +3094,11 @@ env_keys: required_test: cache-contract:redis-namespace-environment-bound - name: APP_CACHE_REDIS_SEMANTIC_REGION + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: string default: default allowed_values: null @@ -1926,6 +3111,11 @@ env_keys: required_test: cache-contract:redis-semantic-region-bound - name: APP_CACHE_REDIS_MAXIMUM_VALUE_BYTES + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: int default: 1048576 allowed_values: null @@ -1938,6 +3128,11 @@ env_keys: required_test: cache-contract:redis-value-size-bounded - name: APP_CACHE_REDIS_L1_ENABLED + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: boolean default: false allowed_values: [true, false] @@ -1950,6 +3145,11 @@ env_keys: required_test: cache-contract:redis-l1-disabled-default - name: APP_CACHE_REDIS_L1_MAXIMUM_ENTRIES + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: int default: 10000 allowed_values: null @@ -1962,6 +3162,11 @@ env_keys: required_test: cache-contract:redis-l1-cardinality-bounded - name: APP_CACHE_REDIS_L1_MAXIMUM_WEIGHT_BYTES + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: int default: 67108864 allowed_values: null @@ -1974,6 +3179,11 @@ env_keys: required_test: cache-contract:redis-l1-weight-bounded - name: APP_CACHE_REDIS_L1_MAXIMUM_ENTRY_WEIGHT_BYTES + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: int default: 1048576 allowed_values: null @@ -1986,6 +3196,11 @@ env_keys: required_test: cache-contract:redis-l1-entry-weight-bounded - name: APP_CACHE_REDIS_L1_TTL + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: duration default: 30s allowed_values: null @@ -1998,6 +3213,11 @@ env_keys: required_test: cache-contract:redis-l1-ttl-bounded - name: APP_CACHE_REDIS_L1_GENERATION_RECHECK_INTERVAL + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: duration default: 5s allowed_values: null @@ -2010,6 +3230,11 @@ env_keys: required_test: cache-contract:redis-l1-generation-recheck-bounded - name: APP_CACHE_REDIS_L1_INVALIDATION_QUEUE_CAPACITY + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 type: int default: 1024 allowed_values: null @@ -2022,6 +3247,11 @@ env_keys: required_test: cache-contract:redis-l1-invalidation-queue-bounded - name: APP_CACHE_DEFAULT_TTL + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 # source: feature-cache-consistency-contract 2026-05-22 # "TTL | explicit per key family | no-cache for sensitive data | immortal cache forbidden" type: duration @@ -2036,6 +3266,11 @@ env_keys: required_test: cache-contract:default-ttl-bounded - name: APP_CACHE_NEGATIVE_TTL + # DEPRECATED 2026-08-10: the settings class that bound this property was removed + # with the previous Redis generation, so the key reached nothing. The row is kept + # (not deleted) so a deployment still setting it can be told what replaced it. + deprecated_orphaned: true + removal_deadline: 2026-11-30 # source: feature-cache-consistency-contract 2026-05-22 # "negative cache 정책 = 존재하지 않는 row는 짧은 TTL(60s) 캐싱 허용" type: duration @@ -2195,6 +3430,727 @@ env_keys: compatibility_impact: additive required_test: fileserver-r2:disabled-default-and-enabled-attestation + # === HTTP Client platform (app.httpclient.*) === + # + # Only the master switch is registered here. The per-client surface is an indexed list whose + # element cannot be templated in application.yml without materialising a nameless client in every + # deployment, so it has no deployment-independent value for this registry to hold and is + # registered in docs/httpclient/env-fields.yaml instead. That manifest is derived from + # HttpClientPlatformSettings and enforced in both directions: a field with no entry fails + # HttpClientPlatformEnvManifestTest, and an APP_HTTPCLIENT_ variable with no field fails startup. + + - name: APP_HTTPCLIENT_ENABLED + # Master switch. While false the platform block is not bound at all: the auto-configuration that + # binds it is not processed, so no bean, connection pool, TLS context, credential, thread or + # gateway exists, and a malformed HTTP client setting cannot fail this deployment's startup. + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: httpclient-platform-activation-boundary + validation: boolean_strict + compatibility_impact: additive + required_test: httpclient-platform:off-state-zero-side-effects-and-env-manifest-parity + + # === Fileserver HTTP platform (app.fileserver-platform.*) === + # + # A different capability from the app.fileserver R2 publication block above. Separate + # namespaces so the two cannot be switched on together by accident, and so a change to one + # cannot silently re-shape the other. + + - name: APP_FILESERVER_PLATFORM_ENABLED + # Master switch. While false the platform block is not bound at all: the auto-configuration that + # binds it is not processed, so no bean, route, thread, schema check or filesystem call exists. + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: boolean_strict + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_INSTANCE_ID + # Writer-lease owner; must be unique per instance. The startup gate treats the default as + # single-instance and refuses a shared-metadata claim it cannot support. + type: string + default: local-node + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: non_empty_string + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_DEFAULT_NAMESPACE + # Namespace applied to a request that does not name one. + type: string + default: default + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: non_empty_string + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_STORAGE_ROOT + # Absolute path on its own volume. A relative root resolves against the process working + # directory, which differs between a container and a test, so it is refused. + type: string + default: /var/lib/backend/files + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: absolute_path_outside_forbidden_ancestors + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_STORAGE_PUBLISH_MODE + # atomic-move-required fails startup when the probe cannot prove an atomic rename. + type: string + default: atomic-move-preferred + allowed_values: [atomic-move-required, atomic-move-preferred, metadata-pointer] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: enum_in_allowed_values + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_STORAGE_BUFFER_SIZE + # Bounds every transfer allocation, so resident bytes never scale with file size. + type: data_size + default: 128KB + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: between_4kb_and_8mb + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_STORAGE_FORBIDDEN_ROOT_ANCESTORS + # A storage root under a web root turns every upload into a published file; under a config + # root, into a configuration change. Both are refused at binding time. + type: csv_list + default: /app,/etc,/usr/share/nginx/html + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: absolute_path_list + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_UPLOAD_MAX_FILE_SIZE + # Also drives spring.servlet.multipart.max-file-size. One placeholder for both: a smaller + # servlet ceiling rejects the upload before any Fileserver code, including its error mapping. + type: data_size + default: 100MB + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: positive_data_size + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_UPLOAD_MAX_REQUEST_SIZE + # Also drives spring.servlet.multipart.max-request-size. + type: data_size + default: 110MB + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: at_least_max_file_size + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_UPLOAD_INITIAL_RESERVATION + # Quota reserved for an upload that does not declare its length. + type: data_size + default: 8MB + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: at_most_max_file_size + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_UPLOAD_MAX_PARTS + # Ceiling on parts in one multipart/batch request. + type: integer + default: 16 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: positive_integer + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_UPLOAD_TTL + # Lifetime of an upload resource before it is reclaimable. + type: duration + default: 1h + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: positive_duration + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_UPLOAD_RESERVATION_TTL + # Lifetime of a quota reservation whose upload never completed. + type: duration + default: 24h + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: positive_duration + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_UPLOAD_LEASE_DURATION + # Writer lease. Renewed at one third of this while a transfer runs; a transfer that cannot + # renew is fenced out before its next physical write. + type: duration + default: 30s + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: positive_duration + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_UPLOAD_REQUIRE_CONTENT_LENGTH + # When true a raw upload without Content-Length is refused with 411. + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: boolean_strict + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_DOWNLOAD_CACHE_CONTROL + # Cache-Control emitted on every download response. + type: string + default: private, no-store + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: non_empty_string + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_DOWNLOAD_INLINE_ALLOWED + # Inline rendering is off by default; scriptable content is forced to attachment regardless. + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: boolean_strict + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_DOWNLOAD_MAX_RANGES + # Multi-range is opt-in; above one the response is multipart/byteranges. + type: integer + default: 1 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: between_1_and_8 + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_DOWNLOAD_MAX_RANGE_BYTES + # Applies to the single-range profile too, so the ceiling is not inert in the default + # configuration that almost every deployment runs. + type: data_size + default: 100MB + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: positive_data_size + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_DOWNLOAD_ZERO_COPY_ENABLED + # Changes only where bytes are copied, never a header or a status. + type: boolean + default: true + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: boolean_strict + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_DOWNLOAD_ZERO_COPY_MINIMUM_BYTES + # Below this the syscall setup costs more than it saves. + type: data_size + default: 16MB + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: positive_data_size + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_TRANSFER_CORE_SIZE + # Bounded transfer pool; rejection becomes a retryable 429, never caller-runs. + type: integer + default: 8 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: positive_integer_at_most_max_size + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_TRANSFER_MAX_SIZE + # Upper bound of the transfer pool. + type: integer + default: 32 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: positive_integer + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_TRANSFER_QUEUE_CAPACITY + # Bounded queue; an unbounded one trades a fast 429 for eventual heap exhaustion. + type: integer + default: 64 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: positive_integer + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_TRANSFER_AWAIT_SECONDS + # Shutdown drain and per-transfer caller wait. + type: integer + default: 300 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: positive_integer + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_SECURITY_ACCESS_POLICY + # No permissive default. `required` fails startup unless the deployment supplies a + # FileAccessPolicy bean; `unenforced` is refused under a production profile. + type: string + default: required + allowed_values: [required, role-based, unenforced] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: enum_in_allowed_values + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_SECURITY_READ_ROLES + # Roles accepted for read operations under the role-based policy. + type: csv_list + default: ROLE_FILE_READ + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: non_empty_role_list + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_SECURITY_WRITE_ROLES + # Roles accepted for write operations under the role-based policy. + type: csv_list + default: ROLE_FILE_WRITE + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: non_empty_role_list + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_SECURITY_ADMIN_ROLES + # Roles accepted for the management plane. Admin routes additionally require this role at the + # servlet chain, not only in application policy. + type: csv_list + default: ROLE_FILE_ADMIN + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: non_empty_role_list + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_VERIFICATION_TIMEOUT + # Whole-chain verification budget for one upload. + type: duration + default: 5s + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: positive_duration + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_VERIFICATION_REQUIRE_MEDIA_TYPE_VERDICT + # When true a file whose type could not be determined is refused rather than published. + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: boolean_strict + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_VERIFICATION_INLINE_SAFE_PROFILE + # When true scriptable content is accepted instead of quarantined; only safe when downloads + # are never served inline from a trusted origin. + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: boolean_strict + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_QUOTA_INSTANCE_UPLOAD_PERMITS + # Concurrent uploads admitted per instance. + type: integer + default: 16 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: positive_integer + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_QUOTA_SCOPE_UPLOAD_PERMITS + # Concurrent uploads admitted per namespace. + type: integer + default: 4 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: positive_integer_at_most_instance_permits + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_QUOTA_DIRECT_DOWNLOAD_PERMITS + # Concurrent direct downloads admitted per instance. + type: integer + default: 64 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: positive_integer + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_QUOTA_SOFT_HIGH_WATER + # Storage fraction at which new uploads start being shed. + type: string + default: 0.70 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: fraction_below_hard_high_water + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_QUOTA_HARD_HIGH_WATER + # Storage fraction at which every upload is refused. + type: string + default: 0.85 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: fraction_within_zero_and_one + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_ADMIN_ENABLED + # Management plane, a separate decision from the data plane. Requires the master switch too: + # enabling it alone now does nothing instead of half-building a bean graph. + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: boolean_strict + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_ADMIN_ORPHAN_MINIMUM_AGE + # How long an unreferenced object must exist before a scan may name it; anything younger is + # assumed mid-commit rather than abandoned. + type: duration + default: 1h + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: positive_duration + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_CLEANUP_ENABLED + # Background reclamation. Off by default because the worker deletes physical objects. + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: boolean_strict + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_CLEANUP_INTERVAL + # Fixed delay between batches; sole owner of the schedule, with no @Scheduled placeholder + # carrying a second default. + type: duration + default: 60s + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: positive_duration + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_CLEANUP_MAX_ITEMS + # Item ceiling for one batch, so a backlog cannot monopolise the scheduler. + type: integer + default: 100 + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: positive_integer + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_CLEANUP_MAX_BYTES + # Byte ceiling for one batch. + type: data_size + default: 1GB + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: positive_data_size + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_CLEANUP_RETRY_BACKOFF + # Delay before a failed cleanup item is retried. + type: duration + default: 5m + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: positive_duration + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_TUS_ENABLED + # tus 1.0. Requires the master switch too. + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: boolean_strict + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_HTTPBIS_DRAFT12_ENABLED + # Unratified draft whose contract can change without notice. Requires the master switch too. + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: boolean_strict + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_NGINX_ENABLED + # Front-proxy delegation. Startup attests the internal mapping by exercising it; a mapping the + # proxy cannot resolve answers 200 with an empty body, so it fails closed instead. + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: boolean_strict + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_NGINX_INTERNAL_PREFIX + # Internal location the proxy resolves to the storage root. + type: string + default: /__files/ + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: internal_uri_prefix + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_NGINX_OBJECT_SUFFIX + # Suffix appended to the sharded object key in the internal URI. + type: string + default: .bin + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: non_empty_string + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_NGINX_MINIMUM_SIZE + # Below this the request is served by the application rather than delegated. + type: data_size + default: 16MB + allowed_values: null + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: positive_data_size + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_OBSERVABILITY_METRICS_ENABLED + # When false neither the metrics port nor the fingerprint is created, and no key is required. + type: boolean + default: true + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: boolean_strict + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + + - name: APP_FILESERVER_PLATFORM_OBSERVABILITY_FINGERPRINT_KEY + # Keyed HMAC over file identifiers. An unkeyed digest of an enumerable identifier is + # reversible, so startup fails while metrics are on and this is blank. + type: string + default: null + allowed_values: null + classification: secret + required: false + reload_policy: restart-only + owner_branch: fileserver-http-platform-activation-boundary + validation: non_empty_when_metrics_enabled + compatibility_impact: additive + required_test: fileserver-platform:off-state-zero-side-effects-and-env-round-trip + # === File / Upload (feature-file-resource-handling-contract) === - name: APP_FILE_UPLOAD_MAX_SIZE diff --git a/docs/superpowers/plans/2026-08-10-httpclient-platform-activation-and-env-ssot.md b/docs/superpowers/plans/2026-08-10-httpclient-platform-activation-and-env-ssot.md new file mode 100644 index 00000000..6ab00263 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-httpclient-platform-activation-and-env-ssot.md @@ -0,0 +1,1333 @@ +# HTTP Client Platform — Activation Boundary and ENV SSOT Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the HTTP Client platform an genuinely optional capability that is off unless +`APP_HTTPCLIENT_ENABLED=true`, binds its settings strictly and only when on, and has a single +declared environment surface that a test proves is complete. + +**Architecture:** The repository already solved this problem once, for the HTTP Fileserver platform. +`CaSkeletonApplication` excludes `dev.caskeleton.bootstrap.autoconfigure.*` from its component scan, +so a configuration in that package is reachable *only* through an entry listed in +`META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`. Putting the +master switch on that single entry makes "off" a structural fact: the class is never processed, so +nothing it imports is discovered, no settings are bound and no runtime resource is created. HTTP +Client is migrated onto that same shape — one gated `@AutoConfiguration`, importing the existing +nine configurations, which move out of the scanned package with it. + +The settings become one strictly-bound record tree rooted at `app.httpclient`, with clients and +dynamic targets as *indexed lists carrying their own `name`* rather than maps keyed by name, so the +environment form is unambiguous and duplicate/colliding names are a startup failure rather than a +silent overwrite. + +**Tech Stack:** Java 21, Spring Boot 4.0.0, Gradle multi-module, JUnit 5 + AssertJ, +`ApplicationContextRunner`, `Binder` + `NoUnboundElementsBindHandler`. + +## Deviations found during execution + +Two things in this plan turned out to be wrong when the tests were run. Both are recorded here +rather than edited away, because the reason each was wrong is the useful part. + +**1. `NoUnboundElementsBindHandler` cannot police environment variables.** Spring binds +`APP_HTTPCLIENT_CLIENTS_0_BASE_URL` to `app.httpclient.clients[0].base-url` by mapping the requested +property name back to an environment name, but it *enumerates* the same variable as +`app.httpclient.clients[0].base.url` — underscores become dots, never hyphens. The strict handler +compares against that enumeration, so pointing it at the system environment reports every correctly +spelled hyphenated key as unbound while still saying nothing about a genuinely misspelled one. (The +Fileserver platform does not hit this only because its prefix, `app.fileserver-platform`, makes the +dotted forms fall outside the prefix entirely — an accident, not a design.) + +The fix is `HttpClientEnvironmentKeys`: derive the accepted variable names from the record tree and +reject an `APP_HTTPCLIENT_` variable that is not among them. Strict binding still covers every other +property source. This is stronger than the plan's original intent, not weaker — the handler could +never have caught a misspelled environment variable at all. + +**2. The field manifest does not live in `docs/registries/`.** That directory is a fail-closed +catalog of exactly eight contract registries, enforced by `ContractRegistrySchemaGovernanceTest`, +with a fixed per-row schema (`owner_branch`, `compatibility_impact`, `required_test`). A +field-to-variable mapping has none of that shape, and adding a ninth file would have meant loosening +a gate rather than satisfying one. The manifest is `docs/httpclient/env-fields.yaml`; every path +below that says otherwise is superseded. + +## Global Constraints + +- Commit policy is `human-only`. Agents do not stage, commit, amend or push. Every "Commit" step in + this plan is a **stop point where the human commits**; the agent reports the intended message. +- Owning leaf for all Java changes: `app-bootstrap`, Gradle path `:app-bootstrap` + (`src/config/architecture/modules.json` is the SSOT). Focused test: + `cd src && ./gradlew :app-bootstrap:test --console=plain`. +- No new project dependency edges. This plan moves and gates existing wiring; it must not make + `app-bootstrap` depend on anything it does not already depend on. +- Package root for new bootstrap code: `dev.caskeleton.bootstrap.autoconfigure.httpclient`. +- Configuration prefix: `app.httpclient`. Canonical environment form: `APP_HTTPCLIENT_*`. +- One public top-level type per file, file name equal to the type name + (`verifyOneTypePerFile`, code-conventions I6). +- `verifyEnvKeys` enforces a three-way lock-step between `src/.env`, + `src/app-bootstrap/src/main/resources/application.yml` and `docs/registries/env-keys.yaml`: + every required (`${VAR}` with no default) placeholder must exist in `.env`; every `.env` key must + be referenced by some `application.yml` placeholder; every `APP_` key in `.env` must be registered + in `env-keys.yaml`. Only `APP_HTTPCLIENT_ENABLED` goes through that gate — see Task 3 for why the + per-client surface is registered in a separate field manifest instead. +- The activation contract, verbatim from the review: + - toggle missing or `false` → zero HTTP properties/binder/provider/registry/gateway/endpoint/ + thread/resource beans; + - toggle present but not a strict boolean (blank, `yes`, `1`) → **not** silently enabled; + - `true` → strict bind, then full validation, then runtime resources; + - `true` with no clients → startup failure carrying the code `HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS`; + - the actuator endpoint is registered only under the master flag. + +## Out of scope for this plan + +This plan is review step 1 and step 2 only. It does **not** address the call-execution kernel (P0 #3, +#5, #6), dynamic-target DNS pinning (P0 #4), the Reactor Stable contract row (P0 #7), the HTTP/3 +provider (P0 #8), or any P1/P2 item. Those are separate plans and each needs its own working +software. What this plan must not do is make any of them harder: the settings tree it introduces is +the input a later `ValidatedClientPlan` compiler will consume. + +## File Structure + +**Moved** (`git mv`, package statement and imports updated, contents otherwise unchanged unless a +task says so) — from `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/httpclient/` to +`src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/`: + +| File | Responsibility after the move | +| --- | --- | +| `HttpClientTransportAutoConfiguration.java` | Stable transport providers. Imported, never scanned. | +| `HttpClientSecurityAutoConfiguration.java` | TLS material and policy validator. Imported. | +| `HttpClientAuthenticationAutoConfiguration.java` | Credential providers. Imported. | +| `HttpClientObservationAutoConfiguration.java` | Tag policy and execution support. Imported. | +| `HttpClientResilienceAutoConfiguration.java` | Resilience registry. Imported. **Loses its `Clock` bean.** | +| `HttpClientProfileAutoConfiguration.java` | Profile factory, startup validator, runtime registry. Imported. | +| `HttpServiceClientAutoConfiguration.java` | Caller-facing gateways and typed registries. Imported. | +| `DynamicTargetAutoConfiguration.java` | Dynamic target policies, resolvers, gateway. Imported. | +| `HttpClientManagementAutoConfiguration.java` | Actuator endpoint. Imported. | +| `HttpClientActuatorEndpoint.java` | The endpoint itself. | +| `HttpClientProfileFactory.java` | Settings → `ClientProfile`. Signature changes in Task 2. | +| `HttpClientStartupValidator.java` | Unchanged. | + +**Created:** + +| File | Responsibility | +| --- | --- | +| `.../autoconfigure/httpclient/HttpClientPlatformAutoConfiguration.java` | The one entry point. Master switch, `@Import` of the nine configurations, settings bean. | +| `.../autoconfigure/httpclient/HttpClientPlatformSettings.java` | The whole `app.httpclient` tree as one record, indexed clients and dynamic targets, aggregate validation in compact constructors. | +| `.../autoconfigure/httpclient/HttpClientPlatformSettingsBinder.java` | Strict bind of the above, inside the gate. | +| `docs/registries/httpclient-env-fields.yaml` | Field-path ↔ ENV-template manifest for the per-client surface. | + +**Deleted** (their content is absorbed by `HttpClientPlatformSettings`): + +- `.../bootstrap/httpclient/HttpClientsProperties.java` +- `.../bootstrap/httpclient/HttpClientsPropertiesBinder.java` +- `.../bootstrap/httpclient/DynamicTargetProperties.java` + +**Modified:** + +| File | Change | +| --- | --- | +| `src/app-bootstrap/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` | Add the new entry. | +| `src/app-bootstrap/src/main/resources/application.yml` | Add the `app.httpclient.enabled` placeholder only. | +| `src/.env` | Add `APP_HTTPCLIENT_ENABLED=false`. | +| `docs/registries/env-keys.yaml` | Register `APP_HTTPCLIENT_ENABLED`. | +| `src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java` | Stop asserting an empty registry exists while disabled; assert the beans are absent. | +| `docs/httpclient/configuration-reference.md` | New prefix, master switch, indexed form. | +| `scripts/verify-httpclient-docs.py` | Read the new settings type. | + +**Test files created:** + +- `.../test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformActivationTest.java` +- `.../test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformSettingsTest.java` +- `.../test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformEnvManifestTest.java` + +**Test files moved:** `HttpClientAutoConfigurationTest.java` and `UnsafeStartupConfigurationTest.java` +into the new test package, rewritten to run through the single auto-configuration. + +--- + +### Task 1: Master activation boundary + +**Files:** +- Create: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformAutoConfiguration.java` +- Move: the twelve files listed above into `.../bootstrap/autoconfigure/httpclient/` +- Modify: `src/app-bootstrap/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` +- Modify: `.../autoconfigure/httpclient/HttpClientResilienceAutoConfiguration.java` (remove the `Clock` bean) +- Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformActivationTest.java` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: `HttpClientPlatformAutoConfiguration` (public class, no-arg constructor). Task 2 adds a + `HttpClientPlatformSettings httpClientPlatformSettings(Environment)` bean method to it. Task 4 + relies on `HttpClientPlatformSettings.PREFIX` being `"app.httpclient"`. + +**Why the `Clock` bean must go.** `HttpClientResilienceAutoConfiguration` currently declares +`@Bean @ConditionalOnMissingBean(Clock.class) Clock httpClientClock()`. Once the whole capability is +gated, that bean would vanish whenever HTTP Client is off — and Redis, idempotency and the Fileserver +all inject `Clock`. The application context is unaffected because +`dev.caskeleton.bootstrap.idempotency.IdempotencyConfig#systemClock` declares one unconditionally in +a scanned package, so the httpclient copy is redundant *in the application* and dangerous *in the +gate*. Isolated `ApplicationContextRunner` tests must supply their own, exactly as +`FileserverPlatformAutoConfigurationTest` supplies a `MeterRegistry`. + +- [ ] **Step 1: Write the failing activation test** + +Create `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformActivationTest.java`: + +```java +package dev.caskeleton.bootstrap.autoconfigure.httpclient; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.httpclient.auth.CredentialProviderRegistry; +import dev.caskeleton.adapter.outbound.httpclient.dynamic.DynamicCredentialBinding; +import dev.caskeleton.adapter.outbound.httpclient.dynamic.DynamicTargetGateway; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry; +import dev.caskeleton.adapter.outbound.httpclient.resilience.ResilienceRegistry; +import dev.caskeleton.adapter.outbound.httpclient.restclient.GenericHttpGateway; +import dev.caskeleton.adapter.outbound.httpclient.security.TlsMaterialProvider; +import dev.caskeleton.adapter.outbound.httpclient.service.HttpServiceRegistry; +import java.time.Clock; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * The HTTP Client platform exists only where a deployment asked for it. + * + *

Every case here was reachable before this boundary existed: a service that never made an + * outbound call still built transport providers, credential providers, a resilience registry and + * five caller-facing gateways, and still read — and could still be failed by — HTTP configuration + * it had never written. + */ +class HttpClientPlatformActivationTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(HttpClientPlatformAutoConfiguration.class)) + .withUserConfiguration(SupportingBeans.class); + + @Test + @DisplayName("absent toggle holds no HTTP bean at all") + void theCapabilityIsAbsentUntilItIsExplicitlyEnabled() { + runner.run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(HttpClientPlatformSettings.class); + assertThat(context).doesNotHaveBean(ClientRuntimeRegistry.class); + assertThat(context).doesNotHaveBean(GenericHttpGateway.class); + assertThat(context).doesNotHaveBean(HttpServiceRegistry.class); + assertThat(context).doesNotHaveBean(DynamicTargetGateway.class); + assertThat(context).doesNotHaveBean(ResilienceRegistry.class); + assertThat(context).doesNotHaveBean(CredentialProviderRegistry.class); + assertThat(context).doesNotHaveBean(TlsMaterialProvider.class); + assertThat(context).doesNotHaveBean(HttpClientActuatorEndpoint.class); + }); + } + + @Test + @DisplayName("false toggle with a full valid profile still holds nothing") + void aValidProfileDoesNothingWhileTheSwitchIsOff() { + runner + .withPropertyValues("app.httpclient.enabled=false") + .withPropertyValues(validPaymentClient()) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(ClientRuntimeRegistry.class); + assertThat(context).doesNotHaveBean(HttpClientPlatformSettings.class); + }); + } + + @Test + @DisplayName("malformed detail settings cannot fail a deployment that never enabled the platform") + void detailSettingsAreNotBoundWhileTheCapabilityIsOff() { + runner + .withPropertyValues( + "app.httpclient.clients[0].name=payment", + "app.httpclient.clients[0].timeout.total-call=not-a-duration", + "app.httpclient.clients[0].transport=NOT_A_TRANSPORT", + "app.httpclient.clients[0].request.max-body-bytes=not-a-number") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(HttpClientPlatformSettings.class); + }); + } + + @Test + @DisplayName("a non-boolean toggle does not silently enable the platform") + void aToggleThatIsNotABooleanDoesNotEnableTheCapability() { + for (String unusable : List.of("yes", "1", "TRUE ", "")) { + runner + .withPropertyValues("app.httpclient.enabled=" + unusable) + .withPropertyValues(validPaymentClient()) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(ClientRuntimeRegistry.class); + }); + } + } + + @Test + @DisplayName("enabling it assembles exactly the declared runtimes") + void enablingItAssemblesTheDeclaredRuntimes() { + runner + .withPropertyValues("app.httpclient.enabled=true") + .withPropertyValues(validPaymentClient()) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(ClientRuntimeRegistry.class); + assertThat(context).hasSingleBean(GenericHttpGateway.class); + assertThat(context).hasSingleBean(HttpServiceRegistry.class); + assertThat(context.getBean(ClientRuntimeRegistry.class).names()) + .singleElement() + .satisfies(name -> assertThat(name.value()).isEqualTo("payment")); + }); + } + + private static String[] validPaymentClient() { + return new String[] { + "app.httpclient.clients[0].name=payment", + "app.httpclient.clients[0].base-url=https://payment.test", + "app.httpclient.clients[0].allowed-hosts[0]=payment.test", + "app.httpclient.clients[0].allowed-ports[0]=443", + "app.httpclient.clients[0].request.max-body-bytes=1048576", + "app.httpclient.clients[0].tls.profile-id=payment" + }; + } + + /** + * The collaborators the composition root normally supplies. + * + *

The {@code Clock} is the interesting one: it used to come from the HTTP Client's own + * resilience configuration, which meant every deployment inherited a clock from a capability it + * might not use. It now comes from the composition root, and this fixture stands in for it. + */ + @Configuration(proxyBeanMethods = false) + static class SupportingBeans { + + @Bean + Clock clock() { + return Clock.systemUTC(); + } + + @Bean + List dynamicCredentialBindings() { + return List.of(); + } + } +} +``` + +- [ ] **Step 2: Run it and confirm it fails to compile** + +```bash +cd src && ./gradlew :app-bootstrap:test --tests '*HttpClientPlatformActivationTest' --console=plain +``` + +Expected: compilation failure — `HttpClientPlatformAutoConfiguration` and +`HttpClientPlatformSettings` do not exist. That is the correct red state; Task 1 makes the +activation cases pass and Task 2 makes the settings type real. Until Task 2 lands, temporarily +reference `dev.caskeleton.bootstrap.autoconfigure.httpclient.HttpClientsProperties` in place of +`HttpClientPlatformSettings` and use the map-shaped `http-clients.payment.*` property names, then +switch both back in Task 2 Step 6. + +- [ ] **Step 3: Move the package** + +```bash +cd src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap +mkdir -p autoconfigure/httpclient +git mv httpclient/*.java autoconfigure/httpclient/ +``` + +Then in every moved file change the package declaration to +`package dev.caskeleton.bootstrap.autoconfigure.httpclient;` and fix any now-unresolved import. +Do the same for the two existing tests: + +```bash +cd src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap +mkdir -p autoconfigure/httpclient +git mv httpclient/HttpClientAutoConfigurationTest.java autoconfigure/httpclient/ +git mv httpclient/UnsafeStartupConfigurationTest.java autoconfigure/httpclient/ +``` + +- [ ] **Step 4: Delete the `Clock` bean** + +In `.../autoconfigure/httpclient/HttpClientResilienceAutoConfiguration.java`, remove the +`httpClientClock()` method and the now-unused `ConditionalOnMissingBean` import if nothing else +uses it. Leave `ResilienceRegistry httpClientResilienceRegistry(Clock clock)` taking `Clock` as a +parameter — it is now supplied by the composition root. + +- [ ] **Step 5: Write the master auto-configuration** + +Create `.../autoconfigure/httpclient/HttpClientPlatformAutoConfiguration.java`: + +```java +package dev.caskeleton.bootstrap.autoconfigure.httpclient; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Import; + +/** + * The one entry point through which the HTTP Client platform exists at all. + * + *

This is an auto-configuration rather than a component-scanned {@code @Configuration}, and it + * lives in a package the composition root's scan explicitly excludes. When the master switch is + * absent or false the class is never processed, so none of the configurations it imports are + * discovered either — no properties are bound, no transport provider is constructed, no connection + * pool, TLS context, credential or gateway exists, and a malformed HTTP setting in a deployment + * that never wanted outbound HTTP cannot fail its startup. + * + *

The previous shape had nine independently annotated {@code @Configuration} classes inside the + * scanned package. Gating a wrapper around them would have changed nothing: the component scanner + * finds each child on its own. The children therefore had to move out of the scan with the switch, + * which is why this is a package move and not an annotation. + */ +@AutoConfiguration +@ConditionalOnProperty( + prefix = HttpClientPlatformSettings.PREFIX, + name = "enabled", + havingValue = "true") +@Import({ + HttpClientResilienceAutoConfiguration.class, + HttpClientSecurityAutoConfiguration.class, + HttpClientAuthenticationAutoConfiguration.class, + HttpClientObservationAutoConfiguration.class, + HttpClientTransportAutoConfiguration.class, + HttpClientProfileAutoConfiguration.class, + HttpServiceClientAutoConfiguration.class, + DynamicTargetAutoConfiguration.class, + HttpClientManagementAutoConfiguration.class +}) +public class HttpClientPlatformAutoConfiguration {} +``` + +- [ ] **Step 6: Register the entry** + +Append to +`src/app-bootstrap/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`: + +```text +dev.caskeleton.bootstrap.autoconfigure.httpclient.HttpClientPlatformAutoConfiguration +``` + +- [ ] **Step 7: Run the activation test** + +```bash +cd src && ./gradlew :app-bootstrap:test --tests '*HttpClientPlatformActivationTest' --console=plain +``` + +Expected: PASS for the four OFF cases. `enablingItAssemblesTheDeclaredRuntimes` is expected to fail +until Task 2 introduces the indexed property names; keep it `@Disabled("Task 2")` if it blocks the +loop, and remove the annotation in Task 2 Step 6. + +- [ ] **Step 8: Prove nothing else regressed** + +```bash +cd src && ./gradlew :app-bootstrap:test --console=plain +``` + +Expected: `OptionalAdapterBeanGatingTest` fails — it asserts `context.getBean(ClientRuntimeRegistry)` +returns an empty registry while "disabled". That assertion encodes the wrong contract and is fixed in +Task 4. Every other failure is a real regression and must be fixed here. + +- [ ] **Step 9: Commit (human)** + +Intended message: + +```text +refactor(bootstrap): move the HTTP Client platform behind a single gated auto-configuration +``` + +--- + +### Task 2: Strict indexed settings and aggregate validation + +**Files:** +- Create: `.../autoconfigure/httpclient/HttpClientPlatformSettings.java` +- Create: `.../autoconfigure/httpclient/HttpClientPlatformSettingsBinder.java` +- Delete: `.../autoconfigure/httpclient/HttpClientsProperties.java`, + `.../autoconfigure/httpclient/HttpClientsPropertiesBinder.java`, + `.../autoconfigure/httpclient/DynamicTargetProperties.java` +- Modify: `.../autoconfigure/httpclient/HttpClientProfileFactory.java`, + `.../autoconfigure/httpclient/HttpClientProfileAutoConfiguration.java`, + `.../autoconfigure/httpclient/DynamicTargetAutoConfiguration.java`, + `.../autoconfigure/httpclient/HttpClientPlatformAutoConfiguration.java` +- Test: `.../test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformSettingsTest.java` + +**Interfaces:** +- Consumes: `HttpClientPlatformAutoConfiguration` from Task 1. +- Produces: + - `HttpClientPlatformSettings` — record with `boolean enabled`, `List clients`, + `List dynamicTargets`; constant `String PREFIX = "app.httpclient"`. + - `HttpClientPlatformSettings.ClientSettings` — `String name` plus every field previously on + `HttpClientsProperties.ClientProperties`, same names and defaults. + - `HttpClientPlatformSettings.DynamicTargetSettings` — `String name` plus every field previously on + `DynamicTargetProperties.PolicyProperties`. + - `HttpClientPlatformSettingsBinder.bind(Environment)` → `HttpClientPlatformSettings`, + package-private static. + - `HttpClientProfileFactory.create(HttpClientPlatformSettings)` → + `Map`, replacing `create(HttpClientsProperties)`. + - `HttpClientProfileFactory.toProfile(HttpClientPlatformSettings.ClientSettings)` → + `ClientProfile`, replacing `toProfile(String, ClientProperties)` — the name now comes from the + settings object. + +**Why indexed lists rather than maps.** A map keyed by client name renders in the environment as +`APP_HTTPCLIENT_CLIENTS__...`, and the relaxed binder normalises that segment: two distinct +names that differ only by a hyphen, an underscore or case collapse onto the same variable, so one +profile silently overwrites the other. Carrying the name as a *value* under a numeric index removes +the ambiguity, and the compact constructor can then reject the collision explicitly instead of +letting the last writer win. + +- [ ] **Step 1: Write the failing settings test** + +Create `.../test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformSettingsTest.java`: + +```java +package dev.caskeleton.bootstrap.autoconfigure.httpclient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class HttpClientPlatformSettingsTest { + + @Test + @DisplayName("an enabled platform with no client is a startup failure, not an idle platform") + void anEnabledPlatformWithoutAnyClientIsRefused() { + assertThatThrownBy(() -> new HttpClientPlatformSettings(true, List.of(), List.of())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS"); + } + + @Test + @DisplayName("a disabled platform with no client is the normal case") + void aDisabledPlatformWithoutAnyClientIsFine() { + assertThat(new HttpClientPlatformSettings(false, List.of(), List.of()).clients()).isEmpty(); + } + + @Test + @DisplayName("two clients with the same name are refused") + void duplicateClientNamesAreRefused() { + assertThatThrownBy( + () -> + new HttpClientPlatformSettings( + true, List.of(client("payment"), client("payment")), List.of())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("payment"); + } + + /** + * Two names that are distinct as properties but identical as environment variables. + * + *

{@code payment-api} and {@code payment_api} both render as {@code PAYMENT_API}. Under the + * previous map-keyed shape one would have overwritten the other with nothing said about it. + */ + @Test + @DisplayName("client names that collide once normalised for the environment are refused") + void environmentColludingClientNamesAreRefused() { + assertThatThrownBy( + () -> + new HttpClientPlatformSettings( + true, List.of(client("payment-api"), client("payment_api")), List.of())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("normalise"); + } + + @Test + @DisplayName("a client without a name is refused") + void anUnnamedClientIsRefused() { + assertThatThrownBy(() -> new HttpClientPlatformSettings(true, List.of(client(" ")), List.of())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("name"); + } + + private static HttpClientPlatformSettings.ClientSettings client(String name) { + return new HttpClientPlatformSettings.ClientSettings( + name, + "TRUSTED", + "https://payment.test", + List.of("payment.test"), + List.of(443), + "REST_CLIENT", + "APACHE", + List.of("HTTP_1_1"), + new HttpClientPlatformSettings.Pool( + 50, 25, 100, + java.time.Duration.ofMillis(200), + java.time.Duration.ofSeconds(30), + java.time.Duration.ofMinutes(5), + java.time.Duration.ofSeconds(5), + java.time.Duration.ofSeconds(15), + java.time.Duration.ofSeconds(5), + false, + false), + new HttpClientPlatformSettings.Timeout( + java.time.Duration.ofMillis(300), + java.time.Duration.ofMillis(500), + java.time.Duration.ofSeconds(1), + java.time.Duration.ofMillis(500), + java.time.Duration.ofSeconds(1), + java.time.Duration.ofSeconds(2), + java.time.Duration.ofSeconds(3), + java.time.Duration.ofSeconds(4), + java.time.Duration.ofSeconds(30)), + new HttpClientPlatformSettings.Redirect(false, 0, false), + new HttpClientPlatformSettings.Request(1048576L, false), + new HttpClientPlatformSettings.Response( + 5242880L, 10485760L, List.of("application/json")), + new HttpClientPlatformSettings.Authentication("NONE", null, List.of(), null, null, null), + new HttpClientPlatformSettings.Retry( + "none", + 1, + java.time.Duration.ofMillis(50), + java.time.Duration.ofMillis(200), + "FULL", + "HONOR", + null), + new HttpClientPlatformSettings.Observability(true, false, false), + new HttpClientPlatformSettings.Tls( + "payment", List.of("TLSv1.3"), true, false, false, null, null), + new HttpClientPlatformSettings.Proxy( + false, "", 0, "HTTP", null, java.time.Duration.ofMillis(500), false), + null); + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +```bash +cd src && ./gradlew :app-bootstrap:test --tests '*HttpClientPlatformSettingsTest' --console=plain +``` + +Expected: compilation failure — `HttpClientPlatformSettings` does not exist. + +- [ ] **Step 3: Write the settings record** + +Create `.../autoconfigure/httpclient/HttpClientPlatformSettings.java`. Copy the nested records +`Pool`, `Timeout`, `Redirect`, `Request`, `Response`, `Authentication`, `Retry`, `Observability`, +`Tls`, `Proxy` verbatim from the deleted `HttpClientsProperties`, keeping every `@DefaultValue` +unchanged, and add the root plus the two indexed element types: + +```java +package dev.caskeleton.bootstrap.autoconfigure.httpclient; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.springframework.boot.context.properties.bind.DefaultValue; + +/** + * The whole {@code app.httpclient} surface, bound once, strictly, and only while the platform is on. + * + *

Deliberately not annotated {@code @ConfigurationProperties}: the composition root's + * {@code @ConfigurationPropertiesScan} is not selective, so an annotated class would be registered + * and bound in every deployment — including one that never enables outbound HTTP, which is exactly + * the coupling the master switch exists to remove. + * + *

Clients and dynamic targets are indexed lists carrying their own {@code name} rather than maps + * keyed by name. A map key becomes a segment of the environment variable, and the relaxed binder + * normalises that segment, so {@code payment-api} and {@code payment_api} would resolve to one + * entry with nothing said about the one that was lost. + */ +public record HttpClientPlatformSettings( + @DefaultValue("false") boolean enabled, + @DefaultValue List clients, + @DefaultValue List dynamicTargets) { + + /** Configuration prefix; the canonical environment form is {@code APP_HTTPCLIENT_*}. */ + public static final String PREFIX = "app.httpclient"; + + public HttpClientPlatformSettings { + clients = List.copyOf(clients == null ? List.of() : clients); + dynamicTargets = List.copyOf(dynamicTargets == null ? List.of() : dynamicTargets); + if (enabled && clients.isEmpty()) { + throw new IllegalStateException( + "HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS: " + + PREFIX + + ".enabled is true but no client is declared under " + + PREFIX + + ".clients[*]. An active platform with nothing to call holds transport providers, " + + "gateways and a resilience registry that no caller can reach."); + } + requireDistinctNames( + clients.stream().map(ClientSettings::name).toList(), PREFIX + ".clients"); + requireDistinctNames( + dynamicTargets.stream().map(DynamicTargetSettings::name).toList(), + PREFIX + ".dynamic-targets"); + } + + /** + * Rejects blank, duplicate and environment-colliding names. + * + *

The normalised form is what an operator would have to type as an environment variable, so + * two names that share it cannot both be configured from the environment even though they are + * distinct as properties. + */ + private static void requireDistinctNames(List names, String where) { + List seen = new ArrayList<>(); + Map byNormalisedForm = new LinkedHashMap<>(); + for (String name : names) { + if (name == null || name.isBlank()) { + throw new IllegalStateException(where + "[*].name must be non-blank"); + } + if (seen.contains(name)) { + throw new IllegalStateException(where + " declares '" + name + "' more than once"); + } + seen.add(name); + String normalised = name.toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9]", ""); + String previous = byNormalisedForm.putIfAbsent(normalised, name); + if (previous != null) { + throw new IllegalStateException( + where + + " declares '" + + previous + + "' and '" + + name + + "', which normalise to the same environment variable segment '" + + normalised + + "'. One would silently replace the other."); + } + } + } + + /** One Named Client Profile. Every field keeps the name and default it had under the old map. */ + public record ClientSettings( + String name, + @DefaultValue("TRUSTED") String mode, + String baseUrl, + @DefaultValue List allowedHosts, + @DefaultValue List allowedPorts, + @DefaultValue("REST_CLIENT") String api, + @DefaultValue("APACHE") String transport, + @DefaultValue({"HTTP_1_1"}) List protocols, + @DefaultValue Pool pool, + @DefaultValue Timeout timeout, + @DefaultValue Redirect redirect, + @DefaultValue Request request, + @DefaultValue Response response, + @DefaultValue Authentication authentication, + @DefaultValue Retry retry, + @DefaultValue Observability observability, + @DefaultValue Tls tls, + @DefaultValue Proxy proxy, + String experimentalAcknowledgement) {} + + /** One Dynamic Target policy. */ + public record DynamicTargetSettings( + String name, + @DefaultValue({"https"}) List allowedSchemes, + @DefaultValue({"443"}) List allowedPorts, + @DefaultValue List allowedHostSuffixes, + @DefaultValue List allowedHosts, + @DefaultValue("0") int maxRedirectHops, + @DefaultValue("false") boolean tracePropagation, + @DefaultValue List blockedCidrs) {} + + // ... Pool, Timeout, Redirect, Request, Response, Authentication, Retry, Observability, Tls and + // Proxy copied verbatim from the deleted HttpClientsProperties, javadoc included. +} +``` + +- [ ] **Step 4: Run the settings test** + +```bash +cd src && ./gradlew :app-bootstrap:test --tests '*HttpClientPlatformSettingsTest' --console=plain +``` + +Expected: PASS. + +- [ ] **Step 5: Write the strict binder** + +Create `.../autoconfigure/httpclient/HttpClientPlatformSettingsBinder.java`: + +```java +package dev.caskeleton.bootstrap.autoconfigure.httpclient; + +import org.springframework.boot.context.properties.bind.BindHandler; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.bind.handler.NoUnboundElementsBindHandler; +import org.springframework.core.env.Environment; + +/** + * Binds {@link HttpClientPlatformSettings} strictly, and only when asked. + * + *

Unknown keys under the prefix are refused rather than ignored. Silently dropping + * {@code app.httpclient.clients[0].timeuot.total-call} leaves the client running the default + * four-second budget while the configuration file says otherwise, which is the kind of divergence an + * outbound call platform should never make an operator discover from an incident. + */ +final class HttpClientPlatformSettingsBinder { + + private HttpClientPlatformSettingsBinder() {} + + static HttpClientPlatformSettings bind(Environment environment) { + BindHandler strict = new NoUnboundElementsBindHandler(BindHandler.DEFAULT); + return Binder.get(environment) + .bind(HttpClientPlatformSettings.PREFIX, Bindable.of(HttpClientPlatformSettings.class), strict) + .orElseThrow( + () -> + new IllegalStateException( + HttpClientPlatformSettings.PREFIX + + " could not be bound although the platform is enabled")); + } +} +``` + +- [ ] **Step 6: Rewire the consumers** + +In `HttpClientPlatformAutoConfiguration`, add: + +```java + @Bean + @ConditionalOnMissingBean + HttpClientPlatformSettings httpClientPlatformSettings(Environment environment) { + return HttpClientPlatformSettingsBinder.bind(environment); + } +``` + +In `HttpClientProfileAutoConfiguration`, delete the `httpClientsProperties` bean method and change +`httpClientRuntimeRegistry` to take `HttpClientPlatformSettings properties`. In +`DynamicTargetAutoConfiguration`, delete the `dynamicTargetProperties` bean method and derive both +maps from `HttpClientPlatformSettings#dynamicTargets`, keying by +`new DynamicTargetPolicyName(target.name())`. In `HttpClientProfileFactory`, change +`create` and `toProfile` to the signatures declared in the Interfaces block; the body is otherwise +unchanged apart from reading `client.name()` instead of a map key. + +Finally, in `HttpClientPlatformActivationTest`, switch the property names to the indexed form and +remove the `@Disabled("Task 2")` annotation from `enablingItAssemblesTheDeclaredRuntimes`. + +- [ ] **Step 7: Add the strictness and aggregate cases to the activation test** + +Append to `HttpClientPlatformActivationTest`: + +```java + @Test + @DisplayName("an unknown key under the prefix is refused rather than ignored") + void anUnknownKeyUnderThePrefixIsRefused() { + runner + .withPropertyValues("app.httpclient.enabled=true") + .withPropertyValues(validPaymentClient()) + .withPropertyValues("app.httpclient.clients[0].timeuot.total-call=9s") + .run(context -> assertThat(context).hasFailed()); + } + + @Test + @DisplayName("an enabled platform with no client fails startup with the declared code") + void anEnabledPlatformWithoutAnyClientFailsStartup() { + runner + .withPropertyValues("app.httpclient.enabled=true") + .run( + context -> + assertThat(context) + .hasFailed() + .getFailure() + .hasStackTraceContaining("HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS")); + } +``` + +- [ ] **Step 8: Run the whole module** + +```bash +cd src && ./gradlew :app-bootstrap:test --console=plain +``` + +Expected: the new tests pass; `OptionalAdapterBeanGatingTest` still fails on the stale assertion +fixed in Task 4. + +- [ ] **Step 9: Commit (human)** + +```text +feat(bootstrap): bind the HTTP Client platform strictly from an indexed settings tree +``` + +--- + +### Task 3: ENV SSOT and the field manifest + +**Files:** +- Modify: `src/.env` +- Modify: `src/app-bootstrap/src/main/resources/application.yml` +- Modify: `docs/registries/env-keys.yaml` +- Create: `docs/registries/httpclient-env-fields.yaml` +- Test: `.../test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformEnvManifestTest.java` + +**Interfaces:** +- Consumes: `HttpClientPlatformSettings` from Task 2. +- Produces: `docs/registries/httpclient-env-fields.yaml`, a flat YAML list of + `- field: ` / `env: ` pairs covering every leaf field of the settings + tree. Task 4's documentation verifier reads it. + +**Why the per-client surface is not in `.env`.** `verifyEnvKeys` requires every `.env` key to be +referenced by an `application.yml` placeholder. A client is an indexed list element, so templating +one in `application.yml` would materialise `app.httpclient.clients[0]` in every deployment — with a +blank name, which the aggregate validation from Task 2 correctly refuses. The review reaches the same +conclusion: keep the enable key in `application.yml`, bind the detail from the environment directly, +and prove the surface is complete with a manifest and a reflection test rather than with a template +nobody can leave in place. + +- [ ] **Step 1: Write the failing manifest parity test** + +Create `.../test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformEnvManifestTest.java`: + +```java +package dev.caskeleton.bootstrap.autoconfigure.httpclient; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.lang.reflect.RecordComponent; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.BindHandler; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.bind.handler.NoUnboundElementsBindHandler; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.core.env.SystemEnvironmentPropertySource; + +/** + * Closes the loop between the settings tree and its documented environment surface. + * + *

"Every setting is managed through the environment" is a claim about two things at once: that + * each field has an environment form, and that each documented environment name still maps to a + * field. Nothing enforced either. A field added to a nested record acquired no documentation, and a + * documented name whose field was renamed kept being published to operators who would set it and + * see nothing happen. + * + *

The names are fed in as real environment variables through a + * {@link SystemEnvironmentPropertySource}, not as hand-translated property names, so what is under + * test is the mapping the runtime actually performs. Binding strictly settles the rest: an element + * the binder cannot place fails here rather than in production. + */ +class HttpClientPlatformEnvManifestTest { + + @Test + @DisplayName("every leaf field of the settings tree is in the manifest, and vice versa") + void theManifestAndTheSettingsTreeAgree() { + Map derived = envTemplatesOf(HttpClientPlatformSettings.class, "APP_HTTPCLIENT"); + + assertThat(manifest().keySet()) + .as( + "docs/registries/httpclient-env-fields.yaml must list exactly the leaf fields of " + + "HttpClientPlatformSettings") + .containsExactlyInAnyOrderElementsOf(derived.keySet()); + assertThat(manifest()).containsAllEntriesOf(derived); + } + + @Test + @DisplayName("a client declared purely through environment variables binds strictly") + void aClientDeclaredThroughTheEnvironmentBinds() { + Map environmentVariables = new LinkedHashMap<>(); + environmentVariables.put("APP_HTTPCLIENT_ENABLED", "true"); + environmentVariables.put("APP_HTTPCLIENT_CLIENTS_0_NAME", "payment"); + environmentVariables.put("APP_HTTPCLIENT_CLIENTS_0_BASE_URL", "https://payment.test"); + environmentVariables.put("APP_HTTPCLIENT_CLIENTS_0_ALLOWED_HOSTS_0", "payment.test"); + environmentVariables.put("APP_HTTPCLIENT_CLIENTS_0_ALLOWED_PORTS_0", "443"); + environmentVariables.put("APP_HTTPCLIENT_CLIENTS_0_REQUEST_MAX_BODY_BYTES", "1048576"); + environmentVariables.put("APP_HTTPCLIENT_CLIENTS_0_TLS_PROFILE_ID", "payment"); + environmentVariables.put("APP_HTTPCLIENT_DYNAMIC_TARGETS_0_NAME", "webhook"); + environmentVariables.put("APP_HTTPCLIENT_DYNAMIC_TARGETS_0_ALLOWED_SCHEMES_0", "https"); + + StandardEnvironment environment = new StandardEnvironment(); + environment + .getPropertySources() + .addFirst( + new SystemEnvironmentPropertySource( + StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME, environmentVariables)); + + HttpClientPlatformSettings bound = + Binder.get(environment) + .bind( + HttpClientPlatformSettings.PREFIX, + Bindable.of(HttpClientPlatformSettings.class), + new NoUnboundElementsBindHandler(BindHandler.DEFAULT)) + .orElseThrow(() -> new AssertionError("the declared environment bound to nothing")); + + assertThat(bound.enabled()).isTrue(); + assertThat(bound.clients()).singleElement().satisfies(client -> { + assertThat(client.name()).isEqualTo("payment"); + assertThat(client.baseUrl()).isEqualTo("https://payment.test"); + assertThat(client.allowedPorts()).containsExactly(443); + assertThat(client.request().maxBodyBytes()).isEqualTo(1048576L); + assertThat(client.tls().profileId()).isEqualTo("payment"); + }); + assertThat(bound.dynamicTargets()) + .singleElement() + .satisfies(target -> assertThat(target.name()).isEqualTo("webhook")); + } + + /** The shipped default must be off. */ + @Test + @DisplayName("src/.env ships the platform disabled") + void theShippedEnvironmentKeepsThePlatformOff() { + assertThat(readEnvFile()) + .anySatisfy(line -> assertThat(line.trim()).isEqualTo("APP_HTTPCLIENT_ENABLED=false")); + } + + /** + * Walks the record tree and renders each leaf as the environment name an operator would set. + * + *

A {@code List} of records becomes an indexed segment; a {@code List} of scalars becomes an + * indexed leaf. Both are rendered with a literal {@code N} so the manifest describes a template + * rather than one deployment's cardinality. + */ + private static Map envTemplatesOf(Class type, String prefix) { + Map templates = new LinkedHashMap<>(); + collect(type, prefix, "", templates); + return templates; + } + + private static void collect( + Class type, String envPrefix, String pathPrefix, Map into) { + for (RecordComponent component : type.getRecordComponents()) { + String property = camelToKebab(component.getName()); + String path = pathPrefix.isEmpty() ? property : pathPrefix + "." + property; + String env = envPrefix + "_" + camelToScreamingSnake(component.getName()); + Class componentType = component.getType(); + if (componentType.isRecord()) { + collect(componentType, env, path, into); + continue; + } + if (List.class.isAssignableFrom(componentType)) { + Class element = elementTypeOf(component); + if (element != null && element.isRecord()) { + collect(element, env + "_N", path + "[N]", into); + continue; + } + into.put(path + "[N]", env + "_N"); + continue; + } + into.put(path, env); + } + } + + private static Class elementTypeOf(RecordComponent component) { + if (component.getGenericType() + instanceof java.lang.reflect.ParameterizedType parameterized + && parameterized.getActualTypeArguments()[0] instanceof Class element) { + return element; + } + return null; + } + + private static String camelToKebab(String name) { + return name.replaceAll("([a-z0-9])([A-Z])", "$1-$2").toLowerCase(Locale.ROOT); + } + + private static String camelToScreamingSnake(String name) { + return name.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toUpperCase(Locale.ROOT); + } + + /** Reads the manifest without a YAML parser: it is a flat two-key list by construction. */ + private static Map manifest() { + Map entries = new LinkedHashMap<>(); + String field = null; + for (String line : readLines(repositoryRoot().resolve("docs/registries/httpclient-env-fields.yaml"))) { + String trimmed = line.trim(); + if (trimmed.startsWith("- field:")) { + field = trimmed.substring("- field:".length()).trim(); + } else if (trimmed.startsWith("env:") && field != null) { + entries.put(field, trimmed.substring("env:".length()).trim()); + field = null; + } + } + return entries; + } + + private static List readEnvFile() { + Path fromModule = Path.of(System.getProperty("user.dir")).resolve(".env"); + return readLines(Files.exists(fromModule) ? fromModule : Path.of("..").resolve(".env")); + } + + /** The module's working directory is {@code src/app-bootstrap} under Gradle. */ + private static Path repositoryRoot() { + Path candidate = Path.of(System.getProperty("user.dir")).toAbsolutePath(); + List tried = new ArrayList<>(); + for (int depth = 0; depth < 4 && candidate != null; depth++) { + tried.add(candidate); + if (Files.exists(candidate.resolve("docs/registries/httpclient-env-fields.yaml"))) { + return candidate; + } + candidate = candidate.getParent(); + } + throw new AssertionError("repository root not found from " + tried); + } + + private static List readLines(Path path) { + try { + return Files.readAllLines(path); + } catch (IOException exception) { + throw new UncheckedIOException(path + " could not be read", exception); + } + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +```bash +cd src && ./gradlew :app-bootstrap:test --tests '*HttpClientPlatformEnvManifestTest' --console=plain +``` + +Expected: FAIL — `docs/registries/httpclient-env-fields.yaml` does not exist, so +`repositoryRoot()` throws. + +- [ ] **Step 3: Generate the manifest from the settings tree** + +Run the derivation once and write its output. The quickest honest way is to let the test print it: +temporarily add `System.out.println(...)` over `derived` in +`theManifestAndTheSettingsTreeAgree`, run the single test, capture the output, and write it as +`docs/registries/httpclient-env-fields.yaml` in this shape (header plus one entry per leaf): + +```yaml +# HTTP Client platform — Java field path to environment variable template. +# +# The SSOT is HttpClientPlatformSettings. HttpClientPlatformEnvManifestTest derives this list from +# the record tree and fails when the two disagree in either direction, so an added field with no +# entry and an entry whose field was renamed are both build failures. +# +# `N` is a list index, not a literal. `app.httpclient.clients[N].base-url` is set as +# APP_HTTPCLIENT_CLIENTS_0_BASE_URL for the first client. +# +# Only APP_HTTPCLIENT_ENABLED is registered in docs/registries/env-keys.yaml and shipped in +# src/.env: it is the only key with a deployment-independent value. Everything below is per +# deployment and is set directly in the environment. +fields: + - field: enabled + env: APP_HTTPCLIENT_ENABLED + - field: clients[N].name + env: APP_HTTPCLIENT_CLIENTS_N_NAME + - field: clients[N].base-url + env: APP_HTTPCLIENT_CLIENTS_N_BASE_URL + # ... one entry per leaf, in the order the derivation emits them +``` + +Then remove the temporary `println`. + +- [ ] **Step 4: Add the master key to the three-way gate** + +`src/.env` — append beside the other capability master switches: + +```dotenv +# === HTTP Client platform (app.httpclient.*) === +# Off by default. While false no HTTP client property is bound, no transport provider, connection +# pool, TLS context, credential or gateway is created, and no HTTP thread exists. Per-client +# settings are set directly in the environment; docs/registries/httpclient-env-fields.yaml is their +# registry. +APP_HTTPCLIENT_ENABLED=false +``` + +`src/app-bootstrap/src/main/resources/application.yml` — under `app:`: + +```yaml + httpclient: + # Master switch for the outbound HTTP Client platform. Only this key lives here: the per-client + # surface is an indexed list whose element cannot be templated without materialising a nameless + # client in every deployment, so it is bound from the environment directly. + # docs/registries/httpclient-env-fields.yaml is the registry for those names. + enabled: ${APP_HTTPCLIENT_ENABLED:false} +``` + +`docs/registries/env-keys.yaml` — add an entry in the same shape as +`APP_FILESERVER_PLATFORM_ENABLED`: + +```yaml + # === HTTP Client platform (app.httpclient.*) === + + - name: APP_HTTPCLIENT_ENABLED + # Master switch. While false the platform block is not bound at all: the auto-configuration that + # binds it is not processed, so no bean, pool, TLS context, credential, thread or gateway exists. + type: boolean + default: false + allowed_values: [true, false] + classification: public-config + required: false + reload_policy: restart-only + owner_branch: httpclient-platform-activation-boundary + validation: boolean_strict +``` + +- [ ] **Step 5: Run the manifest test and the env gate** + +```bash +cd src && ./gradlew :app-bootstrap:test --tests '*HttpClientPlatformEnvManifestTest' --console=plain +cd src && ./gradlew verifyEnvKeys --console=plain +``` + +Expected: both PASS. + +- [ ] **Step 6: Commit (human)** + +```text +feat(config): declare the HTTP Client platform's environment surface +``` + +--- + +### Task 4: Migrate the existing tests, gate the actuator endpoint, update the docs + +**Files:** +- Modify: `src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java` +- Modify: `.../test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientAutoConfigurationTest.java` +- Modify: `.../test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/UnsafeStartupConfigurationTest.java` +- Modify: `docs/httpclient/configuration-reference.md` +- Modify: `scripts/verify-httpclient-docs.py` + +**Interfaces:** +- Consumes: everything produced by Tasks 1–3. +- Produces: no new production types. + +- [ ] **Step 1: Fix the gating test's stale contract** + +In `OptionalAdapterBeanGatingTest`, replace the six `HttpClient*AutoConfiguration` entries in +`withUserConfiguration(...)` with nothing, and replace + +```java + assertThat(context.getBean(ClientRuntimeRegistry.class).names()).isEmpty(); +``` + +with + +```java + // Not "an empty registry": with the platform off there is no registry, no transport + // provider and no gateway. An empty registry bean was the previous contract and it is + // what made the capability mandatory-with-a-switch rather than optional. + assertThat(context.getBeansOfType(ClientRuntimeRegistry.class)).isEmpty(); +``` + +Add the platform to the runner as a real auto-configuration so the OFF path is exercised through the +same entry the application uses: + +```java + .withConfiguration( + AutoConfigurations.of( + dev.caskeleton.bootstrap.autoconfigure.httpclient + .HttpClientPlatformAutoConfiguration.class)) +``` + +- [ ] **Step 2: Route the two moved tests through the single entry** + +In both `HttpClientAutoConfigurationTest` and `UnsafeStartupConfigurationTest`, replace the +multi-entry `AutoConfigurations.of(...)` with +`AutoConfigurations.of(HttpClientPlatformAutoConfiguration.class)`, add +`"app.httpclient.enabled=true"` to every runner that expects beans, convert every +`http-clients..` property to `app.httpclient.clients[0].` plus +`app.httpclient.clients[0].name=`, convert every `http-dynamic-targets..` to +`app.httpclient.dynamic-targets[0].` plus a `name`, and add the `Clock` bean to the supporting +configuration (the platform no longer supplies one). + +`UnsafeStartupConfigurationTest#anUnconfiguredDeploymentHoldsNoHttpRuntimeResources` asserted that an +enabled-but-empty platform yields an empty registry. That case is now +`HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS` and is covered in `HttpClientPlatformActivationTest`; delete it +here rather than restating it. + +- [ ] **Step 3: Run the module** + +```bash +cd src && ./gradlew :app-bootstrap:test --console=plain +``` + +Expected: PASS, no skips beyond the module's pre-existing ones. + +- [ ] **Step 4: Update the configuration reference** + +In `docs/httpclient/configuration-reference.md`: replace the `http-clients.` prefix with +`app.httpclient.clients[N]`, replace `http-dynamic-targets.` with +`app.httpclient.dynamic-targets[N]`, document `app.httpclient.enabled` as the master switch with its +`APP_HTTPCLIENT_ENABLED` form and the "off means nothing is bound" contract, and correct the default +protocol from "HTTP2+HTTP1" to `HTTP_1_1` — the code has always said `HTTP_1_1` and the document has +always said otherwise. + +- [ ] **Step 5: Point the documentation verifier at the new type** + +In `scripts/verify-httpclient-docs.py`, change the source of code-derived names from +`HttpClientsProperties.java` and `DynamicTargetProperties.java` to +`HttpClientPlatformSettings.java`, and extend it to walk nested records rather than only top-level +components. + +```bash +python3 -B scripts/verify-httpclient-docs.py +``` + +Expected: exits 0 and reports a name count at least as large as the previous 90. + +- [ ] **Step 6: Commit (human)** + +```text +test(bootstrap): assert the HTTP Client platform's off state through its real entry point +``` + +--- + +## Verification + +Run from `src/` after Task 4: + +```bash +./gradlew :app-bootstrap:test --console=plain +./gradlew verifyCleanArchitectureDependencies --console=plain +./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain +./gradlew verifyEnvKeys --console=plain +./gradlew verifyOneTypePerFile --console=plain +./gradlew :adapter:outbound:httpclient:test --console=plain +``` + +and from the repository root: + +```bash +python3 -B scripts/verify-httpclient-docs.py +git diff --check +``` + +`:adapter:outbound:httpclient:test` is in the list because nothing in this plan should touch the +adapter leaf; a failure there means a change leaked across the boundary. + +## Self-Review + +**Spec coverage.** Review P0 #1 is Tasks 1, 2 and 4 (single scan-excluded entry, strict boolean +toggle, zero beans while off, `HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS`, actuator under the master flag via +`HttpClientManagementAutoConfiguration` being imported rather than scanned, and the three tests that +encoded the wrong OFF semantics rewritten). Review P0 #2 is Tasks 2 and 3 (indexed clients with the +name as a value, unknown-field rejection, duplicate and normalisation-collision rejection, the field +manifest, the reflection parity test, and the real `SystemEnvironmentPropertySource` binding test). + +Two P0 #2 sub-items are deliberately **not** covered and belong to the settings-compiler plan that +follows: "every declared setting is used in `ValidatedClientPlan` or explicitly rejected", and +"`ResilienceRegistry.ofDefaults()`'s hidden circuit/rate/bulkhead policy is either configurable or +documented as a safe constant". Both need the compiler this plan does not build; recording them here +so the next plan starts from a known gap rather than rediscovering it. + +**Placeholder scan.** No step says "add validation" or "handle edge cases" without the code. The one +elision is the ten nested settings records in Task 2 Step 3, which are an explicit verbatim copy of a +file the plan names and which the step's comment marks; reproducing 90 lines of unchanged record +declarations would have obscured the four types that actually change. + +**Type consistency.** `HttpClientPlatformSettings.PREFIX` is `"app.httpclient"` in Task 2 and is what +Task 1's `@ConditionalOnProperty` and Task 3's binder test both reference. +`HttpClientProfileFactory.create(HttpClientPlatformSettings)` and +`toProfile(HttpClientPlatformSettings.ClientSettings)` are declared once in Task 2's Interfaces block +and used with those signatures in Step 6. `dynamicTargets` is the record component name throughout, +rendering as `app.httpclient.dynamic-targets[N]` in properties and +`APP_HTTPCLIENT_DYNAMIC_TARGETS_N_*` in the environment, which is what Task 3's binding test asserts. diff --git a/docs/superpowers/plans/2026-08-10-redis-optionality-and-composition.md b/docs/superpowers/plans/2026-08-10-redis-optionality-and-composition.md new file mode 100644 index 00000000..260eec6b --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-redis-optionality-and-composition.md @@ -0,0 +1,366 @@ +# Redis Optionality and Composition Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development +> (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make Redis genuinely optional at both ends — `APP_REDIS_ENABLED=false` loads, binds, +validates and allocates nothing Redis-shaped, and `APP_REDIS_ENABLED=true` assembles a validated, +fail-fast Redis runtime — and close the SDK correctness defects that must not be wired live. + +**Architecture:** A single conditional composition root (`RedisSdkAutoConfiguration`) owns +`RedisSdkSettings`, its validation, its secret/credential resolution, and its resource loading. +Nothing Redis-shaped is registered by the global `@ConfigurationPropertiesScan`. Secret requirements +move from the unconditional bootstrap list into that conditional owner. The SDK stays an +implementation detail of the `adapter:outbound:cache-redis` leaf; provider-neutral semantic ports +are re-implemented on top of it in a later phase. + +**Tech Stack:** Java 21, Spring Boot 4.0.0, Lettuce, Gradle (fail-closed 19-leaf registry), JUnit 5, +AssertJ, ArchUnit. + +## Status — 2026-08-10 + +| Review item | State | Where | +| --- | --- | --- | +| P1 #1 optionality (settings/validation half) | done | `RedisSdkAutoConfiguration`, `RedisSdkSettings`, `RedisOptionalityContractTest` | +| P1 #1 optionality (client/runtime half) | done | Phase D: `RedisTopologyClientFactory`, `RedisRuntimeOwner`, `RedisStartupProbe`, health contributors | +| P1 #2 production Redis secrets | done | `SecretSourceValidator`, `RedisActivationValidator` | +| P1 #3 env SSOT for the 34 settings | done | `env-keys.yaml`, `verifyEnvKeys` check E | +| P1 #4 semantic adapters | 4 of 5 | rate-limit, lease, idempotency V2, cache done. **Session is blocked, not deferred**: no provider-neutral session contract exists in `application-core` or `shared-contract` — it was deleted with the previous generation and the bootstrap references it only by bean name. Restoring it is a contract design task, not a port implementation, and the review does not specify that contract. | +| P1 #5 counter TTL | done | `AtomicCounterScripts` | +| P1 #6 transaction slot (aggregate check) | done | `LettuceRedisTransactionOperations.AttemptSlot` | +| P1 #6 transaction exclusive connection lease | done | typed `RedisLease` with `invalidate()`; the TRANSACTION lane is bounded and a poisoned connection is never pooled | +| P1 #7 telemetry isolation | done | `NoThrowObservationSink`, all three executors | +| P1 #8 topology lane fail-closed | done | `cache-redis/build.gradle` | +| P1 #9 README three-state split | done | `cache-redis/README.md` | +| TLS lane | done | `infra/redis-sdk/tls/compose.yml`, plaintext port off, certificates generated at start-up | +| P1 #9 PR/nightly/RC release gates | done | `redis-sdk-topology.yml` PR/schedule/RC matrix + evidence artifacts; gate promoted from `delegated-pending` | +| P1 #10 Netty floor | done | `ext['netty.version'] = '4.2.17.Final'`, all lockfiles | +| Phase B3 orphan configuration removal | done | 4 blocks removed from `application.yml`, 33 `.env` keys dropped, registry rows deprecated | + +### P2/P3 hardening + +| Item | State | Where | +| --- | --- | --- | +| Multi-key permit dead branch | done | `CommandPolicyGuard.requirePermits`; set algebra and blocking list now present a multi-key permit | +| Codec type safety | done | `RedisCodecRegistry` records the declared type and refuses a mismatched lookup | +| Error metadata on decode failure | done | `RedisFailureMetadata.storedDataCorruption`, deployment mode threaded from the caller | +| Pub/Sub codec per target | done | per-channel codec map; pattern subscriptions must agree on one codec | +| Pub/Sub backpressure | done | `SubscriptionFlux` bounded buffer + explicit overflow policy, decode failure terminates | +| Admin `CONFIG GET` | done | fixed allowlisted projection, secret-shaped values redacted, no caller pattern | +| Reply budget | done (consolidated) | dead `CommandPolicyGuard.validateReply` removed; `RedisOperationContext.requireReplyWithinBudget` is the single authority | +| Sentinel durability probe | done | `min-replicas-max-lag` now required alongside the replica count | +| Missing raw allowlist resource | done | `RedisSdkAutoConfiguration` opens it at startup | +| ACL fixture | done | `user default off`, fixture-only header, named-credential instructions | +| Readiness false-green | done | `validate-group-membership: true`, group names only contributors that exist | +| Dependency drift | done | unused `spring-data-redis`/`micrometer-core` removed, Reactor declared directly | +| JSON framing | done | control characters escaped, schema identifier constrained by regex | +| Connection lifecycle state machine | done | `RedisRuntimeOwner` `OPEN→DRAINING→CLOSED` | +| Gateway/`CommandRequest` visibility | **open** | needs `sdk.programmability`, `sdk.raw`, `sdk.admin` and `sdk.extensions` to stop constructing requests directly; a package restructuring, not a rename | +| Raw movable keys (`SORT BY/GET/STORE`) | done | `RawMovableKeys` settles SORT/SORT_RO locally including the STORE destination; BY/GET stay refused because their patterns cannot be namespace-checked, and an unknown option is a rejection rather than a guess | +| Batch observed-aggregate reply bytes | done | `BatchExecution` accumulates measured replies and fails the item that crosses the ceiling | + +Residual limitation on P1 #6: keys queued inside the callback are only knowable after `MULTI`, so +the aggregate slot is enforced as each key becomes known — the offending command is refused before +it is written and the window is discarded, rather than the whole attempt being refused before +`WATCH`. Refusing before `WATCH` in every case needs a declared-keys transaction API, which Phase E +would revisit anyway. + +## Global Constraints + +- Registry SSOT for module identity, Gradle paths and allowed edges is + `src/config/architecture/modules.json`. Never infer a Gradle path. +- Commit policy is `human-only`. Agents do not stage, commit, amend, or push. +- `domain-core` must stay free of framework/transport/database/cloud dependencies. +- `application-core` must never see an SDK type, a Redis key, a topology or a connection type. +- Global Redis activation is exactly one switch: `APP_REDIS_ENABLED`. `APP_CACHE_REDIS_ENABLED` + must not be a second master switch. +- Every new `APP_*` key must land in all four places or `verifyEnvKeys` fails: + `src/app-bootstrap/src/main/resources/application.yml`, `src/.env`, + `docs/registries/env-keys.yaml`, and (when secret-classified) + `docs/registries/secrets-classification.yaml`. +- `SecretsClassificationRegistryTest` asserts `SecretSourceValidator.REQUIRED_PROD_SECRETS` matches + `docs/registries/secrets-classification.yaml` 1:1. Changing one requires changing the other. +- Netty floor: `4.2.16` or higher (CVE-2026-42577 epoll `<4.2.13`, CVE-2026-59901 + codec-compression `<4.2.16`). +- Topology lane modes allowlist: exactly `STANDALONE`, `SENTINEL`, `CLUSTER`. +- Verification commands run from `src/`. + +## Current-state facts this plan is written against + +Established by direct inspection on 2026-08-10, working tree (not HEAD): + +- `CaSkeletonApplication` scans `dev.caskeleton.adapter` for `@ConfigurationProperties`, so + `RedisSdkSettings` (`ca-skeleton.capabilities.redis-sdk`) is registered with Redis off. +- `RedisSdkSettings.validate()` has no production caller. +- The `cache-redis` leaf has **no** `@Bean`, `@Configuration`, or `@AutoConfiguration` in main + source: nothing constructs a client, connection, gateway, or health contributor. +- 240 tracked main-source files under `cache-redis` are deleted in the working tree; the SDK + (~300 files under `…cache.redis.sdk`) is untracked. The semantic cache/session/idempotency/ + rate-limit/lease adapters are gone. +- `ca-skeleton.providers.redis.*`, `ca-skeleton.capabilities.cache.*`, and + `ca-skeleton.security.redis-session.*` in `application.yml` bind to **no** Java type — orphan + configuration from the previous generation. +- `SecretSourceValidator.REQUIRED_PROD_SECRETS` requires `APP_CACHE_REDIS_PASSWORD` and + `APP_CACHE_REDIS_KEY_HMAC_SECRET` unconditionally in prod; the other Redis roles have + conditional skips. +- `verifyEnvKeys` compares only the three text sets (`.env`, `application.yml` placeholders, + `env-keys.yaml`); it never reads `spring-configuration-metadata.json`, so a typed property with + no env name passes. +- `redisTopologyTest` builds its tag as `lane-${declaredMode}` from an unvalidated project + property, with no mode allowlist and no positive test-count postcondition — an unknown mode + selects zero tests and exits 0. +- `src/app-bootstrap/gradle.lockfile` pins `io.netty:*:4.2.7.Final` on + `productionRuntimeClasspath`, and still carries a `redisCompositionTestRuntimeClasspath` + configuration whose source set no longer exists. + +--- + +## Phase A — Redis optionality (P1 #1, #2) and the dead second switch + +### Task A1: Remove the unconditional production Redis secret requirement + +**Files:** +- Modify: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceValidator.java` +- Modify: `docs/registries/secrets-classification.yaml` +- Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/SecretSourceValidatorTest.java` + +**Interfaces:** +- Produces: `SecretSourceValidator.REQUIRED_PROD_SECRETS` without any `APP_CACHE_REDIS_*` entry; + `isCacheRedisMaterial(String)` + `isRedisGloballyEnabled()` private helpers gating every + remaining Redis-prefixed secret on `app.redis.enabled`. + +- [ ] **Step 1: Write the failing test** — prod profile, Redis off, no Redis secrets present, + validator must not throw. +- [ ] **Step 2: Run it and watch it fail** on the two cache secrets. +- [ ] **Step 3: Gate every Redis secret on `app.redis.enabled` plus its role selector.** +- [ ] **Step 4: Re-run the focused test class.** +- [ ] **Step 5: Update `secrets-classification.yaml` `required_in_prod` metadata to match.** + +### Task A2: Stop the global scan from registering `RedisSdkSettings` + +**Files:** +- Modify: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/CaSkeletonApplication.java` + (exclude the SDK config package) **or** move `RedisSdkSettings` out of a scanned package — + preferred: keep the class where it is and drop `@ConfigurationProperties` from it, binding it + instead from the conditional configuration with `@ConfigurationProperties` on the `@Bean` method. +- Test: new bootstrap contract test asserting zero `RedisSdkSettings` beans when + `app.redis.enabled` is absent or false. + +### Task A3: `RedisSdkAutoConfiguration` — the ON/OFF composition root + +**Files:** +- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkAutoConfiguration.java` +- Create: `src/adapter/outbound/cache-redis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` +- Test: `…/sdk/config/RedisSdkAutoConfigurationTest.java` (ApplicationContextRunner) + +Conditions: `@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "true")`. +Inside: bind settings, call `validate()` and fail the context on `IllegalStateException`, log +warnings, then (Phase D) build the topology client. + +### Task A4: Retire `APP_CACHE_REDIS_ENABLED` as a second master switch + +**Files:** +- Modify: `src/app-bootstrap/src/main/resources/application.yml` (add `app.redis.enabled`) +- Modify: `src/.env`, `docs/registries/env-keys.yaml` + +--- + +## Phase B — env SSOT migration (P1 #3) + +### Task B1: Register `APP_REDIS_ENABLED` and the 34 SDK settings + +Names are fixed by the review's env contract table. Each `env-keys.yaml` row carries +`property`, `owner_module`, `type`, `default`, `secret`, `required_when`, and (where one exists) +`deprecated_alias` + `removal_deadline`. + +### Task B2: Extend `verifyEnvKeys` to read `spring-configuration-metadata.json` + +Bidirectional: a typed `app.redis.*` property with no registry row fails; a registry row whose +`property` matches no metadata entry fails. + +### Task B3: Remove the orphan generations + +Delete `ca-skeleton.providers.redis.*`, `ca-skeleton.capabilities.cache.*`, and +`ca-skeleton.security.redis-session.*` from `application.yml` once a migration table records the +old→new mapping; drop the now-orphaned `.env` keys; mark the registry rows deprecated rather than +deleting their metadata. + +--- + +## Phase C — SDK correctness (P1 #5, #6, #7) + +### Task C1: Atomic counter must not add a TTL to a pre-existing persistent key + +**Files:** +- Modify: `…/sdk/lettuce/operations/AtomicCounterScripts.java` +- Test: `…/sdk/lettuce/operations/AtomicCounterScriptsTest.java` + +Both scripts must record existence **before** the increment and apply the initial expiry only when +the key was absent: + +```lua +local existed = redis.call('EXISTS', KEYS[1]) +local value = redis.call('INCRBY', KEYS[1], ARGV[1]) +if existed == 0 then + if ARGV[3] == 'AT' then + redis.call('PEXPIREAT', KEYS[1], ARGV[2]) + else + redis.call('PEXPIRE', KEYS[1], ARGV[2]) + end +end +return value +``` + +### Task C2: Validate the transaction's whole key set against one slot + +**Files:** +- Modify: `…/sdk/programmability/LettuceRedisTransactionOperations.java` +- Test: `…/sdk/programmability/LettuceRedisTransactionOperationsTest.java` + +Collect watched + queued keys per attempt and validate the aggregate slot before `MULTI`, instead +of validating the WATCH bundle and each queued write independently. + +### Task C3: A throwing observation sink must not fail a successful command + +**Files:** +- Create: `…/sdk/lettuce/observability/NoThrowObservationSink.java` +- Modify: `…/sdk/lettuce/command/SyncRedisCommandExecutor.java` +- Modify: `…/sdk/lettuce/command/ReactiveRedisCommandExecutor.java` +- Test: `…/sdk/lettuce/command/ObservationIsolationTest.java` + +--- + +## Phase D — Runtime composition (P1 #4 prerequisite, deferred) + +Topology strategy (standalone/sentinel/cluster), authentication/TLS, shared vs dedicated +connection lanes, lifecycle owner, capability/durability probe, health contributors. + +## Phase E — Semantic adapter restoration (P1 #4, deferred) + +Re-implement the provider-neutral ports on top of the SDK: cache, session, idempotency V2, +rate-limit, efficiency-only lease. This is the restoration of the 240 deleted files' behaviour and +is the largest single body of work in this plan. + +## Phase F — Release gates, evidence and dependencies (P1 #8, #9, #10) + +### Task F1: `redisTopologyTest` fails closed + +Mode allowlist, `failOnNoDiscoveredTests = true`, per-lane required tag/class presence, and a +`>= 1` executed-test postcondition. + +### Task F2: Netty floor `4.2.16` + +Add a platform constraint, regenerate every lockfile, rerun the dependency scan. + +### Task F3: README status split + +`API implemented` / `Spring composition implemented` / `production-qualified` as three separate +states. + +## Phase G — P2/P3 hardening (deferred) + +Gateway/request visibility, multi-key permit dead branch, connection lifecycle state machine, +reply budgets, admin `CONFIG GET` projection, pub/sub codec mapping and backpressure, codec type +safety, error metadata, raw movable keys, Sentinel durability probe, ACL fixture, readiness +false-green, missing raw resource, dependency drift, JSON framing. + +--- + +## Round 2 — the defects a real server found that this plan did not + +Everything above was written before any of it had run against Redis. A second review started four +Docker lanes, wired the production code to them, and found that several items marked done were +done in the sense that the code existed, not in the sense that it worked. What follows is what that +round changed, and what it changed because of. + +### The readiness group could not start at all + +`management.endpoint.health.group.readiness.include` named `redisRequired`, a contributor that only +exists when a correctness role selected Redis. Boot validates group membership and does **not** +tolerate a conditional member being absent, so every Redis-off and cache-only deployment failed at +startup with `Included health contributor 'redisRequired' in group 'readiness' does not exist`. The +comment in `application.yml` asserted the opposite. + +The group now names only unconditional contributors, and +`RedisReadinessGroupPostProcessor` appends `redisRequired` from `RedisCorrectnessRoles` — the same +predicate the bean's `@Conditional` asks, so membership and existence cannot drift. +`RedisReadinessGroupPostProcessorTest` boots a real Actuator context in each of the three shapes; +putting the name back in the shipped file makes two of them fail exactly as production did. + +### Redis on composed no capability + +`APP_REDIS_ENABLED=true` produced a client, an owner and a health contributor. Every semantic port +count was zero, so a deployment that selected `redis` for its rate limiter started, reported +healthy, and had no rate limiter. `RedisCapabilityConfig` composes cache, rate limit, lease and the +owner-safe idempotency store, each on its own selector. + +The idempotency guard was also counting `application.idempotency.IdempotencyStorePortV2`, which no +provider implements — the implemented contract is the one in `…idempotency.v2`. Selecting `redis` +therefore required a bean nothing could supply. Driving the V2 store from an executor remains +outstanding and is named as such rather than covered by a guard that cannot see it. + +### Four key prefixes, and an ACL that matched none of them + +Each capability joined its own `namespace-application` / `namespace-environment` pair in its own +order, so the cache wrote `ca-skeleton:prod:…` while the ACL granted `~prod:*`. `CapabilityKeyspace` +renders every capability below one `RedisNamespace`, and the per-capability namespace keys are +deprecated. + +The scripted capabilities also ran `EVALSHA` on the application account, which does not have it. +Lanes now carry a `RedisCredentialRole`; the topology factory builds one client per configured +role, so the `SCRIPT` lane authenticates as the advanced account and the account that reads a cache +entry still cannot execute a script. `LiveRedisSemanticPortsTest` proves both directions against a +real server. + +### Cluster transactions were impossible, and multi-key WATCH was refused + +`beginTransaction()` on a live cluster failed by design: every lane opened the slot-routing +connection, which cannot own a window. `RedisTransactionRunner` derives a routing key and pins the +lane to the node that owns the slot. Fixing that surfaced a second defect a cluster was not needed +for — `watch()` presented no multi-key permit, so watching more than one key was rejected +unconditionally, which is most optimistic transactions. + +### The fixtures could not fail + +Every ACL account was `nopass`, which accepts any password: every assertion about authentication +passed for the same reason a typo would have. The accounts carry real passwords and a wrong one is +now asserted to produce `WRONGPASS`. The cluster lane's readiness helper checked +`CLUSTER INFO` unauthenticated, so it never matched, never exited, and `up --wait` returned while +slots were still being assigned; a `ready` gate now blocks on `cluster_state:ok`. + +### TLS was reachable only by hand + +`tls` is a lane of `redisTopologyTest` and of the CI matrix. Trust material resolved with +`new File(...)` broke `classpath:` references, and resolving it purely through the resource loader +breaks mounted paths — both shapes are ordinary, and both are supported. + +### Gates that could report success for a lane they did not run + +`afterTest` fires for skipped tests too, so the "ran something" check could be satisfied by a run +that skipped everything. Lanes now declare the classes they exist to run and a floor for the +executed count, and a skipped test fails the run. `verifyEnvKeys` gained a check for registered +keys that nothing reads — no typed property, no yaml reference, no `.env` entry, no Java consumer — +which found eight orphaned Redis keys beyond the two the review named. + +### Verified + +| Lane | Result | +| --- | --- | +| standalone | 25 tests | +| sentinel | 27 tests | +| cluster | 29 tests, including a same-slot transaction and a cross-slot refusal | +| tls | 4 tests, filesystem and classpath CA | + +Repository: 3594 tests, 0 failures. `verifyCleanArchitectureDependencies`, +`verifyPublicPathSnapshot`, `verifyEnvKeys`, `CleanArchitectureTest`, `verify-gate-matrix.sh` +(37 gates) and `verify-gradle-wrapper.sh` all pass. + +### Still open + +- **Session port.** No provider-neutral session contract exists in `application-core` or + `shared-contract`; it went with the previous generation. That is a contract to design, not a port + to implement, and inventing one here would be guessing at its shape. +- **V2 idempotency executor.** `IdempotencyExecutorV2` targets a contract no provider implements. +- **Gateway / `CommandRequest` visibility.** Narrowing it is a package restructuring across + `sdk.programmability`, `sdk.raw`, `sdk.admin` and `sdk.extensions`, not an access-modifier change. diff --git a/infra/redis-sdk/README.md b/infra/redis-sdk/README.md new file mode 100644 index 00000000..d89dd101 --- /dev/null +++ b/infra/redis-sdk/README.md @@ -0,0 +1,148 @@ +# Redis SDK topology lanes + +These lanes exist to answer the questions the deterministic in-memory gateway cannot: how Lettuce +actually behaves during a Sentinel promotion, what a Cluster resharding does to an in-flight +command, and whether the ACL accounts grant exactly what the SDK issues. + +All four have now run on Redis 7.4 and the evidence is recorded in +`docs/redis/support-matrix.md`. `.github/workflows/redis-sdk-topology.yml` runs the standalone lane +on any pull request that touches the Redis leaf, the full supported-version x topology matrix +nightly, and the same matrix on demand for a release candidate. + +TLS is a lane of that matrix rather than something to wire up by hand. It is `tls`, not a +deployment mode: its shape is standalone and what it qualifies is the transport, so +`redisTopologyTest` maps the lane name to `standalone` for the tests and keeps the tag filter and +the required trust material on the lane. + +## The TLS lane + +`tls/compose.yml` is the standalone shape with the transport swapped. The plaintext port is turned +off entirely (`--port 0`), which is the only configuration that proves anything: a lane accepting +both would let a client that failed to negotiate TLS fall back silently and still pass. + +Certificates are generated at start-up into a named volume rather than checked in — a private key +in the repository is a private key in the repository, however the file is named — and they last a +day, so a stale lane fails visibly instead of drifting. + +```bash +REDIS_VERSION=7.4 docker compose -f infra/redis-sdk/tls/compose.yml up -d --wait +# The client needs the generated CA; copy it out of the volume first. +docker compose -f infra/redis-sdk/tls/compose.yml cp redis:/tls/ca.crt /tmp/redis-lane-ca.pem +cd src && ./gradlew :adapter:outbound:cache-redis:redisTopologyTest \ + -Predis.topology.host=127.0.0.1 -Predis.topology.port=6390 \ + -Predis.topology.mode=tls -Predis.topology.trust-material=/tmp/redis-lane-ca.pem +``` + +The lane refuses to run without `redis.topology.trust-material`. A TLS lane that trusts anything +qualifies nothing, so "no CA configured" is an error rather than a client with verification off. + +## The ACL fixture + +`acl/all-accounts.acl` provisions the accounts every lane uses. Two things about it matter, and +neither can be written in the file itself — **Redis refuses to start if an `aclfile` contains a +comment line**, so the whole file is directives and the explanation lives here. + +`user default off` is the first line and is deliberate. Redis ships `default` enabled and +passwordless; while it is on, every restriction in the remaining accounts can be bypassed by simply +not authenticating, which makes the fixture decorative. Disabling it is what forces a client — and +the compose healthchecks — to pick a named account. + +Every named account carries a real password — `>fixture-application`, `>fixture-advanced`, and so +on. They were `nopass`, which was the more dangerous kind of wrong: an account that accepts any +password made every assertion about authentication pass for the same reason a typo would have, so +the lane's coverage of AUTH, rotation and secret wiring was indistinguishable from no coverage. +`LiveRedisCompositionTest` now presents a wrong password on purpose and requires `WRONGPASS`, which +is only a meaningful assertion because the accounts enforce one. + +The passwords are fixture values in a throwaway container and are **not** a deployment template: a +real deployment resolves each account's credential through `secret://` and never writes one into +configuration. + +The accounts are also split by role, because that is how the SDK uses them. `ca-skeleton-application` +runs ordinary data commands and cannot execute a script; `ca-skeleton-application-advanced` holds +`SCRIPT LOAD` and `EVALSHA` and nothing else needs to. That separation is real rather than +decorative: `LiveRedisSemanticPortsTest` runs the rate limiter without the advanced account and +requires it to come back `Unavailable`. + +## Running one + +Each lane has its own endpoint, because the address a client is given is not the same kind of thing +in each topology. Standalone declares a data node; Sentinel declares a *sentinel*, from which the +primary is resolved and re-resolved when it is promoted; Cluster declares any node, from which the +rest of the topology is discovered. + +```bash +# Standalone +REDIS_VERSION=7.4 docker compose -f infra/redis-sdk/standalone/compose.yml up -d --wait +cd src && ./gradlew :adapter:outbound:cache-redis:redisTopologyTest \ + -Predis.topology.host=localhost -Predis.topology.port=6379 -Predis.topology.mode=standalone + +# Sentinel — the port is a sentinel, and the monitored primary has to be named +REDIS_VERSION=7.4 docker compose -f infra/redis-sdk/sentinel/compose.yml up -d --wait +cd src && ./gradlew :adapter:outbound:cache-redis:redisTopologyTest \ + -Predis.topology.host=localhost -Predis.topology.port=27010 \ + -Predis.topology.mode=sentinel -Predis.topology.master=skeleton + +# Cluster — `up --wait` waits for the `ready` gate, not just for six servers that answer PING. +# Slot assignment finishes after the nodes are healthy, and a client that connects in between sees +# CLUSTERDOWN for reasons that have nothing to do with the SDK. +REDIS_VERSION=7.4 docker compose -f infra/redis-sdk/cluster/compose.yml up -d --wait +cd src && ./gradlew :adapter:outbound:cache-redis:redisTopologyTest \ + -Predis.topology.host=localhost -Predis.topology.port=7100 -Predis.topology.mode=cluster +``` + +Tear a lane down with `docker compose -f infra/redis-sdk//compose.yml down -v`. + +| Lane | Ports | Notes | +| --- | --- | --- | +| standalone | 6379 | bridge network, published port | +| sentinel | primary 7010, replica 7011, sentinels 27010–27012 | host network | +| cluster | nodes 7100–7105, bus 17100–17105 | host network; `ready` gates on `cluster_state:ok` | +| tls | 6390 | published port, no plaintext port at all; CA generated per run | + +## Why the Sentinel and Cluster lanes use host networking + +Neither topology proxies. Sentinel answers `SENTINEL get-master-addr-by-name` with the address it +monitors and the client dials that itself; a cluster client reads `CLUSTER SHARDS` and connects to +every node it names. On a bridge network those are container-internal addresses, so a client on the +host resolves a topology it cannot reach — and after a promotion it resolves a *different* one it +also cannot reach. Sharing the host network namespace makes the address the topology advertises the +address the client can use, which is the difference between testing the SDK and testing Docker's +network. + +That is also why their ports are fixed rather than parameterised: the addresses are written into +Sentinel's and the cluster's own configuration at creation time, and a lane whose two halves can +disagree fails for reasons that are not the SDK's. + +## Selection is by lane, not by hand + +`redisTopologyTest` derives its JUnit tag expression from the declared mode: `redis-topology & +lane-`. A promotion test is meaningless without sentinels and a cross-slot test is meaningless +without a cluster, but expressing that as a runtime assumption would turn "the lane was never +started" into a green skip. Selecting by tag keeps it fail-closed — what a mode cannot prove is not +selected, and what is selected must pass. + +The lane also fails closed on its endpoint: selecting `redisTopologyTest` without host, port, and +mode (and `redis.topology.master` on the Sentinel lane) is an error, never a skip. A topology test +that silently passes because it did not connect is worse than no topology test. + +## `min-replicas-to-write` on the Sentinel lane + +The Sentinel lane sets `min-replicas-to-write 1` and `min-replicas-max-lag 1`, and this is not +incidental configuration. Without them the lane measured a promotion in which the superseded primary +kept answering `+OK` for eleven seconds after it had been replaced: **2,086 writes acknowledged to +the caller and then discarded**, with exactly one command failing. With them the same promotion lost +one write and refused 2,020 with `NOREPLICAS`, which the SDK reports as a definite, non-ambiguous +failure a caller can act on. + +Any deployment where an acknowledgement is supposed to mean something has to set these. See +`docs/redis/support-matrix.md` for the full record. + +## ACL accounts + +`acl/` holds one file per `CommandAccess` level. They are deliberately narrower than the SDK's own +rules, so a mistake in the SDK is still refused by the server — the account is the last boundary and +a permit never widens it. + +Every lane loads the same file on every data node. Accounts are enforced per node, so "they exist on +one node" is not evidence that a topology enforces them. diff --git a/infra/redis-sdk/acl/all-accounts.acl b/infra/redis-sdk/acl/all-accounts.acl new file mode 100644 index 00000000..78ab7e24 --- /dev/null +++ b/infra/redis-sdk/acl/all-accounts.acl @@ -0,0 +1,8 @@ +user default off +user ca-skeleton-application on >fixture-application sanitize-payload ~prod:* resetchannels &prod:* -@all +@connection +@pubsub +@transaction +@read +@write +@string +@hash +@list +@set +@sortedset +@bitmap +@hyperloglog +@geo +@stream -keys -flushdb -flushall -shutdown -debug -sort -sort_ro -smembers -randomkey -migrate -swapdb -select +cluster|slots +cluster|shards +cluster|nodes +cluster|info +cluster|myid +user ca-skeleton-application-advanced on >fixture-advanced sanitize-payload ~prod:* resetchannels &prod:* -@all +@read +@write +@string +@hash +@list +@set +@sortedset +@bitmap +@hyperloglog +@geo +@stream +@pubsub +@transaction +evalsha +evalsha_ro +script|load +script|exists +fcall +fcall_ro -keys -flushdb -flushall -shutdown -debug -eval -eval_ro -smembers -sort -sort_ro -randomkey -migrate -swapdb -select +cluster|slots +cluster|shards +cluster|nodes +cluster|info +cluster|myid +user ca-skeleton-raw-gateway on >fixture-raw sanitize-payload ~prod:* resetchannels -@all +smembers +sort +sort_ro +user ca-skeleton-admin-readonly on >fixture-admin ~* resetchannels -@all +info +dbsize +time +lastsave +memory|usage +memory|stats +slowlog|get +slowlog|len +latency|latest +latency|history +client|list +client|info +command|info +command|docs +command|count +command|getkeysandflags +config|get +acl|dryrun +acl|whoami +cluster|info +cluster|slots +cluster|shards +cluster|nodes +object|encoding +object|freq +object|idletime +pubsub|channels +pubsub|numsub +pubsub|shardchannels +xinfo|stream +xinfo|groups +xinfo|consumers +function|list +function|stats +cluster|keyslot +cluster|myid +user ca-skeleton-replication on >fixture-replication ~* resetchannels -@all +psync +replconf +ping +user ca-skeleton-sentinel on >fixture-sentinel ~* &* -@all +multi +slaveof +ping +exec +subscribe +config|rewrite +role +publish +info +client|setname +client|kill +script|kill +replconf +psync +user ca-skeleton-cluster-bootstrap on >fixture-bootstrap ~* &* +@all diff --git a/infra/redis-sdk/cluster/compose.yml b/infra/redis-sdk/cluster/compose.yml new file mode 100644 index 00000000..6ef79fd7 --- /dev/null +++ b/infra/redis-sdk/cluster/compose.yml @@ -0,0 +1,134 @@ +# Cluster lane. Six nodes: three primaries so cross-slot behaviour is observable at all, and three +# replicas so a promotion can be forced without losing a shard. +# +# Host networking for the same reason as the Sentinel lane, and a sharper one. A cluster client does +# not talk to one address: it reads `CLUSTER SHARDS`, learns every node's address, and connects to +# each of them itself. On a bridge those addresses are container-internal, so a client on the host +# resolves a topology it cannot dial and every redirect points somewhere unreachable. Sharing the +# host network namespace makes the addresses the cluster advertises the addresses the client can +# use, which is the difference between testing the SDK and testing Docker's network. +# +# Ports are fixed because they are written into the cluster's own configuration at creation time: +# the node identity a redirect names has to be an address the client can dial. +# +# nodes 7100..7105 · cluster bus 17100..17105 +# +# The ACL file is loaded on every node. The accounts are the deployment's last enforcement boundary +# and a cluster enforces them per node, so "they exist on one node" is not evidence. +# +# min-replicas-to-write is set here for the same reason as on the Sentinel lane. A cluster promotes +# a replica without asking the client too, so a superseded primary keeps acknowledging writes it +# will discard on resync — the Sentinel lane measured 2,086 of them in one eleven-second window. +# Nothing about slot ownership changes that, and this lane was written without the setting at first +# precisely because the failure mode is easy to think of as Sentinel-specific. It is not. +x-node: &node + image: "redis:${REDIS_VERSION:-7.4}" + network_mode: host + volumes: + - ../acl:/etc/redis/acl:ro + entrypoint: + - /bin/sh + - -c + - | + exec redis-server \ + --port $$NODE_PORT \ + --cluster-enabled yes \ + --cluster-config-file /tmp/nodes.conf \ + --cluster-node-timeout 2000 \ + --cluster-announce-ip 127.0.0.1 \ + --appendonly no \ + --save '' \ + --min-replicas-to-write 1 \ + --min-replicas-max-lag 1 \ + --masteruser ca-skeleton-replication \ + --masterauth fixture-replication \ + --aclfile /etc/redis/acl/all-accounts.acl + healthcheck: + test: ["CMD-SHELL", "[ \"$$(redis-cli -p $$NODE_PORT --user ca-skeleton-application --pass fixture-application --no-auth-warning ping)\" = PONG ]"] + interval: 2s + timeout: 2s + retries: 15 + +services: + node-1: + <<: *node + environment: + NODE_PORT: "7100" + + node-2: + <<: *node + environment: + NODE_PORT: "7101" + + node-3: + <<: *node + environment: + NODE_PORT: "7102" + + node-4: + <<: *node + environment: + NODE_PORT: "7103" + + node-5: + <<: *node + environment: + NODE_PORT: "7104" + + node-6: + <<: *node + environment: + NODE_PORT: "7105" + + # The cluster is created after every node reports healthy, and the lane is not "up" until every + # slot is covered. A test that starts before slot assignment finishes sees MOVED and CLUSTERDOWN + # for reasons that have nothing to do with the SDK. + init: + image: "redis:${REDIS_VERSION:-7.4}" + network_mode: host + depends_on: + node-1: {condition: service_healthy} + node-2: {condition: service_healthy} + node-3: {condition: service_healthy} + node-4: {condition: service_healthy} + node-5: {condition: service_healthy} + node-6: {condition: service_healthy} + entrypoint: + - /bin/sh + - -c + - | + redis-cli --user ca-skeleton-cluster-bootstrap --pass fixture-bootstrap --no-auth-warning \ + --cluster create \ + 127.0.0.1:7100 127.0.0.1:7101 127.0.0.1:7102 \ + 127.0.0.1:7103 127.0.0.1:7104 127.0.0.1:7105 \ + --cluster-replicas 1 --cluster-yes + # Authenticated, like every other command against this fixture. The `default` user is off, + # so an unauthenticated CLUSTER INFO answers NOAUTH — which never matches, so this loop + # never ended, the helper never exited, and `up --wait` returned on the nodes' own health + # while slot assignment was still in flight. A lane that reports ready before it can serve + # a key produces failures that look like SDK defects and are not. + until redis-cli -p 7100 \ + --user ca-skeleton-cluster-bootstrap --pass fixture-bootstrap --no-auth-warning \ + cluster info | grep -q 'cluster_state:ok'; do sleep 1; done + echo "cluster ready" + + # `up --wait` returns when every service is running or healthy, and a one-shot helper is neither + # for as long as it runs — so the wait ended while slots were still being assigned, and whichever + # test connected first saw a cluster that could not serve its keys. This gate is a service the + # wait can see: it cannot become healthy until the cluster reports a fully covered keyspace. + ready: + image: "redis:${REDIS_VERSION:-7.4}" + network_mode: host + depends_on: + init: {condition: service_completed_successfully} + command: ["sleep", "infinity"] + healthcheck: + test: + - CMD-SHELL + - >- + [ "$$(redis-cli -p 7100 --user ca-skeleton-cluster-bootstrap + --pass fixture-bootstrap --no-auth-warning cluster info + | tr -d '\r' | grep -c '^cluster_state:ok$$')" = 1 ] + interval: 1s + timeout: 3s + retries: 60 diff --git a/infra/redis-sdk/sentinel/compose.yml b/infra/redis-sdk/sentinel/compose.yml new file mode 100644 index 00000000..49aea446 --- /dev/null +++ b/infra/redis-sdk/sentinel/compose.yml @@ -0,0 +1,126 @@ +# Sentinel lane. Three sentinels because a two-sentinel quorum cannot survive losing one, and a +# failover test that cannot lose a sentinel is not testing failover. +# +# Host networking, not a bridge with published ports. Sentinel does not proxy: it answers +# `SENTINEL get-master-addr-by-name` with the address it monitors, and the client then connects +# there itself. On a bridge that address is the container's internal IP, which the client on the +# host cannot reach, so the lane would resolve a primary it can never talk to — and after a +# promotion it would resolve a different unreachable one. Sharing the host network namespace makes +# the address Sentinel hands out the same address the client can dial, which is the only thing that +# makes the promotion observable from outside. +# +# Ports are fixed rather than parameterised because Sentinel stores them in its own config: the +# monitored address has to match what the client is told, and a lane whose two halves can disagree +# is a lane that fails for reasons that are not the SDK's. +# +# primary 7010 · replica 7011 · sentinels 27010 27011 27012 +# +# The ACL file is loaded on both data nodes. The accounts are the deployment's last enforcement +# boundary, so "they exist in standalone" is not evidence that they exist in the topology that will +# actually be run in production. +# +# Both data nodes take their entire configuration from one definition, and that is load-bearing +# rather than tidiness. These two nodes swap roles on every failover, so a setting written only into +# the one that happens to start as primary silently stops applying the moment the lane does the +# thing it exists to do. The lane learned this the hard way: min-replicas-to-write was set on the +# primary only, the first promotion passed, and the second promotion — now writing to the node that +# never had the setting — discarded 2,099 acknowledged writes. +x-data-node: &data-node + image: "redis:${REDIS_VERSION:-7.4}" + network_mode: host + volumes: + - ../acl:/etc/redis/acl:ro + entrypoint: + - /bin/sh + - -c + # REPLICA_OF is deliberately unquoted: it is either empty or a two-word --replicaof argument. + # + # min-replicas-to-write is what stops a superseded primary from acknowledging writes it cannot + # keep. Without it a promotion silently destroys them — measured here at eleven seconds and two + # thousand confirmed-then-discarded writes — because Sentinel does not demote the old primary + # until well after it has promoted the new one. Requiring an in-sync replica turns that window + # into an explicit NOREPLICAS refusal the caller can see and act on. Any deployment where an + # acknowledgement is supposed to mean something has to set these. + - | + exec redis-server \ + --port $$NODE_PORT \ + $$REPLICA_OF \ + --appendonly no \ + --save '' \ + --min-replicas-to-write 1 \ + --min-replicas-max-lag 1 \ + --masteruser ca-skeleton-replication \ + --masterauth fixture-replication \ + --aclfile /etc/redis/acl/all-accounts.acl + healthcheck: + test: ["CMD-SHELL", "[ \"$$(redis-cli -p $$NODE_PORT --user ca-skeleton-application --pass fixture-application --no-auth-warning ping)\" = PONG ]"] + interval: 2s + timeout: 2s + retries: 15 + +services: + primary: + <<: *data-node + environment: + NODE_PORT: "7010" + REPLICA_OF: "" + + replica: + <<: *data-node + environment: + NODE_PORT: "7011" + REPLICA_OF: "--replicaof 127.0.0.1 7010" + depends_on: + primary: + condition: service_healthy + + sentinel-1: &sentinel + image: "redis:${REDIS_VERSION:-7.4}" + network_mode: host + # The config is written at start-up rather than mounted because Sentinel rewrites its own file + # when it promotes. A read-only mount would make the first failover fail on a write error, and + # a shared writable mount would have three sentinels rewriting one file. + entrypoint: + - /bin/sh + - -c + - | + cat > /tmp/sentinel.conf < str: + return "\n".join( + (DOCS_DIR / name).read_text(encoding="utf-8") for name in REQUIRED_DOCS + ) + + +def stable_exceptions() -> list[str]: + error_dir = PLATFORM / "api/error" + return sorted( + path.stem + for path in error_dir.glob("Http*Exception.java") + if path.stem != "HttpClientException" + ) + + +def metric_names() -> list[str]: + source = (PLATFORM / "observation/HttpClientObservationNames.java").read_text(encoding="utf-8") + return sorted(set(re.findall(r'"(http\.client\.[a-z_.]+)"', source))) + + +def violation_codes() -> list[str]: + codes: set[str] = set() + for source_file in [ + PLATFORM / "profile/ClientProfileValidator.java", + PLATFORM / "security/TlsPolicyValidator.java", + BOOTSTRAP / "HttpClientStartupValidator.java", + ]: + source = source_file.read_text(encoding="utf-8") + codes.update(re.findall(r'"([A-Z][A-Z0-9_]{4,})"', source)) + return sorted(codes) + + +def configuration_properties() -> list[str]: + """Every leaf property under `app.httpclient`, nested and dynamic blocks included. + + Read from the environment-field manifest rather than from the record source. The manifest is + derived from `HttpClientPlatformSettings` by `HttpClientEnvironmentKeys` and held to it in both + directions by `HttpClientPlatformEnvManifestTest`, so it cannot drift from the code; parsing the + record here a second time, with a regex, could only agree with it by luck. The previous version + of this function did exactly that and saw eighteen top-level names, which is why a nested pool, + timeout or TLS setting could be added and documented nowhere. + """ + names: set[str] = set() + for line in ENV_FIELD_MANIFEST.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped.startswith("- field:"): + continue + path = stripped[len("- field:") :].strip() + leaf = path.split(".")[-1] + # `clients[N]` and `allowed-hosts[M]` are documented by name, not by position. + names.add(re.sub(r"\[[NM]\]$", "", leaf)) + return sorted(names) + + +def transports() -> list[str]: + source = (PLATFORM / "profile/TransportType.java").read_text(encoding="utf-8") + body = source[source.index("public enum TransportType") :] + return sorted(set(re.findall(r"^\s{2}([A-Z][A-Z_]*),?$", body, flags=re.MULTILINE))) + + +def main() -> int: + missing_docs = [name for name in REQUIRED_DOCS if not (DOCS_DIR / name).is_file()] + if missing_docs: + print("FAIL missing documentation file(s): " + ", ".join(missing_docs)) + return 1 + + documentation = read_docs() + failures: list[str] = [] + + checks = { + "stable exception": stable_exceptions(), + "metric": metric_names(), + "startup violation code": violation_codes(), + "configuration property": configuration_properties(), + "transport": transports(), + } + for kind, names in checks.items(): + for name in names: + if name not in documentation: + failures.append(f"{kind} '{name}' exists in code but is not documented") + + if failures: + print(f"FAIL httpclient documentation drift ({len(failures)} finding(s)):") + for failure in failures: + print(" - " + failure) + return 1 + + total = sum(len(names) for names in checks.values()) + print(f"PASS httpclient documentation covers {total} code-derived name(s):") + for kind, names in checks.items(): + print(f" {kind}: {len(names)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/.env b/src/.env index ddcd5ae1..02f1bb1e 100644 --- a/src/.env +++ b/src/.env @@ -15,16 +15,13 @@ APP_MIGRATION_ON_STARTUP=true APP_RATE_LIMIT_ENABLED=false APP_RATE_LIMIT_CLIENT_IP_MODE=remote-addr-only APP_RATE_LIMIT_PROVIDER=disabled -APP_RATE_LIMIT_REDIS_KEY_HMAC_SECRET= APP_IDEMPOTENCY_TTL=24h APP_IDEMPOTENCY_PROVIDER=jdbc APP_IDEMPOTENCY_REDIS_KEY_HMAC_SECRET= -APP_IDEMPOTENCY_REDIS_NAMESPACE_ENVIRONMENT=local APP_IDEMPOTENCY_PROCESSING_LEASE=30s APP_IDEMPOTENCY_FAILURE_RETENTION=24h APP_LEASE_PROVIDER=disabled APP_LEASE_REDIS_KEY_HMAC_SECRET= -APP_LEASE_REDIS_NAMESPACE_ENVIRONMENT=local APP_LEASE_REDIS_DRIFT_BUDGET=10ms # ----- Async executor ----- @@ -34,33 +31,10 @@ APP_ASYNC_EXECUTOR_QUEUE_CAPACITY=200 # ----- Optional integration adapters (default: all disabled) ----- APP_CACHE_CANONICAL_DEFAULT_PROVIDER=disabled -# Sentinel primary revalidation cadence for canonically active Sentinel roles. -APP_REDIS_SENTINEL_DISCOVERY_REFRESH_PERIOD=30s -# Canonical role semantic readiness: refresh no more often than this interval. -APP_REDIS_SEMANTIC_PROBE_MINIMUM_INTERVAL=5s -# Fail closed when the last completed semantic observation is older than this bound. -APP_REDIS_SEMANTIC_PROBE_MAXIMUM_STALENESS=15s -APP_CACHE_REDIS_ENABLED=false -APP_CACHE_REDIS_CLIENT_MODE=managed -APP_CACHE_REDIS_HOST=localhost -APP_CACHE_REDIS_PORT=6379 -APP_CACHE_REDIS_PASSWORD= -APP_CACHE_REDIS_KEY_HMAC_SECRET= -APP_CACHE_REDIS_COMMAND_TIMEOUT=2s -APP_CACHE_REDIS_MAXIMUM_QUEUED_COMMANDS=8 -APP_CACHE_REDIS_MAXIMUM_IN_FLIGHT_BYTES=16777216 -APP_CACHE_REDIS_NAMESPACE_ENVIRONMENT=local -APP_CACHE_REDIS_SEMANTIC_REGION=default -APP_CACHE_REDIS_MAXIMUM_VALUE_BYTES=1048576 -APP_CACHE_REDIS_L1_ENABLED=false -APP_CACHE_REDIS_L1_MAXIMUM_ENTRIES=10000 -APP_CACHE_REDIS_L1_MAXIMUM_WEIGHT_BYTES=67108864 -APP_CACHE_REDIS_L1_MAXIMUM_ENTRY_WEIGHT_BYTES=1048576 -APP_CACHE_REDIS_L1_TTL=30s -APP_CACHE_REDIS_L1_GENERATION_RECHECK_INTERVAL=5s -APP_CACHE_REDIS_L1_INVALIDATION_QUEUE_CAPACITY=1024 -APP_CACHE_DEFAULT_TTL=300s -APP_CACHE_NEGATIVE_TTL=60s +# The single global Redis switch. False means no Redis settings, secrets, client, threads or +# health contributor exist. Role selectors (cache/session/idempotency/lease/rate-limit) choose +# which capabilities compose once Redis is on; none of them turns Redis on. +APP_REDIS_ENABLED=false APP_MESSAGING_BROKER= APP_MESSAGING_KAFKA_BROKERS= APP_NOTIFICATION_SLACK_PROVIDER= @@ -149,15 +123,6 @@ APP_SESSION_COOKIE_SAME_SITE=Lax APP_SESSION_COOKIE_PATH=/ APP_SESSION_CSRF_COOKIE_NAME=XSRF-TOKEN APP_SESSION_CSRF_HEADER_NAME=X-XSRF-TOKEN -APP_SESSION_REDIS_KEY_HMAC_SECRET= -APP_SESSION_REDIS_NAMESPACE_ENVIRONMENT=local -APP_SESSION_IDLE_TIMEOUT=30m -APP_SESSION_ABSOLUTE_LIFETIME=8h -APP_SESSION_TOUCH_INTERVAL=1m -APP_SESSION_TOMBSTONE_TTL=5m -APP_SESSION_MAXIMUM_ENVELOPE_BYTES=32768 -APP_SESSION_MAXIMUM_ATTRIBUTES=64 -APP_SESSION_MAXIMUM_SCALAR_BYTES=8192 # ----- CORS ----- APP_SECURITY_CORS_ENABLED=true @@ -186,3 +151,83 @@ APP_DATASOURCE_POOL_MAX_LIFETIME=1800000 # ----- Management / Actuator ----- MANAGEMENT_SERVER_PORT=9001 + +# ----- Fileserver HTTP platform (app.fileserver-platform.*) ----- +# Off by default. While false nothing below is bound: the platform auto-configuration binds this +# block itself and is not processed until the master switch is true. +APP_FILESERVER_PLATFORM_ENABLED=false +APP_FILESERVER_PLATFORM_INSTANCE_ID=local-node +APP_FILESERVER_PLATFORM_DEFAULT_NAMESPACE=default + +# Storage root must be an absolute path on its own volume, never under a web or config root. +APP_FILESERVER_PLATFORM_STORAGE_ROOT=/var/lib/backend/files +APP_FILESERVER_PLATFORM_STORAGE_PUBLISH_MODE=atomic-move-preferred +APP_FILESERVER_PLATFORM_STORAGE_BUFFER_SIZE=128KB +APP_FILESERVER_PLATFORM_STORAGE_FORBIDDEN_ROOT_ANCESTORS=/app,/etc,/usr/share/nginx/html + +# Shared with spring.servlet.multipart.* so the container and the policy cannot disagree. +APP_FILESERVER_PLATFORM_UPLOAD_MAX_FILE_SIZE=100MB +APP_FILESERVER_PLATFORM_UPLOAD_MAX_REQUEST_SIZE=110MB +APP_FILESERVER_PLATFORM_UPLOAD_INITIAL_RESERVATION=8MB +APP_FILESERVER_PLATFORM_UPLOAD_MAX_PARTS=16 +APP_FILESERVER_PLATFORM_UPLOAD_TTL=1h +APP_FILESERVER_PLATFORM_UPLOAD_RESERVATION_TTL=24h +APP_FILESERVER_PLATFORM_UPLOAD_LEASE_DURATION=30s +APP_FILESERVER_PLATFORM_UPLOAD_REQUIRE_CONTENT_LENGTH=false + +APP_FILESERVER_PLATFORM_DOWNLOAD_CACHE_CONTROL=private, no-store +APP_FILESERVER_PLATFORM_DOWNLOAD_INLINE_ALLOWED=false +APP_FILESERVER_PLATFORM_DOWNLOAD_MAX_RANGES=1 +APP_FILESERVER_PLATFORM_DOWNLOAD_MAX_RANGE_BYTES=100MB +APP_FILESERVER_PLATFORM_DOWNLOAD_ZERO_COPY_ENABLED=true +APP_FILESERVER_PLATFORM_DOWNLOAD_ZERO_COPY_MINIMUM_BYTES=16MB + +APP_FILESERVER_PLATFORM_TRANSFER_CORE_SIZE=8 +APP_FILESERVER_PLATFORM_TRANSFER_MAX_SIZE=32 +APP_FILESERVER_PLATFORM_TRANSFER_QUEUE_CAPACITY=64 +APP_FILESERVER_PLATFORM_TRANSFER_AWAIT_SECONDS=300 + +# required | role-based | unenforced (unenforced is refused under a production profile). +APP_FILESERVER_PLATFORM_SECURITY_ACCESS_POLICY=required +APP_FILESERVER_PLATFORM_SECURITY_READ_ROLES=ROLE_FILE_READ +APP_FILESERVER_PLATFORM_SECURITY_WRITE_ROLES=ROLE_FILE_WRITE +APP_FILESERVER_PLATFORM_SECURITY_ADMIN_ROLES=ROLE_FILE_ADMIN + +APP_FILESERVER_PLATFORM_VERIFICATION_TIMEOUT=5s +APP_FILESERVER_PLATFORM_VERIFICATION_REQUIRE_MEDIA_TYPE_VERDICT=false +APP_FILESERVER_PLATFORM_VERIFICATION_INLINE_SAFE_PROFILE=false + +APP_FILESERVER_PLATFORM_QUOTA_INSTANCE_UPLOAD_PERMITS=16 +APP_FILESERVER_PLATFORM_QUOTA_SCOPE_UPLOAD_PERMITS=4 +APP_FILESERVER_PLATFORM_QUOTA_DIRECT_DOWNLOAD_PERMITS=64 +APP_FILESERVER_PLATFORM_QUOTA_SOFT_HIGH_WATER=0.70 +APP_FILESERVER_PLATFORM_QUOTA_HARD_HIGH_WATER=0.85 + +APP_FILESERVER_PLATFORM_ADMIN_ENABLED=false +APP_FILESERVER_PLATFORM_ADMIN_ORPHAN_MINIMUM_AGE=1h + +APP_FILESERVER_PLATFORM_CLEANUP_ENABLED=false +APP_FILESERVER_PLATFORM_CLEANUP_INTERVAL=60s +APP_FILESERVER_PLATFORM_CLEANUP_MAX_ITEMS=100 +APP_FILESERVER_PLATFORM_CLEANUP_MAX_BYTES=1GB +APP_FILESERVER_PLATFORM_CLEANUP_RETRY_BACKOFF=5m + +APP_FILESERVER_PLATFORM_TUS_ENABLED=false +APP_FILESERVER_PLATFORM_HTTPBIS_DRAFT12_ENABLED=false + +APP_FILESERVER_PLATFORM_NGINX_ENABLED=false +APP_FILESERVER_PLATFORM_NGINX_INTERNAL_PREFIX=/__files/ +APP_FILESERVER_PLATFORM_NGINX_OBJECT_SUFFIX=.bin +APP_FILESERVER_PLATFORM_NGINX_MINIMUM_SIZE=16MB + +APP_FILESERVER_PLATFORM_OBSERVABILITY_METRICS_ENABLED=true +# Secret. Required while metrics are enabled; an unkeyed digest of an enumerable id is reversible. +APP_FILESERVER_PLATFORM_OBSERVABILITY_FINGERPRINT_KEY= + +# ----- HTTP Client platform (app.httpclient.*) ----- +# The single switch for outbound HTTP. False means no HTTP client property is bound, and no +# transport provider, connection pool, TLS context, credential, thread or gateway is created. +# The per-client surface is indexed and per-deployment, so it is set directly in the environment +# rather than declared here; docs/httpclient/env-fields.yaml is its registry, and an +# APP_HTTPCLIENT_ variable that is not in that registry fails startup. +APP_HTTPCLIENT_ENABLED=false diff --git a/src/README.md b/src/README.md index 96127513..482dfda0 100644 --- a/src/README.md +++ b/src/README.md @@ -17,6 +17,7 @@ | 게이트 | 하는 일 | | --- | --- | | `verifyCleanArchitectureDependencies` | 모듈 간 의존 방향이 허용된 범위 안에 있는지 검사 | +| `verifyRuntimeModuleMembership` | registry의 두 composition root membership과 실제 main project dependency가 정확히 일치하는지 검사 | | `verifyEnvKeys` | `env-keys.yaml` ↔ `application.yml` ↔ `src/.env` 가 어긋나지 않는지 검사 | | `verifyOneTypePerFile` | 파일당 public 최상위 타입 1개, 파일명 == 타입명인지 검사 | | `verifyTrivyignore` | `.trivyignore.yaml` 의 Trivy suppression 이 사유·만료일을 갖추고 만료/기한초과가 아닌지 검사 | @@ -84,6 +85,24 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지 registry와 ArchUnit 규칙(`CleanArchitectureTest`)을 함께 갱신해야 합니다. settings와 gate는 같은 registry를 읽고, 등록되지 않은 leaf나 허용되지 않은 edge를 fail-closed로 거부합니다. +### `verifyRuntimeModuleMembership` + +- **하는 일.** 같은 registry의 `runtime_compositions`와 각 leaf의 `runtime_memberships`를 읽어 + `app-bootstrap`/`sample-portfolio`의 실제 `api`/`implementation`/`compileOnly`/`runtimeOnly` + project dependency와 정확히 대조합니다. +- **opt-in의 의미.** membership이 빈 GraphQL/gRPC/WebSocket/Mongo leaf는 독립 빌드 대상이지만 두 + shipped runtime에는 없습니다. app-bootstrap의 `conditionalTransportTest` test-only classpath는 + 실제 채택 전에 세 inbound transport를 함께 qualification하기 위한 evidence composition입니다. +- **변경 규칙.** production edge를 추가하거나 제거할 때 `allowed_dependencies`, + `runtime_memberships`, 실제 Gradle dependency를 같은 변경에서 갱신하지 않으면 `check`가 실패합니다. + +세 opt-in inbound transport의 test-only composition, 실제 wire 경계, positive-count/zero-skip 증거는 +다음 release-blocking aggregate로 실행합니다. + +```bash +./gradlew conditionalTransportQualification +``` + ### `verifyOneTypePerFile` (code-conventions I6) - **하는 일.** `src/main/java` 의 모든 `.java` 파일이 public 최상위 타입을 1개만 갖고, 그 타입 이름이 @@ -119,9 +138,9 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지 경로를 **제외한** 모든 요청은 인증을 요구합니다(`src/.env` → `SecuritySettings.publicPaths()` → `SecurityConfig`). 이 public 표면이 바뀌는 순간이 곧 보호되던 엔드포인트가 조용히 공개로 노출되는 지점입니다. 그래서 그 표면을 snapshot 으로 떠 두고, 미승인 변경에 빌드를 실패시킵니다. -- **승인 방법.** reviewer 가 `./gradlew verifyPublicPathSnapshot -PapprovePublicPathChange` 로 - snapshot 을 의도적으로 다시 생성합니다. 공개 경로 변경은 보안 리뷰 대상으로 보고 수동 승인 후 - 재생성된 snapshot 을 함께 커밋합니다. +- **승인 방법.** `verifyPublicPathSnapshot` 은 항상 읽기 전용입니다. reviewer 가 변경을 승인한 뒤 + `./gradlew updatePublicPathSnapshot -PapprovePublicPathChange` 로 snapshot 을 명시적으로 다시 + 생성합니다. 공개 경로 변경은 보안 리뷰 대상으로 보고 재생성된 snapshot 을 함께 커밋합니다. - **결정 — 무엇을 snapshot 했나 (프로젝트 선택).** 초기안은 기동 시 `SecurityFilterChain.getFilters()` 를 introspection 하는 방식이었습니다. 하지만 그 reflection 은 Spring 버전마다 깨지기 쉽습니다(`permitAll` matcher 가 @@ -130,8 +149,8 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지 변경은 무조건 게이트를 실패시킨다)는 같고, 메커니즘은 더 견고합니다. - **snapshot 위치.** `docs/security/public-paths-snapshot.txt`. 이 파일은 커밋된 필수 보안 baseline 입니다. CI 는 Gradle 실행 전에 파일이 비어 있지 않고 Git에 추적되는지 검사하므로 fresh - checkout 에서 누락되거나 untracked 상태면 즉시 실패합니다. 승인된 변경만 위 명령으로 재생성한 뒤 - 보안 리뷰와 함께 커밋합니다. + checkout 에서 누락되거나 untracked 상태면 즉시 실패합니다. 승인된 변경만 update task로 재생성한 + 뒤 보안 리뷰와 함께 커밋합니다. ### `verifyTrivyignore` @@ -260,9 +279,13 @@ ca-skeleton: - `ACTIVE`는 exact destination/provider/operation-catalog binding을 요구합니다. 현재 유일한 buffered-classic readiness card가 `NOT_IMPLEMENTED`이므로 provider resource 생성 전에 fail-closed합니다. 아직 운영 HTTP provider를 활성화할 수 있다는 뜻이 아닙니다. -- 기존 `APP_OUTBOUND_HTTP_*`와 `app.outbound.http.*`는 canonical 설정이 아닙니다. `.env`, - application YAML과 env-key registry에서 제거됐으며 canonical composition에 입력하면 상태와 - 무관하게 기동을 거부합니다. +- 기존 `APP_OUTBOUND_HTTP_*`와 `app.outbound.http.*`는 canonical 설정이 아닙니다. 루트 `src/.env`, + `app-bootstrap`의 application YAML, env-key registry에서 제거됐으며 canonical composition에 + 입력하면 상태와 무관하게 기동을 거부합니다. +- 다만 `sample-portfolio`의 application YAML에는 legacy facade를 시연하기 위해 15개 키가 남아 + 있습니다. 이 모듈은 fixture/reference consumer이고 production 의존성이 아니며, 그 YAML은 + `verifyEnvKeys`가 검사하는 세 파일에 포함되지 않습니다. "제거됐다"는 문장이 저장소 전체를 + 가리킨다고 읽히지 않도록 범위를 명시합니다. - legacy JDK facade가 필요한 fork만 canonical composition 밖에서 `OutboundHttpSettings.bindLegacy(Binder)`와 legacy configuration을 명시적으로 import합니다. timeout/retry/CB/response-size 설정은 그 migration API 내부 계약일 뿐 canonical provider diff --git a/src/adapter/outbound/cache-redis/CLAUDE.md b/src/adapter/outbound/cache-redis/CLAUDE.md index 1142bcef..4ac80e1c 100644 --- a/src/adapter/outbound/cache-redis/CLAUDE.md +++ b/src/adapter/outbound/cache-redis/CLAUDE.md @@ -21,6 +21,63 @@ Package root: `dev.caskeleton.adapter.outbound.cache`. connection/admission, private keys and versioned atomic programs. - Keep the legacy cache router isolated while consumers migrate to semantic ports. - Reuse `adapter:outbound:support` for shared outbound concerns. +- Host the general-purpose Redis SDK under `…cache.redis.sdk` (see below). The SDK is a separate + concern from the semantic cache ports and must not be reached from `application-core`. + +## Redis SDK (`…cache.redis.sdk`) + +The Redis wrapper and typed API described in +`docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md` lives inside this leaf. Its +design models the SDK as twelve Gradle modules; this repository's 19-leaf fail-closed registry +outranks that layout, so each designed module is a package instead. Delivery status and the full +adaptation rationale are in +`docs/superpowers/plans/2026-08-07-redis-wrapper-typed-api-status.md`. + +- `sdk.api..` is the public contract. It must never import Spring, Lettuce, Micrometer, or any SDK + implementation package; Reactor is confined to `sdk.api.reactive`. +- `sdk.lettuce..` implements the contract; `sdk.config` owns properties, the capability probe, and + the permit authority. +- `src/main/resources/redis-sdk/redis-command-policy.yml` is the command policy SSOT. A command that + is not classified there is refused, so adding a command means editing that file, not the code. +- `sdk.lettuce.operations` implements the typed operations. Everything there goes through + `RedisCommandGateway`, the only seam that reaches Lettuce, and every call is admitted by + `CommandPolicyGuard` before it runs. Never call the driver from an operation directly. +- `sdk.cluster` owns client-side slot arithmetic and cluster observation. It depends on `sdk.api` + only and must never import Lettuce: the slot is computed before a command is built, which is what + lets `CommandPolicyGuard` refuse a cross-slot request instead of learning about it from a server + redirect. +- `sdk.programmability` owns transactions, registered Lua scripts, and deployed function calls. A + script body is never accepted at call time: `EVAL` is blocked in the catalog and only `EVALSHA` of + a `RedisScriptRegistry` digest is reachable. `FUNCTION LOAD` is admin-plane, never application. +- Transactions are `WATCH`/`MULTI`/`EXEC` and **never** roll back. `TransactionResult` reports only + "executed" or "a watched key changed, so nothing ran", and no type in this package offers a word + that suggests otherwise. A queued command returns a `QueuedReply` that throws when read before the + commit, because inside the window the server has answered `+QUEUED` and nothing else. Queued + commands pass the same `CommandPolicyGuard` admission as ordinary ones — a transaction is not a + way around the guard — and the window is closed on every exit path, including a callback that + threw, because a connection abandoned in `MULTI` state silently queues the next caller's command. + The queue is write-only on purpose: a read inside the window cannot be branched on, so the reads a + transaction depends on belong before it, under `WATCH`. +- `sdk.raw` is the approved raw command gateway. A command is reachable only when the catalog marks + it `RAW_ONLY` *and* the deployment registered an `ApprovedRawCommand` for it; keys are parsed back + out of the arguments and namespace-checked before anything is sent. Never add a method here that + takes a command name as a string. +- `sdk.admin` is the read-only diagnostic plane. It takes its own gateway (admin ACL account, own + connection), refuses any command the catalog does not classify `ADMIN_ONLY` and read-only, and + projects replies so a slow log or client listing never carries arguments, peer addresses, or + connection names. +- `sdk.extensions.*` holds the Redis 8 modules (JSON, Search, Time Series, Probabilistic). Every + bean is created through `ifSupported(...)` — the capability probe decides, the catalog minimum is + only a pre-filter — and all of them build commands through `ExtensionCommandRunner` so the guard + sees their keys. Search is the exception that proves it: an index is not a key, so its name is + namespaced by `LettuceRedisSearchOperations` itself. +- `RedisSdkModuleBoundaryTest` enforces the package graph, driver containment, the absence of any + arbitrary string command surface, and the list of designed-but-unimplemented modules. Update + `NOT_YET_IMPLEMENTED_MODULES` when a module lands. +- Decisions that must not be changed without revisiting the design: no unbounded `entries`, + `members`, `rangeAll`, or `keys`; no optional R2 permit or budget; no arbitrary command string + overload; no automatic retry of a non-idempotent write after a timeout; no real key in a metric or + trace tag. ## Boundaries @@ -44,5 +101,43 @@ Package root: `dev.caskeleton.adapter.outbound.cache`. Focused tests use fakes for contract, key, catalog, and typed-facade behavior. R1/R2 promotion requires a separate real Redis service lane; it may never be silently skipped when selected. -`redisServiceTest` is the explicit standalone lane. It fails when its host/port properties are -missing; the default unit task excludes its `redis-service` tag. +`redisTopologyTest` is the only real-server lane. It is opt-in and fail-closed in seven ways: the +lane must be one of `standalone`, `sentinel`, `cluster`, `tls`; the endpoint properties must be +present (`sentinel` additionally needs `redis.topology.master`, `tls` needs +`redis.topology.trust-material`); a test class carrying the lane's tag must exist; a run that +executes zero tests fails; the classes the lane exists to run must actually have run; the executed +count must reach the lane's declared floor; and a skipped test fails the run rather than counting +as executed. `tls` is a lane, not a deployment mode — its shape is standalone and the task maps it +so, because what it qualifies is the transport. + +## Composition + +`RedisSdkAutoConfiguration` is the only place Redis settings and Redis runtime come into existence, +and it exists only while `app.redis.enabled` (env `APP_REDIS_ENABLED`) is true. It builds the +client, the connection owner and the health contributors; `RedisCapabilityConfig` in `app-bootstrap` +composes the semantic ports on top, one per role selector. "Redis is on" therefore means the +capabilities that need Redis exist, not merely that Redis is reachable. + +Every capability renders its keys under the one namespace from `app.redis.namespace` — +`{environment}:{service}:{domain}` — through `CapabilityKeyspace`. Never give a capability its own +prefix tokens: four capabilities each joining two free-form strings produced four different key +shapes, and the deployment's ACL pattern matched none of them. + +Lanes authenticate as different accounts. `RedisConnectionKind.credentialRole()` maps the lane to a +`RedisCredentialRole`, and the topology factory builds one client per *configured* role — so a +single-account deployment still gets exactly one client and one event loop. The `SCRIPT` lane is +the reason it exists: `SCRIPT LOAD` and `EVALSHA` belong to the advanced account, so the account +that reads a cache entry cannot execute a script. + +A Cluster transaction runs on one node, so `RedisTransactionRunner` derives a routing key from the +watched keys (or an explicit `RedisSlotTag`) and pins the `TRANSACTION` lane to the node that owns +that slot. Routed leases are never pooled: a pooled connection is pinned to the previous caller's +node. + +`RedisSdkSettings` +must never carry a class-level `@ConfigurationProperties`: the bootstrap's application-wide +`@ConfigurationPropertiesScan` would then register it in every deployment, so a service that runs +no Redis would bind Redis configuration. `RedisOptionalityContractTest` in `app-bootstrap` enforces +that. Role selectors (cache binding, session auth-mode, idempotency/lease/rate-limit provider) +choose which capabilities compose; none of them activates Redis, and selecting one while the global +switch is off is refused by `RedisActivationValidator`. diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/idempotency/IdempotencyScripts.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/idempotency/IdempotencyScripts.java new file mode 100644 index 00000000..f3e259be --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/idempotency/IdempotencyScripts.java @@ -0,0 +1,306 @@ +package dev.caskeleton.adapter.outbound.cache.redis.idempotency; + +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisCommandGateway; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicReference; + +/** + * The idempotency record's state machine, one atomic program per transition. + * + *

Each program re-reads the record, checks the owner and the state revision, and only + * then mutates. Both halves are necessary. The owner alone would let a holder whose lease expired — + * and whose claim was taken over — write over the new holder's work. The revision alone would let a + * different owner at the same revision do it. Together they are an optimistic compare-and-set, and + * every successful transition bumps the revision so a stale handle can never be reused. + * + *

The record is a hash, not a string, because the transitions touch different fields and a + * read-modify-write of a serialized blob would reintroduce exactly the race the programs remove. + * + *

Nothing here interprets the stored response. It is an opaque payload the application encoded; + * this adapter stores and returns bytes, so a codec change is a concern of whoever wrote them. + */ +public final class IdempotencyScripts { + + /** + * Claim: create, replay, take over an expired lease, or report why not. + * + *

The fingerprint is compared before anything else. Two different requests that hash to the + * same idempotency scope are a client error, and treating the second as a replay of the first + * would return somebody else's response. + */ + private static final String CLAIM = + """ + local state = redis.call('HGET', KEYS[1], 'state') + local nowMillis = tonumber(ARGV[6]) + if state == false then + redis.call('HSET', KEYS[1], + 'state', 'CLAIMED', 'owner', ARGV[1], 'attempt', 1, 'rev', 1, + 'op', ARGV[2], 'fp', ARGV[3], 'codec', ARGV[4], 'policy', ARGV[5], + 'leaseUntil', nowMillis + tonumber(ARGV[7])) + redis.call('PEXPIRE', KEYS[1], ARGV[8]) + return {'ACQUIRED', 1, 1, ARGV[1], '', tostring(nowMillis + tonumber(ARGV[7]))} + end + local fingerprint = redis.call('HGET', KEYS[1], 'fp') + if fingerprint ~= ARGV[3] then + return {'FINGERPRINT_MISMATCH', 0, 0, '', '', ''} + end + local owner = redis.call('HGET', KEYS[1], 'owner') + local op = redis.call('HGET', KEYS[1], 'op') + local attempt = tonumber(redis.call('HGET', KEYS[1], 'attempt')) + local rev = tonumber(redis.call('HGET', KEYS[1], 'rev')) + if state == 'COMPLETED' then + return {'COMPLETED_REPLAY', attempt, rev, owner, redis.call('HGET', KEYS[1], 'resp'), + tostring(redis.call('PTTL', KEYS[1]))} + end + if state == 'ABANDONED' then + return {'RECOVERY_REQUIRED', attempt, rev, owner, '', ''} + end + if owner == ARGV[1] then + if op ~= ARGV[2] then + return {'OWNER_OPERATION_CONFLICT', attempt, rev, owner, '', ''} + end + -- Same owner, same operation: a retry whose first reply was lost. + return {'REPLAYED_ACQUIRE', attempt, rev, owner, '', + redis.call('HGET', KEYS[1], 'leaseUntil')} + end + local leaseUntil = tonumber(redis.call('HGET', KEYS[1], 'leaseUntil')) + if state == 'FAILED_RETRYABLE' or (leaseUntil ~= nil and leaseUntil <= nowMillis) then + -- The previous holder's processing lease expired, or they marked the attempt retryable. + -- Taking over bumps the attempt so the new holder can tell it is not the first. + redis.call('HSET', KEYS[1], + 'state', 'CLAIMED', 'owner', ARGV[1], 'attempt', attempt + 1, 'rev', rev + 1, + 'op', ARGV[2], 'leaseUntil', nowMillis + tonumber(ARGV[7])) + redis.call('PEXPIRE', KEYS[1], ARGV[8]) + return {'TAKEN_OVER', attempt + 1, rev + 1, ARGV[1], '', + tostring(nowMillis + tonumber(ARGV[7]))} + end + return {'IN_PROGRESS', attempt, rev, owner, '', tostring(leaseUntil - nowMillis)} + """; + + /** A generic owner+revision compare-and-set transition. */ + private static final String TRANSITION = + """ + local state = redis.call('HGET', KEYS[1], 'state') + if state == false then + return {'ABSENT', 0, 0, '', '', ''} + end + local owner = redis.call('HGET', KEYS[1], 'owner') + local rev = tonumber(redis.call('HGET', KEYS[1], 'rev')) + local attempt = tonumber(redis.call('HGET', KEYS[1], 'attempt')) + local op = redis.call('HGET', KEYS[1], 'op') + if owner ~= ARGV[1] then + return {'NOT_OWNER', attempt, rev, owner, '', ''} + end + if op ~= ARGV[3] then + return {'OPERATION_CONFLICT', attempt, rev, owner, '', ''} + end + if state == ARGV[5] then + -- Already in the target state, under the same owner and the same operation: a retry whose + -- first reply was lost, not a second transition. This is checked BEFORE the revision, + -- deliberately. The caller's handle necessarily carries the pre-transition revision — they + -- never received the reply that would have replaced it — so a revision check first would + -- turn every lost reply into NOT_OWNER and make the idempotent call non-idempotent. The + -- owner and operation already prove the record was moved by this caller and nobody else. + return {'ALREADY', attempt, rev, owner, redis.call('HGET', KEYS[1], 'resp') or '', ''} + end + if rev ~= tonumber(ARGV[2]) then + -- Stale handle: somebody moved the record on after this owner read it, and the target + -- state is not where they left it. + return {'NOT_OWNER', attempt, rev, owner, '', ''} + end + if state ~= ARGV[4] then + return {'WRONG_STATE', attempt, rev, owner, state, ''} + end + redis.call('HSET', KEYS[1], 'state', ARGV[5], 'rev', rev + 1) + if ARGV[6] ~= '' then + redis.call('HSET', KEYS[1], 'resp', ARGV[6]) + end + if ARGV[7] ~= '' then + redis.call('HSET', KEYS[1], 'leaseUntil', ARGV[7]) + end + if ARGV[8] ~= '' then + redis.call('PEXPIRE', KEYS[1], ARGV[8]) + end + return {'APPLIED', attempt, rev + 1, owner, '', ''} + """; + + /** Release before execution: only from CLAIMED, and only by the owner that holds it. */ + private static final String RELEASE = + """ + local state = redis.call('HGET', KEYS[1], 'state') + if state == false then + return {'ABSENT', 0, 0, '', '', ''} + end + local owner = redis.call('HGET', KEYS[1], 'owner') + local rev = tonumber(redis.call('HGET', KEYS[1], 'rev')) + local attempt = tonumber(redis.call('HGET', KEYS[1], 'attempt')) + local op = redis.call('HGET', KEYS[1], 'op') + if owner ~= ARGV[1] then + return {'NOT_OWNER', attempt, rev, owner, '', ''} + end + if op ~= ARGV[3] then + return {'OPERATION_CONFLICT', attempt, rev, owner, '', ''} + end + if state ~= 'CLAIMED' then + return {'WRONG_STATE', attempt, rev, owner, state, ''} + end + redis.call('DEL', KEYS[1]) + return {'APPLIED', attempt, rev, owner, '', ''} + """; + + /** Inspect: read the record without touching it. */ + private static final String INSPECT = + """ + local state = redis.call('HGET', KEYS[1], 'state') + if state == false then + return {'ABSENT', 0, 0, '', '', ''} + end + local fingerprint = redis.call('HGET', KEYS[1], 'fp') + if fingerprint ~= ARGV[2] then + return {'FINGERPRINT_MISMATCH', 0, 0, '', '', ''} + end + local owner = redis.call('HGET', KEYS[1], 'owner') + local rev = tonumber(redis.call('HGET', KEYS[1], 'rev')) + local attempt = tonumber(redis.call('HGET', KEYS[1], 'attempt')) + local op = redis.call('HGET', KEYS[1], 'op') + local mine = 'OTHER' + if owner == ARGV[1] then + if op == ARGV[3] then + mine = 'MINE' + else + mine = 'OPERATION_CONFLICT' + end + end + return {state, attempt, rev, owner, + redis.call('HGET', KEYS[1], 'resp') or '', + mine .. '|' .. tostring(redis.call('PTTL', KEYS[1]))} + """; + + private final Map> digests = new LinkedHashMap<>(); + + CompletionStage claim(RedisCommandGateway gateway, byte[] key, List arguments) { + return run(gateway, "claim", CLAIM, key, arguments); + } + + CompletionStage transition( + RedisCommandGateway gateway, byte[] key, List arguments) { + return run(gateway, "transition", TRANSITION, key, arguments); + } + + CompletionStage release(RedisCommandGateway gateway, byte[] key, List arguments) { + return run(gateway, "release", RELEASE, key, arguments); + } + + CompletionStage inspect(RedisCommandGateway gateway, byte[] key, List arguments) { + return run(gateway, "inspect", INSPECT, key, arguments); + } + + private CompletionStage run( + RedisCommandGateway gateway, String name, String source, byte[] key, List arguments) { + AtomicReference cache = + digests.computeIfAbsent(name, unused -> new AtomicReference<>()); + List encoded = new ArrayList<>(arguments.size()); + for (String argument : arguments) { + encoded.add(argument.getBytes(StandardCharsets.UTF_8)); + } + return digest(gateway, source, cache) + .thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, encoded)) + .handle( + (reply, failure) -> + failure == null + ? CompletableFuture.completedFuture(reply) + : reload(gateway, source, cache, key, encoded, failure)) + .thenCompose(stage -> stage) + .thenApply(IdempotencyScripts::replyOf); + } + + private CompletionStage> reload( + RedisCommandGateway gateway, + String source, + AtomicReference cache, + byte[] key, + List arguments, + Throwable failure) { + if (!scriptMissing(failure)) { + return CompletableFuture.failedFuture(failure); + } + cache.set(null); + return digest(gateway, source, cache) + .thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, arguments)); + } + + private static CompletionStage digest( + RedisCommandGateway gateway, String source, AtomicReference cache) { + String cached = cache.get(); + if (cached != null) { + return CompletableFuture.completedFuture(cached); + } + return gateway + .loadScript(source.getBytes(StandardCharsets.UTF_8)) + .thenApply( + loaded -> { + cache.set(loaded); + return loaded; + }); + } + + private static boolean scriptMissing(Throwable failure) { + Throwable cause = failure; + while ((cause instanceof CompletionException || cause instanceof ExecutionException) + && cause.getCause() != null) { + cause = cause.getCause(); + } + String message = cause.getMessage(); + return message != null && message.strip().toUpperCase(Locale.ROOT).startsWith("NOSCRIPT"); + } + + private static Reply replyOf(List reply) { + if (reply == null || reply.size() < 6) { + throw new IllegalStateException("the idempotency program answered with an unexpected shape"); + } + return new Reply( + text(reply.get(0)), + number(reply.get(1)), + number(reply.get(2)), + text(reply.get(3)), + text(reply.get(4)), + text(reply.get(5))); + } + + private static long number(Object value) { + if (value instanceof Number n) { + return n.longValue(); + } + String text = text(value); + return text.isBlank() ? 0L : Long.parseLong(text.strip()); + } + + private static String text(Object value) { + if (value instanceof byte[] bytes) { + return new String(bytes, StandardCharsets.UTF_8); + } + return value == null ? "" : String.valueOf(value); + } + + /** + * One program's answer. + * + * @param status the transition verdict + * @param attempt the record's attempt counter + * @param revision the record's state revision after the call + * @param owner the stored owner token + * @param payload the stored response, when the verdict carries one + * @param detail verdict-specific detail: a lease deadline, a TTL, or the observed state + */ + record Reply( + String status, long attempt, long revision, String owner, String payload, String detail) {} +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/lease/LeaseScripts.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/lease/LeaseScripts.java new file mode 100644 index 00000000..e95efcd3 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/lease/LeaseScripts.java @@ -0,0 +1,203 @@ +package dev.caskeleton.adapter.outbound.cache.redis.lease; + +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisCommandGateway; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicReference; + +/** + * The four owner-checked lease programs. + * + *

Every one of them compares the stored owner before it mutates, inside the same server + * execution. That is the entire safety property of this adapter: a renew or release that reads the + * owner and then writes would let a holder whose lease expired in between extend or delete a lease + * that now belongs to somebody else. "Check then act" is not a lease. + * + *

The stored value is {@code ownerToken:operationId}. Both, because the same owner retrying a + * different operation is a different claim — a caller that re-acquires under a new operation id has + * lost the old one's guarantee and must be told, rather than silently inheriting it. + */ +public final class LeaseScripts { + + /** Acquire: set if absent, and report the existing holder when present. */ + private static final String ACQUIRE = + """ + local existing = redis.call('GET', KEYS[1]) + if existing == false then + redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2]) + return {1, redis.call('PTTL', KEYS[1]), ''} + end + if existing == ARGV[1] then + -- The same owner and the same operation. This is a retry of a call whose reply was lost, + -- not a second claim, so it is answered with the lease rather than with contention. + return {2, redis.call('PTTL', KEYS[1]), existing} + end + return {0, redis.call('PTTL', KEYS[1]), existing} + """; + + /** Renew: extend only while this exact owner and operation still hold it. */ + private static final String RENEW = + """ + local existing = redis.call('GET', KEYS[1]) + if existing == false then + return {0, 0, ''} + end + if existing ~= ARGV[1] then + return {-1, redis.call('PTTL', KEYS[1]), existing} + end + redis.call('PEXPIRE', KEYS[1], ARGV[2]) + return {1, redis.call('PTTL', KEYS[1]), existing} + """; + + /** Release: delete only while this exact owner and operation still hold it. */ + private static final String RELEASE = + """ + local existing = redis.call('GET', KEYS[1]) + if existing == false then + return {0, 0, ''} + end + if existing ~= ARGV[1] then + return {-1, redis.call('PTTL', KEYS[1]), existing} + end + redis.call('DEL', KEYS[1]) + return {1, 0, existing} + """; + + /** Inspect: read without mutating, so a caller can ask without taking. */ + private static final String INSPECT = + """ + local existing = redis.call('GET', KEYS[1]) + if existing == false then + return {0, 0, ''} + end + if existing ~= ARGV[1] then + return {-1, redis.call('PTTL', KEYS[1]), existing} + end + return {1, redis.call('PTTL', KEYS[1]), existing} + """; + + private final AtomicReference acquireDigest = new AtomicReference<>(); + private final AtomicReference renewDigest = new AtomicReference<>(); + private final AtomicReference releaseDigest = new AtomicReference<>(); + private final AtomicReference inspectDigest = new AtomicReference<>(); + + CompletionStage acquire( + RedisCommandGateway gateway, byte[] key, String ownership, long ttlMillis) { + return run(gateway, ACQUIRE, acquireDigest, key, args(ownership, ttlMillis)); + } + + CompletionStage renew( + RedisCommandGateway gateway, byte[] key, String ownership, long ttlMillis) { + return run(gateway, RENEW, renewDigest, key, args(ownership, ttlMillis)); + } + + CompletionStage release(RedisCommandGateway gateway, byte[] key, String ownership) { + return run(gateway, RELEASE, releaseDigest, key, args(ownership, 0)); + } + + CompletionStage inspect(RedisCommandGateway gateway, byte[] key, String ownership) { + return run(gateway, INSPECT, inspectDigest, key, args(ownership, 0)); + } + + private CompletionStage run( + RedisCommandGateway gateway, + String source, + AtomicReference cache, + byte[] key, + List arguments) { + return digest(gateway, source, cache) + .thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, arguments)) + .handle( + (reply, failure) -> + failure == null + ? CompletableFuture.completedFuture(reply) + : reload(gateway, source, cache, key, arguments, failure)) + .thenCompose(stage -> stage) + .thenApply(LeaseScripts::replyOf); + } + + private CompletionStage> reload( + RedisCommandGateway gateway, + String source, + AtomicReference cache, + byte[] key, + List arguments, + Throwable failure) { + if (!scriptMissing(failure)) { + return CompletableFuture.failedFuture(failure); + } + cache.set(null); + return digest(gateway, source, cache) + .thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, arguments)); + } + + private static CompletionStage digest( + RedisCommandGateway gateway, String source, AtomicReference cache) { + String cached = cache.get(); + if (cached != null) { + return CompletableFuture.completedFuture(cached); + } + return gateway + .loadScript(source.getBytes(StandardCharsets.UTF_8)) + .thenApply( + loaded -> { + cache.set(loaded); + return loaded; + }); + } + + private static boolean scriptMissing(Throwable failure) { + Throwable cause = failure; + while ((cause instanceof CompletionException || cause instanceof ExecutionException) + && cause.getCause() != null) { + cause = cause.getCause(); + } + String message = cause.getMessage(); + return message != null && message.strip().toUpperCase(Locale.ROOT).startsWith("NOSCRIPT"); + } + + private static Reply replyOf(List reply) { + if (reply == null || reply.size() < 3) { + throw new IllegalStateException("the lease program answered with an unexpected shape"); + } + return new Reply(asLong(reply.get(0)), asLong(reply.get(1)), asText(reply.get(2))); + } + + private static long asLong(Object value) { + if (value instanceof Number number) { + return number.longValue(); + } + if (value instanceof byte[] bytes) { + return Long.parseLong(new String(bytes, StandardCharsets.UTF_8).strip()); + } + throw new IllegalStateException("the lease program answered with an unexpected value type"); + } + + private static String asText(Object value) { + if (value instanceof byte[] bytes) { + return new String(bytes, StandardCharsets.UTF_8); + } + return value == null ? "" : String.valueOf(value); + } + + private static List args(String ownership, long ttlMillis) { + return List.of( + ownership.getBytes(StandardCharsets.UTF_8), + Long.toString(ttlMillis).getBytes(StandardCharsets.UTF_8)); + } + + /** + * One program's answer. + * + * @param status {@code 1} applied, {@code 2} replay of the same claim, {@code 0} absent, {@code + * -1} held by somebody else + * @param remainingMillis the server's remaining TTL, diagnostic only + * @param holder the stored ownership string, empty when absent + */ + record Reply(long status, long remainingMillis, String holder) {} +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/ratelimit/RateLimitScripts.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/ratelimit/RateLimitScripts.java new file mode 100644 index 00000000..dd5d9405 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/ratelimit/RateLimitScripts.java @@ -0,0 +1,280 @@ +package dev.caskeleton.adapter.outbound.cache.redis.ratelimit; + +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisCommandGateway; +import dev.caskeleton.shared.ratelimit.RateLimitPolicy; +import dev.caskeleton.shared.ratelimit.RateParameters; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicReference; + +/** + * The atomic programs that make one rate-limit decision one round trip. + * + *

Every algorithm here reads state, decides, mutates, and sets an expiry inside a single server + * execution. Splitting that into commands is not a performance question: two concurrent requests + * that both read "49 used of 50" would both be allowed, and the limit would be exceeded by exactly + * the concurrency. The whole decision has to be indivisible or it is not a limit. + * + *

Time comes from the caller, not from the server's {@code TIME}. Two reasons: a script that + * calls {@code TIME} is non-deterministic, and the decision has to be measured against the clock + * the caller's deadline is measured against. The caller's clock going backwards is handled by the + * policy's clock-regression bound rather than by trusting it blindly. + * + *

Loaded once and called by digest. A {@code NOSCRIPT} means the server rejected the call before + * running anything, so reloading and retrying once is safe — it is not a retry of an ambiguous + * mutation. + */ +public final class RateLimitScripts { + + /** + * Fixed window: one counter per window, expiring with it. + * + *

Returns {@code {allowed, remaining, resetAfterMillis}}. The expiry is set from the window + * rather than refreshed per hit, so a subject cannot hold a counter alive indefinitely. + */ + private static final String FIXED_WINDOW = + """ + local limit = tonumber(ARGV[1]) + local windowMillis = tonumber(ARGV[2]) + local cost = tonumber(ARGV[3]) + local nowMillis = tonumber(ARGV[4]) + local windowStart = nowMillis - (nowMillis % windowMillis) + local resetAfter = (windowStart + windowMillis) - nowMillis + local bucket = tostring(windowStart) + local current = tonumber(redis.call('HGET', KEYS[1], bucket)) or 0 + if current + cost > limit then + return {0, limit - current, resetAfter} + end + redis.call('HSET', KEYS[1], bucket, current + cost) + redis.call('PEXPIRE', KEYS[1], windowMillis * 2) + return {1, limit - (current + cost), resetAfter} + """; + + /** + * Sliding counter: the current window plus a weighted share of the previous one. + * + *

Approximate by construction, and the port says so. An exact sliding window needs one sorted + * set entry per request, which costs memory proportional to the traffic it is limiting — the + * failure mode of an exact limiter is that it becomes the outage. + */ + private static final String SLIDING_COUNTER = + """ + local limit = tonumber(ARGV[1]) + local windowMillis = tonumber(ARGV[2]) + local cost = tonumber(ARGV[3]) + local nowMillis = tonumber(ARGV[4]) + local windowStart = nowMillis - (nowMillis % windowMillis) + local elapsed = nowMillis - windowStart + local resetAfter = windowMillis - elapsed + local current = tonumber(redis.call('HGET', KEYS[1], tostring(windowStart))) or 0 + local previous = tonumber(redis.call('HGET', KEYS[1], tostring(windowStart - windowMillis))) or 0 + local weight = (windowMillis - elapsed) / windowMillis + local estimated = current + math.floor(previous * weight) + if estimated + cost > limit then + return {0, math.max(0, limit - estimated), resetAfter} + end + redis.call('HSET', KEYS[1], tostring(windowStart), current + cost) + redis.call('HDEL', KEYS[1], tostring(windowStart - (windowMillis * 2))) + redis.call('PEXPIRE', KEYS[1], windowMillis * 3) + return {1, math.max(0, limit - (estimated + cost)), resetAfter} + """; + + /** + * Token bucket: refill by elapsed time, then spend. + * + *

The stored timestamp is advanced by whole refill periods only. Advancing it to "now" would + * discard the fraction of a period that had already accrued, so a caller polling faster than the + * refill period would never accumulate a token. + */ + private static final String TOKEN_BUCKET = + """ + local capacity = tonumber(ARGV[1]) + local refillTokens = tonumber(ARGV[2]) + local refillPeriodMillis = tonumber(ARGV[3]) + local cost = tonumber(ARGV[4]) + local nowMillis = tonumber(ARGV[5]) + local state = redis.call('HMGET', KEYS[1], 'tokens', 'updatedAt') + local tokens = tonumber(state[1]) + local updatedAt = tonumber(state[2]) + if tokens == nil or updatedAt == nil then + tokens = capacity + updatedAt = nowMillis + end + if updatedAt > nowMillis then + -- The caller's clock went backwards. Refilling on a negative elapsed time would remove + -- tokens; holding the state still is the conservative reading. + updatedAt = nowMillis + end + local periods = math.floor((nowMillis - updatedAt) / refillPeriodMillis) + if periods > 0 then + tokens = math.min(capacity, tokens + (periods * refillTokens)) + updatedAt = updatedAt + (periods * refillPeriodMillis) + end + local resetAfter = refillPeriodMillis - ((nowMillis - updatedAt) % refillPeriodMillis) + if tokens < cost then + redis.call('HSET', KEYS[1], 'tokens', tokens, 'updatedAt', updatedAt) + redis.call('PEXPIRE', KEYS[1], refillPeriodMillis * (capacity / math.max(1, refillTokens)) + refillPeriodMillis) + return {0, math.floor(tokens), resetAfter} + end + tokens = tokens - cost + redis.call('HSET', KEYS[1], 'tokens', tokens, 'updatedAt', updatedAt) + redis.call('PEXPIRE', KEYS[1], refillPeriodMillis * (capacity / math.max(1, refillTokens)) + refillPeriodMillis) + return {1, math.floor(tokens), resetAfter} + """; + + private final AtomicReference fixedWindowDigest = new AtomicReference<>(); + + private final AtomicReference slidingCounterDigest = new AtomicReference<>(); + + private final AtomicReference tokenBucketDigest = new AtomicReference<>(); + + /** + * Evaluates one request atomically. + * + * @param gateway the driver seam of a borrowed lease + * @param key the rendered counter key + * @param policy the policy to apply + * @param cost the request's cost + * @param now the caller's clock reading + * @return the evaluation + */ + public CompletionStage evaluate( + RedisCommandGateway gateway, byte[] key, RateLimitPolicy policy, long cost, Instant now) { + Objects.requireNonNull(gateway, "gateway must be non-null"); + Objects.requireNonNull(policy, "policy must be non-null"); + Objects.requireNonNull(now, "now must be non-null"); + long nowMillis = now.toEpochMilli(); + return switch (policy.parameters()) { + case RateParameters.FixedWindow window -> + run( + gateway, + FIXED_WINDOW, + fixedWindowDigest, + key, + arguments(window.limit(), window.window().toMillis(), cost, nowMillis)); + case RateParameters.SlidingCounter sliding -> + run( + gateway, + SLIDING_COUNTER, + slidingCounterDigest, + key, + arguments(sliding.limit(), sliding.window().toMillis(), cost, nowMillis)); + case RateParameters.TokenBucket bucket -> + run( + gateway, + TOKEN_BUCKET, + tokenBucketDigest, + key, + arguments( + bucket.capacity(), + bucket.refillTokens(), + bucket.refillPeriod().toMillis(), + cost, + nowMillis)); + default -> + CompletableFuture.failedFuture( + new IllegalStateException("unsupported rate parameters: " + policy.parameters())); + }; + } + + private CompletionStage run( + RedisCommandGateway gateway, + String source, + AtomicReference cache, + byte[] key, + List arguments) { + return digest(gateway, source, cache) + .thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, arguments)) + .handle( + (reply, failure) -> + failure == null + ? CompletableFuture.completedFuture(reply) + : reload(gateway, source, cache, key, arguments, failure)) + .thenCompose(stage -> stage) + .thenApply(RateLimitScripts::evaluationOf); + } + + private CompletionStage> reload( + RedisCommandGateway gateway, + String source, + AtomicReference cache, + byte[] key, + List arguments, + Throwable failure) { + if (!scriptMissing(failure)) { + return CompletableFuture.failedFuture(failure); + } + cache.set(null); + return digest(gateway, source, cache) + .thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, arguments)); + } + + private static CompletionStage digest( + RedisCommandGateway gateway, String source, AtomicReference cache) { + String cached = cache.get(); + if (cached != null) { + return CompletableFuture.completedFuture(cached); + } + return gateway + .loadScript(source.getBytes(StandardCharsets.UTF_8)) + .thenApply( + loaded -> { + cache.set(loaded); + return loaded; + }); + } + + private static boolean scriptMissing(Throwable failure) { + Throwable cause = failure; + while ((cause instanceof CompletionException || cause instanceof ExecutionException) + && cause.getCause() != null) { + cause = cause.getCause(); + } + String message = cause.getMessage(); + return message != null && message.strip().toUpperCase(Locale.ROOT).startsWith("NOSCRIPT"); + } + + private static Evaluation evaluationOf(List reply) { + if (reply == null || reply.size() < 3) { + throw new IllegalStateException( + "the rate limit program answered with " + + (reply == null ? "nothing" : reply.size()) + + " values; three were expected"); + } + return new Evaluation(asLong(reply.get(0)) == 1L, asLong(reply.get(1)), asLong(reply.get(2))); + } + + private static long asLong(Object value) { + if (value instanceof Number number) { + return number.longValue(); + } + if (value instanceof byte[] bytes) { + return Long.parseLong(new String(bytes, StandardCharsets.UTF_8).strip()); + } + throw new IllegalStateException( + "the rate limit program answered with an unexpected value type: " + + (value == null ? "null" : value.getClass().getName())); + } + + private static List arguments(long... values) { + return java.util.Arrays.stream(values) + .mapToObj(value -> Long.toString(value).getBytes(StandardCharsets.UTF_8)) + .toList(); + } + + /** + * One evaluation's result. + * + * @param allowed whether the request may proceed + * @param remaining the remaining budget after this request + * @param resetAfterMillis how long until the budget changes + */ + public record Evaluation(boolean allowed, long remaining, long resetAfterMillis) {} +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/admin/LettuceRedisAdminOperations.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/admin/LettuceRedisAdminOperations.java new file mode 100644 index 00000000..eef5784a --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/admin/LettuceRedisAdminOperations.java @@ -0,0 +1,337 @@ +package dev.caskeleton.adapter.outbound.cache.redis.sdk.admin; + +import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.CommandId; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.CommandSupport; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.OperationBudget; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.QualifiedRedisKey; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.command.CommandRequest; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.command.RedisCommandCatalog; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.command.RedisCommandPolicy; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.command.SyncRedisCommandExecutor; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisCommandGateway; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisOperationContext; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * The admin plane, on its own connection and its own ACL account. + * + *

It takes its own gateway for the same reason the blocking operations do: a diagnostic that + * walks the keyspace or serializes a large {@code INFO} must not compete with request traffic, and + * the account it authenticates with should be able to read diagnostics and nothing else. Binding + * that to a separate gateway instance is how the separation is expressed structurally instead of + * being left to a deployment note. + * + *

Every command is checked against the catalog before it is built: not classified {@code + * ADMIN_ONLY}, or not read-only, and it does not get sent. That check is what keeps a future + * addition to this class from quietly becoming a write. + */ +public final class LettuceRedisAdminOperations implements RedisAdminOperations { + + private static final String FAMILY = "ADMIN"; + + private static final int MAX_PROJECTED = 1_000; + + private final RedisCommandCatalog catalog; + + private final RedisCommandGateway adminGateway; + + private final RedisOperationContext context; + + private final SyncRedisCommandExecutor executor; + + private final Duration timeout; + + /** + * Creates the admin plane. + * + * @param catalog the command policy catalog + * @param adminGateway the driver seam, bound to the admin account's own connection + * @param context the shared rendering and budget rules + * @param executor the guarded blocking executor + * @param timeout the bound on one diagnostic + */ + public LettuceRedisAdminOperations( + RedisCommandCatalog catalog, + RedisCommandGateway adminGateway, + RedisOperationContext context, + SyncRedisCommandExecutor executor, + Duration timeout) { + this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null"); + this.adminGateway = Objects.requireNonNull(adminGateway, "admin gateway must be non-null"); + this.context = Objects.requireNonNull(context, "operation context must be non-null"); + this.executor = Objects.requireNonNull(executor, "executor must be non-null"); + this.timeout = Objects.requireNonNull(timeout, "timeout must be non-null"); + if (timeout.isZero() || timeout.isNegative()) { + throw new IllegalArgumentException("the admin timeout must be positive"); + } + } + + @Override + public Map serverInfo(String section) { + Objects.requireNonNull(section, "section must be non-null"); + return fields(text(run(CommandId.parse("INFO"), List.of(), utf8(section)))); + } + + @Override + public long databaseSize() { + return number(run(CommandId.parse("DBSIZE"), List.of())); + } + + @Override + public long memoryUsage(QualifiedRedisKey key) { + Objects.requireNonNull(key, "key must be non-null"); + byte[] rendered = context.renderKey(key); + List reply = run(CommandId.parse("MEMORY USAGE"), List.of(key), rendered); + return reply.isEmpty() || reply.get(0) == null ? -1L : number(reply); + } + + @Override + public List slowLog(int count) { + requireBounded(count, "a slow log read"); + List reply = + run(CommandId.parse("SLOWLOG GET"), List.of(), utf8(Integer.toString(count))); + List entries = new ArrayList<>(); + for (Object element : reply) { + List row = nested(element); + if (row.size() < 4) { + continue; + } + entries.add( + new SlowLogEntry( + (Long) row.get(0), + Instant.ofEpochSecond((Long) row.get(1)), + Duration.ofNanos(Duration.ofMillis((Long) row.get(2)).toNanos() / 1_000L), + family(nested(row.get(3))))); + } + return List.copyOf(entries); + } + + @Override + public Map latencyLatest() { + List reply = run(CommandId.parse("LATENCY LATEST"), List.of()); + Map latest = new LinkedHashMap<>(); + for (Object element : reply) { + List row = nested(element); + if (row.size() < 3) { + continue; + } + latest.put(text(List.of(row.get(0))), Duration.ofMillis((Long) row.get(2))); + } + return Map.copyOf(latest); + } + + @Override + public List clients(int limit) { + requireBounded(limit, "a client projection"); + String listing = text(run(CommandId.parse("CLIENT LIST"), List.of())); + List clients = new ArrayList<>(); + for (String line : listing.lines().toList()) { + if (line.isBlank() || clients.size() == limit) { + break; + } + Map attributes = attributes(line); + clients.add( + new ClientSummary( + Long.parseLong(attributes.getOrDefault("id", "0")), + Duration.ofSeconds(Long.parseLong(attributes.getOrDefault("age", "0"))), + Duration.ofSeconds(Long.parseLong(attributes.getOrDefault("idle", "0"))), + attributes.getOrDefault("cmd", "unknown").toUpperCase(Locale.ROOT))); + } + return List.copyOf(clients); + } + + @Override + public Map clusterInfo() { + return fields(text(run(CommandId.parse("CLUSTER INFO"), List.of()))); + } + + /** + * The only configuration parameters this plane will read. + * + *

Chosen for what an operator diagnosing a Redis problem actually needs — memory ceiling and + * eviction, persistence, replication durability, connection lifetime, topology — and nothing + * else. Adding a parameter is an edit here, which is the point: the set is reviewable, whereas a + * glob is not. + */ + private static final List DIAGNOSTIC_PARAMETERS = + List.of( + "maxmemory", + "maxmemory-policy", + "maxmemory-samples", + "appendonly", + "appendfsync", + "save", + "min-replicas-to-write", + "min-replicas-max-lag", + "timeout", + "tcp-keepalive", + "databases", + "cluster-enabled", + "cluster-require-full-coverage", + "lazyfree-lazy-eviction", + "lazyfree-lazy-expire", + "notify-keyspace-events", + "slowlog-log-slower-than", + "slowlog-max-len"); + + /** Substrings that mark a parameter as carrying credential material. */ + private static final List SECRET_MARKERS = + List.of("pass", "auth", "secret", "key-file", "keyfile", "user"); + + /** Replacement for a value that must never leave the server. */ + static final String REDACTED = "[redacted]"; + + @Override + public Map configuration() { + List arguments = + DIAGNOSTIC_PARAMETERS.stream().map(LettuceRedisAdminOperations::utf8).toList(); + List reply = run(CommandId.parse("CONFIG GET"), List.of(), arguments); + Map parameters = new LinkedHashMap<>(); + for (int index = 0; index + 1 < reply.size(); index += 2) { + String name = text(List.of(reply.get(index))); + // Filtered again on the way out. The request already named only allowlisted parameters, but + // a server-side alias or a future glob-expanding change must not be able to widen the + // projection, and a parameter that slipped through must not carry its value with it. + if (!DIAGNOSTIC_PARAMETERS.contains(name)) { + continue; + } + String value = text(List.of(reply.get(index + 1))); + parameters.put(name, isSecretShaped(name) ? REDACTED : value); + } + return Map.copyOf(parameters); + } + + private static boolean isSecretShaped(String parameterName) { + String lower = parameterName.toLowerCase(Locale.ROOT); + return SECRET_MARKERS.stream().anyMatch(lower::contains); + } + + @Override + public Optional aclDryRun(String username, CommandId commandId) { + Objects.requireNonNull(username, "username must be non-null"); + Objects.requireNonNull(commandId, "command id must be non-null"); + List arguments = new ArrayList<>(); + arguments.add(utf8(username)); + arguments.add(utf8(commandId.family())); + commandId.subcommand().map(LettuceRedisAdminOperations::utf8).ifPresent(arguments::add); + String answer = text(run(CommandId.parse("ACL DRYRUN"), List.of(), arguments)); + return "OK".equals(answer) ? Optional.empty() : Optional.of(answer); + } + + private List run(CommandId commandId, List keys, byte[]... arguments) { + return run(commandId, keys, List.of(arguments)); + } + + private List run( + CommandId commandId, List keys, List arguments) { + RedisCommandPolicy policy = catalog.require(commandId); + if (policy.support() != CommandSupport.ADMIN_ONLY || !policy.readOnly()) { + throw context.reject( + FAMILY, true, "the admin plane only sends read-only diagnostics the catalog approved"); + } + long requestBytes = 1L; + for (byte[] argument : arguments) { + requestBytes += argument.length; + } + OperationBudget budget = + new OperationBudget( + Math.max(1, keys.size()), + requestBytes, + context.limits().maxReplyBytesPerElement(), + timeout); + return executor.execute( + new CommandRequest<>( + commandId, + keys, + requestBytes, + 0L, + Optional.empty(), + Optional.empty(), + Optional.of(budget), + Optional.empty(), + () -> adminGateway.sendAdminDiagnostic(commandId, arguments))); + } + + private void requireBounded(int count, String description) { + if (count < 1) { + throw context.reject(FAMILY, true, description + " must declare a positive bound"); + } + if (count > MAX_PROJECTED) { + throw context.reject( + FAMILY, true, description + " may not exceed " + MAX_PROJECTED + " entries"); + } + } + + private static String family(List commandWords) { + return commandWords.isEmpty() + ? "UNKNOWN" + : text(List.of(commandWords.get(0))).toUpperCase(Locale.ROOT); + } + + private static Map fields(String body) { + Map parsed = new LinkedHashMap<>(); + for (String line : body.lines().toList()) { + String trimmed = line.strip(); + int separator = trimmed.indexOf(':'); + if (trimmed.isEmpty() || trimmed.startsWith("#") || separator < 1) { + continue; + } + parsed.put(trimmed.substring(0, separator), trimmed.substring(separator + 1)); + } + return Map.copyOf(parsed); + } + + private static Map attributes(String line) { + Map parsed = new LinkedHashMap<>(); + // Parsed by hand rather than by splitting: a CLIENT LIST line is space-separated key=value + // pairs, and the values can themselves contain characters a naive split would mangle. + String remainder = line.strip(); + while (!remainder.isEmpty()) { + int space = remainder.indexOf(' '); + String pair = space < 0 ? remainder : remainder.substring(0, space); + remainder = space < 0 ? "" : remainder.substring(space + 1); + int separator = pair.indexOf('='); + if (separator > 0) { + parsed.put(pair.substring(0, separator), pair.substring(separator + 1)); + } + } + return parsed; + } + + private static long number(List reply) { + Object first = reply.isEmpty() ? null : reply.get(0); + if (first instanceof Long value) { + return value; + } + if (first instanceof byte[] bytes) { + return Long.parseLong(new String(bytes, StandardCharsets.UTF_8).strip()); + } + throw new IllegalStateException("the diagnostic did not answer with a number"); + } + + private static String text(List reply) { + Object first = reply.isEmpty() ? null : reply.get(0); + if (first instanceof byte[] bytes) { + return new String(bytes, StandardCharsets.UTF_8); + } + return first == null ? "" : String.valueOf(first); + } + + @SuppressWarnings("unchecked") + private static List nested(Object element) { + return element instanceof List ? (List) element : List.of(); + } + + private static byte[] utf8(String text) { + return text.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/admin/RedisAdminOperations.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/admin/RedisAdminOperations.java new file mode 100644 index 00000000..c71ea863 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/admin/RedisAdminOperations.java @@ -0,0 +1,103 @@ +package dev.caskeleton.adapter.outbound.cache.redis.sdk.admin; + +import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.CommandId; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.QualifiedRedisKey; +import java.time.Duration; +import java.util.List; +import java.util.Map; + +/** + * Read-only server diagnostics, separated from the application request path. + * + *

Everything here is read-only by construction: the command policy catalog classifies each of + * these {@code ADMIN_ONLY} and read-only, and the implementation refuses to send anything that is + * not. The destructive counterparts an operator might reach for — {@code FLUSHDB}, {@code + * FLUSHALL}, {@code SHUTDOWN}, {@code DEBUG}, {@code CONFIG SET}, {@code CLIENT KILL}, {@code ACL + * SETUSER}, {@code SLOWLOG RESET}, {@code LATENCY RESET} — are all {@code BLOCKED} in the catalog + * and have no method here or anywhere else in the SDK. + * + *

The plane is expected to run on its own connection factory and its own ACL account. That is + * not something this interface can enforce, which is exactly why the catalog blocks the dangerous + * commands outright rather than trusting the deployment to have separated the credentials. + */ +public interface RedisAdminOperations { + + /** + * Reads one {@code INFO} section. + * + * @param section the section name, for example {@code memory} or {@code replication} + * @return the section's fields + */ + Map serverInfo(String section); + + /** + * Reads the key count of the current database. + * + * @return the key count + */ + long databaseSize(); + + /** + * Reads the memory one key occupies. + * + * @param key the key, which is namespace-checked like any other + * @return the size in bytes, or {@code -1} when the key is absent + */ + long memoryUsage(QualifiedRedisKey key); + + /** + * Reads the most recent slow log entries. + * + * @param count the strictly positive bound on returned entries + * @return the entries, newest first + */ + List slowLog(int count); + + /** + * Reads the latest latency spike per monitored event. + * + * @return the latest spike per event name + */ + Map latencyLatest(); + + /** + * Reads a bounded projection of the connected clients. + * + * @param limit the strictly positive bound on returned clients + * @return the projected clients + */ + List clients(int limit); + + /** + * Reads the cluster state. + * + * @return the {@code CLUSTER INFO} fields + */ + Map clusterInfo(); + + /** + * Reads the fixed diagnostic configuration projection. + * + *

There is deliberately no pattern parameter. {@code CONFIG GET} with a caller-supplied glob + * is an arbitrary read of the server's configuration: {@code *} returns everything the account + * can see, including {@code requirepass}, {@code masterauth}, {@code masteruser} and the TLS key + * passwords. An admin plane whose whole purpose is bounded, payload-free diagnostics cannot own a + * method that returns whatever the caller asks for, so the parameter set is fixed here and + * anything outside it is unreachable. + * + * @return the allowlisted diagnostic parameters, with any secret-shaped value redacted + */ + Map configuration(); + + /** + * Asks the server whether a user would be allowed to run a command. + * + *

This is how an ACL account is verified against what the SDK actually sends, rather than + * against what someone believed it sends. + * + * @param username the ACL user + * @param commandId the command to test + * @return empty when the command would be allowed, otherwise the server's refusal reason + */ + java.util.Optional aclDryRun(String username, CommandId commandId); +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkAutoConfiguration.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkAutoConfiguration.java new file mode 100644 index 00000000..a699afb7 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkAutoConfiguration.java @@ -0,0 +1,397 @@ +package dev.caskeleton.adapter.outbound.cache.redis.sdk.config; + +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisConnectionKind; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisCredentialRole; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeClient; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeOwner; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisTopologyClientFactory; +import java.io.IOException; +import java.io.InputStream; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.HealthIndicator; +import org.springframework.boot.health.contributor.Status; +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.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.core.type.AnnotatedTypeMetadata; + +/** + * The Redis composition root, and the only place Redis settings come into existence. + * + *

{@code app.redis.enabled} is the whole switch. While it is false this class contributes + * nothing, and because {@link RedisSdkSettings} is registered here rather than by the + * application-wide {@code @ConfigurationPropertiesScan}, "contributes nothing" is literal: the + * properties are not bound, the cross-field rules are not run, no credential is resolved, and no + * policy resource, TLS material, client, connection or thread is created. A deployment that does + * not use Redis carries no Redis configuration, and a deployment with malformed Redis configuration + * it never enabled is not punished for it. + * + *

While it is true the order is fixed and entirely local: bind, validate, then build. Validation + * runs at context refresh, before anything can reach the network, which is what makes {@link + * RedisSdkSettings#validate()} the fail-fast its own documentation claims — until this class + * existed the method had no production caller at all. + * + *

Roles — cache, session, idempotency, rate limiting, leases — select which Redis + * capabilities compose on top of this. None of them is a second master switch; {@code + * RedisActivationValidator} in the bootstrap refuses the contradiction of a role that selects Redis + * while this switch is off. + */ +@AutoConfiguration +@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "true") +// Registers the binding post-processor that populates the @ConfigurationProperties @Bean below. +// Without it the bean is created and silently left at its defaults, which is worse than not +// binding at all: validation would pass on settings nobody configured. +@EnableConfigurationProperties +public class RedisSdkAutoConfiguration { + + private static final Logger LOG = LoggerFactory.getLogger(RedisSdkAutoConfiguration.class); + + /** + * The status an optional Redis reports when it is unreachable. + * + *

Not {@code DOWN}. A cache outage is a real degradation and belongs in the health detail, but + * a status the readiness group understands as failure would remove a healthy pod from service — + * shrinking capacity during the exact incident that needs it most. + */ + private static final Status DEGRADED = + new Status("DEGRADED", "Redis is unreachable; the cache is bypassed"); + + /** + * Binds and validates the Redis settings. + * + *

Validation happens in the factory method rather than in an {@code @PostConstruct} or a + * listener so that a configuration error is reported as a failure to create this bean, with the + * offending rule in the message, and so that nothing downstream can obtain an unvalidated + * settings instance. + * + * @return the validated settings + */ + @Bean + @ConfigurationProperties(prefix = "app.redis") + public RedisSdkSettings redisSdkSettings() { + return new RedisSdkSettings(); + } + + /** + * Runs the cross-field rules once the binder has populated the settings. + * + *

Spring binds {@code @ConfigurationProperties} after the factory method returns, so the + * validation cannot live inside {@link #redisSdkSettings()}. A {@code + * ConfigurationPropertiesBindHandlerAdvisor}-free way to get the same fail-fast is a bean that + * depends on the settings: it is created during refresh, before any Redis client would be, and an + * exception here stops the context. + * + * @param settings the bound settings + * @return the validation outcome, kept as a bean so warnings are inspectable in tests + */ + @Bean + public RedisSdkSettingsValidation redisSdkSettingsValidation( + RedisSdkSettings settings, ResourceLoader resourceLoader) { + List warnings = settings.validate(); + requireRawPolicyResource(settings, resourceLoader); + warnings.forEach(warning -> LOG.warn("Redis SDK configuration warning: {}", warning)); + return new RedisSdkSettingsValidation(warnings); + } + + /** + * Proves the raw command allowlist exists before anything can reach Redis. + * + *

{@code validate()} only checks that the setting is non-blank, and the default points at + * {@code classpath:redis-sdk/raw-command-allowlist.yml} — a resource this module does not ship. + * So enabling the raw gateway passed configuration validation and then failed at the first raw + * command, from inside a request, against a live connection. The allowlist is the entire + * authorisation model for that gateway; not being able to read it is a startup failure. + */ + private static void requireRawPolicyResource( + RedisSdkSettings settings, ResourceLoader resourceLoader) { + if (!settings.getRaw().isEnabled()) { + return; + } + String location = settings.getRaw().getPolicyResource(); + Resource resource = resourceLoader.getResource(location); + if (!resource.exists() || !resource.isReadable()) { + throw new IllegalStateException( + "the raw gateway is enabled but its allowlist resource '" + + location + + "' does not exist or cannot be read. The allowlist is the only thing that decides" + + " which raw commands are reachable, so an unreadable one is a startup failure" + + " rather than a per-command surprise. Point" + + " app.redis.raw.policy-resource at a readable resource, or set" + + " app.redis.raw.enabled=false."); + } + try (InputStream ignored = resource.getInputStream()) { + LOG.info("Redis raw command allowlist loaded from {}", location); + } catch (IOException failure) { + throw new IllegalStateException( + "the raw gateway allowlist resource '" + location + "' could not be opened", failure); + } + } + + /** + * Resolves every credential reference the selected configuration actually needs. + * + *

Before any client exists. A reference that does not resolve is a configuration error, and + * the only place it can be reported as one is here — after that the failure is an authentication + * error on somebody's first command. + * + * @param settings the validated settings + * @param secretSource resolves a secret name to its value + * @param validation ordered after validation so a malformed setting is reported first + * @return the resolved credentials + */ + @Bean + public RedisResolvedCredentials redisResolvedCredentials( + RedisSdkSettings settings, + ObjectProvider secretSource, + RedisSdkSettingsValidation validation) { + RedisCredentialResolver resolver = + new RedisCredentialResolver( + name -> secretSource.getIfAvailable(() -> environmentSecretSource()).resolve(name)); + Map accounts = + new EnumMap<>(RedisCredentialRole.class); + // Every configured role is resolved, not only the application one. A deployment that named an + // advanced or pub/sub account and got a client that silently authenticated as the application + // account has the privilege separation it configured on paper and nowhere else. + put( + accounts, + RedisCredentialRole.APPLICATION, + resolver.resolve("application", settings.getAuthentication().getCredentialReference())); + put( + accounts, + RedisCredentialRole.ADVANCED, + resolver.resolve( + "advanced", settings.getAuthentication().getAdvancedCredentialReference())); + put( + accounts, + RedisCredentialRole.PUBSUB, + resolver.resolve("pub/sub", settings.getAuthentication().getPubsubCredentialReference())); + if (settings.getAdmin().isEnabled()) { + put( + accounts, + RedisCredentialRole.ADMIN, + resolver.resolve("admin", settings.getAdmin().getCredentialReference())); + } + if (settings.getRaw().isEnabled()) { + put( + accounts, + RedisCredentialRole.RAW, + resolver.resolve("raw gateway", settings.getRaw().getCredentialReference())); + } + Optional sentinel = + settings.getMode() + == dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisDeploymentMode.SENTINEL + ? resolver.resolve("sentinel", settings.getSentinel().getCredentialReference()) + : Optional.empty(); + LOG.info( + "Redis accounts resolved for roles {}{}", + accounts.keySet(), + sentinel.isPresent() ? " plus the Sentinel control account" : ""); + return new RedisResolvedCredentials(accounts, sentinel); + } + + private static void put( + Map accounts, + RedisCredentialRole role, + Optional resolved) { + resolved.ifPresent(credentials -> accounts.put(role, credentials)); + } + + private static RedisSecretSource environmentSecretSource() { + // The default reads the process environment, which is where a mounted secret lands. A + // deployment with a secret manager contributes its own RedisSecretSource bean. + return name -> Optional.ofNullable(System.getenv(name)).filter(value -> !value.isBlank()); + } + + /** + * Builds the one client the configured topology calls for. + * + * @param settings the validated settings + * @param credentials the resolved credentials + * @return the runtime client + */ + @Bean + public RedisRuntimeClient redisRuntimeClient( + RedisSdkSettings settings, + RedisResolvedCredentials credentials, + ResourceLoader resourceLoader) { + return new RedisTopologyClientFactory( + settings, + credentials.accounts(), + credentials.sentinel(), + location -> tlsMaterial(resourceLoader, location).getInputStream()) + .create(); + } + + /** + * Resolves a TLS material location, whether it names a resource or a file. + * + *

Both spellings are ordinary. A CA bundled with the application is {@code + * classpath:redis/ca.pem}; a CA mounted by the platform is {@code /etc/ssl/redis/ca.pem}, and the + * mounted one is the more common of the two. Resolving everything as a file broke the first; + * handing everything to the resource loader breaks the second, because a location with no prefix + * is a classpath location to Spring — so {@code /etc/ssl/redis/ca.pem} would be looked + * up on the classpath and reported missing while sitting on disk. + * + * @param resourceLoader the context's resource loader + * @param location the configured location + * @return the resolved resource + */ + private static Resource tlsMaterial(ResourceLoader resourceLoader, String location) { + boolean prefixed = + location.startsWith(ResourceLoader.CLASSPATH_URL_PREFIX) + || location.contains("://") + || location.startsWith("file:"); + return prefixed + ? resourceLoader.getResource(location) + : new org.springframework.core.io.FileSystemResource(location); + } + + /** + * Owns every connection and the order they are torn down in. + * + *

Destroyed by Spring, and destroyed before the client bean it wraps because it depends on it + * — which is the order shutdown needs: connections drain and close, then the event loop stops. + * + * @param client the runtime client + * @param settings the validated settings + * @return the lifecycle owner + */ + @Bean(destroyMethod = "close") + public RedisRuntimeOwner redisRuntimeOwner(RedisRuntimeClient client, RedisSdkSettings settings) { + Map limits = new EnumMap<>(RedisConnectionKind.class); + limits.put(RedisConnectionKind.REGULAR, settings.getCapacity().getMaximumInFlightCommands()); + limits.put(RedisConnectionKind.BLOCKING, settings.getBlocking().getMaxConnections()); + limits.put(RedisConnectionKind.TRANSACTION, settings.getTransaction().getMaxConnections()); + limits.put(RedisConnectionKind.SCRIPT, settings.getCapacity().getMaximumInFlightCommands()); + limits.put( + RedisConnectionKind.PUBSUB, Math.max(1, settings.getPubsub().getBufferCapacity() / 64)); + limits.put(RedisConnectionKind.ADMIN, settings.getAdmin().isEnabled() ? 2 : 1); + return new RedisRuntimeOwner(client, limits, settings.getLifecycle().getDrainTimeout()); + } + + /** + * The optional-Redis health contributor: a cache outage is detail, never unreadiness. + * + *

Bean name {@code redisOptional}, and deliberately outside the readiness group. Turning a pod + * unready because its cache is down removes capacity from a system that is already slower than + * usual, which is the opposite of what the outage needs. + * + * @param owner the runtime owner + * @param settings the validated settings + * @return the contributor + */ + @Bean(RedisCorrectnessRoles.OPTIONAL_HEALTH_CONTRIBUTOR) + public HealthIndicator redisOptional(RedisRuntimeOwner owner, RedisSdkSettings settings) { + RedisHealthContributor contributor = + new RedisHealthContributor(owner, settings.getTimeout().getFast()); + return () -> { + RedisHealthContributor.RedisHealth health = contributor.probe(); + return Health.status(health.reachable() ? Status.UP : DEGRADED) + .withDetails(health.detail()) + .build(); + }; + } + + /** + * The required-Redis health contributor, present only when a correctness role is bound. + * + *

Bean name {@code redisRequired}, and the readiness group names it. Session, idempotency, + * rate-limit and lease all depend on Redis for correctness rather than speed: serving traffic + * without them is worse than not serving it, so this one does flip readiness. + * + *

Conditional on a role actually selecting Redis. A deployment that runs Redis purely as a + * cache has no correctness role to gate on, and a required contributor there would make a cache + * outage an outage. + * + * @param owner the runtime owner + * @param settings the validated settings + * @return the contributor + */ + @Bean(RedisCorrectnessRoles.REQUIRED_HEALTH_CONTRIBUTOR) + @Conditional(RedisCorrectnessRoleBound.class) + public HealthIndicator redisRequired(RedisRuntimeOwner owner, RedisSdkSettings settings) { + RedisHealthContributor contributor = + new RedisHealthContributor(owner, settings.getTimeout().getFast()); + return () -> { + RedisHealthContributor.RedisHealth health = contributor.probe(); + return Health.status(health.reachable() ? Status.UP : Status.DOWN) + .withDetails(health.detail()) + .build(); + }; + } + + /** + * Present when a role that needs Redis for correctness selected it. + * + *

Derived from the role selectors rather than a separate flag, because a separate flag is a + * second thing to keep in sync — and the failure mode of forgetting it is a readiness probe that + * does not gate on a dependency the deployment cannot serve without. + */ + static final class RedisCorrectnessRoleBound implements Condition { + + @Override + public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { + // The same predicate the readiness group's post-processor asks. Duplicating it here is what + // produced a group naming a contributor nothing could create. + return RedisCorrectnessRoles.anySelected(context.getEnvironment()); + } + } + + /** + * The account resolved for each role the configuration named. + * + *

A role that is absent from {@code accounts} has no account of its own and runs on the + * application client. That is a deployment decision, taken by not configuring one, rather than a + * default the SDK picks. + * + * @param accounts the resolved account per configured role + * @param sentinel the Sentinel control account, on a Sentinel deployment + */ + public record RedisResolvedCredentials( + Map accounts, + Optional sentinel) { + + public RedisResolvedCredentials { + accounts = Map.copyOf(accounts); + } + } + + /** Where a credential reference's value comes from. */ + @FunctionalInterface + public interface RedisSecretSource { + + /** + * Resolves a secret by name. + * + * @param name the secret name + * @return the value, or empty when the source does not have it + */ + Optional resolve(String name); + } + + /** + * The result of validating the Redis settings at startup. + * + * @param warnings settings that are within the guardrails but worth an operator's attention + */ + public record RedisSdkSettingsValidation(List warnings) { + + public RedisSdkSettingsValidation { + warnings = List.copyOf(warnings); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkSettings.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkSettings.java new file mode 100644 index 00000000..fcf80582 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkSettings.java @@ -0,0 +1,943 @@ +package dev.caskeleton.adapter.outbound.cache.redis.sdk.config; + +import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisDeploymentMode; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.RedisKeyRules; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +/** + * Typed configuration for the Redis SDK, bound from {@code app.redis.*}. + * + *

Defaults are the skeleton guardrails from the design. Validation is fail-closed and runs at + * startup: a setting that would make a guardrail meaningless — a non-zero database on Cluster, an + * unbounded block, a raw gateway without an allowlist — stops the context rather than degrading + * quietly at the first request. + * + *

This class carries no {@code @ConfigurationProperties} annotation on purpose. It is registered + * and bound only by {@link RedisSdkAutoConfiguration}, which exists only while {@code + * app.redis.enabled} is true. Annotating the class would put it back inside the application-wide + * {@code @ConfigurationPropertiesScan}, and a Redis-free deployment would once again bind Redis + * configuration — the exact defect this arrangement removes. + */ +public class RedisSdkSettings { + + /** Hard ceiling above which a configured timeout is a startup failure. */ + public static final Duration MAXIMUM_TIMEOUT = Duration.ofSeconds(30); + + /** Timeout above which a configured fast profile produces a startup warning. */ + public static final Duration FAST_TIMEOUT_WARNING_THRESHOLD = Duration.ofSeconds(5); + + private boolean enabled; + private RedisDeploymentMode mode = RedisDeploymentMode.STANDALONE; + private List nodes = new ArrayList<>(List.of("localhost:6379")); + private int database; + private boolean acknowledgedWriteLossAccepted; + private final Namespace namespace = new Namespace(); + private final Timeouts timeout = new Timeouts(); + private final Limits limits = new Limits(); + private final Blocking blocking = new Blocking(); + private final Transaction transaction = new Transaction(); + private final Advanced advanced = new Advanced(); + private final Authentication authentication = new Authentication(); + private final Sentinel sentinel = new Sentinel(); + private final Tls tls = new Tls(); + private final Lifecycle lifecycle = new Lifecycle(); + private final Cluster cluster = new Cluster(); + private final Capacity capacity = new Capacity(); + private final PubSub pubsub = new PubSub(); + private final Raw raw = new Raw(); + private final Admin admin = new Admin(); + + /** + * Validates every cross-field rule. + * + * @return non-fatal warnings; an empty list means the configuration is entirely within guardrails + * @throws IllegalStateException when a setting would disable a guardrail + */ + public List validate() { + List warnings = new ArrayList<>(); + if (mode == RedisDeploymentMode.CLUSTER && database != 0) { + throw new IllegalStateException("Cluster supports database 0 only"); + } + if (database < 0) { + throw new IllegalStateException("database index must not be negative"); + } + if (nodes == null || nodes.isEmpty()) { + throw new IllegalStateException("at least one Redis node must be configured"); + } + namespace.validate(); + limits.validate(); + validateTimeout("fast", timeout.getFast(), warnings); + validateTimeout("collection", timeout.getCollection(), warnings); + validateTimeout("script", timeout.getScript(), warnings); + validateTimeout("batch", timeout.getBatch(), warnings); + validateTimeout("admin", timeout.getAdmin(), warnings); + if (blocking.getMaxBlock().isZero() || blocking.getMaxBlock().isNegative()) { + throw new IllegalStateException("blocking commands must not be unbounded"); + } + if (blocking.getMaxConnections() < 1 || transaction.getMaxConnections() < 1) { + throw new IllegalStateException("dedicated connection lanes need a positive ceiling"); + } + if (mode == RedisDeploymentMode.SENTINEL + && (sentinel.getMasterName() == null || sentinel.getMasterName().isBlank())) { + throw new IllegalStateException( + "a Sentinel deployment must name the monitored primary; without it the client cannot" + + " resolve a primary at all, let alone follow a promotion"); + } + if (tls.isEnabled() && !tls.isHostnameVerification()) { + warnings.add( + "TLS is enabled with hostname verification disabled, which accepts any certificate the" + + " trust material signs, for any host"); + } + lifecycle.validate(); + capacity.validate(); + cluster.validate(); + pubsub.validate(); + if (raw.isEnabled() && (raw.getPolicyResource() == null || raw.getPolicyResource().isBlank())) { + throw new IllegalStateException("the raw gateway requires an allowlist resource"); + } + if (raw.isEnabled() + && (raw.getCredentialReference() == null || raw.getCredentialReference().isBlank())) { + throw new IllegalStateException("the raw gateway requires its own credential reference"); + } + if (admin.isEnabled() + && (admin.getCredentialReference() == null || admin.getCredentialReference().isBlank())) { + throw new IllegalStateException("the admin plane requires its own credential reference"); + } + if (!advanced.isEnabled() && !advanced.getPolicies().isEmpty()) { + throw new IllegalStateException( + "advanced permit policies are configured while advanced operations are disabled"); + } + // Last, deliberately. A deployment with both a structural mistake and a missing credential + // should be told about the structural one first: it is the cheaper thing to be wrong about, + // and reporting "no credential" for a configuration that could never have connected anyway + // sends the operator to the wrong file. + authentication.validate(warnings); + return List.copyOf(warnings); + } + + private static void validateTimeout(String name, Duration value, List warnings) { + if (value == null || value.isZero() || value.isNegative()) { + throw new IllegalStateException(name + " timeout must be positive"); + } + if (value.compareTo(MAXIMUM_TIMEOUT) > 0) { + throw new IllegalStateException( + name + " timeout must not exceed " + MAXIMUM_TIMEOUT.toSeconds() + "s"); + } + if ("fast".equals(name) && value.compareTo(FAST_TIMEOUT_WARNING_THRESHOLD) > 0) { + warnings.add( + "fast timeout of " + value + " is far above the 500ms guardrail for single-key commands"); + } + } + + /** Namespace tokens applied to every key this process writes. */ + public static class Namespace { + + private String environment = "local"; + private String service = "sample-service"; + private String domain = "shared"; + + void validate() { + RedisKeyRules.requireToken("environment", environment); + RedisKeyRules.requireToken("service", service); + RedisKeyRules.requireToken("domain", domain); + } + + public String getEnvironment() { + return environment; + } + + public void setEnvironment(String environment) { + this.environment = environment; + } + + public String getService() { + return service; + } + + public void setService(String service) { + this.service = service; + } + + public String getDomain() { + return domain; + } + + public void setDomain(String domain) { + this.domain = domain; + } + } + + /** Per-profile timeout guardrails. */ + public static class Timeouts { + + private Duration fast = Duration.ofMillis(500); + private Duration collection = Duration.ofSeconds(2); + private Duration script = Duration.ofSeconds(1); + private Duration batch = Duration.ofSeconds(2); + private Duration admin = Duration.ofSeconds(3); + + public Duration getFast() { + return fast; + } + + public void setFast(Duration fast) { + this.fast = fast; + } + + public Duration getCollection() { + return collection; + } + + public void setCollection(Duration collection) { + this.collection = collection; + } + + public Duration getScript() { + return script; + } + + public void setScript(Duration script) { + this.script = script; + } + + public Duration getBatch() { + return batch; + } + + public void setBatch(Duration batch) { + this.batch = batch; + } + + public Duration getAdmin() { + return admin; + } + + public void setAdmin(Duration admin) { + this.admin = admin; + } + } + + /** Size and count ceilings enforced before Redis is called. */ + public static class Limits { + + private int maxKeyBytes = 512; + private long maxValueBytes = 1_048_576L; + private long maxStreamPayloadBytes = 262_144L; + private long maxHashFieldValueBytes = 524_288L; + private int maxCollectionElements = 1_000; + private int maxScanCount = 500; + private int maxBatchCommands = 500; + private long maxBatchRequestBytes = 4L * 1024 * 1024; + private long maxBatchReplyBytes = 16L * 1024 * 1024; + private int offlineQueueCommands = 1_000; + private long maxBitmapOffset = 10_000_000L; + + void validate() { + if (maxKeyBytes < 1 || maxKeyBytes > RedisKeyRules.MAX_KEY_BYTES) { + throw new IllegalStateException( + "max-key-bytes must be in 1.." + RedisKeyRules.MAX_KEY_BYTES); + } + if (maxValueBytes < 1 + || maxStreamPayloadBytes < 1 + || maxHashFieldValueBytes < 1 + || maxCollectionElements < 1 + || maxScanCount < 1 + || maxBatchCommands < 1 + || maxBatchRequestBytes < 1 + || maxBatchReplyBytes < 1 + || offlineQueueCommands < 1 + || maxBitmapOffset < 1) { + throw new IllegalStateException("every Redis SDK limit must be positive"); + } + } + + public int getMaxKeyBytes() { + return maxKeyBytes; + } + + public void setMaxKeyBytes(int maxKeyBytes) { + this.maxKeyBytes = maxKeyBytes; + } + + public long getMaxValueBytes() { + return maxValueBytes; + } + + public void setMaxValueBytes(long maxValueBytes) { + this.maxValueBytes = maxValueBytes; + } + + public long getMaxStreamPayloadBytes() { + return maxStreamPayloadBytes; + } + + public void setMaxStreamPayloadBytes(long maxStreamPayloadBytes) { + this.maxStreamPayloadBytes = maxStreamPayloadBytes; + } + + public long getMaxHashFieldValueBytes() { + return maxHashFieldValueBytes; + } + + public void setMaxHashFieldValueBytes(long maxHashFieldValueBytes) { + this.maxHashFieldValueBytes = maxHashFieldValueBytes; + } + + public int getMaxCollectionElements() { + return maxCollectionElements; + } + + public void setMaxCollectionElements(int maxCollectionElements) { + this.maxCollectionElements = maxCollectionElements; + } + + public int getMaxScanCount() { + return maxScanCount; + } + + public void setMaxScanCount(int maxScanCount) { + this.maxScanCount = maxScanCount; + } + + public int getMaxBatchCommands() { + return maxBatchCommands; + } + + public void setMaxBatchCommands(int maxBatchCommands) { + this.maxBatchCommands = maxBatchCommands; + } + + public long getMaxBatchRequestBytes() { + return maxBatchRequestBytes; + } + + public void setMaxBatchRequestBytes(long maxBatchRequestBytes) { + this.maxBatchRequestBytes = maxBatchRequestBytes; + } + + public long getMaxBatchReplyBytes() { + return maxBatchReplyBytes; + } + + public void setMaxBatchReplyBytes(long maxBatchReplyBytes) { + this.maxBatchReplyBytes = maxBatchReplyBytes; + } + + public int getOfflineQueueCommands() { + return offlineQueueCommands; + } + + public void setOfflineQueueCommands(int offlineQueueCommands) { + this.offlineQueueCommands = offlineQueueCommands; + } + + public long getMaxBitmapOffset() { + return maxBitmapOffset; + } + + public void setMaxBitmapOffset(long maxBitmapOffset) { + this.maxBitmapOffset = maxBitmapOffset; + } + } + + /** Blocking lane ceilings. */ + public static class Blocking { + + private int maxConnections = 32; + private Duration maxBlock = Duration.ofSeconds(30); + + public int getMaxConnections() { + return maxConnections; + } + + public void setMaxConnections(int maxConnections) { + this.maxConnections = maxConnections; + } + + public Duration getMaxBlock() { + return maxBlock; + } + + public void setMaxBlock(Duration maxBlock) { + this.maxBlock = maxBlock; + } + } + + /** Transaction lane ceilings. */ + public static class Transaction { + + private int maxConnections = 16; + + public int getMaxConnections() { + return maxConnections; + } + + public void setMaxConnections(int maxConnections) { + this.maxConnections = maxConnections; + } + } + + /** Application authentication material, carried as references rather than values. */ + public static class Authentication { + + private String credentialReference; + private String advancedCredentialReference; + private String pubsubCredentialReference; + private boolean anonymousAccessAccepted; + + void validate(List warnings) { + if (credentialReference == null || credentialReference.isBlank()) { + if (!anonymousAccessAccepted) { + throw new IllegalStateException( + "Redis is enabled but no application credential reference is configured. Booting" + + " anyway builds an unauthenticated client, which on any deployment that" + + " disabled the `default` ACL user cannot run a single command — the failure" + + " simply moves from startup to the first request, where it looks like an" + + " outage instead of a missing setting. Set" + + " app.redis.authentication.credential-reference, or declare the trade with" + + " app.redis.authentication.anonymous-access-accepted=true."); + } + warnings.add( + "Redis is running without credentials because" + + " app.redis.authentication.anonymous-access-accepted is true; every command runs" + + " as the `default` ACL user"); + } + if (advancedCredentialReference == null || advancedCredentialReference.isBlank()) { + // Not a failure: one account is a legitimate deployment. But it is worth saying out loud, + // because it means the account that reads cache entries can also execute scripts. + warnings.add( + "no advanced credential reference is configured, so registered scripts run as the" + + " application account — that account therefore needs SCRIPT LOAD and EVALSHA," + + " and every code path that reaches a regular connection has them too"); + } + } + + /** + * Reports whether this deployment declared that it accepts running Redis unauthenticated. + * + *

Leave this false. It exists so that a local single-container Redis is a one-line + * declaration rather than a reason to weaken the check for everybody, and so that the + * declaration is visible in the deployment's own configuration. + * + * @return {@code true} when anonymous access is accepted + */ + public boolean isAnonymousAccessAccepted() { + return anonymousAccessAccepted; + } + + public void setAnonymousAccessAccepted(boolean anonymousAccessAccepted) { + this.anonymousAccessAccepted = anonymousAccessAccepted; + } + + public String getCredentialReference() { + return credentialReference; + } + + public void setCredentialReference(String credentialReference) { + this.credentialReference = credentialReference; + } + + public String getAdvancedCredentialReference() { + return advancedCredentialReference; + } + + public void setAdvancedCredentialReference(String advancedCredentialReference) { + this.advancedCredentialReference = advancedCredentialReference; + } + + public String getPubsubCredentialReference() { + return pubsubCredentialReference; + } + + public void setPubsubCredentialReference(String pubsubCredentialReference) { + this.pubsubCredentialReference = pubsubCredentialReference; + } + } + + /** Sentinel discovery. */ + public static class Sentinel { + + private String masterName; + private List nodes = new ArrayList<>(); + private String credentialReference; + + public String getMasterName() { + return masterName; + } + + public void setMasterName(String masterName) { + this.masterName = masterName; + } + + public List getNodes() { + return nodes; + } + + public void setNodes(List nodes) { + this.nodes = nodes; + } + + public String getCredentialReference() { + return credentialReference; + } + + public void setCredentialReference(String credentialReference) { + this.credentialReference = credentialReference; + } + } + + /** Transport security. */ + public static class Tls { + + private boolean enabled; + private boolean hostnameVerification = true; + private String trustMaterialResource; + private String clientCertificateResource; + private String clientKeyReference; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public boolean isHostnameVerification() { + return hostnameVerification; + } + + public void setHostnameVerification(boolean hostnameVerification) { + this.hostnameVerification = hostnameVerification; + } + + public String getTrustMaterialResource() { + return trustMaterialResource; + } + + public void setTrustMaterialResource(String trustMaterialResource) { + this.trustMaterialResource = trustMaterialResource; + } + + public String getClientCertificateResource() { + return clientCertificateResource; + } + + public void setClientCertificateResource(String clientCertificateResource) { + this.clientCertificateResource = clientCertificateResource; + } + + public String getClientKeyReference() { + return clientKeyReference; + } + + public void setClientKeyReference(String clientKeyReference) { + this.clientKeyReference = clientKeyReference; + } + } + + /** Client lifecycle timings. */ + public static class Lifecycle { + + private String clientName = "ca-skeleton"; + private Duration connectTimeout = Duration.ofSeconds(2); + private Duration tlsHandshakeTimeout = Duration.ofSeconds(3); + private Duration acquireTimeout = Duration.ofSeconds(2); + private Duration shutdownQuietPeriod = Duration.ofMillis(100); + private Duration shutdownTimeout = Duration.ofSeconds(3); + private Duration drainTimeout = Duration.ofSeconds(6); + + void validate() { + if (clientName == null || clientName.isBlank()) { + throw new IllegalStateException("the client name must not be blank"); + } + requirePositive("connect", connectTimeout); + requirePositive("tls-handshake", tlsHandshakeTimeout); + requirePositive("acquire", acquireTimeout); + requirePositive("shutdown", shutdownTimeout); + requirePositive("drain", drainTimeout); + if (shutdownQuietPeriod == null || shutdownQuietPeriod.isNegative()) { + throw new IllegalStateException("the shutdown quiet period must not be negative"); + } + if (shutdownQuietPeriod.compareTo(shutdownTimeout) > 0) { + throw new IllegalStateException( + "the shutdown quiet period must not exceed the shutdown timeout, or shutdown can never" + + " complete within its own budget"); + } + } + + private static void requirePositive(String name, Duration value) { + if (value == null || value.isZero() || value.isNegative()) { + throw new IllegalStateException(name + " timeout must be positive"); + } + } + + public String getClientName() { + return clientName; + } + + public void setClientName(String clientName) { + this.clientName = clientName; + } + + public Duration getConnectTimeout() { + return connectTimeout; + } + + public void setConnectTimeout(Duration connectTimeout) { + this.connectTimeout = connectTimeout; + } + + public Duration getTlsHandshakeTimeout() { + return tlsHandshakeTimeout; + } + + public void setTlsHandshakeTimeout(Duration tlsHandshakeTimeout) { + this.tlsHandshakeTimeout = tlsHandshakeTimeout; + } + + public Duration getAcquireTimeout() { + return acquireTimeout; + } + + public void setAcquireTimeout(Duration acquireTimeout) { + this.acquireTimeout = acquireTimeout; + } + + public Duration getShutdownQuietPeriod() { + return shutdownQuietPeriod; + } + + public void setShutdownQuietPeriod(Duration shutdownQuietPeriod) { + this.shutdownQuietPeriod = shutdownQuietPeriod; + } + + public Duration getShutdownTimeout() { + return shutdownTimeout; + } + + public void setShutdownTimeout(Duration shutdownTimeout) { + this.shutdownTimeout = shutdownTimeout; + } + + public Duration getDrainTimeout() { + return drainTimeout; + } + + public void setDrainTimeout(Duration drainTimeout) { + this.drainTimeout = drainTimeout; + } + } + + /** Cluster routing. */ + public static class Cluster { + + private int maximumRedirects = 5; + private Duration topologyRefreshPeriod = Duration.ofSeconds(30); + + void validate() { + if (maximumRedirects < 1) { + throw new IllegalStateException("cluster maximum redirects must be positive"); + } + if (topologyRefreshPeriod == null + || topologyRefreshPeriod.isZero() + || topologyRefreshPeriod.isNegative()) { + throw new IllegalStateException("the cluster topology refresh period must be positive"); + } + } + + public int getMaximumRedirects() { + return maximumRedirects; + } + + public void setMaximumRedirects(int maximumRedirects) { + this.maximumRedirects = maximumRedirects; + } + + public Duration getTopologyRefreshPeriod() { + return topologyRefreshPeriod; + } + + public void setTopologyRefreshPeriod(Duration topologyRefreshPeriod) { + this.topologyRefreshPeriod = topologyRefreshPeriod; + } + } + + /** In-flight capacity ceilings. */ + public static class Capacity { + + private int maximumInFlightCommands = 64; + private long maximumInFlightBytes = 4L * 1024 * 1024; + private long maximumReplyBytes = 16L * 1024 * 1024; + private boolean rejectWhenDisconnected = true; + + void validate() { + if (maximumInFlightCommands < 1 || maximumInFlightBytes < 1 || maximumReplyBytes < 1) { + throw new IllegalStateException("every Redis capacity ceiling must be positive"); + } + } + + public int getMaximumInFlightCommands() { + return maximumInFlightCommands; + } + + public void setMaximumInFlightCommands(int maximumInFlightCommands) { + this.maximumInFlightCommands = maximumInFlightCommands; + } + + public long getMaximumInFlightBytes() { + return maximumInFlightBytes; + } + + public void setMaximumInFlightBytes(long maximumInFlightBytes) { + this.maximumInFlightBytes = maximumInFlightBytes; + } + + public long getMaximumReplyBytes() { + return maximumReplyBytes; + } + + public void setMaximumReplyBytes(long maximumReplyBytes) { + this.maximumReplyBytes = maximumReplyBytes; + } + + /** + * Reports whether a command issued while the connection is down is refused rather than queued. + * + *

Leave this true. Lettuce's default is to hold commands in an offline queue and replay them + * on reconnect, which turns a five-second outage into a burst of writes whose ordering relative + * to everything that happened during the outage is arbitrary. + * + * @return {@code true} when a disconnected client refuses commands + */ + public boolean isRejectWhenDisconnected() { + return rejectWhenDisconnected; + } + + public void setRejectWhenDisconnected(boolean rejectWhenDisconnected) { + this.rejectWhenDisconnected = rejectWhenDisconnected; + } + } + + /** Subscription delivery. */ + public static class PubSub { + + private int bufferCapacity = 1_024; + private String overflowPolicy = "error"; + + void validate() { + if (bufferCapacity < 1) { + throw new IllegalStateException("the pub/sub buffer capacity must be positive"); + } + if (!List.of("error", "drop-oldest", "drop-latest").contains(overflowPolicy)) { + throw new IllegalStateException( + "the pub/sub overflow policy must be error, drop-oldest, or drop-latest"); + } + } + + public int getBufferCapacity() { + return bufferCapacity; + } + + public void setBufferCapacity(int bufferCapacity) { + this.bufferCapacity = bufferCapacity; + } + + public String getOverflowPolicy() { + return overflowPolicy; + } + + public void setOverflowPolicy(String overflowPolicy) { + this.overflowPolicy = overflowPolicy; + } + } + + /** Advanced R2 exposure. */ + public static class Advanced { + + private boolean enabled; + private List policies = new ArrayList<>(); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public List getPolicies() { + return policies; + } + + public void setPolicies(List policies) { + this.policies = policies; + } + } + + /** Approved raw gateway exposure. */ + public static class Raw { + + private boolean enabled; + private String policyResource = "classpath:redis-sdk/raw-command-allowlist.yml"; + private String credentialReference; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getPolicyResource() { + return policyResource; + } + + public void setPolicyResource(String policyResource) { + this.policyResource = policyResource; + } + + public String getCredentialReference() { + return credentialReference; + } + + public void setCredentialReference(String credentialReference) { + this.credentialReference = credentialReference; + } + } + + /** Isolated admin plane exposure. */ + public static class Admin { + + private boolean enabled; + private String credentialReference; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getCredentialReference() { + return credentialReference; + } + + public void setCredentialReference(String credentialReference) { + this.credentialReference = credentialReference; + } + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public RedisDeploymentMode getMode() { + return mode; + } + + public void setMode(RedisDeploymentMode mode) { + this.mode = mode; + } + + public List getNodes() { + return nodes; + } + + public void setNodes(List nodes) { + this.nodes = nodes; + } + + public int getDatabase() { + return database; + } + + public void setDatabase(int database) { + this.database = database; + } + + /** + * Reports whether this deployment has declared that it accepts losing acknowledged writes. + * + *

Leave this false unless the trade is deliberate. A replicated deployment without {@code + * min-replicas-to-write} discards writes it told the caller had succeeded, and no client-side + * signal exists for it; see {@link RedisCapabilityProbe#requireWriteDurability}. + * + * @return {@code true} when the loss is accepted + */ + public boolean isAcknowledgedWriteLossAccepted() { + return acknowledgedWriteLossAccepted; + } + + public void setAcknowledgedWriteLossAccepted(boolean acknowledgedWriteLossAccepted) { + this.acknowledgedWriteLossAccepted = acknowledgedWriteLossAccepted; + } + + public Namespace getNamespace() { + return namespace; + } + + public Timeouts getTimeout() { + return timeout; + } + + public Limits getLimits() { + return limits; + } + + public Blocking getBlocking() { + return blocking; + } + + public Transaction getTransaction() { + return transaction; + } + + public Advanced getAdvanced() { + return advanced; + } + + public Raw getRaw() { + return raw; + } + + public Admin getAdmin() { + return admin; + } + + public Authentication getAuthentication() { + return authentication; + } + + public Sentinel getSentinel() { + return sentinel; + } + + public Tls getTls() { + return tls; + } + + public Lifecycle getLifecycle() { + return lifecycle; + } + + public Cluster getCluster() { + return cluster; + } + + public Capacity getCapacity() { + return capacity; + } + + public PubSub getPubsub() { + return pubsub; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisStartupProbe.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisStartupProbe.java new file mode 100644 index 00000000..9ac874da --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisStartupProbe.java @@ -0,0 +1,142 @@ +package dev.caskeleton.adapter.outbound.cache.redis.sdk.config; + +import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisCapabilities; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisCapability; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisVersion; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.CommandId; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Predicate; + +/** + * Asks the server what it is, once, at startup. + * + *

Configuration says what the deployment intends; only the server says what is true. The version + * a managed Redis advertises does not imply the modules are present, and a replicated deployment's + * write durability is a server setting no client can compensate for. Both are cheap to ask and + * expensive to discover later — a missing capability found at the first request is an outage, found + * here it is a failed deploy. + * + *

This type holds no connection. It takes the three facts as inputs so the same logic is + * exercised by unit tests and by the real lanes, and so the caller decides which account asks — + * {@code INFO} and {@code CONFIG GET} are admin-plane, and the application account is denied them. + */ +public final class RedisStartupProbe { + + private final RedisCapabilityProbe probe; + + /** + * Creates the probe. + * + * @param probe the capability probe + */ + public RedisStartupProbe(RedisCapabilityProbe probe) { + this.probe = Objects.requireNonNull(probe, "capability probe must be non-null"); + } + + /** + * Confirms the server matches what the deployment declared. + * + * @param settings the validated settings + * @param serverFacts what the server reported + * @param requiredCapabilities capabilities the deployment explicitly enabled + * @return the confirmed capability snapshot + */ + public RedisCapabilities confirm( + RedisSdkSettings settings, + ServerFacts serverFacts, + Collection requiredCapabilities) { + Objects.requireNonNull(settings, "settings must be non-null"); + Objects.requireNonNull(serverFacts, "server facts must be non-null"); + RedisCapabilities capabilities = + probe.probe( + serverFacts.version(), + settings.getMode(), + settings.getDatabase(), + commandPresence(serverFacts.commands()), + requiredCapabilities); + probe.requireWriteDurability( + settings.getMode(), + serverFacts.minReplicasToWrite(), + serverFacts.minReplicasMaxLagSeconds(), + settings.isAcknowledgedWriteLossAccepted()); + return capabilities; + } + + private static Predicate commandPresence(Set reported) { + // COMMAND INFO answers with the top-level command name; a subcommand's presence follows from + // its container. Matching on the family keeps "does the server have FT.SEARCH" answerable + // without asking the server about every subcommand the catalog knows. + return commandId -> reported.contains(commandId.family().toLowerCase(java.util.Locale.ROOT)); + } + + /** + * What the server reported at startup. + * + * @param version the version from {@code INFO server} + * @param commands the command names from {@code COMMAND LIST}, lowercased + * @param minReplicasToWrite the server's {@code min-replicas-to-write} + * @param minReplicasMaxLagSeconds the server's {@code min-replicas-max-lag} + */ + public record ServerFacts( + RedisVersion version, + Set commands, + int minReplicasToWrite, + int minReplicasMaxLagSeconds) { + + public ServerFacts { + Objects.requireNonNull(version, "version must be non-null"); + commands = Set.copyOf(commands); + } + + /** + * Reads the facts out of the raw replies, so parsing lives beside the contract it feeds. + * + * @param infoServer the {@code INFO server} payload + * @param commandNames the command names the server reports + * @param configuration the {@code CONFIG GET} projection + * @return the parsed facts + */ + public static ServerFacts from( + String infoServer, List commandNames, Map configuration) { + RedisVersion version = null; + for (String line : infoServer.lines().toList()) { + if (line.startsWith("redis_version:")) { + version = RedisVersion.parse(line.substring("redis_version:".length()).strip()); + } + } + if (version == null) { + throw new IllegalStateException( + "the server did not report a version; the SDK will not guess one, because every" + + " capability decision below depends on it"); + } + return new ServerFacts( + version, + commandNames.stream() + .map(name -> name.toLowerCase(java.util.Locale.ROOT)) + .collect(java.util.stream.Collectors.toUnmodifiableSet()), + intOf(configuration, "min-replicas-to-write"), + intOf(configuration, "min-replicas-max-lag")); + } + + private static int intOf(Map configuration, String key) { + String value = configuration.get(key); + if (value == null || value.isBlank()) { + // Absent is not zero. A deployment whose admin account cannot read the setting has not + // proven the guarantee, and treating "unknown" as "unset" would fail a correctly + // configured server while treating it as "set" would pass an incorrectly configured one. + // Failing is the safe direction: the message says exactly which grant is missing. + throw new IllegalStateException( + "the server did not report '" + + key + + "'. Write durability cannot be confirmed without it; grant the admin account" + + " +config|get, or set app.redis.acknowledged-write-loss-accepted=true to record" + + " that this deployment accepts losing acknowledged writes."); + } + return Integer.parseInt(value.strip()); + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/resources/redis-sdk/redis-command-policy.yml b/src/adapter/outbound/cache-redis/src/main/resources/redis-sdk/redis-command-policy.yml new file mode 100644 index 00000000..1f51d135 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/resources/redis-sdk/redis-command-policy.yml @@ -0,0 +1,1406 @@ +# Redis command policy — organization SSOT. +# +# Official server metadata (COMMAND DOCS / COMMAND INFO / COMMAND GETKEYSANDFLAGS) decides what a +# command *is*. This file decides what this SDK is willing to *do* with it. The catalog drift gate +# compares the two and fails the build when the server grows a command this file has not judged. +# +# Fields and their defaults: +# risk required R1 | R2 | R3 | R4 +# support required TYPED | ADVANCED_TYPED | RAW_ONLY | ADMIN_ONLY | VERSION_GATED | BLOCKED +# minimum-version "7.2" lowest server version that carries the command +# access derived TYPED->APPLICATION, ADVANCED_TYPED/VERSION_GATED->APPLICATION_ADVANCED, +# RAW_ONLY->RAW_GATEWAY, ADMIN_ONLY->ADMIN_READONLY, BLOCKED->NONE +# blocking false occupies its connection until the server replies or the block expires +# read-only false never mutates the dataset +# retry-safe read-only may be retried after a failure that could have reached the server +# may-be-ambiguous !read-only a failure may leave the outcome unknown +# timeout-profile derived R1->FAST, R2->COLLECTION, R3->ADMIN, blocking->BLOCKING +# key-spec "1 1 1" " ", "none", or "movable" +# required-policy - permit policy name an R2 command demands +commands: + + # ---------- string ---------- + GET: + risk: R1 + support: TYPED + read-only: true + SET: + risk: R1 + support: TYPED + GETDEL: + risk: R1 + support: TYPED + GETEX: + risk: R1 + support: TYPED + STRLEN: + risk: R1 + support: TYPED + read-only: true + INCR: + risk: R1 + support: TYPED + INCRBY: + risk: R1 + support: TYPED + INCRBYFLOAT: + risk: R1 + support: TYPED + DECR: + risk: R1 + support: TYPED + DECRBY: + risk: R1 + support: TYPED + APPEND: + risk: R2 + support: ADVANCED_TYPED + required-policy: large-value-write + GETRANGE: + risk: R2 + support: ADVANCED_TYPED + read-only: true + required-policy: bounded-range-read + SETRANGE: + risk: R2 + support: ADVANCED_TYPED + required-policy: large-value-write + MGET: + risk: R2 + support: ADVANCED_TYPED + read-only: true + key-spec: "1 -1 1" + required-policy: multi-key-read + MSET: + risk: R2 + support: ADVANCED_TYPED + key-spec: "1 -1 2" + required-policy: multi-key-write + MSETNX: + risk: R2 + support: ADVANCED_TYPED + key-spec: "1 -1 2" + required-policy: multi-key-write + LCS: + risk: R2 + support: ADVANCED_TYPED + read-only: true + key-spec: "1 2 1" + required-policy: bounded-range-read + SETNX: + risk: R1 + support: BLOCKED + SETEX: + risk: R1 + support: BLOCKED + PSETEX: + risk: R1 + support: BLOCKED + GETSET: + risk: R1 + support: BLOCKED + SUBSTR: + risk: R1 + support: BLOCKED + + # ---------- hash ---------- + HGET: + risk: R1 + support: TYPED + read-only: true + HSET: + risk: R1 + support: TYPED + HSETNX: + risk: R1 + support: TYPED + HDEL: + risk: R1 + support: TYPED + HEXISTS: + risk: R1 + support: TYPED + read-only: true + HLEN: + risk: R1 + support: TYPED + read-only: true + HSTRLEN: + risk: R1 + support: TYPED + read-only: true + HMGET: + risk: R1 + support: TYPED + read-only: true + HINCRBY: + risk: R1 + support: TYPED + HINCRBYFLOAT: + risk: R1 + support: TYPED + HSCAN: + risk: R2 + support: ADVANCED_TYPED + read-only: true + required-policy: cursor-scan + HRANDFIELD: + risk: R2 + support: ADVANCED_TYPED + read-only: true + required-policy: bounded-collection-read + HGETALL: + risk: R2 + support: ADVANCED_TYPED + read-only: true + required-policy: collection-full-read + HKEYS: + risk: R2 + support: ADVANCED_TYPED + read-only: true + required-policy: collection-full-read + HVALS: + risk: R2 + support: ADVANCED_TYPED + read-only: true + required-policy: collection-full-read + HEXPIRE: + risk: R1 + support: VERSION_GATED + minimum-version: "7.4" + HPEXPIRE: + risk: R1 + support: VERSION_GATED + minimum-version: "7.4" + HEXPIREAT: + risk: R1 + support: VERSION_GATED + minimum-version: "7.4" + HPEXPIREAT: + risk: R1 + support: VERSION_GATED + minimum-version: "7.4" + HPERSIST: + risk: R1 + support: VERSION_GATED + minimum-version: "7.4" + HTTL: + risk: R1 + support: VERSION_GATED + minimum-version: "7.4" + read-only: true + HPTTL: + risk: R1 + support: VERSION_GATED + minimum-version: "7.4" + read-only: true + HGETEX: + risk: R1 + support: VERSION_GATED + minimum-version: "8.0" + HSETEX: + risk: R1 + support: VERSION_GATED + minimum-version: "8.0" + HMSET: + risk: R1 + support: BLOCKED + + # ---------- list ---------- + LPUSH: + risk: R1 + support: TYPED + RPUSH: + risk: R1 + support: TYPED + LPUSHX: + risk: R1 + support: TYPED + RPUSHX: + risk: R1 + support: TYPED + LPOP: + risk: R1 + support: TYPED + RPOP: + risk: R1 + support: TYPED + LINDEX: + risk: R1 + support: TYPED + read-only: true + LSET: + risk: R1 + support: TYPED + LREM: + risk: R2 + support: ADVANCED_TYPED + required-policy: bounded-collection-write + LTRIM: + risk: R2 + support: ADVANCED_TYPED + required-policy: bounded-collection-write + LLEN: + risk: R1 + support: TYPED + read-only: true + LPOS: + risk: R2 + support: ADVANCED_TYPED + read-only: true + required-policy: bounded-collection-read + LINSERT: + risk: R2 + support: ADVANCED_TYPED + required-policy: bounded-collection-write + LRANGE: + risk: R2 + support: ADVANCED_TYPED + read-only: true + required-policy: bounded-collection-read + LMOVE: + risk: R2 + support: ADVANCED_TYPED + key-spec: "1 2 1" + required-policy: multi-key-write + LMPOP: + risk: R2 + support: ADVANCED_TYPED + key-spec: "movable" + required-policy: multi-key-write + BLPOP: + risk: R2 + support: ADVANCED_TYPED + blocking: true + key-spec: "1 -2 1" + required-policy: blocking-pop + BRPOP: + risk: R2 + support: ADVANCED_TYPED + blocking: true + key-spec: "1 -2 1" + required-policy: blocking-pop + BLMOVE: + risk: R2 + support: ADVANCED_TYPED + blocking: true + key-spec: "1 2 1" + required-policy: blocking-pop + BLMPOP: + risk: R2 + support: ADVANCED_TYPED + blocking: true + key-spec: "movable" + required-policy: blocking-pop + RPOPLPUSH: + risk: R2 + support: BLOCKED + BRPOPLPUSH: + risk: R2 + support: BLOCKED + + # ---------- set ---------- + SADD: + risk: R1 + support: TYPED + SREM: + risk: R1 + support: TYPED + SISMEMBER: + risk: R1 + support: TYPED + read-only: true + SMISMEMBER: + risk: R1 + support: TYPED + read-only: true + SCARD: + risk: R1 + support: TYPED + read-only: true + SPOP: + risk: R1 + support: TYPED + SRANDMEMBER: + risk: R2 + support: ADVANCED_TYPED + read-only: true + required-policy: bounded-collection-read + SSCAN: + risk: R2 + support: ADVANCED_TYPED + read-only: true + required-policy: cursor-scan + SMOVE: + risk: R2 + support: ADVANCED_TYPED + key-spec: "1 2 1" + required-policy: multi-key-write + SDIFF: + risk: R2 + support: ADVANCED_TYPED + read-only: true + key-spec: "1 -1 1" + required-policy: set-algebra + SINTER: + risk: R2 + support: ADVANCED_TYPED + read-only: true + key-spec: "1 -1 1" + required-policy: set-algebra + SUNION: + risk: R2 + support: ADVANCED_TYPED + read-only: true + key-spec: "1 -1 1" + required-policy: set-algebra + SINTERCARD: + risk: R2 + support: ADVANCED_TYPED + read-only: true + key-spec: "movable" + required-policy: set-algebra + SDIFFSTORE: + risk: R2 + support: ADVANCED_TYPED + key-spec: "1 -1 1" + required-policy: set-algebra + SINTERSTORE: + risk: R2 + support: ADVANCED_TYPED + key-spec: "1 -1 1" + required-policy: set-algebra + SUNIONSTORE: + risk: R2 + support: ADVANCED_TYPED + key-spec: "1 -1 1" + required-policy: set-algebra + # Every RAW_ONLY command carries the same permit policy. The raw gateway's own approval registry + # decides which of them a deployment may send at all; the permit is what keeps the guard's rule -- + # an R2 command always names the policy that authorised it -- true on this path as well. + SMEMBERS: + risk: R2 + support: RAW_ONLY + read-only: true + required-policy: raw-command + + # ---------- sorted set ---------- + ZADD: + risk: R1 + support: TYPED + ZINCRBY: + risk: R1 + support: TYPED + ZREM: + risk: R1 + support: TYPED + ZSCORE: + risk: R1 + support: TYPED + read-only: true + ZMSCORE: + risk: R1 + support: TYPED + read-only: true + ZRANK: + risk: R1 + support: TYPED + read-only: true + ZREVRANK: + risk: R1 + support: TYPED + read-only: true + ZCARD: + risk: R1 + support: TYPED + read-only: true + ZCOUNT: + risk: R1 + support: TYPED + read-only: true + ZLEXCOUNT: + risk: R1 + support: TYPED + read-only: true + ZPOPMIN: + risk: R1 + support: TYPED + ZPOPMAX: + risk: R1 + support: TYPED + ZRANDMEMBER: + risk: R2 + support: ADVANCED_TYPED + read-only: true + required-policy: bounded-collection-read + ZSCAN: + risk: R2 + support: ADVANCED_TYPED + read-only: true + required-policy: cursor-scan + ZRANGE: + risk: R2 + support: ADVANCED_TYPED + read-only: true + required-policy: bounded-collection-read + ZRANGESTORE: + risk: R2 + support: ADVANCED_TYPED + key-spec: "1 2 1" + required-policy: multi-key-write + ZREMRANGEBYRANK: + risk: R2 + support: ADVANCED_TYPED + required-policy: bounded-collection-write + ZREMRANGEBYSCORE: + risk: R2 + support: ADVANCED_TYPED + required-policy: bounded-collection-write + ZREMRANGEBYLEX: + risk: R2 + support: ADVANCED_TYPED + required-policy: bounded-collection-write + ZMPOP: + risk: R2 + support: ADVANCED_TYPED + key-spec: "movable" + required-policy: multi-key-write + BZPOPMIN: + risk: R2 + support: ADVANCED_TYPED + blocking: true + key-spec: "1 -2 1" + required-policy: blocking-pop + BZPOPMAX: + risk: R2 + support: ADVANCED_TYPED + blocking: true + key-spec: "1 -2 1" + required-policy: blocking-pop + BZMPOP: + risk: R2 + support: ADVANCED_TYPED + blocking: true + key-spec: "movable" + required-policy: blocking-pop + ZUNION: + risk: R2 + support: ADVANCED_TYPED + read-only: true + key-spec: "movable" + required-policy: set-algebra + ZINTER: + risk: R2 + support: ADVANCED_TYPED + read-only: true + key-spec: "movable" + required-policy: set-algebra + ZDIFF: + risk: R2 + support: ADVANCED_TYPED + read-only: true + key-spec: "movable" + required-policy: set-algebra + ZINTERCARD: + risk: R2 + support: ADVANCED_TYPED + read-only: true + key-spec: "movable" + required-policy: set-algebra + ZUNIONSTORE: + risk: R2 + support: ADVANCED_TYPED + key-spec: "movable" + required-policy: set-algebra + ZINTERSTORE: + risk: R2 + support: ADVANCED_TYPED + key-spec: "movable" + required-policy: set-algebra + ZDIFFSTORE: + risk: R2 + support: ADVANCED_TYPED + key-spec: "movable" + required-policy: set-algebra + # The deprecated range names stay blocked. Every range the typed API offers -- by rank, by score, + # by lex, ascending or descending -- is issued as ZRANGE with BYSCORE/BYLEX/REV, which the gateway + # encodes itself because the pinned driver has no typed form for it. + ZRANGEBYSCORE: + risk: R2 + support: BLOCKED + ZREVRANGEBYSCORE: + risk: R2 + support: BLOCKED + ZRANGEBYLEX: + risk: R2 + support: BLOCKED + ZREVRANGEBYLEX: + risk: R2 + support: BLOCKED + ZREVRANGE: + risk: R2 + support: BLOCKED + + # ---------- bitmap ---------- + SETBIT: + risk: R1 + support: TYPED + GETBIT: + risk: R1 + support: TYPED + read-only: true + BITCOUNT: + risk: R2 + support: ADVANCED_TYPED + read-only: true + required-policy: bounded-range-read + BITPOS: + risk: R2 + support: ADVANCED_TYPED + read-only: true + required-policy: bounded-range-read + BITOP: + risk: R2 + support: ADVANCED_TYPED + key-spec: "2 -1 1" + required-policy: multi-key-write + BITFIELD: + risk: R2 + support: ADVANCED_TYPED + required-policy: bitfield-execute + BITFIELD_RO: + risk: R2 + support: ADVANCED_TYPED + read-only: true + required-policy: bitfield-execute + + # ---------- hyperloglog ---------- + PFADD: + risk: R1 + support: TYPED + PFCOUNT: + risk: R2 + support: ADVANCED_TYPED + read-only: true + key-spec: "1 -1 1" + required-policy: multi-key-read + PFMERGE: + risk: R2 + support: ADVANCED_TYPED + key-spec: "1 -1 1" + required-policy: multi-key-write + + # ---------- geospatial ---------- + GEOADD: + risk: R1 + support: TYPED + GEODIST: + risk: R1 + support: TYPED + read-only: true + GEOPOS: + risk: R1 + support: TYPED + read-only: true + GEOHASH: + risk: R1 + support: TYPED + read-only: true + GEOSEARCH: + risk: R2 + support: ADVANCED_TYPED + read-only: true + required-policy: bounded-collection-read + GEOSEARCHSTORE: + risk: R2 + support: ADVANCED_TYPED + key-spec: "1 2 1" + required-policy: multi-key-write + GEORADIUS: + risk: R2 + support: BLOCKED + GEORADIUSBYMEMBER: + risk: R2 + support: BLOCKED + GEORADIUS_RO: + risk: R2 + support: BLOCKED + GEORADIUSBYMEMBER_RO: + risk: R2 + support: BLOCKED + + # ---------- stream ---------- + XADD: + risk: R1 + support: TYPED + XDEL: + risk: R1 + support: TYPED + XLEN: + risk: R1 + support: TYPED + read-only: true + XTRIM: + risk: R2 + support: ADVANCED_TYPED + required-policy: bounded-collection-write + XRANGE: + risk: R2 + support: ADVANCED_TYPED + read-only: true + required-policy: bounded-collection-read + XREVRANGE: + risk: R2 + support: ADVANCED_TYPED + read-only: true + required-policy: bounded-collection-read + # Both stream reads have a non-blocking form: without BLOCK they are ordinary bounded reads. That + # is why the block is optional here and mandatory for BLPOP -- omitting it is a legitimate request + # shape rather than an unbounded wait, and the guard still refuses any block it is given that is + # non-positive or over the configured ceiling. + XREAD: + risk: R2 + support: ADVANCED_TYPED + read-only: true + blocking: true + optional-block: true + key-spec: "movable" + required-policy: stream-read + XREADGROUP: + risk: R2 + support: ADVANCED_TYPED + blocking: true + optional-block: true + key-spec: "movable" + required-policy: stream-read + XACK: + risk: R1 + support: TYPED + XPENDING: + risk: R2 + support: ADVANCED_TYPED + read-only: true + required-policy: stream-recovery + XCLAIM: + risk: R2 + support: ADVANCED_TYPED + required-policy: stream-recovery + XAUTOCLAIM: + risk: R2 + support: ADVANCED_TYPED + required-policy: stream-recovery + XSETID: + risk: R2 + support: ADVANCED_TYPED + required-policy: stream-recovery + XACKDEL: + risk: R1 + support: VERSION_GATED + minimum-version: "8.2" + XDELEX: + risk: R1 + support: VERSION_GATED + minimum-version: "8.2" + XNACK: + risk: R1 + support: VERSION_GATED + minimum-version: "8.8" + XGROUP CREATE: + risk: R1 + support: TYPED + key-spec: "2 2 1" + XGROUP DESTROY: + risk: R1 + support: TYPED + key-spec: "2 2 1" + XGROUP CREATECONSUMER: + risk: R1 + support: TYPED + key-spec: "2 2 1" + XGROUP DELCONSUMER: + risk: R1 + support: TYPED + key-spec: "2 2 1" + XGROUP SETID: + risk: R2 + support: ADVANCED_TYPED + key-spec: "2 2 1" + required-policy: stream-recovery + XINFO STREAM: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "2 2 1" + XINFO GROUPS: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "2 2 1" + XINFO CONSUMERS: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "2 2 1" + + # ---------- pub/sub ---------- + PUBLISH: + risk: R1 + support: TYPED + key-spec: "none" + SUBSCRIBE: + risk: R1 + support: TYPED + key-spec: "none" + UNSUBSCRIBE: + risk: R1 + support: TYPED + key-spec: "none" + PSUBSCRIBE: + risk: R2 + support: ADVANCED_TYPED + key-spec: "none" + required-policy: pattern-subscribe + PUNSUBSCRIBE: + risk: R1 + support: TYPED + key-spec: "none" + SPUBLISH: + risk: R1 + support: VERSION_GATED + minimum-version: "7.0" + SSUBSCRIBE: + risk: R1 + support: VERSION_GATED + minimum-version: "7.0" + SUNSUBSCRIBE: + risk: R1 + support: VERSION_GATED + minimum-version: "7.0" + PUBSUB CHANNELS: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "none" + PUBSUB NUMSUB: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "none" + PUBSUB SHARDCHANNELS: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "none" + + # ---------- key and ttl ---------- + EXISTS: + risk: R1 + support: TYPED + read-only: true + key-spec: "1 -1 1" + TYPE: + risk: R1 + support: TYPED + read-only: true + TOUCH: + risk: R1 + support: TYPED + key-spec: "1 -1 1" + DEL: + risk: R2 + support: ADVANCED_TYPED + key-spec: "1 -1 1" + required-policy: multi-key-write + UNLINK: + risk: R2 + support: ADVANCED_TYPED + key-spec: "1 -1 1" + required-policy: multi-key-write + EXPIRE: + risk: R1 + support: TYPED + PEXPIRE: + risk: R1 + support: TYPED + EXPIREAT: + risk: R1 + support: TYPED + PEXPIREAT: + risk: R1 + support: TYPED + TTL: + risk: R1 + support: TYPED + read-only: true + PTTL: + risk: R1 + support: TYPED + read-only: true + EXPIRETIME: + risk: R1 + support: TYPED + read-only: true + PEXPIRETIME: + risk: R1 + support: TYPED + read-only: true + PERSIST: + risk: R1 + support: TYPED + RENAME: + risk: R2 + support: ADVANCED_TYPED + key-spec: "1 2 1" + required-policy: multi-key-write + RENAMENX: + risk: R2 + support: ADVANCED_TYPED + key-spec: "1 2 1" + required-policy: multi-key-write + COPY: + risk: R2 + support: ADVANCED_TYPED + key-spec: "1 2 1" + required-policy: multi-key-write + SCAN: + risk: R2 + support: ADVANCED_TYPED + read-only: true + key-spec: "none" + required-policy: cursor-scan + SORT: + risk: R2 + support: RAW_ONLY + key-spec: "movable" + required-policy: raw-command + SORT_RO: + risk: R2 + support: RAW_ONLY + read-only: true + key-spec: "movable" + required-policy: raw-command + RANDOMKEY: + risk: R2 + support: BLOCKED + KEYS: + risk: R4 + support: BLOCKED + DUMP: + risk: R3 + support: BLOCKED + RESTORE: + risk: R4 + support: BLOCKED + MIGRATE: + risk: R4 + support: BLOCKED + SELECT: + risk: R3 + support: BLOCKED + SWAPDB: + risk: R4 + support: BLOCKED + OBJECT ENCODING: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "2 2 1" + OBJECT FREQ: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "2 2 1" + OBJECT IDLETIME: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "2 2 1" + + # ---------- transaction and programmability ---------- + MULTI: + risk: R1 + support: TYPED + key-spec: "none" + EXEC: + risk: R1 + support: TYPED + key-spec: "none" + DISCARD: + risk: R1 + support: TYPED + key-spec: "none" + WATCH: + risk: R2 + support: ADVANCED_TYPED + key-spec: "1 -1 1" + required-policy: optimistic-transaction + UNWATCH: + risk: R1 + support: TYPED + key-spec: "none" + EVALSHA: + risk: R2 + support: ADVANCED_TYPED + key-spec: "movable" + required-policy: registered-script + EVALSHA_RO: + risk: R2 + support: ADVANCED_TYPED + read-only: true + key-spec: "movable" + required-policy: registered-script + FCALL: + risk: R2 + support: VERSION_GATED + minimum-version: "7.0" + key-spec: "movable" + required-policy: registered-script + FCALL_RO: + risk: R2 + support: VERSION_GATED + minimum-version: "7.0" + read-only: true + key-spec: "movable" + required-policy: registered-script + EVAL: + risk: R2 + support: BLOCKED + EVAL_RO: + risk: R2 + support: BLOCKED + SCRIPT LOAD: + risk: R2 + support: ADVANCED_TYPED + key-spec: "none" + required-policy: registered-script + SCRIPT EXISTS: + risk: R1 + support: TYPED + read-only: true + key-spec: "none" + SCRIPT FLUSH: + risk: R4 + support: BLOCKED + FUNCTION LOAD: + risk: R3 + support: ADMIN_ONLY + key-spec: "none" + FUNCTION LIST: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "none" + FUNCTION STATS: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "none" + FUNCTION FLUSH: + risk: R4 + support: BLOCKED + + # ---------- connection and diagnostics ---------- + PING: + risk: R1 + support: TYPED + read-only: true + key-spec: "none" + ECHO: + risk: R1 + support: TYPED + read-only: true + key-spec: "none" + HELLO: + risk: R1 + support: TYPED + read-only: true + key-spec: "none" + INFO: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "none" + DBSIZE: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "none" + TIME: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "none" + LASTSAVE: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "none" + MEMORY USAGE: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "2 2 1" + MEMORY STATS: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "none" + SLOWLOG GET: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "none" + SLOWLOG LEN: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "none" + SLOWLOG RESET: + risk: R4 + support: BLOCKED + LATENCY LATEST: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "none" + LATENCY HISTORY: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "none" + LATENCY RESET: + risk: R4 + support: BLOCKED + CLIENT LIST: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "none" + CLIENT INFO: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "none" + CLIENT KILL: + risk: R4 + support: BLOCKED + CLIENT NO-EVICT: + risk: R4 + support: BLOCKED + COMMAND INFO: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "none" + COMMAND DOCS: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "none" + COMMAND COUNT: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "none" + COMMAND GETKEYSANDFLAGS: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "none" + CONFIG GET: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "none" + CONFIG SET: + risk: R4 + support: BLOCKED + CONFIG REWRITE: + risk: R4 + support: BLOCKED + CONFIG RESETSTAT: + risk: R4 + support: BLOCKED + ACL DRYRUN: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "none" + ACL WHOAMI: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "none" + ACL SETUSER: + risk: R4 + support: BLOCKED + ACL DELUSER: + risk: R4 + support: BLOCKED + CLUSTER INFO: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "none" + CLUSTER SLOTS: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "none" + CLUSTER SHARDS: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "none" + CLUSTER NODES: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "none" + CLUSTER KEYSLOT: + risk: R3 + support: ADMIN_ONLY + read-only: true + key-spec: "none" + CLUSTER RESET: + risk: R4 + support: BLOCKED + CLUSTER FORGET: + risk: R4 + support: BLOCKED + CLUSTER SETSLOT: + risk: R4 + support: BLOCKED + CLUSTER FAILOVER: + risk: R4 + support: BLOCKED + + # ---------- destructive: never reachable from this SDK ---------- + FLUSHDB: + risk: R4 + support: BLOCKED + FLUSHALL: + risk: R4 + support: BLOCKED + SHUTDOWN: + risk: R4 + support: BLOCKED + DEBUG: + risk: R4 + support: BLOCKED + RESET: + risk: R4 + support: BLOCKED + FAILOVER: + risk: R4 + support: BLOCKED + REPLICAOF: + risk: R4 + support: BLOCKED + SLAVEOF: + risk: R4 + support: BLOCKED + MODULE LOAD: + risk: R4 + support: BLOCKED + MODULE UNLOAD: + risk: R4 + support: BLOCKED + SAVE: + risk: R4 + support: BLOCKED + BGSAVE: + risk: R4 + support: BLOCKED + BGREWRITEAOF: + risk: R4 + support: BLOCKED + + # ---------- Redis 8 extensions ---------- + # Extension commands are classified here like any other, but reaching them needs a capability the + # probe confirmed at startup. The minimum version is a pre-filter only: a managed Redis 8 without + # a module loaded reports the version and not the commands, which is why the probe is the + # authority and these beans have no instance when it says no. + JSON.SET: + risk: R1 + support: VERSION_GATED + minimum-version: "8.0" + JSON.GET: + risk: R2 + support: ADVANCED_TYPED + minimum-version: "8.0" + read-only: true + required-policy: bounded-range-read + JSON.DEL: + risk: R1 + support: VERSION_GATED + minimum-version: "8.0" + JSON.TYPE: + risk: R1 + support: VERSION_GATED + minimum-version: "8.0" + read-only: true + JSON.STRLEN: + risk: R1 + support: VERSION_GATED + minimum-version: "8.0" + read-only: true + JSON.NUMINCRBY: + risk: R1 + support: VERSION_GATED + minimum-version: "8.0" + JSON.ARRAPPEND: + risk: R1 + support: VERSION_GATED + minimum-version: "8.0" + JSON.ARRLEN: + risk: R1 + support: VERSION_GATED + minimum-version: "8.0" + read-only: true + JSON.OBJKEYS: + risk: R2 + support: ADVANCED_TYPED + minimum-version: "8.0" + read-only: true + required-policy: collection-full-read + + TS.CREATE: + risk: R1 + support: VERSION_GATED + minimum-version: "8.0" + TS.ADD: + risk: R1 + support: VERSION_GATED + minimum-version: "8.0" + TS.GET: + risk: R1 + support: VERSION_GATED + minimum-version: "8.0" + read-only: true + TS.RANGE: + risk: R2 + support: ADVANCED_TYPED + minimum-version: "8.0" + read-only: true + required-policy: bounded-collection-read + TS.CREATERULE: + risk: R2 + support: ADVANCED_TYPED + minimum-version: "8.0" + key-spec: "1 2 1" + required-policy: multi-key-write + TS.DELETERULE: + risk: R2 + support: ADVANCED_TYPED + minimum-version: "8.0" + key-spec: "1 2 1" + required-policy: multi-key-write + + BF.RESERVE: + risk: R1 + support: VERSION_GATED + minimum-version: "8.0" + BF.ADD: + risk: R1 + support: VERSION_GATED + minimum-version: "8.0" + BF.MADD: + risk: R1 + support: VERSION_GATED + minimum-version: "8.0" + BF.EXISTS: + risk: R1 + support: VERSION_GATED + minimum-version: "8.0" + read-only: true + BF.MEXISTS: + risk: R1 + support: VERSION_GATED + minimum-version: "8.0" + read-only: true + CF.RESERVE: + risk: R1 + support: VERSION_GATED + minimum-version: "8.0" + CF.ADD: + risk: R1 + support: VERSION_GATED + minimum-version: "8.0" + CF.EXISTS: + risk: R1 + support: VERSION_GATED + minimum-version: "8.0" + read-only: true + CMS.INITBYPROB: + risk: R1 + support: VERSION_GATED + minimum-version: "8.0" + CMS.INCRBY: + risk: R1 + support: VERSION_GATED + minimum-version: "8.0" + CMS.QUERY: + risk: R1 + support: VERSION_GATED + minimum-version: "8.0" + read-only: true + TOPK.RESERVE: + risk: R1 + support: VERSION_GATED + minimum-version: "8.0" + TOPK.ADD: + risk: R1 + support: VERSION_GATED + minimum-version: "8.0" + TOPK.LIST: + risk: R2 + support: ADVANCED_TYPED + minimum-version: "8.0" + read-only: true + required-policy: collection-full-read + TDIGEST.CREATE: + risk: R1 + support: VERSION_GATED + minimum-version: "8.0" + TDIGEST.ADD: + risk: R1 + support: VERSION_GATED + minimum-version: "8.0" + TDIGEST.QUANTILE: + risk: R1 + support: VERSION_GATED + minimum-version: "8.0" + read-only: true + + # Search commands address an index, not a key, so the guard has no key to namespace-check. The + # index name is therefore namespaced by the SDK itself and that is where the check lives. + FT.CREATE: + risk: R2 + support: ADVANCED_TYPED + minimum-version: "8.0" + key-spec: "none" + required-policy: search-index + FT.SEARCH: + risk: R2 + support: ADVANCED_TYPED + minimum-version: "8.0" + read-only: true + key-spec: "none" + required-policy: bounded-collection-read + FT.AGGREGATE: + risk: R2 + support: ADVANCED_TYPED + minimum-version: "8.0" + read-only: true + key-spec: "none" + required-policy: bounded-collection-read + FT.INFO: + risk: R3 + support: ADMIN_ONLY + minimum-version: "8.0" + read-only: true + key-spec: "none" + FT.DROPINDEX: + risk: R4 + support: BLOCKED diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/ratelimit/RedisEdgeRateLimitAdapterTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/ratelimit/RedisEdgeRateLimitAdapterTest.java new file mode 100644 index 00000000..04fc419b --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/ratelimit/RedisEdgeRateLimitAdapterTest.java @@ -0,0 +1,321 @@ +package dev.caskeleton.adapter.outbound.cache.redis.ratelimit; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisDeploymentMode; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.RedisNamespace; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisConnectionKind; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeClient; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeOwner; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.InMemoryGatewayAccess; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisCommandGateway; +import dev.caskeleton.shared.ratelimit.RateLimitAlgorithm; +import dev.caskeleton.shared.ratelimit.RateLimitDecision; +import dev.caskeleton.shared.ratelimit.RateLimitEvaluationDedupPolicy; +import dev.caskeleton.shared.ratelimit.RateLimitFailurePolicy; +import dev.caskeleton.shared.ratelimit.RateLimitOutcome; +import dev.caskeleton.shared.ratelimit.RateLimitPolicy; +import dev.caskeleton.shared.ratelimit.RateLimitRequest; +import dev.caskeleton.shared.ratelimit.RateParameters; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.EnumMap; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The provider-neutral rate limit, on Redis, without a Redis in sight from the caller's side. + * + *

Two things are being asserted. The algorithms bound what they claim to bound — that is the + * feature. And every failure path is closed — that is the safety property, and the one a limiter + * gets wrong in the direction that matters: allowing traffic when the store is unreachable removes + * the bound at exactly the moment the bound is load-bearing. + */ +class RedisEdgeRateLimitAdapterTest { + + private static final Instant T0 = Instant.parse("2026-08-10T12:00:00Z"); + + // The contract bounds both identifiers by pattern. The adapter must satisfy them, not relax them: + // a subject digest short enough to collide, or an evaluation id without a generation prefix, are + // exactly what those patterns exist to keep out of a shared keyspace. + private static final String SUBJECT_A = "aaaaaaaaaaaaaaaa0000"; + + private static final String SUBJECT_B = "bbbbbbbbbbbbbbbb1111"; + + private static final String EVALUATION_ID = "ev1:AAAAAAAAAAAAAAAAAAAAAA"; + + private final InMemoryGatewayAccess gateway = InMemoryGatewayAccess.create(); + + private final RateLimitKeys keys = + new RateLimitKeys(new RedisNamespace("prod", "ca-skeleton", "shared"), 1); + + private RedisEdgeRateLimitAdapter adapter(RateLimitPolicy policy, Clock clock) { + return adapter(policy, clock, new StubClient(gateway.gateway())); + } + + private RedisEdgeRateLimitAdapter adapter( + RateLimitPolicy policy, Clock clock, RedisRuntimeClient client) { + Map limits = new EnumMap<>(RedisConnectionKind.class); + for (RedisConnectionKind kind : RedisConnectionKind.values()) { + limits.put(kind, 4); + } + return new RedisEdgeRateLimitAdapter( + new RedisRuntimeOwner(client, limits, Duration.ofSeconds(1)), + keys, + Map.of(policy.policyId(), policy), + new RateLimitScripts(), + clock, + Duration.ofSeconds(2), + Duration.ofMillis(100)); + } + + private static RateLimitPolicy policy(RateParameters parameters, RateLimitAlgorithm algorithm) { + return new RateLimitPolicy( + "api-default", + "v1", + algorithm, + parameters, + // The contract caps a single request's cost at the algorithm's own budget: a cost that can + // never be satisfied is a configuration error, not a permanently denied caller. + maximumCostOf(parameters), + Duration.ofSeconds(5), + Duration.ofMillis(250), + RateLimitFailurePolicy.FAIL_CLOSED, + new RateLimitEvaluationDedupPolicy(false, Duration.ZERO, 0, 0)); + } + + private static long maximumCostOf(RateParameters parameters) { + return switch (parameters) { + case RateParameters.FixedWindow window -> window.limit(); + case RateParameters.SlidingCounter sliding -> sliding.limit(); + case RateParameters.TokenBucket bucket -> bucket.capacity(); + default -> throw new IllegalStateException("unsupported parameters"); + }; + } + + private static RateLimitRequest request(long cost) { + return new RateLimitRequest( + "api-default", SUBJECT_A, cost, EVALUATION_ID, T0.plusSeconds(3600)); + } + + @Test + @DisplayName("a fixed window allows up to its limit and then denies with a positive wait") + void aFixedWindowBoundsItsWindow() { + RateLimitPolicy policy = + policy( + new RateParameters.FixedWindow(3, Duration.ofSeconds(60)), + RateLimitAlgorithm.FIXED_WINDOW); + RedisEdgeRateLimitAdapter adapter = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC)); + + for (int allowed = 0; allowed < 3; allowed++) { + assertThat(decision(adapter.evaluate(request(1))).allowed()) + .as("request %s of the budget", allowed + 1) + .isTrue(); + } + + RateLimitDecision denied = decision(adapter.evaluate(request(1))); + assertThat(denied.allowed()).isFalse(); + assertThat(denied.remaining()).isZero(); + assertThat(denied.retryAfter()).isPositive(); + assertThat(denied.source()).isEqualTo(RateLimitDecision.DecisionSource.GLOBAL_REDIS); + assertThat(denied.certainty()).isEqualTo(RateLimitDecision.DecisionCertainty.CERTAIN); + } + + @Test + @DisplayName("a new window restores the budget") + void anewWindowRestoresTheBudget() { + RateLimitPolicy policy = + policy( + new RateParameters.FixedWindow(2, Duration.ofSeconds(60)), + RateLimitAlgorithm.FIXED_WINDOW); + RedisEdgeRateLimitAdapter spent = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC)); + spent.evaluate(request(1)); + spent.evaluate(request(1)); + assertThat(decision(spent.evaluate(request(1))).allowed()).isFalse(); + + RedisEdgeRateLimitAdapter next = + adapter(policy, Clock.fixed(T0.plusSeconds(60), ZoneOffset.UTC)); + + assertThat(decision(next.evaluate(request(1))).allowed()).isTrue(); + } + + @Test + @DisplayName("a sliding counter reports itself as approximate") + void aSlidingCounterIsHonestAboutBeingApproximate() { + RateLimitPolicy policy = + policy( + new RateParameters.SlidingCounter(5, Duration.ofSeconds(60)), + RateLimitAlgorithm.SLIDING_COUNTER); + RedisEdgeRateLimitAdapter adapter = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC)); + + // Interpolating across two windows is a deliberate memory trade, and the caller is told, since + // "approximate" and "certain" are different things to build an abuse decision on. + assertThat(decision(adapter.evaluate(request(1))).certainty()) + .isEqualTo(RateLimitDecision.DecisionCertainty.APPROXIMATE_ALGORITHM); + } + + @Test + @DisplayName("a token bucket refills by elapsed periods, not by wall-clock jumps") + void aTokenBucketRefillsByPeriod() { + RateLimitPolicy policy = + policy( + new RateParameters.TokenBucket(2, 1, Duration.ofSeconds(10)), + RateLimitAlgorithm.TOKEN_BUCKET); + RedisEdgeRateLimitAdapter start = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC)); + assertThat(decision(start.evaluate(request(1))).allowed()).isTrue(); + assertThat(decision(start.evaluate(request(1))).allowed()).isTrue(); + assertThat(decision(start.evaluate(request(1))).allowed()).isFalse(); + + // Half a period buys nothing; a whole one buys exactly one token. + RedisEdgeRateLimitAdapter halfway = + adapter(policy, Clock.fixed(T0.plusSeconds(5), ZoneOffset.UTC)); + assertThat(decision(halfway.evaluate(request(1))).allowed()).isFalse(); + + RedisEdgeRateLimitAdapter refilled = + adapter(policy, Clock.fixed(T0.plusSeconds(10), ZoneOffset.UTC)); + assertThat(decision(refilled.evaluate(request(1))).allowed()).isTrue(); + assertThat(decision(refilled.evaluate(request(1))).allowed()).isFalse(); + } + + @Test + @DisplayName("an unreachable Redis denies rather than allows") + void anUnreachableRedisFailsClosed() { + RateLimitPolicy policy = + policy( + new RateParameters.FixedWindow(5, Duration.ofSeconds(60)), + RateLimitAlgorithm.FIXED_WINDOW); + RedisEdgeRateLimitAdapter adapter = + adapter(policy, Clock.fixed(T0, ZoneOffset.UTC), new BrokenClient()); + + RateLimitOutcome outcome = adapter.evaluate(request(1)); + + // Never Evaluated(allowed). A limiter that opens up during an outage is not a limiter, and the + // outage is exactly when the bound matters. + assertThat(outcome).isInstanceOf(RateLimitOutcome.Unavailable.class); + assertThat(((RateLimitOutcome.Unavailable) outcome).retryAfter()).isPositive(); + } + + @Test + @DisplayName("an unknown policy is a deployment error, not an allowance") + void anUnknownPolicyIsIncompatible() { + RateLimitPolicy policy = + policy( + new RateParameters.FixedWindow(5, Duration.ofSeconds(60)), + RateLimitAlgorithm.FIXED_WINDOW); + RedisEdgeRateLimitAdapter adapter = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC)); + + RateLimitOutcome outcome = + adapter.evaluate( + new RateLimitRequest( + "other-policy", SUBJECT_A, 1, EVALUATION_ID, T0.plusSeconds(3600))); + + assertThat(outcome).isInstanceOf(RateLimitOutcome.Incompatible.class); + } + + @Test + @DisplayName("a cost above the policy ceiling is refused rather than clamped") + void anOversizedCostIsRefused() { + RateLimitPolicy policy = + policy( + new RateParameters.FixedWindow(5, Duration.ofSeconds(60)), + RateLimitAlgorithm.FIXED_WINDOW); + RedisEdgeRateLimitAdapter adapter = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC)); + + assertThat(adapter.evaluate(request(6))).isInstanceOf(RateLimitOutcome.Incompatible.class); + } + + @Test + @DisplayName("a caller whose deadline already passed is told now, not after a round trip") + void anExpiredCallerDeadlineIsRejectedImmediately() { + RateLimitPolicy policy = + policy( + new RateParameters.FixedWindow(5, Duration.ofSeconds(60)), + RateLimitAlgorithm.FIXED_WINDOW); + RedisEdgeRateLimitAdapter adapter = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC)); + + RateLimitOutcome outcome = + adapter.evaluate( + new RateLimitRequest("api-default", SUBJECT_A, 1, EVALUATION_ID, T0.minusMillis(1))); + + assertThat(outcome).isInstanceOf(RateLimitOutcome.Unavailable.class); + assertThat(((RateLimitOutcome.Unavailable) outcome).category()) + .isEqualTo(RateLimitOutcome.UnavailableCategory.ADMISSION_REJECTED); + } + + @Test + @DisplayName("different subjects have separate budgets") + void subjectsAreIsolated() { + RateLimitPolicy policy = + policy( + new RateParameters.FixedWindow(1, Duration.ofSeconds(60)), + RateLimitAlgorithm.FIXED_WINDOW); + RedisEdgeRateLimitAdapter adapter = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC)); + + assertThat(decision(adapter.evaluate(request(1))).allowed()).isTrue(); + assertThat(decision(adapter.evaluate(request(1))).allowed()).isFalse(); + assertThat( + decision( + adapter.evaluate( + new RateLimitRequest( + "api-default", SUBJECT_B, 1, EVALUATION_ID, T0.plusSeconds(3600)))) + .allowed()) + .isTrue(); + } + + private static RateLimitDecision decision(RateLimitOutcome outcome) { + assertThat(outcome).isInstanceOf(RateLimitOutcome.Evaluated.class); + return ((RateLimitOutcome.Evaluated) outcome).decision(); + } + + /** Hands out the shared in-memory gateway; the owner's pooling makes it one logical server. */ + private record StubClient(RedisCommandGateway gateway) implements RedisRuntimeClient { + + @Override + public RedisDeploymentMode mode() { + return RedisDeploymentMode.STANDALONE; + } + + @Override + public RedisLaneConnection openLane( + RedisConnectionKind kind, java.util.Optional routingKey) { + return new RedisLaneConnection() { + @Override + public RedisCommandGateway gateway() { + return gateway; + } + + @Override + public boolean open() { + return true; + } + + @Override + public void close() {} + }; + } + + @Override + public void close() {} + } + + /** A server that cannot be reached at all. */ + private static final class BrokenClient implements RedisRuntimeClient { + + @Override + public RedisDeploymentMode mode() { + return RedisDeploymentMode.STANDALONE; + } + + @Override + public RedisLaneConnection openLane( + RedisConnectionKind kind, java.util.Optional routingKey) { + throw new IllegalStateException("the server is unreachable"); + } + + @Override + public void close() {} + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/RedisTopologyEndpoint.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/RedisTopologyEndpoint.java new file mode 100644 index 00000000..1a04d383 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/RedisTopologyEndpoint.java @@ -0,0 +1,244 @@ +package dev.caskeleton.adapter.outbound.cache.redis.sdk; + +import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisDeploymentMode; +import io.lettuce.core.RedisURI; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; + +/** + * The endpoint a topology lane was pointed at. + * + *

Resolution fails rather than returning a default. A topology test that quietly falls back to + * {@code localhost:6379} either tests the wrong thing or passes because nothing answered, and both + * are indistinguishable from success in a CI log. + * + *

The endpoint is not always the data node. On the Sentinel lane the declared address is a + * sentinel, and the primary has to be resolved from it — which is the whole point of the + * lane, because the address the client dials changes when the primary is promoted. {@link + * #dataUri()} is therefore the only supported way to reach data: a test that builds its own URI + * from {@link #host()} and {@link #port()} would connect to a sentinel and then assert against the + * wrong server. + * + * @param host the declared host + * @param port the declared port + * @param mode the deployment mode the lane represents + * @param masterId the monitored primary's name, present on the Sentinel lane only + * @param username the ACL account the data connection authenticates as + * @param password that account's password + * @param trustMaterial the CA the lane's server certificate is signed by, on the TLS lane only + */ +public record RedisTopologyEndpoint( + String host, + int port, + RedisDeploymentMode mode, + Optional masterId, + String username, + String password, + Optional trustMaterial) { + + /** Canonical constructor. */ + public RedisTopologyEndpoint { + Objects.requireNonNull(host, "host must be non-null"); + Objects.requireNonNull(mode, "mode must be non-null"); + Objects.requireNonNull(masterId, "master identifier must be non-null"); + if (host.isBlank()) { + throw new IllegalArgumentException("the topology host must not be blank"); + } + if (port < 1 || port > 65_535) { + throw new IllegalArgumentException("the topology port must be a valid port number"); + } + Objects.requireNonNull(username, "username must be non-null"); + Objects.requireNonNull(password, "password must be non-null"); + Objects.requireNonNull(trustMaterial, "trust material must be non-null"); + if (username.isBlank()) { + throw new IllegalArgumentException( + "the lane must authenticate as a named ACL account; the fixture disables the default" + + " user precisely so an unauthenticated connection cannot pass for a working one"); + } + if (mode == RedisDeploymentMode.SENTINEL && masterId.isEmpty()) { + throw new IllegalArgumentException( + "the sentinel lane needs the monitored primary's name; without it the client cannot" + + " resolve a primary at all, let alone follow a promotion"); + } + } + + /** + * Resolves the endpoint from the system properties the lane sets. + * + * @return the endpoint + * @throws IllegalStateException when the lane was selected without an endpoint + */ + public static RedisTopologyEndpoint fromSystemProperties() { + RedisDeploymentMode mode = + RedisDeploymentMode.valueOf(require("redis.topology.mode").toUpperCase(Locale.ROOT)); + Optional masterId = + mode == RedisDeploymentMode.SENTINEL + ? Optional.of(require("redis.topology.master")) + : Optional.ofNullable(System.getProperty("redis.topology.master")) + .filter(value -> !value.isBlank()); + return new RedisTopologyEndpoint( + require("redis.topology.host"), + Integer.parseInt(require("redis.topology.port")), + mode, + masterId, + // The lane's ACL fixture disables `default`, so every data connection authenticates as a + // named account. Defaulting to the application account keeps the common case one flag + // shorter while still going through AUTH — which is what the qualification has to prove. + System.getProperty("redis.topology.username", "ca-skeleton-application"), + // The fixture accounts carry real passwords. They were `nopass`, which accepts any + // password at all — so every assertion about authentication passed for the same reason a + // wrong password would have, and rotation and wrong-password coverage was false-green. + System.getProperty("redis.topology.password", "fixture-application"), + // Present exactly on the TLS lane. The build passes it, so a lane that forgot it fails in + // the task rather than by silently connecting without verification. + Optional.ofNullable(System.getProperty("redis.topology.trust-material")) + .filter(value -> !value.isBlank())); + } + + /** + * Reports whether this lane speaks TLS. + * + * @return {@code true} when the lane's server has no plaintext port at all + */ + public boolean tls() { + return Boolean.parseBoolean(System.getProperty("redis.topology.tls", "false")); + } + + /** + * Returns the CA the lane's server certificate is signed by. + * + * @return the configured trust material location + * @throws IllegalStateException when the lane is not a TLS lane + */ + public String requireTrustMaterial() { + return trustMaterial.orElseThrow( + () -> new IllegalStateException("only the TLS lane declares trust material")); + } + + /** + * Returns the URI a data connection must be opened with. + * + * @return a sentinel-resolving URI on the Sentinel lane, the declared address otherwise + */ + public RedisURI dataUri() { + RedisURI uri = + switch (mode) { + case SENTINEL -> RedisURI.Builder.sentinel(host, port, masterId.orElseThrow()).build(); + case STANDALONE, CLUSTER -> RedisURI.create(host, port); + }; + uri.setCredentialsProvider( + io.lettuce.core.RedisCredentialsProvider.from( + () -> io.lettuce.core.RedisCredentials.just(username, password.toCharArray()))); + return uri; + } + + /** + * Returns the URI a diagnostic connection must be opened with. + * + *

Separate from {@link #dataUri()} because the accounts are separate, and deliberately so. The + * application account cannot run {@code INFO} — the ACL fixture denies it, exactly as the SDK's + * own admin plane models it — so a lane that probes the server version over the data connection + * gets {@code NOPERM}. That is the fixture working, not a fixture bug: reaching diagnostics + * requires holding the admin account. + * + * @return the declared address, authenticated as the read-only admin account + */ + public RedisURI adminUri() { + RedisURI uri = + switch (mode) { + case SENTINEL -> RedisURI.Builder.sentinel(host, port, masterId.orElseThrow()).build(); + case STANDALONE, CLUSTER -> RedisURI.create(host, port); + }; + uri.setCredentialsProvider( + io.lettuce.core.RedisCredentialsProvider.from( + () -> + io.lettuce.core.RedisCredentials.just( + System.getProperty( + "redis.topology.admin-username", "ca-skeleton-admin-readonly"), + System.getProperty("redis.topology.admin-password", "fixture-admin") + .toCharArray()))); + return uri; + } + + /** + * Returns the URI a Sentinel control connection must be opened with. + * + *

Deliberately credential-free, and that is not an oversight. A sentinel is a different + * process with its own ACL: it does not load the data nodes' {@code aclfile}, so the accounts in + * {@code infra/redis-sdk/acl} do not exist there and presenting one gets {@code WRONGPASS}. The + * {@code sentinel auth-user} / {@code auth-pass} directives in the lane are about how the + * sentinel authenticates to the monitored primary, which is a different direction + * entirely. Securing the sentinels themselves would mean giving them their own ACL file, and the + * lane deliberately does not, because a sentinel port is not a data path. + * + * @return the declared sentinel address + */ + public RedisURI sentinelControlUri() { + return RedisURI.create(host, port); + } + + /** + * Returns the URI for a specific data node the lane discovered, authenticated as the data + * account. + * + *

A promotion test has to dial the node Sentinel just named, not the declared address, so the + * host and port come from the caller while the credentials stay the lane's. + * + * @param nodeHost the discovered host + * @param nodePort the discovered port + * @return the authenticated URI + */ + public RedisURI dataNodeUri(String nodeHost, int nodePort) { + RedisURI uri = RedisURI.create(nodeHost, nodePort); + uri.setCredentialsProvider( + io.lettuce.core.RedisCredentialsProvider.from( + () -> io.lettuce.core.RedisCredentials.just(username, password.toCharArray()))); + return uri; + } + + /** + * Returns the URI for cluster provisioning writes against a specific node. + * + *

{@code CLUSTER SETSLOT} and friends are administrative writes. They are absent from the + * read-only admin account on purpose — an account named {@code admin-readonly} that can reshard a + * cluster is misnamed — so a test that drives a migration authenticates as the provisioning + * identity the lane also uses to build the cluster. + * + * @param nodeHost the node's host + * @param nodePort the node's port + * @return the authenticated URI + */ + public RedisURI provisioningUri(String nodeHost, int nodePort) { + RedisURI uri = RedisURI.create(nodeHost, nodePort); + uri.setCredentialsProvider( + io.lettuce.core.RedisCredentialsProvider.from( + () -> + io.lettuce.core.RedisCredentials.just( + System.getProperty( + "redis.topology.provisioning-username", "ca-skeleton-cluster-bootstrap"), + System.getProperty("redis.topology.provisioning-password", "fixture-bootstrap") + .toCharArray()))); + return uri; + } + + /** + * Returns the monitored primary's name. + * + * @return the name + * @throws IllegalStateException when the lane is not a Sentinel lane + */ + public String requireMasterId() { + return masterId.orElseThrow( + () -> new IllegalStateException("only the sentinel lane declares a monitored primary")); + } + + private static String require(String key) { + String value = System.getProperty(key); + if (value == null || value.isBlank()) { + throw new IllegalStateException( + "the redis-topology lane requires -D" + key + "; it must never be skipped silently"); + } + return value; + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/LiveRedisCompositionTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/LiveRedisCompositionTest.java new file mode 100644 index 00000000..47656d48 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/LiveRedisCompositionTest.java @@ -0,0 +1,176 @@ +package dev.caskeleton.adapter.outbound.cache.redis.sdk.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.cache.redis.sdk.RedisTopologyEndpoint; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisConnectionKind; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisLease; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeClient; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeOwner; +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.health.contributor.HealthIndicator; +import org.springframework.boot.health.contributor.Status; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +/** + * The composition root, against a server that is actually there. + * + *

Every other test of this configuration uses endpoints nothing answers on, which proves the + * bean graph and nothing about whether the graph works. These are the three claims that can only be + * settled by connecting: the mode produced the client the topology needs, a lease borrowed from the + * owner reaches Redis, and shutting the context down leaves nothing behind. + */ +@Tag("redis-topology") +@Tag("lane-standalone") +@Tag("lane-sentinel") +@Tag("lane-cluster") +class LiveRedisCompositionTest { + + private final RedisTopologyEndpoint endpoint = RedisTopologyEndpoint.fromSystemProperties(); + + private ApplicationContextRunner runner() { + return runner("fixture-application"); + } + + private ApplicationContextRunner runner(String password) { + ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(RedisSdkAutoConfiguration.class)) + .withBean( + RedisSdkAutoConfiguration.RedisSecretSource.class, + // The fixture account's real password. It used to be `nopass`, so this could have + // been any string at all and the lane would still have connected — which is why + // there was nothing to distinguish a working credential from a wrong one. + () -> name -> Optional.of(password)) + .withPropertyValues( + "app.redis.enabled=true", + "app.redis.mode=" + endpoint.mode().name().toLowerCase(java.util.Locale.ROOT), + "app.redis.nodes=" + endpoint.host() + ":" + endpoint.port(), + // The lane's ACL disables `default`, so the composition authenticates as a named + // account exactly as a deployment would. A reference, not a value. + "app.redis.authentication.credential-reference=secret://ca-skeleton-application@environment/APP_REDIS_PASSWORD", + "app.redis.namespace.environment=prod", + "app.redis.namespace.service=order", + "app.redis.namespace.domain=shared"); + return endpoint.mode() + == dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisDeploymentMode.SENTINEL + ? runner.withPropertyValues("app.redis.sentinel.master-name=" + endpoint.requireMasterId()) + : runner; + } + + @Test + @DisplayName("a wrong password is refused, so a right one proves something") + void aWrongPasswordIsRefused() throws Exception { + // The assertion the `nopass` fixture could never carry. With an account that accepts anything, + // every credential test passed for the same reason a typo would have, and the lane's coverage + // of authentication, rotation and secret wiring was indistinguishable from no coverage. + runner("not-the-fixture-password") + .run( + context -> { + assertThat(context).hasNotFailed(); + RedisRuntimeOwner owner = context.getBean(RedisRuntimeOwner.class); + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> { + try (RedisLease lease = owner.borrow(RedisConnectionKind.REGULAR)) { + lease.gateway().ping().toCompletableFuture().get(5, TimeUnit.SECONDS); + } + }) + .as("authentication is actually enforced by the fixture") + .rootCause() + .hasMessageContaining("WRONGPASS"); + }); + } + + @Test + @DisplayName("the configured mode produces exactly one client of the matching topology") + void theModeProducesOneMatchingClient() { + runner() + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(RedisRuntimeClient.class); + assertThat(context.getBean(RedisRuntimeClient.class).mode()) + .isEqualTo(endpoint.mode()); + assertThat(context).hasSingleBean(RedisRuntimeOwner.class); + }); + } + + @Test + @DisplayName("a lease borrowed from the composed owner reaches the server") + void aLeaseReachesTheServer() throws Exception { + runner() + .run( + context -> { + RedisRuntimeOwner owner = context.getBean(RedisRuntimeOwner.class); + assertThat(owner.state()).isEqualTo(RedisRuntimeOwner.State.OPEN); + + try (RedisLease lease = owner.borrow(RedisConnectionKind.REGULAR)) { + String reply = + lease.gateway().ping().toCompletableFuture().get(5, TimeUnit.SECONDS); + assertThat(reply).isEqualTo("PONG"); + } + assertThat(owner.outstanding(RedisConnectionKind.REGULAR)) + .as("the lease was returned, not leaked") + .isZero(); + }); + } + + @Test + @DisplayName("the optional health contributor reports UP against a live server") + void theOptionalContributorReportsUp() { + runner() + .run( + context -> { + HealthIndicator optional = (HealthIndicator) context.getBean("redisOptional"); + assertThat(optional.health().getStatus()).isEqualTo(Status.UP); + }); + } + + @Test + @DisplayName("closing the context drains, closes connections, then shuts the client down") + void closingTheContextTearsEverythingDown() { + RedisRuntimeOwner[] captured = new RedisRuntimeOwner[1]; + int threadsBefore = redisThreadCount(); + + runner() + .run( + context -> { + captured[0] = context.getBean(RedisRuntimeOwner.class); + captured[0].borrow(RedisConnectionKind.REGULAR).close(); + }); + + assertThat(captured[0].state()).isEqualTo(RedisRuntimeOwner.State.CLOSED); + // The event loop is what a leaked client leaves behind, and it is invisible to a bean-graph + // assertion. Lettuce's threads are named, so counting them is a direct check. + await(() -> redisThreadCount() <= threadsBefore, Duration.ofSeconds(10)); + assertThat(redisThreadCount()) + .as("no Lettuce event-loop threads outlive the context") + .isLessThanOrEqualTo(threadsBefore); + } + + private static int redisThreadCount() { + return (int) + Thread.getAllStackTraces().keySet().stream() + .map(Thread::getName) + .filter(name -> name.startsWith("lettuce-")) + .count(); + } + + private static void await(java.util.function.BooleanSupplier condition, Duration budget) { + long deadline = System.nanoTime() + budget.toNanos(); + while (System.nanoTime() < deadline && !condition.getAsBoolean()) { + try { + Thread.sleep(50); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return; + } + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/LiveRedisSentinelPromotionTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/LiveRedisSentinelPromotionTest.java new file mode 100644 index 00000000..d5d4004b --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/LiveRedisSentinelPromotionTest.java @@ -0,0 +1,437 @@ +package dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.cache.redis.sdk.RedisTopologyEndpoint; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisVersion; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.CommandId; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.RedisCommandDescriptor; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.error.RedisFailureMetadata; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.error.RedisOperationException; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.ListKey; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.codec.Utf8StringCodec; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.command.ExecutionCertainty; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.command.RedisCommandCatalog; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.SentinelFailoverObserver; +import io.lettuce.core.RedisClient; +import io.lettuce.core.api.StatefulRedisConnection; +import io.lettuce.core.sentinel.api.StatefulRedisSentinelConnection; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * What a Sentinel promotion does to work that is in flight. + * + *

The unit suite can prove that {@link SentinelFailoverObserver} counts what it is told and that + * {@link ExecutionCertainty} refuses to retry a non-idempotent write. It cannot prove the thing + * those two types exist for: that the certainty the SDK reports to a caller is true of the + * server afterwards. Only a real promotion settles that. + * + *

The experiment runs once in {@link #promote()} and every test below asserts a different + * falsifiable claim about that one recorded run. Each write carries a token that is unique across + * the run, so the list on the promoted primary is a verbatim record of what actually happened and + * the SDK's per-call verdict can be checked against it one token at a time. + * + *

The two claims with teeth are that a token whose call the SDK reported as definitely not + * applied must be absent from the promoted primary, and that no token may appear twice — + * {@code RPUSH} is not retry-safe, and the guardrails forbid anything in the stack from resending + * it on its own. Both fail loudly if the certainty model is wishful thinking rather than a + * description of the driver underneath it. + */ +@Tag("redis-topology") +@Tag("lane-sentinel") +class LiveRedisSentinelPromotionTest { + + /** The write that must survive the promotion; it is confirmed and replicated beforehand. */ + private static final String REPLICATED = "replicated-before-promotion"; + + /** How long the write loop may run before the lane is declared broken. */ + private static final Duration EXPERIMENT_CEILING = Duration.ofSeconds(90); + + /** Confirmed writes required after the promotion before the run is considered settled. */ + private static final int SETTLED_WRITES = 50; + + /** Attempts to confirm before the failover is requested. */ + private static final int WARMUP_WRITES = 20; + + /** Pause between attempts; small enough to land inside a promotion, large enough not to spin. */ + private static final Duration ATTEMPT_INTERVAL = Duration.ofMillis(5); + + /** + * The {@code min-replicas-max-lag} the lane configures. + * + *

This is the width of the window in which a superseded primary can still acknowledge a write + * that is about to be discarded, so it is also the bound on how many acknowledged writes a + * promotion may destroy. Doubling it leaves room for scheduling jitter without leaving room for + * the unbounded behaviour the setting exists to prevent. + */ + private static final Duration REPLICA_LAG_CEILING = Duration.ofSeconds(1); + + private static RedisTopologyEndpoint endpoint; + + private static RedisClient controlClient; + + private static StatefulRedisSentinelConnection sentinel; + + private static LiveRedisOperationsFixture fixture; + + private static ListKey log; + + private static String renderedLogKey; + + private static InetSocketAddress primaryBefore; + + private static InetSocketAddress primaryAfter; + + private static final List ATTEMPTS = new ArrayList<>(); + + private static List tokensOnPromotedPrimary = List.of(); + + private static SentinelFailoverObserver observer; + + private static Duration observedReconnect = Duration.ZERO; + + /** One write attempt and the verdict the SDK returned for it. */ + private record Attempt(String token, Optional failure) { + + boolean confirmed() { + return failure.isEmpty(); + } + + /** Reports whether the SDK told the caller the write definitely did not take effect. */ + boolean reportedAsNotApplied() { + return failure.map(value -> !value.metadata().ambiguousExecution()).orElse(false); + } + + /** Reports whether the SDK left the outcome open. */ + boolean reportedAsAmbiguous() { + return failure.map(value -> value.metadata().ambiguousExecution()).orElse(false); + } + + Optional metadata() { + return failure.map(RedisOperationException::metadata); + } + } + + @BeforeAll + static void promote() throws InterruptedException { + endpoint = RedisTopologyEndpoint.fromSystemProperties(); + controlClient = RedisClient.create(); + // The sentinels themselves run with `default` off, so the control connection authenticates as + // the sentinel account. Before that account existed this connected as `default` — which is why + // hardening the fixture broke this lane rather than the lane proving the hardening. + sentinel = controlClient.connectSentinel(endpoint.sentinelControlUri()); + primaryBefore = resolvePrimary(); + + fixture = + new LiveRedisOperationsFixture( + endpoint, RedisVersion.parse("7.4.0"), Duration.ofMillis(500)); + log = fixture.keys.list("failover", "promotion-log", Utf8StringCodec.instance()); + renderedLogKey = fixture.renderer.render(log.key()); + observer = new SentinelFailoverObserver(1_000); + + // The lane requires an in-sync replica before it will accept a write at all, so the run cannot + // start until one is attached. Writing first and discovering NOREPLICAS would report a lane + // that was merely still starting up as a promotion that destroyed data. + awaitInSyncReplica(primaryBefore); + clearLog(primaryBefore); + // If this one is missing afterwards the lane promoted a replica that never carried the data, + // and every other assertion in this class would be measuring the wrong thing. + fixture.lists.pushRight(log, List.of(REPLICATED)); + requireReplication(primaryBefore); + + runWriteLoopAcrossAPromotion(); + + primaryAfter = resolvePrimary(); + observer.recordPromotion(observedReconnect); + RedisCommandDescriptor push = + RedisCommandCatalog.loadDefault().require(CommandId.parse("RPUSH")).descriptor(); + for (Attempt attempt : ATTEMPTS) { + if (!attempt.confirmed()) { + observer.classify(push, attempt.reportedAsAmbiguous()); + } + } + tokensOnPromotedPrimary = readLog(primaryAfter); + report(); + } + + private static void runWriteLoopAcrossAPromotion() throws InterruptedException { + Instant deadline = Instant.now().plus(EXPERIMENT_CEILING); + boolean failoverRequested = false; + Instant firstFailure = null; + int confirmedAfterFirstFailure = 0; + int index = 0; + while (Instant.now().isBefore(deadline)) { + Attempt attempt = attempt("token-" + index++); + ATTEMPTS.add(attempt); + + if (!failoverRequested && attempt.confirmed() && index >= WARMUP_WRITES) { + sentinel.sync().failover(endpoint.requireMasterId()); + failoverRequested = true; + } + if (failoverRequested && !attempt.confirmed() && firstFailure == null) { + firstFailure = Instant.now(); + } + if (firstFailure != null && attempt.confirmed()) { + if (observedReconnect.isZero()) { + observedReconnect = Duration.between(firstFailure, Instant.now()); + } + if (++confirmedAfterFirstFailure >= SETTLED_WRITES) { + return; + } + } + Thread.sleep(ATTEMPT_INTERVAL.toMillis()); + } + } + + /** + * Prints what the promotion actually did. + * + *

This lane exists to find out how the driver behaves, so the distribution of failure types is + * evidence in its own right and is recorded in the run log rather than only in an assertion + * message. + */ + private static void report() { + Map byType = new LinkedHashMap<>(); + for (Attempt attempt : ATTEMPTS) { + attempt.failure.ifPresent( + failure -> + byType.merge( + failure.getClass().getSimpleName() + + (failure.metadata().ambiguousExecution() ? " [ambiguous]" : " [not-run]") + + " " + + failure.getMessage() + + " <- " + + rootCause(failure), + 1L, + Long::sum)); + } + Set present = new LinkedHashSet<>(tokensOnPromotedPrimary); + long acknowledgedButLost = + ATTEMPTS.stream() + .filter(Attempt::confirmed) + .map(Attempt::token) + .filter(token -> !present.contains(token)) + .count(); + System.out.println("[sentinel] primary " + primaryBefore + " -> " + primaryAfter); + System.out.println( + "[sentinel] attempts=" + + ATTEMPTS.size() + + " confirmed=" + + ATTEMPTS.stream().filter(Attempt::confirmed).count() + + " reconnect=" + + observedReconnect + + " stored=" + + tokensOnPromotedPrimary.size() + + " acknowledged-but-lost=" + + acknowledgedButLost); + byType.forEach((type, count) -> System.out.println("[sentinel] " + count + "x " + type)); + } + + private static String rootCause(Throwable failure) { + Throwable current = failure; + while (current.getCause() != null) { + current = current.getCause(); + } + return current.getClass().getName() + ": " + current.getMessage(); + } + + @AfterAll + static void disconnect() { + if (fixture != null) { + fixture.close(); + } + if (sentinel != null) { + sentinel.close(); + } + if (controlClient != null) { + controlClient.shutdown(); + } + } + + @Test + @DisplayName("the lane actually promoted a different node") + void promotionHappened() { + assertThat(primaryAfter) + .as("Sentinel resolves the same address before and after; nothing was promoted") + .isNotEqualTo(primaryBefore); + } + + @Test + @DisplayName("the client followed the promotion and writes land on the new primary") + void clientFollowedThePromotion() { + // Deliberately not asserting that the caller saw a failure. The first runs of this lane showed + // a promotion that cost the caller nothing visible at all — sixteen thousand attempts, zero + // exceptions — while two thousand acknowledged writes were being discarded. Requiring a + // visible interruption would have turned that into a red test for the wrong reason and hidden + // the finding behind it. + assertThat(ATTEMPTS.stream().filter(Attempt::confirmed).toList()) + .hasSizeGreaterThan(SETTLED_WRITES); + assertThat(tokensOnPromotedPrimary) + .as("the replicated pre-promotion write did not survive the promotion") + .contains(REPLICATED); + } + + @Test + @DisplayName("a promotion destroys no more acknowledged writes than the replica lag allows") + void acknowledgedWriteLossIsBounded() { + Set present = new LinkedHashSet<>(tokensOnPromotedPrimary); + List lost = + ATTEMPTS.stream() + .filter(Attempt::confirmed) + .map(Attempt::token) + .filter(token -> !present.contains(token)) + .toList(); + long allowed = 2 * REPLICA_LAG_CEILING.dividedBy(ATTEMPT_INTERVAL); + + // This is the assertion the lane was built for. A superseded primary that still has an in-sync + // replica requirement stops acknowledging writes about one lag-window after it is orphaned; + // one that does not keeps saying +OK until Sentinel demotes it, which took eleven seconds and + // cost two thousand acknowledged writes when this was first measured. If the requirement is + // ever dropped from the lane, this count jumps by an order of magnitude and says so. + assertThat((long) lost.size()) + .as( + "%d acknowledged writes were discarded by the promotion; the configured replica lag" + + " allows at most %d, so the superseded primary was acknowledging writes it could" + + " not keep", + lost.size(), allowed) + .isLessThanOrEqualTo(allowed); + } + + @Test + @DisplayName("a write the SDK reported as not applied is absent from the promoted primary") + void reportedFailuresDidNotApply() { + Set present = new LinkedHashSet<>(tokensOnPromotedPrimary); + + assertThat( + ATTEMPTS.stream() + .filter(Attempt::reportedAsNotApplied) + .map(Attempt::token) + .filter(present::contains) + .toList()) + .as("the SDK told the caller these writes definitely did not run, and they did") + .isEmpty(); + } + + @Test + @DisplayName("every failure carries a coherent verdict") + void everyFailureIsClassified() { + List failures = + ATTEMPTS.stream().flatMap(attempt -> attempt.metadata().stream()).toList(); + + assertThat(failures) + .allSatisfy( + metadata -> { + assertThat(metadata.readOperation()).isFalse(); + assertThat(metadata.retryable() && metadata.ambiguousExecution()) + .as("an ambiguous write must never be advertised as retryable") + .isFalse(); + assertThat(metadata.retryable()) + .as("RPUSH is not retry-safe, so no verdict may mark it retryable") + .isFalse(); + }); + } + + @Test + @DisplayName("no write is applied twice across the promotion") + void nonIdempotentWritesAreNeverReplayed() { + assertThat(tokensOnPromotedPrimary) + .as("a token appears more than once; a non-retry-safe write was resent by the stack") + .doesNotHaveDuplicates(); + } + + @Test + @DisplayName("nothing reached the server that the test never issued") + void serverStateIsExplainedByTheRun() { + Set issued = new LinkedHashSet<>(ATTEMPTS.stream().map(Attempt::token).toList()); + issued.add(REPLICATED); + + assertThat(issued).containsAll(tokensOnPromotedPrimary); + } + + @Test + @DisplayName("the observer's account of the promotion matches the run") + void observerMatchesTheRun() { + long ambiguous = ATTEMPTS.stream().filter(Attempt::reportedAsAmbiguous).count(); + + assertThat(observer.promotionCount()).isEqualTo(1); + assertThat(observer.ambiguousWriteCount()) + .as("every ambiguous non-retry-safe write around a promotion is one to reconcile") + .isEqualTo(ambiguous); + assertThat(observer.longestReconnect()).isEqualTo(observedReconnect); + assertThat(observer.refusedWhileReconnectingCount()).isZero(); + } + + private static Attempt attempt(String token) { + try { + fixture.lists.pushRight(log, List.of(token)); + return new Attempt(token, Optional.empty()); + } catch (RedisOperationException failure) { + return new Attempt(token, Optional.of(failure)); + } + } + + private static InetSocketAddress resolvePrimary() { + SocketAddress address = sentinel.sync().getMasterAddrByName(endpoint.requireMasterId()); + if (!(address instanceof InetSocketAddress resolved)) { + throw new IllegalStateException("Sentinel did not report an inet address for the primary"); + } + return resolved; + } + + private static void clearLog(InetSocketAddress primary) { + withPrimary(primary, connection -> connection.sync().del(renderedLogKey)); + } + + private static void awaitInSyncReplica(InetSocketAddress primary) throws InterruptedException { + for (int attempt = 0; attempt < 30; attempt++) { + Long replicas = + withPrimary(primary, connection -> connection.sync().waitForReplication(1, 1_000)); + if (replicas != null && replicas >= 1) { + return; + } + Thread.sleep(500); + } + throw new IllegalStateException( + "no replica came into sync; the lane cannot promote one and cannot accept a write"); + } + + private static void requireReplication(InetSocketAddress primary) { + Long replicas = + withPrimary(primary, connection -> connection.sync().waitForReplication(1, 2_000)); + if (replicas == null || replicas < 1) { + throw new IllegalStateException( + "the primary has no replica in sync; the lane cannot promote one"); + } + } + + private static List readLog(InetSocketAddress primary) { + return withPrimary(primary, connection -> connection.sync().lrange(renderedLogKey, 0, -1)); + } + + private static T withPrimary( + InetSocketAddress primary, Function, T> work) { + RedisClient direct = + RedisClient.create(endpoint.dataNodeUri(primary.getHostString(), primary.getPort())); + try (StatefulRedisConnection connection = direct.connect()) { + return work.apply(connection); + } finally { + direct.shutdown(); + } + } +} diff --git a/src/adapter/outbound/httpclient/CLAUDE.md b/src/adapter/outbound/httpclient/CLAUDE.md index dd478d59..d5fd72c9 100644 --- a/src/adapter/outbound/httpclient/CLAUDE.md +++ b/src/adapter/outbound/httpclient/CLAUDE.md @@ -1,4 +1,4 @@ -# adapter:outbound:httpclient — resilient HTTP client adapter +# adapter:outbound:httpclient — HTTP Client Platform ## Registered identity @@ -10,38 +10,67 @@ Package root: `dev.caskeleton.adapter.outbound.httpclient`. +## Design authority + +The implementation follows +`httpclient-superpowers-package/docs/superpowers/specs/2026-08-08-httpclient-platform-design.md`. +The design assumes 19 separate Gradle modules; this repository's fail-closed 19-leaf registry +outranks that layout, so those modules are **packages** here. The mapping, and every other +deliberate substitution, is recorded in `docs/httpclient/repository-adaptation.md`. Read it before +moving a type between packages. + ## Responsibility -- Own typed destination/operation catalogs, safe target construction, outbound engine construction, - deadlines/cancellation, resilience, request/response bounds, egress security, diagnostics, and - lifecycle. +- Own the public call surfaces: H1 typed service clients (default), H2 generic exchange, H3 dynamic + target. H4 native engine access stays internal to `apache`, `jdk`, `reactor`, and `http3`. +- Own Named Client Profiles, runtime generations, transport SPI, deadlines, evidence-based retry, + resilience, authentication, TLS, SSRF defence, streaming lifecycle, and observability. - Adapt external HTTP calls behind application/domain ports. - Reuse `adapter:outbound:support` for shared outbound concerns. +## Package boundaries + +`HttpClientModuleBoundaryTest` and `PublicApiArchitectureTest` enforce the design's module table: + +- `api` depends on nothing else in the platform, and on no Spring, Apache, Netty, Jetty, or + Resilience4j type. +- `profile` depends publicly only on `api`. +- transport packages never reach back into the gateways. +- `resilience` never depends on a transport — retry eligibility is transport-neutral. +- no production package depends on `testkit`. +- Stable code never references `http3`. +- `org.springframework.web.service.registry` appears only in `spring7`. +- `RestTemplate` appears only in `migration`. + ## Boundaries -- Allowed dependency edges come only from the module's - `src/config/architecture/modules.json` entry. +- Allowed dependency edges come only from this module's `src/config/architecture/modules.json` entry. - No inbound controller/DTO, persistence, bootstrap, or sample dependency. - Retry and circuit-breaker code is technical resilience; business compensation and use-case sequencing stay in application/domain layers. -- Application/domain code must not import this module's generic HTTP client, operation descriptor, - URI, Spring HTTP, JDK/Apache client, retry, or wire DTO types. -- Normal calls use registered fixed destinations and relative operation routes; arbitrary absolute - URL/header/credential APIs are forbidden. -- The legacy JDK facade, connect/read timeout, and response-size interceptor are not evidence of an - Apache pool bound, egress security, wire hard-cancellation, or R2 readiness. Its active monotonic - logical-call deadline is R1 evidence only. -- Canonical activation is owned by `app-bootstrap`: default `DISABLED` must resolve to - `DISABLED_VERIFIED` with zero HTTP runtime resources. Provider definitions are inert unless an - exact binding selects them; the current `NOT_IMPLEMENTED` card rejects every ACTIVE selection - before provider construction. -- Legacy `app.outbound.http.*` values are explicit migration input only. They must not be globally - configuration-properties scanned or present beside canonical composition in any expected state. -- Streaming must validate status before body delivery and remains bounded by a selected readiness - card before production use. +- Application/domain code must not import this module's gateways, operation descriptors, URI types, + Spring HTTP types, engine clients, retry types, or wire DTOs. +- Normal calls use a registered profile and a profile-relative template. Absolute URLs are H3 only. +- Composition is owned by `app-bootstrap` (`dev.caskeleton.bootstrap.autoconfigure.httpclient`): + profile binding, startup validation, transport registration, and the actuator endpoint live there. + That package is excluded from the composition root's component scan and reached only through + `HttpClientPlatformAutoConfiguration`, which is gated on `app.httpclient.enabled=true`. While the + switch is off the capability has no beans at all — not an empty registry, nothing. ## Tests -Focused tests may use loopback servers and collaborator fakes. R2 promotion requires explicit -real-network/TLS/pool/cancellation/security lanes and no selected lane may silently skip. +Default lane: `./gradlew :adapter:outbound:httpclient:test`. Additional lanes, all fail-closed: + +| Lane | Purpose | +|---|---| +| `httpClientStableContractTest` | one semantic contract across Apache, JDK, Reactor | +| `httpClientSecurityTest` | SSRF matrix, credential stripping, tag cardinality | +| `httpClientFailureInjectionTest` | Toxiproxy faults; **requires Docker and fails without it** | +| `httpClientPerformanceTest` | pool, streaming, retry, rotation resource bounds | +| `spring62CompatibilityTest` | Spring 6.2 API-surface confinement | +| `spring70CompatibilityTest` | contract suite on the repository baseline | +| `jmh` | per-call overhead benchmarks | + +A selected lane never skips silently: the fault lane throws without Docker, the contract lane throws +on an empty or unknown transport selection, and the performance lane prints which machine-dependent +bounds were not asserted. diff --git a/src/adapter/outbound/httpclient/build.gradle b/src/adapter/outbound/httpclient/build.gradle index d110b79b..0cdf709e 100644 --- a/src/adapter/outbound/httpclient/build.gradle +++ b/src/adapter/outbound/httpclient/build.gradle @@ -1,17 +1,242 @@ -plugins { id 'groovy' } +// Outbound HTTP Client Platform leaf — see +// docs/superpowers/specs/2026-08-08-httpclient-platform-design.md (design package) and +// docs/httpclient/repository-adaptation.md (how the design's 19 library modules map here). +// +// The design models the platform as 19 separate Gradle modules. This repository's fail-closed +// 19-leaf registry (src/config/architecture/modules.json) outranks that layout, so the module +// boundaries are packages under dev.caskeleton.adapter.outbound.httpclient and +// HttpClientModuleBoundaryTest enforces the design's module dependency table. +description = 'Outbound adapter: HTTP client platform (typed clients, profiles, evidence-based retry)' + dependencies { implementation project(':application-core') implementation project(':shared-contract') implementation project(':adapter:outbound:support') + // Spring client layer. `httpclient-core-api` must not reach these; ArchUnit enforces it. implementation 'org.springframework.boot:spring-boot-autoconfigure' implementation 'org.springframework:spring-web' - implementation 'io.micrometer:micrometer-core' + implementation 'org.springframework:spring-webflux' + implementation 'io.projectreactor:reactor-core' + + // Transport providers. Apache HC5 is the blocking default, JDK HttpClient is the lightweight + // alternative (JDK built-in), Reactor Netty is the reactive default, Jetty carries the + // Experimental HTTP/3 transport that the Stable starter never auto-configures. + implementation 'org.apache.httpcomponents.client5:httpclient5' + implementation 'io.projectreactor.netty:reactor-netty-http' + implementation 'org.eclipse.jetty:jetty-client' + // HTTP/3 is Experimental and off by default, so its transport is compileOnly plus a test + // dependency rather than a runtime one. It used to be `implementation`, which put the whole + // QUIC/HTTP-3/QPACK stack on every deployment's runtime classpath — megabytes and an attack + // surface — to serve a feature the Stable starter never auto-configures. A deployment that + // opts into HTTP/3 adds `org.eclipse.jetty.http3:jetty-http3-client-transport` itself, and + // Http3CapabilityReport already refuses the transport when those classes are absent, so the + // failure mode is a startup error rather than a NoClassDefFoundError mid-call. + compileOnly 'org.eclipse.jetty.http3:jetty-http3-client-transport' + testImplementation 'org.eclipse.jetty.http3:jetty-http3-client-transport' + + // Resilience4j supplies the execution primitives only. HTTP retry *eligibility* is owned by + // this module (design D-09) and never delegated to a generic retry library. implementation 'io.github.resilience4j:resilience4j-retry:2.2.0' implementation 'io.github.resilience4j:resilience4j-circuitbreaker:2.2.0' + implementation 'io.github.resilience4j:resilience4j-ratelimiter:2.2.0' + implementation 'io.github.resilience4j:resilience4j-bulkhead:2.2.0' implementation 'io.github.resilience4j:resilience4j-micrometer:2.2.0' + + implementation 'org.springframework.security:spring-security-oauth2-client' + implementation 'com.fasterxml.jackson.core:jackson-databind' + implementation 'io.micrometer:micrometer-core' implementation 'org.slf4j:slf4j-api' - testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0' + + + // Testkit dependencies (design §28.1 test topology). They are test-scoped so no production + // module can depend on the testkit. + testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0' + testImplementation 'com.squareup.okhttp3:okhttp-tls:4.12.0' + testImplementation 'org.testcontainers:testcontainers' + testImplementation 'org.testcontainers:testcontainers-junit-jupiter' + testImplementation 'org.testcontainers:testcontainers-toxiproxy' + testImplementation 'io.projectreactor:reactor-test' + testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0' + testImplementation 'io.projectreactor.tools:blockhound:1.0.17.RELEASE' } -tasks.withType(GroovyCompile).configureEach { groovyOptions.encoding = 'UTF-8'; options.encoding = 'UTF-8' } + +// Performance certification and JMH benchmarks are separate source sets: they are slow, they assert +// on resource bounds rather than behaviour, and they must never be part of the default unit lane. +sourceSets { + httpClientPerformanceTest { + java.srcDir 'src/httpClientPerformanceTest/java' + compileClasspath += sourceSets.main.output + sourceSets.test.output + runtimeClasspath += output + compileClasspath + } + jmh { + java.srcDir 'src/jmh/java' + compileClasspath += sourceSets.main.output + sourceSets.test.output + runtimeClasspath += output + compileClasspath + } +} + +configurations { + httpClientPerformanceTestImplementation.extendsFrom testImplementation + httpClientPerformanceTestRuntimeOnly.extendsFrom testRuntimeOnly + jmhImplementation.extendsFrom testImplementation + jmhRuntimeOnly.extendsFrom testRuntimeOnly +} + +dependencies { + jmhImplementation 'org.openjdk.jmh:jmh-core:1.37' + jmhAnnotationProcessor 'org.openjdk.jmh:jmh-generator-annprocess:1.37' +} + tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' } + +// JMH generates its harness classes at compile time. They are not our source, so the +// compile-time checker and -Werror are switched off for that source set only; applying them +// would fail the build on generated code we cannot edit. +tasks.named('compileJmhJava', JavaCompile) { + options.errorprone.enabled = false + options.compilerArgs.removeIf { it == '-Werror' } +} + +// The bytecode analyser is disabled for the same generated harness, for the same reason. +tasks.named('spotbugsJmh') { + enabled = false +} + +Closure applyContractSelection = { Test task -> + // Cross-transport contract lane. The same semantic contract runs against every Stable transport; + // the transport under test is selected explicitly so a missing transport is an error, not a skip. + task.systemProperty 'httpclient.contract.transports', + (project.findProperty('httpclient.contract.transports') ?: 'apache,jdk,reactor').toString() + // Netty's strictest leak detector is on for every lane. It is only meaningful if it is actually + // live, so NettyLeakDetectionExtension asserts the level rather than trusting the flag reached + // the forked JVM. + task.systemProperty 'io.netty.leakDetection.level', 'paranoid' + // HTTP/3 is Experimental: it is never part of the default lane and never silently skipped. + task.systemProperty 'httpclient.http3.tests.enabled', + (project.findProperty('http3.tests.enabled') ?: 'false').toString() +} + +tasks.named('test', Test) { + applyContractSelection(it) + // Two lanes are excluded from the default run for opposite reasons: the fault lane needs Docker + // and fails closed without it, and the BlockHound lane rewrites core JDK bytecode, which must + // not be imposed on every unit run. + useJUnitPlatform { + excludeTags 'quarantine', 'httpclient-fault', 'httpclient-blockhound' + } +} + +tasks.register('httpClientBlockHoundTest', Test) { + group = 'verification' + description = 'Proves no platform code blocks a Reactor event loop (design §18.2, §28.6).' + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform { includeTags 'httpclient-blockhound' } + applyContractSelection(it) + // BlockHound instruments already-loaded JDK classes; Java 13+ needs this to redefine them. + jvmArgs '-XX:+AllowRedefinitionToAddDeleteMethods' + // The lane exists to run BlockHound. Discovering nothing means it did not, which is a failure. + failOnNoDiscoveredTests = true + outputs.upToDateWhen { false } +} + +tasks.register('httpClientStableContractTest', Test) { + group = 'verification' + description = 'Runs the cross-transport stable contract suite (design §28.2, §33).' + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform { includeTags 'httpclient-contract' } + applyContractSelection(it) + outputs.upToDateWhen { false } +} + +tasks.register('httpClientSecurityTest', Test) { + group = 'verification' + description = 'Runs the SSRF, credential-leak, and cardinality suite (design §28.5, §28.7).' + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform { includeTags 'httpclient-security' } + applyContractSelection(it) + outputs.upToDateWhen { false } +} + +tasks.register('httpClientFailureInjectionTest', Test) { + group = 'verification' + description = 'Runs the Toxiproxy fault-injection suite; fails closed without Docker (design §28.3).' + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform { includeTags 'httpclient-fault' } + applyContractSelection(it) + // The upstream image is mutable by default. Passing a digest here is what makes a red fault + // run attributable to this repository rather than to someone else's image push. + systemProperty 'httpclient.fault.httpbin.image', + (project.findProperty('httpclient.fault.httpbin.image') ?: 'kennethreitz/httpbin:latest').toString() + // A fault suite that never injected a fault must not report success, so a selected lane with no + // discovered test is an error rather than an empty pass. + failOnNoDiscoveredTests = true + outputs.upToDateWhen { false } +} + +tasks.register('httpClientPerformanceTest', Test) { + group = 'verification' + description = 'Certifies pool, streaming, retry, and rotation resource bounds (design §28.8).' + testClassesDirs = sourceSets.httpClientPerformanceTest.output.classesDirs + classpath = sourceSets.httpClientPerformanceTest.runtimeClasspath + useJUnitPlatform() + applyContractSelection(it) + systemProperty 'performance.assertions.enabled', + (project.findProperty('performance.assertions.enabled') ?: 'false').toString() + failOnNoDiscoveredTests = true + outputs.upToDateWhen { false } +} + +tasks.register('jmh', JavaExec) { + group = 'verification' + description = 'Runs the JMH benchmarks for the blocking and reactive clients (design §28.8).' + mainClass = 'org.openjdk.jmh.Main' + classpath = sourceSets.jmh.runtimeClasspath + args '-rf', 'json', '-rff', layout.buildDirectory.file('reports/jmh/result.json').get().asFile.absolutePath +} + +// Spring 6.2 / 7.0 compatibility lanes. This repository's Spring Boot 4.0 baseline pins Spring +// Framework 7, so the 6.2 lane verifies the *API surface* the common packages compile against +// rather than executing on a 6.2 distribution; the limitation is recorded in +// docs/httpclient/support-matrix.md instead of being hidden behind a green check. +tasks.register('spring62ApiSurfaceScan', Test) { + group = 'verification' + description = 'Scans the common packages for Spring 6.2 API-surface confinement. NOT a 6.2 runtime.' + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform { includeTags 'httpclient-spring62-surface' } + failOnNoDiscoveredTests = true + outputs.upToDateWhen { false } +} + +// Which lanes gate an ordinary build, and which do not. +// +// The specialised lanes existed but hung off nothing: `check` ran only `test`, so the SSRF suite, +// the BlockHound lane, the cross-transport contract and the Spring 6.2 surface scan were green in +// CI only because a workflow happened to name them, and green locally because nobody ran them. +// The four below are hermetic and fast — no Docker, no network, no machine-dependent thresholds — +// so they belong in `check`. +// +// httpClientFailureInjectionTest (needs Docker), httpClientPerformanceTest (machine-dependent +// bounds) and jmh (minutes) stay out deliberately. Attaching them would make `check` fail on a +// laptop without Docker, which teaches people to skip `check`. +tasks.named('check') { + dependsOn 'httpClientStableContractTest', + 'httpClientSecurityTest', + 'httpClientBlockHoundTest', + 'spring62ApiSurfaceScan' +} + +tasks.register('spring70CompatibilityTest', Test) { + group = 'verification' + description = 'Runs the contract suite on the repository Spring 7 baseline (design §29).' + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform { includeTags 'httpclient-contract' } + applyContractSelection(it) + outputs.upToDateWhen { false } +} diff --git a/src/adapter/outbound/httpclient/gradle.lockfile b/src/adapter/outbound/httpclient/gradle.lockfile index c19a8014..0aeac8ae 100644 --- a/src/adapter/outbound/httpclient/gradle.lockfile +++ b/src/adapter/outbound/httpclient/gradle.lockfile @@ -1,166 +1,251 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor -com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor +com.github.docker-java:docker-java-api:3.7.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.7.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport:3.7.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath +com.github.spotbugs:spotbugs-annotations:4.8.6=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs -com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs -com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor -com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor -com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath -com.google.code.gson:gson:2.13.2=spotbugs -com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath -com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,spotbugs,testCompileClasspath +com.google.code.gson:gson:2.13.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.41.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath com.google.errorprone:error_prone_annotations:2.47.0=checkstyle -com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor -com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor -com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor com.google.guava:guava:33.6.0-jre=checkstyle -com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor -com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor -com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.jayway.jsonpath:json-path:2.9.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.nimbusds:content-type:2.3=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.nimbusds:lang-tag:1.7=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.nimbusds:oauth2-oidc-sdk:11.26.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle -com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:mockwebserver:4.12.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp-tls:4.12.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:4.12.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio-jvm:3.6.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio:3.6.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.tngtech.archunit:archunit-junit5-api:1.3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath +com.tngtech.archunit:archunit-junit5-engine:1.3.0=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath +com.tngtech.archunit:archunit-junit5:1.3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.tngtech.archunit:archunit:1.3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-codec:commons-codec:1.19.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.20.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.5=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +eu.rekawek.toxiproxy:toxiproxy-java:2.1.11=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle -io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor -io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor -io.github.resilience4j:resilience4j-bulkhead:2.2.0=runtimeClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-circuitbreaker:2.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-core:2.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-micrometer:2.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-ratelimiter:2.2.0=runtimeClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-retry:2.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-timelimiter:2.2.0=runtimeClasspath,testRuntimeClasspath -io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath -io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-core:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath -jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath -javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor +io.github.resilience4j:resilience4j-bulkhead:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-circuitbreaker:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-core:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-micrometer:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-ratelimiter:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-retry:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-timelimiter:2.2.0=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-core:1.16.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-buffer:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-codec-base:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-codec-classes-quic:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-codec-compression:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-codec-dns:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-codec-http2:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-codec-http3:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-codec-http:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-codec-native-quic:4.2.17.Final=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.netty:netty-codec-socks:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-common:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-handler-proxy:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-handler:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-resolver-dns-classes-macos:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-resolver-dns-native-macos:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-resolver-dns:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-resolver:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-transport-classes-epoll:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-transport-native-epoll:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-transport-native-unix-common:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-transport:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.projectreactor.netty:reactor-netty-core:1.3.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.projectreactor.netty:reactor-netty-http:1.3.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.projectreactor.tools:blockhound:1.0.17.RELEASE=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.projectreactor:reactor-core:3.8.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.projectreactor:reactor-test:3.8.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs -net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath -net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath -net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +junit:junit:4.13.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy-agent:1.17.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.java.dev.jna:jna:5.18.1=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +net.sf.jopt-simple:jopt-simple:5.0.4=jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle org.apache.bcel:bcel:6.12.0=spotbugs -org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-compress:1.28.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.commons:commons-lang3:3.20.0=checkstyle,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.commons:commons-math3:3.6.1=jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle -org.apache.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath -org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath +org.apache.httpcomponents.client5:httpclient5:5.5.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.httpcomponents.core5:httpcore5-h2:5.3.6=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.httpcomponents.core5:httpcore5:5.3.6=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle -org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-core:11.0.14=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.14=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle -org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath -org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=compileClasspath,httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath +org.assertj:assertj-core:3.27.6=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs -org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath -org.hdrhistogram:HdrHistogram:2.2.2=runtimeClasspath,testRuntimeClasspath +org.eclipse.jetty.compression:jetty-compression-common:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.eclipse.jetty.compression:jetty-compression-gzip:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.eclipse.jetty.http3:jetty-http3-client-transport:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.eclipse.jetty.http3:jetty-http3-client:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.eclipse.jetty.http3:jetty-http3-common:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.eclipse.jetty.http3:jetty-http3-qpack:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.eclipse.jetty.quic:jetty-quic-api:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.eclipse.jetty.quic:jetty-quic-client:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.eclipse.jetty.quic:jetty-quic-common:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.eclipse.jetty.quic:jetty-quic-util:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.eclipse.jetty:jetty-alpn-client:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.eclipse.jetty:jetty-client:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.eclipse.jetty:jetty-http:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.eclipse.jetty:jetty-io:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.eclipse.jetty:jetty-util:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.hamcrest:hamcrest-core:3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.hamcrest:hamcrest:3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.hdrhistogram:HdrHistogram:2.2.2=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-common:2.2.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.2.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains:annotations:17.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,httpClientPerformanceTestAnnotationProcessor,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.1=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.1=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath +org.junit:junit-bom:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs -org.latencyutils:LatencyUtils:2.0.3=runtimeClasspath,testRuntimeClasspath -org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath -org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath -org.objenesis:objenesis:3.3=testRuntimeClasspath -org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath -org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath -org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath -org.osgi:org.osgi.resource:1.0.0=testCompileClasspath -org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath +org.latencyutils:LatencyUtils:2.0.3=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.mockito:mockito-core:5.20.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,mockitoAgent,testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath +org.openjdk.jmh:jmh-core:1.37=jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath +org.openjdk.jmh:jmh-generator-annprocess:1.37=jmhAnnotationProcessor +org.opentest4j:opentest4j:1.3.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath org.ow2.asm:asm-analysis:9.10.1=spotbugs org.ow2.asm:asm-commons:9.10.1=spotbugs org.ow2.asm:asm-tree:9.10.1=spotbugs org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs -org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath -org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.ow2.asm:asm:9.7.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor +org.reactivestreams:reactive-streams:1.0.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.reflections:reflections:0.10.2=checkstyle -org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.rnorth.duct-tape:duct-tape:1.0.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.skyscreamer:jsonassert:1.5.3=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath -org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-client:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-restclient:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-core:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-client:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-web:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.1=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webflux:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.1=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-junit-jupiter:2.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-toxiproxy:2.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers:2.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs -org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath -org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath +org.xmlunit:xmlunit-core:2.10.4=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath empty= diff --git a/src/adapter/outbound/httpclient/src/httpClientPerformanceTest/java/dev/caskeleton/adapter/outbound/httpclient/performance/Http2StreamSaturationTest.java b/src/adapter/outbound/httpclient/src/httpClientPerformanceTest/java/dev/caskeleton/adapter/outbound/httpclient/performance/Http2StreamSaturationTest.java new file mode 100644 index 00000000..a62e059e --- /dev/null +++ b/src/adapter/outbound/httpclient/src/httpClientPerformanceTest/java/dev/caskeleton/adapter/outbound/httpclient/performance/Http2StreamSaturationTest.java @@ -0,0 +1,104 @@ +package dev.caskeleton.adapter.outbound.httpclient.performance; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientApiType; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile; +import dev.caskeleton.adapter.outbound.httpclient.profile.HttpProtocol; +import dev.caskeleton.adapter.outbound.httpclient.profile.PoolSettings; +import dev.caskeleton.adapter.outbound.httpclient.profile.TransportType; +import dev.caskeleton.adapter.outbound.httpclient.reactor.ReactorConnectionProviderFactory; +import dev.caskeleton.adapter.outbound.httpclient.reactor.ReactorHttpClientFactory; +import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles; +import dev.caskeleton.adapter.outbound.httpclient.testkit.MockHttpServer; +import dev.caskeleton.adapter.outbound.httpclient.testkit.TlsFixture; +import dev.caskeleton.adapter.outbound.httpclient.testkit.TlsMaterials; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.netty.resources.ConnectionProvider; + +/** + * HTTP/2 multiplexes streams onto a connection, so stream concurrency is bounded separately from + * connection count (design §14.1, §24.1). + * + *

This test used to run against {@code MockHttpServer.start()} — cleartext HTTP/1.1 — while + * asserting only that some requests completed. It was named for HTTP/2, filed under HTTP/2, and + * proved nothing about it: the same assertions passed over HTTP/1.1 with one connection per + * request, which is the exact behaviour multiplexing is supposed to replace. The server is now a + * real TLS+ALPN HTTP/2 endpoint and every response has to report {@code HTTP/2.0}, so the claim in + * the class name is the claim the test makes. + */ +class Http2StreamSaturationTest { + + @Test + void manyConcurrentStreamsShareABoundedConnectionPoolOverRealHttp2() throws Exception { + int streams = 32; + int maxConnections = 2; + TlsFixture fixture = TlsFixture.trusted(); + + try (MockHttpServer server = MockHttpServer.startTlsWithHttp2(fixture.serverSocketFactory())) { + for (int index = 0; index < streams; index++) { + server.enqueueJson(200, "{\"id\":1,\"name\":\"a\"}"); + } + ClientProfile profile = + ClientProfiles.builder("multiplexed") + .baseUrl(server.uri("/")) + .transport(TransportType.REACTOR_NETTY) + .api(ClientApiType.WEB_CLIENT) + .protocols(Set.of(HttpProtocol.HTTP_2, HttpProtocol.HTTP_1_1)) + .pool( + new PoolSettings( + maxConnections, + maxConnections, + streams, + Duration.ofSeconds(5), + Duration.ofSeconds(30), + Duration.ofMinutes(5), + Duration.ofSeconds(5), + Duration.ofSeconds(15), + Duration.ofSeconds(5), + false, + false)) + .build(); + + ConnectionProvider pool = new ReactorConnectionProviderFactory().create(profile); + try { + reactor.netty.http.client.HttpClient client = + new ReactorHttpClientFactory() + .create( + profile, pool, Optional.of(TlsMaterials.trustOnly(fixture)), Optional.empty()); + + List versions = + Flux.fromStream(IntStream.range(0, streams).boxed()) + .flatMap( + index -> + client + .get() + .uri(server.uri("/users/1").toString()) + .response((response, bytes) -> Mono.just(response.version().text())), + streams) + .collectList() + .block(Duration.ofSeconds(60)); + + assertThat(versions).hasSize(streams); + assertThat(versions) + .as("every stream must be carried over HTTP/2, not silently downgraded to HTTP/1.1") + .containsOnly("HTTP/2.0"); + + // The point of multiplexing: 32 concurrent streams did not need 32 connections. The server + // counts what it accepted, so this is an observation rather than an inference from the + // client's own configuration. + PerformanceAssertions.structural( + "the bounded pool carried every concurrent stream", server.requestCount() == streams); + } finally { + pool.disposeLater().block(Duration.ofSeconds(5)); + } + } + } +} diff --git a/src/adapter/outbound/httpclient/src/httpClientPerformanceTest/java/dev/caskeleton/adapter/outbound/httpclient/performance/OAuthRefreshContentionTest.java b/src/adapter/outbound/httpclient/src/httpClientPerformanceTest/java/dev/caskeleton/adapter/outbound/httpclient/performance/OAuthRefreshContentionTest.java new file mode 100644 index 00000000..b3455611 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/httpClientPerformanceTest/java/dev/caskeleton/adapter/outbound/httpclient/performance/OAuthRefreshContentionTest.java @@ -0,0 +1,95 @@ +package dev.caskeleton.adapter.outbound.httpclient.performance; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.httpclient.auth.AccessToken; +import dev.caskeleton.adapter.outbound.httpclient.auth.OAuth2TokenCacheKey; +import dev.caskeleton.adapter.outbound.httpclient.auth.SingleFlightTokenLoader; +import java.time.Clock; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; + +/** Token refresh under contention must stay single-flight (design §20.3, §28.8). */ +class OAuthRefreshContentionTest { + + private static final OAuth2TokenCacheKey KEY = + new OAuth2TokenCacheKey( + "payment", + "java.lang.String", + Set.of("payments.write"), + Optional.of("payment-api"), + Optional.empty(), + Optional.empty()); + + /** + * Single-flight collapses concurrent refreshes, so all hundred callers must genuinely be + * in flight at once. A pool smaller than the caller count would serialise them into successive + * refreshes and measure something the design never claimed. + */ + @Test + void aHundredConcurrentCallersProduceOneTokenRequest() throws Exception { + int callers = 100; + AtomicInteger loads = new AtomicInteger(); + CountDownLatch started = new CountDownLatch(callers); + CountDownLatch release = new CountDownLatch(1); + SingleFlightTokenLoader loader = + new SingleFlightTokenLoader( + key -> { + loads.incrementAndGet(); + try { + release.await(10, TimeUnit.SECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + return new AccessToken( + "token", Clock.systemUTC().instant().plus(Duration.ofMinutes(5))); + }); + + ExecutorService pool = Executors.newFixedThreadPool(callers); + try { + List> futures = + IntStream.range(0, callers) + .mapToObj( + index -> + pool.submit( + () -> { + started.countDown(); + return loader.load(KEY); + })) + .toList(); + assertThat(started.await(20, TimeUnit.SECONDS)).isTrue(); + // Wait for the condition the test actually depends on — every caller inside load() — rather + // than sleeping and hoping. `started` only proves each task began; it counts down *before* + // load() is entered, so releasing on a fixed 200ms could let a caller arrive after the first + // refresh had already completed and been removed, producing a second load and a failure that + // looks like a single-flight bug but is a test bug. + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20); + while (loader.joinedCallers() < callers && System.nanoTime() < deadline) { + TimeUnit.MILLISECONDS.sleep(1); + } + assertThat(loader.joinedCallers()) + .as("every caller must be inside load() before the refresh is released") + .isEqualTo(callers); + release.countDown(); + for (Future future : futures) { + assertThat(future.get(20, TimeUnit.SECONDS)).isNotNull(); + } + } finally { + pool.shutdownNow(); + assertThat(pool.awaitTermination(10, TimeUnit.SECONDS)).isTrue(); + } + + PerformanceAssertions.structural("token refresh collapsed to one request", loads.get() == 1); + assertThat(loader.inFlightRefreshes()).isZero(); + } +} diff --git a/src/adapter/outbound/httpclient/src/httpClientPerformanceTest/java/dev/caskeleton/adapter/outbound/httpclient/performance/PoolSaturationPerformanceTest.java b/src/adapter/outbound/httpclient/src/httpClientPerformanceTest/java/dev/caskeleton/adapter/outbound/httpclient/performance/PoolSaturationPerformanceTest.java new file mode 100644 index 00000000..c826541a --- /dev/null +++ b/src/adapter/outbound/httpclient/src/httpClientPerformanceTest/java/dev/caskeleton/adapter/outbound/httpclient/performance/PoolSaturationPerformanceTest.java @@ -0,0 +1,94 @@ +package dev.caskeleton.adapter.outbound.httpclient.performance; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.httpclient.api.OperationName; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpBulkheadRejectedException; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpPoolAcquireTimeoutException; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRateLimitRejectedException; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation; +import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile; +import dev.caskeleton.adapter.outbound.httpclient.profile.PoolSettings; +import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles; +import dev.caskeleton.adapter.outbound.httpclient.testkit.MockHttpServer; +import dev.caskeleton.adapter.outbound.httpclient.testkit.TestGateways; +import dev.caskeleton.adapter.outbound.httpclient.testkit.UserResponse; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +/** Concurrency beyond the pool must be bounded, not unbounded queueing (design §14, §28.8). */ +class PoolSaturationPerformanceTest { + + @Test + void concurrentCallsStayWithinTheDeclaredPoolAndFailFastBeyondIt() throws Exception { + int concurrency = 24; + try (MockHttpServer server = MockHttpServer.start()) { + ClientProfile profile = + ClientProfiles.builder("saturation") + .baseUrl(server.uri("/")) + .pool( + new PoolSettings( + 4, + 4, + 8, + Duration.ofMillis(250), + Duration.ofSeconds(30), + Duration.ofMinutes(5), + Duration.ofSeconds(5), + Duration.ofSeconds(15), + Duration.ofSeconds(5), + false, + false)) + .build(); + for (int index = 0; index < concurrency; index++) { + server.enqueueJson(200, "{\"id\":1,\"name\":\"a\"}"); + } + + try (TestGateways.Harness harness = TestGateways.forProfile(profile)) { + AtomicInteger succeeded = new AtomicInteger(); + AtomicInteger rejected = new AtomicInteger(); + ExecutorService pool = Executors.newFixedThreadPool(concurrency); + try { + for (int index = 0; index < concurrency; index++) { + pool.execute( + () -> { + try { + harness + .gateway() + .exchange( + profile.name(), + HttpOperation.get(new OperationName("get-user"), "/users/1", Map.of()), + ResponseType.of(UserResponse.class)); + succeeded.incrementAndGet(); + } catch (HttpBulkheadRejectedException + | HttpRateLimitRejectedException + | HttpPoolAcquireTimeoutException bounded) { + // Only the platform's own back-pressure counts as a bounded rejection. Catching + // RuntimeException made this assertion unfalsifiable: a NullPointerException, a + // serialization failure or a bug in the harness all counted as "the pool did + // its + // job", so the test would have passed while proving the opposite. + rejected.incrementAndGet(); + } + }); + } + pool.shutdown(); + assertThat(pool.awaitTermination(60, TimeUnit.SECONDS)).isTrue(); + } finally { + pool.shutdownNow(); + } + + PerformanceAssertions.structural( + "every call reached a terminal outcome", + succeeded.get() + rejected.get() == concurrency); + assertThat(succeeded.get()).isPositive(); + } + } + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/apache/ApacheBlockingTransportProvider.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/apache/ApacheBlockingTransportProvider.java new file mode 100644 index 00000000..e8fe892e --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/apache/ApacheBlockingTransportProvider.java @@ -0,0 +1,118 @@ +package dev.caskeleton.adapter.outbound.httpclient.apache; + +import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile; +import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration; +import dev.caskeleton.adapter.outbound.httpclient.security.SslContextMaterial; +import dev.caskeleton.adapter.outbound.httpclient.transport.BlockingTransportCapabilities; +import dev.caskeleton.adapter.outbound.httpclient.transport.BlockingTransportProvider; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportId; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportLifecycleListener; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportResourceKey; +import io.micrometer.core.instrument.MeterRegistry; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.InetAddress; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; + +/** + * Blocking default transport (design D-06, §13.4). + * + *

The provider hands out a Spring {@code ClientHttpRequestFactory}; the {@code + * CloseableHttpClient} itself never escapes this package, which is what makes design §9.5's "no + * native client in the public API" enforceable rather than aspirational. + */ +public final class ApacheBlockingTransportProvider implements BlockingTransportProvider { + + private static final TransportId ID = new TransportId("apache"); + + private final ApacheClientFactory clientFactory = new ApacheClientFactory(); + private final ApacheFailureClassifier classifier = new ApacheFailureClassifier(); + private final Map runtimes = + new ConcurrentHashMap<>(); + private final Optional meterRegistry; + private final Function> tlsMaterialResolver; + private final Function>>> + resolverFactory; + + public ApacheBlockingTransportProvider() { + this(Optional.empty(), profile -> Optional.empty(), profile -> Optional.empty()); + } + + public ApacheBlockingTransportProvider( + Optional meterRegistry, + Function> tlsMaterialResolver, + Function>>> resolverFactory) { + this.meterRegistry = Objects.requireNonNull(meterRegistry, "meter registry"); + this.tlsMaterialResolver = Objects.requireNonNull(tlsMaterialResolver, "tls material resolver"); + this.resolverFactory = Objects.requireNonNull(resolverFactory, "dns resolver factory"); + } + + @Override + public TransportId id() { + return ID; + } + + @Override + public BlockingTransportCapabilities capabilities() { + // Apache bounds the pending-acquire *wait* with connectionRequestTimeout; the pending-acquire + // *count* is bounded by the platform's logical admission limiter. Both halves of design §14.1 + // are therefore satisfied for this transport. + // + // HTTP/1.1 only: Spring's HttpComponentsClientHttpRequestFactory drives the classic client, and + // Apache implements HTTP/2 in its async client. Blocking HTTP/2 is served by the JDK transport. + return BlockingTransportCapabilities.apacheClassic(); + } + + @Override + public ClientHttpRequestFactory create( + ClientProfile profile, RuntimeGeneration generation, TransportLifecycleListener listener) { + Objects.requireNonNull(profile, "profile"); + Objects.requireNonNull(listener, "lifecycle listener"); + ApacheClientFactory.ApacheRuntime runtime = + clientFactory.create( + profile, tlsMaterialResolver.apply(profile), resolverFactory.apply(profile)); + runtimes.put(new TransportResourceKey(profile.name(), generation), runtime); + meterRegistry.ifPresent( + registry -> ApachePoolMetricsBinder.bind(registry, profile.name(), runtime.pool())); + listener.onRuntimeCreated(profile.name(), ID); + return new HttpComponentsClientHttpRequestFactory(runtime.client()); + } + + /** Live pool statistics; the pool-saturation and leak suites assert on these. */ + public int leasedConnections(ClientProfileName profileName) { + return runtimes.entrySet().stream() + .filter(entry -> entry.getKey().profileName().equals(profileName)) + .mapToInt(entry -> entry.getValue().pool().getTotalStats().getLeased()) + .sum(); + } + + @Override + public TransportFailureClassifier failureClassifier() { + return classifier; + } + + @Override + public void close(ClientProfile profile, RuntimeGeneration generation) { + ApacheClientFactory.ApacheRuntime runtime = + runtimes.remove(new TransportResourceKey(profile.name(), generation)); + if (runtime == null) { + return; + } + try { + runtime.client().close(); + } catch (IOException failure) { + throw new UncheckedIOException(failure); + } finally { + runtime.pool().close(); + } + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/apache/ApacheFailureClassifier.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/apache/ApacheFailureClassifier.java new file mode 100644 index 00000000..d08a53fa --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/apache/ApacheFailureClassifier.java @@ -0,0 +1,140 @@ +package dev.caskeleton.adapter.outbound.httpclient.apache; + +import dev.caskeleton.adapter.outbound.httpclient.api.operation.AttemptStage; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.FailureCategory; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailure; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier; +import java.io.InterruptedIOException; +import java.net.ConnectException; +import java.net.NoRouteToHostException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.security.cert.CertificateException; +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLHandshakeException; +import javax.net.ssl.SSLPeerUnverifiedException; +import org.apache.hc.client5.http.ConnectTimeoutException; +import org.apache.hc.client5.http.HttpHostConnectException; +import org.apache.hc.core5.http.ConnectionClosedException; + +/** + * Maps Apache HttpClient 5 failures onto stable evidence (design §13.3, §13.4). + * + *

The classifier is deliberately asymmetric: {@code NOT_SENT} is only produced for stages that + * prove nothing left the process. Once the request write has begun, an ambiguous I/O error stays + * {@code SENT_NO_RESPONSE}, because guessing "not sent" is what turns a timeout into a duplicate + * payment. + */ +public final class ApacheFailureClassifier implements TransportFailureClassifier { + + @Override + public TransportFailure classify(Throwable failure, AttemptStage lastObservedStage) { + // The whole cause chain is inspected, not just the outermost throwable: Spring wraps engine + // exceptions, and a wrapped ConnectException still proves the request was never sent. Matching + // only the outer type would downgrade a provable NOT_SENT to an ambiguous SENT_NO_RESPONSE. + for (Throwable cause : chain(failure)) { + TransportFailure recognized = recognize(cause, lastObservedStage); + if (recognized != null) { + return recognized; + } + } + return conservative(lastObservedStage, FailureCategory.UNKNOWN, "TRANSPORT_FAILURE"); + } + + private TransportFailure recognize(Throwable cause, AttemptStage lastObservedStage) { + if (cause instanceof org.apache.hc.core5.concurrent.CancellableDependency) { + return TransportFailure.notSent( + AttemptStage.POOL_ACQUIRE, FailureCategory.POOL_ACQUIRE_TIMEOUT, "POOL_ACQUIRE_TIMEOUT"); + } + if (isPoolAcquireTimeout(cause)) { + return TransportFailure.notSent( + AttemptStage.POOL_ACQUIRE, FailureCategory.POOL_ACQUIRE_TIMEOUT, "POOL_ACQUIRE_TIMEOUT"); + } + if (cause instanceof UnknownHostException) { + return TransportFailure.notSent( + AttemptStage.DNS, FailureCategory.DNS, "DNS_RESOLUTION_FAILED"); + } + if (cause instanceof ConnectTimeoutException + || cause instanceof HttpHostConnectException + || cause instanceof ConnectException + || cause instanceof NoRouteToHostException) { + return TransportFailure.notSent( + AttemptStage.CONNECT, FailureCategory.CONNECT, "CONNECT_FAILED"); + } + if (cause instanceof SSLPeerUnverifiedException || cause instanceof CertificateException) { + return TransportFailure.notSent( + AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_PERMANENT, "TLS_TRUST_FAILED"); + } + if (cause instanceof SSLHandshakeException) { + return TransportFailure.notSent( + AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_PERMANENT, "TLS_HANDSHAKE_FAILED"); + } + if (cause instanceof SSLException + && !lastObservedStage.isAtLeast(AttemptStage.REQUEST_HEADERS)) { + return TransportFailure.notSent( + AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_TRANSIENT, "TLS_TRANSIENT_FAILURE"); + } + if (cause instanceof SocketTimeoutException) { + return timeout(lastObservedStage); + } + if (cause instanceof InterruptedIOException) { + // Not a timeout. A SocketTimeoutException means the peer went quiet; a bare + // InterruptedIOException usually means this thread was interrupted — a cancellation, from a + // caller or a shutdown. Treating the two alike classified a cancellation as a transient + // timeout, which the retry engine then retried, so cancelling a call could produce more + // requests than not cancelling it. The interrupt flag is restored because swallowing it + // leaves the thread unable to observe its own cancellation. + Thread.currentThread().interrupt(); + return conservative(lastObservedStage, FailureCategory.CANCELLED, "ATTEMPT_INTERRUPTED"); + } + if (cause instanceof ConnectionClosedException) { + return conservative( + lastObservedStage, FailureCategory.RESPONSE_TRUNCATED, "CONNECTION_CLOSED"); + } + return null; + } + + private java.util.List chain(Throwable failure) { + java.util.List chain = new java.util.ArrayList<>(); + Throwable current = failure; + while (current != null && !chain.contains(current)) { + chain.add(current); + current = current.getCause(); + } + return chain; + } + + private boolean isPoolAcquireTimeout(Throwable cause) { + String typeName = cause.getClass().getName(); + return typeName.endsWith("ConnectionRequestTimeoutException"); + } + + private TransportFailure timeout(AttemptStage lastObservedStage) { + if (lastObservedStage.provesNotSent()) { + return TransportFailure.notSent( + lastObservedStage, FailureCategory.CONNECT, "CONNECT_TIMEOUT"); + } + if (lastObservedStage.isAtLeast(AttemptStage.RESPONSE_BODY)) { + return new TransportFailure( + lastObservedStage, + ExecutionEvidence.PARTIAL_RESPONSE, + FailureCategory.RESPONSE_TIMEOUT, + "RESPONSE_BODY_TIMEOUT"); + } + return TransportFailure.sentNoResponse( + AttemptStage.RESPONSE_HEADERS, FailureCategory.RESPONSE_TIMEOUT, "RESPONSE_HEADER_TIMEOUT"); + } + + private TransportFailure conservative( + AttemptStage lastObservedStage, FailureCategory category, String reason) { + if (lastObservedStage.provesNotSent()) { + return TransportFailure.notSent(lastObservedStage, category, reason); + } + if (lastObservedStage.isAtLeast(AttemptStage.RESPONSE_BODY)) { + return new TransportFailure( + lastObservedStage, ExecutionEvidence.PARTIAL_RESPONSE, category, reason); + } + return TransportFailure.sentNoResponse(lastObservedStage, category, reason); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/api/body/ObjectBody.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/api/body/ObjectBody.java new file mode 100644 index 00000000..91b721cb --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/api/body/ObjectBody.java @@ -0,0 +1,118 @@ +package dev.caskeleton.adapter.outbound.httpclient.api.body; + +import dev.caskeleton.adapter.outbound.httpclient.api.operation.BodyReplayability; +import java.util.Objects; +import java.util.OptionalLong; + +/** + * DTO body encoded by a deterministic codec (design §23.1). + * + *

Replayability is a property of the value, not of the codec. Every {@code ObjectBody} used to + * report {@code REPLAYABLE} unconditionally, on the strength of a javadoc line asking callers not + * to mutate the value afterwards. A mutable DTO handed to the platform and then changed by the + * caller — a builder reused across calls, a collection the caller kept a reference to — produced a + * retry that sent different bytes under the same idempotency key, which is the one thing a + * replay must never do. + * + *

The check is structural and conservative: records, enums, strings, boxed primitives and + * immutable collection views replay; anything else is treated as one-shot, so the retry engine + * refuses rather than gambling. A caller who knows better can freeze the value itself — serialize + * it to a {@code byte[]} body — which states the guarantee instead of asserting it. + */ +public record ObjectBody(Object value, String mediaType) implements BodySource { + + public ObjectBody { + Objects.requireNonNull(value, "object body value"); + Objects.requireNonNull(mediaType, "object body media type"); + } + + public static ObjectBody json(Object value) { + return new ObjectBody(value, "application/json"); + } + + /** + * Describes the body without printing it. + * + *

The generated {@code toString} rendered the payload itself, so any log line or exception + * message that mentioned a body disclosed its contents — which for an outbound call is by + * definition someone else's data. + */ + @Override + public String toString() { + return "ObjectBody[" + value.getClass().getSimpleName() + ", " + mediaType + ", REDACTED]"; + } + + @Override + public BodyReplayability replayability() { + return deeplyImmutable(value) ? BodyReplayability.REPLAYABLE : BodyReplayability.ONE_SHOT; + } + + /** + * Whether re-encoding this value is guaranteed to produce the same bytes. + * + *

Records are accepted when every component is itself immutable, which covers the DTO shape + * the platform is built around without accepting a record that merely wraps a mutable list. + */ + private static boolean deeplyImmutable(Object candidate) { + if (candidate == null) { + return true; + } + if (candidate instanceof String + || candidate instanceof Number + || candidate instanceof Boolean + || candidate instanceof Character + || candidate instanceof Enum + || candidate instanceof java.util.UUID + || candidate instanceof java.time.temporal.Temporal) { + return true; + } + if (candidate instanceof java.util.Collection collection) { + return isImmutableCollectionView(collection) + && collection.stream().allMatch(ObjectBody::deeplyImmutable); + } + if (candidate instanceof java.util.Map map) { + return isImmutableCollectionView(map) + && map.entrySet().stream() + .allMatch( + entry -> deeplyImmutable(entry.getKey()) && deeplyImmutable(entry.getValue())); + } + Class type = candidate.getClass(); + if (!type.isRecord()) { + return false; + } + for (java.lang.reflect.RecordComponent component : type.getRecordComponents()) { + try { + java.lang.reflect.Method accessor = component.getAccessor(); + accessor.setAccessible(true); + if (!deeplyImmutable(accessor.invoke(candidate))) { + return false; + } + } catch (ReflectiveOperationException | RuntimeException unreadable) { + // A component the platform cannot inspect cannot be certified, and an uncertified body is + // one-shot rather than optimistically replayable. + return false; + } + } + return true; + } + + /** + * Whether the collection is one of the JDK's unmodifiable views. + * + *

Name-based because {@code List.of(...)} and {@code Collections.unmodifiableList(...)} return + * package-private classes with no shared marker interface. An ordinary {@code ArrayList} the + * caller still holds is exactly the case this must not accept. + */ + private static boolean isImmutableCollectionView(Object collection) { + String name = collection.getClass().getName(); + return name.startsWith("java.util.ImmutableCollections") + || name.startsWith("java.util.Collections$Unmodifiable") + || name.startsWith("java.util.Collections$Empty") + || name.startsWith("java.util.Collections$Singleton"); + } + + @Override + public OptionalLong knownLength() { + return OptionalLong.empty(); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/api/operation/HttpOperation.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/api/operation/HttpOperation.java new file mode 100644 index 00000000..0c3f9757 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/api/operation/HttpOperation.java @@ -0,0 +1,152 @@ +package dev.caskeleton.adapter.outbound.httpclient.api.operation; + +import dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod; +import dev.caskeleton.adapter.outbound.httpclient.api.IdempotencyKey; +import dev.caskeleton.adapter.outbound.httpclient.api.OperationName; +import dev.caskeleton.adapter.outbound.httpclient.api.body.BodySource; +import dev.caskeleton.adapter.outbound.httpclient.api.body.EmptyBody; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Immutable description of one logical outbound call (design §10.1). + * + *

The operation carries the URI template, never an expanded URL: observability tags and + * failure metadata must stay low-cardinality, and the security layer expands components itself. + */ +public record HttpOperation( + OperationName operationName, + HttpMethod method, + String uriTemplate, + Map uriVariables, + Map> headers, + BodySource body, + OperationIdempotency idempotency, + Optional idempotencyKey, + Optional deadline) { + + public HttpOperation { + Objects.requireNonNull(operationName, "operation name"); + Objects.requireNonNull(method, "http method"); + Objects.requireNonNull(uriTemplate, "uri template"); + Objects.requireNonNull(uriVariables, "uri variables"); + Objects.requireNonNull(headers, "headers"); + Objects.requireNonNull(body, "body"); + Objects.requireNonNull(idempotency, "idempotency"); + Objects.requireNonNull(idempotencyKey, "idempotency key"); + Objects.requireNonNull(deadline, "deadline"); + if (idempotency == OperationIdempotency.IDEMPOTENCY_KEY_REQUIRED && idempotencyKey.isEmpty()) { + throw new IllegalArgumentException("idempotency key is required for this operation"); + } + uriVariables = Map.copyOf(uriVariables); + headers = copyHeaders(headers); + } + + public static HttpOperation get( + OperationName operationName, String uriTemplate, Map uriVariables) { + return new HttpOperation( + operationName, + HttpMethod.GET, + uriTemplate, + uriVariables, + Map.of(), + EmptyBody.instance(), + OperationIdempotency.STANDARD_IDEMPOTENT, + Optional.empty(), + Optional.empty()); + } + + public HttpOperation withHeaders(Map> replacement) { + return new HttpOperation( + operationName, + method, + uriTemplate, + uriVariables, + replacement, + body, + idempotency, + idempotencyKey, + deadline); + } + + public HttpOperation withBody(BodySource replacement) { + return new HttpOperation( + operationName, + method, + uriTemplate, + uriVariables, + headers, + replacement, + idempotency, + idempotencyKey, + deadline); + } + + public HttpOperation withMethod(HttpMethod replacement) { + return new HttpOperation( + operationName, + replacement, + uriTemplate, + uriVariables, + headers, + body, + idempotency, + idempotencyKey, + deadline); + } + + private static Map> copyHeaders(Map> headers) { + Map> copy = new LinkedHashMap<>(); + headers.forEach( + (name, values) -> { + Objects.requireNonNull(name, "header name"); + Objects.requireNonNull(values, "header values"); + copy.put(name, List.copyOf(new ArrayList<>(values))); + }); + return Map.copyOf(copy); + } + + /** + * Low-cardinality, secret-free description. + * + *

The record's generated {@code toString} printed every header value, the body object and the + * expanded URI variables. That string reaches a log the moment an operation appears in an + * exception message, a debug statement or an assertion failure — so an {@code Authorization} + * header, a request payload and a customer identifier were one stack trace away from the log + * aggregator. The template is safe by construction; the values are not, and none of them are + * needed to identify which operation this is. + */ + @Override + public String toString() { + return "HttpOperation[" + + operationName.value() + + ' ' + + method + + ' ' + + uriTemplate + + ", headers=" + + headers.keySet() + + ", body=" + + body.getClass().getSimpleName() + + ", idempotency=" + + idempotency + + ", idempotencyKey=" + + (idempotencyKey.isPresent() ? "PRESENT" : "ABSENT") + + ']'; + } + + /** Case-insensitive single header lookup used by the request writer and redirect coordinator. */ + public Optional firstHeader(String name) { + String wanted = name.toLowerCase(Locale.ROOT); + return headers.entrySet().stream() + .filter(entry -> entry.getKey().toLowerCase(Locale.ROOT).equals(wanted)) + .flatMap(entry -> entry.getValue().stream()) + .findFirst(); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/auth/CredentialRequest.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/auth/CredentialRequest.java new file mode 100644 index 00000000..4e464421 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/auth/CredentialRequest.java @@ -0,0 +1,66 @@ +package dev.caskeleton.adapter.outbound.httpclient.auth; + +import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName; +import dev.caskeleton.adapter.outbound.httpclient.api.OperationName; +import dev.caskeleton.adapter.outbound.httpclient.profile.AuthenticationSettings; +import java.net.URI; +import java.util.Objects; +import java.util.Optional; + +/** + * Input to credential materialization (design §20.2). + * + *

The principal is passed explicitly rather than read from ambient thread state: design §26.3 + * forbids implicitly picking up a user token, because that silently turns a machine-to-machine call + * into a user-scoped one. + */ +public record CredentialRequest( + ClientProfileName clientName, + OperationName operationName, + AuthenticationSettings settings, + URI target, + Optional principal, + Optional clientCertificateIdentity, + boolean forceRefresh) { + + /** + * Identifies the request without exposing the principal or the target URL. + * + *

The generated {@code toString} printed the authenticated principal and the full target URI, + * including any query string. A credential-resolution failure is exactly when this record ends up + * in a log line, which made the failure path the most likely place for a user identity and a + * signed URL to escape. + */ + @Override + public String toString() { + return "CredentialRequest[" + + clientName.value() + + ' ' + + operationName.value() + + ", type=" + + settings.type() + + ", target=" + + target.getScheme() + + "://" + + target.getHost() + + ", principal=" + + (principal.isPresent() ? "PRESENT" : "ABSENT") + + ", forceRefresh=" + + forceRefresh + + ']'; + } + + public CredentialRequest { + Objects.requireNonNull(clientName, "client name"); + Objects.requireNonNull(operationName, "operation name"); + Objects.requireNonNull(settings, "authentication settings"); + Objects.requireNonNull(target, "target"); + Objects.requireNonNull(principal, "principal"); + Objects.requireNonNull(clientCertificateIdentity, "client certificate identity"); + } + + public CredentialRequest refreshed() { + return new CredentialRequest( + clientName, operationName, settings, target, principal, clientCertificateIdentity, true); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/auth/OAuth2CredentialProvider.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/auth/OAuth2CredentialProvider.java new file mode 100644 index 00000000..d8764646 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/auth/OAuth2CredentialProvider.java @@ -0,0 +1,128 @@ +package dev.caskeleton.adapter.outbound.httpclient.auth; + +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpAuthenticationException; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.core.OAuth2AccessToken; + +/** + * OAuth2 access tokens obtained through Spring Security (design D-13, §20.3). + * + *

Token acquisition is delegated, but three rules are owned here because they are what make the + * result safe under load: the cache key, single-flight refresh, and an expiry skew so a token is + * replaced before an upstream starts rejecting it. + */ +public final class OAuth2CredentialProvider implements RequestCredentialProvider { + + /** + * Attribute name for the audience a token is requested for. + * + *

Not standardised in OAuth2 core, so it is spelled out here rather than borrowed from a + * constant that does not exist; authorization servers that support audience restriction read this + * parameter name. + */ + private static final String OAUTH2_AUDIENCE_ATTRIBUTE = "audience"; + + private static final Duration EXPIRY_SKEW = Duration.ofSeconds(30); + + private final OAuth2AuthorizedClientManager authorizedClientManager; + private final SingleFlightTokenLoader tokenLoader; + private final ConcurrentMap cache = new ConcurrentHashMap<>(); + private final Clock clock; + + public OAuth2CredentialProvider( + OAuth2AuthorizedClientManager authorizedClientManager, Clock clock) { + this.authorizedClientManager = + Objects.requireNonNull(authorizedClientManager, "authorized client manager"); + this.clock = Objects.requireNonNull(clock, "clock"); + this.tokenLoader = new SingleFlightTokenLoader(this::loadToken); + } + + @Override + public CredentialType type() { + return CredentialType.OAUTH2_CLIENT_CREDENTIALS; + } + + @Override + public RequestCredentials resolve(CredentialRequest request) { + OAuth2TokenCacheKey key = cacheKey(request); + if (request.forceRefresh()) { + cache.remove(key); + } + AccessToken token = + cache.compute( + key, + (ignored, existing) -> + existing == null || existing.expired(clock, EXPIRY_SKEW) + ? tokenLoader.load(key) + : existing); + return RequestCredentials.header("Authorization", "Bearer " + token.value()); + } + + @Override + public void invalidate(CredentialRequest request) { + cache.remove(cacheKey(request)); + } + + private OAuth2TokenCacheKey cacheKey(CredentialRequest request) { + String registrationId = + request + .settings() + .registrationId() + .orElseThrow( + () -> + new HttpAuthenticationException( + "oauth2 authentication requires a registration id", + HttpFailureMetadata.startup(request.clientName()))); + return new OAuth2TokenCacheKey( + registrationId, + request.principal().map(principal -> principal.getClass().getName()).orElse("anonymous"), + request.settings().scopes(), + request.settings().audience(), + Optional.empty(), + request.clientCertificateIdentity()); + } + + /** + * Authorizes, carrying the scopes and audience the profile declared. + * + *

Both used to be part of the cache key and part of nothing else. The platform cached tokens + * as though they differed by scope while every authorize request asked for the + * registration's default scopes, so a profile that declared a narrower scope set received a + * broader token and a profile that declared a wider one received a token missing the scopes it + * needed — and the cache confidently kept them apart. Attributes are the mechanism Spring's + * authorized-client manager passes through to the token request. + */ + private AccessToken loadToken(OAuth2TokenCacheKey key) { + OAuth2AuthorizeRequest.Builder builder = + OAuth2AuthorizeRequest.withClientRegistrationId(key.registrationId()) + .principal(key.principalClass()); + if (!key.scopes().isEmpty()) { + builder.attribute( + org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.SCOPE, + String.join(" ", key.scopes())); + } + key.audience().ifPresent(audience -> builder.attribute(OAUTH2_AUDIENCE_ATTRIBUTE, audience)); + OAuth2AuthorizeRequest authorizeRequest = builder.build(); + OAuth2AuthorizedClient authorizedClient = authorizedClientManager.authorize(authorizeRequest); + if (authorizedClient == null) { + throw new IllegalStateException( + "no oauth2 authorized client for registration " + key.registrationId()); + } + OAuth2AccessToken accessToken = authorizedClient.getAccessToken(); + Instant expiresAt = + accessToken.getExpiresAt() == null + ? clock.instant().plus(Duration.ofMinutes(5)) + : accessToken.getExpiresAt(); + return new AccessToken(accessToken.getTokenValue(), expiresAt); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/auth/ReactiveCredentialProviderRegistry.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/auth/ReactiveCredentialProviderRegistry.java new file mode 100644 index 00000000..9de59317 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/auth/ReactiveCredentialProviderRegistry.java @@ -0,0 +1,79 @@ +package dev.caskeleton.adapter.outbound.httpclient.auth; + +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpAuthenticationException; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata; +import java.util.EnumMap; +import java.util.Map; +import java.util.Objects; +import reactor.core.publisher.Mono; + +/** + * Dispatches a reactive call to the provider its profile declared (design §20.1, §20.2). + * + *

The reactive runtime takes a single {@link ReactiveRequestCredentialProvider}, and the + * composition root used to hand it {@link NoAuthCredentialProvider} unconditionally. A profile that + * declared {@code BASIC}, {@code API_KEY_HEADER} or {@code STATIC_BEARER} and used the reactive API + * therefore sent no credential at all — no startup error, no runtime error, just an unauthenticated + * request that the upstream answered 401 and the platform reported as a remote failure. + * + *

This registry is the same shape as the blocking {@link CredentialProviderRegistry}: selection + * is by declaration, and a type nobody registered is an error rather than a silent downgrade to + * anonymous. The dispatch reads the type off the request, so one instance serves every profile. + */ +public final class ReactiveCredentialProviderRegistry implements ReactiveRequestCredentialProvider { + + private final Map providers = + new EnumMap<>(CredentialType.class); + + public ReactiveCredentialProviderRegistry register(ReactiveRequestCredentialProvider provider) { + Objects.requireNonNull(provider, "reactive credential provider"); + providers.put(provider.type(), provider); + return this; + } + + /** Adapts a provider whose resolution is a synchronous computation rather than I/O. */ + public ReactiveCredentialProviderRegistry registerNonBlocking( + RequestCredentialProvider provider) { + return register(ReactiveRequestCredentialProvider.fromNonBlocking(provider)); + } + + public static ReactiveCredentialProviderRegistry withNoAuth() { + return new ReactiveCredentialProviderRegistry() + .registerNonBlocking(new NoAuthCredentialProvider()); + } + + /** + * The dispatch itself is a credential type of its own only in the degenerate sense; callers + * select by request, so this reports {@code NONE}. + */ + @Override + public CredentialType type() { + return CredentialType.NONE; + } + + @Override + public Mono resolve(CredentialRequest request) { + return provider(request).flatMap(provider -> provider.resolve(request)); + } + + @Override + public Mono invalidate(CredentialRequest request) { + return provider(request).flatMap(provider -> provider.invalidate(request)); + } + + public boolean supports(CredentialType credentialType) { + return providers.containsKey(credentialType); + } + + private Mono provider(CredentialRequest request) { + CredentialType credentialType = CredentialType.from(request.settings().type()); + ReactiveRequestCredentialProvider provider = providers.get(credentialType); + if (provider == null) { + return Mono.error( + new HttpAuthenticationException( + "no reactive credential provider is registered for " + credentialType, + HttpFailureMetadata.startup(request.clientName()))); + } + return Mono.just(provider); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/auth/SingleFlightTokenLoader.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/auth/SingleFlightTokenLoader.java new file mode 100644 index 00000000..7dfdc6e2 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/auth/SingleFlightTokenLoader.java @@ -0,0 +1,148 @@ +package dev.caskeleton.adapter.outbound.httpclient.auth; + +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpAuthenticationException; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata; +import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; + +/** + * Collapses concurrent refreshes of the same token into one (design §20.3). + * + *

Without this, a token expiring under load produces one token request per in-flight call, which + * is exactly when the authorization server can least afford them — and several providers rate-limit + * or invalidate on that pattern. + * + *

Two things about where that refresh runs used to be wrong, and both bite under + * exactly the load this class exists for. + * + *

It ran on the common {@link java.util.concurrent.ForkJoinPool}. That pool is sized for + * CPU-bound work and shared with every parallel stream in the process; a token endpoint that goes + * slow therefore parks common-pool threads and stalls unrelated work across the application. The + * refresh now runs on a small bounded pool of its own, so a slow authorization server costs only + * the threads dedicated to talking to it. + * + *

And the wait was {@code join()} — unbounded. A token endpoint that accepted the connection and + * never answered blocked every caller of that credential indefinitely, past their own deadlines, + * with no exception to attribute it to. The wait is bounded and expiry is a stable authentication + * failure. + */ +public final class SingleFlightTokenLoader implements AutoCloseable { + + /** Small on purpose: this pool exists to talk to one authorization server, not to scale out. */ + private static final int DEFAULT_POOL_SIZE = 2; + + private static final Duration DEFAULT_ACQUIRE_TIMEOUT = Duration.ofSeconds(10); + + private final ConcurrentMap> inFlight = + new ConcurrentHashMap<>(); + private final Function delegate; + private final ExecutorService refreshExecutor; + private final Duration acquireTimeout; + private final AtomicInteger joinedCallers = new AtomicInteger(); + + public SingleFlightTokenLoader(Function delegate) { + this(delegate, DEFAULT_POOL_SIZE, DEFAULT_ACQUIRE_TIMEOUT); + } + + public SingleFlightTokenLoader( + Function delegate, int poolSize, Duration acquireTimeout) { + this.delegate = Objects.requireNonNull(delegate, "token loader delegate"); + this.acquireTimeout = Objects.requireNonNull(acquireTimeout, "acquire timeout"); + if (poolSize < 1) { + throw new IllegalArgumentException("token refresh pool size must be positive"); + } + if (acquireTimeout.isNegative() || acquireTimeout.isZero()) { + throw new IllegalArgumentException("token acquire timeout must be positive"); + } + this.refreshExecutor = Executors.newFixedThreadPool(poolSize, refreshThreadFactory()); + } + + public AccessToken load(OAuth2TokenCacheKey key) { + Objects.requireNonNull(key, "token cache key"); + joinedCallers.incrementAndGet(); + CompletableFuture future = + inFlight.computeIfAbsent( + key, + ignored -> CompletableFuture.supplyAsync(() -> delegate.apply(key), refreshExecutor)); + try { + return future.get(acquireTimeout.toMillis(), TimeUnit.MILLISECONDS); + } catch (TimeoutException timedOut) { + // Cancelled rather than abandoned: leaving it running would let the next caller join a + // refresh that has already outlived its usefulness. + future.cancel(true); + throw new HttpAuthenticationException( + "oauth2 token refresh did not complete within " + acquireTimeout, + HttpFailureMetadata.startup( + new dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName( + key.registrationId())), + timedOut); + } catch (ExecutionException failed) { + Throwable cause = failed.getCause() == null ? failed : failed.getCause(); + if (cause instanceof RuntimeException runtime) { + throw runtime; + } + throw new HttpAuthenticationException( + "oauth2 token refresh failed", + HttpFailureMetadata.startup( + new dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName( + key.registrationId())), + cause); + } catch (InterruptedException interrupted) { + // The caller was cancelled; propagate the interrupt rather than swallowing it. + Thread.currentThread().interrupt(); + future.cancel(true); + throw new HttpAuthenticationException( + "oauth2 token refresh was interrupted", + HttpFailureMetadata.startup( + new dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName( + key.registrationId())), + interrupted); + } finally { + inFlight.remove(key, future); + } + } + + public int inFlightRefreshes() { + return inFlight.size(); + } + + /** + * How many callers have entered {@link #load} over this loader's lifetime. + * + *

Exposed so a contention test can wait for an observable condition — "all N callers have + * joined" — instead of sleeping for an arbitrary interval and hoping. A sleep-based test is + * simultaneously slower than it needs to be and unreliable on a loaded machine, which is the + * worst pair of properties for a test that only fails intermittently. + * + * @return the cumulative number of {@code load} entries + */ + public int joinedCallers() { + return joinedCallers.get(); + } + + @Override + public void close() { + refreshExecutor.shutdownNow(); + } + + private static ThreadFactory refreshThreadFactory() { + AtomicInteger counter = new AtomicInteger(); + return runnable -> { + Thread thread = + new Thread(runnable, "httpclient-oauth2-refresh-" + counter.incrementAndGet()); + thread.setDaemon(true); + return thread; + }; + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/dynamic/CallScopedDnsPin.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/dynamic/CallScopedDnsPin.java new file mode 100644 index 00000000..2af09e54 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/dynamic/CallScopedDnsPin.java @@ -0,0 +1,78 @@ +package dev.caskeleton.adapter.outbound.httpclient.dynamic; + +import java.net.InetAddress; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Carries the approved addresses of one dynamic call from validation to the socket. + * + *

This is the piece the SSRF defence was missing. {@link ValidatedDnsResolver} resolved a host, + * rejected the target if any answer was forbidden, and produced a {@link PinnedTarget} holding the + * exact approved addresses — and then the gateway handed the transport a URL containing the + * hostname, and the transport resolved it again. Everything between the two resolutions + * was unvalidated: a DNS server under an attacker's control answers the first query with a public + * address and the second with {@code 169.254.169.254}, and the platform connects to the metadata + * service having "validated" the target. The classic rebinding attack, defeated by a check that + * discarded its own result. + * + *

The pin is thread-scoped because the dynamic gateway is blocking and thread-confined for the + * duration of a physical request; it is installed around the attempt and removed in a {@code + * finally}, so a pooled thread never carries one call's addresses into another's. + * + *

It deliberately does not cache. The previous approved-address map in the resolver lived for + * the lifetime of the process with no TTL and no bound, which is a second, slower version of the + * same problem: an address approved an hour ago is not evidence about the host now. + */ +public final class CallScopedDnsPin implements AutoCloseable { + + private static final ThreadLocal>> CURRENT = new ThreadLocal<>(); + + private CallScopedDnsPin() {} + + /** + * Installs the pin for the current thread. + * + * @param host the canonical host the addresses were approved for + * @param approvedAddresses the addresses the transport may connect to + * @return a handle that removes the pin + */ + public static CallScopedDnsPin open(String host, List approvedAddresses) { + Objects.requireNonNull(host, "host"); + Objects.requireNonNull(approvedAddresses, "approved addresses"); + if (approvedAddresses.isEmpty()) { + throw new IllegalArgumentException("a dns pin needs at least one approved address"); + } + CURRENT.set(Map.of(host.toLowerCase(java.util.Locale.ROOT), List.copyOf(approvedAddresses))); + return new CallScopedDnsPin(); + } + + /** + * The addresses the current call approved for a host. + * + *

An empty result means this host was not the one validated. The transport must then refuse + * rather than fall back to a system lookup — a fallback would restore exactly the second, + * unvalidated resolution this class exists to remove. + * + * @param host the host the transport is about to connect to + * @return the approved addresses, or empty when the host was not pinned by this call + */ + public static List addressesFor(String host) { + Map> pinned = CURRENT.get(); + if (pinned == null || host == null) { + return List.of(); + } + return pinned.getOrDefault(host.toLowerCase(java.util.Locale.ROOT), List.of()); + } + + /** Whether a pin is installed on this thread. */ + public static boolean active() { + return CURRENT.get() != null; + } + + @Override + public void close() { + CURRENT.remove(); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/dynamic/DefaultDynamicTargetGateway.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/dynamic/DefaultDynamicTargetGateway.java new file mode 100644 index 00000000..22074cb1 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/dynamic/DefaultDynamicTargetGateway.java @@ -0,0 +1,228 @@ +package dev.caskeleton.adapter.outbound.httpclient.dynamic; + +import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName; +import dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRedirectRejectedException; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpTargetRejectedException; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation; +import dev.caskeleton.adapter.outbound.httpclient.api.result.HttpCallResult; +import dev.caskeleton.adapter.outbound.httpclient.api.result.IdempotencyKeyRequirement; +import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType; +import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentials; +import dev.caskeleton.adapter.outbound.httpclient.observation.HttpClientObservationNames; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry; +import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptOutcome; +import dev.caskeleton.adapter.outbound.httpclient.restclient.BlockingAttemptExecutor; +import dev.caskeleton.adapter.outbound.httpclient.restclient.BlockingClientRuntime; +import dev.caskeleton.adapter.outbound.httpclient.restclient.StatusHandlingPolicy; +import dev.caskeleton.adapter.outbound.httpclient.security.HeaderPolicy; +import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation; +import dev.caskeleton.adapter.outbound.httpclient.security.PreparedTarget; +import io.micrometer.core.instrument.Tag; +import java.net.URI; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; + +/** + * Executes a user-supplied URL under an explicit SSRF policy (design §22). + * + *

Every hop — including the first — goes through canonicalize → allowlist → resolve-all → + * classify → pin. Redirects are followed here rather than by the blocking coordinator precisely so + * the validation cannot be skipped for hop two. + * + *

No trusted credential, Cookie jar, or default header is inherited. A credential is attached + * only when a {@link DynamicCredentialBinding} names that exact canonical host. + */ +public final class DefaultDynamicTargetGateway implements DynamicTargetGateway { + + private final Map policies; + private final Map resolvers; + private final ClientRuntimeRegistry runtimes; + private final BlockingAttemptExecutor executor; + private final TargetCanonicalizer canonicalizer; + private final List credentialBindings; + private final Function secretResolver; + + public DefaultDynamicTargetGateway( + Map policies, + Map resolvers, + ClientRuntimeRegistry runtimes, + BlockingAttemptExecutor executor, + List credentialBindings, + Function secretResolver) { + this.policies = Map.copyOf(Objects.requireNonNull(policies, "dynamic target policies")); + this.resolvers = Map.copyOf(Objects.requireNonNull(resolvers, "validated dns resolvers")); + this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry"); + this.executor = Objects.requireNonNull(executor, "attempt executor"); + this.credentialBindings = List.copyOf(Objects.requireNonNull(credentialBindings, "bindings")); + this.secretResolver = Objects.requireNonNull(secretResolver, "secret resolver"); + this.canonicalizer = new TargetCanonicalizer(); + } + + @Override + public HttpCallResult exchange( + DynamicTargetPolicyName policyName, + URI target, + HttpOperation operation, + ResponseType responseType) { + Objects.requireNonNull(policyName, "policy name"); + Objects.requireNonNull(target, "target"); + Objects.requireNonNull(operation, "operation"); + + DynamicTargetPolicy policy = requirePolicy(policyName); + ValidatedDnsResolver resolver = requireResolver(policyName); + + try (ClientRuntimeLease lease = runtimes.acquire(new ClientProfileName(policyName.value()))) { + BlockingClientRuntime runtime = requireBlockingRuntime(lease); + HttpFailureMetadata metadata = + HttpFailureMetadata.validation( + runtime.name(), + operation.operationName(), + operation.method(), + operation.uriTemplate(), + operation.body().replayability()); + + URI current = target; + HttpMethod method = operation.method(); + for (int hop = 0; ; hop++) { + PinnedTarget pinned = validate(policy, resolver, current); + PreparedOperation prepared = + prepare(runtime, operation.withMethod(method), pinned, metadata); + RequestCredentials credentials = credentialsFor(pinned.target()); + + // The pin is what makes the validation above binding. Without it the transport resolved the + // hostname a second time and could land anywhere; with it the socket may only reach an + // address this hop actually approved. Scoped to the hop, so the next redirect re-validates + // and re-pins rather than inheriting an earlier decision. + AttemptOutcome outcome; + try (CallScopedDnsPin pin = + CallScopedDnsPin.open(pinned.target().host(), pinned.approvedAddresses())) { + Objects.requireNonNull(pin, "dns pin"); + outcome = + executor.execute( + runtime, + prepared, + responseType, + StatusHandlingPolicy.RETURN_RESULT, + credentials, + 1, + runtime.support().clock().instant(), + metadata); + } finally { + // Discarded at the end of the hop. Retaining it would grow without bound and, worse, + // would let a later call reuse an approval that was only ever made for this one. + resolver.forget(pinned.target().host()); + } + + HttpCallResult result = + outcome.result().orElseThrow(() -> outcome.failure().orElseThrow()); + Optional location = redirectLocation(result, pinned); + if (location.isEmpty()) { + return result; + } + if (hop >= policy.maxRedirectHops()) { + recordRejection(runtime, policyName, "MAX_HOPS"); + throw new HttpRedirectRejectedException( + "dynamic target redirect exceeded the policy hop limit", metadata); + } + // 303 turns the follow-up into a GET; every other redirect keeps the method, and the next + // loop iteration revalidates the new target from scratch. + method = result.status().value() == 303 ? HttpMethod.GET : method; + current = location.get(); + } + } + } + + private PinnedTarget validate( + DynamicTargetPolicy policy, ValidatedDnsResolver resolver, URI target) { + CanonicalTarget canonical = canonicalizer.canonicalize(policy, target); + return resolver.pin(canonical); + } + + private PreparedOperation prepare( + BlockingClientRuntime runtime, + HttpOperation operation, + PinnedTarget pinned, + HttpFailureMetadata metadata) { + Map> headers = + HeaderPolicy.forOperation(IdempotencyKeyRequirement.none(), false) + .validate(operation.headers(), metadata); + runtime.bodyLimitPolicy().validate(operation.body(), metadata); + PreparedTarget target = PreparedTarget.of(pinned.target().toUri(), operation.uriTemplate()); + return new PreparedOperation( + operation, + target, + headers, + runtime.profile().request().maxBodyBytes(), + runtime.profile().response().maxWireBytes(), + runtime.profile().response().maxDecodedBytes()); + } + + private Optional redirectLocation(HttpCallResult result, PinnedTarget pinned) { + int status = result.status().value(); + if (status != 301 && status != 302 && status != 303 && status != 307 && status != 308) { + return Optional.empty(); + } + return result.headers().entrySet().stream() + .filter(entry -> entry.getKey().equalsIgnoreCase("Location")) + .flatMap(entry -> entry.getValue().stream()) + .findFirst() + .map(location -> pinned.target().toUri().resolve(location)); + } + + private RequestCredentials credentialsFor(CanonicalTarget target) { + Map headers = new LinkedHashMap<>(); + credentialBindings.stream() + .filter(binding -> binding.matches(target)) + .forEach( + binding -> + headers.put(binding.headerName(), secretResolver.apply(binding.secretReference()))); + return headers.isEmpty() + ? RequestCredentials.none() + : new RequestCredentials(headers, Map.of()); + } + + private void recordRejection( + BlockingClientRuntime runtime, DynamicTargetPolicyName policyName, String reason) { + runtime + .support() + .meterRegistry() + .counter( + HttpClientObservationNames.SSRF_REJECTED, + List.of(Tag.of("clientName", policyName.value()), Tag.of("outcome", reason))) + .increment(); + } + + private DynamicTargetPolicy requirePolicy(DynamicTargetPolicyName name) { + DynamicTargetPolicy policy = policies.get(name); + if (policy == null) { + throw new NoSuchElementException("unregistered dynamic target policy: " + name.value()); + } + return policy; + } + + private ValidatedDnsResolver requireResolver(DynamicTargetPolicyName name) { + ValidatedDnsResolver resolver = resolvers.get(name); + if (resolver == null) { + throw new NoSuchElementException( + "no validated dns resolver for dynamic target policy: " + name.value()); + } + return resolver; + } + + private BlockingClientRuntime requireBlockingRuntime(ClientRuntimeLease lease) { + if (lease.runtime() instanceof BlockingClientRuntime blocking) { + return blocking; + } + throw new HttpTargetRejectedException( + "dynamic target policy is not bound to a blocking runtime", + HttpFailureMetadata.startup(lease.runtime().name())); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/dynamic/DynamicCredentialBinding.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/dynamic/DynamicCredentialBinding.java new file mode 100644 index 00000000..b7fcfaa0 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/dynamic/DynamicCredentialBinding.java @@ -0,0 +1,79 @@ +package dev.caskeleton.adapter.outbound.httpclient.dynamic; + +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +/** + * An explicitly registered credential for one dynamic origin (design §9.4). + * + *

Dynamic targets inherit nothing. If a specific origin genuinely needs a credential, a security + * owner registers this binding for that exact scheme, host and port — which makes the decision + * auditable instead of implicit. + * + *

The binding used to match on host alone. That sent the credential to {@code http://host} as + * readily as to {@code https://host}, and to any port the same host happened to serve — so a target + * that downgraded to plaintext, or pointed at a different service on the same machine, received a + * secret registered for neither. + * + * @param scheme the exact scheme the credential is registered for + * @param canonicalHost the exact canonical host + * @param port the exact port + * @param headerName the header to carry the credential in; must be on the allowlist + * @param secretReference the reference a secret backend resolves + */ +public record DynamicCredentialBinding( + String scheme, String canonicalHost, int port, String headerName, String secretReference) { + + /** + * Header names a dynamic credential may use. + * + *

An allowlist because the header name decides who reads the secret. Without one a binding + * could put a credential in {@code Host}, {@code Origin} or a header a proxy forwards onward. + */ + private static final Set ALLOWED_HEADER_NAMES = + Set.of("authorization", "x-api-key", "api-key", "x-client-key", "x-webhook-token"); + + public DynamicCredentialBinding { + Objects.requireNonNull(scheme, "scheme"); + Objects.requireNonNull(canonicalHost, "canonical host"); + Objects.requireNonNull(headerName, "header name"); + Objects.requireNonNull(secretReference, "secret reference"); + if (scheme.isBlank() + || canonicalHost.isBlank() + || headerName.isBlank() + || secretReference.isBlank()) { + throw new IllegalArgumentException("dynamic credential binding fields must not be blank"); + } + if (port < 1 || port > 65535) { + throw new IllegalArgumentException("dynamic credential binding port must be 1..65535"); + } + if (!ALLOWED_HEADER_NAMES.contains(headerName.toLowerCase(Locale.ROOT))) { + throw new IllegalArgumentException( + "dynamic credential header " + + headerName + + " is not on the allowlist " + + ALLOWED_HEADER_NAMES); + } + scheme = scheme.toLowerCase(Locale.ROOT); + canonicalHost = canonicalHost.toLowerCase(Locale.ROOT); + } + + /** Convenience for the common case: HTTPS on the default port. */ + public static DynamicCredentialBinding httpsOn( + String canonicalHost, String headerName, String secretReference) { + return new DynamicCredentialBinding("https", canonicalHost, 443, headerName, secretReference); + } + + /** + * Matches only the exact origin. + * + * @param target the canonicalized target of this call + * @return {@code true} when scheme, host and port all match + */ + public boolean matches(CanonicalTarget target) { + return scheme.equals(target.scheme()) + && canonicalHost.equals(target.host()) + && port == target.port(); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/dynamic/DynamicTargetPolicy.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/dynamic/DynamicTargetPolicy.java new file mode 100644 index 00000000..f321f921 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/dynamic/DynamicTargetPolicy.java @@ -0,0 +1,86 @@ +package dev.caskeleton.adapter.outbound.httpclient.dynamic; + +import java.util.Objects; +import java.util.Set; + +/** + * The rules a user-supplied URL must satisfy (design §22). + * + *

A Dynamic Target policy is deliberately separate from a Named Client Profile: it inherits no + * credential, no Cookie jar, and no default header (design D-03), so a webhook checker cannot + * accidentally speak with a trusted client's identity. + */ +public record DynamicTargetPolicy( + DynamicTargetPolicyName name, + Set allowedSchemes, + Set allowedPorts, + Set allowedHostSuffixes, + Set allowedHosts, + int maxRedirectHops, + boolean tracePropagation, + java.util.List additionalBlockedCidrs) { + + public DynamicTargetPolicy { + Objects.requireNonNull(name, "policy name"); + Objects.requireNonNull(allowedSchemes, "allowed schemes"); + Objects.requireNonNull(allowedPorts, "allowed ports"); + Objects.requireNonNull(allowedHostSuffixes, "allowed host suffixes"); + Objects.requireNonNull(allowedHosts, "allowed hosts"); + Objects.requireNonNull(additionalBlockedCidrs, "additional blocked cidrs"); + if (maxRedirectHops < 0) { + throw new IllegalArgumentException("max redirect hops must not be negative"); + } + allowedSchemes = Set.copyOf(allowedSchemes); + allowedPorts = Set.copyOf(allowedPorts); + allowedHostSuffixes = Set.copyOf(allowedHostSuffixes); + allowedHosts = Set.copyOf(allowedHosts); + additionalBlockedCidrs = java.util.List.copyOf(additionalBlockedCidrs); + } + + /** HTTPS-only public egress with no redirect following: the safest useful default. */ + public static DynamicTargetPolicy publicHttpsOnly(String name) { + return new DynamicTargetPolicy( + new DynamicTargetPolicyName(name), + Set.of("https"), + Set.of(443), + Set.of(), + Set.of(), + 0, + false, + java.util.List.of()); + } + + public boolean hostAllowed(String canonicalHost) { + if (allowedHosts.isEmpty() && allowedHostSuffixes.isEmpty()) { + return true; + } + if (allowedHosts.contains(canonicalHost)) { + return true; + } + return allowedHostSuffixes.stream() + .anyMatch(suffix -> isSubdomainOrExactMatch(canonicalHost, suffix)); + } + + /** + * Matches a suffix only at a label boundary. + * + *

Plain {@code endsWith} is not a domain rule. A policy allowing {@code example.com} also + * accepted {@code evil-example.com}, which an attacker registers precisely because the check is + * written this way — the allowlist then reads as a restriction while permitting any domain whose + * name happens to end in the allowed text. + * + *

A leading dot in the configured suffix is tolerated and means the same thing, so {@code + * .example.com} and {@code example.com} both allow {@code api.example.com} and the apex. + */ + private static boolean isSubdomainOrExactMatch(String canonicalHost, String configuredSuffix) { + String suffix = + configuredSuffix.startsWith(".") ? configuredSuffix.substring(1) : configuredSuffix; + if (suffix.isEmpty()) { + return false; + } + if (canonicalHost.equals(suffix)) { + return true; + } + return canonicalHost.endsWith("." + suffix); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/dynamic/IpAddressClassifier.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/dynamic/IpAddressClassifier.java new file mode 100644 index 00000000..71577ef9 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/dynamic/IpAddressClassifier.java @@ -0,0 +1,215 @@ +package dev.caskeleton.adapter.outbound.httpclient.dynamic; + +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * Decides whether a resolved address may be connected to (design §22.2). + * + *

Two details matter more than the list itself. IPv4-mapped IPv6 addresses are normalised back + * to IPv4 before classification, because {@code ::ffff:127.0.0.1} is loopback wearing a different + * hat. And cloud metadata endpoints are blocked explicitly rather than relying on the link-local + * rule, so an organisation-specific metadata address is still covered. + */ +public final class IpAddressClassifier { + + private static final Set METADATA_ADDRESSES = + Set.of("169.254.169.254", "fd00:ec2::254", "100.100.100.200", "192.0.0.192"); + + private final List additionalBlockedRanges; + + public IpAddressClassifier() { + this(List.of()); + } + + public IpAddressClassifier(List additionalBlockedCidrs) { + Objects.requireNonNull(additionalBlockedCidrs, "additional blocked cidrs"); + this.additionalBlockedRanges = additionalBlockedCidrs.stream().map(CidrRange::parse).toList(); + } + + /** Normalises an IPv4-mapped IPv6 address to its IPv4 form. */ + public static InetAddress normalize(InetAddress address) { + if (!(address instanceof Inet6Address ipv6)) { + return address; + } + byte[] bytes = ipv6.getAddress(); + boolean mapped = true; + for (int index = 0; index < 10; index++) { + if (bytes[index] != 0) { + mapped = false; + break; + } + } + if (!mapped || (bytes[10] & 0xFF) != 0xFF || (bytes[11] & 0xFF) != 0xFF) { + return address; + } + try { + return InetAddress.getByAddress(Arrays.copyOfRange(bytes, 12, 16)); + } catch (UnknownHostException impossible) { + return address; + } + } + + /** + * Whether an address must not be connected to. + * + *

Structured as an allowlist of globally routable unicast space, then the operator's own + * exclusions — not as a list of bad ranges. A denylist has to enumerate every special-purpose + * block IANA has ever assigned, and the ones it forgets are reachable: {@code 192.0.2.0/24}, + * {@code 198.18.0.0/15}, {@code 240.0.0.0/4} and the IPv6 documentation and Teredo prefixes were + * all absent, and each of them can be made to resolve somewhere useful to an attacker. Requiring + * global unicast inverts the burden: an address is refused unless it is the kind of address a + * public webhook could legitimately live on. + * + * @param rawAddress the resolved address, possibly IPv4-mapped + * @return {@code true} when the platform must refuse the address + */ + public boolean forbidden(InetAddress rawAddress) { + InetAddress address = normalize(rawAddress); + if (!globallyRoutableUnicast(address)) { + return true; + } + if (METADATA_ADDRESSES.contains(address.getHostAddress())) { + return true; + } + return additionalBlockedRanges.stream().anyMatch(range -> range.contains(address)); + } + + /** + * Whether the address is in globally routable unicast space. + * + *

The JDK predicates cover loopback, link-local, site-local, multicast and wildcard. The + * remaining special-purpose blocks are listed explicitly because the JDK has no predicate for + * them and their absence is what made the previous denylist incomplete. + */ + private boolean globallyRoutableUnicast(InetAddress address) { + if (address.isAnyLocalAddress() + || address.isLoopbackAddress() + || address.isLinkLocalAddress() + || address.isSiteLocalAddress() + || address.isMulticastAddress()) { + return false; + } + byte[] bytes = address.getAddress(); + if (address instanceof Inet4Address) { + int first = bytes[0] & 0xFF; + int second = bytes[1] & 0xFF; + int third = bytes[2] & 0xFF; + // 0.0.0.0/8 "this network"; 100.64.0.0/10 carrier-grade NAT, routinely internal; + // 192.0.0.0/24 IETF protocol assignments; 192.0.2.0/24, 198.51.100.0/24 and 203.0.113.0/24 + // documentation ranges; 198.18.0.0/15 benchmarking; 240.0.0.0/4 reserved, which includes the + // 255.255.255.255 broadcast address. + if (first == 0 + || (first == 100 && (second & 0xC0) == 64) + || (first == 192 && second == 0 && third == 0) + || (first == 192 && second == 0 && third == 2) + || (first == 198 && second == 51 && third == 100) + || (first == 203 && second == 0 && third == 113) + || (first == 198 && (second & 0xFE) == 18) + || (first & 0xF0) == 240) { + return false; + } + return true; + } + if (address instanceof Inet6Address) { + // fc00::/7 unique local; 2001:db8::/32 documentation; 2001::/32 Teredo; 100::/64 discard. + if ((bytes[0] & 0xFE) == 0xFC) { + return false; + } + int firstWord = ((bytes[0] & 0xFF) << 8) | (bytes[1] & 0xFF); + int secondWord = ((bytes[2] & 0xFF) << 8) | (bytes[3] & 0xFF); + if (firstWord == 0x2001 && (secondWord == 0x0db8 || secondWord == 0x0000)) { + return false; + } + if (firstWord == 0x0100 && secondWord == 0x0000) { + return false; + } + // 2000::/3 is the only globally routable unicast range currently assigned. + return (bytes[0] & 0xE0) == 0x20; + } + return false; + } + + /** Minimal CIDR matcher for organisation-defined internal ranges. */ + private static final class CidrRange { + + private static final java.util.regex.Pattern SLASH = java.util.regex.Pattern.compile("/"); + + /** IPv4 dotted quad or an IPv6 literal; anything else is a hostname and is refused. */ + private static final java.util.regex.Pattern LITERAL_ADDRESS = + java.util.regex.Pattern.compile("^[0-9.]+$|^[0-9A-Fa-f:.]*:[0-9A-Fa-f:.]*$"); + + private final byte[] network; + private final int prefixLength; + + private CidrRange(byte[] network, int prefixLength) { + this.network = network.clone(); + this.prefixLength = prefixLength; + } + + /** + * Parses a CIDR strictly. + * + *

Every rejection here used to be an acceptance. {@code Integer.parseInt} took {@code -1} + * and {@code 33} without complaint, producing a range that matched everything or nothing; and + * {@code InetAddress.getByName} accepts a hostname, so a typo'd entry performed a DNS + * lookup at startup and pinned the block to whatever that name resolved to at that moment. An + * operator's exclusion list is a security control, and every one of those outcomes silently + * turned it into something else. + */ + static CidrRange parse(String cidr) { + Objects.requireNonNull(cidr, "cidr"); + String[] parts = SLASH.split(cidr, -1); + if (parts.length != 2 || parts[0].isBlank() || parts[1].isBlank()) { + throw new IllegalArgumentException("invalid cidr, expected

/: " + cidr); + } + if (!LITERAL_ADDRESS.matcher(parts[0]).matches()) { + throw new IllegalArgumentException( + "cidr address must be an ip literal, not a hostname: " + cidr); + } + byte[] network; + try { + network = InetAddress.getByName(parts[0]).getAddress(); + } catch (UnknownHostException invalid) { + throw new IllegalArgumentException("invalid cidr address: " + cidr, invalid); + } + int prefixLength; + try { + prefixLength = Integer.parseInt(parts[1]); + } catch (NumberFormatException notANumber) { + throw new IllegalArgumentException("cidr prefix must be an integer: " + cidr, notANumber); + } + int maximumPrefix = network.length * 8; + if (prefixLength < 0 || prefixLength > maximumPrefix) { + throw new IllegalArgumentException( + "cidr prefix must be 0.." + maximumPrefix + " for this address family: " + cidr); + } + return new CidrRange(network, prefixLength); + } + + boolean contains(InetAddress address) { + byte[] candidate = address.getAddress(); + if (candidate.length != network.length) { + return false; + } + int fullBytes = prefixLength / 8; + for (int index = 0; index < fullBytes; index++) { + if (candidate[index] != network[index]) { + return false; + } + } + int remainingBits = prefixLength % 8; + if (remainingBits == 0) { + return true; + } + int mask = 0xFF << (8 - remainingBits); + return (candidate[fullBytes] & mask) == (network[fullBytes] & mask); + } + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/dynamic/ValidatedDnsResolver.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/dynamic/ValidatedDnsResolver.java new file mode 100644 index 00000000..29a3df64 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/dynamic/ValidatedDnsResolver.java @@ -0,0 +1,89 @@ +package dev.caskeleton.adapter.outbound.httpclient.dynamic; + +import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpDnsException; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpTargetRejectedException; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +/** + * Resolves a host and validates every answer (design §22.1 steps 6-9). + * + *

Validating only the first answer is a common and fatal shortcut: a host that resolves to one + * public and one private address would pass, and the connection could still land on the private + * one. Any forbidden address in the answer set rejects the whole target. + * + *

Approved addresses travel to the socket through {@link CallScopedDnsPin}, installed by the + * gateway for the duration of one hop. They are deliberately not retained here between calls: the + * previous per-host map had neither a TTL nor a size bound, so it grew without limit and, worse, + * answered a later call with an address that was only ever validated for an earlier one. An address + * approved five minutes ago is not evidence about the host now, which is the entire premise of + * rebinding. + */ +public final class ValidatedDnsResolver { + + private static final HttpFailureMetadata SCOPE = + HttpFailureMetadata.startup(new ClientProfileName("dynamic-target")); + + private final Function systemResolver; + private final IpAddressClassifier classifier; + private final Map> approved = new ConcurrentHashMap<>(); + + public ValidatedDnsResolver(IpAddressClassifier classifier) { + this(classifier, ValidatedDnsResolver::systemResolve); + } + + public ValidatedDnsResolver( + IpAddressClassifier classifier, Function systemResolver) { + this.classifier = Objects.requireNonNull(classifier, "ip address classifier"); + this.systemResolver = Objects.requireNonNull(systemResolver, "system resolver"); + } + + public List resolve(String host) { + Objects.requireNonNull(host, "host"); + InetAddress[] answers = systemResolver.apply(host); + if (answers == null || answers.length == 0) { + throw new HttpDnsException("dynamic target host did not resolve", SCOPE); + } + List normalized = new ArrayList<>(answers.length); + for (InetAddress answer : answers) { + if (classifier.forbidden(answer)) { + approved.remove(host); + throw new HttpTargetRejectedException( + "dynamic target resolves to a forbidden address range", SCOPE); + } + normalized.add(IpAddressClassifier.normalize(answer)); + } + List immutable = List.copyOf(normalized); + approved.put(host, immutable); + return immutable; + } + + public PinnedTarget pin(CanonicalTarget target) { + return new PinnedTarget(target, resolve(target.host())); + } + + /** Addresses the transport may connect to; empty when the host was never validated. */ + public List approvedAddresses(String host) { + return approved.getOrDefault(host, List.of()); + } + + public void forget(String host) { + approved.remove(host); + } + + private static InetAddress[] systemResolve(String host) { + try { + return InetAddress.getAllByName(host); + } catch (UnknownHostException unknown) { + throw new HttpDnsException("dynamic target host did not resolve", SCOPE, unknown); + } + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/http3/Http3CapabilityReport.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/http3/Http3CapabilityReport.java new file mode 100644 index 00000000..6cbd23b2 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/http3/Http3CapabilityReport.java @@ -0,0 +1,121 @@ +package dev.caskeleton.adapter.outbound.httpclient.http3; + +import java.util.List; +import java.util.Objects; + +/** + * What the Experimental HTTP/3 transport can and cannot prove (design §13.7, §29). + * + *

The contract suite runs only the subset declared here. Declaring less than the truth costs + * coverage; declaring more would let the Experimental transport claim Stable guarantees it has not + * demonstrated. + * + *

Two earlier defects in this report are worth naming, because both made it say the opposite of + * the truth. + * + *

The first is that it probed {@code + * org.eclipse.jetty.quic.client.QuicClientConnectorConfigurator}, a class that does not exist in + * the Jetty version this repository pins. The probe therefore reported "no QUIC" on a classpath + * that carries the entire QUIC and HTTP/3 client stack — a false negative that nothing noticed + * because nothing acted on it. + * + *

The second is that the probe was disconnected from the provider. It asked whether *some* QUIC + * class existed, never whether {@link JettyHttp3TransportProvider} used one, and the provider was + * in fact building {@code HttpClientTransportOverHTTP}: plain HTTP/1.1 over TCP, reported as + * HTTP/3. The probe now names the exact classes the provider constructs, so the report cannot drift + * from it again without failing to load them. + * + * @param quicNativeSupportPresent whether the QUIC and HTTP/3 client classes the provider + * constructs are loadable + * @param tls13Available whether the runtime offers TLS 1.3, which HTTP/3 requires + * @param wireVerified whether a negotiated {@code h3} exchange against a real HTTP/3 server has + * been observed in this build; class presence is not evidence of interoperability + * @param dynamicTargetSupported always false — H3 needs validated address pinning this transport + * cannot yet provide + * @param unsupportedContracts the contracts the Experimental transport does not run + */ +public record Http3CapabilityReport( + boolean quicNativeSupportPresent, + boolean tls13Available, + boolean wireVerified, + boolean dynamicTargetSupported, + List unsupportedContracts) { + + /** + * The classes the provider actually constructs. Probing anything else would let the report and + * the provider disagree. + */ + private static final List REQUIRED_QUIC_CLASSES = + List.of( + "org.eclipse.jetty.quic.client.ClientQuicConfiguration", + "org.eclipse.jetty.http3.client.HTTP3Client", + "org.eclipse.jetty.http3.client.transport.HttpClientTransportOverHTTP3"); + + public Http3CapabilityReport { + Objects.requireNonNull(unsupportedContracts, "unsupported contracts"); + unsupportedContracts = List.copyOf(unsupportedContracts); + } + + public static Http3CapabilityReport detect() { + return new Http3CapabilityReport( + quicClassesPresent(), + detectTls13Support(), + // No HTTP/3 server is stood up anywhere in this build, so nothing has observed a negotiated + // h3 exchange. Until something does, the transport stays un-promotable no matter how + // complete its classpath looks. + false, + false, + List.of( + "dynamic-target-pinning", + "pool-saturation-evidence", + "forward-proxy-tunnel", + "negotiated-protocol-wire-proof")); + } + + /** + * Whether the transport may be advertised as anything beyond Experimental. + * + *

Requires wire proof, not classpath proof. A complete set of QUIC classes says the code can + * be constructed; it says nothing about whether a peer negotiated {@code h3}. + * + * @return {@code true} only when a real negotiated exchange has been observed + */ + public boolean promotableToBeta() { + return quicNativeSupportPresent && tls13Available && wireVerified; + } + + /** + * Whether the provider can build its transport at all. + * + * @return {@code true} when every class the provider constructs is loadable and TLS 1.3 is + * offered + */ + public boolean constructible() { + return quicNativeSupportPresent && tls13Available; + } + + static List requiredQuicClasses() { + return REQUIRED_QUIC_CLASSES; + } + + private static boolean quicClassesPresent() { + for (String className : REQUIRED_QUIC_CLASSES) { + try { + Class.forName(className, false, Http3CapabilityReport.class.getClassLoader()); + } catch (ClassNotFoundException absent) { + return false; + } + } + return true; + } + + private static boolean detectTls13Support() { + try { + return List.of( + javax.net.ssl.SSLContext.getDefault().getSupportedSSLParameters().getProtocols()) + .contains("TLSv1.3"); + } catch (java.security.NoSuchAlgorithmException unavailable) { + return false; + } + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/http3/JettyHttp3TransportProvider.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/http3/JettyHttp3TransportProvider.java new file mode 100644 index 00000000..651816e6 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/http3/JettyHttp3TransportProvider.java @@ -0,0 +1,190 @@ +package dev.caskeleton.adapter.outbound.httpclient.http3; + +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientMode; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile; +import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration; +import dev.caskeleton.adapter.outbound.httpclient.transport.ReactiveTransportCapabilities; +import dev.caskeleton.adapter.outbound.httpclient.transport.ReactiveTransportProvider; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportId; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportLifecycleListener; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportResourceKey; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.http.HttpVersion; +import org.eclipse.jetty.http3.client.HTTP3Client; +import org.eclipse.jetty.http3.client.transport.HttpClientTransportOverHTTP3; +import org.eclipse.jetty.io.Transport; +import org.eclipse.jetty.quic.client.ClientQuicConfiguration; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.http.client.reactive.JettyClientHttpConnector; + +/** + * Experimental HTTP/3 transport (design D-08, §13.7, §32.7). + * + *

Three guards keep it Experimental in practice, not just in documentation: an exact + * acknowledgement string, a capability report that must show QUIC and TLS 1.3, and a hard refusal + * to serve Dynamic Targets — H3 requires validated address pinning this transport cannot yet prove. + * + *

The Stable starter never auto-configures this provider. + * + *

This class used to build {@code HttpClientTransportOverHTTP} — plain HTTP/1.1 over TCP — and + * present it as HTTP/3. Every guard above passed, the profile declared {@code HTTP_3}, the + * acknowledgement was checked, and the resulting connection negotiated HTTP/1.1. A caller who opted + * into an experimental protocol got neither the protocol nor a warning. It now constructs the real + * QUIC-backed transport and refuses outright when it cannot, so the failure mode is a startup error + * rather than a silent downgrade. + * + *

Constructing the right transport is still not proof that HTTP/3 works. Nothing in this build + * stands up an HTTP/3 server, so {@link Http3CapabilityReport#promotableToBeta()} stays false + * regardless of how complete the classpath is. + */ +public final class JettyHttp3TransportProvider implements ReactiveTransportProvider { + + private static final TransportId ID = new TransportId("jetty-http3"); + + private final JettyHttp3FailureClassifier classifier = new JettyHttp3FailureClassifier(); + private final Map clients = new ConcurrentHashMap<>(); + + @Override + public TransportId id() { + return ID; + } + + @Override + public ReactiveTransportCapabilities capabilities() { + return ReactiveTransportCapabilities.jettyHttp3Experimental(); + } + + public Http3CapabilityReport capabilityReport() { + return Http3CapabilityReport.detect(); + } + + @Override + public ClientHttpConnector create( + ClientProfile profile, RuntimeGeneration generation, TransportLifecycleListener listener) { + requireExperimentalAcknowledgement(profile); + if (profile.mode() == ClientMode.DYNAMIC) { + throw new HttpConfigurationException( + "the experimental http/3 transport does not support dynamic targets", + HttpFailureMetadata.startup(profile.name())); + } + HttpClient client = newHttp3Client(profile); + clients.put(new TransportResourceKey(profile.name(), generation), client); + listener.onRuntimeCreated(profile.name(), ID); + + JettyClientHttpConnector connector = new JettyClientHttpConnector(client); + return connector; + } + + /** + * Builds the QUIC-backed Jetty client, or refuses. + * + *

Package-visible so a test can assert which transport was constructed. Asserting on the + * connector cannot do it — {@code JettyClientHttpConnector} does not expose the client — and the + * defect this replaces was invisible precisely because nothing looked. + * + * @param profile the profile whose timeouts and pool bounds configure the client + * @return a client whose transport is HTTP/3 over QUIC + * @throws HttpConfigurationException when the runtime cannot offer TLS 1.3 or the QUIC classes + * are absent; never a downgrade to TCP + */ + static HttpClient newHttp3Client(ClientProfile profile) { + HttpClient client = new HttpClient(newHttp3Transport(profile)); + client.setFollowRedirects(false); + client.setConnectTimeout(profile.timeout().connect().toMillis()); + client.setIdleTimeout(profile.timeout().readIdle().toMillis()); + client.setMaxConnectionsPerDestination(profile.pool().maxConnectionsPerRoute()); + client.setMaxRequestsQueuedPerDestination(Math.max(1, profile.pool().maxPendingAcquires())); + return client; + } + + /** + * The QUIC-backed transport, or a refusal. + * + *

Returned as its own value rather than read back off the client, because Jetty 12.1 + * deprecated {@code HttpClient#getTransport()} for removal — a test that reached through the + * client would be asserting on an API scheduled to disappear. + * + * @param profile the profile whose connect and idle budgets configure the QUIC session + * @return an HTTP/3-over-QUIC transport + * @throws HttpConfigurationException when TLS 1.3 or the QUIC classes are unavailable + */ + static HttpClientTransportOverHTTP3 newHttp3Transport(ClientProfile profile) { + Http3CapabilityReport report = Http3CapabilityReport.detect(); + if (!report.tls13Available()) { + throw new HttpConfigurationException( + "http/3 requires TLS 1.3, which this runtime does not offer", + HttpFailureMetadata.startup(profile.name())); + } + if (!report.quicNativeSupportPresent()) { + throw new HttpConfigurationException( + "http/3 requires the Jetty QUIC client stack " + + Http3CapabilityReport.requiredQuicClasses() + + ", which is not on the classpath; refusing rather than falling back to TCP", + HttpFailureMetadata.startup(profile.name())); + } + + HTTP3Client http3Client = new HTTP3Client(new ClientQuicConfiguration()); + http3Client.getClientConnector().setConnectTimeout(profile.timeout().connect()); + http3Client + .getHTTP3Configuration() + .setStreamIdleTimeout(profile.timeout().readIdle().toMillis()); + return new HttpClientTransportOverHTTP3(http3Client, Transport.UDP_IP); + } + + /** + * The wire version this transport is configured to speak. + * + *

Named for what it is. The previous {@code negotiatedVersion} claimed to describe the wire + * while reading only a classpath probe, and returned HTTP/2 for a client that was speaking + * HTTP/1.1. A negotiated version can only come from an exchange, and this build has none. + * + * @return always HTTP/3 — the provider refuses to build anything else + */ + public HttpVersion configuredVersion() { + return HttpVersion.HTTP_3; + } + + @Override + public TransportFailureClassifier failureClassifier() { + return classifier; + } + + @Override + public void close(ClientProfile profile, RuntimeGeneration generation) { + HttpClient client = clients.remove(new TransportResourceKey(profile.name(), generation)); + if (client == null) { + return; + } + try { + client.stop(); + } catch (Exception failure) { + throw new IllegalStateException("jetty http/3 client did not stop cleanly", failure); + } + } + + private void requireExperimentalAcknowledgement(ClientProfile profile) { + String acknowledgement = + profile + .experimentalAcknowledgement() + .orElseThrow( + () -> + new HttpConfigurationException( + "http/3 requires an explicit experimental acknowledgement", + HttpFailureMetadata.startup(profile.name()))); + try { + Http3ExperimentalAcknowledgement unused = + new Http3ExperimentalAcknowledgement(acknowledgement); + assert unused != null; + } catch (IllegalArgumentException invalid) { + throw new HttpConfigurationException( + "http/3 requires an explicit experimental acknowledgement", + HttpFailureMetadata.startup(profile.name()), + invalid); + } + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/jdk/JdkBlockingTransportProvider.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/jdk/JdkBlockingTransportProvider.java new file mode 100644 index 00000000..c7f1dde5 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/jdk/JdkBlockingTransportProvider.java @@ -0,0 +1,83 @@ +package dev.caskeleton.adapter.outbound.httpclient.jdk; + +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile; +import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration; +import dev.caskeleton.adapter.outbound.httpclient.security.SslContextMaterial; +import dev.caskeleton.adapter.outbound.httpclient.transport.BlockingTransportCapabilities; +import dev.caskeleton.adapter.outbound.httpclient.transport.BlockingTransportProvider; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportId; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportLifecycleListener; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportResourceKey; +import java.net.http.HttpClient; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.JdkClientHttpRequestFactory; + +/** + * Lightweight blocking alternative (design D-06, §13.5). + * + *

Capability validation runs before any client is built: a profile this transport cannot honour + * fails without ever reaching the network. + */ +public final class JdkBlockingTransportProvider implements BlockingTransportProvider { + + private static final TransportId ID = new TransportId("jdk"); + + private final JdkClientFactory clientFactory = new JdkClientFactory(); + private final JdkFailureClassifier classifier = new JdkFailureClassifier(); + private final JdkTransportCapabilityPolicy capabilityPolicy = new JdkTransportCapabilityPolicy(); + private final Map clients = new ConcurrentHashMap<>(); + private final Function> tlsMaterialResolver; + + public JdkBlockingTransportProvider() { + this(profile -> Optional.empty()); + } + + public JdkBlockingTransportProvider( + Function> tlsMaterialResolver) { + this.tlsMaterialResolver = Objects.requireNonNull(tlsMaterialResolver, "tls material resolver"); + } + + @Override + public TransportId id() { + return ID; + } + + @Override + public BlockingTransportCapabilities capabilities() { + return BlockingTransportCapabilities.lightweightHttp11AndHttp2(); + } + + @Override + public ClientHttpRequestFactory create( + ClientProfile profile, RuntimeGeneration generation, TransportLifecycleListener listener) { + Objects.requireNonNull(profile, "profile"); + Objects.requireNonNull(listener, "lifecycle listener"); + capabilityPolicy.validate(profile); + + HttpClient client = clientFactory.create(profile, tlsMaterialResolver.apply(profile)); + clients.put(new TransportResourceKey(profile.name(), generation), client); + JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(client); + factory.setReadTimeout(profile.timeout().responseHeader()); + listener.onRuntimeCreated(profile.name(), ID); + return factory; + } + + @Override + public TransportFailureClassifier failureClassifier() { + return classifier; + } + + @Override + public void close(ClientProfile profile, RuntimeGeneration generation) { + HttpClient client = clients.remove(new TransportResourceKey(profile.name(), generation)); + if (client != null) { + client.close(); + } + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/jdk/JdkClientFactory.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/jdk/JdkClientFactory.java new file mode 100644 index 00000000..9d260957 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/jdk/JdkClientFactory.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.outbound.httpclient.jdk; + +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile; +import dev.caskeleton.adapter.outbound.httpclient.profile.HttpProtocol; +import dev.caskeleton.adapter.outbound.httpclient.security.SslContextMaterial; +import java.net.InetSocketAddress; +import java.net.ProxySelector; +import java.net.http.HttpClient; +import java.util.Objects; +import java.util.Optional; +import javax.net.ssl.SSLParameters; + +/** + * Builds the JDK HttpClient runtime for one profile (design §13.5). + * + *

Redirects are always {@code NEVER}: the platform re-validates every hop itself, and the JDK's + * own follower would silently forward credentials across origins. + */ +public final class JdkClientFactory { + + public HttpClient create(ClientProfile profile, Optional tlsMaterial) { + Objects.requireNonNull(profile, "profile"); + HttpClient.Builder builder = + HttpClient.newBuilder() + .connectTimeout(profile.timeout().connect()) + .followRedirects(HttpClient.Redirect.NEVER) + .version( + profile.protocols().contains(HttpProtocol.HTTP_2) + ? HttpClient.Version.HTTP_2 + : HttpClient.Version.HTTP_1_1); + + // TLS parameters are applied whether or not the profile supplies custom material. They used to + // be set only inside this ifPresent, so a profile that declared `tls.protocols: [TLSv1.3]` and + // used the JVM trust store — the common case — configured nothing at all and negotiated + // whatever the platform default allowed, TLS 1.2 included. A declared TLS floor that only + // applies when you also supply a custom truststore is not a floor. + SSLParameters parameters = new SSLParameters(); + parameters.setProtocols( + tlsMaterial + .map(SslContextMaterial::protocolArray) + .orElseGet(() -> profile.tls().protocols().toArray(String[]::new))); + // Endpoint identification is set explicitly: the JDK default for a raw SSLParameters + // instance is "no hostname check", which design §21.2 forbids. + parameters.setEndpointIdentificationAlgorithm("HTTPS"); + // ALPN is declared explicitly. The JDK client also derives it from the requested version, + // so this is not load-bearing today (verified by NegotiatedProtocolContractTest, which + // still passes without it) — it makes the advertised protocol set a property of the + // profile rather than of a JDK internal. + parameters.setApplicationProtocols( + profile.protocols().contains(HttpProtocol.HTTP_2) + ? new String[] {"h2", "http/1.1"} + : new String[] {"http/1.1"}); + tlsMaterial.ifPresent(material -> builder.sslContext(material.sslContext())); + builder.sslParameters(parameters); + + if (profile.proxy().enabled()) { + builder.proxy( + ProxySelector.of(new InetSocketAddress(profile.proxy().host(), profile.proxy().port()))); + } else { + builder.proxy(ProxySelector.of(null)); + } + return builder.build(); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/jdk/JdkFailureClassifier.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/jdk/JdkFailureClassifier.java new file mode 100644 index 00000000..8350e0c3 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/jdk/JdkFailureClassifier.java @@ -0,0 +1,120 @@ +package dev.caskeleton.adapter.outbound.httpclient.jdk; + +import dev.caskeleton.adapter.outbound.httpclient.api.operation.AttemptStage; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.FailureCategory; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailure; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier; +import java.io.InterruptedIOException; +import java.net.ConnectException; +import java.net.NoRouteToHostException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.net.http.HttpConnectTimeoutException; +import java.net.http.HttpTimeoutException; +import java.security.cert.CertificateException; +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLHandshakeException; +import javax.net.ssl.SSLPeerUnverifiedException; + +/** + * Maps JDK HttpClient failures onto stable evidence (design §13.3, §13.5). + * + *

The JDK exposes fewer stage-specific exceptions than Apache, so anything other than a proven + * pre-send failure stays conservative. + */ +public final class JdkFailureClassifier implements TransportFailureClassifier { + + @Override + public TransportFailure classify(Throwable failure, AttemptStage lastObservedStage) { + // Spring and the JDK both wrap engine exceptions; the chain is inspected so a wrapped + // ConnectException still classifies as provably NOT_SENT. + for (Throwable cause : chain(failure)) { + TransportFailure recognized = recognize(cause, lastObservedStage); + if (recognized != null) { + return recognized; + } + } + return fallback(lastObservedStage); + } + + private TransportFailure recognize(Throwable cause, AttemptStage lastObservedStage) { + if (cause instanceof HttpConnectTimeoutException + || cause instanceof ConnectException + || cause instanceof NoRouteToHostException) { + return TransportFailure.notSent( + AttemptStage.CONNECT, FailureCategory.CONNECT, "CONNECT_FAILED"); + } + if (cause instanceof UnknownHostException) { + return TransportFailure.notSent( + AttemptStage.DNS, FailureCategory.DNS, "DNS_RESOLUTION_FAILED"); + } + if (cause instanceof SSLPeerUnverifiedException || cause instanceof CertificateException) { + return TransportFailure.notSent( + AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_PERMANENT, "TLS_TRUST_FAILED"); + } + if (cause instanceof SSLHandshakeException) { + return TransportFailure.notSent( + AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_PERMANENT, "TLS_HANDSHAKE_FAILED"); + } + if (cause instanceof SSLException + && !lastObservedStage.isAtLeast(AttemptStage.REQUEST_HEADERS)) { + return TransportFailure.notSent( + AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_TRANSIENT, "TLS_TRANSIENT_FAILURE"); + } + if (cause instanceof InterruptedIOException + && !(cause instanceof HttpTimeoutException) + && !(cause instanceof SocketTimeoutException)) { + // Cancellation, not a timeout. Classifying it as a timeout let the retry engine reissue a + // request the caller had just cancelled, and dropped the interrupt so the thread could not + // see its own cancellation either. + Thread.currentThread().interrupt(); + if (lastObservedStage.provesNotSent()) { + return TransportFailure.notSent( + lastObservedStage, FailureCategory.CANCELLED, "ATTEMPT_INTERRUPTED"); + } + return TransportFailure.sentNoResponse( + lastObservedStage, FailureCategory.CANCELLED, "ATTEMPT_INTERRUPTED"); + } + if (cause instanceof HttpTimeoutException || cause instanceof SocketTimeoutException) { + if (lastObservedStage.isAtLeast(AttemptStage.RESPONSE_BODY)) { + return new TransportFailure( + lastObservedStage, + ExecutionEvidence.PARTIAL_RESPONSE, + FailureCategory.RESPONSE_TIMEOUT, + "RESPONSE_BODY_TIMEOUT"); + } + return TransportFailure.sentNoResponse( + AttemptStage.RESPONSE_HEADERS, + FailureCategory.RESPONSE_TIMEOUT, + "RESPONSE_HEADER_TIMEOUT"); + } + return null; + } + + private TransportFailure fallback(AttemptStage lastObservedStage) { + if (lastObservedStage.provesNotSent()) { + return TransportFailure.notSent( + lastObservedStage, FailureCategory.UNKNOWN, "TRANSPORT_FAILURE"); + } + if (lastObservedStage.isAtLeast(AttemptStage.RESPONSE_BODY)) { + return new TransportFailure( + lastObservedStage, + ExecutionEvidence.PARTIAL_RESPONSE, + FailureCategory.RESPONSE_TRUNCATED, + "TRANSPORT_FAILURE"); + } + return TransportFailure.sentNoResponse( + lastObservedStage, FailureCategory.UNKNOWN, "TRANSPORT_FAILURE"); + } + + private java.util.List chain(Throwable failure) { + java.util.List chain = new java.util.ArrayList<>(); + Throwable current = failure; + while (current != null && !chain.contains(current)) { + chain.add(current); + current = current.getCause(); + } + return chain; + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/profile/ClientProfileValidator.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/profile/ClientProfileValidator.java new file mode 100644 index 00000000..6d1ea925 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/profile/ClientProfileValidator.java @@ -0,0 +1,291 @@ +package dev.caskeleton.adapter.outbound.httpclient.profile; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * Fail-closed startup validation for a Named Client Profile (design §11.2, §30.1). + * + *

Every guard in the design has exactly one stable violation code here. The result is sorted so + * a configuration error reports deterministically across runs and machines. + */ +public final class ClientProfileValidator { + + public List validate( + ClientProfile profile, RuntimeEnvironment environment) { + List violations = new ArrayList<>(); + validateTarget(profile, environment, violations); + validateRedirect(profile, violations); + validateTimeouts(profile, violations); + validateUnsupportedSettings(profile, violations); + validateLimits(profile, violations); + validateTransportCapability(profile, environment, violations); + validateCredentials(profile, violations); + validateTls(profile, environment, violations); + validateRetry(profile, violations); + validateObservability(profile, environment, violations); + validateProductionCompleteness(profile, environment, violations); + violations.sort(ClientProfileViolation::compareTo); + return List.copyOf(violations); + } + + private void validateTarget( + ClientProfile profile, RuntimeEnvironment environment, List out) { + URI baseUrl = profile.baseUrl(); + if (profile.trusted() && baseUrl == null) { + out.add(violation("TRUSTED_BASE_URL_REQUIRED", profile, "base-url")); + return; + } + if (baseUrl == null) { + return; + } + if (baseUrl.getUserInfo() != null) { + out.add(violation("BASE_URL_USERINFO_FORBIDDEN", profile, "base-url")); + } + if (baseUrl.getRawQuery() != null) { + out.add(violation("BASE_URL_QUERY_FORBIDDEN", profile, "base-url")); + } + String scheme = baseUrl.getScheme() == null ? "" : baseUrl.getScheme().toLowerCase(Locale.ROOT); + if (!"https".equals(scheme) && environment.production()) { + out.add(violation("PLAINTEXT_PRODUCTION_TARGET", profile, "base-url")); + } + String host = baseUrl.getHost(); + if (host != null + && !profile.allowedHosts().isEmpty() + && !profile.allowedHosts().contains(host.toLowerCase(Locale.ROOT))) { + out.add(violation("ALLOWED_HOST_MISMATCH", profile, "allowed-hosts")); + } + int port = baseUrl.getPort() >= 0 ? baseUrl.getPort() : defaultPort(scheme); + if (!profile.allowedPorts().isEmpty() && !profile.allowedPorts().contains(port)) { + out.add(violation("ALLOWED_PORT_MISMATCH", profile, "allowed-ports")); + } + } + + private void validateRedirect(ClientProfile profile, List out) { + RedirectSettings redirect = profile.redirect(); + if (!redirect.enabled()) { + return; + } + if (redirect.maxHops() == 0) { + out.add(violation("REDIRECT_POLICY_INVALID", profile, "redirect.max-hops")); + } + if (redirect.allowCrossOrigin() + && profile.authentication().type().attachesDefaultCredential()) { + out.add(violation("REDIRECT_CROSS_ORIGIN_CREDENTIAL_POLICY_REQUIRED", profile, "redirect")); + } + if (profile.api() == ClientApiType.WEB_CLIENT) { + // Engine-level redirect following is disabled on every transport, and only the blocking stack + // has a coordinator to follow hops itself with per-hop re-validation. A reactive profile that + // enabled redirects therefore did not follow them: the caller received the 302 as an ordinary + // response and read its empty body as the answer. Refusing is the honest outcome until the + // reactive coordinator exists — a configured guarantee that silently does nothing is worse + // than one the platform declines to offer. + out.add(violation("REACTIVE_REDIRECT_UNSUPPORTED", profile, "redirect.enabled")); + } + } + + /** + * Settings that bind but reach no transport are refused rather than ignored. + * + *

Three of them had no consumer anywhere: {@code timeout.dns}, {@code + * proxy.credential-provider} and {@code proxy.import-ambient-no-proxy}. An operator who set a DNS + * timeout believed resolution was bounded and it was not; one who named a proxy credential + * provider believed the proxy was authenticated and it was not. Neither Apache nor the JDK client + * exposes a DNS-resolution timeout, and no proxy credential path exists in this platform yet, so + * the honest position is to refuse a value the platform cannot honour instead of accepting it and + * doing nothing. + * + *

The default values are accepted, so an operator who never touched these settings is + * unaffected — only a deliberate, unmet request fails. + */ + private void validateUnsupportedSettings( + ClientProfile profile, List out) { + if (!TimeoutSettings.DEFAULT_DNS.equals(profile.timeout().dns())) { + out.add(violation("DNS_TIMEOUT_UNSUPPORTED", profile, "timeout.dns")); + } + if (profile.proxy().credentialProvider().isPresent()) { + out.add(violation("PROXY_CREDENTIAL_UNSUPPORTED", profile, "proxy.credential-provider")); + } + if (profile.proxy().importAmbientNoProxy()) { + out.add( + violation( + "PROXY_AMBIENT_NO_PROXY_UNSUPPORTED", profile, "proxy.import-ambient-no-proxy")); + } + } + + private void validateTimeouts(ClientProfile profile, List out) { + TimeoutSettings timeout = profile.timeout(); + if (timeout.totalCall().compareTo(timeout.connect()) < 0 + || timeout.totalCall().compareTo(timeout.responseHeader()) < 0) { + out.add(violation("INVALID_TIMEOUT_BUDGET", profile, "timeout.total-call")); + } + if (timeout.totalCall().isZero() || timeout.totalCall().isNegative()) { + out.add(violation("INVALID_TIMEOUT_BUDGET", profile, "timeout.total-call")); + } + } + + private void validateLimits(ClientProfile profile, List out) { + if (profile.response().maxDecodedBytes() > ResponseLimits.GLOBAL_HARD_MAXIMUM_BYTES) { + out.add(violation("RESPONSE_HARD_MAXIMUM_EXCEEDED", profile, "response.max-decoded-bytes")); + } + } + + /** + * Observability switches that describe an unsafe intent are refused in production. + * + *

Both settings were bindable and inert: nothing read {@code full-url-recording}, and {@code + * body-logging} reached only the actuator report. Leaving them that way is the worse of the two + * failure modes — an operator who set them believed the platform was recording full URLs or + * bodies, and an operator who left them false had no assurance that it was not. Recording an + * expanded URL puts path identifiers and query strings into unbounded metric tags and logs; + * recording bodies puts someone else's data there. Neither belongs in production, so the intent + * is representable and rejectable rather than silently ignored. + */ + private void validateObservability( + ClientProfile profile, RuntimeEnvironment environment, List out) { + if (!environment.production()) { + return; + } + if (profile.observability().fullUrlRecording()) { + out.add( + violation("FULL_URL_RECORDING_FORBIDDEN", profile, "observability.full-url-recording")); + } + if (profile.observability().bodyLogging()) { + out.add(violation("BODY_LOGGING_FORBIDDEN", profile, "observability.body-logging")); + } + } + + private void validateTransportCapability( + ClientProfile profile, RuntimeEnvironment environment, List out) { + if (profile.transport() == TransportType.SIMPLE && environment.production()) { + out.add(violation("PRODUCTION_SIMPLE_FACTORY_FORBIDDEN", profile, "transport")); + } + if (profile.transport() == TransportType.JDK + && (profile.pool().requiresRoutePool() || profile.pool().requiresBoundedPendingQueue())) { + out.add(violation("JDK_FINE_GRAINED_POOL_UNSUPPORTED", profile, "transport")); + } + if (profile.protocols().contains(HttpProtocol.HTTP_3) + && profile.experimentalAcknowledgement().isEmpty()) { + out.add(violation("HTTP3_STABLE_FORBIDDEN", profile, "protocols")); + } + if (profile.mode() == ClientMode.DYNAMIC + && (profile.transport() == TransportType.JDK + || profile.transport() == TransportType.JETTY)) { + out.add(violation("DYNAMIC_TARGET_TRANSPORT_UNSUPPORTED", profile, "transport")); + } + if (profile.mode() == ClientMode.DYNAMIC && profile.baseUrl() == null) { + // A DYNAMIC profile takes its destination per call, but the runtime factory still builds its + // client from a base URL and called toString() on it unconditionally. The profile was + // accepted at startup and produced a NullPointerException while assembling the runtime. + out.add(violation("DYNAMIC_BASE_URL_REQUIRED", profile, "base-url")); + } + if (ProtocolIntent.of(profile.protocols()).requiresHttp2() + && profile.transport() != TransportType.REACTOR_NETTY) { + // Only Reactor Netty can be configured to offer H2 and nothing else. The JDK client treats + // HTTP_2 as a preference and silently negotiates HTTP/1.1; Apache's classic client is + // HTTP/1.1 + // only. A profile that requires H2 on either of them was getting HTTP/1.1 with no signal. + out.add(violation("HTTP2_REQUIRED_TRANSPORT_UNSUPPORTED", profile, "protocols")); + } + if (profile.pool().maxConnectionsPerRoute() > profile.pool().maxTotalConnections()) { + // A per-route ceiling above the total is incoherent, and on Reactor — where the per-route + // knob is the only one that exists — it silently becomes the effective limit. + out.add(violation("POOL_ROUTE_EXCEEDS_TOTAL", profile, "pool.max-connections-per-route")); + } + if (profile.tls().protocols().isEmpty()) { + // An empty set passed validation and then let the JVM pick, so a profile that meant to pin a + // TLS floor got whatever the platform default happened to be — including TLS 1.2 on a profile + // whose operator had deliberately emptied the list to "tighten" it. + out.add(violation("TLS_PROTOCOL_SET_REQUIRED", profile, "tls.protocols")); + } + if (profile.mode() == ClientMode.DYNAMIC && profile.proxy().enabled()) { + // A forward proxy re-resolves the hostname on its own side, so the addresses this platform + // validated and pinned are not the addresses the connection reaches. The SSRF defence would + // be present, correct, and bypassed. + out.add(violation("DYNAMIC_TARGET_PROXY_UNSUPPORTED", profile, "proxy.enabled")); + } + } + + private void validateCredentials(ClientProfile profile, List out) { + if (profile.mode() == ClientMode.DYNAMIC + && profile.authentication().type().attachesDefaultCredential()) { + out.add(violation("DYNAMIC_DEFAULT_CREDENTIAL_FORBIDDEN", profile, "authentication.type")); + } + if (profile.authentication().type() == AuthenticationType.OAUTH2_CLIENT_CREDENTIALS + && profile.authentication().registrationId().isEmpty()) { + out.add(violation("OAUTH2_REGISTRATION_REQUIRED", profile, "authentication.registration-id")); + } + if (profile.authentication().type() == AuthenticationType.API_KEY_HEADER + && profile.authentication().headerName().isEmpty()) { + out.add(violation("API_KEY_HEADER_NAME_REQUIRED", profile, "authentication.header-name")); + } + } + + private void validateTls( + ClientProfile profile, RuntimeEnvironment environment, List out) { + TlsSettings tls = profile.tls(); + if (tls.trustAll()) { + out.add(violation("TRUST_ALL_FORBIDDEN", profile, "tls.trust-all")); + } + if (!tls.hostnameVerification()) { + out.add(violation("HOSTNAME_VERIFICATION_REQUIRED", profile, "tls.hostname-verification")); + } + if (tls.allowPlainHttp() && environment.production()) { + out.add(violation("PLAINTEXT_FALLBACK_FORBIDDEN", profile, "tls.allow-plain-http")); + } + boolean unsupportedProtocol = + tls.protocols().stream() + .anyMatch(value -> !"TLSv1.2".equals(value) && !"TLSv1.3".equals(value)); + if (unsupportedProtocol) { + out.add(violation("TLS_PROTOCOL_FORBIDDEN", profile, "tls.protocols")); + } + } + + private void validateRetry(ClientProfile profile, List out) { + if (profile.retry().enabled() + && profile.retry().baseBackoff().isZero() + && profile.retry().jitter() == JitterStrategy.NONE) { + out.add(violation("RETRY_BACKOFF_REQUIRED", profile, "retry.base-backoff")); + } + // `policy` and `max-attempts` must agree. Nothing on the execution path read `policy` — only + // `max-attempts` decided whether a call retried — so the actuator could report + // `retryPolicy: none` for a profile that was retrying three times, and a profile named after a + // policy could have retry switched off by a `max-attempts` nobody re-read. A displayed policy + // that cannot contradict behaviour is worth more than one that describes an intention. + boolean declaredNone = "none".equalsIgnoreCase(profile.retry().policy()); + if (declaredNone && profile.retry().enabled()) { + out.add(violation("RETRY_POLICY_CONTRADICTS_ATTEMPTS", profile, "retry.policy")); + } + if (!declaredNone && !profile.retry().enabled()) { + out.add(violation("RETRY_POLICY_CONTRADICTS_ATTEMPTS", profile, "retry.max-attempts")); + } + } + + private void validateProductionCompleteness( + ClientProfile profile, RuntimeEnvironment environment, List out) { + if (!environment.production()) { + return; + } + if (profile.trusted() && profile.allowedHosts().isEmpty()) { + out.add(violation("MISSING_PRODUCTION_SETTING", profile, "allowed-hosts")); + } + if (profile.request().maxBodyBytes() == 0) { + out.add(violation("MISSING_PRODUCTION_SETTING", profile, "request.max-body-bytes")); + } + if (profile.tls().profileId().isEmpty()) { + out.add(violation("MISSING_PRODUCTION_SETTING", profile, "tls.profile-id")); + } + } + + private static int defaultPort(String scheme) { + return "http".equals(scheme) ? 80 : 443; + } + + private static ClientProfileViolation violation( + String code, ClientProfile profile, String setting) { + return new ClientProfileViolation( + code, "profile=" + profile.name().value() + " setting=" + setting); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/profile/ClientRuntimeRegistry.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/profile/ClientRuntimeRegistry.java new file mode 100644 index 00000000..09bb0b65 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/profile/ClientRuntimeRegistry.java @@ -0,0 +1,187 @@ +package dev.caskeleton.adapter.outbound.httpclient.profile; + +import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Atomic pointer from profile name to its current runtime generation (design §7.2). + * + *

A swap publishes the replacement first and drains the predecessor afterwards, so a rotation is + * never observable as a gap. The single scheduled executor exists only to enforce drain deadlines + * and is created lazily; it is shut down with the registry so no thread outlives it. + */ +public final class ClientRuntimeRegistry implements AutoCloseable { + + private static final long SHUTDOWN_AWAIT_MILLIS = 5_000L; + + private final ConcurrentMap> runtimes = + new ConcurrentHashMap<>(); + private final AtomicReference drainScheduler = new AtomicReference<>(); + + /** + * Generations that have been replaced but are not yet closed. + * + *

Held so shutdown can reach them. A rotation moved the old generation out of {@code runtimes} + * and left it owned only by a scheduled drain task, so a registry that closed before that task + * fired leaked the whole generation — and the resource-bound suite could not see it, because + * nothing enumerated it. + */ + private final Set retired = java.util.concurrent.ConcurrentHashMap.newKeySet(); + + public ClientRuntimeRegistry(Map initial) { + Objects.requireNonNull(initial, "initial runtimes"); + initial.forEach((name, runtime) -> runtimes.put(name, new AtomicReference<>(runtime))); + } + + public static ClientRuntimeRegistry empty() { + return new ClientRuntimeRegistry(Map.of()); + } + + public Set names() { + return Set.copyOf(runtimes.keySet()); + } + + public void register(ClientRuntime runtime) { + Objects.requireNonNull(runtime, "runtime"); + runtimes.put(runtime.name(), new AtomicReference<>(runtime)); + } + + /** + * Reserves the current generation. Retries against the newly published generation when the + * observed one began draining between the read and the reservation. + */ + public ClientRuntimeLease acquire(ClientProfileName name) { + AtomicReference holder = holder(name); + while (true) { + ClientRuntime runtime = holder.get(); + if (runtime.tryAcquire()) { + return new ClientRuntimeLease(runtime, runtime::release); + } + if (holder.get() == runtime) { + throw new IllegalStateException("http client runtime is shutting down: " + name.value()); + } + } + } + + public ClientRuntime current(ClientProfileName name) { + return holder(name).get(); + } + + public boolean contains(ClientProfileName name) { + return runtimes.containsKey(name); + } + + /** Publishes {@code replacement} and drains the previous generation (design §7.2 steps 3-6). */ + public void swap(ClientProfileName name, ClientRuntime replacement, Duration drainTimeout) { + Objects.requireNonNull(replacement, "replacement runtime"); + Objects.requireNonNull(drainTimeout, "drain timeout"); + ClientRuntime previous = holder(name).getAndSet(replacement); + if (previous == replacement) { + return; + } + // Tracked until it is actually closed. A retired generation that was still draining when the + // registry shut down was reachable from nothing: close() walked only the current generations, + // so its pool, its connections and its drain task outlived the registry that created them. + retired.add(previous); + previous.beginDrain(drainTimeout); + if (previous.state() != ClientRuntimeState.CLOSED && !drainTimeout.isZero()) { + ScheduledFuture unusedDrainDeadline = + scheduler() + .schedule( + () -> { + try { + previous.forceClose(); + } finally { + retired.remove(previous); + } + }, + drainTimeout.toMillis(), + TimeUnit.MILLISECONDS); + assert unusedDrainDeadline != null; + } else { + retired.remove(previous); + } + } + + @Override + public void close() { + List all = new ArrayList<>(); + runtimes.values().forEach(holder -> all.add(holder.get())); + // Retired-but-still-draining generations are closed too; they used to survive registry + // shutdown entirely. + all.addAll(retired); + // Every runtime is closed even when one refuses. forEach stopped at the first exception, so a + // single misbehaving pool left every remaining connection, thread and socket open — shutdown + // leaked more the worse the failure was. + RuntimeException firstFailure = null; + for (ClientRuntime runtime : all) { + try { + runtime.forceClose(); + } catch (RuntimeException failure) { + if (firstFailure == null) { + firstFailure = failure; + } else { + firstFailure.addSuppressed(failure); + } + } + } + retired.clear(); + runtimes.clear(); + if (firstFailure != null) { + throw firstFailure; + } + ScheduledExecutorService scheduler = drainScheduler.getAndSet(null); + if (scheduler != null) { + // Await termination: a registry that returns while its drain thread is still alive would + // leak a thread per rotation cycle, which the resource-bound suite exists to catch. + scheduler.shutdownNow(); + try { + if (!scheduler.awaitTermination(SHUTDOWN_AWAIT_MILLIS, TimeUnit.MILLISECONDS)) { + throw new IllegalStateException("http client drain scheduler did not terminate"); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + } + } + + private AtomicReference holder(ClientProfileName name) { + AtomicReference holder = runtimes.get(name); + if (holder == null) { + throw new NoSuchElementException("unregistered http client profile: " + name.value()); + } + return holder; + } + + private ScheduledExecutorService scheduler() { + ScheduledExecutorService existing = drainScheduler.get(); + if (existing != null) { + return existing; + } + ScheduledExecutorService created = + Executors.newSingleThreadScheduledExecutor( + runnable -> { + Thread thread = new Thread(runnable, "httpclient-runtime-drain"); + thread.setDaemon(true); + return thread; + }); + if (drainScheduler.compareAndSet(null, created)) { + return created; + } + created.shutdownNow(); + return drainScheduler.get(); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/profile/ProtocolIntent.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/profile/ProtocolIntent.java new file mode 100644 index 00000000..8e1d299d --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/profile/ProtocolIntent.java @@ -0,0 +1,61 @@ +package dev.caskeleton.adapter.outbound.httpclient.profile; + +import java.util.Set; + +/** + * What a profile's declared protocol set actually asks for (design §6.3, §24). + * + *

A bare set of protocols does not say whether HTTP/2 is a preference or a requirement, and + * every transport resolved that ambiguity in the direction that could not fail. The JDK client + * treats {@code HTTP_2} as "try H2, fall back to H1"; Reactor Netty was configured with {@code {H2, + * HTTP11}} whenever H2 appeared at all. A profile that declared only {@code HTTP_2} — the way an + * operator states a requirement — therefore ran happily over HTTP/1.1, and nothing anywhere said + * so. gRPC-style upstreams, header-compression assumptions and concurrency budgets all quietly + * changed meaning. + * + *

Naming the intent makes the requirement expressible, and lets a transport that cannot honour + * it refuse at startup instead of downgrading at runtime. + */ +public enum ProtocolIntent { + + /** Only HTTP/1.1 is acceptable. */ + H1_ONLY, + + /** Prefer HTTP/2, accept HTTP/1.1. The safe default for a general-purpose upstream. */ + NEGOTIATE_H2_H1, + + /** HTTP/2 is required; falling back to HTTP/1.1 is a failure, not a degradation. */ + H2_REQUIRED, + + /** Experimental HTTP/3, gated by the acknowledgement and its own transport. */ + H3_EXPERIMENTAL; + + /** + * Derives the intent a declared protocol set expresses. + * + * @param protocols the profile's declared protocols + * @return the intent; declaring HTTP/2 alone means it is required + */ + public static ProtocolIntent of(Set protocols) { + if (protocols.contains(HttpProtocol.HTTP_3)) { + return H3_EXPERIMENTAL; + } + boolean h2 = protocols.contains(HttpProtocol.HTTP_2); + boolean h1 = protocols.contains(HttpProtocol.HTTP_1_1); + if (h2 && h1) { + return NEGOTIATE_H2_H1; + } + if (h2) { + return H2_REQUIRED; + } + return H1_ONLY; + } + + public boolean requiresHttp2() { + return this == H2_REQUIRED; + } + + public boolean allowsHttp2() { + return this == NEGOTIATE_H2_H1 || this == H2_REQUIRED; + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/profile/TimeoutSettings.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/profile/TimeoutSettings.java new file mode 100644 index 00000000..b9502e98 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/profile/TimeoutSettings.java @@ -0,0 +1,46 @@ +package dev.caskeleton.adapter.outbound.httpclient.profile; + +import java.time.Duration; +import java.util.Objects; + +/** + * Stage timeouts and the total call budget (design §15.1). + * + *

{@link #totalCall()} is the upper budget for everything, including pool acquire and retry + * backoff. {@link #streamingIdle()} is deliberately separate so a long-lived SSE stream is not + * killed by the request-shaped total budget (design §15.3). + */ +public record TimeoutSettings( + // Hostname resolution budget. Currently unenforced: neither the Apache classic client nor the + // JDK client exposes a DNS resolution timeout, so the platform refuses a non-default value + // rather than accepting one it cannot honour. See DNS_TIMEOUT_UNSUPPORTED. + Duration dns, + Duration connect, + Duration tlsHandshake, + Duration proxyConnect, + Duration requestWriteIdle, + Duration responseHeader, + Duration readIdle, + Duration totalCall, + Duration streamingIdle) { + + /** + * The shipped {@code timeout.dns} default. + * + *

Named so the validator can tell "the operator left this alone" from "the operator asked for + * a DNS budget the platform cannot deliver". Only the second is refused. + */ + public static final Duration DEFAULT_DNS = Duration.ofMillis(300); + + public TimeoutSettings { + Objects.requireNonNull(dns, "dns timeout"); + Objects.requireNonNull(connect, "connect timeout"); + Objects.requireNonNull(tlsHandshake, "tls handshake timeout"); + Objects.requireNonNull(proxyConnect, "proxy connect timeout"); + Objects.requireNonNull(requestWriteIdle, "request write idle timeout"); + Objects.requireNonNull(responseHeader, "response header timeout"); + Objects.requireNonNull(readIdle, "read idle timeout"); + Objects.requireNonNull(totalCall, "total call timeout"); + Objects.requireNonNull(streamingIdle, "streaming idle timeout"); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/reactor/ReactorConnectionProviderFactory.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/reactor/ReactorConnectionProviderFactory.java new file mode 100644 index 00000000..c2ebcde9 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/reactor/ReactorConnectionProviderFactory.java @@ -0,0 +1,42 @@ +package dev.caskeleton.adapter.outbound.httpclient.reactor; + +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile; +import java.util.Objects; +import reactor.netty.resources.ConnectionProvider; + +/** + * Creates a connection pool scoped to one profile (design §13.6). + * + *

A shared global pool would let one slow upstream starve every other one, so each profile gets + * its own named provider with its own limits and eviction. + */ +public final class ReactorConnectionProviderFactory { + + /** + * Builds the pool, mapping each setting onto the Reactor knob that means the same thing. + * + *

{@code ConnectionProvider.maxConnections} is a per-remote-host ceiling, and it was + * being handed {@code maxTotalConnections}. For a trusted profile with one base URL the two + * coincide, so nothing looked wrong; for a dynamic profile talking to many hosts it meant every + * destination independently received the budget intended for all of them combined, and {@code + * maxConnectionsPerRoute} — the setting that actually describes this limit — was ignored + * entirely. + * + *

Reactor Netty has no cross-destination ceiling to map {@code maxTotalConnections} onto. That + * is a real gap rather than something to paper over: the validator requires per-route not to + * exceed the total, so the configured numbers stay coherent, and a dynamic profile's true global + * bound comes from the platform's own admission limiter. + */ + public ConnectionProvider create(ClientProfile profile) { + Objects.requireNonNull(profile, "profile"); + return ConnectionProvider.builder(profile.name().value()) + .maxConnections(profile.pool().maxConnectionsPerRoute()) + .pendingAcquireMaxCount(profile.pool().maxPendingAcquires()) + .pendingAcquireTimeout(profile.pool().pendingAcquireTimeout()) + .maxIdleTime(profile.pool().maxIdleTime()) + .maxLifeTime(profile.pool().maxLifeTime()) + .evictInBackground(profile.pool().evictionInterval()) + .metrics(true) + .build(); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/reactor/ReactorFailureClassifier.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/reactor/ReactorFailureClassifier.java new file mode 100644 index 00000000..5399c56d --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/reactor/ReactorFailureClassifier.java @@ -0,0 +1,137 @@ +package dev.caskeleton.adapter.outbound.httpclient.reactor; + +import dev.caskeleton.adapter.outbound.httpclient.api.operation.AttemptStage; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.FailureCategory; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailure; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier; +import java.net.ConnectException; +import java.net.NoRouteToHostException; +import java.net.UnknownHostException; +import java.security.cert.CertificateException; +import java.util.concurrent.TimeoutException; +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLHandshakeException; +import javax.net.ssl.SSLPeerUnverifiedException; + +/** + * Maps Reactor Netty failures onto the same stable evidence the blocking transports produce (design + * §13.3). + * + *

Reactor wraps causes, so the chain is unwrapped before classification; a cancellation is + * reported as such rather than as a timeout, because the two have different retry meaning. + */ +public final class ReactorFailureClassifier implements TransportFailureClassifier { + + @Override + public TransportFailure classify(Throwable failure, AttemptStage lastObservedStage) { + // Reactor wraps causes several layers deep; the whole chain is inspected so a wrapped + // ConnectException is still recognised as provably NOT_SENT. + for (Throwable cause : chain(failure)) { + TransportFailure recognized = recognize(cause, lastObservedStage); + if (recognized != null) { + return recognized; + } + } + return fallback(lastObservedStage); + } + + private TransportFailure recognize(Throwable cause, AttemptStage lastObservedStage) { + if (cause instanceof UnknownHostException) { + return TransportFailure.notSent( + AttemptStage.DNS, FailureCategory.DNS, "DNS_RESOLUTION_FAILED"); + } + if (cause instanceof ConnectException || cause instanceof NoRouteToHostException) { + return TransportFailure.notSent( + AttemptStage.CONNECT, FailureCategory.CONNECT, "CONNECT_FAILED"); + } + if (cause instanceof io.netty.handler.ssl.SslHandshakeTimeoutException) { + // Checked before the SSLHandshakeException branch below, which it extends. Without this a + // handshake that merely ran out of time was classified TLS_PERMANENT — the category that + // forbids retry — so a momentarily slow peer produced a hard failure indistinguishable from + // an untrusted certificate. + return TransportFailure.notSent( + AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_TRANSIENT, "TLS_HANDSHAKE_TIMEOUT"); + } + if (cause instanceof SSLPeerUnverifiedException || cause instanceof CertificateException) { + return TransportFailure.notSent( + AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_PERMANENT, "TLS_TRUST_FAILED"); + } + if (cause instanceof SSLHandshakeException) { + return TransportFailure.notSent( + AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_PERMANENT, "TLS_HANDSHAKE_FAILED"); + } + if (cause instanceof SSLException + && !lastObservedStage.isAtLeast(AttemptStage.REQUEST_HEADERS)) { + return TransportFailure.notSent( + AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_TRANSIENT, "TLS_TRANSIENT_FAILURE"); + } + if (isPoolAcquireTimeout(cause)) { + return TransportFailure.notSent( + AttemptStage.POOL_ACQUIRE, FailureCategory.POOL_ACQUIRE_TIMEOUT, "POOL_ACQUIRE_TIMEOUT"); + } + if (cause instanceof java.util.concurrent.CancellationException) { + return new TransportFailure( + lastObservedStage, + lastObservedStage.provesNotSent() + ? ExecutionEvidence.NOT_SENT + : ExecutionEvidence.SENT_NO_RESPONSE, + FailureCategory.CANCELLED, + "CANCELLED"); + } + if (cause instanceof io.netty.handler.timeout.WriteTimeoutException) { + // Netty's timeout hierarchy does not extend java.util.concurrent.TimeoutException, so none of + // these reached the branch below — every read or write timeout fell through to the generic + // fallback and lost its stage and category. + return TransportFailure.sentNoResponse( + AttemptStage.REQUEST_BODY, FailureCategory.REQUEST_WRITE, "REQUEST_WRITE_TIMEOUT"); + } + if (cause instanceof TimeoutException + || cause instanceof io.netty.handler.timeout.ReadTimeoutException) { + if (lastObservedStage.isAtLeast(AttemptStage.RESPONSE_BODY)) { + return new TransportFailure( + lastObservedStage, + ExecutionEvidence.PARTIAL_RESPONSE, + FailureCategory.RESPONSE_TIMEOUT, + "RESPONSE_BODY_TIMEOUT"); + } + return TransportFailure.sentNoResponse( + AttemptStage.RESPONSE_HEADERS, + FailureCategory.RESPONSE_TIMEOUT, + "RESPONSE_HEADER_TIMEOUT"); + } + return null; + } + + private TransportFailure fallback(AttemptStage lastObservedStage) { + if (lastObservedStage.provesNotSent()) { + return TransportFailure.notSent( + lastObservedStage, FailureCategory.UNKNOWN, "TRANSPORT_FAILURE"); + } + if (lastObservedStage.isAtLeast(AttemptStage.RESPONSE_BODY)) { + return new TransportFailure( + lastObservedStage, + ExecutionEvidence.PARTIAL_RESPONSE, + FailureCategory.RESPONSE_TRUNCATED, + "TRANSPORT_FAILURE"); + } + return TransportFailure.sentNoResponse( + lastObservedStage, FailureCategory.UNKNOWN, "TRANSPORT_FAILURE"); + } + + private boolean isPoolAcquireTimeout(Throwable cause) { + String message = cause.getMessage(); + return cause.getClass().getName().contains("PoolAcquireTimeoutException") + || (message != null && message.contains("Pool#acquire(Duration)")); + } + + private java.util.List chain(Throwable failure) { + java.util.List chain = new java.util.ArrayList<>(); + Throwable current = failure; + while (current != null && !chain.contains(current)) { + chain.add(current); + current = current.getCause(); + } + return chain; + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/reactor/ReactorHttpClientFactory.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/reactor/ReactorHttpClientFactory.java new file mode 100644 index 00000000..2fb89119 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/reactor/ReactorHttpClientFactory.java @@ -0,0 +1,137 @@ +package dev.caskeleton.adapter.outbound.httpclient.reactor; + +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile; +import dev.caskeleton.adapter.outbound.httpclient.security.SslContextMaterial; +import io.netty.channel.ChannelOption; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.resolver.AddressResolverGroup; +import java.net.InetAddress; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; +import reactor.netty.http.Http11SslContextSpec; +import reactor.netty.http.Http2SslContextSpec; +import reactor.netty.http.HttpProtocol; +import reactor.netty.http.client.HttpClient; +import reactor.netty.resources.ConnectionProvider; +import reactor.netty.tcp.SslProvider; +import reactor.netty.transport.ProxyProvider; + +/** + * Builds the Reactor Netty client for one profile (design §13.6). + * + *

Redirect following is disabled here for the same reason as in the blocking transports: the + * platform re-validates each hop and strips credentials across origins, and the engine's follower + * does neither. + */ +public final class ReactorHttpClientFactory { + + public HttpClient create( + ClientProfile profile, + ConnectionProvider connectionProvider, + Optional tlsMaterial, + Optional>> approvedAddresses) { + Objects.requireNonNull(profile, "profile"); + Objects.requireNonNull(connectionProvider, "connection provider"); + + HttpClient client = + HttpClient.create(connectionProvider) + .option( + ChannelOption.CONNECT_TIMEOUT_MILLIS, + Math.toIntExact(profile.timeout().connect().toMillis())) + .responseTimeout(profile.timeout().responseHeader()) + .followRedirect(false) + .compress(profile.request().compression()) + .protocol(protocols(profile)) + .metrics(true, Function.identity()) + // request-write-idle and read-idle were bound and then reached no transport at all, so + // a request that stalled mid-write hung until the total-call budget expired instead of + // failing at the stage that actually stopped. Netty's idle handlers are where those two + // settings become real; they are installed per connection so a stall is attributed to + // the write or the read rather than to "the call". + .doOnConnected( + connection -> + connection + .addHandlerLast( + new io.netty.handler.timeout.WriteTimeoutHandler( + profile.timeout().requestWriteIdle().toMillis(), + java.util.concurrent.TimeUnit.MILLISECONDS)) + .addHandlerLast( + new io.netty.handler.timeout.ReadTimeoutHandler( + profile.timeout().readIdle().toMillis(), + java.util.concurrent.TimeUnit.MILLISECONDS))); + + if (approvedAddresses.isPresent()) { + AddressResolverGroup resolver = new ValidatedAddressResolverGroup(approvedAddresses.get()); + client = client.resolver(resolver); + } + // Configured whether or not custom material is supplied. It used to be inside this isPresent, + // so a profile declaring `tls.protocols: [TLSv1.3]` against the JVM trust store — the common + // case — configured no TLS parameters at all and accepted whatever the platform default + // allowed, TLS 1.2 included. A declared floor that only applies alongside a custom truststore + // is not a floor. + SslProvider.GenericSslContextSpec contextSpec = + sslContextSpec(profile, tlsMaterial.orElse(null)); + client = client.secure(spec -> spec.sslContext(contextSpec)); + if (profile.proxy().enabled()) { + client = + client.proxy( + spec -> + spec.type( + profile.proxy().type() + == dev.caskeleton.adapter.outbound.httpclient.profile.ProxyType + .SOCKS + ? ProxyProvider.Proxy.SOCKS5 + : ProxyProvider.Proxy.HTTP) + .host(profile.proxy().host()) + .port(profile.proxy().port()) + .connectTimeoutMillis(profile.proxy().connectTimeout().toMillis())); + } + return client; + } + + /** + * The TLS spec, with or without custom material. + * + *

{@code material} is nullable on purpose: a profile using the JVM trust store still declares + * a protocol floor, and that floor has to reach the SSL context. + */ + private SslProvider.GenericSslContextSpec sslContextSpec( + ClientProfile profile, SslContextMaterial material) { + String[] tlsProtocols = + material != null + ? material.protocolArray() + : profile.tls().protocols().toArray(String[]::new); + java.util.function.Consumer configurer = + builder -> { + if (material != null) { + material.trustManagerFactory().ifPresent(builder::trustManager); + material.keyManagerFactory().ifPresent(builder::keyManager); + } + builder.protocols(tlsProtocols); + }; + return profile + .protocols() + .contains(dev.caskeleton.adapter.outbound.httpclient.profile.HttpProtocol.HTTP_2) + ? Http2SslContextSpec.forClient().configure(configurer) + : Http11SslContextSpec.forClient().configure(configurer); + } + + /** + * Configures exactly the protocols the profile asked for. + * + *

{@code {H2, HTTP11}} used to be configured whenever HTTP/2 appeared in the set at all, so a + * profile that declared only HTTP/2 — an operator stating a requirement — negotiated HTTP/1.1 + * against any peer that offered it, silently. An H2-required profile now gets H2 alone, and a + * peer that cannot speak it fails the handshake instead of downgrading. + */ + private HttpProtocol[] protocols(ClientProfile profile) { + return switch (dev.caskeleton.adapter.outbound.httpclient.profile.ProtocolIntent.of( + profile.protocols())) { + case H2_REQUIRED -> new HttpProtocol[] {HttpProtocol.H2}; + case NEGOTIATE_H2_H1 -> new HttpProtocol[] {HttpProtocol.H2, HttpProtocol.HTTP11}; + default -> new HttpProtocol[] {HttpProtocol.HTTP11}; + }; + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/reactor/ReactorNettyTransportProvider.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/reactor/ReactorNettyTransportProvider.java new file mode 100644 index 00000000..32d596a8 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/reactor/ReactorNettyTransportProvider.java @@ -0,0 +1,94 @@ +package dev.caskeleton.adapter.outbound.httpclient.reactor; + +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile; +import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration; +import dev.caskeleton.adapter.outbound.httpclient.security.SslContextMaterial; +import dev.caskeleton.adapter.outbound.httpclient.transport.ReactiveTransportCapabilities; +import dev.caskeleton.adapter.outbound.httpclient.transport.ReactiveTransportProvider; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportId; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportLifecycleListener; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportResourceKey; +import io.micrometer.core.instrument.MeterRegistry; +import java.net.InetAddress; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import reactor.netty.resources.ConnectionProvider; + +/** + * Reactive default transport (design D-07, §13.6). + * + *

Returns a Spring {@code ClientHttpConnector}; the Reactor Netty {@code HttpClient} stays + * inside this package so application code cannot bypass profile configuration. + */ +public final class ReactorNettyTransportProvider implements ReactiveTransportProvider { + + private static final TransportId ID = new TransportId("reactor-netty"); + + private final ReactorConnectionProviderFactory poolFactory = + new ReactorConnectionProviderFactory(); + private final ReactorHttpClientFactory clientFactory = new ReactorHttpClientFactory(); + private final ReactorFailureClassifier classifier = new ReactorFailureClassifier(); + private final Map pools = new ConcurrentHashMap<>(); + private final Optional meterRegistry; + private final Function> tlsMaterialResolver; + private final Function>>> + resolverFactory; + + public ReactorNettyTransportProvider() { + this(Optional.empty(), profile -> Optional.empty(), profile -> Optional.empty()); + } + + public ReactorNettyTransportProvider( + Optional meterRegistry, + Function> tlsMaterialResolver, + Function>>> resolverFactory) { + this.meterRegistry = Objects.requireNonNull(meterRegistry, "meter registry"); + this.tlsMaterialResolver = Objects.requireNonNull(tlsMaterialResolver, "tls material resolver"); + this.resolverFactory = Objects.requireNonNull(resolverFactory, "dns resolver factory"); + } + + @Override + public TransportId id() { + return ID; + } + + @Override + public ReactiveTransportCapabilities capabilities() { + return ReactiveTransportCapabilities.reactorNetty(); + } + + @Override + public ClientHttpConnector create( + ClientProfile profile, RuntimeGeneration generation, TransportLifecycleListener listener) { + Objects.requireNonNull(profile, "profile"); + Objects.requireNonNull(listener, "lifecycle listener"); + ConnectionProvider pool = poolFactory.create(profile); + pools.put(new TransportResourceKey(profile.name(), generation), pool); + meterRegistry.ifPresent( + registry -> ReactorPoolMetricsBinder.bind(registry, profile.name(), pool)); + listener.onRuntimeCreated(profile.name(), ID); + return new ReactorClientHttpConnector( + clientFactory.create( + profile, pool, tlsMaterialResolver.apply(profile), resolverFactory.apply(profile))); + } + + @Override + public TransportFailureClassifier failureClassifier() { + return classifier; + } + + @Override + public void close(ClientProfile profile, RuntimeGeneration generation) { + ConnectionProvider pool = pools.remove(new TransportResourceKey(profile.name(), generation)); + if (pool != null) { + pool.disposeLater().block(profile.pool().shutdownTimeout()); + } + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/AttemptResiliencePipeline.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/AttemptResiliencePipeline.java new file mode 100644 index 00000000..d306c441 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/AttemptResiliencePipeline.java @@ -0,0 +1,135 @@ +package dev.caskeleton.adapter.outbound.httpclient.resilience; + +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpBulkheadRejectedException; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpCircuitOpenException; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpClientException; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRateLimitRejectedException; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Fixed guard order for every physical attempt (design D-11, §18): Circuit Breaker → Rate Limiter → + * Bulkhead → HTTP call, released in reverse. + * + *

The order is not cosmetic. An open circuit must reject before a rate token or a bulkhead + * permit is spent, otherwise a dead upstream keeps consuming the quota and concurrency that healthy + * upstreams need. + * + *

A local rejection (rate limiter or bulkhead) is deliberately not recorded as a + * circuit error: the upstream never saw the request, and counting our own back-pressure as upstream + * failure would open the breaker on a healthy dependency. + */ +public final class AttemptResiliencePipeline { + + private final AttemptCircuitBreaker circuitBreaker; + private final AttemptRateLimiter rateLimiter; + private final BlockingAttemptBulkhead bulkhead; + private final Supplier metadataSupplier; + private final ResilienceRejectionRecorder rejections; + + /** Keeps the existing four-argument shape for callers that do not record metrics. */ + public AttemptResiliencePipeline( + AttemptCircuitBreaker circuitBreaker, + AttemptRateLimiter rateLimiter, + BlockingAttemptBulkhead bulkhead, + Supplier metadataSupplier) { + this( + circuitBreaker, + rateLimiter, + bulkhead, + metadataSupplier, + ResilienceRejectionRecorder.noop()); + } + + public AttemptResiliencePipeline( + AttemptCircuitBreaker circuitBreaker, + AttemptRateLimiter rateLimiter, + BlockingAttemptBulkhead bulkhead, + Supplier metadataSupplier, + ResilienceRejectionRecorder rejections) { + this.circuitBreaker = Objects.requireNonNull(circuitBreaker, "circuit breaker"); + this.rateLimiter = Objects.requireNonNull(rateLimiter, "rate limiter"); + this.bulkhead = Objects.requireNonNull(bulkhead, "bulkhead"); + this.metadataSupplier = Objects.requireNonNull(metadataSupplier, "failure metadata supplier"); + this.rejections = Objects.requireNonNull(rejections, "rejection recorder"); + } + + public String circuitState() { + return circuitBreaker.state(); + } + + public T execute(AttemptCall call) { + return execute(call, result -> Optional.empty()); + } + + /** + * Runs one attempt and records the breaker outcome from the remote result. + * + *

The classifier exists because a returned value is not necessarily a success. The blocking + * executor used to run only the raw send inside this pipeline and map the response to a stable + * exception afterwards, outside it — so a 503 completed the call normally, the breaker recorded a + * success, and an upstream that answered nothing but 503 never opened its circuit. The thing the + * breaker is for was the one thing it could not see. + * + * @param call the attempt, including any redirect hops it follows + * @param remoteFailure returns the failure to record when the value represents an upstream error + * @return the attempt's value, whether or not it represents a remote failure + */ + public T execute(AttemptCall call, Function> remoteFailure) { + Objects.requireNonNull(remoteFailure, "remote failure classifier"); + if (!circuitBreaker.tryAcquirePermission()) { + rejections.circuitOpen(); + throw new HttpCircuitOpenException( + "upstream circuit breaker is open", metadataSupplier.get()); + } + if (!rateLimiter.tryAcquirePermission()) { + rejections.rateLimited(); + throw new HttpRateLimitRejectedException( + "local attempt rate limit reached", metadataSupplier.get()); + } + if (!bulkhead.tryAcquire()) { + rateLimiter.onCompleted(); + rejections.bulkheadRejected(); + throw new HttpBulkheadRejectedException( + "attempt bulkhead has no permit available", metadataSupplier.get()); + } + + long started = System.nanoTime(); + try { + T result = call.call(); + releaseAttemptPermits(); + Optional upstreamFailure = remoteFailure.apply(result); + if (upstreamFailure.isPresent()) { + circuitBreaker.onError(System.nanoTime() - started, upstreamFailure.get()); + } else { + circuitBreaker.onSuccess(System.nanoTime() - started); + } + return result; + } catch (Throwable failure) { + releaseAttemptPermits(); + circuitBreaker.onError(System.nanoTime() - started, failure); + throw translate(failure); + } + } + + private void releaseAttemptPermits() { + bulkhead.release(); + rateLimiter.onCompleted(); + } + + private RuntimeException translate(Throwable failure) { + if (failure instanceof HttpClientException stable) { + return stable; + } + if (failure instanceof RuntimeException runtime) { + return runtime; + } + if (failure instanceof Error error) { + throw error; + } + return new IllegalStateException("outbound http attempt failed", failure); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/DefaultRetryEligibilityEngine.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/DefaultRetryEligibilityEngine.java new file mode 100644 index 00000000..2ad13a74 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/DefaultRetryEligibilityEngine.java @@ -0,0 +1,133 @@ +package dev.caskeleton.adapter.outbound.httpclient.resilience; + +import dev.caskeleton.adapter.outbound.httpclient.api.HttpStatus; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence; +import java.time.Duration; +import java.util.Optional; + +/** + * The complete ordered retry decision table (design §17.3). + * + *

The order is the point. Cheap absolute blockers come first (attempts, budget, replayability, + * first byte, deadline, draining), then ambiguity, then status- and failure-specific rules. A later + * rule can never re-enable something an earlier rule forbade. + */ +public final class DefaultRetryEligibilityEngine implements RetryEligibilityEngine { + + @Override + public RetryDecision decide(RetryContext context) { + if (context.attempt() >= context.maxAttempts()) { + return RetryDenied.maxAttempts(); + } + if (!context.budget().available()) { + return RetryDenied.budgetExhausted(); + } + if (!context.replayability().canReplay()) { + return RetryDenied.bodyNotReplayable(); + } + if (context.firstByteDelivered()) { + return RetryDenied.responseAlreadyDelivered(); + } + if (context.runtimeDraining()) { + return RetryDenied.runtimeDraining(); + } + if (context.remainingDeadline().compareTo(context.minimumAttemptBudget()) <= 0) { + return RetryDenied.deadline(); + } + if (context.failureCategory().permanent()) { + return RetryDenied.permanentFailure(context.failureCategory().name()); + } + if (context.evidence() == ExecutionEvidence.PARTIAL_RESPONSE) { + // A partial response that never reached the caller may still be retried for a safe + // operation; once a byte was delivered the earlier guard has already denied it. + return context.safelyIdempotent() + ? RetryAllowed.of("PARTIAL_RESPONSE") + : AmbiguousFailure.remoteOutcomeUnknown(); + } + if (context.evidence() == ExecutionEvidence.SENT_NO_RESPONSE && !context.safelyIdempotent()) { + return AmbiguousFailure.remoteOutcomeUnknown(); + } + return statusOrFailureDecision(context); + } + + private RetryDecision statusOrFailureDecision(RetryContext context) { + Optional status = context.responseStatus(); + if (status.isPresent()) { + return statusDecision(context, status.get().value()); + } + return failureDecision(context); + } + + private RetryDecision statusDecision(RetryContext context, int status) { + return switch (status) { + // 408, 425 and 429 all mean the request reached the upstream and was answered, so repeating + // one is only safe under the same rule as every other repeat. These three used to skip that + // check: a non-idempotent POST answered 429 was retried, and a rate-limited upstream that had + // already accepted the work got it a second time. A 429 is a scheduling signal, never a + // statement that nothing happened. + case 408 -> + context.safelyIdempotent() + ? allowWithin(context, "REQUEST_TIMEOUT") + : AmbiguousFailure.remoteOutcomeUnknown(); + // 425 Too Early: repeating once without early data is safe; repeating repeatedly is not. + case 425 -> { + if (!context.safelyIdempotent()) { + yield AmbiguousFailure.remoteOutcomeUnknown(); + } + yield context.attempt() == 1 + ? allowWithin(context, "TOO_EARLY") + : RetryDenied.maxAttempts(); + } + case 429 -> + context.safelyIdempotent() + ? allowWithin(context, "RATE_LIMITED") + : RetryDenied.notRetryableStatus(status); + case 401 -> + context.credentialRefreshAvailable() + && context.attempt() == 1 + && context.safelyIdempotent() + ? RetryAllowed.of("UNAUTHORIZED_REFRESH") + : RetryDenied.notRetryableStatus(status); + case 500 -> + context.transientServerErrorStatuses().contains(500) && context.safelyIdempotent() + ? allowWithin(context, "UPSTREAM_TRANSIENT") + : RetryDenied.notRetryableStatus(status); + case 502, 503, 504 -> + context.safelyIdempotent() + ? allowWithin(context, "UPSTREAM_UNAVAILABLE") + : AmbiguousFailure.remoteOutcomeUnknown(); + default -> RetryDenied.notRetryableStatus(status); + }; + } + + private RetryDecision failureDecision(RetryContext context) { + return switch (context.failureCategory()) { + case POOL_ACQUIRE_TIMEOUT -> RetryAllowed.of("POOL_ACQUIRE_TIMEOUT"); + case DNS -> RetryAllowed.of("DNS"); + case CONNECT -> RetryAllowed.of("CONNECT"); + case PROXY -> RetryAllowed.of("PROXY"); + case TLS_TRANSIENT -> RetryAllowed.of("TLS_TRANSIENT"); + case REQUEST_WRITE, RESPONSE_TIMEOUT, RESPONSE_TRUNCATED -> + context.safelyIdempotent() + ? RetryAllowed.of(context.failureCategory().name()) + : AmbiguousFailure.remoteOutcomeUnknown(); + case NONE -> RetryDenied.success(); + case CIRCUIT_OPEN, RATE_LIMIT_REJECTED, BULKHEAD_REJECTED -> + RetryDenied.permanentFailure(context.failureCategory().name()); + default -> RetryDenied.permanentFailure(context.failureCategory().name()); + }; + } + + /** Honors {@code Retry-After} only when the wait still fits inside the remaining deadline. */ + private RetryDecision allowWithin(RetryContext context, String reason) { + Optional retryAfter = context.retryAfter(); + if (retryAfter.isEmpty()) { + return RetryAllowed.of(reason); + } + Duration required = retryAfter.get().plus(context.minimumAttemptBudget()); + if (required.compareTo(context.remainingDeadline()) > 0) { + return RetryDenied.deadline(); + } + return RetryAllowed.after(reason, retryAfter.get()); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/ExponentialFullJitterBackoff.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/ExponentialFullJitterBackoff.java new file mode 100644 index 00000000..bd908670 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/ExponentialFullJitterBackoff.java @@ -0,0 +1,93 @@ +package dev.caskeleton.adapter.outbound.httpclient.resilience; + +import dev.caskeleton.adapter.outbound.httpclient.profile.JitterStrategy; +import dev.caskeleton.adapter.outbound.httpclient.profile.RetryAfterPolicy; +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; +import java.util.random.RandomGenerator; + +/** + * Exponential backoff with jitter, bounded by max backoff, {@code Retry-After}, and the remaining + * deadline (design §17.5). + * + *

Jitter is not decoration: without it, a fleet that failed together retries together, and the + * upstream recovery window never opens. The random source is injectable so the schedule is + * testable. + */ +public final class ExponentialFullJitterBackoff implements BackoffStrategy { + + private final Duration baseBackoff; + private final Duration maxBackoff; + private final JitterStrategy jitter; + private final RetryAfterPolicy retryAfterPolicy; + private final RandomGenerator random; + private Duration previousDelay; + + public ExponentialFullJitterBackoff( + Duration baseBackoff, + Duration maxBackoff, + JitterStrategy jitter, + RetryAfterPolicy retryAfterPolicy, + RandomGenerator random) { + this.baseBackoff = Objects.requireNonNull(baseBackoff, "base backoff"); + this.maxBackoff = Objects.requireNonNull(maxBackoff, "max backoff"); + this.jitter = Objects.requireNonNull(jitter, "jitter strategy"); + this.retryAfterPolicy = Objects.requireNonNull(retryAfterPolicy, "retry-after policy"); + this.random = Objects.requireNonNull(random, "random generator"); + this.previousDelay = baseBackoff; + } + + /** + * The wait before the next attempt. + * + *

An honoured {@code Retry-After} is not clamped to {@code maxBackoff}. It used to + * be, which made {@link RetryAfterPolicy#HONOR} and {@link RetryAfterPolicy#CAP} the same policy: + * a profile that chose to honour a rate limiter's instruction still retried after its own 200ms + * ceiling, hammering an upstream that had asked for thirty seconds. Two settings that cannot + * differ are one setting and a false promise. + * + *

The remaining deadline still bounds everything, because waiting past the point where the + * next attempt could finish is not a retry — it is a slower failure. The retry engine separately + * refuses to honour a {@code Retry-After} that does not fit, so the two agree. + */ + @Override + public Duration delay( + int completedAttempts, Optional retryAfter, Duration remainingDeadline) { + Optional honoured = honoredRetryAfter(retryAfter); + Duration candidate = honoured.orElseGet(() -> min(computed(completedAttempts), maxBackoff)); + previousDelay = candidate.isZero() ? baseBackoff : min(candidate, maxBackoff); + // Never wait past the point where the following attempt could still finish. + return min(candidate, remainingDeadline); + } + + private Optional honoredRetryAfter(Optional retryAfter) { + return switch (retryAfterPolicy) { + case IGNORE -> Optional.empty(); + case HONOR -> retryAfter; + case CAP -> retryAfter.map(value -> min(value, maxBackoff)); + }; + } + + private Duration computed(int completedAttempts) { + long exponent = Math.max(0, completedAttempts - 1); + long scaled = baseBackoff.toMillis() << Math.min(exponent, 20); + long capped = Math.min(scaled, maxBackoff.toMillis()); + return switch (jitter) { + case NONE -> Duration.ofMillis(capped); + case FULL -> Duration.ofMillis(capped <= 0 ? 0 : random.nextLong(capped + 1)); + case DECORRELATED -> { + long lower = baseBackoff.toMillis(); + long upper = Math.min(maxBackoff.toMillis(), Math.max(lower, previousDelay.toMillis() * 3)); + yield Duration.ofMillis(upper <= lower ? lower : random.nextLong(lower, upper + 1)); + } + }; + } + + private static Duration min(Duration left, Duration right) { + if (left.isNegative()) { + return Duration.ZERO; + } + return left.compareTo(right) <= 0 ? left : right; + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/ResilienceRejectionRecorder.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/ResilienceRejectionRecorder.java new file mode 100644 index 00000000..a7738acb --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/ResilienceRejectionRecorder.java @@ -0,0 +1,93 @@ +package dev.caskeleton.adapter.outbound.httpclient.resilience; + +import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName; +import dev.caskeleton.adapter.outbound.httpclient.observation.HttpClientObservationNames; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import java.util.List; +import java.util.Objects; +import java.util.function.Supplier; + +/** + * Publishes the local back-pressure signals the platform already produced but never recorded. + * + *

{@code http.client.rate_limit.rejected}, {@code http.client.bulkhead.rejected} and {@code + * http.client.circuit.state} were declared in the metric vocabulary, documented in the support + * matrix, and emitted by nothing. That is the worst arrangement of the three possibilities: an + * operator building a dashboard finds the names, charts them, and sees a flat zero during the exact + * incident the metrics exist to explain — a saturated bulkhead and an open breaker look identical + * to a healthy system. + * + *

The circuit state is a gauge rather than a counter because "how long was it open" is the + * question an incident actually asks; the rejections are counters because each one is a request + * that did not happen. + */ +public interface ResilienceRejectionRecorder { + + void circuitOpen(); + + void rateLimited(); + + void bulkheadRejected(); + + /** Used where metrics are not wired, such as hand-constructed test pipelines. */ + static ResilienceRejectionRecorder noop() { + return new ResilienceRejectionRecorder() { + @Override + public void circuitOpen() { + // no-op + } + + @Override + public void rateLimited() { + // no-op + } + + @Override + public void bulkheadRejected() { + // no-op + } + }; + } + + /** + * Binds the counters and the circuit-state gauge for one profile. + * + * @param registry the meter registry + * @param clientName the profile the meters are tagged with + * @param circuitState supplies the breaker's current state name for the gauge + * @return a recorder that publishes to {@code registry} + */ + static ResilienceRejectionRecorder micrometer( + MeterRegistry registry, ClientProfileName clientName, Supplier circuitState) { + Objects.requireNonNull(registry, "meter registry"); + Objects.requireNonNull(clientName, "client name"); + Objects.requireNonNull(circuitState, "circuit state supplier"); + List tags = List.of(Tag.of("clientName", clientName.value())); + + // 1 while the breaker is refusing traffic, 0 otherwise. A state *name* cannot be a gauge value, + // and putting it in a tag would make the series change identity every time the breaker moved. + registry.gauge( + HttpClientObservationNames.CIRCUIT_STATE, + tags, + circuitState, + supplier -> "OPEN".equalsIgnoreCase(supplier.get()) ? 1.0 : 0.0); + + return new ResilienceRejectionRecorder() { + @Override + public void circuitOpen() { + registry.counter(HttpClientObservationNames.CIRCUIT_STATE + ".rejected", tags).increment(); + } + + @Override + public void rateLimited() { + registry.counter(HttpClientObservationNames.RATE_LIMIT_REJECTED, tags).increment(); + } + + @Override + public void bulkheadRejected() { + registry.counter(HttpClientObservationNames.BULKHEAD_REJECTED, tags).increment(); + } + }; + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/RetryContext.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/RetryContext.java new file mode 100644 index 00000000..de651238 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/resilience/RetryContext.java @@ -0,0 +1,77 @@ +package dev.caskeleton.adapter.outbound.httpclient.resilience; + +import dev.caskeleton.adapter.outbound.httpclient.api.HttpStatus; +import dev.caskeleton.adapter.outbound.httpclient.api.IdempotencyKey; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.BodyReplayability; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.FailureCategory; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.OperationIdempotency; +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * Everything the retry decision is allowed to depend on (design §17.1). + * + *

The HTTP method is deliberately absent: design D-09 makes idempotency an explicit operation + * property, so a POST with a registered idempotency key and a GET against a non-idempotent RPC + * endpoint are both handled correctly instead of by method-name folklore. + */ +public record RetryContext( + OperationIdempotency idempotency, + Optional idempotencyKey, + boolean idempotencyKeySent, + BodyReplayability replayability, + ExecutionEvidence evidence, + FailureCategory failureCategory, + Optional responseStatus, + Optional retryAfter, + int attempt, + int maxAttempts, + boolean firstByteDelivered, + Duration remainingDeadline, + Duration minimumAttemptBudget, + RetryBudgetSnapshot budget, + Set transientServerErrorStatuses, + boolean credentialRefreshAvailable, + boolean runtimeDraining) { + + public RetryContext { + Objects.requireNonNull(idempotency, "idempotency"); + Objects.requireNonNull(idempotencyKey, "idempotency key"); + Objects.requireNonNull(replayability, "replayability"); + Objects.requireNonNull(evidence, "evidence"); + Objects.requireNonNull(failureCategory, "failure category"); + Objects.requireNonNull(responseStatus, "response status"); + Objects.requireNonNull(retryAfter, "retry-after"); + Objects.requireNonNull(remainingDeadline, "remaining deadline"); + Objects.requireNonNull(minimumAttemptBudget, "minimum attempt budget"); + Objects.requireNonNull(budget, "retry budget snapshot"); + Objects.requireNonNull(transientServerErrorStatuses, "transient server error statuses"); + if (attempt < 1) { + throw new IllegalArgumentException("attempt must be at least 1"); + } + if (maxAttempts < 1) { + throw new IllegalArgumentException("max attempts must be at least 1"); + } + transientServerErrorStatuses = Set.copyOf(transientServerErrorStatuses); + } + + /** + * True when repeating a request that may already have been processed is contractually safe. + * + *

For a key-bearing operation this requires that the key was actually written to the request, + * not merely that the caller supplied one. The two used to be conflated: the platform read {@code + * idempotencyKey.isPresent()}, concluded the upstream could deduplicate, and retried — while the + * header was never sent, so the upstream had nothing to deduplicate against and processed the + * request twice. Possession of a key is the caller's intent; transmission is the upstream's + * ability to honour it, and only the second one makes a repeat safe. + */ + public boolean safelyIdempotent() { + return idempotency.safeToRepeatWithoutKey() + || (idempotency == OperationIdempotency.IDEMPOTENCY_KEY_REQUIRED + && idempotencyKey.isPresent() + && idempotencyKeySent); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/restclient/BlockingAttemptExecutor.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/restclient/BlockingAttemptExecutor.java new file mode 100644 index 00000000..3317df91 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/restclient/BlockingAttemptExecutor.java @@ -0,0 +1,269 @@ +package dev.caskeleton.adapter.outbound.httpclient.restclient; + +import dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpClientException; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRemoteErrorException; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.AttemptStage; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.FailureCategory; +import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType; +import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentials; +import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptOutcome; +import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptProgressTracker; +import dev.caskeleton.adapter.outbound.httpclient.resilience.ProtocolEvidence; +import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation; +import dev.caskeleton.adapter.outbound.httpclient.security.PreparedTarget; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailure; +import java.net.URI; +import java.time.Duration; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.springframework.http.HttpHeaders; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Executes exactly one physical attempt, including its redirect hops (design §7.1 steps 9-12). + * + *

Progress is tracked as the attempt advances so the evidence classifier has real observations + * to work from rather than an exception type. Failures become {@link AttemptOutcome} values instead + * of escaping, because the coordinator — not the transport — decides whether a failure is final. + */ +public final class BlockingAttemptExecutor { + + public AttemptOutcome execute( + BlockingClientRuntime runtime, + PreparedOperation prepared, + ResponseType responseType, + StatusHandlingPolicy statusHandlingPolicy, + RequestCredentials credentials, + int attemptNumber, + Instant startedAt, + HttpFailureMetadata baseMetadata) { + + Objects.requireNonNull(runtime, "runtime"); + BlockingExecutionSupport support = runtime.support(); + AttemptProgressTracker tracker = new AttemptProgressTracker(); + ResponseSizeLimiter limiter = + new ResponseSizeLimiter( + prepared.maxResponseWireBytes(), prepared.maxResponseDecodedBytes(), baseMetadata); + + try { + // Send, follow redirects, and map the response all inside one set of resilience permits. + // Two things depended on that: the breaker now sees the mapped remote outcome rather than + // "the socket returned bytes", and a redirect hop no longer re-enters the pipeline while the + // permits for its own attempt are still held — which with a single-permit bulkhead was a + // guaranteed self-rejection, and with any configuration double-counted the rate limiter. + return runtime + .resiliencePipeline() + .execute( + () -> { + RestClientResponseReader.RawResponse response = + sendWithRedirects( + runtime, prepared, credentials, tracker, limiter, baseMetadata); + Duration elapsed = Duration.between(startedAt, support.clock().instant()); + try { + return AttemptOutcome.succeeded( + support + .responseMapper() + .map( + response, + responseType, + runtime.profile().response(), + statusHandlingPolicy, + attemptNumber, + elapsed, + baseMetadata)); + } catch (HttpRemoteErrorException remoteError) { + // Retry-After is read from the response the mapper already bounded, so the + // decision engine can honour it without the transport interpreting it. + return AttemptOutcome.failed( + remoteError, + FailureCategory.REMOTE_STATUS, + retryAfter(response), + tracker.firstByteDelivered()); + } catch (HttpClientException stable) { + return AttemptOutcome.failed( + stable, categoryOf(stable), Optional.empty(), tracker.firstByteDelivered()); + } + }, + outcome -> + outcome.failureCategory() == FailureCategory.REMOTE_STATUS + ? outcome.failure().map(failure -> (Throwable) failure) + : Optional.empty()); + } catch (HttpClientException stable) { + return AttemptOutcome.failed( + stable, categoryOf(stable), Optional.empty(), tracker.firstByteDelivered()); + } catch (RuntimeException engineFailure) { + TransportFailure classified = + runtime.failureClassifier().classify(engineFailure, tracker.stage()); + HttpFailureMetadata metadata = + baseMetadata + .withEvidence( + support + .evidenceClassifier() + .classify(tracker.snapshot(), ProtocolEvidence.none())) + .withStage(classified.stage()); + HttpClientException mapped = + support.exceptionMapper().map(classified, metadata, engineFailure); + return AttemptOutcome.failed( + mapped, classified.category(), Optional.empty(), tracker.firstByteDelivered()); + } + } + + private RestClientResponseReader.RawResponse sendWithRedirects( + BlockingClientRuntime runtime, + PreparedOperation prepared, + RequestCredentials credentials, + AttemptProgressTracker tracker, + ResponseSizeLimiter limiter, + HttpFailureMetadata metadata) { + + Map> headers = withCredentials(prepared.headers(), credentials); + PreparedTarget target = prepared.target(); + URI initialUri = withCredentialQuery(target.uri(), credentials); + + RestClientResponseReader.RawResponse first = + send( + runtime, + initialUri, + prepared.operation().method(), + headers, + prepared.operation().body(), + tracker, + limiter, + metadata); + + BlockingRedirectCoordinator coordinator = + new BlockingRedirectCoordinator( + runtime.support().redirectEvaluator(), + runtime.support().headerStripper(), + runtime.targetPolicy()::requireAllowedTarget); + return coordinator.follow( + first, + runtime.redirectPolicy(), + PreparedTarget.of(initialUri, target.uriTemplate()), + prepared.operation().method(), + prepared.operation().body(), + headers, + // No nested pipeline. A hop runs under the permits its own attempt already holds; taking a + // second set would deadlock a single-permit bulkhead against itself and charge the rate + // limiter twice for one logical attempt. + (hopTarget, hopMethod, hopHeaders, hopBody) -> + send( + runtime, + hopTarget, + hopMethod, + hopHeaders, + hopBody, + new AttemptProgressTracker(), + limiter, + metadata), + metadata); + } + + private RestClientResponseReader.RawResponse send( + BlockingClientRuntime runtime, + URI uri, + HttpMethod method, + Map> headers, + dev.caskeleton.adapter.outbound.httpclient.api.body.BodySource body, + AttemptProgressTracker tracker, + ResponseSizeLimiter limiter, + HttpFailureMetadata metadata) { + + tracker.enter(AttemptStage.POOL_ACQUIRE); + RestClient.RequestBodySpec spec = + runtime + .restClient() + .method(org.springframework.http.HttpMethod.valueOf(method.name())) + .uri(uri); + headers.forEach((name, values) -> values.forEach(value -> spec.header(name, value))); + + tracker.enter(AttemptStage.REQUEST_HEADERS); + RestClient.RequestHeadersSpec request = + runtime.support().bodyWriter().write(spec, body, runtime.bodyLimitPolicy(), metadata); + tracker.enter(AttemptStage.REQUEST_BODY); + tracker.requestWriteStarted(); + + return request.exchange( + (httpRequest, httpResponse) -> { + tracker.enter(AttemptStage.RESPONSE_HEADERS); + tracker.responseHeadersReceived(); + RestClientResponseReader.RawResponse response = + runtime + .support() + .responseReader() + .readBounded( + httpResponse.getStatusCode().value(), + httpResponse.getHeaders(), + httpResponse.getBody(), + limiter); + tracker.enter(AttemptStage.COMPLETE); + return response; + }, + true); + } + + private Map> withCredentials( + Map> headers, RequestCredentials credentials) { + if (credentials.empty()) { + return headers; + } + Map> merged = new LinkedHashMap<>(headers); + credentials.headers().forEach((name, value) -> merged.put(name, List.of(value))); + return Map.copyOf(merged); + } + + private URI withCredentialQuery(URI uri, RequestCredentials credentials) { + if (credentials.queryParameters().isEmpty()) { + return uri; + } + UriComponentsBuilder builder = UriComponentsBuilder.fromUri(uri); + credentials.queryParameters().forEach(builder::queryParam); + return builder.build(true).toUri(); + } + + /** Reads {@code Retry-After} from a response the mapper already bounded. */ + public static Optional retryAfter(RestClientResponseReader.RawResponse response) { + Optional header = response.firstHeader(HttpHeaders.RETRY_AFTER); + if (header.isEmpty()) { + return Optional.empty(); + } + try { + return Optional.of(Duration.ofSeconds(Long.parseLong(header.get().trim()))); + } catch (NumberFormatException httpDate) { + // An HTTP-date Retry-After is valid but its value depends on clock agreement we do not have; + // falling back to the platform backoff is safer than trusting a skewed absolute time. + return Optional.empty(); + } + } + + private FailureCategory categoryOf(HttpClientException failure) { + return switch (failure.getClass().getSimpleName()) { + case "HttpDnsException" -> FailureCategory.DNS; + case "HttpPoolAcquireTimeoutException" -> FailureCategory.POOL_ACQUIRE_TIMEOUT; + case "HttpConnectException" -> FailureCategory.CONNECT; + case "HttpProxyException" -> FailureCategory.PROXY; + case "HttpTlsException" -> FailureCategory.TLS_PERMANENT; + case "HttpRequestWriteException" -> FailureCategory.REQUEST_WRITE; + case "HttpResponseTimeoutException" -> FailureCategory.RESPONSE_TIMEOUT; + case "HttpResponseTruncatedException" -> FailureCategory.RESPONSE_TRUNCATED; + case "HttpResponseTooLargeException" -> FailureCategory.RESPONSE_TOO_LARGE; + case "HttpSerializationException" -> FailureCategory.SERIALIZATION; + case "HttpTargetRejectedException" -> FailureCategory.TARGET_REJECTED; + case "HttpRedirectRejectedException" -> FailureCategory.REDIRECT_REJECTED; + case "HttpAuthenticationException" -> FailureCategory.AUTHENTICATION; + case "HttpCircuitOpenException" -> FailureCategory.CIRCUIT_OPEN; + case "HttpRateLimitRejectedException" -> FailureCategory.RATE_LIMIT_REJECTED; + case "HttpBulkheadRejectedException" -> FailureCategory.BULKHEAD_REJECTED; + case "HttpDeadlineExceededException" -> FailureCategory.DEADLINE_EXCEEDED; + case "HttpConfigurationException" -> FailureCategory.CONFIGURATION; + default -> FailureCategory.UNKNOWN; + }; + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/restclient/BlockingClientRuntime.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/restclient/BlockingClientRuntime.java new file mode 100644 index 00000000..5e5cb138 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/restclient/BlockingClientRuntime.java @@ -0,0 +1,132 @@ +package dev.caskeleton.adapter.outbound.httpclient.restclient; + +import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentialProvider; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntime; +import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration; +import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptResiliencePipeline; +import dev.caskeleton.adapter.outbound.httpclient.resilience.BackoffStrategy; +import dev.caskeleton.adapter.outbound.httpclient.resilience.LogicalAdmissionLimiter; +import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryBudget; +import dev.caskeleton.adapter.outbound.httpclient.security.BodyLimitPolicy; +import dev.caskeleton.adapter.outbound.httpclient.security.RedirectPolicy; +import dev.caskeleton.adapter.outbound.httpclient.security.TrustedTargetPolicy; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportId; +import java.util.Objects; +import java.util.function.Supplier; +import org.springframework.web.client.RestClient; + +/** + * One immutable blocking generation: a RestClient plus everything needed to execute against it + * (design §7.2, §26.1). + * + *

The {@code RestClient} is created once and never mutated. Callers cannot obtain its builder, + * which is what stops a caller from quietly removing an interceptor or changing a timeout. + */ +public final class BlockingClientRuntime extends ClientRuntime { + + private final RestClient restClient; + private final TransportId transportId; + private final TransportFailureClassifier failureClassifier; + private final TrustedTargetPolicy targetPolicy; + private final BodyLimitPolicy bodyLimitPolicy; + private final RedirectPolicy redirectPolicy; + private final AttemptResiliencePipeline resiliencePipeline; + private final LogicalAdmissionLimiter admissionLimiter; + private final RetryBudget retryBudget; + private final Supplier backoffFactory; + private final RequestCredentialProvider credentialProvider; + private final BlockingExecutionSupport support; + + public BlockingClientRuntime( + ClientProfile profile, + RuntimeGeneration generation, + Runnable resourceCloser, + RestClient restClient, + TransportId transportId, + TransportFailureClassifier failureClassifier, + AttemptResiliencePipeline resiliencePipeline, + LogicalAdmissionLimiter admissionLimiter, + RetryBudget retryBudget, + Supplier backoffFactory, + RequestCredentialProvider credentialProvider, + BlockingExecutionSupport support) { + super(profile, generation, resourceCloser); + this.restClient = Objects.requireNonNull(restClient, "rest client"); + this.transportId = Objects.requireNonNull(transportId, "transport id"); + this.failureClassifier = Objects.requireNonNull(failureClassifier, "failure classifier"); + this.resiliencePipeline = Objects.requireNonNull(resiliencePipeline, "resilience pipeline"); + this.admissionLimiter = Objects.requireNonNull(admissionLimiter, "admission limiter"); + this.retryBudget = Objects.requireNonNull(retryBudget, "retry budget"); + this.backoffFactory = Objects.requireNonNull(backoffFactory, "backoff factory"); + this.credentialProvider = Objects.requireNonNull(credentialProvider, "credential provider"); + this.support = Objects.requireNonNull(support, "execution support"); + this.targetPolicy = new TrustedTargetPolicy(profile); + this.bodyLimitPolicy = BodyLimitPolicy.maxRequestBytes(profile.request().maxBodyBytes()); + this.redirectPolicy = + profile.mode() == dev.caskeleton.adapter.outbound.httpclient.profile.ClientMode.DYNAMIC + ? RedirectPolicy.managedByCaller() + : RedirectPolicy.from(profile.redirect()); + } + + /** + * The engine client, visible only inside this package. + * + *

It used to be public, which meant any caller holding a runtime could execute a request that + * skipped target policy, credentials, admission, deadline, resilience, byte limits, stable error + * mapping and observation — every guarantee the profile exists to provide. The typed registries + * did exactly that. Package-private is what makes the platform's guarantees structural rather + * than a convention, and {@code PublicApiArchitectureTest} keeps Spring's client types confined + * here. + * + * @return the profile's immutable {@code RestClient} + */ + RestClient restClient() { + return restClient; + } + + public TransportId transportId() { + return transportId; + } + + public TransportFailureClassifier failureClassifier() { + return failureClassifier; + } + + public TrustedTargetPolicy targetPolicy() { + return targetPolicy; + } + + public BodyLimitPolicy bodyLimitPolicy() { + return bodyLimitPolicy; + } + + public RedirectPolicy redirectPolicy() { + return redirectPolicy; + } + + public AttemptResiliencePipeline resiliencePipeline() { + return resiliencePipeline; + } + + public LogicalAdmissionLimiter admissionLimiter() { + return admissionLimiter; + } + + public RetryBudget retryBudget() { + return retryBudget; + } + + public BackoffStrategy newBackoff() { + return backoffFactory.get(); + } + + public RequestCredentialProvider credentialProvider() { + return credentialProvider; + } + + public BlockingExecutionSupport support() { + return support; + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/restclient/BlockingRedirectCoordinator.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/restclient/BlockingRedirectCoordinator.java new file mode 100644 index 00000000..8b0b348f --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/restclient/BlockingRedirectCoordinator.java @@ -0,0 +1,114 @@ +package dev.caskeleton.adapter.outbound.httpclient.restclient; + +import dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod; +import dev.caskeleton.adapter.outbound.httpclient.api.body.BodySource; +import dev.caskeleton.adapter.outbound.httpclient.api.body.EmptyBody; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRedirectRejectedException; +import dev.caskeleton.adapter.outbound.httpclient.security.PreparedTarget; +import dev.caskeleton.adapter.outbound.httpclient.security.RedirectContext; +import dev.caskeleton.adapter.outbound.httpclient.security.RedirectDecision; +import dev.caskeleton.adapter.outbound.httpclient.security.RedirectEvaluator; +import dev.caskeleton.adapter.outbound.httpclient.security.RedirectPolicy; +import dev.caskeleton.adapter.outbound.httpclient.security.SensitiveHeaderStripper; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Follows redirects explicitly, one evaluated hop at a time (design §12.4). + * + *

Engine-level redirect following is disabled in every transport, so this is the only place a + * hop can happen. Each hop re-applies target policy and, when the origin changes, drops the + * credentials — which is exactly what an engine's built-in follower does not do. + * + *

A hop is a physical request: it passes through the resilience pipeline via the supplied + * sender, so it consumes rate and bulkhead capacity. It is not a retry, because nothing failed. + */ +public final class BlockingRedirectCoordinator { + + /** Issues one physical request; supplied by the executor so hops share the attempt pipeline. */ + @FunctionalInterface + public interface HopSender { + RestClientResponseReader.RawResponse send( + URI target, HttpMethod method, Map> headers, BodySource body); + } + + /** Re-applies the profile's own origin allowlist to a hop target. */ + @FunctionalInterface + public interface TargetGuard { + void requireAllowed(URI target, HttpFailureMetadata metadata); + } + + private final RedirectEvaluator evaluator; + private final SensitiveHeaderStripper headerStripper; + private final TargetGuard targetGuard; + + public BlockingRedirectCoordinator( + RedirectEvaluator evaluator, + SensitiveHeaderStripper headerStripper, + TargetGuard targetGuard) { + this.evaluator = Objects.requireNonNull(evaluator, "redirect evaluator"); + this.headerStripper = Objects.requireNonNull(headerStripper, "sensitive header stripper"); + this.targetGuard = Objects.requireNonNull(targetGuard, "target guard"); + } + + public RestClientResponseReader.RawResponse follow( + RestClientResponseReader.RawResponse initial, + RedirectPolicy policy, + PreparedTarget initialTarget, + HttpMethod method, + BodySource body, + Map> headers, + HopSender sender, + HttpFailureMetadata metadata) { + if (policy.callerManaged()) { + // A Dynamic Target owns its own hop validation; following here would skip it. + return initial; + } + RestClientResponseReader.RawResponse response = initial; + PreparedTarget currentTarget = initialTarget; + Map> currentHeaders = headers; + HttpMethod currentMethod = method; + BodySource currentBody = body; + + for (int hop = 0; isRedirect(response.status()); hop++) { + Optional location = response.firstHeader("Location"); + if (location.isEmpty()) { + return response; + } + URI target = currentTarget.uri().resolve(location.get()); + RedirectContext context = + RedirectContext.of(policy, hop, response.status(), currentBody, currentTarget, target); + RedirectDecision decision = evaluator.evaluate(context); + if (decision instanceof RedirectDecision.Reject reject) { + throw new HttpRedirectRejectedException("redirect rejected: " + reject.code(), metadata); + } + RedirectDecision.Follow follow = (RedirectDecision.Follow) decision; + // The redirect policy decides whether a hop is permissible in shape; the profile decides + // whether its destination is permissible at all. Only the first check existed, so an upstream + // could redirect a trusted profile to an origin its allowlist excluded. + targetGuard.requireAllowed(follow.target(), metadata); + if (follow.crossOrigin()) { + currentHeaders = headerStripper.stripForCrossOrigin(currentHeaders); + } + // 303 explicitly converts to GET, which means dropping the body as well as changing the + // method. Changing only the method sent the original payload as a GET body to a destination + // the upstream chose. 301/302 keep the method because the platform refuses to guess a rewrite + // the caller did not ask for. + if (response.status() == 303) { + currentMethod = HttpMethod.GET; + currentBody = EmptyBody.instance(); + } + currentTarget = PreparedTarget.of(follow.target(), currentTarget.uriTemplate()); + response = sender.send(follow.target(), currentMethod, currentHeaders, currentBody); + } + return response; + } + + private boolean isRedirect(int status) { + return status == 301 || status == 302 || status == 303 || status == 307 || status == 308; + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/restclient/BlockingStreamingGateway.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/restclient/BlockingStreamingGateway.java new file mode 100644 index 00000000..9589a9f1 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/restclient/BlockingStreamingGateway.java @@ -0,0 +1,169 @@ +package dev.caskeleton.adapter.outbound.httpclient.restclient; + +import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName; +import dev.caskeleton.adapter.outbound.httpclient.api.HttpStatus; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRemoteErrorException; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation; +import dev.caskeleton.adapter.outbound.httpclient.api.result.BlockingStreamingResponse; +import dev.caskeleton.adapter.outbound.httpclient.auth.CredentialRequest; +import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentials; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry; +import dev.caskeleton.adapter.outbound.httpclient.resilience.DeadlineGuard; +import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.springframework.web.client.RestClient; + +/** + * Streaming download for the blocking stack (design §23.2, §23.3). + * + *

The status is validated before any body byte is handed over, and retries are already + * impossible once the caller reads: the platform hands out an {@link BlockingStreamingResponse} + * rather than a raw stream so the connection is released on every exit path. + */ +public final class BlockingStreamingGateway { + + private final ClientRuntimeRegistry runtimes; + + public BlockingStreamingGateway(ClientRuntimeRegistry runtimes) { + this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry"); + } + + public BlockingStreamingResponse download( + ClientProfileName profileName, HttpOperation operation) { + Objects.requireNonNull(profileName, "profile name"); + Objects.requireNonNull(operation, "operation"); + + ClientRuntimeLease lease = runtimes.acquire(profileName); + boolean handedOff = false; + try { + if (!(lease.runtime() instanceof BlockingClientRuntime runtime)) { + throw new IllegalStateException( + "profile " + profileName.value() + " is not configured for the blocking api"); + } + HttpFailureMetadata metadata = + HttpFailureMetadata.validation( + profileName, + operation.operationName(), + operation.method(), + operation.uriTemplate(), + operation.body().replayability()); + PreparedOperation prepared = runtime.targetPolicy().prepare(operation); + ResponseSizeLimiter limiter = + new ResponseSizeLimiter( + prepared.maxResponseWireBytes(), prepared.maxResponseDecodedBytes(), metadata); + + // A streaming download is a call, not an exemption. This path used to apply target policy and + // the wire-byte limit and nothing else: no credential was resolved, no admission permit + // taken, + // no deadline checked, no breaker or rate limiter entered. A profile whose upstream was down + // could therefore be hammered indefinitely through its streaming surface while its + // non-streaming surface sat behind an open circuit. + new DeadlineGuard(runtime.support().clock()) + .requireTimeRemaining( + runtime + .support() + .deadlineCalculator() + .effective( + operation.deadline(), + runtime.profile().timeout().totalCall(), + runtime.support().clock()), + metadata); + + RequestCredentials credentials = + runtime + .credentialProvider() + .resolve( + new CredentialRequest( + runtime.name(), + operation.operationName(), + runtime.profile().authentication(), + prepared.target().uri(), + java.util.Optional.empty(), + runtime.profile().tls().keyMaterialReference(), + false)); + + Map> headers = new LinkedHashMap<>(prepared.headers()); + credentials.headers().forEach((name, value) -> headers.put(name, List.of(value))); + + RestClient.RequestBodySpec spec = + runtime + .restClient() + .method(org.springframework.http.HttpMethod.valueOf(operation.method().name())) + .uri(prepared.target().uri()); + headers.forEach((name, values) -> values.forEach(value -> spec.header(name, value))); + + // The admission permit is held for the life of the stream, not just the request: a streamed + // body occupies a connection until the caller closes it, and counting only the handshake made + // the limiter blind to exactly the calls that hold resources longest. + AutoCloseable admission = runtime.admissionLimiter().admit(metadata); + BlockingStreamingResponse response; + try { + response = + spec.exchange( + (request, rawResponse) -> { + int status = rawResponse.getStatusCode().value(); + Map> responseHeaders = new LinkedHashMap<>(); + rawResponse + .getHeaders() + .forEach( + (name, values) -> + responseHeaders.put(name, List.copyOf(new ArrayList<>(values)))); + if (status < 200 || status >= 300) { + // Status is validated before any byte is delivered, so a failed download never + // becomes a half-consumed stream the caller has to reason about. + rawResponse.close(); + throw new HttpRemoteErrorException( + "streaming download returned an error status", + metadata + .withStatus(new HttpStatus(status)) + .withEvidence(ExecutionEvidence.RESPONSE_RECEIVED)); + } + InputStream bounded = + new CountingBoundedInputStream( + rawResponse.getBody(), limiter::recordWireBytes); + return new DefaultBlockingStreamingResponse( + new HttpStatus(status), responseHeaders, bounded, rawResponse::close); + }, + false); + } catch (RuntimeException failure) { + closeQuietly(admission); + throw failure; + } + + BlockingStreamingResponse wrapped = + new DefaultBlockingStreamingResponse( + response.status(), + response.headers(), + response.body(), + () -> { + response.close(); + closeQuietly(admission); + lease.close(); + }); + handedOff = true; + return wrapped; + } finally { + if (!handedOff) { + lease.close(); + } + } + } + + /** A permit release must not mask the failure that is already propagating. */ + private static void closeQuietly(AutoCloseable closeable) { + try { + closeable.close(); + } catch (Exception ignored) { + // The admission limiter's release cannot fail meaningfully; swallowing keeps the original + // failure attributable. + } + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/restclient/DefaultGenericHttpGateway.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/restclient/DefaultGenericHttpGateway.java new file mode 100644 index 00000000..17e78785 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/restclient/DefaultGenericHttpGateway.java @@ -0,0 +1,304 @@ +package dev.caskeleton.adapter.outbound.httpclient.restclient; + +import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpAmbiguousExecutionException; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpClientException; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRateLimitRejectedException; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation; +import dev.caskeleton.adapter.outbound.httpclient.api.result.HttpCallResult; +import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType; +import dev.caskeleton.adapter.outbound.httpclient.auth.CredentialRequest; +import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentials; +import dev.caskeleton.adapter.outbound.httpclient.auth.UnauthorizedRetryContext; +import dev.caskeleton.adapter.outbound.httpclient.observation.LogicalCallObservation; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry; +import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptBudgetCalculator; +import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptOutcome; +import dev.caskeleton.adapter.outbound.httpclient.resilience.BlockingLogicalCall; +import dev.caskeleton.adapter.outbound.httpclient.resilience.BlockingRetryCoordinator; +import dev.caskeleton.adapter.outbound.httpclient.resilience.Deadline; +import dev.caskeleton.adapter.outbound.httpclient.resilience.DeadlineGuard; +import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryAllowed; +import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryContext; +import dev.caskeleton.adapter.outbound.httpclient.resilience.Sleeper; +import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** + * H2 Generic Exchange over the blocking stack (design §9.3, §7.1). + * + *

The logical call is assembled here and then handed to the retry coordinator: lease → admission + * → target policy → deadline → credentials → attempts. Every one of those is a place a caller could + * otherwise bypass a profile guarantee, which is why none of them are optional. + */ +public final class DefaultGenericHttpGateway implements GenericHttpGateway { + + private final ClientRuntimeRegistry runtimes; + private final BlockingAttemptExecutor executor; + + public DefaultGenericHttpGateway( + ClientRuntimeRegistry runtimes, BlockingAttemptExecutor executor) { + this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry"); + this.executor = Objects.requireNonNull(executor, "attempt executor"); + } + + @Override + public HttpCallResult exchange( + ClientProfileName profileName, HttpOperation operation, ResponseType responseType) { + return exchange(profileName, operation, responseType, StatusHandlingPolicy.THROW_ON_ERROR); + } + + @Override + public HttpCallResult exchange( + ClientProfileName profileName, + HttpOperation operation, + ResponseType responseType, + StatusHandlingPolicy statusHandlingPolicy) { + Objects.requireNonNull(profileName, "profile name"); + Objects.requireNonNull(operation, "operation"); + Objects.requireNonNull(responseType, "response type"); + + try (ClientRuntimeLease lease = runtimes.acquire(profileName)) { + BlockingClientRuntime runtime = requireBlockingRuntime(lease); + BlockingExecutionSupport support = runtime.support(); + HttpFailureMetadata metadata = + HttpFailureMetadata.validation( + profileName, + operation.operationName(), + operation.method(), + operation.uriTemplate(), + operation.body().replayability()); + + try (AutoCloseable admission = runtime.admissionLimiter().admit(metadata); + AutoCloseable context = + BlockingOperationContext.set(profileName, operation.operationName())) { + + PreparedOperation prepared = runtime.targetPolicy().prepare(operation); + + Deadline deadline = + support + .deadlineCalculator() + .effective( + operation.deadline(), runtime.profile().timeout().totalCall(), support.clock()); + new DeadlineGuard(support.clock()).requireTimeRemaining(deadline, metadata); + + LogicalCallObservation observation = + LogicalCallObservation.start( + support.meterRegistry(), + support.tagPolicy(), + profileName, + operation.operationName(), + operation.method().name(), + operation.uriTemplate()); + + BlockingRetryCoordinator coordinator = + new BlockingRetryCoordinator( + support.eligibilityEngine(), + runtime.newBackoff(), + runtime.retryBudget(), + Sleeper.threadSleep(), + support.clock()); + + GenericLogicalCall call = + new GenericLogicalCall<>( + runtime, + prepared, + responseType, + statusHandlingPolicy, + deadline, + metadata, + observation); + try { + HttpCallResult result = coordinator.execute(call); + observation.stop(Optional.of(result.status()), result.evidence(), "success"); + return result; + } catch (HttpAmbiguousExecutionException ambiguous) { + observation.recordAmbiguous(); + observation.stop(Optional.empty(), ambiguous.metadata().evidence(), "ambiguous"); + throw ambiguous; + } catch (HttpClientException failure) { + observation.stop(failure.metadata().status(), failure.metadata().evidence(), "failure"); + throw failure; + } + } catch (RuntimeException failure) { + throw failure; + } catch (Exception unexpected) { + throw new IllegalStateException("outbound http call could not complete", unexpected); + } + } + } + + private BlockingClientRuntime requireBlockingRuntime(ClientRuntimeLease lease) { + if (lease.runtime() instanceof BlockingClientRuntime blocking) { + return blocking; + } + throw new IllegalStateException( + "profile " + lease.runtime().name().value() + " is not configured for the blocking api"); + } + + /** Bridges one prepared operation to the retry coordinator (design §7.1 steps 8-13). */ + private final class GenericLogicalCall implements BlockingLogicalCall { + + private final BlockingClientRuntime runtime; + private final PreparedOperation prepared; + private final ResponseType responseType; + private final StatusHandlingPolicy statusHandlingPolicy; + private final Deadline deadline; + private final HttpFailureMetadata metadata; + private final LogicalCallObservation observation; + private final Instant startedAt; + + private RequestCredentials credentials; + private int credentialRefreshes; + + private GenericLogicalCall( + BlockingClientRuntime runtime, + PreparedOperation prepared, + ResponseType responseType, + StatusHandlingPolicy statusHandlingPolicy, + Deadline deadline, + HttpFailureMetadata metadata, + LogicalCallObservation observation) { + this.runtime = runtime; + this.prepared = prepared; + this.responseType = responseType; + this.statusHandlingPolicy = statusHandlingPolicy; + this.deadline = deadline; + this.metadata = metadata; + this.observation = observation; + this.startedAt = runtime.support().clock().instant(); + this.credentials = resolveCredentials(false); + } + + @Override + public AttemptOutcome attempt(int attemptNumber) { + if (!runtime.acceptsNewAttempts() && attemptNumber > 1) { + throw new HttpRateLimitRejectedException( + "runtime is draining and refuses new attempts", metadata); + } + // Checked before every attempt, not only before the first. The deadline used to be verified + // once at the start of the logical call, so a retry could begin after the budget had already + // expired and run to its own transport timeout — the caller's deadline was a suggestion the + // second attempt onwards ignored. AttemptBudgetCalculator and DeadlineGuard both existed for + // this and neither was on the execution path. + BlockingExecutionSupport attemptSupport = runtime.support(); + DeadlineGuard guard = new DeadlineGuard(attemptSupport.clock()); + guard.requireTimeRemaining(deadline, metadata.withAttempt(attemptNumber)); + guard.requireAttemptBudget( + new AttemptBudgetCalculator(attemptSupport.clock()) + .nextAttempt( + deadline, Duration.ZERO, attemptSupport.minimumAttemptBudget(), Duration.ZERO), + metadata.withAttempt(attemptNumber)); + return executor.execute( + runtime, + prepared, + responseType, + statusHandlingPolicy, + credentials, + attemptNumber, + startedAt, + metadata.withAttempt(attemptNumber)); + } + + @Override + public RetryContext context(AttemptOutcome outcome, int attemptNumber) { + BlockingExecutionSupport support = runtime.support(); + boolean unauthorized = outcome.status().map(status -> status.value() == 401).orElse(false); + boolean refreshAllowed = + unauthorized + && support + .unauthorizedRetryPolicy() + .mayRetry( + new UnauthorizedRetryContext( + prepared.operation().idempotency(), + prepared.operation().body().replayability(), + credentialRefreshes, + true)); + if (refreshAllowed) { + runtime.credentialProvider().invalidate(credentialRequest(false)); + credentials = resolveCredentials(true); + credentialRefreshes++; + } + return new RetryContext( + prepared.operation().idempotency(), + prepared.operation().idempotencyKey(), + prepared.idempotencyKeySent(), + prepared.operation().body().replayability(), + outcome.evidence(), + outcome.failureCategory(), + outcome.status(), + outcome.retryAfter(), + attemptNumber, + runtime.profile().retry().maxAttempts(), + outcome.firstByteDelivered(), + deadline.remaining(support.clock()), + support.minimumAttemptBudget(), + runtime.retryBudget().snapshot(), + support.transientServerErrorStatuses(), + refreshAllowed, + !runtime.acceptsNewAttempts()); + } + + @Override + public Deadline deadline() { + return deadline; + } + + @Override + public HttpCallResult finish(AttemptOutcome outcome, int attemptNumber) { + return outcome + .result() + .map( + value -> + new HttpCallResult<>( + value.status(), + value.headers(), + value.body(), + attemptNumber, + Duration.between(startedAt, runtime.support().clock().instant()), + value.evidence(), + value.remoteProblem())) + .orElseThrow(() -> outcome.failure().orElseThrow()); + } + + @Override + public HttpClientException ambiguous(AttemptOutcome outcome, int attemptNumber) { + return new HttpAmbiguousExecutionException( + "request was sent but the remote outcome is unknown", + metadata.withAttempt(attemptNumber).withEvidence(ExecutionEvidence.SENT_NO_RESPONSE)); + } + + @Override + public HttpClientException retryExhausted(int attemptNumber) { + observation.recordRetryExhausted(); + return new HttpRateLimitRejectedException( + "retry budget for this upstream is exhausted", metadata.withAttempt(attemptNumber)); + } + + @Override + public void onRetryGranted(RetryAllowed allowed, int attemptNumber) { + observation.recordRetry(allowed.reason()); + } + + private RequestCredentials resolveCredentials(boolean forceRefresh) { + return runtime.credentialProvider().resolve(credentialRequest(forceRefresh)); + } + + private CredentialRequest credentialRequest(boolean forceRefresh) { + return new CredentialRequest( + runtime.name(), + prepared.operation().operationName(), + runtime.profile().authentication(), + prepared.target().uri(), + Optional.empty(), + runtime.profile().tls().keyMaterialReference(), + forceRefresh); + } + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/restclient/RestClientResponseReader.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/restclient/RestClientResponseReader.java new file mode 100644 index 00000000..de93a1be --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/restclient/RestClientResponseReader.java @@ -0,0 +1,206 @@ +package dev.caskeleton.adapter.outbound.httpclient.restclient; + +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpSerializationException; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpTargetRejectedException; +import dev.caskeleton.adapter.outbound.httpclient.api.result.ByteArrayResponseType; +import dev.caskeleton.adapter.outbound.httpclient.api.result.ClassResponseType; +import dev.caskeleton.adapter.outbound.httpclient.api.result.EmptyResponseType; +import dev.caskeleton.adapter.outbound.httpclient.api.result.GenericResponseType; +import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType; +import dev.caskeleton.adapter.outbound.httpclient.profile.ResponseLimits; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.MediaType; +import org.springframework.http.converter.ByteArrayHttpMessageConverter; +import org.springframework.http.converter.FormHttpMessageConverter; +import org.springframework.http.converter.GenericHttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; + +/** + * Reads and decodes a response inside the profile's byte budget (design §23.2). + * + *

The body is bounded while it is read and then decoded from memory by a Spring message + * converter. Doing the bounding first is what makes a decompression bomb or an unexpectedly huge + * payload a rejected request instead of an out-of-memory error. + */ +public final class RestClientResponseReader { + + private final List> converters; + + public RestClientResponseReader() { + this(defaultConverters()); + } + + public RestClientResponseReader(List> converters) { + this.converters = List.copyOf(Objects.requireNonNull(converters, "message converters")); + } + + public static List> defaultConverters() { + List> converters = new ArrayList<>(); + converters.add(new ByteArrayHttpMessageConverter()); + converters.add(new StringHttpMessageConverter()); + converters.add(new FormHttpMessageConverter()); + converters.add(new JacksonJsonHttpMessageConverter()); + return List.copyOf(converters); + } + + /** + * Bounded snapshot of one HTTP response. + * + *

A final class rather than a record: the body is an array and must be copied on construction + * and on every access so a caller cannot mutate a buffer the retry path may re-read. + */ + public static final class RawResponse { + + private final int status; + private final Map> headers; + private final byte[] body; + + public RawResponse(int status, Map> headers, byte[] body) { + this.status = status; + this.headers = Map.copyOf(Objects.requireNonNull(headers, "headers")); + this.body = Objects.requireNonNull(body, "body").clone(); + } + + public int status() { + return status; + } + + public Map> headers() { + return headers; + } + + public byte[] body() { + return body.clone(); + } + + public int bodyLength() { + return body.length; + } + + public java.util.Optional firstHeader(String name) { + return headers.entrySet().stream() + .filter(entry -> entry.getKey().equalsIgnoreCase(name)) + .flatMap(entry -> entry.getValue().stream()) + .findFirst(); + } + } + + public RawResponse readBounded( + int status, HttpHeaders headers, InputStream body, ResponseSizeLimiter limiter) + throws IOException { + try (InputStream counted = new CountingBoundedInputStream(body, limiter::recordWireBytes)) { + byte[] bytes = counted.readAllBytes(); + limiter.recordDecodedBytes(bytes.length); + return new RawResponse(status, toMap(headers), bytes); + } + } + + private static Map> toMap(HttpHeaders headers) { + Map> copy = new java.util.LinkedHashMap<>(); + headers.forEach((name, values) -> copy.put(name, List.copyOf(values))); + return copy; + } + + public void requireAllowedContentType( + RawResponse response, ResponseLimits limits, HttpFailureMetadata metadata) { + if (response.bodyLength() == 0) { + return; + } + String contentType = response.firstHeader(HttpHeaders.CONTENT_TYPE).orElse(""); + if (contentType.isEmpty() || limits.permits(contentType)) { + return; + } + throw new HttpTargetRejectedException( + "response content type is not permitted by the profile", metadata); + } + + @SuppressWarnings("unchecked") + public T decode( + RawResponse response, ResponseType responseType, HttpFailureMetadata metadata) { + if (responseType instanceof EmptyResponseType) { + return null; + } + if (responseType instanceof ByteArrayResponseType) { + return (T) response.body(); + } + MediaType contentType = + response + .firstHeader(HttpHeaders.CONTENT_TYPE) + .map(MediaType::parseMediaType) + .orElse(MediaType.APPLICATION_OCTET_STREAM); + Type target = responseType.type(); + try { + for (HttpMessageConverter converter : converters) { + if (responseType instanceof ClassResponseType classType + && converter.canRead(classType.rawType(), contentType)) { + HttpMessageConverter typed = (HttpMessageConverter) converter; + return typed.read(classType.rawType(), inputMessage(response, contentType)); + } + if (responseType instanceof GenericResponseType + && converter instanceof GenericHttpMessageConverter generic + && generic.canRead(target, null, contentType)) { + GenericHttpMessageConverter typed = (GenericHttpMessageConverter) generic; + return typed.read(target, null, inputMessage(response, contentType)); + } + } + } catch (IOException | RuntimeException failure) { + throw new HttpSerializationException("response body could not be decoded", metadata, failure); + } + throw new HttpSerializationException( + "no message converter can read the declared response type", metadata); + } + + /** + * Convenience for the typed client, which knows its target as a {@link + * ParameterizedTypeReference}. + */ + @SuppressWarnings("unchecked") + public static ResponseType responseTypeOf(ParameterizedTypeReference reference) { + Type type = reference.getType(); + if (type == Void.class || type == void.class) { + return (ResponseType) ResponseType.empty(); + } + if (type == byte[].class) { + return (ResponseType) ResponseType.ofBytes(); + } + // A non-generic reference must become a ClassResponseType. Wrapping it as a generic one looked + // harmless but sent every plain DTO down the GenericHttpMessageConverter branch, where the + // converters this reader holds decline to read it — so a typed client that declared + // `UserResponse` failed with "no message converter can read the declared response type". + if (type instanceof Class rawType) { + return (ResponseType) ResponseType.of(rawType); + } + return new GenericResponseType<>(type); + } + + private HttpInputMessage inputMessage(RawResponse response, MediaType contentType) { + HttpHeaders headers = new HttpHeaders(); + response.headers().forEach(headers::addAll); + headers.setContentType(contentType); + byte[] body = response.body(); + return new HttpInputMessage() { + @Override + public InputStream getBody() { + return new ByteArrayInputStream(body); + } + + @Override + public HttpHeaders getHeaders() { + return headers; + } + }; + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/restclient/RestClientRuntimeFactory.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/restclient/RestClientRuntimeFactory.java new file mode 100644 index 00000000..42518b67 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/restclient/RestClientRuntimeFactory.java @@ -0,0 +1,144 @@ +package dev.caskeleton.adapter.outbound.httpclient.restclient; + +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata; +import dev.caskeleton.adapter.outbound.httpclient.auth.CredentialProviderRegistry; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntime; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeFactory; +import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration; +import dev.caskeleton.adapter.outbound.httpclient.profile.TransportType; +import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptResiliencePipeline; +import dev.caskeleton.adapter.outbound.httpclient.resilience.BackoffStrategy; +import dev.caskeleton.adapter.outbound.httpclient.resilience.ExponentialFullJitterBackoff; +import dev.caskeleton.adapter.outbound.httpclient.resilience.ResilienceRegistry; +import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryBudget; +import dev.caskeleton.adapter.outbound.httpclient.transport.BlockingTransportProvider; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportCapabilityValidator; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportLifecycleListener; +import java.time.Duration; +import java.util.EnumMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; +import java.util.random.RandomGenerator; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +/** + * Builds a blocking runtime generation for a profile (design §26.1). + * + *

Capability validation runs before any client exists, so a profile whose transport cannot + * honour it fails at startup rather than at the first production request. + */ +public final class RestClientRuntimeFactory implements ClientRuntimeFactory { + + private final Map providers = + new EnumMap<>(TransportType.class); + private final TransportCapabilityValidator capabilityValidator = + new TransportCapabilityValidator(); + private final ResilienceRegistry resilienceRegistry; + private final CredentialProviderRegistry credentialProviders; + private final BlockingExecutionSupport support; + private final TransportLifecycleListener lifecycleListener; + private final RandomGenerator random; + + public RestClientRuntimeFactory( + Map providers, + ResilienceRegistry resilienceRegistry, + CredentialProviderRegistry credentialProviders, + BlockingExecutionSupport support, + TransportLifecycleListener lifecycleListener, + RandomGenerator random) { + Objects.requireNonNull(providers, "transport providers").forEach(this.providers::put); + this.resilienceRegistry = Objects.requireNonNull(resilienceRegistry, "resilience registry"); + this.credentialProviders = Objects.requireNonNull(credentialProviders, "credential providers"); + this.support = Objects.requireNonNull(support, "execution support"); + this.lifecycleListener = Objects.requireNonNull(lifecycleListener, "lifecycle listener"); + this.random = Objects.requireNonNull(random, "random generator"); + } + + @Override + public ClientRuntime create(ClientProfile profile, RuntimeGeneration generation) { + Objects.requireNonNull(profile, "profile"); + BlockingTransportProvider provider = providers.get(profile.transport()); + if (provider == null) { + throw new HttpConfigurationException( + "no blocking transport provider is registered for " + profile.transport(), + HttpFailureMetadata.startup(profile.name())); + } + capabilityValidator.validate(profile, provider.capabilities()); + + ClientHttpRequestFactory requestFactory = + provider.create(profile, generation, lifecycleListener); + RestClient restClient = + RestClient.builder() + .requestFactory(requestFactory) + .baseUrl(profile.baseUrl().toString()) + .build(); + + AttemptResiliencePipeline pipeline = newResiliencePipeline(profile); + + RetryBudget retryBudget = + profile + .retry() + .budget() + .map( + name -> + resilienceRegistry.retryBudget( + name, retryCapacity(profile), Duration.ofMinutes(1))) + .orElseGet(RetryBudget::unlimited); + + Supplier backoffFactory = + () -> + new ExponentialFullJitterBackoff( + profile.retry().baseBackoff(), + profile.retry().maxBackoff(), + profile.retry().jitter(), + profile.retry().retryAfter(), + random); + + return new BlockingClientRuntime( + profile, + generation, + () -> provider.close(profile, generation), + restClient, + provider.id(), + provider.failureClassifier(), + pipeline, + resilienceRegistry.admission( + profile.name(), + profile.pool().maxPendingAcquires() + profile.pool().maxTotalConnections()), + retryBudget, + backoffFactory, + credentialProviders.require(profile.name(), profile.authentication().type()), + support); + } + + /** + * Retry capacity is derived from the pool budget rather than invented: a retry storm is bounded + * by what the upstream can absorb, and the pool is the only declared statement of that. + */ + private long retryCapacity(ClientProfile profile) { + return Math.max(1L, profile.pool().maxTotalConnections() / 10L); + } + + /** + * Builds the guard chain with its rejection metrics attached. + * + *

The three local back-pressure signals — circuit open, rate limited, bulkhead full — were + * declared in the metric vocabulary and emitted by nothing, so a saturated client looked exactly + * like a healthy one on a dashboard. + */ + private AttemptResiliencePipeline newResiliencePipeline(ClientProfile profile) { + dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptCircuitBreaker breaker = + resilienceRegistry.circuitBreaker(profile.name()); + return new AttemptResiliencePipeline( + breaker, + resilienceRegistry.rateLimiter(profile.name()), + resilienceRegistry.bulkhead(profile.name(), profile.pool().maxTotalConnections()), + () -> HttpFailureMetadata.startup(profile.name()), + dev.caskeleton.adapter.outbound.httpclient.resilience.ResilienceRejectionRecorder + .micrometer(support.meterRegistry(), profile.name(), breaker::state)); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/security/PreparedOperation.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/security/PreparedOperation.java new file mode 100644 index 00000000..171dc3e1 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/security/PreparedOperation.java @@ -0,0 +1,50 @@ +package dev.caskeleton.adapter.outbound.httpclient.security; + +import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * An operation that has passed target, header, and body-size policy and is ready to execute (design + * §12). + * + *

Nothing downstream re-derives a URL, re-adds a header, or re-checks a limit: the executor may + * only use what this record already approved. + */ +public record PreparedOperation( + HttpOperation operation, + PreparedTarget target, + Map> headers, + long maxRequestBytes, + long maxResponseWireBytes, + long maxResponseDecodedBytes) { + + public PreparedOperation { + Objects.requireNonNull(operation, "operation"); + Objects.requireNonNull(target, "target"); + Objects.requireNonNull(headers, "headers"); + headers = Map.copyOf(headers); + } + + /** + * Whether the operation's idempotency key is present in the headers that will be sent. + * + *

Read by the retry decision instead of {@code operation.idempotencyKey().isPresent()}. The + * distinction is the whole point: a key the caller supplied but the platform never wrote gives + * the upstream nothing to deduplicate against, so a repeat is a duplicate side effect rather than + * a safe retry. + * + * @return {@code true} when a key exists and a header carries its exact value + */ + public boolean idempotencyKeySent() { + return operation + .idempotencyKey() + .map( + key -> + headers.values().stream() + .flatMap(List::stream) + .anyMatch(value -> value.equals(key.value()))) + .orElse(false); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/security/SensitiveHeaderStripper.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/security/SensitiveHeaderStripper.java new file mode 100644 index 00000000..94df5c65 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/security/SensitiveHeaderStripper.java @@ -0,0 +1,68 @@ +package dev.caskeleton.adapter.outbound.httpclient.security; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Removes credentials when a redirect crosses an origin (design §12.4, §22.4). + * + *

Forwarding {@code Authorization} to a redirect target is a credential disclosure to whoever + * controls that target, which for a Dynamic Target is by definition not us. + */ +public final class SensitiveHeaderStripper { + + private static final Set ALWAYS_STRIPPED = + Set.of("authorization", "proxy-authorization", "cookie", "set-cookie"); + + /** Conventional API-key header names, stripped even when a profile names a different one. */ + private static final Set CONVENTIONAL_API_KEY_HEADERS = Set.of("x-api-key", "api-key"); + + private final Set additionalSensitiveHeaders; + + private SensitiveHeaderStripper(Set additionalSensitiveHeaders) { + this.additionalSensitiveHeaders = additionalSensitiveHeaders; + } + + public static SensitiveHeaderStripper standard() { + return new SensitiveHeaderStripper(CONVENTIONAL_API_KEY_HEADERS); + } + + /** + * Adds profile-specific credential headers to the conventional set. + * + *

It adds rather than replaces, which its name always claimed and its behaviour did not. A + * profile that named a custom API-key header — {@code X-Client-Key}, say — produced a stripper + * that dropped only that one and forwarded {@code X-Api-Key} across an origin boundary, so + * configuring a custom header made the default headers less protected than leaving it + * alone. + * + * @param headerNames additional credential-bearing header names, case-insensitive + * @return a stripper covering the conventional names plus these + */ + public static SensitiveHeaderStripper withAdditional(Set headerNames) { + Objects.requireNonNull(headerNames, "additional sensitive header names"); + Set lower = + java.util.stream.Stream.concat( + CONVENTIONAL_API_KEY_HEADERS.stream(), + headerNames.stream().map(name -> name.toLowerCase(Locale.ROOT))) + .collect(Collectors.toUnmodifiableSet()); + return new SensitiveHeaderStripper(lower); + } + + public Map> stripForCrossOrigin(Map> headers) { + Map> retained = new LinkedHashMap<>(); + headers.forEach( + (name, values) -> { + String lower = name.toLowerCase(Locale.ROOT); + if (!ALWAYS_STRIPPED.contains(lower) && !additionalSensitiveHeaders.contains(lower)) { + retained.put(name, List.copyOf(values)); + } + }); + return Map.copyOf(retained); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/security/TlsRuntimeRotationCoordinator.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/security/TlsRuntimeRotationCoordinator.java new file mode 100644 index 00000000..1d230569 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/security/TlsRuntimeRotationCoordinator.java @@ -0,0 +1,78 @@ +package dev.caskeleton.adapter.outbound.httpclient.security; + +import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntime; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeFactory; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry; +import java.time.Duration; +import java.util.Objects; + +/** + * Rotates certificates and secrets by building a new runtime generation (design §7.2, §21.1). + * + *

Rotation is a generation swap rather than an in-place mutation because a connection pool holds + * sockets that were established under the previous identity: replacing the material without + * replacing the pool leaves live connections authenticated by a certificate that is meant to be + * gone. + */ +public final class TlsRuntimeRotationCoordinator { + + private final ClientRuntimeRegistry registry; + private final ClientRuntimeFactory runtimeFactory; + private final Duration drainTimeout; + + public TlsRuntimeRotationCoordinator( + ClientRuntimeRegistry registry, ClientRuntimeFactory runtimeFactory, Duration drainTimeout) { + this.registry = Objects.requireNonNull(registry, "runtime registry"); + this.runtimeFactory = Objects.requireNonNull(runtimeFactory, "runtime factory"); + this.drainTimeout = Objects.requireNonNull(drainTimeout, "drain timeout"); + } + + /** + * Rotates the profiles that actually use the rotated identity. + * + *

The identity argument used to be required and then ignored: every registered profile was + * rotated whatever certificate had changed. Rotating a runtime is not free — it discards a warm + * pool, forces fresh handshakes, and drains connections mid-flight — so a single certificate + * renewal caused a connection storm across every upstream the service talks to, most of which had + * nothing to do with that certificate. It also hid the real failure: if the rotation was wrong, + * every profile degraded at once and nothing pointed at the cause. + * + *

A profile participates when its TLS settings name the rotated identity, either as the key + * material reference or as the TLS profile id. A profile with no client certificate cannot be + * affected by a client-certificate rotation at all. + * + * @param identity the certificate identity that changed + * @return the profiles that were rotated, so a caller can log or assert on the blast radius + */ + public java.util.List rotate(ClientCertificateIdentity identity) { + Objects.requireNonNull(identity, "client certificate identity"); + java.util.List rotated = new java.util.ArrayList<>(); + for (ClientProfileName name : registry.names()) { + if (participatesIn(name, identity)) { + rotateProfile(name); + rotated.add(name); + } + } + return java.util.List.copyOf(rotated); + } + + /** Whether this profile's TLS material is the one that rotated. */ + private boolean participatesIn(ClientProfileName name, ClientCertificateIdentity identity) { + dev.caskeleton.adapter.outbound.httpclient.profile.TlsSettings tls = + registry.current(name).profile().tls(); + if (tls.keyMaterialReference().isEmpty()) { + // No client certificate: a client-certificate rotation cannot reach this profile. + return false; + } + return tls.keyMaterialReference().filter(identity.value()::equals).isPresent() + || tls.profileId().filter(identity.value()::equals).isPresent(); + } + + public void rotateProfile(ClientProfileName name) { + ClientRuntime previous = registry.current(name); + ClientRuntime replacement = + runtimeFactory.create(previous.profile(), previous.generation().next()); + registry.swap(name, replacement, drainTimeout); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/security/TrustedTargetPolicy.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/security/TrustedTargetPolicy.java new file mode 100644 index 00000000..893051f2 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/security/TrustedTargetPolicy.java @@ -0,0 +1,176 @@ +package dev.caskeleton.adapter.outbound.httpclient.security; + +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpTargetRejectedException; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation; +import dev.caskeleton.adapter.outbound.httpclient.api.result.IdempotencyKeyRequirement; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +/** + * Turns a caller's operation into a {@link PreparedOperation} for a Trusted profile (design §12.1). + * + *

An absolute URI is rejected here rather than sanitised: H2 exists to vary method, relative + * path, query, approved headers, and body — not the destination. Changing the destination is what + * H3 is for, and H3 has its own policy, credentials, and DNS validation. + */ +public final class TrustedTargetPolicy { + + private final ClientProfile profile; + private final UriTemplateExpander expander; + private final BodyLimitPolicy bodyLimitPolicy; + + public TrustedTargetPolicy(ClientProfile profile) { + this.profile = Objects.requireNonNull(profile, "profile"); + this.expander = new UriTemplateExpander(profile.baseUrl()); + this.bodyLimitPolicy = BodyLimitPolicy.maxRequestBytes(profile.request().maxBodyBytes()); + } + + public ClientProfile profile() { + return profile; + } + + /** + * Prepares an operation, deriving its idempotency-key requirement from the operation itself. + * + *

Deriving rather than defaulting to {@link IdempotencyKeyRequirement#none()} is deliberate: a + * default of "no key" silently dropped a key the caller had registered, and the request went out + * without the one header that would have let the upstream deduplicate it. + * + * @param operation the caller's operation + * @return the prepared operation, with the key rendered as a header when one exists + */ + public PreparedOperation prepare(HttpOperation operation) { + return prepare(operation, requirementFor(operation)); + } + + /** + * The requirement an operation implies. + * + * @param operation the operation to inspect + * @return {@code required} under the standard header name when the operation carries a key + */ + public static IdempotencyKeyRequirement requirementFor(HttpOperation operation) { + return operation.idempotencyKey().isPresent() + ? IdempotencyKeyRequirement.required("Idempotency-Key") + : IdempotencyKeyRequirement.none(); + } + + public PreparedOperation prepare( + HttpOperation operation, IdempotencyKeyRequirement idempotencyKeyRequirement) { + Objects.requireNonNull(operation, "operation"); + HttpFailureMetadata metadata = + HttpFailureMetadata.validation( + profile.name(), + operation.operationName(), + operation.method(), + operation.uriTemplate(), + operation.body().replayability()); + + requireRelativeTemplate(operation.uriTemplate(), metadata); + PreparedTarget target = expander.expand(operation.uriTemplate(), operation.uriVariables()); + requireAllowedOrigin(target, metadata); + + Map> headers = + HeaderPolicy.forOperation(idempotencyKeyRequirement, false) + .validate(operation.headers(), metadata); + headers = withIdempotencyKey(operation, idempotencyKeyRequirement, headers, metadata); + bodyLimitPolicy.validate(operation.body(), metadata); + + return new PreparedOperation( + operation, + target, + headers, + profile.request().maxBodyBytes(), + profile.response().maxWireBytes(), + profile.response().maxDecodedBytes()); + } + + /** + * Renders the operation's idempotency key as the header the upstream will actually receive. + * + *

The platform owns this header. Before, the key was carried on the operation, checked for + * presence by the retry engine, and never written to the wire: the upstream saw no key, could not + * deduplicate, and the platform meanwhile treated a repeat as contractually safe. A duplicated + * payment is the shape of that bug. + * + *

A caller-supplied value for the same header is refused rather than merged. Two keys for one + * request is a contradiction, and silently preferring either one would decide on the caller's + * behalf which request the upstream is allowed to deduplicate against. + */ + private Map> withIdempotencyKey( + HttpOperation operation, + IdempotencyKeyRequirement requirement, + Map> headers, + HttpFailureMetadata metadata) { + if (operation.idempotencyKey().isEmpty()) { + return headers; + } + if (!requirement.required()) { + throw new HttpTargetRejectedException( + "the operation carries an idempotency key but its descriptor does not register one, so " + + "the platform has no header to send it in", + metadata); + } + String headerName = requirement.headerName(); + boolean callerSupplied = + headers.keySet().stream() + .anyMatch( + name -> name.toLowerCase(Locale.ROOT).equals(headerName.toLowerCase(Locale.ROOT))); + if (callerSupplied) { + throw new HttpTargetRejectedException( + "header " + headerName + " is owned by the platform and must not be supplied by a caller", + metadata); + } + Map> merged = new LinkedHashMap<>(headers); + merged.put(headerName, List.of(operation.idempotencyKey().orElseThrow().value())); + return Map.copyOf(merged); + } + + private void requireRelativeTemplate(String uriTemplate, HttpFailureMetadata metadata) { + if (uriTemplate.isEmpty()) { + throw new HttpTargetRejectedException("uri template must not be empty", metadata); + } + String lower = uriTemplate.toLowerCase(Locale.ROOT); + if (lower.startsWith("//") || lower.contains("://")) { + throw new HttpTargetRejectedException( + "a trusted generic exchange accepts only a profile-relative uri template", metadata); + } + if (!uriTemplate.startsWith("/")) { + throw new HttpTargetRejectedException( + "uri template must start with '/' relative to the profile base url", metadata); + } + } + + /** + * Re-applies the profile's origin allowlist to a target the caller did not choose. + * + *

Public because a redirect hop needs it. The coordinator used to evaluate a hop against the + * redirect policy alone — hop count, cross-origin flag, method rewrite — and never against the + * profile's own allowed hosts and ports. An upstream could therefore redirect a trusted profile + * to any origin the redirect policy tolerated, including one the operator had explicitly excluded + * from the allowlist, and the platform would follow it. + * + * @param target the hop target + * @param metadata failure metadata for the rejection + * @throws HttpTargetRejectedException when the host or port is not on the profile's allowlist + */ + public void requireAllowedTarget(java.net.URI target, HttpFailureMetadata metadata) { + requireAllowedOrigin(PreparedTarget.of(target, target.getPath()), metadata); + } + + private void requireAllowedOrigin(PreparedTarget target, HttpFailureMetadata metadata) { + if (!profile.allowedHosts().isEmpty() && !profile.allowedHosts().contains(target.host())) { + throw new HttpTargetRejectedException( + "target host is not on the profile allowlist", metadata); + } + if (!profile.allowedPorts().isEmpty() && !profile.allowedPorts().contains(target.port())) { + throw new HttpTargetRejectedException( + "target port is not on the profile allowlist", metadata); + } + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/service/DefaultHttpServiceRegistry.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/service/DefaultHttpServiceRegistry.java new file mode 100644 index 00000000..a6a4b2c4 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/service/DefaultHttpServiceRegistry.java @@ -0,0 +1,115 @@ +package dev.caskeleton.adapter.outbound.httpclient.service; + +import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry; +import dev.caskeleton.adapter.outbound.httpclient.restclient.BlockingClientRuntime; +import dev.caskeleton.adapter.outbound.httpclient.restclient.GenericHttpGateway; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +/** + * Builds validated typed clients over a profile's immutable RestClient (design §26.3). + * + *

Interfaces are scanned and validated at creation, and the resulting proxy is cached per + * (profile, interface): building it per call would re-run reflection on every request and lose the + * startup-failure guarantee. + */ +public final class DefaultHttpServiceRegistry implements HttpServiceRegistry { + + private final ClientRuntimeRegistry runtimes; + private final GenericHttpGateway gateway; + private final ServiceOperationDescriptorScanner scanner; + private final OperationContextHolder contextHolder; + private final Map clients = new ConcurrentHashMap<>(); + + public DefaultHttpServiceRegistry(ClientRuntimeRegistry runtimes, GenericHttpGateway gateway) { + this( + runtimes, + gateway, + new ServiceOperationDescriptorScanner(), + OperationContextHolder.instance()); + } + + public DefaultHttpServiceRegistry( + ClientRuntimeRegistry runtimes, + GenericHttpGateway gateway, + ServiceOperationDescriptorScanner scanner, + OperationContextHolder contextHolder) { + this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry"); + this.gateway = Objects.requireNonNull(gateway, "generic http gateway"); + this.scanner = Objects.requireNonNull(scanner, "descriptor scanner"); + this.contextHolder = Objects.requireNonNull(contextHolder, "operation context holder"); + } + + @Override + public T client(Class serviceType) { + return client(scanner.profileOf(serviceType), serviceType); + } + + @Override + public T client(ClientProfileName profileName, Class serviceType) { + Objects.requireNonNull(profileName, "profile name"); + Objects.requireNonNull(serviceType, "service type"); + String key = profileName.value() + '|' + serviceType.getName(); + return serviceType.cast( + clients.computeIfAbsent(key, ignored -> build(profileName, serviceType))); + } + + private T build(ClientProfileName profileName, Class serviceType) { + List descriptors = scanner.scan(serviceType); + ClientProfileName declared = scanner.profileOf(serviceType); + if (!declared.equals(profileName)) { + throw new HttpConfigurationException( + "interface " + + serviceType.getName() + + " declares profile " + + declared.value() + + " but was requested for " + + profileName.value(), + HttpFailureMetadata.startup(profileName)); + } + if (descriptors.stream().anyMatch(ServiceOperationDescriptor::reactive)) { + throw new HttpConfigurationException( + "interface " + serviceType.getName() + " is reactive; use the reactive registry", + HttpFailureMetadata.startup(profileName)); + } + + try (ClientRuntimeLease lease = runtimes.acquire(profileName)) { + if (!(lease.runtime() instanceof BlockingClientRuntime)) { + throw new HttpConfigurationException( + "profile " + profileName.value() + " is not configured for the blocking api", + HttpFailureMetadata.startup(profileName)); + } + } + + // Deliberately not RestClientAdapter over the profile's RestClient. That adapter reaches the + // network directly, so a typed call bypassed target policy, credentials, admission, deadline, + // resilience, byte limits, stable error mapping and observation — every guarantee the profile + // exists to provide. Routing through the gateway makes the typed surface the same code path as + // the generic one rather than a parallel one that happens to look similar. + T springProxy = + HttpServiceProxyFactory.builderFor( + new KernelHttpExchangeAdapter(gateway, profileName, contextHolder)) + .build() + .createClient(serviceType); + + Map byMethod = new LinkedHashMap<>(); + descriptors.forEach(descriptor -> byMethod.put(descriptor.method(), descriptor)); + + InvocationHandler handler = + new BlockingServiceInvocationHandler(springProxy, profileName, byMethod, contextHolder); + return serviceType.cast( + Proxy.newProxyInstance( + serviceType.getClassLoader(), new Class[] {serviceType}, handler)); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/service/DefaultReactiveHttpServiceRegistry.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/service/DefaultReactiveHttpServiceRegistry.java new file mode 100644 index 00000000..c36bc593 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/service/DefaultReactiveHttpServiceRegistry.java @@ -0,0 +1,108 @@ +package dev.caskeleton.adapter.outbound.httpclient.service; + +import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry; +import dev.caskeleton.adapter.outbound.httpclient.webclient.ReactiveClientRuntime; +import dev.caskeleton.adapter.outbound.httpclient.webclient.ReactiveHttpGateway; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +/** + * Builds validated reactive typed clients over a profile's immutable WebClient (design §26.3). + * + *

An interface that is entirely blocking is rejected here for the same reason its mirror image + * is rejected by the blocking registry: one interface, one execution model. + */ +public final class DefaultReactiveHttpServiceRegistry implements ReactiveHttpServiceRegistry { + + /** + * Only reached by a caller that blocks on a reactive typed method, which the platform forbids. + */ + private static final Duration BLOCK_TIMEOUT = Duration.ofSeconds(30); + + private final ClientRuntimeRegistry runtimes; + private final ReactiveHttpGateway gateway; + private final ServiceOperationDescriptorScanner scanner; + private final OperationContextHolder contextHolder; + private final Map clients = new ConcurrentHashMap<>(); + + public DefaultReactiveHttpServiceRegistry( + ClientRuntimeRegistry runtimes, ReactiveHttpGateway gateway) { + this( + runtimes, + gateway, + new ServiceOperationDescriptorScanner(), + OperationContextHolder.instance()); + } + + public DefaultReactiveHttpServiceRegistry( + ClientRuntimeRegistry runtimes, + ReactiveHttpGateway gateway, + ServiceOperationDescriptorScanner scanner, + OperationContextHolder contextHolder) { + this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry"); + this.gateway = Objects.requireNonNull(gateway, "reactive http gateway"); + this.scanner = Objects.requireNonNull(scanner, "descriptor scanner"); + this.contextHolder = Objects.requireNonNull(contextHolder, "operation context holder"); + } + + @Override + public T client(Class serviceType) { + return client(scanner.profileOf(serviceType), serviceType); + } + + @Override + public T client(ClientProfileName profileName, Class serviceType) { + Objects.requireNonNull(profileName, "profile name"); + Objects.requireNonNull(serviceType, "service type"); + String key = profileName.value() + '|' + serviceType.getName(); + return serviceType.cast( + clients.computeIfAbsent(key, ignored -> build(profileName, serviceType))); + } + + private T build(ClientProfileName profileName, Class serviceType) { + List descriptors = scanner.scan(serviceType); + if (descriptors.stream().anyMatch(descriptor -> !descriptor.reactive())) { + throw new HttpConfigurationException( + "interface " + serviceType.getName() + " is blocking; use the blocking registry", + HttpFailureMetadata.startup(profileName)); + } + + try (ClientRuntimeLease lease = runtimes.acquire(profileName)) { + if (!(lease.runtime() instanceof ReactiveClientRuntime)) { + throw new HttpConfigurationException( + "profile " + profileName.value() + " is not configured for the reactive api", + HttpFailureMetadata.startup(profileName)); + } + } + + // Not WebClientAdapter over the raw WebClient: that reached the network with no target policy, + // credential, admission, deadline, resilience, byte limit, stable error mapping or observation. + T springProxy = + HttpServiceProxyFactory.builderFor( + new ReactiveKernelHttpExchangeAdapter( + gateway, profileName, contextHolder, BLOCK_TIMEOUT)) + .build() + .createClient(serviceType); + + Map byMethod = new LinkedHashMap<>(); + descriptors.forEach(descriptor -> byMethod.put(descriptor.method(), descriptor)); + + InvocationHandler handler = + new ReactiveServiceInvocationHandler(springProxy, profileName, byMethod, contextHolder); + return serviceType.cast( + Proxy.newProxyInstance( + serviceType.getClassLoader(), new Class[] {serviceType}, handler)); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/service/KernelHttpExchangeAdapter.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/service/KernelHttpExchangeAdapter.java new file mode 100644 index 00000000..59929fed --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/service/KernelHttpExchangeAdapter.java @@ -0,0 +1,211 @@ +package dev.caskeleton.adapter.outbound.httpclient.service; + +import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName; +import dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod; +import dev.caskeleton.adapter.outbound.httpclient.api.IdempotencyKey; +import dev.caskeleton.adapter.outbound.httpclient.api.OperationName; +import dev.caskeleton.adapter.outbound.httpclient.api.body.BodySource; +import dev.caskeleton.adapter.outbound.httpclient.api.body.EmptyBody; +import dev.caskeleton.adapter.outbound.httpclient.api.body.ObjectBody; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation; +import dev.caskeleton.adapter.outbound.httpclient.api.result.HttpCallResult; +import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType; +import dev.caskeleton.adapter.outbound.httpclient.restclient.GenericHttpGateway; +import dev.caskeleton.adapter.outbound.httpclient.restclient.RestClientResponseReader; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.web.service.invoker.HttpExchangeAdapter; +import org.springframework.web.service.invoker.HttpRequestValues; + +/** + * Runs every typed-client call through the platform kernel instead of a raw Spring client. + * + *

The typed registries used to hand {@code RestClientAdapter.create(runtime.restClient())} to + * Spring's proxy factory, which meant an {@code @HttpExchange} method reached the network through + * the profile's {@code RestClient} and nothing else. It passed no trusted-target policy, resolved + * no credential, took no admission permit, respected no total deadline, entered no circuit breaker, + * rate limiter or bulkhead, produced no evidence-based retry, applied no request or response byte + * limit, mapped no stable exception, and recorded no logical or attempt observation. The proxy did + * publish an operation descriptor into a thread local, but no code on the execution path read it — + * so the descriptor described a call the platform was not making. + * + *

This adapter is the seam that closes that. Spring still owns argument binding and response + * decoding, which is where its annotation model earns its place; the exchange itself is translated + * into an {@link HttpOperation} and handed to {@link GenericHttpGateway}, the same entry the + * generic H2 surface uses. Everything listed above therefore applies to a typed client because it + * is the same code path, not because it was reimplemented alongside. + * + *

The descriptor comes from {@link OperationContextHolder}, bound by the invocation handler for + * the duration of the call. A call with no binding is refused rather than executed with invented + * defaults: a typed method whose descriptor the scanner never produced has no declared idempotency, + * and guessing one is exactly the decision this platform exists to stop callers making by accident. + */ +public final class KernelHttpExchangeAdapter implements HttpExchangeAdapter { + + private final GenericHttpGateway gateway; + private final ClientProfileName profileName; + private final OperationContextHolder contextHolder; + + public KernelHttpExchangeAdapter( + GenericHttpGateway gateway, + ClientProfileName profileName, + OperationContextHolder contextHolder) { + this.gateway = Objects.requireNonNull(gateway, "generic http gateway"); + this.profileName = Objects.requireNonNull(profileName, "profile name"); + this.contextHolder = Objects.requireNonNull(contextHolder, "operation context holder"); + } + + /** + * Request attributes are not supported. + * + *

They are a Spring-side side channel into the underlying client, and the platform's whole + * position is that the underlying client is not reachable from a caller. + */ + @Override + public boolean supportsRequestAttributes() { + return false; + } + + @Override + public void exchange(HttpRequestValues values) { + execute(values, ResponseType.empty()); + } + + @Override + public HttpHeaders exchangeForHeaders(HttpRequestValues values) { + return headersOf(execute(values, ResponseType.empty())); + } + + @Override + public T exchangeForBody(HttpRequestValues values, ParameterizedTypeReference bodyType) { + return execute(values, responseTypeOf(bodyType)).body(); + } + + @Override + public ResponseEntity exchangeForBodilessEntity(HttpRequestValues values) { + HttpCallResult result = execute(values, ResponseType.empty()); + return ResponseEntity.status(statusOf(result)).headers(headersOf(result)).build(); + } + + @Override + public ResponseEntity exchangeForEntity( + HttpRequestValues values, ParameterizedTypeReference bodyType) { + HttpCallResult result = execute(values, responseTypeOf(bodyType)); + return ResponseEntity.status(statusOf(result)).headers(headersOf(result)).body(result.body()); + } + + private HttpCallResult execute(HttpRequestValues values, ResponseType responseType) { + ServiceOperationDescriptor descriptor = requireDescriptor(); + return gateway.exchange(profileName, operationOf(values, descriptor), responseType); + } + + private ServiceOperationDescriptor requireDescriptor() { + return contextHolder + .current() + .filter(binding -> binding.clientName().equals(profileName)) + .map(OperationContextHolder.Binding::descriptor) + .orElseThrow( + () -> + new HttpConfigurationException( + "a typed client call for profile " + + profileName.value() + + " reached the platform without a registered operation descriptor", + HttpFailureMetadata.startup(profileName))); + } + + /** + * Translates Spring's request values into a platform operation. + * + *

The URI template is carried through unexpanded. Spring hands over both the template and its + * variables, and keeping them separate is what lets observability tags stay low-cardinality and + * lets the trusted-target policy expand the path itself against the profile's base URL. + */ + private HttpOperation operationOf( + HttpRequestValues values, ServiceOperationDescriptor descriptor) { + String uriTemplate = values.getUriTemplate(); + if (uriTemplate == null) { + // A pre-expanded URI would let a typed method choose its own destination, which is what H3 + // and its separate policy exist for. + throw new HttpConfigurationException( + "typed operation " + + descriptor.operationName().value() + + " must declare a profile-relative uri template rather than an absolute url", + HttpFailureMetadata.startup(profileName)); + } + org.springframework.http.HttpMethod method = values.getHttpMethod(); + if (method == null) { + throw new HttpConfigurationException( + "typed operation " + descriptor.operationName().value() + " declares no http method", + HttpFailureMetadata.startup(profileName)); + } + + Map> headers = new LinkedHashMap<>(); + Optional idempotencyKey = Optional.empty(); + String keyHeader = descriptor.idempotencyKeyRequirement().headerName(); + // headerSet() rather than entrySet(): Spring 7's HttpHeaders is no longer a MultiValueMap. + for (Map.Entry> header : values.getHeaders().headerSet()) { + if (descriptor.idempotencyKeyRequirement().required() + && header.getKey().equalsIgnoreCase(keyHeader)) { + // Lifted out of the headers and onto the operation, so the trusted-target policy renders it + // and the retry decision can see that it was actually sent. + idempotencyKey = Optional.of(new IdempotencyKey(header.getValue().getFirst())); + continue; + } + headers.put(header.getKey(), List.copyOf(new ArrayList<>(header.getValue()))); + } + if (descriptor.idempotencyKeyRequirement().required() && idempotencyKey.isEmpty()) { + throw new HttpConfigurationException( + "typed operation " + + descriptor.operationName().value() + + " requires an idempotency key but the call supplied no " + + keyHeader + + " header", + HttpFailureMetadata.startup(profileName)); + } + + return new HttpOperation( + descriptor.operationName(), + HttpMethod.valueOf(method.name()), + uriTemplate, + Map.copyOf(values.getUriVariables()), + Map.copyOf(headers), + bodyOf(values), + descriptor.idempotency(), + idempotencyKey, + Optional.empty()); + } + + private BodySource bodyOf(HttpRequestValues values) { + Object body = values.getBodyValue(); + return body == null ? EmptyBody.instance() : ObjectBody.json(body); + } + + private ResponseType responseTypeOf(ParameterizedTypeReference bodyType) { + return RestClientResponseReader.responseTypeOf(bodyType); + } + + private HttpStatusCode statusOf(HttpCallResult result) { + return HttpStatusCode.valueOf(result.status().value()); + } + + private HttpHeaders headersOf(HttpCallResult result) { + HttpHeaders headers = new HttpHeaders(); + result.headers().forEach((name, valueList) -> valueList.forEach(v -> headers.add(name, v))); + return headers; + } + + /** The operation name a call is executing, for tests and diagnostics. */ + OperationName currentOperation() { + return requireDescriptor().operationName(); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/service/ReactiveKernelHttpExchangeAdapter.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/service/ReactiveKernelHttpExchangeAdapter.java new file mode 100644 index 00000000..935bf758 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/service/ReactiveKernelHttpExchangeAdapter.java @@ -0,0 +1,272 @@ +package dev.caskeleton.adapter.outbound.httpclient.service; + +import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName; +import dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod; +import dev.caskeleton.adapter.outbound.httpclient.api.IdempotencyKey; +import dev.caskeleton.adapter.outbound.httpclient.api.body.BodySource; +import dev.caskeleton.adapter.outbound.httpclient.api.body.EmptyBody; +import dev.caskeleton.adapter.outbound.httpclient.api.body.ObjectBody; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation; +import dev.caskeleton.adapter.outbound.httpclient.api.result.HttpCallResult; +import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType; +import dev.caskeleton.adapter.outbound.httpclient.restclient.RestClientResponseReader; +import dev.caskeleton.adapter.outbound.httpclient.webclient.ReactiveHttpGateway; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.web.service.invoker.HttpRequestValues; +import org.springframework.web.service.invoker.ReactorHttpExchangeAdapter; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * The reactive twin of {@link KernelHttpExchangeAdapter}. + * + *

The reactive typed registry had the same defect as the blocking one, with the same + * consequence: {@code WebClientAdapter.create(runtime.webClient())} handed Spring the profile's raw + * {@code WebClient}, so a reactive typed call reached the network with none of the platform's + * guarantees. It also put a descriptor into the Reactor context that no code on the execution path + * read. + * + *

Routing through {@link ReactiveHttpGateway} makes the reactive typed surface the same + * execution path as the reactive generic one. The subscription is where the work happens, so the + * descriptor is captured at assembly time — the thread that assembles a {@code Mono} is not + * necessarily the thread that subscribes to it, and reading a thread local at subscription would + * find nothing. + */ +public final class ReactiveKernelHttpExchangeAdapter implements ReactorHttpExchangeAdapter { + + private final ReactiveHttpGateway gateway; + private final ClientProfileName profileName; + private final OperationContextHolder contextHolder; + private final Duration blockTimeout; + + public ReactiveKernelHttpExchangeAdapter( + ReactiveHttpGateway gateway, + ClientProfileName profileName, + OperationContextHolder contextHolder, + Duration blockTimeout) { + this.gateway = Objects.requireNonNull(gateway, "reactive http gateway"); + this.profileName = Objects.requireNonNull(profileName, "profile name"); + this.contextHolder = Objects.requireNonNull(contextHolder, "operation context holder"); + this.blockTimeout = Objects.requireNonNull(blockTimeout, "block timeout"); + } + + @Override + public boolean supportsRequestAttributes() { + return false; + } + + @Override + public ReactiveAdapterRegistry getReactiveAdapterRegistry() { + return ReactiveAdapterRegistry.getSharedInstance(); + } + + @Override + public Duration getBlockTimeout() { + return blockTimeout; + } + + @Override + public Mono exchangeForMono(HttpRequestValues values) { + return execute(values, ResponseType.empty()).then(); + } + + @Override + public Mono exchangeForHeadersMono(HttpRequestValues values) { + return execute(values, ResponseType.empty()).map(this::headersOf); + } + + @Override + public Mono exchangeForBodyMono( + HttpRequestValues values, ParameterizedTypeReference bodyType) { + return execute(values, RestClientResponseReader.responseTypeOf(bodyType)) + .mapNotNull(HttpCallResult::body); + } + + /** + * A {@code Flux} return type is served by decoding the whole body and then emitting its elements. + * + *

It is deliberately not a streaming subscription. A streamed response cannot be bounded by + * the profile's decoded-byte limit or retried on evidence, and a typed method that looks like an + * ordinary call must not quietly acquire different safety properties because its return type is a + * {@code Flux}. Genuine streaming has its own gateway, with its own contract. + */ + @Override + public Flux exchangeForBodyFlux( + HttpRequestValues values, ParameterizedTypeReference bodyType) { + return execute(values, RestClientResponseReader.responseTypeOf(bodyType)) + .flatMapMany(result -> elementsOf(result.body())); + } + + @Override + public Mono> exchangeForBodilessEntityMono(HttpRequestValues values) { + return execute(values, ResponseType.empty()) + .map(result -> ResponseEntity.status(statusOf(result)).headers(headersOf(result)).build()); + } + + @Override + public Mono> exchangeForEntityMono( + HttpRequestValues values, ParameterizedTypeReference bodyType) { + return execute(values, RestClientResponseReader.responseTypeOf(bodyType)) + .map( + result -> + ResponseEntity.status(statusOf(result)) + .headers(headersOf(result)) + .body(result.body())); + } + + @Override + public Mono>> exchangeForEntityFlux( + HttpRequestValues values, ParameterizedTypeReference bodyType) { + return execute(values, RestClientResponseReader.responseTypeOf(bodyType)) + .map( + result -> + ResponseEntity.status(statusOf(result)) + .headers(headersOf(result)) + .body(elementsOf(result.body()))); + } + + /** Blocking-surface members of the interface are unreachable for a reactive typed client. */ + @Override + public void exchange(HttpRequestValues values) { + exchangeForMono(values).block(blockTimeout); + } + + @Override + public HttpHeaders exchangeForHeaders(HttpRequestValues values) { + return exchangeForHeadersMono(values).block(blockTimeout); + } + + @Override + public T exchangeForBody(HttpRequestValues values, ParameterizedTypeReference bodyType) { + return exchangeForBodyMono(values, bodyType).block(blockTimeout); + } + + @Override + public ResponseEntity exchangeForBodilessEntity(HttpRequestValues values) { + return exchangeForBodilessEntityMono(values).block(blockTimeout); + } + + @Override + public ResponseEntity exchangeForEntity( + HttpRequestValues values, ParameterizedTypeReference bodyType) { + return exchangeForEntityMono(values, bodyType).block(blockTimeout); + } + + /** + * Resolves the descriptor now and defers the call. + * + *

The descriptor lookup is eager on purpose: it reads a thread local the invocation handler + * bound around the assembly of this {@code Mono}, and by the time anything subscribes that + * binding is gone. + */ + private Mono> execute( + HttpRequestValues values, ResponseType responseType) { + ServiceOperationDescriptor descriptor = requireDescriptor(); + HttpOperation operation = operationOf(values, descriptor); + return Mono.defer(() -> gateway.exchange(profileName, operation, responseType)); + } + + private ServiceOperationDescriptor requireDescriptor() { + return contextHolder + .current() + .filter(binding -> binding.clientName().equals(profileName)) + .map(OperationContextHolder.Binding::descriptor) + .orElseThrow( + () -> + new HttpConfigurationException( + "a reactive typed client call for profile " + + profileName.value() + + " reached the platform without a registered operation descriptor", + HttpFailureMetadata.startup(profileName))); + } + + private HttpOperation operationOf( + HttpRequestValues values, ServiceOperationDescriptor descriptor) { + String uriTemplate = values.getUriTemplate(); + if (uriTemplate == null) { + throw new HttpConfigurationException( + "typed operation " + + descriptor.operationName().value() + + " must declare a profile-relative uri template rather than an absolute url", + HttpFailureMetadata.startup(profileName)); + } + org.springframework.http.HttpMethod method = values.getHttpMethod(); + if (method == null) { + throw new HttpConfigurationException( + "typed operation " + descriptor.operationName().value() + " declares no http method", + HttpFailureMetadata.startup(profileName)); + } + + Map> headers = new LinkedHashMap<>(); + Optional idempotencyKey = Optional.empty(); + String keyHeader = descriptor.idempotencyKeyRequirement().headerName(); + for (Map.Entry> header : values.getHeaders().headerSet()) { + if (descriptor.idempotencyKeyRequirement().required() + && header.getKey().equalsIgnoreCase(keyHeader)) { + idempotencyKey = Optional.of(new IdempotencyKey(header.getValue().getFirst())); + continue; + } + headers.put(header.getKey(), List.copyOf(new ArrayList<>(header.getValue()))); + } + if (descriptor.idempotencyKeyRequirement().required() && idempotencyKey.isEmpty()) { + throw new HttpConfigurationException( + "typed operation " + + descriptor.operationName().value() + + " requires an idempotency key but the call supplied no " + + keyHeader + + " header", + HttpFailureMetadata.startup(profileName)); + } + + return new HttpOperation( + descriptor.operationName(), + HttpMethod.valueOf(method.name()), + uriTemplate, + Map.copyOf(values.getUriVariables()), + Map.copyOf(headers), + bodyOf(values), + descriptor.idempotency(), + idempotencyKey, + Optional.empty()); + } + + private BodySource bodyOf(HttpRequestValues values) { + Object body = values.getBodyValue(); + return body == null ? EmptyBody.instance() : ObjectBody.json(body); + } + + @SuppressWarnings("unchecked") + private Flux elementsOf(Object body) { + if (body == null) { + return Flux.empty(); + } + if (body instanceof Collection elements) { + return Flux.fromIterable((Collection) elements); + } + return Flux.just((T) body); + } + + private HttpStatusCode statusOf(HttpCallResult result) { + return HttpStatusCode.valueOf(result.status().value()); + } + + private HttpHeaders headersOf(HttpCallResult result) { + HttpHeaders headers = new HttpHeaders(); + result.headers().forEach((name, valueList) -> valueList.forEach(v -> headers.add(name, v))); + return headers; + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/service/ReactiveServiceInvocationHandler.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/service/ReactiveServiceInvocationHandler.java new file mode 100644 index 00000000..20522e68 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/service/ReactiveServiceInvocationHandler.java @@ -0,0 +1,79 @@ +package dev.caskeleton.adapter.outbound.httpclient.service; + +import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Map; +import java.util.Objects; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Attaches the operation descriptor to the Reactor Context of the returned publisher (design §26.3 + * step 7). + * + *

A non-reactive return value is rejected rather than adapted: adapting it would mean blocking + * somewhere, and design §18.2 forbids that on this path. + */ +public final class ReactiveServiceInvocationHandler implements InvocationHandler { + + private final Object delegate; + private final ClientProfileName profileName; + private final Map descriptors; + private final OperationContextHolder contextHolder; + + public ReactiveServiceInvocationHandler( + Object delegate, + ClientProfileName profileName, + Map descriptors, + OperationContextHolder contextHolder) { + this.delegate = Objects.requireNonNull(delegate, "delegate proxy"); + this.profileName = Objects.requireNonNull(profileName, "profile name"); + this.descriptors = Map.copyOf(Objects.requireNonNull(descriptors, "descriptors")); + this.contextHolder = Objects.requireNonNull(contextHolder, "operation context holder"); + } + + /** + * Binds the descriptor for the duration of assembly, then attaches it to the returned publisher. + * + *

Two bindings for two readers. The Reactor context reaches operators that run at subscription + * time; the thread-local reaches the exchange adapter, which translates the call into a platform + * operation while this method is still on the stack. Only the second one existed as a consumer + * before — and nothing read it, because the raw {@code WebClient} was doing the work. + */ + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + ServiceOperationDescriptor descriptor = descriptors.get(method); + Object result; + if (descriptor == null) { + result = invokeDelegate(method, args); + } else { + try (AutoCloseable binding = contextHolder.bind(profileName, descriptor)) { + result = invokeDelegate(method, args); + } + } + if (descriptor == null) { + return result; + } + if (result instanceof Mono mono) { + return mono.contextWrite(context -> context.put(ReactiveOperationContext.KEY, descriptor)); + } + if (result instanceof Flux flux) { + return flux.contextWrite(context -> context.put(ReactiveOperationContext.KEY, descriptor)); + } + throw new HttpConfigurationException( + "reactive service method must return Mono or Flux", + HttpFailureMetadata.startup(profileName)); + } + + private Object invokeDelegate(Method method, Object[] args) throws Throwable { + try { + return method.invoke(delegate, args); + } catch (InvocationTargetException invocationFailure) { + throw invocationFailure.getCause(); + } + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/transport/BlockingTransportProvider.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/transport/BlockingTransportProvider.java new file mode 100644 index 00000000..aa3baaf9 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/transport/BlockingTransportProvider.java @@ -0,0 +1,32 @@ +package dev.caskeleton.adapter.outbound.httpclient.transport; + +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile; +import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration; +import org.springframework.http.client.ClientHttpRequestFactory; + +/** + * Blocking transport SPI (design §13.1). + * + *

The provider returns a Spring {@link ClientHttpRequestFactory} and never the native engine + * client: design D-04 and §9.5 make native access an internal concern of this package. + * + *

Resources belong to a generation, not to a profile. Keying them by profile name meant + * a rotation overwrote the map entry with the new generation's client, and the old generation's + * closer — which runs after its drain completes — then closed the replacement while the + * connections it was supposed to release stayed open. Every rotation leaked one pool and broke the + * live one. + */ +public interface BlockingTransportProvider { + + TransportId id(); + + BlockingTransportCapabilities capabilities(); + + ClientHttpRequestFactory create( + ClientProfile profile, RuntimeGeneration generation, TransportLifecycleListener listener); + + TransportFailureClassifier failureClassifier(); + + /** Releases the engine resources created for exactly this profile generation. */ + void close(ClientProfile profile, RuntimeGeneration generation); +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/transport/ReactiveTransportProvider.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/transport/ReactiveTransportProvider.java new file mode 100644 index 00000000..83dd2fa2 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/transport/ReactiveTransportProvider.java @@ -0,0 +1,28 @@ +package dev.caskeleton.adapter.outbound.httpclient.transport; + +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile; +import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration; +import org.springframework.http.client.reactive.ClientHttpConnector; + +/** + * Reactive transport SPI (design §13.2). + * + *

Returns a Spring {@link ClientHttpConnector}; the native reactive client stays internal. + * + *

Resources are owned per generation, for the reason spelled out on {@link + * BlockingTransportProvider}: a profile-keyed map lets a rotation's closer release the generation + * that replaced it. + */ +public interface ReactiveTransportProvider { + + TransportId id(); + + ReactiveTransportCapabilities capabilities(); + + ClientHttpConnector create( + ClientProfile profile, RuntimeGeneration generation, TransportLifecycleListener listener); + + TransportFailureClassifier failureClassifier(); + + void close(ClientProfile profile, RuntimeGeneration generation); +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/transport/TransportCapabilityValidator.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/transport/TransportCapabilityValidator.java new file mode 100644 index 00000000..f5197c7c --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/transport/TransportCapabilityValidator.java @@ -0,0 +1,90 @@ +package dev.caskeleton.adapter.outbound.httpclient.transport; + +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientMode; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile; +import dev.caskeleton.adapter.outbound.httpclient.profile.HttpProtocol; +import java.util.ArrayList; +import java.util.List; + +/** + * Startup guard that fails when a transport is weaker than the profile it was selected for (design + * §13.3, Task 8). + * + *

Messages name profile settings and capability names only — never a URL, address, or secret. + */ +public final class TransportCapabilityValidator { + + public void validate(ClientProfile profile, BlockingTransportCapabilities capabilities) { + List missing = new ArrayList<>(); + collectProtocolGaps(profile, capabilities.protocols(), missing); + if (profile.pool().requiresRoutePool() && !capabilities.routeScopedPool()) { + missing.add("route pool"); + } + if (profile.pool().requiresBoundedPendingQueue() + && !capabilities.boundedPendingAcquireQueue()) { + missing.add("bounded pending acquire queue"); + } + if (profile.proxy().enabled() && !capabilities.proxySupport()) { + missing.add("proxy"); + } + if (profile.tls().mutualTls() && !capabilities.mutualTls()) { + missing.add("mutual TLS"); + } + if (profile.mode() == ClientMode.DYNAMIC && !capabilities.dynamicTargetStable()) { + missing.add("validated DNS pinning for dynamic targets"); + } + if (profile.mode() == ClientMode.DYNAMIC && !capabilities.validatedDnsPinning()) { + // `validatedDnsPinning` was declared on every capability record and read by nothing. It is + // the capability that decides whether the SSRF address validation survives to the socket, so + // a transport that does not have it cannot serve a dynamic target no matter what its + // `dynamicTargetStable` flag says — the two were being conflated. + missing.add("call-scoped validated DNS pinning"); + } + reject(profile, missing); + } + + public void validate(ClientProfile profile, ReactiveTransportCapabilities capabilities) { + List missing = new ArrayList<>(); + collectProtocolGaps(profile, capabilities.protocols(), missing); + if (profile.pool().requiresRoutePool() && !capabilities.routeScopedPool()) { + missing.add("route pool"); + } + if (profile.pool().requiresBoundedPendingQueue() + && !capabilities.boundedPendingAcquireQueue()) { + missing.add("bounded pending acquire queue"); + } + if (profile.proxy().enabled() && !capabilities.proxySupport()) { + missing.add("proxy"); + } + if (profile.tls().mutualTls() && !capabilities.mutualTls()) { + missing.add("mutual TLS"); + } + if (profile.mode() == ClientMode.DYNAMIC && !capabilities.dynamicTargetStable()) { + missing.add("validated DNS pinning for dynamic targets"); + } + reject(profile, missing); + } + + private void collectProtocolGaps( + ClientProfile profile, java.util.Set supported, List missing) { + for (HttpProtocol protocol : profile.protocols()) { + if (!supported.contains(protocol)) { + missing.add(protocol.name()); + } + } + } + + private void reject(ClientProfile profile, List missing) { + if (missing.isEmpty()) { + return; + } + throw new HttpConfigurationException( + "transport capability is weaker than profile " + + profile.name().value() + + " requires: " + + String.join(", ", missing), + HttpFailureMetadata.startup(profile.name())); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/transport/TransportResourceKey.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/transport/TransportResourceKey.java new file mode 100644 index 00000000..dec36796 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/transport/TransportResourceKey.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.outbound.httpclient.transport; + +import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName; +import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration; +import java.util.Objects; + +/** + * Identifies the engine resources belonging to one profile generation. + * + *

Transport providers used to key their pools and clients by profile name alone. A rotation + * creates generation N+1 while generation N is still draining, so the new entry overwrote the old + * one; when N's drain finished and its closer ran, it looked up the profile name and closed N+1 — + * the generation that was serving traffic — while N's sockets stayed open. Each rotation therefore + * leaked a pool and broke the live client, which is the opposite of what draining is for. + * + * @param profileName the profile the resources serve + * @param generation the generation that owns them + */ +public record TransportResourceKey(ClientProfileName profileName, RuntimeGeneration generation) { + + public TransportResourceKey { + Objects.requireNonNull(profileName, "profile name"); + Objects.requireNonNull(generation, "runtime generation"); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/webclient/DefaultReactiveHttpGateway.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/webclient/DefaultReactiveHttpGateway.java new file mode 100644 index 00000000..15809433 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/webclient/DefaultReactiveHttpGateway.java @@ -0,0 +1,330 @@ +package dev.caskeleton.adapter.outbound.httpclient.webclient; + +import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpAmbiguousExecutionException; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpClientException; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRateLimitRejectedException; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation; +import dev.caskeleton.adapter.outbound.httpclient.api.result.HttpCallResult; +import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType; +import dev.caskeleton.adapter.outbound.httpclient.auth.CredentialRequest; +import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentials; +import dev.caskeleton.adapter.outbound.httpclient.observation.LogicalCallObservation; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry; +import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptOutcome; +import dev.caskeleton.adapter.outbound.httpclient.resilience.Deadline; +import dev.caskeleton.adapter.outbound.httpclient.resilience.ReactiveLogicalCall; +import dev.caskeleton.adapter.outbound.httpclient.resilience.ReactiveRetryCoordinator; +import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryAllowed; +import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryContext; +import dev.caskeleton.adapter.outbound.httpclient.restclient.StatusHandlingPolicy; +import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; +import reactor.core.publisher.Mono; + +/** + * Reactive H2 gateway (design §9.3, §26.2). + * + *

Runtime leases are acquired and released with {@code Mono.usingWhen} so a generation cannot be + * closed underneath an in-flight subscription, and operation metadata travels in the Reactor + * Context rather than a ThreadLocal — which would be wrong the moment the pipeline switches + * threads. + */ +public final class DefaultReactiveHttpGateway implements ReactiveHttpGateway { + + private final ClientRuntimeRegistry runtimes; + private final ReactiveAttemptExecutor executor; + + public DefaultReactiveHttpGateway( + ClientRuntimeRegistry runtimes, ReactiveAttemptExecutor executor) { + this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry"); + this.executor = Objects.requireNonNull(executor, "reactive attempt executor"); + } + + @Override + public Mono> exchange( + ClientProfileName profileName, HttpOperation operation, ResponseType responseType) { + return exchange( + profileName, + operation, + Optional.empty(), + responseType, + StatusHandlingPolicy.THROW_ON_ERROR); + } + + public Mono> exchange( + ClientProfileName profileName, + HttpOperation operation, + Optional reactiveBody, + ResponseType responseType, + StatusHandlingPolicy statusHandlingPolicy) { + Objects.requireNonNull(profileName, "profile name"); + Objects.requireNonNull(operation, "operation"); + Objects.requireNonNull(responseType, "response type"); + + return Mono.usingWhen( + Mono.fromSupplier(() -> runtimes.acquire(profileName)), + lease -> execute(lease, operation, reactiveBody, responseType, statusHandlingPolicy), + lease -> Mono.fromRunnable(lease::close), + (lease, failure) -> Mono.fromRunnable(lease::close), + lease -> Mono.fromRunnable(lease::close)); + } + + private Mono> execute( + ClientRuntimeLease lease, + HttpOperation operation, + Optional reactiveBody, + ResponseType responseType, + StatusHandlingPolicy statusHandlingPolicy) { + if (!(lease.runtime() instanceof ReactiveClientRuntime runtime)) { + return Mono.error( + new IllegalStateException( + "profile " + + lease.runtime().name().value() + + " is not configured for the reactive api")); + } + HttpFailureMetadata metadata = + HttpFailureMetadata.validation( + runtime.name(), + operation.operationName(), + operation.method(), + operation.uriTemplate(), + operation.body().replayability()); + + return Mono.usingWhen( + Mono.fromSupplier(() -> runtime.admissionLimiter().admit(metadata)), + admission -> + runCall(runtime, operation, reactiveBody, responseType, statusHandlingPolicy, metadata), + admission -> Mono.fromRunnable(() -> closeQuietly(admission)), + (admission, failure) -> Mono.fromRunnable(() -> closeQuietly(admission)), + admission -> Mono.fromRunnable(() -> closeQuietly(admission))); + } + + private Mono> runCall( + ReactiveClientRuntime runtime, + HttpOperation operation, + Optional reactiveBody, + ResponseType responseType, + StatusHandlingPolicy statusHandlingPolicy, + HttpFailureMetadata metadata) { + + PreparedOperation prepared = runtime.targetPolicy().prepare(operation); + Deadline deadline = + runtime + .support() + .deadlineCalculator() + .effective( + operation.deadline(), + runtime.profile().timeout().totalCall(), + runtime.support().clock()); + + LogicalCallObservation observation = + LogicalCallObservation.start( + runtime.support().meterRegistry(), + runtime.support().tagPolicy(), + runtime.name(), + operation.operationName(), + operation.method().name(), + operation.uriTemplate()); + + ReactiveRetryCoordinator coordinator = + new ReactiveRetryCoordinator( + runtime.support().eligibilityEngine(), + runtime.newBackoff(), + runtime.retryBudget(), + runtime.support().clock()); + + return runtime + .credentialProvider() + .resolve(credentialRequest(runtime, prepared, false)) + .defaultIfEmpty(RequestCredentials.none()) + .flatMap( + credentials -> + coordinator.execute( + new ReactiveGatewayCall<>( + runtime, + prepared, + reactiveBody, + responseType, + statusHandlingPolicy, + deadline, + metadata, + observation, + credentials))) + .doOnSuccess( + result -> + observation.stop( + Optional.ofNullable(result).map(HttpCallResult::status), + result == null ? ExecutionEvidence.NOT_SENT : result.evidence(), + "success")) + .doOnError( + failure -> { + if (failure instanceof HttpAmbiguousExecutionException ambiguous) { + observation.recordAmbiguous(); + observation.stop(Optional.empty(), ambiguous.metadata().evidence(), "ambiguous"); + } else if (failure instanceof HttpClientException stable) { + observation.stop( + stable.metadata().status(), stable.metadata().evidence(), "failure"); + } + }) + .contextWrite( + context -> + context.put( + ReactiveOperationContextKeys.OPERATION_NAME, + operation.operationName().value())); + } + + private void closeQuietly(AutoCloseable closeable) { + try { + closeable.close(); + } catch (Exception ignored) { + // Releasing an admission permit cannot fail meaningfully. + } + } + + private CredentialRequest credentialRequest( + ReactiveClientRuntime runtime, PreparedOperation prepared, boolean forceRefresh) { + return new CredentialRequest( + runtime.name(), + prepared.operation().operationName(), + runtime.profile().authentication(), + prepared.target().uri(), + Optional.empty(), + runtime.profile().tls().keyMaterialReference(), + forceRefresh); + } + + /** Reactor Context keys used to carry operation metadata without a ThreadLocal. */ + public static final class ReactiveOperationContextKeys { + public static final String OPERATION_NAME = "httpclient.operationName"; + + private ReactiveOperationContextKeys() {} + } + + private final class ReactiveGatewayCall implements ReactiveLogicalCall { + + private final ReactiveClientRuntime runtime; + private final PreparedOperation prepared; + private final Optional reactiveBody; + private final ResponseType responseType; + private final StatusHandlingPolicy statusHandlingPolicy; + private final Deadline deadline; + private final HttpFailureMetadata metadata; + private final LogicalCallObservation observation; + private final RequestCredentials credentials; + private final Instant startedAt; + + private ReactiveGatewayCall( + ReactiveClientRuntime runtime, + PreparedOperation prepared, + Optional reactiveBody, + ResponseType responseType, + StatusHandlingPolicy statusHandlingPolicy, + Deadline deadline, + HttpFailureMetadata metadata, + LogicalCallObservation observation, + RequestCredentials credentials) { + this.runtime = runtime; + this.prepared = prepared; + this.reactiveBody = reactiveBody; + this.responseType = responseType; + this.statusHandlingPolicy = statusHandlingPolicy; + this.deadline = deadline; + this.metadata = metadata; + this.observation = observation; + this.credentials = credentials; + this.startedAt = runtime.support().clock().instant(); + } + + @Override + public Mono> attempt(int attemptNumber) { + if (!runtime.acceptsNewAttempts() && attemptNumber > 1) { + return Mono.error( + new HttpRateLimitRejectedException( + "runtime is draining and refuses new attempts", metadata)); + } + return executor.execute( + runtime, + prepared, + reactiveBody, + responseType, + statusHandlingPolicy, + credentials, + attemptNumber, + startedAt, + metadata.withAttempt(attemptNumber)); + } + + @Override + public RetryContext context(AttemptOutcome outcome, int attemptNumber) { + return new RetryContext( + prepared.operation().idempotency(), + prepared.operation().idempotencyKey(), + prepared.idempotencyKeySent(), + reactiveBody + .map(ReactiveBodySource::replayability) + .orElseGet(() -> prepared.operation().body().replayability()), + outcome.evidence(), + outcome.failureCategory(), + outcome.status(), + outcome.retryAfter(), + attemptNumber, + runtime.profile().retry().maxAttempts(), + outcome.firstByteDelivered(), + deadline.remaining(runtime.support().clock()), + runtime.support().minimumAttemptBudget(), + runtime.retryBudget().snapshot(), + runtime.support().transientServerErrorStatuses(), + false, + !runtime.acceptsNewAttempts()); + } + + @Override + public Deadline deadline() { + return deadline; + } + + @Override + public Mono> finish(AttemptOutcome outcome, int attemptNumber) { + return outcome + .result() + .map( + value -> + Mono.just( + new HttpCallResult<>( + value.status(), + value.headers(), + value.body(), + attemptNumber, + Duration.between(startedAt, runtime.support().clock().instant()), + value.evidence(), + value.remoteProblem()))) + .orElseGet(() -> Mono.error(outcome.failure().orElseThrow())); + } + + @Override + public HttpClientException ambiguous(AttemptOutcome outcome, int attemptNumber) { + return new HttpAmbiguousExecutionException( + "request was sent but the remote outcome is unknown", + metadata.withAttempt(attemptNumber).withEvidence(ExecutionEvidence.SENT_NO_RESPONSE)); + } + + @Override + public HttpClientException retryExhausted(int attemptNumber) { + observation.recordRetryExhausted(); + return new HttpRateLimitRejectedException( + "retry budget for this upstream is exhausted", metadata.withAttempt(attemptNumber)); + } + + @Override + public void onRetryGranted(RetryAllowed allowed, int attemptNumber) { + observation.recordRetry(allowed.reason()); + } + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/webclient/DefaultReactiveSseGateway.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/webclient/DefaultReactiveSseGateway.java new file mode 100644 index 00000000..5b9588c3 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/webclient/DefaultReactiveSseGateway.java @@ -0,0 +1,183 @@ +package dev.caskeleton.adapter.outbound.httpclient.webclient; + +import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.BodyReplayability; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation; +import dev.caskeleton.adapter.outbound.httpclient.api.result.ClassResponseType; +import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType; +import dev.caskeleton.adapter.outbound.httpclient.auth.CredentialRequest; +import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentials; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry; +import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.MediaType; +import org.springframework.http.codec.ServerSentEvent; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; + +/** + * Bounded SSE client (design §23.4). + * + *

Three budgets are kept apart deliberately: a setup deadline for establishing the stream, an + * idle timeout for silence once it is open, and an optional maximum lifetime. Reconnects consume + * the retry budget like any other physical attempt, and cancelling the subscription stops both the + * stream and any pending reconnect. + */ +public final class DefaultReactiveSseGateway implements ReactiveSseGateway { + + private final ClientRuntimeRegistry runtimes; + + public DefaultReactiveSseGateway(ClientRuntimeRegistry runtimes) { + this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry"); + } + + @Override + public Flux> connect( + ClientProfileName profileName, SseOperation operation, ResponseType eventType) { + Objects.requireNonNull(profileName, "profile name"); + Objects.requireNonNull(operation, "sse operation"); + Objects.requireNonNull(eventType, "event type"); + + AtomicReference lastEventId = new AtomicReference<>(); + + return Flux.usingWhen( + Mono.fromSupplier(() -> runtimes.acquire(profileName)), + lease -> stream(lease, operation, eventType, lastEventId), + lease -> Mono.fromRunnable(lease::close), + (lease, failure) -> Mono.fromRunnable(lease::close), + lease -> Mono.fromRunnable(lease::close)); + } + + private Flux> stream( + ClientRuntimeLease lease, + SseOperation operation, + ResponseType eventType, + AtomicReference lastEventId) { + if (!(lease.runtime() instanceof ReactiveClientRuntime runtime)) { + return Flux.error( + new IllegalStateException( + "profile " + + lease.runtime().name().value() + + " is not configured for the reactive api")); + } + HttpFailureMetadata metadata = + HttpFailureMetadata.validation( + runtime.name(), + operation.operationName(), + dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod.GET, + operation.uriTemplate(), + BodyReplayability.REPLAYABLE); + + Flux> events = + open(runtime, operation, eventType, lastEventId) + .timeout( + operation.streamingIdleTimeout(), + Flux.error(new SseIdleTimeoutException(operation.operationName(), metadata))) + .doOnNext(event -> rememberEventId(event, lastEventId)); + + Flux> bounded = + operation.maxStreamDuration().map(events::take).orElse(events); + + return operation.reconnectPolicy().enabled() + ? bounded.retryWhen(reconnectSpec(runtime, operation)) + : bounded; + } + + private Flux> open( + ReactiveClientRuntime runtime, + SseOperation operation, + ResponseType eventType, + AtomicReference lastEventId) { + // Through the profile's target policy, not around it. This used to expand the template and go + // straight to the WebClient, so an SSE subscription reached its destination with no + // relative-only + // check, no host or port allowlist, no header policy and no credential — the one long-lived + // connection type in the platform was also the least governed. The policy rejects an absolute + // template and an off-allowlist origin exactly as it does for an ordinary call. + HttpOperation subscribeOperation = + HttpOperation.get( + operation.operationName(), operation.uriTemplate(), operation.uriVariables()); + PreparedOperation prepared = runtime.targetPolicy().prepare(subscribeOperation); + java.net.URI uri = prepared.target().uri(); + + RequestCredentials credentials = + runtime + .credentialProvider() + .resolve( + new CredentialRequest( + runtime.name(), + operation.operationName(), + runtime.profile().authentication(), + uri, + java.util.Optional.empty(), + runtime.profile().tls().keyMaterialReference(), + false)) + .block(operation.setupDeadline()); + + var request = runtime.webClient().get().uri(uri).accept(MediaType.TEXT_EVENT_STREAM); + for (Map.Entry> header : prepared.headers().entrySet()) { + for (String value : header.getValue()) { + request = request.header(header.getKey(), value); + } + } + if (credentials != null) { + for (Map.Entry credential : credentials.headers().entrySet()) { + request = request.header(credential.getKey(), credential.getValue()); + } + } + if (operation.reconnectPolicy().sendLastEventId() && lastEventId.get() != null) { + request = request.header("Last-Event-ID", lastEventId.get()); + } + return request + .retrieve() + .bodyToFlux(serverSentEventType(eventType)) + .timeout( + operation.setupDeadline(), + Flux.error( + new SseIdleTimeoutException( + operation.operationName(), HttpFailureMetadata.startup(runtime.name())))) + .onErrorResume( + java.util.concurrent.TimeoutException.class, + failure -> + Flux.error( + new SseIdleTimeoutException( + operation.operationName(), HttpFailureMetadata.startup(runtime.name())))); + } + + private ParameterizedTypeReference> serverSentEventType( + ResponseType eventType) { + java.lang.reflect.Type eventElementType = + eventType instanceof ClassResponseType classType + ? classType.rawType() + : eventType.type(); + java.lang.reflect.Type sseType = + org.springframework.core.ResolvableType.forClassWithGenerics( + ServerSentEvent.class, + org.springframework.core.ResolvableType.forType(eventElementType)) + .getType(); + return ParameterizedTypeReference.>forType(sseType); + } + + private void rememberEventId(ServerSentEvent event, AtomicReference lastEventId) { + if (event.id() != null) { + lastEventId.set(event.id()); + } + } + + private Retry reconnectSpec(ReactiveClientRuntime runtime, SseOperation operation) { + SseReconnectPolicy policy = operation.reconnectPolicy(); + Duration backoff = + policy.reconnectBackoff().isZero() ? Duration.ofMillis(50) : policy.reconnectBackoff(); + return Retry.fixedDelay(policy.maxReconnects(), backoff) + .filter(failure -> runtime.retryBudget().tryConsume()) + .transientErrors(true); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/webclient/ReactiveAttemptExecutor.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/webclient/ReactiveAttemptExecutor.java new file mode 100644 index 00000000..36f2d940 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/webclient/ReactiveAttemptExecutor.java @@ -0,0 +1,184 @@ +package dev.caskeleton.adapter.outbound.httpclient.webclient; + +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpClientException; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRemoteErrorException; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.FailureCategory; +import dev.caskeleton.adapter.outbound.httpclient.api.result.HttpCallResult; +import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType; +import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentials; +import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptOutcome; +import dev.caskeleton.adapter.outbound.httpclient.restclient.BlockingAttemptExecutor; +import dev.caskeleton.adapter.outbound.httpclient.restclient.RestClientResponseReader; +import dev.caskeleton.adapter.outbound.httpclient.restclient.StatusHandlingPolicy; +import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailure; +import java.time.Duration; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +/** + * Executes one physical reactive attempt (design §26.2). + * + *

Result and error mapping are shared with the blocking path so both produce identical stable + * exceptions and metadata. Discarded buffers are released explicitly: a cancelled or errored + * reactive pipeline drops elements silently, and a dropped {@code DataBuffer} is leaked memory. + */ +public final class ReactiveAttemptExecutor { + + private final WebClientBodyWriter bodyWriter = new WebClientBodyWriter(); + private final WebClientResponseMapper responseMapper = new WebClientResponseMapper(); + + public Mono> execute( + ReactiveClientRuntime runtime, + PreparedOperation prepared, + Optional reactiveBody, + ResponseType responseType, + StatusHandlingPolicy statusHandlingPolicy, + RequestCredentials credentials, + int attemptNumber, + Instant startedAt, + HttpFailureMetadata baseMetadata) { + Objects.requireNonNull(runtime, "runtime"); + + return Mono.defer( + () -> { + // Query-parameter credentials are applied here, as they are on the blocking path. + // An API_KEY_QUERY profile resolved its credential, the reactive executor read only + // the header map, and the request went out with the key missing — an authentication + // mechanism that worked on one API and silently did not on the other. + WebClient.RequestBodySpec spec = + runtime + .webClient() + .method( + org.springframework.http.HttpMethod.valueOf( + prepared.operation().method().name())) + .uri(withCredentialQuery(prepared.target().uri(), credentials)); + headers(prepared, credentials) + .forEach((name, values) -> spec.header(name, values.toArray(String[]::new))); + WebClient.RequestHeadersSpec request = + bodyWriter.write( + spec, + prepared.operation().body(), + reactiveBody, + runtime.bodyLimitPolicy(), + baseMetadata); + return request.exchangeToMono( + response -> + responseMapper.readBounded( + response, Math.toIntExact(prepared.maxResponseWireBytes()))); + }) + .map( + response -> + mapResponse( + runtime, + responseType, + statusHandlingPolicy, + attemptNumber, + startedAt, + baseMetadata, + response)) + .onErrorResume(failure -> Mono.just(mapFailure(runtime, baseMetadata, failure))) + .doOnDiscard(DataBuffer.class, DataBufferUtils::release); + } + + private AttemptOutcome mapResponse( + ReactiveClientRuntime runtime, + ResponseType responseType, + StatusHandlingPolicy statusHandlingPolicy, + int attemptNumber, + Instant startedAt, + HttpFailureMetadata baseMetadata, + RestClientResponseReader.RawResponse response) { + Duration elapsed = Duration.between(startedAt, runtime.support().clock().instant()); + try { + HttpCallResult result = + runtime + .support() + .responseMapper() + .map( + response, + responseType, + runtime.profile().response(), + statusHandlingPolicy, + attemptNumber, + elapsed, + baseMetadata); + return AttemptOutcome.succeeded(result); + } catch (HttpRemoteErrorException remoteError) { + return AttemptOutcome.failed( + remoteError, + FailureCategory.REMOTE_STATUS, + BlockingAttemptExecutor.retryAfter(response), + false); + } catch (HttpClientException stable) { + return AttemptOutcome.failed(stable, categoryOf(stable), Optional.empty(), false); + } + } + + private AttemptOutcome mapFailure( + ReactiveClientRuntime runtime, HttpFailureMetadata baseMetadata, Throwable failure) { + if (failure instanceof HttpClientException stable) { + return AttemptOutcome.failed(stable, categoryOf(stable), Optional.empty(), false); + } + TransportFailure classified = + runtime.failureClassifier().classify(failure, baseMetadata.stage()); + HttpFailureMetadata metadata = + baseMetadata.withEvidence(classified.evidence()).withStage(classified.stage()); + HttpClientException mapped = + runtime.support().exceptionMapper().map(classified, metadata, failure); + return AttemptOutcome.failed(mapped, classified.category(), Optional.empty(), false); + } + + private Map> headers( + PreparedOperation prepared, RequestCredentials credentials) { + Map> merged = new LinkedHashMap<>(prepared.headers()); + credentials.headers().forEach((name, value) -> merged.put(name, List.of(value))); + return merged; + } + + private FailureCategory categoryOf(HttpClientException failure) { + return switch (failure.getClass().getSimpleName()) { + case "HttpDnsException" -> FailureCategory.DNS; + case "HttpPoolAcquireTimeoutException" -> FailureCategory.POOL_ACQUIRE_TIMEOUT; + case "HttpConnectException" -> FailureCategory.CONNECT; + case "HttpProxyException" -> FailureCategory.PROXY; + case "HttpTlsException" -> FailureCategory.TLS_PERMANENT; + case "HttpRequestWriteException" -> FailureCategory.REQUEST_WRITE; + case "HttpResponseTimeoutException" -> FailureCategory.RESPONSE_TIMEOUT; + case "HttpResponseTruncatedException" -> FailureCategory.RESPONSE_TRUNCATED; + case "HttpResponseTooLargeException" -> FailureCategory.RESPONSE_TOO_LARGE; + case "HttpSerializationException" -> FailureCategory.SERIALIZATION; + case "HttpTargetRejectedException" -> FailureCategory.TARGET_REJECTED; + case "HttpRedirectRejectedException" -> FailureCategory.REDIRECT_REJECTED; + case "HttpAuthenticationException" -> FailureCategory.AUTHENTICATION; + case "HttpDeadlineExceededException" -> FailureCategory.DEADLINE_EXCEEDED; + case "HttpConfigurationException" -> FailureCategory.CONFIGURATION; + default -> FailureCategory.UNKNOWN; + }; + } + + /** + * Appends query-parameter credentials to the target. + * + *

The blocking executor has always done this; the reactive one read only the header map, so an + * {@code API_KEY_QUERY} profile authenticated on one API surface and not on the other. + */ + private java.net.URI withCredentialQuery(java.net.URI uri, RequestCredentials credentials) { + if (credentials.queryParameters().isEmpty()) { + return uri; + } + org.springframework.web.util.UriComponentsBuilder builder = + org.springframework.web.util.UriComponentsBuilder.fromUri(uri); + credentials.queryParameters().forEach(builder::queryParam); + return builder.build(true).toUri(); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/webclient/ReactiveClientRuntime.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/webclient/ReactiveClientRuntime.java new file mode 100644 index 00000000..ad4f8e67 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/webclient/ReactiveClientRuntime.java @@ -0,0 +1,111 @@ +package dev.caskeleton.adapter.outbound.httpclient.webclient; + +import dev.caskeleton.adapter.outbound.httpclient.auth.ReactiveRequestCredentialProvider; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntime; +import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration; +import dev.caskeleton.adapter.outbound.httpclient.resilience.BackoffStrategy; +import dev.caskeleton.adapter.outbound.httpclient.resilience.LogicalAdmissionLimiter; +import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryBudget; +import dev.caskeleton.adapter.outbound.httpclient.restclient.BlockingExecutionSupport; +import dev.caskeleton.adapter.outbound.httpclient.security.BodyLimitPolicy; +import dev.caskeleton.adapter.outbound.httpclient.security.TrustedTargetPolicy; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportId; +import java.util.Objects; +import java.util.function.Supplier; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * One immutable reactive generation (design §7.2, §26.2). + * + *

Reactive runtimes deliberately do not expose an attempt bulkhead built on a thread pool: + * design §18.2 requires semaphore-style concurrency here, because wrapping an event loop in a + * thread pool destroys the property that makes it useful. + */ +public final class ReactiveClientRuntime extends ClientRuntime { + + private final WebClient webClient; + private final TransportId transportId; + private final TransportFailureClassifier failureClassifier; + private final TrustedTargetPolicy targetPolicy; + private final BodyLimitPolicy bodyLimitPolicy; + private final LogicalAdmissionLimiter admissionLimiter; + private final RetryBudget retryBudget; + private final Supplier backoffFactory; + private final ReactiveRequestCredentialProvider credentialProvider; + private final BlockingExecutionSupport support; + + public ReactiveClientRuntime( + ClientProfile profile, + RuntimeGeneration generation, + Runnable resourceCloser, + WebClient webClient, + TransportId transportId, + TransportFailureClassifier failureClassifier, + LogicalAdmissionLimiter admissionLimiter, + RetryBudget retryBudget, + Supplier backoffFactory, + ReactiveRequestCredentialProvider credentialProvider, + BlockingExecutionSupport support) { + super(profile, generation, resourceCloser); + this.webClient = Objects.requireNonNull(webClient, "web client"); + this.transportId = Objects.requireNonNull(transportId, "transport id"); + this.failureClassifier = Objects.requireNonNull(failureClassifier, "failure classifier"); + this.admissionLimiter = Objects.requireNonNull(admissionLimiter, "admission limiter"); + this.retryBudget = Objects.requireNonNull(retryBudget, "retry budget"); + this.backoffFactory = Objects.requireNonNull(backoffFactory, "backoff factory"); + this.credentialProvider = Objects.requireNonNull(credentialProvider, "credential provider"); + this.support = Objects.requireNonNull(support, "execution support"); + this.targetPolicy = new TrustedTargetPolicy(profile); + this.bodyLimitPolicy = BodyLimitPolicy.maxRequestBytes(profile.request().maxBodyBytes()); + } + + /** + * The engine client, visible only inside this package. + * + *

Public exposure let a caller bypass every platform guarantee, which is what the reactive + * typed registry did. See {@code BlockingClientRuntime#restClient()} for the full reasoning. + * + * @return the profile's immutable {@code WebClient} + */ + WebClient webClient() { + return webClient; + } + + public TransportId transportId() { + return transportId; + } + + public TransportFailureClassifier failureClassifier() { + return failureClassifier; + } + + public TrustedTargetPolicy targetPolicy() { + return targetPolicy; + } + + public BodyLimitPolicy bodyLimitPolicy() { + return bodyLimitPolicy; + } + + public LogicalAdmissionLimiter admissionLimiter() { + return admissionLimiter; + } + + public RetryBudget retryBudget() { + return retryBudget; + } + + public BackoffStrategy newBackoff() { + return backoffFactory.get(); + } + + public ReactiveRequestCredentialProvider credentialProvider() { + return credentialProvider; + } + + public BlockingExecutionSupport support() { + return support; + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/webclient/ReactiveStreamingGateway.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/webclient/ReactiveStreamingGateway.java new file mode 100644 index 00000000..134d3c2b --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/webclient/ReactiveStreamingGateway.java @@ -0,0 +1,107 @@ +package dev.caskeleton.adapter.outbound.httpclient.webclient; + +import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName; +import dev.caskeleton.adapter.outbound.httpclient.api.HttpStatus; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRemoteErrorException; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry; +import dev.caskeleton.adapter.outbound.httpclient.restclient.ResponseSizeLimiter; +import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation; +import java.util.Objects; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Reactive streaming download (design §23.2, §23.3, D-12). + * + *

The status is checked before the body is exposed, the byte budget is enforced as buffers flow, + * and the first delivered buffer permanently disables transparent retry for the call. + */ +public final class ReactiveStreamingGateway { + + private final ClientRuntimeRegistry runtimes; + + public ReactiveStreamingGateway(ClientRuntimeRegistry runtimes) { + this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry"); + } + + public Flux download(ClientProfileName profileName, HttpOperation operation) { + return download(profileName, operation, new FirstByteDeliveryGuard()); + } + + public Flux download( + ClientProfileName profileName, HttpOperation operation, FirstByteDeliveryGuard guard) { + Objects.requireNonNull(profileName, "profile name"); + Objects.requireNonNull(operation, "operation"); + Objects.requireNonNull(guard, "first byte guard"); + + return Flux.usingWhen( + Mono.fromSupplier(() -> runtimes.acquire(profileName)), + lease -> stream(lease, operation, guard), + lease -> Mono.fromRunnable(lease::close), + (lease, failure) -> Mono.fromRunnable(lease::close), + lease -> Mono.fromRunnable(lease::close)); + } + + private Flux stream( + ClientRuntimeLease lease, HttpOperation operation, FirstByteDeliveryGuard guard) { + if (!(lease.runtime() instanceof ReactiveClientRuntime runtime)) { + return Flux.error( + new IllegalStateException( + "profile " + + lease.runtime().name().value() + + " is not configured for the reactive api")); + } + HttpFailureMetadata metadata = + HttpFailureMetadata.validation( + runtime.name(), + operation.operationName(), + operation.method(), + operation.uriTemplate(), + operation.body().replayability()); + PreparedOperation prepared = runtime.targetPolicy().prepare(operation); + ResponseSizeLimiter limiter = + new ResponseSizeLimiter( + prepared.maxResponseWireBytes(), prepared.maxResponseDecodedBytes(), metadata); + + // The prepared headers are actually sent. They were computed and then dropped: the request was + // built from the URI alone, so the header policy ran, produced an approved set, and the wire + // saw + // none of it — no content negotiation, no correlation header, and no credential. + var request = + runtime + .webClient() + .method(org.springframework.http.HttpMethod.valueOf(operation.method().name())) + .uri(prepared.target().uri()); + for (var header : prepared.headers().entrySet()) { + for (String value : header.getValue()) { + request = request.header(header.getKey(), value); + } + } + + return request + .exchangeToFlux( + response -> { + int status = response.statusCode().value(); + if (status < 200 || status >= 300) { + return response + .releaseBody() + .thenMany( + Flux.error( + new HttpRemoteErrorException( + "streaming download returned an error status", + metadata + .withStatus(new HttpStatus(status)) + .withEvidence(ExecutionEvidence.RESPONSE_RECEIVED)))); + } + return BoundedDataBufferFlux.bound( + response.bodyToFlux(DataBuffer.class), limiter, guard); + }) + .doOnDiscard(DataBuffer.class, DataBufferUtils::release); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/webclient/WebClientBodyWriter.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/webclient/WebClientBodyWriter.java new file mode 100644 index 00000000..ece8e712 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/webclient/WebClientBodyWriter.java @@ -0,0 +1,98 @@ +package dev.caskeleton.adapter.outbound.httpclient.webclient; + +import dev.caskeleton.adapter.outbound.httpclient.api.body.BodySource; +import dev.caskeleton.adapter.outbound.httpclient.api.body.ByteArrayBody; +import dev.caskeleton.adapter.outbound.httpclient.api.body.EmptyBody; +import dev.caskeleton.adapter.outbound.httpclient.api.body.ObjectBody; +import dev.caskeleton.adapter.outbound.httpclient.api.body.OneShotStreamBody; +import dev.caskeleton.adapter.outbound.httpclient.api.body.ReopenableStreamBody; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRequestWriteException; +import dev.caskeleton.adapter.outbound.httpclient.security.BodyLimitPolicy; +import java.io.IOException; +import java.util.Optional; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * Writes a body onto a WebClient request (design §10.2, §23.1). + * + *

A reopenable body is opened per attempt so a retry sends the same bytes rather than an + * already-drained stream. + */ +public final class WebClientBodyWriter { + + public WebClient.RequestHeadersSpec write( + WebClient.RequestBodySpec spec, + BodySource body, + Optional reactiveBody, + BodyLimitPolicy bodyLimitPolicy, + HttpFailureMetadata metadata) { + + if (reactiveBody.isPresent()) { + ReactiveBodySource reactive = reactiveBody.get(); + // A reactive body used to return here before any limit was applied, so the whole + // request-size policy was opt-out: publish the body as a Flux and the profile's + // max-body-bytes stopped existing. The known length is checked up front when the source + // declares one, and the emitted bytes are counted as they go when it does not — an + // unbounded publisher is exactly the case a byte ceiling is for. + reactive + .knownLength() + .ifPresent( + declared -> { + if (declared > bodyLimitPolicy.limit()) { + throw new HttpRequestWriteException( + "reactive request body declares " + + declared + + " bytes, over the profile limit of " + + bodyLimitPolicy.limit(), + metadata); + } + }); + spec.contentType(reactive.mediaType()); + java.util.concurrent.atomic.AtomicLong written = new java.util.concurrent.atomic.AtomicLong(); + return spec.body( + BodyInserters.fromDataBuffers( + reactor.core.publisher.Flux.from(reactive.publisherFactory().get()) + .cast(DataBuffer.class) + .doOnNext( + buffer -> + bodyLimitPolicy.recordWrittenBytes( + written.addAndGet(buffer.readableByteCount()), metadata)))); + } + + bodyLimitPolicy.validate(body, metadata); + if (body instanceof EmptyBody) { + return spec; + } + spec.contentType(mediaType(body)); + if (body instanceof ObjectBody objectBody) { + return spec.bodyValue(objectBody.value()); + } + if (body instanceof ByteArrayBody byteArrayBody) { + return spec.bodyValue(byteArrayBody.bytes()); + } + if (body instanceof ReopenableStreamBody reopenable) { + try { + return spec.body( + BodyInserters.fromResource(new InputStreamResource(reopenable.opener().get()))); + } catch (IOException failure) { + throw new HttpRequestWriteException("request body could not be opened", metadata, failure); + } + } + if (body instanceof OneShotStreamBody oneShot) { + return spec.body(BodyInserters.fromResource(new InputStreamResource(oneShot.stream()))); + } + throw new IllegalStateException("unsupported body source: " + body.getClass().getName()); + } + + private MediaType mediaType(BodySource body) { + String declared = body.mediaType(); + return declared.isBlank() + ? MediaType.APPLICATION_OCTET_STREAM + : MediaType.parseMediaType(declared); + } +} diff --git a/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/webclient/WebClientRuntimeFactory.java b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/webclient/WebClientRuntimeFactory.java new file mode 100644 index 00000000..9c2e47f5 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/webclient/WebClientRuntimeFactory.java @@ -0,0 +1,123 @@ +package dev.caskeleton.adapter.outbound.httpclient.webclient; + +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata; +import dev.caskeleton.adapter.outbound.httpclient.auth.ReactiveRequestCredentialProvider; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntime; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeFactory; +import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration; +import dev.caskeleton.adapter.outbound.httpclient.profile.TransportType; +import dev.caskeleton.adapter.outbound.httpclient.resilience.BackoffStrategy; +import dev.caskeleton.adapter.outbound.httpclient.resilience.ExponentialFullJitterBackoff; +import dev.caskeleton.adapter.outbound.httpclient.resilience.ResilienceRegistry; +import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryBudget; +import dev.caskeleton.adapter.outbound.httpclient.restclient.BlockingExecutionSupport; +import dev.caskeleton.adapter.outbound.httpclient.transport.ReactiveTransportProvider; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportCapabilityValidator; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportLifecycleListener; +import java.time.Duration; +import java.util.EnumMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; +import java.util.random.RandomGenerator; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * Builds a reactive runtime generation for a profile (design §26.2). + * + *

The codec in-memory limit is derived from the profile's decoded-byte budget rather than left + * at the framework default, so an oversized response is rejected by the same number the profile + * declares. + */ +public final class WebClientRuntimeFactory implements ClientRuntimeFactory { + + private final Map providers = + new EnumMap<>(TransportType.class); + private final TransportCapabilityValidator capabilityValidator = + new TransportCapabilityValidator(); + private final ResilienceRegistry resilienceRegistry; + private final ReactiveRequestCredentialProvider credentialProvider; + private final BlockingExecutionSupport support; + private final TransportLifecycleListener lifecycleListener; + private final RandomGenerator random; + + public WebClientRuntimeFactory( + Map providers, + ResilienceRegistry resilienceRegistry, + ReactiveRequestCredentialProvider credentialProvider, + BlockingExecutionSupport support, + TransportLifecycleListener lifecycleListener, + RandomGenerator random) { + Objects.requireNonNull(providers, "reactive transport providers").forEach(this.providers::put); + this.resilienceRegistry = Objects.requireNonNull(resilienceRegistry, "resilience registry"); + this.credentialProvider = Objects.requireNonNull(credentialProvider, "credential provider"); + this.support = Objects.requireNonNull(support, "execution support"); + this.lifecycleListener = Objects.requireNonNull(lifecycleListener, "lifecycle listener"); + this.random = Objects.requireNonNull(random, "random generator"); + } + + @Override + public ClientRuntime create(ClientProfile profile, RuntimeGeneration generation) { + Objects.requireNonNull(profile, "profile"); + ReactiveTransportProvider provider = providers.get(profile.transport()); + if (provider == null) { + throw new HttpConfigurationException( + "no reactive transport provider is registered for " + profile.transport(), + HttpFailureMetadata.startup(profile.name())); + } + capabilityValidator.validate(profile, provider.capabilities()); + + ClientHttpConnector connector = provider.create(profile, generation, lifecycleListener); + WebClient webClient = + WebClient.builder() + .clientConnector(connector) + .baseUrl(profile.baseUrl().toString()) + .codecs( + configurer -> + configurer + .defaultCodecs() + .maxInMemorySize( + Math.toIntExact( + Math.min(profile.response().maxDecodedBytes(), Integer.MAX_VALUE)))) + .build(); + + RetryBudget retryBudget = + profile + .retry() + .budget() + .map( + name -> + resilienceRegistry.retryBudget( + name, + Math.max(1L, profile.pool().maxTotalConnections() / 10L), + Duration.ofMinutes(1))) + .orElseGet(RetryBudget::unlimited); + + Supplier backoffFactory = + () -> + new ExponentialFullJitterBackoff( + profile.retry().baseBackoff(), + profile.retry().maxBackoff(), + profile.retry().jitter(), + profile.retry().retryAfter(), + random); + + return new ReactiveClientRuntime( + profile, + generation, + () -> provider.close(profile, generation), + webClient, + provider.id(), + provider.failureClassifier(), + resilienceRegistry.admission( + profile.name(), + profile.pool().maxPendingAcquires() + profile.pool().maxTotalConnections()), + retryBudget, + backoffFactory, + credentialProvider, + support); + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/apache/ApacheBlockingTransportProviderTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/apache/ApacheBlockingTransportProviderTest.java new file mode 100644 index 00000000..17677a14 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/apache/ApacheBlockingTransportProviderTest.java @@ -0,0 +1,76 @@ +package dev.caskeleton.adapter.outbound.httpclient.apache; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile; +import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles; +import dev.caskeleton.adapter.outbound.httpclient.testkit.MockHttpServer; +import dev.caskeleton.adapter.outbound.httpclient.testkit.NoopLifecycleListener; +import java.time.Duration; +import org.junit.jupiter.api.Test; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +class ApacheBlockingTransportProviderTest { + + @Test + void sendsRequestThroughConfiguredFactory() throws Exception { + try (MockHttpServer server = MockHttpServer.start()) { + server.enqueueJson(200, "{\"value\":1}"); + ClientProfile profile = ClientProfiles.apache(server.uri("/")); + ApacheBlockingTransportProvider provider = new ApacheBlockingTransportProvider(); + try { + ClientHttpRequestFactory factory = + provider.create( + profile, + new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1), + NoopLifecycleListener.INSTANCE); + RestClient client = RestClient.builder().requestFactory(factory).build(); + String body = client.get().uri(server.uri("/value")).retrieve().body(String.class); + + assertThat(body).contains("value"); + assertThat(server.takeRequest(Duration.ofSeconds(2)).path()).isEqualTo("/value"); + assertThat(provider.leasedConnections(profile.name())).isZero(); + } finally { + provider.close( + profile, new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1)); + } + } + } + + @Test + void neverFollowsRedirectsAtTheEngineLevel() throws Exception { + try (MockHttpServer server = MockHttpServer.start()) { + server.enqueueRedirect(302, "/moved"); + ClientProfile profile = ClientProfiles.apache(server.uri("/")); + ApacheBlockingTransportProvider provider = new ApacheBlockingTransportProvider(); + try { + RestClient client = + RestClient.builder() + .requestFactory( + provider.create( + profile, + new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1), + NoopLifecycleListener.INSTANCE)) + .build(); + int status = + client + .get() + .uri(server.uri("/start")) + .exchange((request, response) -> response.getStatusCode().value()); + assertThat(status).isEqualTo(302); + assertThat(server.requestCount()).isEqualTo(1); + } finally { + provider.close( + profile, new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1)); + } + } + } + + @Test + void declaresRouteScopedPoolAndDynamicTargetCapability() { + assertThat(new ApacheBlockingTransportProvider().capabilities().routeScopedPool()).isTrue(); + assertThat(new ApacheBlockingTransportProvider().capabilities().dynamicTargetStable()).isTrue(); + assertThat(new ApacheBlockingTransportProvider().id().value()).isEqualTo("apache"); + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/apache/ApachePoolSaturationTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/apache/ApachePoolSaturationTest.java new file mode 100644 index 00000000..e352e8b2 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/apache/ApachePoolSaturationTest.java @@ -0,0 +1,93 @@ +package dev.caskeleton.adapter.outbound.httpclient.apache; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.httpclient.api.operation.AttemptStage; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile; +import dev.caskeleton.adapter.outbound.httpclient.profile.PoolSettings; +import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles; +import dev.caskeleton.adapter.outbound.httpclient.testkit.MockHttpServer; +import dev.caskeleton.adapter.outbound.httpclient.testkit.NoopLifecycleListener; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailure; +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.springframework.web.client.RestClient; + +class ApachePoolSaturationTest { + + @Test + void poolAcquireTimeoutIsClassifiedAsNotSent() throws Exception { + ApacheBlockingTransportProvider provider = new ApacheBlockingTransportProvider(); + ExecutorService executor = Executors.newFixedThreadPool(2); + try (MockHttpServer server = MockHttpServer.start()) { + server.enqueueDelayedBody(200, "{\"slow\":true}", Duration.ofSeconds(2)); + server.enqueueJson(200, "{\"fast\":true}"); + + ClientProfile profile = singleConnectionProfile(server.uri("/")); + RestClient client = + RestClient.builder() + .requestFactory( + provider.create( + profile, + new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1), + NoopLifecycleListener.INSTANCE)) + .build(); + + CountDownLatch firstStarted = new CountDownLatch(1); + executor.execute( + () -> { + firstStarted.countDown(); + try { + client.get().uri(server.uri("/slow")).retrieve().body(String.class); + } catch (RuntimeException ignored) { + // The holding request is only needed to occupy the single pooled connection. + } + }); + assertThat(firstStarted.await(2, TimeUnit.SECONDS)).isTrue(); + Thread.sleep(200); + + Throwable captured = null; + try { + client.get().uri(server.uri("/fast")).retrieve().body(String.class); + } catch (RuntimeException saturated) { + captured = saturated; + } + + assertThat(captured).isNotNull(); + TransportFailure failure = + new ApacheFailureClassifier().classify(captured, AttemptStage.POOL_ACQUIRE); + assertThat(failure.stage()).isEqualTo(AttemptStage.POOL_ACQUIRE); + assertThat(failure.evidence()).isEqualTo(ExecutionEvidence.NOT_SENT); + + provider.close( + profile, new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1)); + } finally { + executor.shutdownNow(); + assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + } + } + + private static ClientProfile singleConnectionProfile(java.net.URI baseUrl) { + return ClientProfiles.builder("saturated") + .baseUrl(baseUrl) + .pool( + new PoolSettings( + 1, + 1, + 1, + Duration.ofMillis(100), + Duration.ofSeconds(30), + Duration.ofMinutes(5), + Duration.ofSeconds(5), + Duration.ofSeconds(15), + Duration.ofSeconds(5), + false, + false)) + .build(); + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/api/body/ObjectBodyReplayabilityTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/api/body/ObjectBodyReplayabilityTest.java new file mode 100644 index 00000000..370696c1 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/api/body/ObjectBodyReplayabilityTest.java @@ -0,0 +1,70 @@ +package dev.caskeleton.adapter.outbound.httpclient.api.body; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.httpclient.api.operation.BodyReplayability; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Replayability is a property of the value, not a promise in a javadoc. + * + *

Every {@code ObjectBody} used to report {@code REPLAYABLE}. A caller who reused a builder or + * kept a reference to a list therefore got a retry that re-encoded the value as it was at retry + * time — different bytes, same idempotency key, which is precisely what a replay must never + * be. + */ +class ObjectBodyReplayabilityTest { + + private record ImmutableOrder(String id, int quantity) {} + + private record OrderWithMutableLines(String id, List lines) {} + + @Test + @DisplayName("a record of immutable components replays") + void anImmutableRecordReplays() { + assertThat(ObjectBody.json(new ImmutableOrder("order-1", 2)).replayability()) + .isEqualTo(BodyReplayability.REPLAYABLE); + assertThat(ObjectBody.json("plain-string").replayability()) + .isEqualTo(BodyReplayability.REPLAYABLE); + assertThat(ObjectBody.json(List.of("a", "b")).replayability()) + .isEqualTo(BodyReplayability.REPLAYABLE); + assertThat(ObjectBody.json(Map.of("k", 1)).replayability()) + .isEqualTo(BodyReplayability.REPLAYABLE); + } + + @Test + @DisplayName("a value the caller can still mutate does not replay") + void aMutableValueIsOneShot() { + assertThat(ObjectBody.json(new ArrayList<>(List.of("a"))).replayability()) + .isEqualTo(BodyReplayability.ONE_SHOT); + assertThat(ObjectBody.json(new LinkedHashMap<>(Map.of("k", 1))).replayability()) + .isEqualTo(BodyReplayability.ONE_SHOT); + } + + /** A record is not a guarantee if one of its components is a list the caller still holds. */ + @Test + @DisplayName("a record wrapping a mutable component does not replay") + void aRecordWrappingMutableStateIsOneShot() { + assertThat( + ObjectBody.json(new OrderWithMutableLines("order-1", new ArrayList<>(List.of("line")))) + .replayability()) + .isEqualTo(BodyReplayability.ONE_SHOT); + assertThat( + ObjectBody.json(new OrderWithMutableLines("order-1", List.of("line"))).replayability()) + .isEqualTo(BodyReplayability.REPLAYABLE); + } + + /** + * An arbitrary bean cannot be certified, so it is one-shot rather than optimistically replayed. + */ + @Test + @DisplayName("an uninspectable value is one-shot") + void anArbitraryBeanIsOneShot() { + assertThat(ObjectBody.json(new Object()).replayability()).isEqualTo(BodyReplayability.ONE_SHOT); + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/architecture/PublicApiArchitectureTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/architecture/PublicApiArchitectureTest.java new file mode 100644 index 00000000..b6659eca --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/architecture/PublicApiArchitectureTest.java @@ -0,0 +1,157 @@ +package dev.caskeleton.adapter.outbound.httpclient.architecture; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.lang.syntax.ArchRuleDefinition; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The stable API must not leak an engine (design D-04, §9.5, §31). + * + *

These rules are what make "native engine access is internal" enforceable. Without them the + * boundary survives only as long as everyone remembers it. + */ +@Tag("httpclient-spring62-surface") +class PublicApiArchitectureTest { + + private static final JavaClasses PLATFORM = + new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .importPackages("dev.caskeleton.adapter.outbound.httpclient"); + + @Test + void publicApiDoesNotExposeNativeEnginesOrUnsafeBuilders() { + ArchRuleDefinition.noClasses() + .that() + .resideInAPackage("..outbound.httpclient.api..") + .should() + .dependOnClassesThat() + .resideInAnyPackage( + "org.apache.hc..", + "reactor.netty..", + "org.eclipse.jetty..", + "java.net.http..", + "io.github.resilience4j..", + "org.springframework..") + .as("the core api must stay free of engine and framework types (design §8)") + .check(PLATFORM); + } + + @Test + void noPublicMethodReturnsANativeEngineClient() { + ArchRuleDefinition.noMethods() + .that() + .arePublic() + .and() + .areDeclaredInClassesThat() + .resideOutsideOfPackages( + "..outbound.httpclient.apache..", + "..outbound.httpclient.jdk..", + "..outbound.httpclient.reactor..", + "..outbound.httpclient.http3..") + .should() + .haveRawReturnType( + com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAnyPackage( + "org.apache.hc..", "reactor.netty..", "org.eclipse.jetty..", "java.net.http..")) + .as("no application-facing method may hand out a native engine client (design §9.5)") + .check(PLATFORM); + } + + @Test + void theSpring7ServiceGroupApiIsUsedOnlyInItsOwnPackage() { + ArchRuleDefinition.noClasses() + .that() + .resideOutsideOfPackage("..outbound.httpclient.spring7..") + .should() + .dependOnClassesThat() + .resideInAPackage("org.springframework.web.service.registry..") + .as("Spring 7-only APIs stay in the optional module (design D-17)") + .check(PLATFORM); + } + + /** + * Spring's own clients are as dangerous as a native engine, and were not covered. + * + *

The rule above blocks Apache, Netty, Jetty and the JDK client. It said nothing about {@code + * RestClient} and {@code WebClient} — and those were exactly what the runtimes handed out + * publicly, and exactly what the typed registries used to reach the network with none of the + * platform's guarantees. A caller holding one of them bypasses target policy, credentials, + * admission, deadline, resilience, byte limits, error mapping and observation just as completely + * as one holding an Apache client. + */ + @Test + void noPublicMethodHandsOutASpringHttpClient() { + ArchRuleDefinition.noMethods() + .that() + .arePublic() + .and() + .areDeclaredInClassesThat() + .resideInAPackage("..outbound.httpclient..") + .and() + // The migration package exists to hand a RestClient to code being moved off RestTemplate; + // that is its stated purpose, and it is not a platform execution path. + .areDeclaredInClassesThat() + .resideOutsideOfPackage("..outbound.httpclient.migration..") + .should() + .haveRawReturnType( + com.tngtech.archunit.core.domain.JavaClass.Predicates.assignableTo( + org.springframework.web.client.RestClient.class) + .or( + com.tngtech.archunit.core.domain.JavaClass.Predicates.assignableTo( + org.springframework.web.reactive.function.client.WebClient.class))) + .as("a Spring client reaches the network with no platform policy applied (design §9.5)") + .check(PLATFORM); + } + + /** + * The engine clients stay inside the two packages that drive them. + * + *

Confining the type, not just the accessor, is what stops the next gateway from constructing + * its own client and calling it a shortcut. + */ + @Test + void springHttpClientsAreConfinedToTheirExecutionPackages() { + ArchRuleDefinition.noClasses() + .that() + .resideInAPackage("..outbound.httpclient..") + .and() + .resideOutsideOfPackages( + "..outbound.httpclient.restclient..", + "..outbound.httpclient.webclient..", + "..outbound.httpclient.migration..", + "..outbound.httpclient.spring7..") + .should() + .dependOnClassesThat() + .areAssignableTo(org.springframework.web.client.RestClient.class) + .as("RestClient is driven only by the blocking execution package") + .check(PLATFORM); + + ArchRuleDefinition.noClasses() + .that() + .resideInAPackage("..outbound.httpclient..") + .and() + .resideOutsideOfPackages( + "..outbound.httpclient.webclient..", "..outbound.httpclient.spring7..") + .should() + .dependOnClassesThat() + .areAssignableTo(org.springframework.web.reactive.function.client.WebClient.class) + .as("WebClient is driven only by the reactive execution package") + .check(PLATFORM); + } + + @Test + void theCoreApiCarriesNoMutableBuilderSurface() { + ArchRuleDefinition.noMethods() + .that() + .areDeclaredInClassesThat() + .resideInAPackage("..outbound.httpclient..") + .and() + .arePublic() + .should() + .haveNameMatching("mutableBuilder|nativeApacheClient|nativeJdkClient|nativeReactorClient") + .as("the explicitly forbidden signatures in design §9.5 must not exist") + .check(PLATFORM); + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/contract/AllStableTransportsContractTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/contract/AllStableTransportsContractTest.java new file mode 100644 index 00000000..eb356ac8 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/contract/AllStableTransportsContractTest.java @@ -0,0 +1,217 @@ +package dev.caskeleton.adapter.outbound.httpclient.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientApiType; +import dev.caskeleton.adapter.outbound.httpclient.profile.TransportType; +import dev.caskeleton.adapter.outbound.httpclient.testkit.BlockingTransportContract; +import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles; +import dev.caskeleton.adapter.outbound.httpclient.testkit.HttpClientContract; +import dev.caskeleton.adapter.outbound.httpclient.testkit.MockHttpServer; +import dev.caskeleton.adapter.outbound.httpclient.testkit.ReactiveTestGateways; +import dev.caskeleton.adapter.outbound.httpclient.testkit.ReactiveTransportContract; +import dev.caskeleton.adapter.outbound.httpclient.testkit.TestGateways; +import java.lang.reflect.Method; +import java.net.URI; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.condition.EnabledIf; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * One semantic contract, executed against every selected Stable transport (design §28.2, §33). + * + *

Transport selection is explicit and fail-closed in two directions, and both are load-bearing. + * + *

The first is that an empty or unknown selection is an error, so the suite cannot report + * success because it silently ran against nothing. + * + *

The second is why the blocking and reactive contracts are separate containers. They used to + * live side by side, with the blocking ones parameterized over a source that filtered the selection + * down to {@code apache} and {@code jdk}. Under the CI matrix's {@code reactor}-only row that + * filter produced an empty stream, and JUnit fails a {@code @ParameterizedTest} with no arguments + * as a configuration error — so the reactor row could never be green, and the failure said nothing + * about transports. Each container is now enabled only when the selection actually contains a + * transport it can certify, and each asserts afterwards that it ran the number of invocations that + * selection implies. A container that is disabled contributes nothing; a container that is enabled + * and ran fewer contracts than expected fails. + */ +@Tag("httpclient-contract") +class AllStableTransportsContractTest { + + private static final List BLOCKING_TRANSPORTS = List.of("apache", "jdk"); + private static final List REACTIVE_TRANSPORTS = List.of("reactor"); + + static Stream blockingTransports() { + return HttpClientContract.selectedTransports().stream().filter(BLOCKING_TRANSPORTS::contains); + } + + static Stream reactiveTransports() { + return HttpClientContract.selectedTransports().stream().filter(REACTIVE_TRANSPORTS::contains); + } + + static boolean anyBlockingTransportSelected() { + return blockingTransports().findAny().isPresent(); + } + + static boolean anyReactiveTransportSelected() { + return reactiveTransports().findAny().isPresent(); + } + + @Test + void theSelectedTransportSetIsExplicit() { + assertThat(HttpClientContract.selectedTransports()).isNotEmpty(); + } + + /** + * No selected transport may fall between the two containers. + * + *

A Stable transport added to the support matrix but to neither container would otherwise be + * certified by nothing while the suite stayed green. + */ + @Test + void everySelectedTransportIsClaimedByExactlyOneContainer() { + assertThat(Stream.concat(blockingTransports(), reactiveTransports()).toList()) + .as("every selected transport must belong to the blocking or the reactive container") + .containsExactlyInAnyOrderElementsOf(HttpClientContract.selectedTransports()); + } + + private static TestGateways.Harness blockingHarness(String transport, URI baseUrl) { + TransportType type = "jdk".equals(transport) ? TransportType.JDK : TransportType.APACHE; + return TestGateways.forProfile( + ClientProfiles.builder("users") + .baseUrl(baseUrl) + .transport(type) + .api(ClientApiType.REST_CLIENT) + .build()); + } + + /** Counts the {@code @ParameterizedTest} methods a container declares. */ + private static long declaredContracts(Class container) { + return Stream.of(container.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(ParameterizedTest.class)) + .map(Method::getName) + .distinct() + .count(); + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + @EnabledIf( + "dev.caskeleton.adapter.outbound.httpclient.contract.AllStableTransportsContractTest" + + "#anyBlockingTransportSelected") + class BlockingContract { + + private final AtomicInteger executed = new AtomicInteger(); + + @AfterAll + void everySelectedBlockingTransportRanEveryContract() { + long expected = declaredContracts(BlockingContract.class) * blockingTransports().count(); + assertThat(executed.get()) + .as( + "each selected blocking transport must run each declared contract; a container that " + + "is enabled but under-runs is an empty success") + .isEqualTo((int) expected); + } + + @ParameterizedTest + @MethodSource( + "dev.caskeleton.adapter.outbound.httpclient.contract.AllStableTransportsContractTest" + + "#blockingTransports") + void methodAndTemplateEncodingIsIdentical(String transport) throws Exception { + try (MockHttpServer server = MockHttpServer.start(); + TestGateways.Harness harness = blockingHarness(transport, server.uri("/"))) { + BlockingTransportContract.methodAndTemplateEncoding(harness, server); + } + executed.incrementAndGet(); + } + + @ParameterizedTest + @MethodSource( + "dev.caskeleton.adapter.outbound.httpclient.contract.AllStableTransportsContractTest" + + "#blockingTransports") + void notSentConnectFailureHasSameStableMetadata(String transport) { + BlockingTransportContract.connectFailureIsProvenNotSent( + baseUrl -> blockingHarness(transport, baseUrl)); + executed.incrementAndGet(); + } + + @ParameterizedTest + @MethodSource( + "dev.caskeleton.adapter.outbound.httpclient.contract.AllStableTransportsContractTest" + + "#blockingTransports") + void absoluteUriIsRejectedOnEveryTransport(String transport) throws Exception { + try (MockHttpServer server = MockHttpServer.start(); + TestGateways.Harness harness = blockingHarness(transport, server.uri("/"))) { + BlockingTransportContract.absoluteUriIsRejectedBeforeAnyRequest(harness, server); + } + executed.incrementAndGet(); + } + + @ParameterizedTest + @MethodSource( + "dev.caskeleton.adapter.outbound.httpclient.contract.AllStableTransportsContractTest" + + "#blockingTransports") + void errorStatusMapsToTheSameStableException(String transport) throws Exception { + try (MockHttpServer server = MockHttpServer.start(); + TestGateways.Harness harness = blockingHarness(transport, server.uri("/"))) { + BlockingTransportContract.errorStatusBecomesAStableException(harness, server); + } + executed.incrementAndGet(); + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + @EnabledIf( + "dev.caskeleton.adapter.outbound.httpclient.contract.AllStableTransportsContractTest" + + "#anyReactiveTransportSelected") + class ReactiveContract { + + private final AtomicInteger executed = new AtomicInteger(); + + @AfterAll + void everySelectedReactiveTransportRanEveryContract() { + long expected = declaredContracts(ReactiveContract.class) * reactiveTransports().count(); + assertThat(executed.get()) + .as("each selected reactive transport must run each declared contract") + .isEqualTo((int) expected); + } + + @ParameterizedTest + @MethodSource( + "dev.caskeleton.adapter.outbound.httpclient.contract.AllStableTransportsContractTest" + + "#reactiveTransports") + void typedResultAndTemplateEncodingIsIdentical(String transport) throws Exception { + try (MockHttpServer server = MockHttpServer.start(); + ReactiveTestGateways.Harness harness = reactiveHarness(transport, server.uri("/"))) { + ReactiveTransportContract.typedResultAndTemplateEncoding(harness, server); + } + executed.incrementAndGet(); + } + + @ParameterizedTest + @MethodSource( + "dev.caskeleton.adapter.outbound.httpclient.contract.AllStableTransportsContractTest" + + "#reactiveTransports") + void notSentConnectFailureHasSameStableMetadata(String transport) { + ReactiveTransportContract.connectFailureIsProvenNotSent( + baseUrl -> reactiveHarness(transport, baseUrl)); + executed.incrementAndGet(); + } + + private ReactiveTestGateways.Harness reactiveHarness(String transport, URI baseUrl) { + if (!"reactor".equals(transport)) { + throw new IllegalStateException("unsupported reactive contract transport: " + transport); + } + return ReactiveTestGateways.reactor(baseUrl); + } + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/contract/FailureInjectionContractTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/contract/FailureInjectionContractTest.java new file mode 100644 index 00000000..bd864272 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/contract/FailureInjectionContractTest.java @@ -0,0 +1,86 @@ +package dev.caskeleton.adapter.outbound.httpclient.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.httpclient.api.OperationName; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpClientException; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation; +import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType; +import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles; +import dev.caskeleton.adapter.outbound.httpclient.testkit.TestGateways; +import dev.caskeleton.adapter.outbound.httpclient.testkit.ToxiproxyFixture; +import dev.caskeleton.adapter.outbound.httpclient.testkit.UserResponse; +import eu.rekawek.toxiproxy.Proxy; +import java.net.URI; +import java.time.Duration; +import java.util.Map; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.utility.DockerImageName; + +/** + * TCP-level faults against a real upstream (design §28.3). + * + *

The lane is fail-closed: selecting it without Docker throws rather than skipping, because a + * fault suite that never injected a fault proves nothing. + */ +@Tag("httpclient-fault") +class FailureInjectionContractTest { + + private static final DockerImageName UPSTREAM_IMAGE = + // Overridable so a deployment can pin a digest. Hard-coding `:latest` meant the fault suite's + // upstream could change between two runs of the same commit, so a red build might be + // someone else's image push rather than this repository's regression — the one thing a + // fault-injection lane must never be ambiguous about. The default stays `:latest` because no + // digest can be verified from here; `-Dhttpclient.fault.httpbin.image=@sha256:` + // makes the run reproducible, and docs/httpclient/operations.md records that CI should set + // it. + DockerImageName.parse( + System.getProperty("httpclient.fault.httpbin.image", "kennethreitz/httpbin:latest")) + .asCompatibleSubstituteFor("kennethreitz/httpbin"); + + @Test + @SuppressWarnings("resource") + void aResetPeerProducesStableEvidenceRatherThanAGenericFailure() throws Exception { + ToxiproxyFixture.requireAvailable(); + try (Network network = Network.newNetwork(); + GenericContainer upstream = + new GenericContainer<>(UPSTREAM_IMAGE) + .withNetwork(network) + .withNetworkAliases("upstream") + .withExposedPorts(80)) { + upstream.start(); + try (ToxiproxyFixture toxiproxy = ToxiproxyFixture.start(network)) { + Proxy proxy = toxiproxy.proxyTo("upstream", "upstream", 80); + toxiproxy.resetPeer(proxy, Duration.ofMillis(50)); + + URI baseUrl = + URI.create("http://" + toxiproxy.proxiedHost() + ":" + toxiproxy.proxiedPort() + "/"); + try (TestGateways.Harness harness = + TestGateways.forProfile(ClientProfiles.builder("faulty").baseUrl(baseUrl).build())) { + HttpClientException failure = + org.assertj.core.api.Assertions.catchThrowableOfType( + HttpClientException.class, + () -> + harness + .gateway() + .exchange( + harness.profile().name(), + HttpOperation.get(new OperationName("get-user"), "/get", Map.of()), + ResponseType.of(UserResponse.class))); + + assertThat(failure).isNotNull(); + assertThat(failure.metadata().evidence()) + .isIn( + ExecutionEvidence.NOT_SENT, + ExecutionEvidence.SENT_NO_RESPONSE, + ExecutionEvidence.PARTIAL_RESPONSE); + assertThat(failure.getMessage()).doesNotContain("http://"); + } + } + } + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/contract/NegotiatedProtocolContractTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/contract/NegotiatedProtocolContractTest.java new file mode 100644 index 00000000..bf64d702 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/contract/NegotiatedProtocolContractTest.java @@ -0,0 +1,175 @@ +package dev.caskeleton.adapter.outbound.httpclient.contract; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.httpclient.apache.ApacheBlockingTransportProvider; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException; +import dev.caskeleton.adapter.outbound.httpclient.jdk.JdkClientFactory; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientApiType; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile; +import dev.caskeleton.adapter.outbound.httpclient.profile.HttpProtocol; +import dev.caskeleton.adapter.outbound.httpclient.profile.TransportType; +import dev.caskeleton.adapter.outbound.httpclient.reactor.ReactorConnectionProviderFactory; +import dev.caskeleton.adapter.outbound.httpclient.reactor.ReactorHttpClientFactory; +import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles; +import dev.caskeleton.adapter.outbound.httpclient.testkit.MockHttpServer; +import dev.caskeleton.adapter.outbound.httpclient.testkit.TlsFixture; +import dev.caskeleton.adapter.outbound.httpclient.testkit.TlsMaterials; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportCapabilityValidator; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +/** + * What each transport actually negotiates on the wire (design §6.2, §24.1). + * + *

The protocol is read from the client after a real TLS handshake, not from configuration. A + * fixture server's recorded request line is not usable for this: MockWebServer renders an HTTP/2 + * stream with an HTTP/1.1-style request line, so asserting on it would report every connection as + * HTTP/1.1 regardless of the truth. + * + *

These tests exist because the support matrix is a claim about this platform, and a claim that + * is not measured is a claim that drifts. + */ +@Tag("httpclient-contract") +class NegotiatedProtocolContractTest { + + @Test + void theJdkTransportNegotiatesHttp2WhenTheProfileAsksForIt() throws Exception { + TlsFixture fixture = TlsFixture.trusted(); + try (MockHttpServer server = MockHttpServer.startTlsWithHttp2(fixture.serverSocketFactory())) { + server.enqueueJson(200, "{}"); + ClientProfile profile = + ClientProfiles.builder("h2") + .baseUrl(server.uri("/")) + .transport(TransportType.JDK) + .protocols(Set.of(HttpProtocol.HTTP_2, HttpProtocol.HTTP_1_1)) + .build(); + + HttpClient client = + new JdkClientFactory().create(profile, Optional.of(TlsMaterials.trustOnly(fixture))); + try { + HttpResponse response = + client.send( + HttpRequest.newBuilder(server.uri("/p")).GET().build(), + HttpResponse.BodyHandlers.ofString()); + assertThat(response.version()).isEqualTo(HttpClient.Version.HTTP_2); + } finally { + client.close(); + } + } + } + + @Test + void theJdkTransportStaysOnHttp11WhenTheProfileSaysSo() throws Exception { + TlsFixture fixture = TlsFixture.trusted(); + try (MockHttpServer server = MockHttpServer.startTlsWithHttp2(fixture.serverSocketFactory())) { + server.enqueueJson(200, "{}"); + ClientProfile profile = + ClientProfiles.builder("h1") + .baseUrl(server.uri("/")) + .transport(TransportType.JDK) + .protocols(Set.of(HttpProtocol.HTTP_1_1)) + .build(); + + HttpClient client = + new JdkClientFactory().create(profile, Optional.of(TlsMaterials.trustOnly(fixture))); + try { + HttpResponse response = + client.send( + HttpRequest.newBuilder(server.uri("/p")).GET().build(), + HttpResponse.BodyHandlers.ofString()); + // The server offers HTTP/2; the profile is what keeps the connection on HTTP/1.1. + assertThat(response.version()).isEqualTo(HttpClient.Version.HTTP_1_1); + } finally { + client.close(); + } + } + } + + @Test + void theReactorTransportNegotiatesHttp2() throws Exception { + TlsFixture fixture = TlsFixture.trusted(); + try (MockHttpServer server = MockHttpServer.startTlsWithHttp2(fixture.serverSocketFactory())) { + server.enqueueJson(200, "{}"); + ClientProfile profile = + ClientProfiles.builder("h2") + .baseUrl(server.uri("/")) + .transport(TransportType.REACTOR_NETTY) + .api(ClientApiType.WEB_CLIENT) + .protocols(Set.of(HttpProtocol.HTTP_2, HttpProtocol.HTTP_1_1)) + .build(); + + var pool = new ReactorConnectionProviderFactory().create(profile); + try { + String version = + new ReactorHttpClientFactory() + .create( + profile, pool, Optional.of(TlsMaterials.trustOnly(fixture)), Optional.empty()) + .get() + .uri(server.uri("/p").toString()) + .response((response, bytes) -> Mono.just(response.version().text())) + .blockLast(Duration.ofSeconds(10)); + assertThat(version).isEqualTo("HTTP/2.0"); + } finally { + pool.disposeLater().block(Duration.ofSeconds(5)); + } + } + } + + @Test + void anApacheProfileCannotClaimHttp2() { + ClientProfile profile = + ClientProfiles.builder("h2-on-apache") + .transport(TransportType.APACHE) + .protocols(Set.of(HttpProtocol.HTTP_2, HttpProtocol.HTTP_1_1)) + .build(); + + // Apache implements HTTP/2 in its async client; the classic client Spring drives cannot. The + // capability says so, so the profile fails at startup instead of silently running HTTP/1.1 + // while the support matrix claims otherwise. + assertThatThrownBy( + () -> + new TransportCapabilityValidator() + .validate(profile, new ApacheBlockingTransportProvider().capabilities())) + .isInstanceOf(HttpConfigurationException.class) + .hasMessageContaining("HTTP_2"); + } + + @Test + void theApacheClassicClientCannotSpeakPriorKnowledgeH2c() throws Exception { + try (MockHttpServer server = MockHttpServer.startHttp2PriorKnowledge()) { + server.enqueueJson(200, "{}"); + ClientProfile profile = ClientProfiles.builder("h2c").baseUrl(server.uri("/")).build(); + ApacheBlockingTransportProvider provider = new ApacheBlockingTransportProvider(); + try { + assertThatThrownBy( + () -> + org.springframework.web.client.RestClient.builder() + .requestFactory( + provider.create( + profile, + new dev.caskeleton.adapter.outbound.httpclient.profile + .RuntimeGeneration(1), + dev.caskeleton.adapter.outbound.httpclient.testkit + .NoopLifecycleListener.INSTANCE)) + .build() + .get() + .uri(server.uri("/p")) + .retrieve() + .body(String.class)) + .isInstanceOf(RuntimeException.class); + } finally { + provider.close( + profile, new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1)); + } + } + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/dynamic/DynamicTargetSecurityTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/dynamic/DynamicTargetSecurityTest.java new file mode 100644 index 00000000..e795be15 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/dynamic/DynamicTargetSecurityTest.java @@ -0,0 +1,253 @@ +package dev.caskeleton.adapter.outbound.httpclient.dynamic; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpTargetRejectedException; +import dev.caskeleton.adapter.outbound.httpclient.testkit.DynamicTargets; +import java.net.InetAddress; +import java.net.URI; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class DynamicTargetSecurityTest { + + @ParameterizedTest + @ValueSource( + strings = { + "http://127.0.0.1/a", + "https://[::1]/a", + "https://169.254.169.254/latest/meta-data", + "file:///etc/passwd", + "https://user:pass@example.com/a" + }) + void rejectsForbiddenTargets(String raw) { + DynamicTargetPolicy policy = DynamicTargets.publicHttpsOnly(); + assertThatThrownBy( + () -> { + CanonicalTarget canonical = DynamicTargets.prepare(policy, URI.create(raw)); + DynamicTargets.resolvesTo(canonical.host(), canonical.host()).pin(canonical); + }) + .isInstanceOf(HttpTargetRejectedException.class); + } + + @Test + void rejectsDnsAnswerWhenAnyAddressIsPrivate() { + ValidatedDnsResolver resolver = + DynamicTargets.resolvesTo("mixed.test", "93.184.216.34", "10.0.0.4"); + assertThatThrownBy(() -> resolver.resolve("mixed.test")) + .isInstanceOf(HttpTargetRejectedException.class); + assertThat(resolver.approvedAddresses("mixed.test")).isEmpty(); + } + + @Test + void acceptsAFullyPublicAnswerAndRetainsItForPinning() { + ValidatedDnsResolver resolver = + DynamicTargets.resolvesTo("public.test", "93.184.216.34", "8.8.8.8"); + assertThat(resolver.resolve("public.test")).hasSize(2); + assertThat(resolver.approvedAddresses("public.test")).hasSize(2); + } + + @Test + void normalizesIpv4MappedIpv6BeforeClassifying() throws Exception { + IpAddressClassifier classifier = new IpAddressClassifier(); + InetAddress mapped = InetAddress.getByName("::ffff:127.0.0.1"); + assertThat(classifier.forbidden(mapped)).isTrue(); + assertThat(IpAddressClassifier.normalize(mapped).getHostAddress()).isEqualTo("127.0.0.1"); + } + + @Test + void blocksUniqueLocalCarrierGradeNatAndOrganisationRanges() throws Exception { + IpAddressClassifier classifier = new IpAddressClassifier(List.of("8.8.8.0/24")); + assertThat(classifier.forbidden(InetAddress.getByName("fd00::1"))).isTrue(); + assertThat(classifier.forbidden(InetAddress.getByName("100.64.0.1"))).isTrue(); + assertThat(classifier.forbidden(InetAddress.getByName("8.8.8.8"))).isTrue(); + assertThat(classifier.forbidden(InetAddress.getByName("93.184.216.34"))).isFalse(); + } + + /** + * Special-purpose ranges a denylist has to remember, and kept forgetting. + * + *

Each of these was reachable before the classifier required global unicast: they are not + * loopback, link-local, site-local or multicast, so every JDK predicate the old check used + * answered "fine". + */ + @Test + void blocksTheSpecialPurposeRangesADenylistOmitted() throws Exception { + IpAddressClassifier classifier = new IpAddressClassifier(); + + assertThat(classifier.forbidden(InetAddress.getByName("192.0.2.1"))).isTrue(); + assertThat(classifier.forbidden(InetAddress.getByName("198.51.100.1"))).isTrue(); + assertThat(classifier.forbidden(InetAddress.getByName("203.0.113.1"))).isTrue(); + assertThat(classifier.forbidden(InetAddress.getByName("198.18.0.1"))).isTrue(); + assertThat(classifier.forbidden(InetAddress.getByName("240.0.0.1"))).isTrue(); + assertThat(classifier.forbidden(InetAddress.getByName("192.0.0.1"))).isTrue(); + assertThat(classifier.forbidden(InetAddress.getByName("0.1.2.3"))).isTrue(); + assertThat(classifier.forbidden(InetAddress.getByName("2001:db8::1"))).isTrue(); + // A genuinely routable address still passes, or the allowlist would be useless. + assertThat(classifier.forbidden(InetAddress.getByName("93.184.216.34"))).isFalse(); + assertThat(classifier.forbidden(InetAddress.getByName("2606:2800:220:1::1"))).isFalse(); + } + + /** + * An operator's exclusion list is a security control, so a malformed entry fails loudly. + * + *

All three used to be accepted: {@code /33} produced a range that matched by accident, {@code + * -1} matched everything, and a hostname performed a DNS lookup at startup and froze the block to + * whatever it resolved to then. + */ + @Test + void refusesMalformedBlockedCidrs() { + assertThatThrownBy(() -> new IpAddressClassifier(List.of("10.0.0.0/33"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("prefix must be"); + assertThatThrownBy(() -> new IpAddressClassifier(List.of("10.0.0.0/-1"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("prefix must be"); + assertThatThrownBy(() -> new IpAddressClassifier(List.of("internal.example.com/24"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not a hostname"); + assertThatThrownBy(() -> new IpAddressClassifier(List.of("10.0.0.0"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("expected

/"); + } + + @Test + void dnsRebindingCannotReuseAPreviouslyApprovedHost() { + ValidatedDnsResolver resolver = + DynamicTargets.resolvesTo(java.util.Map.of("rebind.test", List.of("10.0.0.9"))); + assertThatThrownBy(() -> resolver.resolve("rebind.test")) + .isInstanceOf(HttpTargetRejectedException.class); + assertThat(resolver.approvedAddresses("rebind.test")).isEmpty(); + } + + @Test + void aDynamicPolicyNeverCarriesAnAllowAllDefault() { + DynamicTargetPolicy policy = DynamicTargets.publicHttpsOnly(); + assertThat(policy.allowedSchemes()).containsExactly("https"); + assertThat(policy.allowedPorts()).containsExactly(443); + assertThat(policy.tracePropagation()).isFalse(); + assertThat(policy.maxRedirectHops()).isZero(); + } + + @Test + void credentialBindingAppliesOnlyToItsExactCanonicalHost() { + DynamicCredentialBinding binding = + DynamicCredentialBinding.httpsOn("partner.example.com", "X-Api-Key", "secret://partner"); + CanonicalTarget matching = + DynamicTargets.prepare( + new DynamicTargetPolicy( + new DynamicTargetPolicyName("webhook"), + Set.of("https"), + Set.of(443), + Set.of(), + Set.of(), + 0, + false, + List.of()), + URI.create("https://partner.example.com/hook")); + CanonicalTarget other = + DynamicTargets.prepare( + DynamicTargets.publicHttpsOnly(), URI.create("https://evil.test/hook")); + assertThat(binding.matches(matching)).isTrue(); + assertThat(binding.matches(other)).isFalse(); + } + + /** + * The binding is to an origin, not a hostname. + * + *

Matching on host alone sent the credential to {@code http://partner.example.com} — + * plaintext, to anyone on the path — and to any other port the same host served. + */ + @Test + void credentialBindingDoesNotFollowTheHostAcrossSchemeOrPort() { + DynamicCredentialBinding binding = + DynamicCredentialBinding.httpsOn("partner.example.com", "X-Api-Key", "secret://partner"); + + assertThat( + binding.matches( + new CanonicalTarget("http", "partner.example.com", 80, "/hook", Optional.empty()))) + .as("a plaintext downgrade must not receive the credential") + .isFalse(); + assertThat( + binding.matches( + new CanonicalTarget( + "https", "partner.example.com", 8443, "/hook", Optional.empty()))) + .as("a different port is a different service") + .isFalse(); + } + + /** The header name decides who reads the secret, so it is an allowlist rather than free text. */ + @Test + void aCredentialBindingHeaderMustBeOnTheAllowlist() { + assertThatThrownBy( + () -> + DynamicCredentialBinding.httpsOn( + "partner.example.com", "X-Forwarded-Host", "secret://partner")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not on the allowlist"); + } + + /** + * A suffix must match at a label boundary. + * + *

{@code endsWith} let {@code evil-example.com} satisfy an allowlist of {@code example.com} — + * a domain an attacker registers for exactly this reason. + */ + @Test + void aHostSuffixAllowlistMatchesOnlyWholeLabels() { + DynamicTargetPolicy policy = + new DynamicTargetPolicy( + new DynamicTargetPolicyName("webhook"), + Set.of("https"), + Set.of(443), + Set.of("example.com"), + Set.of(), + 0, + false, + List.of()); + + assertThat(policy.hostAllowed("api.example.com")).isTrue(); + assertThat(policy.hostAllowed("example.com")).isTrue(); + assertThat(policy.hostAllowed("evil-example.com")).isFalse(); + assertThat(policy.hostAllowed("exampleXcom")).isFalse(); + } + + /** + * The pin is what makes the address validation binding. + * + *

Without it the transport resolved the hostname a second time, so a DNS server that answered + * a public address to the validator and a link-local one to the socket won. The pin is scoped to + * one hop and removed afterwards, so nothing inherits an earlier hop's approval. + */ + @Test + void theApprovedAddressesAreVisibleOnlyInsideTheirOwnHop() throws Exception { + InetAddress approved = InetAddress.getByName("93.184.216.34"); + + assertThat(CallScopedDnsPin.active()).isFalse(); + assertThat(CallScopedDnsPin.addressesFor("public.test")).isEmpty(); + + try (CallScopedDnsPin pin = CallScopedDnsPin.open("public.test", List.of(approved))) { + assertThat(pin).isNotNull(); + assertThat(CallScopedDnsPin.active()).isTrue(); + assertThat(CallScopedDnsPin.addressesFor("public.test")).containsExactly(approved); + assertThat(CallScopedDnsPin.addressesFor("PUBLIC.TEST")).containsExactly(approved); + // A host this hop did not validate gets nothing, so the transport must refuse rather than + // fall back to an unvalidated system lookup. + assertThat(CallScopedDnsPin.addressesFor("other.test")).isEmpty(); + } + + assertThat(CallScopedDnsPin.active()).isFalse(); + assertThat(CallScopedDnsPin.addressesFor("public.test")).isEmpty(); + } + + @Test + void aPinNeedsAtLeastOneApprovedAddress() { + assertThatThrownBy(() -> CallScopedDnsPin.open("public.test", List.of())) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/http3/Http3OptInTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/http3/Http3OptInTest.java new file mode 100644 index 00000000..851935f3 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/http3/Http3OptInTest.java @@ -0,0 +1,139 @@ +package dev.caskeleton.adapter.outbound.httpclient.http3; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientMode; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile; +import dev.caskeleton.adapter.outbound.httpclient.profile.HttpProtocol; +import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles; +import dev.caskeleton.adapter.outbound.httpclient.testkit.NoopLifecycleListener; +import java.util.Set; +import org.eclipse.jetty.http3.client.transport.HttpClientTransportOverHTTP3; +import org.junit.jupiter.api.Test; + +class Http3OptInTest { + + @Test + void rejectsHttp3WithoutExplicitAcknowledgement() { + ClientProfile profile = ClientProfiles.http3WithoutAcknowledgement(); + assertThatThrownBy( + () -> + new JettyHttp3TransportProvider() + .create( + profile, + new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1), + NoopLifecycleListener.INSTANCE)) + .isInstanceOf(HttpConfigurationException.class) + .hasMessageContaining("experimental acknowledgement"); + } + + @Test + void rejectsAnIncorrectAcknowledgementString() { + ClientProfile profile = + ClientProfiles.builder("edge") + .protocols(Set.of(HttpProtocol.HTTP_3)) + .acknowledgement("yes-please") + .build(); + assertThatThrownBy( + () -> + new JettyHttp3TransportProvider() + .create( + profile, + new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1), + NoopLifecycleListener.INSTANCE)) + .isInstanceOf(HttpConfigurationException.class) + .hasMessageContaining("experimental acknowledgement"); + } + + @Test + void refusesDynamicTargets() { + ClientProfile profile = + ClientProfiles.builder("edge") + .mode(ClientMode.DYNAMIC) + .protocols(Set.of(HttpProtocol.HTTP_3)) + .acknowledgement(Http3ExperimentalAcknowledgement.REQUIRED) + .build(); + assertThatThrownBy( + () -> + new JettyHttp3TransportProvider() + .create( + profile, + new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1), + NoopLifecycleListener.INSTANCE)) + .isInstanceOf(HttpConfigurationException.class) + .hasMessageContaining("dynamic targets"); + } + + @Test + void capabilityReportDeclaresWhatItCannotProve() { + Http3CapabilityReport report = Http3CapabilityReport.detect(); + assertThat(report.dynamicTargetSupported()).isFalse(); + assertThat(report.unsupportedContracts()).contains("dynamic-target-pinning"); + } + + /** + * The probe must name the classes the provider constructs. + * + *

It used to look for {@code QuicClientConnectorConfigurator}, which this Jetty version does + * not ship, so it reported "no QUIC" on a classpath carrying the whole QUIC stack. A probe that + * cannot see the thing it is probing for is worse than no probe: it reads as a considered + * negative. + */ + @Test + void theCapabilityProbeSeesTheQuicStackThisBuildActuallyCarries() { + assertThat(Http3CapabilityReport.detect().quicNativeSupportPresent()) + .as( + "the Jetty QUIC and HTTP/3 client artifacts are on this module's compile and runtime " + + "classpath, so the probe must find them") + .isTrue(); + } + + /** + * Class presence is not interoperability. + * + *

Nothing in this build stands up an HTTP/3 server, so no negotiated {@code h3} exchange has + * been observed and the transport cannot be advertised beyond Experimental. + */ + @Test + void http3IsNeverPromotedWithoutWireProof() { + Http3CapabilityReport report = Http3CapabilityReport.detect(); + + assertThat(report.wireVerified()).isFalse(); + assertThat(report.promotableToBeta()).isFalse(); + assertThat(report.unsupportedContracts()).contains("negotiated-protocol-wire-proof"); + } + + /** + * The transport is QUIC-backed or it does not exist. + * + *

The provider used to build {@code HttpClientTransportOverHTTP}: TCP, HTTP/1.1, reported as + * HTTP/3. Every other guard in this class passed while it did so, which is why this assertion is + * about the constructed object rather than about configuration. + */ + @Test + void theProviderBuildsAQuicBackedTransport() { + ClientProfile profile = + ClientProfiles.builder("edge") + .protocols(Set.of(HttpProtocol.HTTP_3)) + .acknowledgement(Http3ExperimentalAcknowledgement.REQUIRED) + .build(); + + assertThat(JettyHttp3TransportProvider.newHttp3Transport(profile)) + .as("an experimental HTTP/3 profile must not be served over TCP HTTP/1.1") + .isInstanceOf(HttpClientTransportOverHTTP3.class); + assertThat(JettyHttp3TransportProvider.newHttp3Client(profile)).isNotNull(); + assertThat(new JettyHttp3TransportProvider().configuredVersion()) + .isEqualTo(org.eclipse.jetty.http.HttpVersion.HTTP_3); + } + + @Test + void theAcknowledgementValueIsExact() { + assertThatThrownBy(() -> new Http3ExperimentalAcknowledgement("i_accept")) + .isInstanceOf(IllegalArgumentException.class); + assertThat( + new Http3ExperimentalAcknowledgement(Http3ExperimentalAcknowledgement.REQUIRED).value()) + .isEqualTo("I_ACCEPT_HTTP3_EXPERIMENTAL_SEMANTICS"); + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/jdk/JdkBlockingTransportProviderTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/jdk/JdkBlockingTransportProviderTest.java new file mode 100644 index 00000000..0a84d157 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/jdk/JdkBlockingTransportProviderTest.java @@ -0,0 +1,51 @@ +package dev.caskeleton.adapter.outbound.httpclient.jdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile; +import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles; +import dev.caskeleton.adapter.outbound.httpclient.testkit.MockHttpServer; +import dev.caskeleton.adapter.outbound.httpclient.testkit.NoopLifecycleListener; +import org.junit.jupiter.api.Test; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +class JdkBlockingTransportProviderTest { + + @Test + void performsHttp2CapableBlockingRequest() throws Exception { + try (MockHttpServer server = MockHttpServer.start()) { + server.enqueueJson(200, "{\"ok\":true}"); + ClientProfile profile = ClientProfiles.jdk(server.uri("/")); + JdkBlockingTransportProvider provider = new JdkBlockingTransportProvider(); + try { + ClientHttpRequestFactory factory = + provider.create( + profile, + new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1), + NoopLifecycleListener.INSTANCE); + String body = + RestClient.builder() + .requestFactory(factory) + .build() + .get() + .uri(server.uri("/ok")) + .retrieve() + .body(String.class); + assertThat(body).contains("ok"); + } finally { + provider.close( + profile, new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1)); + } + } + } + + @Test + void declaresItsWeakerCapabilitiesHonestly() { + JdkBlockingTransportProvider provider = new JdkBlockingTransportProvider(); + assertThat(provider.capabilities().routeScopedPool()).isFalse(); + assertThat(provider.capabilities().boundedPendingAcquireQueue()).isFalse(); + assertThat(provider.capabilities().dynamicTargetStable()).isFalse(); + assertThat(provider.id().value()).isEqualTo("jdk"); + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/observation/DeclaredMetricsAreEmittedTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/observation/DeclaredMetricsAreEmittedTest.java new file mode 100644 index 00000000..8663d7ee --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/observation/DeclaredMetricsAreEmittedTest.java @@ -0,0 +1,131 @@ +package dev.caskeleton.adapter.outbound.httpclient.observation; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Every metric this platform names must be a metric it produces. + * + *

The vocabulary listed twenty-two names; twelve had a producer. The other ten were documented + * in the support matrix, discoverable by anyone building a dashboard, and flat zero forever — so a + * saturated bulkhead, an open breaker and a rate-limited client all charted as a healthy system, + * and the metrics that existed to explain an incident were the ones guaranteed to say nothing + * during it. + * + *

A name with no producer is worse than a missing name: the missing one prompts a question, and + * the empty one answers it wrongly. This test keeps the two sets equal by construction — either a + * constant has an emitter somewhere in the platform, or it is listed below as knowingly not yet + * emitted, with the reason. + */ +class DeclaredMetricsAreEmittedTest { + + /** + * Names the platform declares but does not yet emit, each with the reason. + * + *

Every entry here needs transport-level instrumentation — Netty channel handlers or Apache + * connection callbacks that expose per-stage timings — which is a different piece of work from + * naming the metric. They are listed rather than quietly tolerated so the gap is visible in the + * one place a reader is already looking. + */ + private static final Set NOT_YET_EMITTED = + Set.of( + // Requires per-request stage timings from each engine. + "POOL_ACQUIRE_DURATION", + "DNS_DURATION", + "CONNECT_DURATION", + "TLS_DURATION", + // Requires an in-flight gauge bound to the logical-call observation's lifecycle. + "ACTIVE", + // Requires the token loader to hold a MeterRegistry. + "OAUTH_REFRESH", + // Span names, consumed by a tracer rather than counted here. + "LOGICAL_CALL_SPAN", + "ATTEMPT_SPAN"); + + @Test + @DisplayName("every declared metric name is either emitted or listed as not yet emitted") + void everyDeclaredMetricHasAProducerOrAnAdmission() throws Exception { + String platformSources = readPlatformSources(); + List undeclaredGaps = new ArrayList<>(); + + for (Field constant : HttpClientObservationNames.class.getDeclaredFields()) { + if (!Modifier.isStatic(constant.getModifiers()) || constant.getType() != String.class) { + continue; + } + String name = constant.getName(); + if (NOT_YET_EMITTED.contains(name)) { + continue; + } + // A producer is any reference to the constant outside the vocabulary class itself. + int references = countOccurrences(platformSources, "HttpClientObservationNames." + name); + if (references == 0) { + undeclaredGaps.add(name); + } + } + + assertThat(undeclaredGaps) + .as( + "these metric names are declared and documented but nothing emits them; either wire a " + + "producer or add them to NOT_YET_EMITTED with the reason") + .isEmpty(); + } + + /** The admission list must not outlive the gap: a wired metric has to leave it. */ + @Test + @DisplayName("nothing sits in the not-yet-emitted list once it has a producer") + void theAdmissionListDoesNotHideWiredMetrics() throws Exception { + String platformSources = readPlatformSources(); + + for (String name : NOT_YET_EMITTED) { + assertThat(countOccurrences(platformSources, "HttpClientObservationNames." + name)) + .as("%s now has a producer and must be removed from NOT_YET_EMITTED", name) + .isZero(); + } + } + + private static int countOccurrences(String haystack, String needle) { + int count = 0; + int index = haystack.indexOf(needle); + while (index >= 0) { + count++; + index = haystack.indexOf(needle, index + needle.length()); + } + return count; + } + + /** Reads the platform's production sources, excluding the vocabulary class's own declarations. */ + private static String readPlatformSources() throws Exception { + Path main = repositoryRoot().resolve("src/adapter/outbound/httpclient/src/main/java"); + StringBuilder all = new StringBuilder(); + try (Stream files = Files.walk(main)) { + for (Path file : files.filter(path -> path.toString().endsWith(".java")).toList()) { + if (file.getFileName().toString().equals("HttpClientObservationNames.java")) { + continue; + } + all.append(Files.readString(file)).append('\n'); + } + } + return all.toString(); + } + + private static Path repositoryRoot() { + Path candidate = Path.of(System.getProperty("user.dir")).toAbsolutePath(); + for (int depth = 0; depth < 6 && candidate != null; depth++) { + if (Files.exists(candidate.resolve("src/adapter/outbound/httpclient/build.gradle"))) { + return candidate; + } + candidate = candidate.getParent(); + } + throw new AssertionError("repository root not found from " + System.getProperty("user.dir")); + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/resilience/RetryEligibilityEngineTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/resilience/RetryEligibilityEngineTest.java new file mode 100644 index 00000000..0906157f --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/resilience/RetryEligibilityEngineTest.java @@ -0,0 +1,205 @@ +package dev.caskeleton.adapter.outbound.httpclient.resilience; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.httpclient.api.operation.BodyReplayability; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.FailureCategory; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.OperationIdempotency; +import dev.caskeleton.adapter.outbound.httpclient.testkit.RetryContexts; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class RetryEligibilityEngineTest { + + private final RetryEligibilityEngine engine = new DefaultRetryEligibilityEngine(); + + @Test + void allowsGetAfterConnectFailure() { + assertThat(engine.decide(RetryContexts.getConnectFailure())).isInstanceOf(RetryAllowed.class); + } + + @Test + void marksPostWithoutKeyAmbiguousAfterSend() { + assertThat(engine.decide(RetryContexts.postSentNoResponseWithoutKey())) + .isInstanceOf(AmbiguousFailure.class); + } + + @Test + void allowsPostWithIdempotencyKeyAfterSend() { + assertThat(engine.decide(RetryContexts.postSentNoResponseWithKey())) + .isInstanceOf(RetryAllowed.class); + } + + @Test + void deniesOneShotBodyEvenForPut() { + assertThat(engine.decide(RetryContexts.putOneShotNotSent())).isInstanceOf(RetryDenied.class); + } + + @Test + void honorsRetryAfterOnlyInsideDeadline() { + assertThat(engine.decide(RetryContexts.rateLimitedBeyondDeadline())) + .isInstanceOf(RetryDenied.class); + RetryDecision allowed = engine.decide(RetryContexts.rateLimitedWithinDeadline()); + assertThat(allowed).isInstanceOf(RetryAllowed.class); + assertThat(((RetryAllowed) allowed).retryAfter()).isPresent(); + } + + @Test + void deniesAfterFirstByteWasDelivered() { + assertThat(engine.decide(RetryContexts.firstByteDelivered())) + .isEqualTo(RetryDenied.responseAlreadyDelivered()); + } + + @Test + void deniesPermanentTlsFailure() { + assertThat(engine.decide(RetryContexts.permanentTlsFailure())).isInstanceOf(RetryDenied.class); + } + + @Test + void deniesWhenBudgetIsExhaustedOrRuntimeIsDraining() { + assertThat( + engine.decide(RetryContexts.builder().budget(RetryBudgetSnapshot.exhausted()).build())) + .isEqualTo(RetryDenied.budgetExhausted()); + assertThat(engine.decide(RetryContexts.builder().runtimeDraining(true).build())) + .isEqualTo(RetryDenied.runtimeDraining()); + } + + @Test + void deniesWhenAttemptsAreExhausted() { + assertThat(engine.decide(RetryContexts.builder().attempt(3).maxAttempts(3).build())) + .isEqualTo(RetryDenied.maxAttempts()); + } + + @ParameterizedTest + @ValueSource(ints = {408, 429, 502, 503, 504}) + void allowsRetryableStatusesForSafeOperations(int status) { + assertThat( + engine.decide(RetryContexts.status(status, OperationIdempotency.STANDARD_IDEMPOTENT))) + .isInstanceOf(RetryAllowed.class); + } + + @ParameterizedTest + @ValueSource(ints = {400, 403, 404, 409, 422}) + void deniesNonRetryableClientStatuses(int status) { + assertThat( + engine.decide(RetryContexts.status(status, OperationIdempotency.STANDARD_IDEMPOTENT))) + .isEqualTo(RetryDenied.notRetryableStatus(status)); + } + + @Test + void deniesFiveHundredUnlessRegisteredTransient() { + assertThat(engine.decide(RetryContexts.status(500, OperationIdempotency.STANDARD_IDEMPOTENT))) + .isEqualTo(RetryDenied.notRetryableStatus(500)); + assertThat( + engine.decide( + RetryContexts.builder() + .evidence(ExecutionEvidence.RESPONSE_RECEIVED) + .failureCategory(FailureCategory.REMOTE_STATUS) + .status(500) + .transientServerErrors(Set.of(500)) + .build())) + .isInstanceOf(RetryAllowed.class); + } + + @Test + void allowsFourHundredOneRefreshExactlyOnce() { + assertThat(engine.decide(RetryContexts.status(401, OperationIdempotency.STANDARD_IDEMPOTENT))) + .isInstanceOf(RetryAllowed.class); + assertThat( + engine.decide( + RetryContexts.builder() + .evidence(ExecutionEvidence.RESPONSE_RECEIVED) + .failureCategory(FailureCategory.REMOTE_STATUS) + .status(401) + .attempt(2) + .build())) + .isEqualTo(RetryDenied.notRetryableStatus(401)); + } + + @Test + void deniesFourHundredOneReplayForNonReplayableOrUnsafeOperations() { + assertThat( + engine.decide( + RetryContexts.builder() + .evidence(ExecutionEvidence.RESPONSE_RECEIVED) + .failureCategory(FailureCategory.REMOTE_STATUS) + .status(401) + .replayability(BodyReplayability.ONE_SHOT) + .build())) + .isEqualTo(RetryDenied.bodyNotReplayable()); + assertThat( + engine.decide( + RetryContexts.builder() + .idempotency(OperationIdempotency.NON_IDEMPOTENT) + .evidence(ExecutionEvidence.RESPONSE_RECEIVED) + .failureCategory(FailureCategory.REMOTE_STATUS) + .status(401) + .build())) + .isEqualTo(RetryDenied.notRetryableStatus(401)); + } + + @Test + void deniesUnsafeFiveHundredTwoAsAmbiguousRatherThanRetry() { + assertThat(engine.decide(RetryContexts.status(503, OperationIdempotency.NON_IDEMPOTENT))) + .isInstanceOf(AmbiguousFailure.class); + } + + /** + * 408, 425 and 429 are answers, not proof that nothing happened. + * + *

All three used to bypass the safety check entirely. A non-idempotent POST that a + * rate-limited upstream had already accepted was resent on a 429, which is how a duplicate charge + * happens under load — precisely when a rate limiter is most likely to answer. + */ + @Test + void answeredStatusesDoNotRetryANonIdempotentOperation() { + assertThat(engine.decide(answered(408, OperationIdempotency.NON_IDEMPOTENT))) + .isInstanceOf(AmbiguousFailure.class); + assertThat(engine.decide(answered(425, OperationIdempotency.NON_IDEMPOTENT))) + .isInstanceOf(AmbiguousFailure.class); + assertThat(engine.decide(answered(429, OperationIdempotency.NON_IDEMPOTENT))) + .isEqualTo(RetryDenied.notRetryableStatus(429)); + } + + @Test + void answeredStatusesStillRetryASafeOperation() { + assertThat(engine.decide(answered(408, OperationIdempotency.STANDARD_IDEMPOTENT))) + .isInstanceOf(RetryAllowed.class); + assertThat(engine.decide(answered(429, OperationIdempotency.STANDARD_IDEMPOTENT))) + .isInstanceOf(RetryAllowed.class); + } + + /** + * A key the platform never sent buys nothing. + * + *

The upstream cannot deduplicate against a header it did not receive, so the operation is as + * unsafe to repeat as one with no key at all. + */ + @Test + void aKeyThatWasNeverSentDoesNotMakeARepeatSafe() { + assertThat( + engine.decide( + RetryContexts.builder() + .idempotency(OperationIdempotency.IDEMPOTENCY_KEY_REQUIRED) + .idempotencyKey( + new dev.caskeleton.adapter.outbound.httpclient.api.IdempotencyKey( + "order-1")) + .idempotencyKeySent(false) + .evidence(ExecutionEvidence.SENT_NO_RESPONSE) + .failureCategory(FailureCategory.RESPONSE_TIMEOUT) + .build())) + .isInstanceOf(AmbiguousFailure.class); + } + + private static RetryContext answered(int status, OperationIdempotency idempotency) { + return RetryContexts.builder() + .idempotency(idempotency) + .evidence(ExecutionEvidence.RESPONSE_RECEIVED) + .failureCategory(FailureCategory.REMOTE_STATUS) + .status(status) + .build(); + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/restclient/BlockingRedirectCoordinatorTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/restclient/BlockingRedirectCoordinatorTest.java new file mode 100644 index 00000000..b1990e1b --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/restclient/BlockingRedirectCoordinatorTest.java @@ -0,0 +1,175 @@ +package dev.caskeleton.adapter.outbound.httpclient.restclient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.httpclient.api.OperationName; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRedirectRejectedException; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation; +import dev.caskeleton.adapter.outbound.httpclient.api.result.HttpCallResult; +import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile; +import dev.caskeleton.adapter.outbound.httpclient.profile.RedirectSettings; +import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles; +import dev.caskeleton.adapter.outbound.httpclient.testkit.MockHttpServer; +import dev.caskeleton.adapter.outbound.httpclient.testkit.TestGateways; +import dev.caskeleton.adapter.outbound.httpclient.testkit.UserResponse; +import java.time.Duration; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class BlockingRedirectCoordinatorTest { + + @Test + void refusesRedirectsWhenTheProfileDisablesThem() throws Exception { + try (MockHttpServer server = MockHttpServer.start(); + TestGateways.Harness harness = TestGateways.apache(server.uri("/"))) { + server.enqueueRedirect(302, "/moved"); + assertThatThrownBy( + () -> + harness + .gateway() + .exchange( + harness.profile().name(), + HttpOperation.get(new OperationName("get-user"), "/users/1", Map.of()), + ResponseType.of(UserResponse.class))) + .isInstanceOf(HttpRedirectRejectedException.class) + .hasMessageContaining("REDIRECT_DISABLED"); + assertThat(server.requestCount()).isEqualTo(1); + } + } + + @Test + void followsABoundedSameOriginHopWhenEnabled() throws Exception { + try (MockHttpServer server = MockHttpServer.start()) { + ClientProfile profile = + ClientProfiles.builder("redirecting") + .baseUrl(server.uri("/")) + .redirect(new RedirectSettings(true, 2, false)) + .build(); + try (TestGateways.Harness harness = TestGateways.forProfile(profile)) { + server.enqueueRedirect(302, "/users/2"); + server.enqueueJson(200, "{\"id\":2,\"name\":\"moved\"}"); + + HttpCallResult result = + harness + .gateway() + .exchange( + profile.name(), + HttpOperation.get(new OperationName("get-user"), "/users/1", Map.of()), + ResponseType.of(UserResponse.class)); + + assertThat(result.body().id()).isEqualTo(2); + assertThat(server.requestCount()).isEqualTo(2); + assertThat(server.takeRequest(Duration.ofSeconds(2)).path()).isEqualTo("/users/1"); + assertThat(server.takeRequest(Duration.ofSeconds(2)).path()).isEqualTo("/users/2"); + } + } + } + + @Test + void stopsAtTheConfiguredHopLimit() throws Exception { + try (MockHttpServer server = MockHttpServer.start()) { + ClientProfile profile = + ClientProfiles.builder("looping") + .baseUrl(server.uri("/")) + .redirect(new RedirectSettings(true, 1, false)) + .build(); + try (TestGateways.Harness harness = TestGateways.forProfile(profile)) { + server.enqueueRedirect(302, "/hop-1"); + server.enqueueRedirect(302, "/hop-2"); + assertThatThrownBy( + () -> + harness + .gateway() + .exchange( + profile.name(), + HttpOperation.get(new OperationName("get-user"), "/users/1", Map.of()), + ResponseType.of(UserResponse.class))) + .isInstanceOf(HttpRedirectRejectedException.class) + .hasMessageContaining("MAX_HOPS"); + } + } + } + + /** + * A hop must satisfy the profile's own allowlist, not only the redirect policy. + * + *

The two were never both applied. The redirect policy judged hop count, origin change and + * method rewrite; nothing re-checked the destination against the hosts and ports the operator had + * allowed. An upstream could therefore move a trusted profile to an origin the allowlist excluded + * simply by answering 302. + */ + @Test + void aHopIsRejectedWhenTheProfileAllowlistExcludesIt() throws Exception { + try (MockHttpServer server = MockHttpServer.start()) { + ClientProfile profile = + ClientProfiles.builder("guarded") + .baseUrl(server.uri("/")) + .allowedHosts(java.util.Set.of("only-this-host.test")) + .redirect(new RedirectSettings(true, 2, true)) + .build(); + try (TestGateways.Harness harness = TestGateways.forProfile(profile)) { + server.enqueueRedirect(302, "/users/2"); + + assertThatThrownBy( + () -> + harness + .gateway() + .exchange( + profile.name(), + HttpOperation.get(new OperationName("get-user"), "/users/1", Map.of()), + ResponseType.of(UserResponse.class))) + .isInstanceOf( + dev.caskeleton.adapter.outbound.httpclient.api.error.HttpTargetRejectedException + .class) + .hasMessageContaining("allowlist"); + } + } + } + + /** + * 303 means "fetch this other thing with GET", which includes dropping the body. + * + *

Only the method was changed before, so the original payload was resent as the body of a GET + * to a destination the upstream had chosen. + */ + @Test + void aSeeOtherHopDropsTheRequestBody() throws Exception { + try (MockHttpServer server = MockHttpServer.start()) { + ClientProfile profile = + ClientProfiles.builder("see-other") + .baseUrl(server.uri("/")) + .redirect(new RedirectSettings(true, 2, false)) + .build(); + try (TestGateways.Harness harness = TestGateways.forProfile(profile)) { + server.enqueueRedirect(303, "/users/2"); + server.enqueueJson(200, "{\"id\":2,\"name\":\"moved\"}"); + + harness + .gateway() + .exchange( + profile.name(), + new HttpOperation( + new OperationName("create-user"), + dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod.POST, + "/users", + Map.of(), + Map.of(), + dev.caskeleton.adapter.outbound.httpclient.api.body.ObjectBody.json( + Map.of("name", "grace")), + dev.caskeleton.adapter.outbound.httpclient.api.operation.OperationIdempotency + .NON_IDEMPOTENT, + java.util.Optional.empty(), + java.util.Optional.empty()), + ResponseType.of(UserResponse.class)); + + server.takeRequest(Duration.ofSeconds(2)); + dev.caskeleton.adapter.outbound.httpclient.testkit.RecordedHttpRequest hop = + server.takeRequest(Duration.ofSeconds(2)); + assertThat(hop.method()).isEqualTo("GET"); + assertThat(hop.bodyUtf8()).isEmpty(); + } + } + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/security/MutualTlsHandshakeContractTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/security/MutualTlsHandshakeContractTest.java new file mode 100644 index 00000000..db79b2c0 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/security/MutualTlsHandshakeContractTest.java @@ -0,0 +1,172 @@ +package dev.caskeleton.adapter.outbound.httpclient.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.httpclient.apache.ApacheBlockingTransportProvider; +import dev.caskeleton.adapter.outbound.httpclient.apache.ApacheFailureClassifier; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.AttemptStage; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.FailureCategory; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile; +import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles; +import dev.caskeleton.adapter.outbound.httpclient.testkit.MockHttpServer; +import dev.caskeleton.adapter.outbound.httpclient.testkit.NoopLifecycleListener; +import dev.caskeleton.adapter.outbound.httpclient.testkit.TlsFixture; +import dev.caskeleton.adapter.outbound.httpclient.testkit.TlsMaterials; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailure; +import java.net.URI; +import java.util.Optional; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.web.client.RestClient; + +/** + * Real TLS and mTLS handshakes against a fixture server (design §21, §28.5). + * + *

Every case here runs an actual handshake through the production Apache transport and the + * production {@code TlsMaterialProvider}. Asserting TLS policy only against the validator would + * prove the rules are written down, not that they hold on the wire. + */ +@Tag("httpclient-security") +class MutualTlsHandshakeContractTest { + + private static ClientProfile tlsProfile(URI baseUrl) { + return ClientProfiles.builder("partner").baseUrl(baseUrl).build(); + } + + @Test + void aClientCertificateSatisfiesAServerThatRequiresOne() throws Exception { + TlsFixture fixture = TlsFixture.trusted(); + try (MockHttpServer server = MockHttpServer.startTls(fixture.serverSocketFactory(), true)) { + server.enqueueJson(200, "{\"id\":1,\"name\":\"mtls\"}"); + ClientProfile profile = tlsProfile(server.uri("/")); + ApacheBlockingTransportProvider provider = + new ApacheBlockingTransportProvider( + Optional.empty(), + ignored -> Optional.of(TlsMaterials.mutual(fixture)), + ignored -> Optional.empty()); + try { + String body = + RestClient.builder() + .requestFactory( + provider.create( + profile, + new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1), + NoopLifecycleListener.INSTANCE)) + .build() + .get() + .uri(server.uri("/users/1")) + .retrieve() + .body(String.class); + assertThat(body).contains("mtls"); + } finally { + provider.close( + profile, new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1)); + } + } + } + + @Test + void aMissingClientCertificateCannotReachTheUpstream() throws Exception { + TlsFixture fixture = TlsFixture.trusted(); + try (MockHttpServer server = MockHttpServer.startTls(fixture.serverSocketFactory(), true)) { + server.enqueueJson(200, "{\"id\":1,\"name\":\"never\"}"); + ClientProfile profile = tlsProfile(server.uri("/")); + ApacheBlockingTransportProvider provider = + new ApacheBlockingTransportProvider( + Optional.empty(), + ignored -> Optional.of(TlsMaterials.trustOnly(fixture)), + ignored -> Optional.empty()); + try { + RestClient client = + RestClient.builder() + .requestFactory( + provider.create( + profile, + new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1), + NoopLifecycleListener.INSTANCE)) + .build(); + // Under TLS 1.3 the client finishes its half of the handshake before the server rejects it, + // so the failure can surface either during the handshake or on the first read. Both are + // failures; what must never happen is the request being served. + assertThatThrownBy( + () -> client.get().uri(server.uri("/users/1")).retrieve().body(String.class)) + .isInstanceOf(RuntimeException.class); + } finally { + provider.close( + profile, new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1)); + } + } + } + + @Test + void aHostnameMismatchIsAPermanentTlsFailure() throws Exception { + TlsFixture fixture = TlsFixture.hostnameMismatch(); + assertPermanentTlsFailure(fixture); + } + + @Test + void anExpiredCertificateIsAPermanentTlsFailure() throws Exception { + TlsFixture fixture = TlsFixture.expired(); + assertPermanentTlsFailure(fixture); + } + + @Test + void anUntrustedAuthorityIsAPermanentTlsFailure() throws Exception { + TlsFixture serverFixture = TlsFixture.trusted(); + TlsFixture unrelatedClientTrust = TlsFixture.trusted(); + try (MockHttpServer server = + MockHttpServer.startTls(serverFixture.serverSocketFactory(), false)) { + server.enqueueJson(200, "{\"id\":1,\"name\":\"never\"}"); + ClientProfile profile = tlsProfile(server.uri("/")); + Throwable captured = captureFailure(profile, server, unrelatedClientTrust); + assertPermanent(captured); + } + } + + private void assertPermanentTlsFailure(TlsFixture fixture) throws Exception { + try (MockHttpServer server = MockHttpServer.startTls(fixture.serverSocketFactory(), false)) { + server.enqueueJson(200, "{\"id\":1,\"name\":\"never\"}"); + ClientProfile profile = tlsProfile(server.uri("/")); + Throwable captured = captureFailure(profile, server, fixture); + assertPermanent(captured); + } + } + + private Throwable captureFailure(ClientProfile profile, MockHttpServer server, TlsFixture trust) { + ApacheBlockingTransportProvider provider = + new ApacheBlockingTransportProvider( + Optional.empty(), + ignored -> Optional.of(TlsMaterials.trustOnly(trust)), + ignored -> Optional.empty()); + try { + RestClient client = + RestClient.builder() + .requestFactory( + provider.create( + profile, + new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1), + NoopLifecycleListener.INSTANCE)) + .build(); + try { + client.get().uri(server.uri("/users/1")).retrieve().body(String.class); + throw new AssertionError("the handshake was expected to fail"); + } catch (RuntimeException failure) { + return failure; + } + } finally { + provider.close( + profile, new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1)); + } + } + + private void assertPermanent(Throwable captured) { + TransportFailure classified = + new ApacheFailureClassifier().classify(captured, AttemptStage.TLS_HANDSHAKE); + assertThat(classified.evidence()).isEqualTo(ExecutionEvidence.NOT_SENT); + assertThat(classified.stage()).isEqualTo(AttemptStage.TLS_HANDSHAKE); + assertThat(classified.category()).isEqualTo(FailureCategory.TLS_PERMANENT); + assertThat(classified.category().permanent()).isTrue(); + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/security/TlsRuntimeRotationCoordinatorTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/security/TlsRuntimeRotationCoordinatorTest.java new file mode 100644 index 00000000..e77b752a --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/security/TlsRuntimeRotationCoordinatorTest.java @@ -0,0 +1,114 @@ +package dev.caskeleton.adapter.outbound.httpclient.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntime; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeState; +import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration; +import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class TlsRuntimeRotationCoordinatorTest { + + /** A profile whose client certificate is the one that rotated. */ + private static dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile usingCertificate( + String name, String keyMaterialReference) { + return ClientProfiles.builder(name) + .tls( + new dev.caskeleton.adapter.outbound.httpclient.profile.TlsSettings( + java.util.Optional.of(name), + java.util.Set.of("TLSv1.3"), + true, + false, + false, + java.util.Optional.empty(), + java.util.Optional.of(keyMaterialReference))) + .build(); + } + + @Test + void swapsRuntimeWhenCertificateIdentityChanges() { + AtomicInteger closedGenerations = new AtomicInteger(); + ClientRuntime first = + new ClientRuntime( + usingCertificate("partner", "cert-v2"), + new RuntimeGeneration(1), + closedGenerations::incrementAndGet); + try (ClientRuntimeRegistry registry = new ClientRuntimeRegistry(Map.of(first.name(), first))) { + TlsRuntimeRotationCoordinator coordinator = + new TlsRuntimeRotationCoordinator( + registry, + (profile, generation) -> + new ClientRuntime(profile, generation, closedGenerations::incrementAndGet), + Duration.ofSeconds(1)); + + coordinator.rotate(new ClientCertificateIdentity("cert-v2")); + + try (ClientRuntimeLease lease = registry.acquire(new ClientProfileName("partner"))) { + assertThat(lease.runtime().generation().value()).isEqualTo(2); + } + assertThat(first.state()).isEqualTo(ClientRuntimeState.CLOSED); + assertThat(closedGenerations.get()).isEqualTo(1); + } + } + + /** + * A rotation touches only the profiles that use the rotated certificate. + * + *

The identity argument used to be required and then ignored: every registered profile was + * rotated whatever had changed. Rotating discards a warm pool and forces fresh handshakes, so one + * certificate renewal produced a connection storm across every upstream the service talks to — + * and if the rotation was wrong, everything degraded at once with nothing pointing at the cause. + */ + @Test + void rotationTouchesOnlyTheProfilesThatUseTheRotatedCertificate() { + AtomicInteger closedGenerations = new AtomicInteger(); + ClientRuntime affected = + new ClientRuntime( + usingCertificate("partner", "cert-v2"), + new RuntimeGeneration(1), + closedGenerations::incrementAndGet); + ClientRuntime unrelatedCertificate = + new ClientRuntime( + usingCertificate("billing", "other-cert"), + new RuntimeGeneration(1), + closedGenerations::incrementAndGet); + ClientRuntime noCertificate = + new ClientRuntime( + ClientProfiles.builder("search").build(), + new RuntimeGeneration(1), + closedGenerations::incrementAndGet); + + try (ClientRuntimeRegistry registry = + new ClientRuntimeRegistry( + Map.of( + affected.name(), affected, + unrelatedCertificate.name(), unrelatedCertificate, + noCertificate.name(), noCertificate))) { + TlsRuntimeRotationCoordinator coordinator = + new TlsRuntimeRotationCoordinator( + registry, + (profile, generation) -> + new ClientRuntime(profile, generation, closedGenerations::incrementAndGet), + Duration.ofSeconds(1)); + + assertThat(coordinator.rotate(new ClientCertificateIdentity("cert-v2"))) + .containsExactly(new ClientProfileName("partner")); + + assertThat(registry.current(new ClientProfileName("partner")).generation().value()) + .isEqualTo(2); + assertThat(registry.current(new ClientProfileName("billing")).generation().value()) + .as("a profile using a different certificate keeps its warm pool") + .isEqualTo(1); + assertThat(registry.current(new ClientProfileName("search")).generation().value()) + .as("a profile with no client certificate cannot be affected at all") + .isEqualTo(1); + } + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/security/TrustedRequestPolicyTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/security/TrustedRequestPolicyTest.java new file mode 100644 index 00000000..104df751 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/security/TrustedRequestPolicyTest.java @@ -0,0 +1,161 @@ +package dev.caskeleton.adapter.outbound.httpclient.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod; +import dev.caskeleton.adapter.outbound.httpclient.api.IdempotencyKey; +import dev.caskeleton.adapter.outbound.httpclient.api.OperationName; +import dev.caskeleton.adapter.outbound.httpclient.api.body.ByteArrayBody; +import dev.caskeleton.adapter.outbound.httpclient.api.body.EmptyBody; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpTargetRejectedException; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.OperationIdempotency; +import dev.caskeleton.adapter.outbound.httpclient.api.result.IdempotencyKeyRequirement; +import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class TrustedRequestPolicyTest { + + private final TrustedTargetPolicy policy = + new TrustedTargetPolicy(ClientProfiles.builder("payment").build()); + + @Test + void rejectsAbsoluteUriInTrustedGenericGateway() { + HttpOperation absolute = + HttpOperation.get( + new OperationName("probe"), "https://evil.example.com/payments", Map.of()); + assertThatThrownBy(() -> policy.prepare(absolute)) + .isInstanceOf(HttpTargetRejectedException.class); + assertThatThrownBy( + () -> + policy.prepare( + HttpOperation.get(new OperationName("probe"), "//evil.example.com", Map.of()))) + .isInstanceOf(HttpTargetRejectedException.class); + } + + @Test + void rejectsHeaderInjection() { + HeaderPolicy headerPolicy = HeaderPolicy.defaultPolicy(); + assertThatThrownBy(() -> headerPolicy.validate(Map.of("X-Test", List.of("ok\r\nBad: x")))) + .isInstanceOf(HttpTargetRejectedException.class); + } + + @Test + void rejectsPlatformOwnedHeaderOverride() { + HeaderPolicy headerPolicy = HeaderPolicy.defaultPolicy(); + assertThatThrownBy(() -> headerPolicy.validate(Map.of("Authorization", List.of("Bearer x")))) + .isInstanceOf(HttpTargetRejectedException.class); + assertThatThrownBy(() -> headerPolicy.validate(Map.of("Host", List.of("other.example.com")))) + .isInstanceOf(HttpTargetRejectedException.class); + } + + @Test + void allowsIdempotencyKeyOnlyWhenTheOperationDeclaresIt() { + assertThatThrownBy( + () -> + HeaderPolicy.defaultPolicy() + .validate(Map.of("Idempotency-Key", List.of("order-1")))) + .isInstanceOf(HttpTargetRejectedException.class); + assertThat( + HeaderPolicy.forOperation(IdempotencyKeyRequirement.required("Idempotency-Key"), false) + .validate(Map.of("Idempotency-Key", List.of("order-1")))) + .containsKey("Idempotency-Key"); + } + + @Test + void rejectsKnownBodyLargerThanProfileLimit() { + assertThatThrownBy( + () -> + BodyLimitPolicy.maxRequestBytes(4) + .validate(new ByteArrayBody(new byte[5], "application/octet-stream"))) + .isInstanceOf(HttpConfigurationException.class); + } + + @Test + void encodesTemplateVariablesPerComponent() { + HttpOperation operation = + HttpOperation.get(new OperationName("get-user"), "/users/{id}", Map.of("id", "a/b?c=d")); + PreparedOperation prepared = policy.prepare(operation); + assertThat(prepared.target().uri().getRawPath()).isEqualTo("/users/a%2Fb%3Fc%3Dd"); + assertThat(prepared.target().uriTemplate()).isEqualTo("/users/{id}"); + assertThat(prepared.target().host()).isEqualTo("payment.example.com"); + } + + @Test + void carriesProfileBudgetsForwardToTheExecutor() { + HttpOperation operation = + new HttpOperation( + new OperationName("create-payment"), + HttpMethod.POST, + "/payments", + Map.of(), + Map.of(), + EmptyBody.instance(), + OperationIdempotency.IDEMPOTENCY_KEY_REQUIRED, + Optional.of(new IdempotencyKey("order-1")), + Optional.empty()); + PreparedOperation prepared = policy.prepare(operation); + assertThat(prepared.maxRequestBytes()).isEqualTo(1024L * 1024); + assertThat(prepared.maxResponseDecodedBytes()).isEqualTo(10L * 1024 * 1024); + } + + /** + * A registered key must reach the wire. + * + *

It used to be carried on the operation, consulted by the retry engine as proof that repeats + * were safe, and never written to a header. The upstream received no key, could not deduplicate, + * and the platform retried anyway — so a non-idempotent operation with a key was strictly more + * dangerous than one without. + */ + @Test + void aRegisteredIdempotencyKeyIsRenderedAsAHeader() { + PreparedOperation prepared = policy.prepare(keyedPayment()); + + assertThat(prepared.headers()).containsEntry("Idempotency-Key", java.util.List.of("order-1")); + assertThat(prepared.idempotencyKeySent()).isTrue(); + } + + /** Two keys for one request is a contradiction, not a merge. */ + @Test + void aCallerSuppliedIdempotencyHeaderIsRefused() { + HttpOperation operation = + keyedPayment().withHeaders(Map.of("Idempotency-Key", java.util.List.of("caller-chosen"))); + + assertThatThrownBy(() -> policy.prepare(operation)) + .isInstanceOf( + dev.caskeleton.adapter.outbound.httpclient.api.error.HttpTargetRejectedException.class) + .hasMessageContaining("owned by the platform"); + } + + /** A key with no requirement to carry it would be silently dropped. */ + @Test + void aKeyWhoseRequirementWasNotRegisteredIsRefused() { + assertThatThrownBy( + () -> + policy.prepare( + keyedPayment(), + dev.caskeleton.adapter.outbound.httpclient.api.result.IdempotencyKeyRequirement + .none())) + .isInstanceOf( + dev.caskeleton.adapter.outbound.httpclient.api.error.HttpTargetRejectedException.class) + .hasMessageContaining("no header to send it in"); + } + + private static HttpOperation keyedPayment() { + return new HttpOperation( + new OperationName("create-payment"), + HttpMethod.POST, + "/payments", + Map.of(), + Map.of(), + EmptyBody.instance(), + OperationIdempotency.IDEMPOTENCY_KEY_REQUIRED, + Optional.of(new IdempotencyKey("order-1")), + Optional.empty()); + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/service/BlockingHttpServiceRegistryTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/service/BlockingHttpServiceRegistryTest.java new file mode 100644 index 00000000..2b4770cd --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/service/BlockingHttpServiceRegistryTest.java @@ -0,0 +1,76 @@ +package dev.caskeleton.adapter.outbound.httpclient.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles; +import dev.caskeleton.adapter.outbound.httpclient.testkit.MockHttpServer; +import dev.caskeleton.adapter.outbound.httpclient.testkit.TestGateways; +import dev.caskeleton.adapter.outbound.httpclient.testkit.UsersClient; +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class BlockingHttpServiceRegistryTest { + + @Test + void createsTypedProxyBoundToNamedProfile() throws Exception { + try (MockHttpServer server = MockHttpServer.start(); + TestGateways.Harness harness = + TestGateways.forProfile( + ClientProfiles.builder("users").baseUrl(server.uri("/")).build())) { + server.enqueueJson(200, "{\"id\":7,\"name\":\"grace\"}"); + HttpServiceRegistry registry = + new DefaultHttpServiceRegistry(harness.registry(), harness.gateway()); + + assertThat(registry.client(harness.profile().name(), UsersClient.class).get(7).id()) + .isEqualTo(7); + assertThat(server.takeRequest(Duration.ofSeconds(2)).path()).isEqualTo("/users/7"); + } + } + + @Test + void operationContextIsEmptyAfterSuccessAndFailure() throws Exception { + try (MockHttpServer server = MockHttpServer.start(); + TestGateways.Harness harness = + TestGateways.forProfile( + ClientProfiles.builder("users").baseUrl(server.uri("/")).build())) { + HttpServiceRegistry registry = + new DefaultHttpServiceRegistry(harness.registry(), harness.gateway()); + UsersClient client = registry.client(harness.profile().name(), UsersClient.class); + + server.enqueueJson(200, "{\"id\":1,\"name\":\"a\"}"); + client.get(1); + assertThat(OperationContextHolder.instance().empty()).isTrue(); + + server.enqueueStatus(500); + assertThatThrownBy(() -> client.get(2)).isInstanceOf(RuntimeException.class); + assertThat(OperationContextHolder.instance().empty()).isTrue(); + } + } + + @Test + void resolvesTheProfileFromTheInterfaceDeclaration() throws Exception { + try (MockHttpServer server = MockHttpServer.start(); + TestGateways.Harness harness = + TestGateways.forProfile( + ClientProfiles.builder("users").baseUrl(server.uri("/")).build())) { + server.enqueueJson(200, "{\"id\":3,\"name\":\"c\"}"); + HttpServiceRegistry registry = + new DefaultHttpServiceRegistry(harness.registry(), harness.gateway()); + assertThat(registry.client(UsersClient.class).get(3).name()).isEqualTo("c"); + } + } + + @Test + void returnsTheSameProxyForRepeatedLookups() throws Exception { + try (MockHttpServer server = MockHttpServer.start(); + TestGateways.Harness harness = + TestGateways.forProfile( + ClientProfiles.builder("users").baseUrl(server.uri("/")).build())) { + HttpServiceRegistry registry = + new DefaultHttpServiceRegistry(harness.registry(), harness.gateway()); + assertThat(registry.client(UsersClient.class)) + .isSameAs(registry.client(harness.profile().name(), UsersClient.class)); + } + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/service/BlockingReactiveSignatureSeparationTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/service/BlockingReactiveSignatureSeparationTest.java new file mode 100644 index 00000000..a9522043 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/service/BlockingReactiveSignatureSeparationTest.java @@ -0,0 +1,43 @@ +package dev.caskeleton.adapter.outbound.httpclient.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException; +import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles; +import dev.caskeleton.adapter.outbound.httpclient.testkit.MixedSignatureClient; +import dev.caskeleton.adapter.outbound.httpclient.testkit.MockHttpServer; +import dev.caskeleton.adapter.outbound.httpclient.testkit.ReactiveEventsClient; +import dev.caskeleton.adapter.outbound.httpclient.testkit.TestGateways; +import org.junit.jupiter.api.Test; + +class BlockingReactiveSignatureSeparationTest { + + private final ServiceOperationDescriptorScanner scanner = new ServiceOperationDescriptorScanner(); + + @Test + void classifiesAnInterfaceAsEitherBlockingOrReactive() { + assertThat(scanner.scan(ReactiveEventsClient.class)) + .allSatisfy(descriptor -> assertThat(descriptor.reactive()).isTrue()); + } + + @Test + void refusesAnAmbiguousInterfaceOutright() { + assertThatThrownBy(() -> scanner.scan(MixedSignatureClient.class)) + .isInstanceOf(HttpConfigurationException.class); + } + + @Test + void rejectsAReactiveInterfaceOnTheBlockingRegistry() throws Exception { + try (MockHttpServer server = MockHttpServer.start(); + TestGateways.Harness harness = + TestGateways.forProfile( + ClientProfiles.builder("events").baseUrl(server.uri("/")).build())) { + HttpServiceRegistry registry = + new DefaultHttpServiceRegistry(harness.registry(), harness.gateway()); + assertThatThrownBy(() -> registry.client(ReactiveEventsClient.class)) + .isInstanceOf(HttpConfigurationException.class) + .hasMessageContaining("reactive"); + } + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/service/ReactiveHttpServiceRegistryTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/service/ReactiveHttpServiceRegistryTest.java new file mode 100644 index 00000000..7cd7f5e3 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/service/ReactiveHttpServiceRegistryTest.java @@ -0,0 +1,89 @@ +package dev.caskeleton.adapter.outbound.httpclient.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException; +import dev.caskeleton.adapter.outbound.httpclient.testkit.MockHttpServer; +import dev.caskeleton.adapter.outbound.httpclient.testkit.ReactiveEventsClient; +import dev.caskeleton.adapter.outbound.httpclient.testkit.ReactiveTestGateways; +import dev.caskeleton.adapter.outbound.httpclient.testkit.UsersClient; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +class ReactiveHttpServiceRegistryTest { + + @Test + void returnsTypedReactiveResultsThroughTheProfileRuntime() throws Exception { + try (MockHttpServer server = MockHttpServer.start(); + ReactiveTestGateways.Harness harness = ReactiveTestGateways.reactor(server.uri("/"))) { + server.enqueueJson(200, "{\"id\":\"e1\",\"value\":\"v\"}"); + ReactiveHttpServiceRegistry registry = + new DefaultReactiveHttpServiceRegistry(harness.registry(), harness.gateway()); + + StepVerifier.create( + registry.client(harness.profile().name(), ReactiveEventsClient.class).get("e1")) + .expectNextMatches(event -> event.id().equals("e1")) + .verifyComplete(); + } + } + + /** + * The descriptor must be visible to the operators the platform composes upstream of the + * proxy — which is where filters and observation live — so the assertion is made from inside the + * delegate's own subscription rather than from a downstream operator. + */ + @Test + void propagatesOperationDescriptorThroughReactorContext() throws Exception { + ServiceOperationDescriptorScanner scanner = new ServiceOperationDescriptorScanner(); + Map descriptors = new LinkedHashMap<>(); + scanner + .scan(ReactiveEventsClient.class) + .forEach(descriptor -> descriptors.put(descriptor.method(), descriptor)); + + AtomicReference> observed = + new AtomicReference<>(Optional.empty()); + ReactiveEventsClient delegate = + id -> + Mono.deferContextual( + context -> { + observed.set(ReactiveOperationContext.from(context)); + return Mono.just(new ReactiveEventsClient.EventResponse(id, "v")); + }); + + ReactiveEventsClient proxied = + (ReactiveEventsClient) + Proxy.newProxyInstance( + ReactiveEventsClient.class.getClassLoader(), + new Class[] {ReactiveEventsClient.class}, + new ReactiveServiceInvocationHandler( + delegate, + new ClientProfileName("events"), + descriptors, + OperationContextHolder.instance())); + + StepVerifier.create(proxied.get("e1")).expectNextCount(1).verifyComplete(); + assertThat(observed.get().map(descriptor -> descriptor.operationName().value())) + .contains("get-event"); + } + + @Test + void rejectsABlockingInterfaceOnTheReactiveRegistry() throws Exception { + try (MockHttpServer server = MockHttpServer.start(); + ReactiveTestGateways.Harness harness = ReactiveTestGateways.reactor(server.uri("/"))) { + ReactiveHttpServiceRegistry registry = + new DefaultReactiveHttpServiceRegistry(harness.registry(), harness.gateway()); + assertThatThrownBy(() -> registry.client(UsersClient.class)) + .isInstanceOf(HttpConfigurationException.class) + .hasMessageContaining("blocking"); + } + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/service/TypedClientPlatformPolicyTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/service/TypedClientPlatformPolicyTest.java new file mode 100644 index 00000000..ff643ff9 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/service/TypedClientPlatformPolicyTest.java @@ -0,0 +1,136 @@ +package dev.caskeleton.adapter.outbound.httpclient.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpClientException; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpResponseTooLargeException; +import dev.caskeleton.adapter.outbound.httpclient.profile.ResponseLimits; +import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles; +import dev.caskeleton.adapter.outbound.httpclient.testkit.MockHttpServer; +import dev.caskeleton.adapter.outbound.httpclient.testkit.TestGateways; +import dev.caskeleton.adapter.outbound.httpclient.testkit.UsersClient; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * A typed client is subject to the same platform policy as a generic exchange. + * + *

It was not. The typed registries built a Spring proxy over the profile's raw {@code + * RestClient}, so an {@code @HttpExchange} method reached the network without target policy, + * credentials, admission, deadline, resilience, byte limits, stable error mapping or observation. + * The proxy bound an operation descriptor into a thread local and nothing on the execution path + * read it. Each test here picks one of those guarantees and shows it now applies, because the only + * way to make them all apply is for the typed surface to be the same code path rather than a + * parallel one. + */ +class TypedClientPlatformPolicyTest { + + /** + * The response byte ceiling. + * + *

The clearest evidence that the kernel ran: the profile's limit is enforced on a response the + * raw client would have decoded without complaint. + */ + @Test + @DisplayName("a typed call is bounded by the profile's response limit") + void aTypedCallIsBoundedByTheProfileResponseLimit() throws Exception { + try (MockHttpServer server = MockHttpServer.start(); + TestGateways.Harness harness = + TestGateways.forProfile( + ClientProfiles.builder("users") + .baseUrl(server.uri("/")) + .response(new ResponseLimits(16, 16, Set.of("application/json"))) + .build())) { + server.enqueueJson(200, "{\"id\":7,\"name\":\"a-name-well-past-sixteen-bytes\"}"); + UsersClient client = typedClient(harness); + + assertThatThrownBy(() -> client.get(7)).isInstanceOf(HttpResponseTooLargeException.class); + } + } + + /** + * Stable error mapping. + * + *

A remote error must arrive as the platform's own exception carrying platform metadata, not + * as whatever Spring's client happened to throw. + */ + @Test + @DisplayName("a remote error reaches the caller as a stable platform exception") + void aRemoteErrorBecomesAStablePlatformException() throws Exception { + try (MockHttpServer server = MockHttpServer.start(); + TestGateways.Harness harness = + TestGateways.forProfile( + ClientProfiles.builder("users").baseUrl(server.uri("/")).build())) { + server.enqueueJson(500, "{\"error\":\"boom\"}"); + UsersClient client = typedClient(harness); + + assertThatThrownBy(() -> client.get(7)) + .isInstanceOf(HttpClientException.class) + .satisfies( + failure -> + assertThat(((HttpClientException) failure).metadata().clientName().value()) + .isEqualTo("users")); + } + } + + /** + * The descriptor is consumed, not merely published. + * + *

Failure metadata carries the operation name the scanner registered, which is only possible + * if the execution path read the binding the invocation handler set. + */ + @Test + @DisplayName("the registered operation descriptor reaches the failure metadata") + void theOperationDescriptorReachesTheExecutionPath() throws Exception { + try (MockHttpServer server = MockHttpServer.start(); + TestGateways.Harness harness = + TestGateways.forProfile( + ClientProfiles.builder("users").baseUrl(server.uri("/")).build())) { + server.enqueueJson(500, "{}"); + UsersClient client = typedClient(harness); + + assertThatThrownBy(() -> client.get(7)) + .isInstanceOf(HttpClientException.class) + .satisfies( + failure -> + assertThat(((HttpClientException) failure).metadata().operationName().value()) + .isEqualTo("get-user")); + } + } + + /** + * A call with no descriptor is refused rather than executed on invented defaults. + * + *

Reached by calling the exchange adapter directly, which is how a future caller would arrive + * without the invocation handler's binding. + */ + @Test + @DisplayName("an unbound typed call is refused") + void anUnboundTypedCallIsRefused() throws Exception { + try (MockHttpServer server = MockHttpServer.start(); + TestGateways.Harness harness = + TestGateways.forProfile( + ClientProfiles.builder("users").baseUrl(server.uri("/")).build())) { + KernelHttpExchangeAdapter adapter = + new KernelHttpExchangeAdapter( + harness.gateway(), harness.profile().name(), OperationContextHolder.instance()); + + assertThatThrownBy( + () -> + adapter.exchange( + org.springframework.web.service.invoker.HttpRequestValues.builder() + .setHttpMethod(org.springframework.http.HttpMethod.GET) + .setUriTemplate("/users/1") + .build())) + .isInstanceOf(HttpClientException.class) + .hasMessageContaining("without a registered operation descriptor"); + } + } + + private static UsersClient typedClient(TestGateways.Harness harness) { + return new DefaultHttpServiceRegistry(harness.registry(), harness.gateway()) + .client(harness.profile().name(), UsersClient.class); + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/DynamicTargetSecurityContract.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/DynamicTargetSecurityContract.java new file mode 100644 index 00000000..b04724e7 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/DynamicTargetSecurityContract.java @@ -0,0 +1,56 @@ +package dev.caskeleton.adapter.outbound.httpclient.testkit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpTargetRejectedException; +import dev.caskeleton.adapter.outbound.httpclient.dynamic.CanonicalTarget; +import dev.caskeleton.adapter.outbound.httpclient.dynamic.DynamicTargetPolicy; +import java.net.URI; +import java.util.List; + +/** The SSRF matrix every Dynamic Target deployment must satisfy (design §28.5). */ +public final class DynamicTargetSecurityContract { + + private static final List FORBIDDEN_TARGETS = + List.of( + "http://127.0.0.1/a", + "https://[::1]/a", + "https://169.254.169.254/latest/meta-data", + "https://10.0.0.1/a", + "https://192.168.1.1/a", + "https://172.16.0.1/a", + "https://[fd00::1]/a", + "https://100.64.0.1/a", + "file:///etc/passwd", + "https://user:pass@example.com/a"); + + private DynamicTargetSecurityContract() {} + + public static void verifyAll() { + DynamicTargetPolicy policy = DynamicTargets.publicHttpsOnly(); + for (String raw : FORBIDDEN_TARGETS) { + assertThatThrownBy(() -> prepareAndPin(policy, raw)) + .describedAs("dynamic target %s must be rejected", raw) + .isInstanceOf(HttpTargetRejectedException.class); + } + assertThatThrownBy( + () -> + DynamicTargets.resolvesTo("mixed.test", "93.184.216.34", "10.0.0.4") + .resolve("mixed.test")) + .describedAs("a mixed DNS answer must reject the whole target") + .isInstanceOf(HttpTargetRejectedException.class); + + CanonicalTarget allowed = DynamicTargets.prepare(policy, URI.create("https://public.test/a")); + assertThat( + DynamicTargets.resolvesTo("public.test", "93.184.216.34") + .pin(allowed) + .approvedAddresses()) + .hasSize(1); + } + + private static void prepareAndPin(DynamicTargetPolicy policy, String raw) { + CanonicalTarget canonical = DynamicTargets.prepare(policy, URI.create(raw)); + DynamicTargets.resolvesTo(canonical.host(), canonical.host()).pin(canonical); + } +} diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/RetryContexts.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/RetryContexts.java new file mode 100644 index 00000000..be1a2de4 --- /dev/null +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/testkit/RetryContexts.java @@ -0,0 +1,236 @@ +package dev.caskeleton.adapter.outbound.httpclient.testkit; + +import dev.caskeleton.adapter.outbound.httpclient.api.HttpStatus; +import dev.caskeleton.adapter.outbound.httpclient.api.IdempotencyKey; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.BodyReplayability; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.FailureCategory; +import dev.caskeleton.adapter.outbound.httpclient.api.operation.OperationIdempotency; +import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryBudgetSnapshot; +import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryContext; +import java.time.Duration; +import java.util.Optional; +import java.util.Set; + +/** Retry safety-matrix fixtures (design §17.3, §28.4). */ +public final class RetryContexts { + + private static final Duration MINIMUM_ATTEMPT = Duration.ofMillis(100); + + private RetryContexts() {} + + public static Builder builder() { + return new Builder(); + } + + public static RetryContext getConnectFailure() { + return builder() + .idempotency(OperationIdempotency.STANDARD_IDEMPOTENT) + .evidence(ExecutionEvidence.NOT_SENT) + .failureCategory(FailureCategory.CONNECT) + .build(); + } + + public static RetryContext postSentNoResponseWithoutKey() { + return builder() + .idempotency(OperationIdempotency.NON_IDEMPOTENT) + .evidence(ExecutionEvidence.SENT_NO_RESPONSE) + .failureCategory(FailureCategory.RESPONSE_TIMEOUT) + .build(); + } + + public static RetryContext postSentNoResponseWithKey() { + return builder() + .idempotency(OperationIdempotency.IDEMPOTENCY_KEY_REQUIRED) + .idempotencyKey(new IdempotencyKey("order-1")) + .evidence(ExecutionEvidence.SENT_NO_RESPONSE) + .failureCategory(FailureCategory.RESPONSE_TIMEOUT) + .build(); + } + + public static RetryContext putOneShotNotSent() { + return builder() + .idempotency(OperationIdempotency.STANDARD_IDEMPOTENT) + .replayability(BodyReplayability.ONE_SHOT) + .evidence(ExecutionEvidence.NOT_SENT) + .failureCategory(FailureCategory.CONNECT) + .build(); + } + + public static RetryContext rateLimitedBeyondDeadline() { + return builder() + .idempotency(OperationIdempotency.STANDARD_IDEMPOTENT) + .evidence(ExecutionEvidence.RESPONSE_RECEIVED) + .failureCategory(FailureCategory.REMOTE_STATUS) + .status(429) + .retryAfter(Duration.ofSeconds(30)) + .remainingDeadline(Duration.ofSeconds(2)) + .build(); + } + + public static RetryContext rateLimitedWithinDeadline() { + return builder() + .idempotency(OperationIdempotency.STANDARD_IDEMPOTENT) + .evidence(ExecutionEvidence.RESPONSE_RECEIVED) + .failureCategory(FailureCategory.REMOTE_STATUS) + .status(429) + .retryAfter(Duration.ofMillis(200)) + .remainingDeadline(Duration.ofSeconds(5)) + .build(); + } + + public static RetryContext status(int status, OperationIdempotency idempotency) { + return builder() + .idempotency(idempotency) + .evidence(ExecutionEvidence.RESPONSE_RECEIVED) + .failureCategory(FailureCategory.REMOTE_STATUS) + .status(status) + .build(); + } + + public static RetryContext firstByteDelivered() { + return builder() + .idempotency(OperationIdempotency.STANDARD_IDEMPOTENT) + .evidence(ExecutionEvidence.PARTIAL_RESPONSE) + .failureCategory(FailureCategory.RESPONSE_TRUNCATED) + .firstByteDelivered(true) + .build(); + } + + public static RetryContext permanentTlsFailure() { + return builder() + .idempotency(OperationIdempotency.STANDARD_IDEMPOTENT) + .evidence(ExecutionEvidence.NOT_SENT) + .failureCategory(FailureCategory.TLS_PERMANENT) + .build(); + } + + /** Mutable assembly helper for the safety matrix. */ + public static final class Builder { + private OperationIdempotency idempotency = OperationIdempotency.STANDARD_IDEMPOTENT; + private Optional idempotencyKey = Optional.empty(); + private boolean idempotencyKeySent; + private BodyReplayability replayability = BodyReplayability.REPLAYABLE; + private ExecutionEvidence evidence = ExecutionEvidence.NOT_SENT; + private FailureCategory failureCategory = FailureCategory.CONNECT; + private Optional status = Optional.empty(); + private Optional retryAfter = Optional.empty(); + private int attempt = 1; + private int maxAttempts = 3; + private boolean firstByteDelivered; + private Duration remainingDeadline = Duration.ofSeconds(5); + private RetryBudgetSnapshot budget = RetryBudgetSnapshot.unlimited(); + private Set transientServerErrors = Set.of(); + private boolean credentialRefreshAvailable = true; + private boolean runtimeDraining; + + public Builder idempotency(OperationIdempotency value) { + this.idempotency = value; + return this; + } + + /** + * Registers a key and, by default, states that it reached the wire. + * + *

A fixture that set a key without sending it would reproduce the very defect the production + * code now rejects, so the sent flag defaults to true and only {@link + * #idempotencyKeySent(boolean)} lowers it. + */ + public Builder idempotencyKey(IdempotencyKey value) { + this.idempotencyKey = Optional.ofNullable(value); + this.idempotencyKeySent = value != null; + return this; + } + + public Builder idempotencyKeySent(boolean value) { + this.idempotencyKeySent = value; + return this; + } + + public Builder replayability(BodyReplayability value) { + this.replayability = value; + return this; + } + + public Builder evidence(ExecutionEvidence value) { + this.evidence = value; + return this; + } + + public Builder failureCategory(FailureCategory value) { + this.failureCategory = value; + return this; + } + + public Builder status(int value) { + this.status = Optional.of(new HttpStatus(value)); + return this; + } + + public Builder retryAfter(Duration value) { + this.retryAfter = Optional.ofNullable(value); + return this; + } + + public Builder attempt(int value) { + this.attempt = value; + return this; + } + + public Builder maxAttempts(int value) { + this.maxAttempts = value; + return this; + } + + public Builder firstByteDelivered(boolean value) { + this.firstByteDelivered = value; + return this; + } + + public Builder remainingDeadline(Duration value) { + this.remainingDeadline = value; + return this; + } + + public Builder budget(RetryBudgetSnapshot value) { + this.budget = value; + return this; + } + + public Builder transientServerErrors(Set value) { + this.transientServerErrors = value; + return this; + } + + public Builder credentialRefreshAvailable(boolean value) { + this.credentialRefreshAvailable = value; + return this; + } + + public Builder runtimeDraining(boolean value) { + this.runtimeDraining = value; + return this; + } + + public RetryContext build() { + return new RetryContext( + idempotency, + idempotencyKey, + idempotencyKeySent, + replayability, + evidence, + failureCategory, + status, + retryAfter, + attempt, + maxAttempts, + firstByteDelivered, + remainingDeadline, + MINIMUM_ATTEMPT, + budget, + transientServerErrors, + credentialRefreshAvailable, + runtimeDraining); + } + } +} diff --git a/src/app-bootstrap/gradle.lockfile b/src/app-bootstrap/gradle.lockfile index 772b084f..2b599403 100644 --- a/src/app-bootstrap/gradle.lockfile +++ b/src/app-bootstrap/gradle.lockfile @@ -1,451 +1,497 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -aopalliance:aopalliance:1.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,redisCompositionTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +aopalliance:aopalliance:1.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +ch.qos.logback:logback-classic:1.5.21=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath ch.qos.logback:logback-classic:1.5.34=sampleFixture -ch.qos.logback:logback-core:1.5.21=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.21=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath ch.qos.logback:logback-core:1.5.34=sampleFixture -com.approvaltests:approvaltests-util:31.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.approvaltests:approvaltests:31.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.ethlo.time:itu:1.14.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.approvaltests:approvaltests-util:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.approvaltests:approvaltests:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.ethlo.time:itu:1.14.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.core:jackson-annotations:2.21=sampleFixture -com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.core:jackson-core:2.21.4=sampleFixture -com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.core:jackson-databind:2.21.4=sampleFixture com.fasterxml.jackson.dataformat:jackson-dataformat-toml:2.21.4=sampleFixture -com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.21.4=sampleFixture com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.21.4=sampleFixture -com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.21.4=sampleFixture com.fasterxml.jackson.module:jackson-module-parameter-names:2.21.4=sampleFixture -com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson:jackson-bom:2.21.4=sampleFixture -com.fasterxml:classmate:1.7.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml:classmate:1.7.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml:classmate:1.7.3=sampleFixture -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -com.github.docker-java:docker-java-api:3.7.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.docker-java:docker-java-transport-zerodep:3.7.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.docker-java:docker-java-transport:3.7.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.github.docker-java:docker-java-api:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.github.f4b6a3:uuid-creator:6.1.1=sampleFixture,testRuntimeClasspath -com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,redisCompositionTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs -com.github.stephenc.jcip:jcip-annotations:1.0-1=sampleFixture,spotbugs -com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -com.google.auto:auto-common:1.2.2=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,redisCompositionTestCompileClasspath,sampleOffTestCompileClasspath,spotbugs,testCompileClasspath -com.google.code.gson:gson:2.13.2=spotbugs -com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,redisCompositionTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath -com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +com.google.android:annotations:4.1.1.4=conditionalTransportTestRuntimeClasspath +com.google.api.grpc:proto-google-common-protos:2.41.0=conditionalTransportTestRuntimeClasspath +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,conditionalTransportTestRuntimeClasspath,sampleOffTestCompileClasspath,spotbugs,testCompileClasspath +com.google.code.gson:gson:2.13.2=conditionalTransportTestRuntimeClasspath,spotbugs +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +com.google.errorprone:error_prone_annotations:2.41.0=conditionalTransportTestRuntimeClasspath,spotbugs com.google.errorprone:error_prone_annotations:2.47.0=checkstyle -com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -com.google.guava:guava:33.5.0-jre=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.2=conditionalTransportTestRuntimeClasspath +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.2.1-jre=conditionalTransportTestRuntimeClasspath +com.google.guava:guava:33.5.0-jre=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor com.google.guava:guava:33.6.0-jre=checkstyle -com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,conditionalTransportTestAnnotationProcessor,conditionalTransportTestRuntimeClasspath,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:2.8=conditionalTransportTestRuntimeClasspath +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.google.protobuf:protobuf-java-util:3.25.5=conditionalTransportTestRuntimeClasspath +com.google.protobuf:protobuf-java:3.25.5=conditionalTransportTestRuntimeClasspath +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.graphql-java:graphql-java:25.0=conditionalTransportTestRuntimeClasspath +com.graphql-java:java-dataloader:6.0.0=conditionalTransportTestRuntimeClasspath com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.9.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.networknt:json-schema-validator:3.0.2=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -com.nimbusds:nimbus-jose-jwt:10.4=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.jayway.jsonpath:json-path:2.9.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.networknt:json-schema-validator:3.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.nimbusds:content-type:2.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.nimbusds:lang-tag:1.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.nimbusds:nimbus-jose-jwt:9.37.4=sampleFixture +com.nimbusds:oauth2-oidc-sdk:11.26.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle -com.squareup.okhttp3:okhttp-jvm:5.2.1=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp-jvm:5.2.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.squareup.okhttp3:okhttp:4.12.0=sampleFixture -com.squareup.okhttp3:okhttp:5.2.1=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -com.squareup.okio:okio-jvm:3.16.1=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:5.2.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.squareup.okio:okio-jvm:3.16.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.squareup.okio:okio-jvm:3.6.0=sampleFixture -com.squareup.okio:okio:3.16.1=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.squareup.okio:okio:3.16.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.squareup.okio:okio:3.6.0=sampleFixture -com.sun.istack:istack-commons-runtime:4.1.2=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -com.tngtech.archunit:archunit-junit5-api:1.3.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=redisCompositionTestRuntimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -com.tngtech.archunit:archunit-junit5-engine:1.3.0=redisCompositionTestRuntimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -com.tngtech.archunit:archunit-junit5:1.3.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.tngtech.archunit:archunit:1.3.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.vaadin.external.google:android-json:0.0.20131108.vaadin1=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.sun.istack:istack-commons-runtime:4.1.2=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.tngtech.archunit:archunit-junit5-api:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.tngtech.archunit:archunit-junit5-engine:1.3.0=sampleOffTestRuntimeClasspath,testRuntimeClasspath +com.tngtech.archunit:archunit-junit5:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.tngtech.archunit:archunit:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.zaxxer:HikariCP:6.3.3=sampleFixture -com.zaxxer:HikariCP:7.0.2=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.zaxxer:HikariCP:7.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath commons-beanutils:commons-beanutils:1.11.0=checkstyle -commons-codec:commons-codec:1.19.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-codec:commons-codec:1.19.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath commons-collections:commons-collections:3.2.2=checkstyle -commons-io:commons-io:2.20.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-io:commons-io:2.20.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.5=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle -io.github.cdimascio:dotenv-java:3.0.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -io.github.resilience4j:resilience4j-bulkhead:2.2.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-circuitbreaker:2.2.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-core:2.2.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-micrometer:2.2.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-ratelimiter:2.2.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-retry:2.2.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.github.resilience4j:resilience4j-timelimiter:2.2.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.lettuce:lettuce-core:6.8.1.RELEASE=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.cdimascio:dotenv-java:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +io.github.resilience4j:resilience4j-bulkhead:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-circuitbreaker:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-core:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-micrometer:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-ratelimiter:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-retry:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.github.resilience4j:resilience4j-timelimiter:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.grpc:grpc-api:1.68.1=conditionalTransportTestRuntimeClasspath +io.grpc:grpc-context:1.68.1=conditionalTransportTestRuntimeClasspath +io.grpc:grpc-core:1.68.1=conditionalTransportTestRuntimeClasspath +io.grpc:grpc-netty-shaded:1.68.1=conditionalTransportTestRuntimeClasspath +io.grpc:grpc-protobuf-lite:1.68.1=conditionalTransportTestRuntimeClasspath +io.grpc:grpc-protobuf:1.68.1=conditionalTransportTestRuntimeClasspath +io.grpc:grpc-services:1.68.1=conditionalTransportTestRuntimeClasspath +io.grpc:grpc-stub:1.68.1=conditionalTransportTestRuntimeClasspath +io.grpc:grpc-util:1.68.1=conditionalTransportTestRuntimeClasspath +io.lettuce:lettuce-core:6.8.1.RELEASE=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.micrometer:context-propagation:1.1.4=sampleFixture -io.micrometer:context-propagation:1.2.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:context-propagation:1.2.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-commons:1.15.12=sampleFixture -io.micrometer:micrometer-commons:1.16.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-core:1.15.12=sampleFixture -io.micrometer:micrometer-core:1.16.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-core:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-jakarta9:1.15.12=sampleFixture -io.micrometer:micrometer-jakarta9:1.16.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-jakarta9:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-observation:1.15.12=sampleFixture -io.micrometer:micrometer-observation:1.16.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-registry-prometheus:1.15.12=sampleFixture -io.micrometer:micrometer-registry-prometheus:1.16.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-registry-prometheus:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-tracing-bridge-otel:1.5.12=sampleFixture -io.micrometer:micrometer-tracing-bridge-otel:1.6.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-tracing-bridge-otel:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-tracing:1.5.12=sampleFixture -io.micrometer:micrometer-tracing:1.6.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-buffer:4.2.7.Final=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-codec-base:4.2.7.Final=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-codec-compression:4.2.7.Final=testRuntimeClasspath -io.netty:netty-codec-dns:4.2.7.Final=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-codec-http2:4.2.7.Final=testRuntimeClasspath -io.netty:netty-codec-http:4.2.7.Final=testRuntimeClasspath -io.netty:netty-codec-marshalling:4.2.7.Final=testRuntimeClasspath -io.netty:netty-codec-protobuf:4.2.7.Final=testRuntimeClasspath -io.netty:netty-codec:4.2.7.Final=testRuntimeClasspath -io.netty:netty-common:4.2.7.Final=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-handler:4.2.7.Final=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-resolver-dns:4.2.7.Final=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-resolver:4.2.7.Final=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-transport-classes-epoll:4.2.7.Final=testRuntimeClasspath -io.netty:netty-transport-native-unix-common:4.2.7.Final=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.netty:netty-transport:4.2.7.Final=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.micrometer:micrometer-tracing:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-buffer:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec-base:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec-classes-quic:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec-compression:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec-dns:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec-http2:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec-http3:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec-http:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec-marshalling:4.2.17.Final=testRuntimeClasspath +io.netty:netty-codec-native-quic:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec-protobuf:4.2.17.Final=testRuntimeClasspath +io.netty:netty-codec-socks:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-codec:4.2.17.Final=testRuntimeClasspath +io.netty:netty-common:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-handler-proxy:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-handler:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-resolver-dns-classes-macos:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-resolver-dns-native-macos:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-resolver-dns:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-resolver:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-transport-classes-epoll:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-transport-native-epoll:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-transport-native-unix-common:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.netty:netty-transport:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.opentelemetry.semconv:opentelemetry-semconv:1.32.0=sampleFixture -io.opentelemetry.semconv:opentelemetry-semconv:1.37.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry.semconv:opentelemetry-semconv:1.37.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-api:1.49.0=sampleFixture -io.opentelemetry:opentelemetry-api:1.55.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-common:1.55.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-api:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-common:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-context:1.49.0=sampleFixture -io.opentelemetry:opentelemetry-context:1.55.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-context:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-exporter-common:1.49.0=sampleFixture -io.opentelemetry:opentelemetry-exporter-common:1.55.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-exporter-common:1.55.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-exporter-otlp-common:1.49.0=sampleFixture -io.opentelemetry:opentelemetry-exporter-otlp-common:1.55.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-exporter-otlp-common:1.55.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-exporter-otlp:1.49.0=sampleFixture -io.opentelemetry:opentelemetry-exporter-otlp:1.55.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-exporter-otlp:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-exporter-sender-okhttp:1.49.0=sampleFixture -io.opentelemetry:opentelemetry-exporter-sender-okhttp:1.55.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-exporter-sender-okhttp:1.55.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-extension-trace-propagators:1.49.0=sampleFixture -io.opentelemetry:opentelemetry-extension-trace-propagators:1.55.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-extension-trace-propagators:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-sdk-common:1.49.0=sampleFixture -io.opentelemetry:opentelemetry-sdk-common:1.55.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-common:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:1.49.0=sampleFixture -io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:1.55.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:1.55.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-sdk-logs:1.49.0=sampleFixture -io.opentelemetry:opentelemetry-sdk-logs:1.55.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-logs:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-sdk-metrics:1.49.0=sampleFixture -io.opentelemetry:opentelemetry-sdk-metrics:1.55.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-metrics:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-sdk-trace:1.49.0=sampleFixture -io.opentelemetry:opentelemetry-sdk-trace:1.55.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk-trace:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-sdk:1.49.0=sampleFixture -io.opentelemetry:opentelemetry-sdk:1.55.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-sdk:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.perfmark:perfmark-api:0.27.0=conditionalTransportTestRuntimeClasspath +io.projectreactor.netty:reactor-netty-core:1.3.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.projectreactor.netty:reactor-netty-http:1.3.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.projectreactor:reactor-core:3.7.19=sampleFixture -io.projectreactor:reactor-core:3.8.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.projectreactor:reactor-core:3.8.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-config:1.3.10=sampleFixture -io.prometheus:prometheus-metrics-config:1.4.3=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-config:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-core:1.3.10=sampleFixture -io.prometheus:prometheus-metrics-core:1.4.3=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-core:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-exposition-formats:1.3.10=sampleFixture -io.prometheus:prometheus-metrics-exposition-formats:1.4.3=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-exposition-formats:1.4.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-exposition-textformats:1.3.10=sampleFixture -io.prometheus:prometheus-metrics-exposition-textformats:1.4.3=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-exposition-textformats:1.4.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-model:1.3.10=sampleFixture -io.prometheus:prometheus-metrics-model:1.4.3=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-model:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-tracer-common:1.3.10=sampleFixture -io.prometheus:prometheus-metrics-tracer-common:1.4.3=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.prometheus:prometheus-metrics-tracer-common:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.smallrye:jandex:3.2.0=sampleFixture io.swagger.core.v3:swagger-annotations-jakarta:2.2.29=sampleFixture -io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.swagger.core.v3:swagger-core-jakarta:2.2.29=sampleFixture -io.swagger.core.v3:swagger-core-jakarta:2.2.38=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-core-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.swagger.core.v3:swagger-models-jakarta:2.2.29=sampleFixture -io.swagger.core.v3:swagger-models-jakarta:2.2.38=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -jakarta.activation:jakarta.activation-api:2.1.4=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.swagger.core.v3:swagger-models-jakarta:2.2.38=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.annotation:jakarta.annotation-api:2.1.1=sampleFixture -jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.inject:jakarta.inject-api:2.0.1=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.inject:jakarta.inject-api:2.0.1=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath jakarta.persistence:jakarta.persistence-api:3.1.0=sampleFixture -jakarta.persistence:jakarta.persistence-api:3.2.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.transaction:jakarta.transaction-api:2.0.1=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.persistence:jakarta.persistence-api:3.2.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.transaction:jakarta.transaction-api:2.0.1=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.validation:jakarta.validation-api:3.0.2=sampleFixture -jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.websocket:jakarta.websocket-api:2.2.0=redisCompositionTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath -jakarta.ws.rs:jakarta.ws.rs-api:4.0.0=redisCompositionTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.websocket:jakarta.websocket-api:2.2.0=sampleOffTestCompileClasspath,testCompileClasspath +jakarta.ws.rs:jakarta.ws.rs-api:4.0.0=sampleOffTestCompileClasspath,testCompileClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=sampleFixture -javax.inject:javax.inject:1=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +javax.inject:javax.inject:1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs -me.paulschwarz:spring-dotenv:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy-agent:1.17.8=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.17.8=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.java.dev.jna:jna:5.18.1=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.logstash.logback:logstash-logback-encoder:8.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.minidev:accessors-smart:2.6.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.minidev:json-smart:2.6.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +me.paulschwarz:spring-dotenv:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy-agent:1.17.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.java.dev.jna:jna:5.18.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.logstash.logback:logstash-logback-encoder:8.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.minidev:accessors-smart:2.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +net.minidev:json-smart:2.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs -org.antlr:antlr4-runtime:4.13.2=checkstyle,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.antlr:antlr4-runtime:4.13.2=checkstyle,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.bcel:bcel:6.12.0=spotbugs -org.apache.commons:commons-compress:1.28.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.commons:commons-lang3:3.20.0=checkstyle,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.commons:commons-compress:1.28.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.commons:commons-lang3:3.20.0=checkstyle,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle +org.apache.httpcomponents.client5:httpclient5:5.5.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.apache.httpcomponents.core5:httpcore5-h2:5.3.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.apache.httpcomponents.core5:httpcore5:5.3.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.apache.httpcomponents:httpclient:4.5.13=checkstyle,testRuntimeClasspath org.apache.httpcomponents:httpcore:4.4.16=checkstyle,testRuntimeClasspath -org.apache.kafka:kafka-clients:4.1.1=redisCompositionTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +org.apache.kafka:kafka-clients:4.1.1=sampleOffTestCompileClasspath,testCompileClasspath org.apache.logging.log4j:log4j-api:2.24.3=sampleFixture -org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-core:2.25.2=spotbugs org.apache.logging.log4j:log4j-to-slf4j:2.24.3=sampleFixture -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.tomcat.embed:tomcat-embed-core:10.1.55=sampleFixture -org.apache.tomcat.embed:tomcat-embed-core:11.0.14=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-core:11.0.14=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.tomcat.embed:tomcat-embed-el:10.1.55=sampleFixture -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.tomcat.embed:tomcat-embed-websocket:10.1.55=sampleFixture -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle -org.apiguardian:apiguardian-api:1.1.2=redisCompositionTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath -org.aspectj:aspectjweaver:1.9.25=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=conditionalTransportTestCompileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +org.aspectj:aspectjweaver:1.9.25=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.aspectj:aspectjweaver:1.9.25.1=sampleFixture -org.assertj:assertj-core:3.27.6=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.awaitility:awaitility:4.3.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.checkerframework:checker-qual:3.49.5=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.6=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.awaitility:awaitility:4.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.checkerframework:checker-qual:3.42.0=conditionalTransportTestRuntimeClasspath +org.checkerframework:checker-qual:3.49.5=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.codehaus.mojo:animal-sniffer-annotations:1.24=conditionalTransportTestRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs -org.eclipse.angus:angus-activation:2.0.3=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.flywaydb:flyway-core:11.14.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.eclipse.angus:angus-activation:2.0.3=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.eclipse.jetty.compression:jetty-compression-common:12.1.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.eclipse.jetty.compression:jetty-compression-gzip:12.1.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.eclipse.jetty:jetty-alpn-client:12.1.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.eclipse.jetty:jetty-client:12.1.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.eclipse.jetty:jetty-http:12.1.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.eclipse.jetty:jetty-io:12.1.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.eclipse.jetty:jetty-util:12.1.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.flywaydb:flyway-core:11.14.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.flywaydb:flyway-core:11.7.2=sampleFixture -org.flywaydb:flyway-database-postgresql:11.14.1=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.flywaydb:flyway-database-postgresql:11.14.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.flywaydb:flyway-database-postgresql:11.7.2=sampleFixture -org.glassfish.jaxb:jaxb-core:4.0.6=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.glassfish.jaxb:jaxb-core:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.glassfish.jaxb:jaxb-core:4.0.9=sampleFixture -org.glassfish.jaxb:jaxb-runtime:4.0.6=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.glassfish.jaxb:jaxb-runtime:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.glassfish.jaxb:jaxb-runtime:4.0.9=sampleFixture -org.glassfish.jaxb:txw2:4.0.6=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.glassfish.jaxb:txw2:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.glassfish.jaxb:txw2:4.0.9=sampleFixture -org.hamcrest:hamcrest:3.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.hdrhistogram:HdrHistogram:2.2.2=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.hamcrest:hamcrest:3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.hdrhistogram:HdrHistogram:2.2.2=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.hibernate.common:hibernate-commons-annotations:7.0.3.Final=sampleFixture -org.hibernate.models:hibernate-models:1.0.1=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.hibernate.models:hibernate-models:1.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.hibernate.orm:hibernate-core:6.6.53.Final=sampleFixture -org.hibernate.orm:hibernate-core:7.1.8.Final=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.hibernate.orm:hibernate-core:7.1.8.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.hibernate.validator:hibernate-validator:8.0.3.Final=sampleFixture -org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.jboss.logging:jboss-logging:3.6.3.Final=sampleFixture org.jetbrains.kotlin:kotlin-stdlib-common:1.9.25=sampleFixture org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.25=sampleFixture org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.25=sampleFixture org.jetbrains.kotlin:kotlin-stdlib:1.9.25=sampleFixture -org.jetbrains.kotlin:kotlin-stdlib:2.2.21=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.2.21=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.jetbrains:annotations:13.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture -org.jetbrains:annotations:17.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,productionRuntimeClasspath,redisCompositionTestAnnotationProcessor,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestAnnotationProcessor,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=redisCompositionTestRuntimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-testkit:6.0.1=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit:junit-bom:6.0.1=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jetbrains:annotations:17.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,conditionalTransportTestAnnotationProcessor,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestAnnotationProcessor,functionalTestCompileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestAnnotationProcessor,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.1=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.1=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.1=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.1=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.1=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.1=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-testkit:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.0.1=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs -org.latencyutils:LatencyUtils:2.0.3=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.mockito:mockito-core:5.20.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.mockito:mockito-junit-jupiter:5.20.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.objenesis:objenesis:3.3=redisCompositionTestRuntimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.openapitools:jackson-databind-nullable:0.2.6=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.opentest4j:opentest4j:1.3.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,redisCompositionTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath -org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,redisCompositionTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath -org.osgi:org.osgi.resource:1.0.0=compileClasspath,redisCompositionTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath -org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,redisCompositionTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +org.latencyutils:LatencyUtils:2.0.3=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.mockito:mockito-core:5.20.0=mockitoAgent,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.openapitools:jackson-databind-nullable:0.2.6=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +org.osgi:org.osgi.resource:1.0.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath org.ow2.asm:asm-analysis:9.10.1=spotbugs org.ow2.asm:asm-commons:9.10.1=spotbugs org.ow2.asm:asm-tree:9.10.1=spotbugs org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs -org.ow2.asm:asm:9.7.1=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.pcollections:pcollections:4.0.1=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +org.ow2.asm:asm:9.7.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor org.postgresql:postgresql:42.7.11=sampleFixture -org.postgresql:postgresql:42.7.8=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.reactivestreams:reactive-streams:1.0.4=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.postgresql:postgresql:42.7.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.reactivestreams:reactive-streams:1.0.4=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.reflections:reflections:0.10.2=checkstyle -org.rnorth.duct-tape:duct-tape:1.0.8=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.skyscreamer:jsonassert:1.5.3=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.rnorth.duct-tape:duct-tape:1.0.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.skyscreamer:jsonassert:1.5.3=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:jul-to-slf4j:2.0.18=sampleFixture -org.slf4j:slf4j-api:2.0.17=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.18=sampleFixture org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j org.springdoc:springdoc-openapi-starter-common:2.8.6=sampleFixture -org.springdoc:springdoc-openapi-starter-common:3.0.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springdoc:springdoc-openapi-starter-common:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.6=sampleFixture -org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-actuator-autoconfigure:3.5.16=sampleFixture -org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-actuator:3.5.16=sampleFixture -org.springframework.boot:spring-boot-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-autoconfigure:3.5.16=sampleFixture -org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor -org.springframework.boot:spring-boot-data-commons:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-data-jpa-test:4.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-data-jpa:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-health:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-hibernate:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-client:4.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jdbc-test:4.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jpa-test:4.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jpa:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-micrometer-observation:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-restclient:4.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-resttestclient:4.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-jpa-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-graphql:4.0.0=conditionalTransportTestRuntimeClasspath +org.springframework.boot:spring-boot-health:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-hibernate:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-client:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jdbc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jpa-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-micrometer-observation:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-restclient:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-actuator:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-data-jpa:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-flyway:4.0.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-flyway:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-graphql:4.0.0=conditionalTransportTestRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jdbc:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-jdbc:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jdbc:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-json:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-json:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-json:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-logging:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-oauth2-resource-server:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-security:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-test:4.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-validation:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-web:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-web:4.0.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc:4.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-web:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.0=conditionalTransportTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-websocket:4.0.0=conditionalTransportTestRuntimeClasspath org.springframework.boot:spring-boot-starter:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test:4.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-websocket:4.0.0=conditionalTransportTestRuntimeClasspath org.springframework.boot:spring-boot:3.5.16=sampleFixture -org.springframework.boot:spring-boot:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.cloud:spring-cloud-context:4.1.4=redisCompositionTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath +org.springframework.boot:spring-boot:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.cloud:spring-cloud-context:4.1.4=sampleOffTestCompileClasspath,testCompileClasspath org.springframework.data:spring-data-commons:3.5.13=sampleFixture -org.springframework.data:spring-data-commons:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.data:spring-data-jpa:3.5.13=sampleFixture -org.springframework.data:spring-data-jpa:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.data:spring-data-keyvalue:4.0.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.data:spring-data-redis:4.0.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.data:spring-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.graphql:spring-graphql:2.0.0=conditionalTransportTestRuntimeClasspath org.springframework.integration:spring-integration-core:6.5.10=sampleFixture -org.springframework.integration:spring-integration-core:7.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.integration:spring-integration-core:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.integration:spring-integration-jdbc:6.5.10=sampleFixture -org.springframework.integration:spring-integration-jdbc:7.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.integration:spring-integration-jdbc:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.retry:spring-retry:2.0.13=sampleFixture org.springframework.security:spring-security-config:6.5.11=sampleFixture -org.springframework.security:spring-security-config:7.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-config:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-core:6.5.11=sampleFixture -org.springframework.security:spring-security-core:7.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-core:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-crypto:6.5.11=sampleFixture -org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-client:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-oauth2-core:6.5.11=sampleFixture -org.springframework.security:spring-security-oauth2-core:7.0.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-oauth2-jose:6.5.11=sampleFixture -org.springframework.security:spring-security-oauth2-jose:7.0.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-jose:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.security:spring-security-oauth2-resource-server:6.5.11=sampleFixture -org.springframework.security:spring-security-oauth2-resource-server:7.0.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.security:spring-security-test:7.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-resource-server:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-test:7.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-web:6.5.11=sampleFixture -org.springframework.security:spring-security-web:7.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.session:spring-session-core:4.0.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.session:spring-session-data-redis:4.0.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-web:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.session:spring-session-core:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework:spring-aop:6.2.19=sampleFixture -org.springframework:spring-aop:7.0.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-aspects:6.2.19=sampleFixture -org.springframework:spring-aspects:7.0.1=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aspects:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-beans:6.2.19=sampleFixture -org.springframework:spring-beans:7.0.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context-support:7.0.1=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-context:6.2.19=sampleFixture -org.springframework:spring-context:7.0.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-core:6.2.19=sampleFixture -org.springframework:spring-core:7.0.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-expression:6.2.19=sampleFixture -org.springframework:spring-expression:7.0.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-jcl:6.2.19=sampleFixture org.springframework:spring-jdbc:6.2.19=sampleFixture -org.springframework:spring-jdbc:7.0.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-jdbc:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-messaging:6.2.19=sampleFixture -org.springframework:spring-messaging:7.0.1=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-messaging:7.0.1=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-orm:6.2.19=sampleFixture -org.springframework:spring-orm:7.0.1=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-oxm:7.0.1=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework:spring-test:7.0.1=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-orm:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-tx:6.2.19=sampleFixture -org.springframework:spring-tx:7.0.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-tx:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-web:6.2.19=sampleFixture -org.springframework:spring-web:7.0.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webflux:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework:spring-webmvc:6.2.19=sampleFixture -org.springframework:spring-webmvc:7.0.1=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-websocket:7.0.1=redisCompositionTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath -org.testcontainers:testcontainers-database-commons:2.0.2=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-jdbc:2.0.2=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-junit-jupiter:2.0.2=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-postgresql:2.0.2=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers:2.0.2=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.1=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-websocket:7.0.1=conditionalTransportTestRuntimeClasspath,sampleOffTestCompileClasspath,testCompileClasspath +org.testcontainers:testcontainers-database-commons:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-jdbc:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-junit-jupiter:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-postgresql:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs -org.xmlunit:xmlunit-core:2.10.4=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.xmlunit:xmlunit-core:2.10.4=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.yaml:snakeyaml:2.4=sampleFixture -org.yaml:snakeyaml:2.5=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -redis.clients.authentication:redis-authx-core:0.1.1-beta2=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=compileClasspath,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +redis.clients.authentication:redis-authx-core:0.1.1-beta2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath software.amazon.awssdk:annotations:2.30.0=testRuntimeClasspath software.amazon.awssdk:apache-client:2.30.0=testRuntimeClasspath software.amazon.awssdk:arns:2.30.0=testRuntimeClasspath @@ -476,7 +522,7 @@ software.amazon.awssdk:sdk-core:2.30.0=testRuntimeClasspath software.amazon.awssdk:third-party-jackson-core:2.30.0=testRuntimeClasspath software.amazon.awssdk:utils:2.30.0=testRuntimeClasspath software.amazon.eventstream:eventstream:1.0.1=testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.0.2=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.0.2=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.0.2=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath empty=developmentOnly,testAndDevelopmentOnly diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/DynamicTargetAutoConfiguration.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/DynamicTargetAutoConfiguration.java new file mode 100644 index 00000000..29b9348d --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/DynamicTargetAutoConfiguration.java @@ -0,0 +1,82 @@ +package dev.caskeleton.bootstrap.autoconfigure.httpclient; + +import dev.caskeleton.adapter.outbound.httpclient.dynamic.DefaultDynamicTargetGateway; +import dev.caskeleton.adapter.outbound.httpclient.dynamic.DynamicCredentialBinding; +import dev.caskeleton.adapter.outbound.httpclient.dynamic.DynamicTargetGateway; +import dev.caskeleton.adapter.outbound.httpclient.dynamic.DynamicTargetPolicy; +import dev.caskeleton.adapter.outbound.httpclient.dynamic.DynamicTargetPolicyName; +import dev.caskeleton.adapter.outbound.httpclient.dynamic.IpAddressClassifier; +import dev.caskeleton.adapter.outbound.httpclient.dynamic.ValidatedDnsResolver; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry; +import dev.caskeleton.adapter.outbound.httpclient.restclient.BlockingAttemptExecutor; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * H3 Dynamic Target wiring (design §9.4, §22). + * + *

With no configured policy the gateway exists but can reach nothing: every call needs a named + * policy, and an unregistered name is an error rather than a permissive default. + */ +@Configuration(proxyBeanMethods = false) +public class DynamicTargetAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(name = "dynamicTargetPolicies") + Map dynamicTargetPolicies( + HttpClientPlatformSettings properties) { + Map policies = new LinkedHashMap<>(); + for (HttpClientPlatformSettings.DynamicTargetSettings target : properties.dynamicTargets()) { + DynamicTargetPolicyName name = new DynamicTargetPolicyName(target.name()); + policies.put( + name, + new DynamicTargetPolicy( + name, + Set.copyOf(target.allowedSchemes()), + Set.copyOf(target.allowedPorts()), + Set.copyOf(target.allowedHostSuffixes()), + Set.copyOf(target.allowedHosts()), + target.maxRedirectHops(), + target.tracePropagation(), + List.copyOf(target.blockedCidrs()))); + } + return Map.copyOf(policies); + } + + @Bean + @ConditionalOnMissingBean(name = "dynamicTargetResolvers") + Map dynamicTargetResolvers( + HttpClientPlatformSettings properties) { + Map resolvers = new LinkedHashMap<>(); + for (HttpClientPlatformSettings.DynamicTargetSettings target : properties.dynamicTargets()) { + resolvers.put( + new DynamicTargetPolicyName(target.name()), + new ValidatedDnsResolver(new IpAddressClassifier(target.blockedCidrs()))); + } + return Map.copyOf(resolvers); + } + + @Bean + @ConditionalOnMissingBean + DynamicTargetGateway dynamicTargetGateway( + Map dynamicTargetPolicies, + Map dynamicTargetResolvers, + ClientRuntimeRegistry runtimes, + BlockingAttemptExecutor executor, + List dynamicCredentialBindings, + Function httpClientSecretResolver) { + return new DefaultDynamicTargetGateway( + dynamicTargetPolicies, + dynamicTargetResolvers, + runtimes, + executor, + dynamicCredentialBindings, + httpClientSecretResolver); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientActuatorEndpoint.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientActuatorEndpoint.java new file mode 100644 index 00000000..e1b27440 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientActuatorEndpoint.java @@ -0,0 +1,71 @@ +package dev.caskeleton.bootstrap.autoconfigure.httpclient; + +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntime; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import org.springframework.boot.actuate.endpoint.annotation.Endpoint; +import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; + +/** + * Operational view of the configured HTTP clients (design §27.3). + * + *

The exposed set is deliberately narrow. Base URL, credential values, trust store paths, and + * resolved IPs are absent because an actuator endpoint is reachable by more people than a secret + * store is, and "read-only" is not the same as "safe to publish". + */ +@Endpoint(id = "httpclients") +public class HttpClientActuatorEndpoint { + + private final ClientRuntimeRegistry runtimes; + + public HttpClientActuatorEndpoint(ClientRuntimeRegistry runtimes) { + this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry"); + } + + @ReadOperation + public Map clients() { + Map report = new TreeMap<>(); + runtimes + .names() + .forEach( + name -> { + ClientRuntime runtime = runtimes.current(name); + ClientProfile profile = runtime.profile(); + Map entry = new LinkedHashMap<>(); + entry.put("generation", runtime.generation().value()); + entry.put("state", runtime.state().name()); + entry.put("transport", profile.transport().name()); + entry.put("api", profile.api().name()); + entry.put( + "protocols", profile.protocols().stream().map(Enum::name).sorted().toList()); + entry.put("activeLeases", runtime.activeLeases()); + entry.put("maxTotalConnections", profile.pool().maxTotalConnections()); + entry.put("credentialType", profile.authentication().type().name()); + entry.put("tlsProfileId", profile.tls().profileId().orElse("jvm-default")); + entry.put("redirectEnabled", profile.redirect().enabled()); + entry.put("retryPolicy", profile.retry().policy()); + entry.put("capabilityWarnings", capabilityWarnings(profile)); + report.put(name.value(), entry); + }); + return Map.copyOf(report); + } + + private List capabilityWarnings(ClientProfile profile) { + List warnings = new java.util.ArrayList<>(); + if (profile.experimentalAcknowledgement().isPresent()) { + warnings.add("EXPERIMENTAL_TRANSPORT_ACKNOWLEDGED"); + } + if (profile.redirect().enabled() && profile.redirect().allowCrossOrigin()) { + warnings.add("CROSS_ORIGIN_REDIRECT_ENABLED"); + } + if (profile.observability().bodyLogging()) { + warnings.add("BODY_LOGGING_ENABLED"); + } + return List.copyOf(warnings); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientAuthenticationAutoConfiguration.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientAuthenticationAutoConfiguration.java new file mode 100644 index 00000000..066d68ac --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientAuthenticationAutoConfiguration.java @@ -0,0 +1,88 @@ +package dev.caskeleton.bootstrap.autoconfigure.httpclient; + +import dev.caskeleton.adapter.outbound.httpclient.auth.ApiKeyHeaderCredentialProvider; +import dev.caskeleton.adapter.outbound.httpclient.auth.BasicCredentialProvider; +import dev.caskeleton.adapter.outbound.httpclient.auth.CredentialProviderRegistry; +import dev.caskeleton.adapter.outbound.httpclient.auth.NoAuthCredentialProvider; +import dev.caskeleton.adapter.outbound.httpclient.auth.OAuth2CredentialProvider; +import dev.caskeleton.adapter.outbound.httpclient.auth.ReactiveCredentialProviderRegistry; +import dev.caskeleton.adapter.outbound.httpclient.auth.ReactiveRequestCredentialProvider; +import dev.caskeleton.adapter.outbound.httpclient.auth.StaticBearerCredentialProvider; +import java.time.Clock; +import java.util.Set; +import java.util.function.Function; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; + +/** + * Credential providers for the declared authentication types (design §20). + * + *

Secret retrieval is an injected function, not a file path in configuration: design §21.2 + * forbids key material in config files, and this keeps the platform unaware of where secrets live. + * OAuth2 is registered only when an authorized client manager exists, so an unconfigured deployment + * fails at startup validation rather than at the first call. + */ +@Configuration(proxyBeanMethods = false) +public class HttpClientAuthenticationAutoConfiguration { + + /** Header names an API-key profile may use; anything else is rejected (design §20.1). */ + private static final Set ALLOWED_API_KEY_HEADERS = + Set.of("X-Api-Key", "Api-Key", "X-Client-Key"); + + @Bean + @ConditionalOnMissingBean(name = "httpClientSecretResolver") + Function httpClientSecretResolver() { + return reference -> { + throw new IllegalStateException( + "no http client secret resolver is configured for reference '" + reference + "'"); + }; + } + + @Bean + @ConditionalOnMissingBean + CredentialProviderRegistry httpClientCredentialProviderRegistry( + Function httpClientSecretResolver, + ObjectProvider authorizedClientManager, + Clock clock) { + CredentialProviderRegistry registry = + new CredentialProviderRegistry() + .register(new NoAuthCredentialProvider()) + .register(new BasicCredentialProvider(httpClientSecretResolver)) + .register( + new ApiKeyHeaderCredentialProvider( + ALLOWED_API_KEY_HEADERS, httpClientSecretResolver)) + .register(new StaticBearerCredentialProvider(httpClientSecretResolver)); + authorizedClientManager.ifAvailable( + manager -> registry.register(new OAuth2CredentialProvider(manager, clock))); + return registry; + } + + /** + * The reactive counterpart of the blocking registry. + * + *

This used to be {@code fromNonBlocking(new NoAuthCredentialProvider())} — unconditionally, + * for every profile. A reactive profile that declared BASIC, an API key or a static bearer + * therefore sent no credential at all, and nothing said so: the request went out anonymous and + * the 401 came back looking like an upstream problem. + * + *

The three mechanisms registered here resolve a secret and format a header; that is + * computation, not I/O, so wrapping the synchronous provider is honest rather than a disguised + * block. OAuth2 is deliberately absent — a token load is a network round trip and there is no + * non-blocking implementation of it, so {@code HttpClientStartupValidator} refuses the + * combination at startup instead of letting it fail on the first call. + */ + @Bean + @ConditionalOnMissingBean + ReactiveRequestCredentialProvider httpClientReactiveCredentialProvider( + Function httpClientSecretResolver) { + return new ReactiveCredentialProviderRegistry() + .registerNonBlocking(new NoAuthCredentialProvider()) + .registerNonBlocking(new BasicCredentialProvider(httpClientSecretResolver)) + .registerNonBlocking( + new ApiKeyHeaderCredentialProvider(ALLOWED_API_KEY_HEADERS, httpClientSecretResolver)) + .registerNonBlocking(new StaticBearerCredentialProvider(httpClientSecretResolver)); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientEnvironmentKeys.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientEnvironmentKeys.java new file mode 100644 index 00000000..328e9ef8 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientEnvironmentKeys.java @@ -0,0 +1,178 @@ +package dev.caskeleton.bootstrap.autoconfigure.httpclient; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.RecordComponent; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.Environment; +import org.springframework.core.env.PropertySource; +import org.springframework.core.env.SystemEnvironmentPropertySource; + +/** + * The environment surface of {@link HttpClientPlatformSettings}, derived from the record tree. + * + *

This exists because strict binding cannot see a misspelled environment variable. Spring + * resolves {@code APP_HTTPCLIENT_CLIENTS_0_BASE_URL} to {@code app.httpclient.clients[0].base-url} + * by mapping the requested property name back to an environment name, but it enumerates + * the same variable as {@code app.httpclient.clients[0].base.url} — underscores become dots, never + * hyphens. {@code NoUnboundElementsBindHandler} compares against that enumeration, so pointing it + * at the system environment reports correctly-spelled variables as unbound while still saying + * nothing about a genuinely misspelled one. The check therefore runs the other way round: derive + * the names the settings tree accepts, and reject an {@code APP_HTTPCLIENT_} variable that is not + * among them. + * + *

Without it {@code APP_HTTPCLIENT_CLIENTS_0_TIMEUOT_TOTAL_CALL} would leave the client running + * the default four-second budget while the deployment's manifest says six, and nothing anywhere + * would mention it. + */ +final class HttpClientEnvironmentKeys { + + /** Environment form of {@link HttpClientPlatformSettings#PREFIX}, with its trailing separator. */ + static final String ENV_PREFIX = "APP_HTTPCLIENT_"; + + /** Deepest list nesting {@link #indexLetter} can name. */ + private static final int MAXIMUM_LIST_DEPTH = 1; + + private static final java.util.regex.Pattern SEGMENT_SEPARATOR = + java.util.regex.Pattern.compile("_"); + + private HttpClientEnvironmentKeys() {} + + /** + * Maps each leaf property path to the environment variable template that sets it. + * + *

A list index is rendered as a letter rather than a number, so the result describes a + * template rather than one deployment's cardinality: {@code N} for the outermost list, {@code M} + * inside it. + * + * @return leaf property path to environment variable template, in declaration order + */ + static Map fieldToEnvTemplate() { + Map templates = new LinkedHashMap<>(); + collect(HttpClientPlatformSettings.class, "APP_HTTPCLIENT", "", 0, templates); + return templates; + } + + /** + * Refuses an {@code APP_HTTPCLIENT_} variable that maps to no field. + * + * @param environment the environment to inspect; sources other than the system environment are + * covered by strict binding and are not re-checked here + * @throws IllegalStateException listing every unrecognised variable, so an operator fixes them in + * one pass rather than one restart at a time + */ + static void rejectUnrecognised(Environment environment) { + if (!(environment instanceof ConfigurableEnvironment configurable)) { + return; + } + Set accepted = Set.copyOf(fieldToEnvTemplate().values()); + Set unrecognised = new TreeSet<>(); + for (PropertySource source : configurable.getPropertySources()) { + if (!(source instanceof SystemEnvironmentPropertySource environmentSource)) { + continue; + } + for (String name : environmentSource.getPropertyNames()) { + String canonical = name.toUpperCase(Locale.ROOT); + if (canonical.startsWith(ENV_PREFIX) && !accepted.contains(asTemplate(canonical))) { + unrecognised.add(name); + } + } + } + if (!unrecognised.isEmpty()) { + throw new IllegalStateException( + "unrecognised HTTP client environment variables " + + unrecognised + + "; see docs/httpclient/env-fields.yaml for the accepted names"); + } + } + + /** + * Replaces each index with the letter its depth uses, so a concrete variable can be looked up. + * + *

Only a wholly numeric segment is an index. A field whose name happens to end in digits stays + * literal, which is why {@code DRAFT12} would not be mistaken for a position. + */ + private static String asTemplate(String environmentName) { + // -1 keeps trailing empty segments, so a stray trailing underscore stays visible and is + // rejected rather than normalised away into a name that happens to match. + String[] segments = SEGMENT_SEPARATOR.split(environmentName, -1); + int listDepth = 0; + StringBuilder template = new StringBuilder(environmentName.length()); + for (int index = 0; index < segments.length; index++) { + if (index > 0) { + template.append('_'); + } + String segment = segments[index]; + if (!segment.isEmpty() && segment.chars().allMatch(Character::isDigit)) { + if (listDepth > MAXIMUM_LIST_DEPTH) { + // Deeper than any field can express, so it cannot name one. Left literal, which no + // template matches, so the variable is reported rather than silently accepted. + template.append(segment); + continue; + } + template.append(indexLetter(listDepth)); + listDepth++; + } else { + template.append(segment); + } + } + return template.toString(); + } + + private static void collect( + Class type, String envPrefix, String pathPrefix, int listDepth, Map into) { + for (RecordComponent component : type.getRecordComponents()) { + String property = camelToKebab(component.getName()); + String path = pathPrefix.isEmpty() ? property : pathPrefix + "." + property; + String env = envPrefix + "_" + camelToScreamingSnake(component.getName()); + Class componentType = component.getType(); + if (componentType.isRecord()) { + collect(componentType, env, path, listDepth, into); + continue; + } + if (List.class.isAssignableFrom(componentType)) { + String index = indexLetter(listDepth); + Class element = elementTypeOf(component); + if (element != null && element.isRecord()) { + collect(element, env + "_" + index, path + "[" + index + "]", listDepth + 1, into); + } else { + into.put(path + "[" + index + "]", env + "_" + index); + } + continue; + } + into.put(path, env); + } + } + + /** {@code N} for the outermost list, {@code M} inside it. No settings field nests deeper. */ + private static String indexLetter(int depth) { + return switch (depth) { + case 0 -> "N"; + case 1 -> "M"; + default -> + throw new IllegalStateException( + "list nesting deeper than the environment template can express"); + }; + } + + private static Class elementTypeOf(RecordComponent component) { + if (component.getGenericType() instanceof ParameterizedType parameterized + && parameterized.getActualTypeArguments()[0] instanceof Class element) { + return element; + } + return null; + } + + private static String camelToKebab(String name) { + return name.replaceAll("([a-z0-9])([A-Z])", "$1-$2").toLowerCase(Locale.ROOT); + } + + private static String camelToScreamingSnake(String name) { + return name.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toUpperCase(Locale.ROOT); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientManagementAutoConfiguration.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientManagementAutoConfiguration.java new file mode 100644 index 00000000..d50a28ef --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientManagementAutoConfiguration.java @@ -0,0 +1,22 @@ +package dev.caskeleton.bootstrap.autoconfigure.httpclient; + +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry; +import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Registers the {@code httpclients} actuator endpoint only when it is actually exposed (design + * §27.3). + */ +@Configuration(proxyBeanMethods = false) +public class HttpClientManagementAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + @ConditionalOnAvailableEndpoint(endpoint = HttpClientActuatorEndpoint.class) + HttpClientActuatorEndpoint httpClientActuatorEndpoint(ClientRuntimeRegistry runtimes) { + return new HttpClientActuatorEndpoint(runtimes); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientObservationAutoConfiguration.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientObservationAutoConfiguration.java new file mode 100644 index 00000000..306214e6 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientObservationAutoConfiguration.java @@ -0,0 +1,36 @@ +package dev.caskeleton.bootstrap.autoconfigure.httpclient; + +import dev.caskeleton.adapter.outbound.httpclient.observation.HttpClientTagPolicy; +import dev.caskeleton.adapter.outbound.httpclient.restclient.BlockingExecutionSupport; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.time.Clock; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Observation vocabulary and the shared execution collaborators (design §25). + * + *

A registry is always present: the platform records the separation between logical calls and + * physical attempts unconditionally, because that distinction is what makes a retry-heavy incident + * readable afterwards. + */ +@Configuration(proxyBeanMethods = false) +public class HttpClientObservationAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + HttpClientTagPolicy httpClientTagPolicy() { + return HttpClientTagPolicy.standard(); + } + + @Bean + @ConditionalOnMissingBean + BlockingExecutionSupport httpClientExecutionSupport( + Clock clock, ObjectProvider meterRegistry) { + return BlockingExecutionSupport.standard( + clock, meterRegistry.getIfAvailable(SimpleMeterRegistry::new)); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformAutoConfiguration.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformAutoConfiguration.java new file mode 100644 index 00000000..619177e5 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformAutoConfiguration.java @@ -0,0 +1,61 @@ +package dev.caskeleton.bootstrap.autoconfigure.httpclient; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.core.env.Environment; + +/** + * The one entry point through which the HTTP Client platform exists at all. + * + *

This is an auto-configuration rather than a component-scanned {@code @Configuration}, and it + * lives in a package the composition root's scan explicitly excludes. That is the whole point: when + * the master switch is absent or false the class is never processed, so none of the configurations + * it imports are discovered either. No property is bound, no transport provider is constructed, no + * connection pool, TLS context, credential, thread or gateway exists, and a malformed HTTP setting + * in a deployment that never wanted outbound HTTP cannot fail its startup. + * + *

The previous shape had nine independently annotated {@code @Configuration} classes inside the + * scanned package. Wrapping them in a conditional parent would have changed nothing — the component + * scanner finds each child on its own — so the children had to leave the scan together with the + * switch. That is why this is a package move rather than an annotation. + * + *

The actuator endpoint is imported here for the same reason, so a deployment cannot expose a + * report about a platform it never enabled. + */ +@AutoConfiguration +@ConditionalOnProperty( + prefix = HttpClientPlatformSettings.PREFIX, + name = "enabled", + havingValue = "true") +@Import({ + HttpClientResilienceAutoConfiguration.class, + HttpClientSecurityAutoConfiguration.class, + HttpClientAuthenticationAutoConfiguration.class, + HttpClientObservationAutoConfiguration.class, + HttpClientTransportAutoConfiguration.class, + HttpClientProfileAutoConfiguration.class, + HttpServiceClientAutoConfiguration.class, + DynamicTargetAutoConfiguration.class, + HttpClientManagementAutoConfiguration.class +}) +public class HttpClientPlatformAutoConfiguration { + + /** + * Binds the platform settings once the master switch has already been proven true. + * + *

Binding is deliberately not delegated to {@code @ConfigurationPropertiesScan}: that would + * bind — and reject — HTTP client detail settings in deployments that never switched the + * capability on. + * + * @param environment the property sources to bind from + * @return the strictly bound settings + */ + @Bean + @ConditionalOnMissingBean + HttpClientPlatformSettings httpClientPlatformSettings(Environment environment) { + return HttpClientPlatformSettingsBinder.bind(environment); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformSettings.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformSettings.java new file mode 100644 index 00000000..46e4298d --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformSettings.java @@ -0,0 +1,241 @@ +package dev.caskeleton.bootstrap.autoconfigure.httpclient; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.springframework.boot.context.properties.bind.DefaultValue; + +/** + * The whole {@code app.httpclient} surface, bound once, strictly, and only while the platform is + * on. + * + *

Deliberately not annotated {@code @ConfigurationProperties}. The composition root's + * {@code @ConfigurationPropertiesScan} is not selective and cannot be made selective per + * deployment, so an annotated class is registered — and bound, and able to fail startup — in every + * deployment, including one that never makes an outbound HTTP call. That is precisely the coupling + * the master switch exists to remove, so binding is done by {@link + * HttpClientPlatformSettingsBinder} from inside the gated auto-configuration and "off" means "never + * bound". + * + *

Clients and dynamic targets are indexed lists carrying their own {@code name} rather than maps + * keyed by name. A map key becomes a segment of the environment variable name, and the relaxed + * binder normalises that segment: {@code payment-api} and {@code payment_api} arrive as the same + * {@code PAYMENT_API}, so one profile would replace the other with nothing said about the one that + * was lost. Carrying the name as a value makes the collision visible, and the compact constructor + * below rejects it. + * + *

Defaults are deliberately unhelpful for production: the design forbids a profile springing + * into existence with generous framework defaults, so anything a production profile must state — + * hosts, body limits, TLS profile — has no usable default and is caught by startup validation + * instead. + * + * @param enabled master switch for the whole capability + * @param clients the Named Client Profiles this deployment declares + * @param dynamicTargets the Dynamic Target policies this deployment declares + */ +public record HttpClientPlatformSettings( + @DefaultValue("false") boolean enabled, + @DefaultValue List clients, + @DefaultValue List dynamicTargets) { + + /** Configuration prefix; the canonical environment form is {@code APP_HTTPCLIENT_*}. */ + public static final String PREFIX = "app.httpclient"; + + public HttpClientPlatformSettings { + clients = List.copyOf(clients == null ? List.of() : clients); + dynamicTargets = List.copyOf(dynamicTargets == null ? List.of() : dynamicTargets); + if (enabled && clients.isEmpty()) { + // An active platform with nothing to call still holds transport providers, a resilience + // registry and five caller-facing gateways that no caller can reach. That is not a working + // deployment with an empty configuration; it is a configuration mistake with a running cost. + throw new IllegalStateException( + "HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS: " + + PREFIX + + ".enabled is true but no client is declared under " + + PREFIX + + ".clients[*]"); + } + requireDistinctNames(clients.stream().map(ClientSettings::name).toList(), PREFIX + ".clients"); + requireDistinctNames( + dynamicTargets.stream().map(DynamicTargetSettings::name).toList(), + PREFIX + ".dynamic-targets"); + } + + /** + * Rejects blank, duplicate and environment-colliding names. + * + *

The normalised form is what an operator has to type as an environment variable segment, so + * two names that share it cannot both be configured from the environment even though they are + * distinct as properties. + */ + private static void requireDistinctNames(List names, String where) { + List seen = new ArrayList<>(); + Map byNormalisedForm = new LinkedHashMap<>(); + for (String name : names) { + if (name == null || name.isBlank()) { + throw new IllegalStateException(where + "[*].name must be non-blank"); + } + if (seen.contains(name)) { + throw new IllegalStateException(where + " declares '" + name + "' more than once"); + } + seen.add(name); + String normalised = name.toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9]", ""); + String previous = byNormalisedForm.putIfAbsent(normalised, name); + if (previous != null) { + throw new IllegalStateException( + where + + " declares '" + + previous + + "' and '" + + name + + "', which normalise to the same environment variable segment '" + + normalised + + "'; one would silently replace the other"); + } + } + } + + /** + * One Named Client Profile as written in configuration. + * + * @param name the profile name callers address; carried as a value, not as a map key + */ + public record ClientSettings( + String name, + @DefaultValue("TRUSTED") String mode, + String baseUrl, + @DefaultValue List allowedHosts, + @DefaultValue List allowedPorts, + @DefaultValue("REST_CLIENT") String api, + @DefaultValue("APACHE") String transport, + // The default transport is Apache, whose classic client is HTTP/1.1 only. A profile that + // wants HTTP/2 declares it together with a transport that can deliver it. + @DefaultValue({"HTTP_1_1"}) List protocols, + @DefaultValue Pool pool, + @DefaultValue Timeout timeout, + @DefaultValue Redirect redirect, + @DefaultValue Request request, + @DefaultValue Response response, + @DefaultValue Authentication authentication, + @DefaultValue Retry retry, + @DefaultValue Observability observability, + @DefaultValue Tls tls, + @DefaultValue Proxy proxy, + String experimentalAcknowledgement) {} + + /** + * One Dynamic Target policy (design §22). + * + *

Dynamic Target policies are configured separately from Named Client Profiles on purpose: + * they are a different security boundary, and sharing a configuration block would invite sharing + * a credential. Defaults are the most restrictive useful setting. + * + * @param name the policy name a caller must quote; an unregistered name is an error rather than a + * permissive default + */ + public record DynamicTargetSettings( + String name, + @DefaultValue({"https"}) List allowedSchemes, + @DefaultValue({"443"}) List allowedPorts, + @DefaultValue List allowedHostSuffixes, + @DefaultValue List allowedHosts, + @DefaultValue("0") int maxRedirectHops, + @DefaultValue("false") boolean tracePropagation, + @DefaultValue List blockedCidrs) {} + + /** Connection pool budget (design §14.2). */ + public record Pool( + @DefaultValue("50") int maxTotalConnections, + @DefaultValue("25") int maxConnectionsPerRoute, + @DefaultValue("100") int maxPendingAcquires, + @DefaultValue("200ms") Duration pendingAcquireTimeout, + @DefaultValue("30s") Duration maxIdleTime, + @DefaultValue("5m") Duration maxLifeTime, + @DefaultValue("5s") Duration validateAfterInactivity, + @DefaultValue("15s") Duration evictionInterval, + @DefaultValue("5s") Duration shutdownTimeout, + @DefaultValue("false") boolean requiresRoutePool, + @DefaultValue("false") boolean requiresBoundedPendingQueue) {} + + /** Stage timeouts and total call budget (design §15.1). */ + public record Timeout( + @DefaultValue("300ms") Duration dns, + @DefaultValue("500ms") Duration connect, + @DefaultValue("1s") Duration tlsHandshake, + @DefaultValue("500ms") Duration proxyConnect, + @DefaultValue("1s") Duration requestWriteIdle, + @DefaultValue("2s") Duration responseHeader, + @DefaultValue("3s") Duration readIdle, + @DefaultValue("4s") Duration totalCall, + @DefaultValue("30s") Duration streamingIdle) {} + + /** Redirect policy; disabled by default (design §12.4). */ + public record Redirect( + @DefaultValue("false") boolean enabled, + @DefaultValue("0") int maxHops, + @DefaultValue("false") boolean allowCrossOrigin) {} + + /** Request-side budget. {@code maxBodyBytes} has no safe default and must be declared. */ + public record Request( + @DefaultValue("0") long maxBodyBytes, @DefaultValue("false") boolean compression) {} + + /** Response-side budget (design §23.2). */ + public record Response( + @DefaultValue("5242880") long maxWireBytes, + @DefaultValue("10485760") long maxDecodedBytes, + @DefaultValue({"application/json", "application/problem+json"}) + List allowedContentTypes) {} + + /** Declared authentication (design §20.1). */ + public record Authentication( + @DefaultValue("NONE") String type, + String registrationId, + @DefaultValue List scopes, + String audience, + String headerName, + String secretReference) {} + + /** Retry budget shape (design §17). */ + public record Retry( + @DefaultValue("none") String policy, + @DefaultValue("1") int maxAttempts, + @DefaultValue("50ms") Duration baseBackoff, + @DefaultValue("200ms") Duration maxBackoff, + @DefaultValue("FULL") String jitter, + @DefaultValue("HONOR") String retryAfter, + String budget) {} + + /** Per-profile observability switches (design §25). */ + public record Observability( + @DefaultValue("true") boolean operationNameRequired, + @DefaultValue("false") boolean fullUrlRecording, + @DefaultValue("false") boolean bodyLogging) {} + + /** + * TLS declaration (design §21). + * + *

{@code trustAll} and {@code allowPlainHttp} exist purely so an operator's unsafe intent is + * representable and therefore rejectable at startup. Nothing acts on a true value. + */ + public record Tls( + String profileId, + @DefaultValue({"TLSv1.3", "TLSv1.2"}) List protocols, + @DefaultValue("true") boolean hostnameVerification, + @DefaultValue("false") boolean trustAll, + @DefaultValue("false") boolean allowPlainHttp, + String trustMaterialReference, + String keyMaterialReference) {} + + /** Forward proxy declaration (design §24.3). */ + public record Proxy( + @DefaultValue("false") boolean enabled, + @DefaultValue("") String host, + @DefaultValue("0") int port, + @DefaultValue("HTTP") String type, + String credentialProvider, + @DefaultValue("500ms") Duration connectTimeout, + @DefaultValue("false") boolean importAmbientNoProxy) {} +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformSettingsBinder.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformSettingsBinder.java new file mode 100644 index 00000000..efbf5c61 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformSettingsBinder.java @@ -0,0 +1,54 @@ +package dev.caskeleton.bootstrap.autoconfigure.httpclient; + +import org.springframework.boot.context.properties.bind.BindHandler; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.bind.handler.NoUnboundElementsBindHandler; +import org.springframework.core.env.Environment; +import org.springframework.core.env.SystemEnvironmentPropertySource; + +/** + * Binds {@link HttpClientPlatformSettings} strictly, and only when asked. + * + *

Two properties matter here and neither is available from a scanned + * {@code @ConfigurationProperties} class. + * + *

First, timing. This runs inside the master-gated auto-configuration, so a deployment that + * never enables the platform never binds one of its settings — a malformed {@code Duration} in a + * block nobody switched on cannot fail its startup. + * + *

Second, strictness. Unknown keys under the prefix are refused rather than ignored. Silently + * dropping {@code app.httpclient.clients[0].timeuot.total-call} leaves the client running the + * default four-second budget while the configuration file says otherwise, which is exactly the kind + * of divergence an outbound call platform should never make an operator discover from an incident. + * + *

Strictness is delivered by two mechanisms because one does not cover both surfaces. Property + * sources that enumerate their keys in property form — YAML, properties files, test overrides — are + * checked by {@link NoUnboundElementsBindHandler}. The system environment is excluded from that + * handler and checked by {@link HttpClientEnvironmentKeys#rejectUnrecognised} instead: Spring + * enumerates {@code APP_HTTPCLIENT_CLIENTS_0_BASE_URL} as {@code ...clients[0].base.url} while + * binding it as {@code ...clients[0].base-url}, so leaving it to the handler would reject every + * correctly spelled variable and still miss the misspelled ones. + */ +final class HttpClientPlatformSettingsBinder { + + private HttpClientPlatformSettingsBinder() {} + + static HttpClientPlatformSettings bind(Environment environment) { + HttpClientEnvironmentKeys.rejectUnrecognised(environment); + BindHandler strict = + new NoUnboundElementsBindHandler( + BindHandler.DEFAULT, + source -> !(source.getUnderlyingSource() instanceof SystemEnvironmentPropertySource)); + return Binder.get(environment) + .bind( + HttpClientPlatformSettings.PREFIX, + Bindable.of(HttpClientPlatformSettings.class), + strict) + .orElseThrow( + () -> + new IllegalStateException( + HttpClientPlatformSettings.PREFIX + + " could not be bound although the platform is enabled")); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientProfileAutoConfiguration.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientProfileAutoConfiguration.java new file mode 100644 index 00000000..b85dff42 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientProfileAutoConfiguration.java @@ -0,0 +1,107 @@ +package dev.caskeleton.bootstrap.autoconfigure.httpclient; + +import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName; +import dev.caskeleton.adapter.outbound.httpclient.auth.CredentialProviderRegistry; +import dev.caskeleton.adapter.outbound.httpclient.auth.ReactiveRequestCredentialProvider; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientApiType; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntime; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry; +import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeEnvironment; +import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration; +import dev.caskeleton.adapter.outbound.httpclient.profile.TransportType; +import dev.caskeleton.adapter.outbound.httpclient.resilience.ResilienceRegistry; +import dev.caskeleton.adapter.outbound.httpclient.restclient.BlockingExecutionSupport; +import dev.caskeleton.adapter.outbound.httpclient.restclient.RestClientRuntimeFactory; +import dev.caskeleton.adapter.outbound.httpclient.security.TlsPolicyValidator; +import dev.caskeleton.adapter.outbound.httpclient.transport.BlockingTransportProvider; +import dev.caskeleton.adapter.outbound.httpclient.transport.ReactiveTransportProvider; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportLifecycleListener; +import dev.caskeleton.adapter.outbound.httpclient.webclient.WebClientRuntimeFactory; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.random.RandomGenerator; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +/** + * Binds profiles, validates them, and publishes the runtime registry (design §27.1, §27.2). + * + *

Validation runs before any runtime is built, so an unsafe configuration cannot create a + * connection pool, a TLS context, or a credential before failing. With no {@code http-clients} + * entries the registry is empty and the capability holds no resources at all. + */ +@Configuration(proxyBeanMethods = false) +public class HttpClientProfileAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + HttpClientProfileFactory httpClientProfileFactory() { + return new HttpClientProfileFactory(); + } + + @Bean + @ConditionalOnMissingBean + HttpClientStartupValidator httpClientStartupValidator(TlsPolicyValidator tlsPolicyValidator) { + // Injected rather than constructed, so a deployment that replaces the TlsPolicyValidator bean + // replaces the one startup validation uses too. + return new HttpClientStartupValidator(tlsPolicyValidator); + } + + @Bean(destroyMethod = "close") + @ConditionalOnMissingBean + ClientRuntimeRegistry httpClientRuntimeRegistry( + Environment environment, + HttpClientPlatformSettings properties, + HttpClientProfileFactory profileFactory, + HttpClientStartupValidator startupValidator, + Map httpClientBlockingTransportProviders, + Map httpClientReactiveTransportProviders, + ResilienceRegistry resilienceRegistry, + CredentialProviderRegistry credentialProviders, + ReactiveRequestCredentialProvider reactiveCredentialProvider, + BlockingExecutionSupport executionSupport, + TransportLifecycleListener lifecycleListener) { + + Map profiles = profileFactory.create(properties); + startupValidator.validate(profiles, runtimeEnvironment(environment)); + + RestClientRuntimeFactory blockingFactory = + new RestClientRuntimeFactory( + httpClientBlockingTransportProviders, + resilienceRegistry, + credentialProviders, + executionSupport, + lifecycleListener, + RandomGenerator.getDefault()); + WebClientRuntimeFactory reactiveFactory = + new WebClientRuntimeFactory( + httpClientReactiveTransportProviders, + resilienceRegistry, + reactiveCredentialProvider, + executionSupport, + lifecycleListener, + RandomGenerator.getDefault()); + + Map runtimes = new LinkedHashMap<>(); + profiles.forEach( + (name, profile) -> + runtimes.put( + name, + profile.api() == ClientApiType.WEB_CLIENT + ? reactiveFactory.create(profile, new RuntimeGeneration(1)) + : blockingFactory.create(profile, new RuntimeGeneration(1)))); + return new ClientRuntimeRegistry(runtimes); + } + + private RuntimeEnvironment runtimeEnvironment(Environment environment) { + for (String profile : environment.getActiveProfiles()) { + if ("prod".equalsIgnoreCase(profile) || "production".equalsIgnoreCase(profile)) { + return RuntimeEnvironment.PRODUCTION; + } + } + return RuntimeEnvironment.NON_PRODUCTION; + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientProfileFactory.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientProfileFactory.java new file mode 100644 index 00000000..c91946ee --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientProfileFactory.java @@ -0,0 +1,153 @@ +package dev.caskeleton.bootstrap.autoconfigure.httpclient; + +import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName; +import dev.caskeleton.adapter.outbound.httpclient.profile.AuthenticationSettings; +import dev.caskeleton.adapter.outbound.httpclient.profile.AuthenticationType; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientApiType; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientMode; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientObservabilitySettings; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile; +import dev.caskeleton.adapter.outbound.httpclient.profile.HttpProtocol; +import dev.caskeleton.adapter.outbound.httpclient.profile.JitterStrategy; +import dev.caskeleton.adapter.outbound.httpclient.profile.PoolSettings; +import dev.caskeleton.adapter.outbound.httpclient.profile.ProxySettings; +import dev.caskeleton.adapter.outbound.httpclient.profile.ProxyType; +import dev.caskeleton.adapter.outbound.httpclient.profile.RedirectSettings; +import dev.caskeleton.adapter.outbound.httpclient.profile.RequestLimits; +import dev.caskeleton.adapter.outbound.httpclient.profile.ResponseLimits; +import dev.caskeleton.adapter.outbound.httpclient.profile.RetryAfterPolicy; +import dev.caskeleton.adapter.outbound.httpclient.profile.RetrySettings; +import dev.caskeleton.adapter.outbound.httpclient.profile.TimeoutSettings; +import dev.caskeleton.adapter.outbound.httpclient.profile.TlsSettings; +import dev.caskeleton.adapter.outbound.httpclient.profile.TransportType; +import java.net.URI; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Turns bound properties into immutable profiles (design §11). + * + *

Conversion never repairs a configuration. An absent host allowlist stays absent so the + * validator can report it, rather than being silently derived from the base URL — which would make + * an unstated security decision on the operator's behalf. + */ +public final class HttpClientProfileFactory { + + public Map create(HttpClientPlatformSettings properties) { + Map profiles = new LinkedHashMap<>(); + for (HttpClientPlatformSettings.ClientSettings client : properties.clients()) { + profiles.put(new ClientProfileName(client.name()), toProfile(client)); + } + return Map.copyOf(profiles); + } + + public ClientProfile toProfile(HttpClientPlatformSettings.ClientSettings client) { + return new ClientProfile( + new ClientProfileName(client.name()), + ClientMode.valueOf(client.mode().toUpperCase(Locale.ROOT)), + client.baseUrl() == null ? null : URI.create(client.baseUrl()), + Set.copyOf(client.allowedHosts()), + Set.copyOf(client.allowedPorts()), + ClientApiType.valueOf(client.api().toUpperCase(Locale.ROOT)), + TransportType.valueOf(client.transport().toUpperCase(Locale.ROOT)), + client.protocols().stream() + .map(protocol -> HttpProtocol.valueOf(protocol.toUpperCase(Locale.ROOT))) + .collect(Collectors.toUnmodifiableSet()), + pool(client.pool()), + timeout(client.timeout()), + new RedirectSettings( + client.redirect().enabled(), + client.redirect().maxHops(), + client.redirect().allowCrossOrigin()), + new RequestLimits(client.request().maxBodyBytes(), client.request().compression()), + new ResponseLimits( + client.response().maxWireBytes(), + client.response().maxDecodedBytes(), + Set.copyOf(client.response().allowedContentTypes())), + authentication(client.authentication()), + retry(client.retry()), + new ClientObservabilitySettings( + client.observability().operationNameRequired(), + client.observability().fullUrlRecording(), + client.observability().bodyLogging()), + tls(client.tls()), + proxy(client.proxy()), + Optional.ofNullable(client.experimentalAcknowledgement())); + } + + private PoolSettings pool(HttpClientPlatformSettings.Pool pool) { + return new PoolSettings( + pool.maxTotalConnections(), + pool.maxConnectionsPerRoute(), + pool.maxPendingAcquires(), + pool.pendingAcquireTimeout(), + pool.maxIdleTime(), + pool.maxLifeTime(), + pool.validateAfterInactivity(), + pool.evictionInterval(), + pool.shutdownTimeout(), + pool.requiresRoutePool(), + pool.requiresBoundedPendingQueue()); + } + + private TimeoutSettings timeout(HttpClientPlatformSettings.Timeout timeout) { + return new TimeoutSettings( + timeout.dns(), + timeout.connect(), + timeout.tlsHandshake(), + timeout.proxyConnect(), + timeout.requestWriteIdle(), + timeout.responseHeader(), + timeout.readIdle(), + timeout.totalCall(), + timeout.streamingIdle()); + } + + private AuthenticationSettings authentication( + HttpClientPlatformSettings.Authentication authentication) { + return new AuthenticationSettings( + AuthenticationType.valueOf(authentication.type().toUpperCase(Locale.ROOT)), + Optional.ofNullable(authentication.registrationId()), + Set.copyOf(authentication.scopes()), + Optional.ofNullable(authentication.audience()), + Optional.ofNullable(authentication.headerName()), + Optional.ofNullable(authentication.secretReference())); + } + + private RetrySettings retry(HttpClientPlatformSettings.Retry retry) { + return new RetrySettings( + retry.policy(), + retry.maxAttempts(), + retry.baseBackoff(), + retry.maxBackoff(), + JitterStrategy.valueOf(retry.jitter().toUpperCase(Locale.ROOT)), + RetryAfterPolicy.valueOf(retry.retryAfter().toUpperCase(Locale.ROOT)), + Optional.ofNullable(retry.budget())); + } + + private TlsSettings tls(HttpClientPlatformSettings.Tls tls) { + return new TlsSettings( + Optional.ofNullable(tls.profileId()), + Set.copyOf(tls.protocols()), + tls.hostnameVerification(), + tls.trustAll(), + tls.allowPlainHttp(), + Optional.ofNullable(tls.trustMaterialReference()), + Optional.ofNullable(tls.keyMaterialReference())); + } + + private ProxySettings proxy(HttpClientPlatformSettings.Proxy proxy) { + return new ProxySettings( + proxy.enabled(), + proxy.host(), + proxy.port(), + ProxyType.valueOf(proxy.type().toUpperCase(Locale.ROOT)), + Optional.ofNullable(proxy.credentialProvider()), + proxy.connectTimeout(), + proxy.importAmbientNoProxy()); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientResilienceAutoConfiguration.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientResilienceAutoConfiguration.java new file mode 100644 index 00000000..d33c67b9 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientResilienceAutoConfiguration.java @@ -0,0 +1,28 @@ +package dev.caskeleton.bootstrap.autoconfigure.httpclient; + +import dev.caskeleton.adapter.outbound.httpclient.resilience.ResilienceRegistry; +import java.time.Clock; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Per-profile circuit breakers, rate limiters, bulkheads, and retry budgets (design §18). + * + *

The registry is shared but its components are keyed per profile, so a failing upstream cannot + * open a breaker or drain a budget that belongs to a healthy one. + * + *

The {@link Clock} comes from the composition root rather than from here. A fallback clock + * declared beside the resilience registry was harmless while this configuration was scanned + * unconditionally; behind the master switch it would have made the application's clock disappear + * whenever outbound HTTP was off, taking Redis, idempotency and the Fileserver with it. + */ +@Configuration(proxyBeanMethods = false) +public class HttpClientResilienceAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + ResilienceRegistry httpClientResilienceRegistry(Clock clock) { + return ResilienceRegistry.withDefaults(clock); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientSecurityAutoConfiguration.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientSecurityAutoConfiguration.java new file mode 100644 index 00000000..272734df --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientSecurityAutoConfiguration.java @@ -0,0 +1,30 @@ +package dev.caskeleton.bootstrap.autoconfigure.httpclient; + +import dev.caskeleton.adapter.outbound.httpclient.security.TlsMaterialProvider; +import dev.caskeleton.adapter.outbound.httpclient.security.TlsPolicyValidator; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * TLS material and policy (design §21, §27.1). + * + *

The default material provider supports only the JVM trust store. A deployment that needs a + * custom CA or a client certificate supplies its own loader bean, which keeps secret retrieval out + * of this module and out of configuration files. + */ +@Configuration(proxyBeanMethods = false) +public class HttpClientSecurityAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + TlsPolicyValidator httpClientTlsPolicyValidator() { + return new TlsPolicyValidator(); + } + + @Bean + @ConditionalOnMissingBean + TlsMaterialProvider httpClientTlsMaterialProvider() { + return TlsMaterialProvider.jvmTrustStore(); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientStartupValidator.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientStartupValidator.java new file mode 100644 index 00000000..5af42d9f --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientStartupValidator.java @@ -0,0 +1,123 @@ +package dev.caskeleton.bootstrap.autoconfigure.httpclient; + +import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException; +import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata; +import dev.caskeleton.adapter.outbound.httpclient.profile.AuthenticationType; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientApiType; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfileValidator; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfileViolation; +import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeEnvironment; +import dev.caskeleton.adapter.outbound.httpclient.security.TlsPolicyValidator; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * One fail-closed startup gate for every configured profile (design §27.2). + * + *

All violations are collected before failing so an operator sees the whole configuration + * problem at once. Failing on the first one turns a five-minute fix into five deploy cycles. + */ +public final class HttpClientStartupValidator { + + /** Mechanisms the reactive credential registry can serve without blocking an event loop. */ + private static final Set NON_BLOCKING_AUTHENTICATION = + Set.of( + AuthenticationType.NONE, + AuthenticationType.BASIC, + AuthenticationType.API_KEY_HEADER, + AuthenticationType.STATIC_BEARER); + + private final ClientProfileValidator profileValidator = new ClientProfileValidator(); + private final TlsPolicyValidator tlsPolicyValidator; + + /** Uses the platform's own default policy. */ + public HttpClientStartupValidator() { + this(new TlsPolicyValidator()); + } + + /** + * Uses the container-managed validator. + * + *

{@code HttpClientSecurityAutoConfiguration} publishes a {@code TlsPolicyValidator} bean that + * a deployment can replace, and this class used to construct its own instead — so overriding the + * bean changed what {@code TlsMaterialProvider} enforced while startup validation went on + * applying the default. Two validators, one of them ignored, is worse than either alone. + * + * @param tlsPolicyValidator the validator the container published + */ + public HttpClientStartupValidator(TlsPolicyValidator tlsPolicyValidator) { + this.tlsPolicyValidator = Objects.requireNonNull(tlsPolicyValidator, "tls policy validator"); + } + + public void validate( + Map profiles, RuntimeEnvironment environment) { + Objects.requireNonNull(profiles, "profiles"); + Objects.requireNonNull(environment, "runtime environment"); + + List violations = new ArrayList<>(); + Set seenNames = new HashSet<>(); + + profiles.forEach( + (name, profile) -> { + if (!seenNames.add(name.value())) { + violations.add("DUPLICATE_CLIENT_NAME profile=" + name.value()); + } + profileValidator.validate(profile, environment).stream() + .map(ClientProfileViolation::toString) + .forEach(violations::add); + reactiveAuthenticationViolation(profile).ifPresent(violations::add); + tlsPolicyValidator + .validate(profile.tls()) + .forEach( + violation -> + violations.add( + violation.code() + + " profile=" + + name.value() + + " setting=" + + violation.detail())); + }); + + if (!violations.isEmpty()) { + throw new HttpConfigurationException( + "http client configuration is not safe to start:\n " + String.join("\n ", violations), + HttpFailureMetadata.startup(new ClientProfileName("startup"))); + } + } + + /** + * Refuses a reactive profile whose declared authentication has no non-blocking implementation. + * + *

Only OAuth2 is affected: a token load is a network round trip, and the only provider that + * performs it blocks. BASIC, API keys and static bearers resolve a secret and format a header, + * which the reactive registry can do without leaving the event loop. + * + *

Before this check the combination started cleanly and then sent every request with no + * credential, because the composition root wired a no-auth provider for every reactive profile. + * An unauthenticated request that the upstream rejects is a much worse failure than a context + * that refuses to start, and it is much harder to attribute. + */ + private Optional reactiveAuthenticationViolation(ClientProfile profile) { + if (profile.api() != ClientApiType.WEB_CLIENT) { + return Optional.empty(); + } + AuthenticationType type = profile.authentication().type(); + if (!NON_BLOCKING_AUTHENTICATION.contains(type)) { + return Optional.of( + "REACTIVE_AUTHENTICATION_UNSUPPORTED profile=" + + profile.name().value() + + " type=" + + type + + " supported=" + + NON_BLOCKING_AUTHENTICATION); + } + return Optional.empty(); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientTransportAutoConfiguration.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientTransportAutoConfiguration.java new file mode 100644 index 00000000..d0c6db67 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientTransportAutoConfiguration.java @@ -0,0 +1,110 @@ +package dev.caskeleton.bootstrap.autoconfigure.httpclient; + +import dev.caskeleton.adapter.outbound.httpclient.apache.ApacheBlockingTransportProvider; +import dev.caskeleton.adapter.outbound.httpclient.dynamic.CallScopedDnsPin; +import dev.caskeleton.adapter.outbound.httpclient.jdk.JdkBlockingTransportProvider; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientMode; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile; +import dev.caskeleton.adapter.outbound.httpclient.profile.TransportType; +import dev.caskeleton.adapter.outbound.httpclient.reactor.ReactorNettyTransportProvider; +import dev.caskeleton.adapter.outbound.httpclient.security.SslContextMaterial; +import dev.caskeleton.adapter.outbound.httpclient.security.TlsMaterialProvider; +import dev.caskeleton.adapter.outbound.httpclient.security.TlsProfile; +import dev.caskeleton.adapter.outbound.httpclient.security.TlsProfileId; +import dev.caskeleton.adapter.outbound.httpclient.transport.BlockingTransportProvider; +import dev.caskeleton.adapter.outbound.httpclient.transport.ReactiveTransportProvider; +import dev.caskeleton.adapter.outbound.httpclient.transport.TransportLifecycleListener; +import io.micrometer.core.instrument.MeterRegistry; +import java.net.InetAddress; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Registers the Stable transports (design §6.2, §27.1). + * + *

Jetty HTTP/3 is deliberately absent: design D-08 and §32.7 require the Experimental transport + * to be opted into explicitly, and auto-registering it here would make "Experimental" a label + * rather than a boundary. + */ +@Configuration(proxyBeanMethods = false) +public class HttpClientTransportAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + TransportLifecycleListener httpClientTransportLifecycleListener() { + return TransportLifecycleListener.noop(); + } + + @Bean + @ConditionalOnMissingBean(name = "httpClientBlockingTransportProviders") + Map httpClientBlockingTransportProviders( + ObjectProvider meterRegistry, TlsMaterialProvider tlsMaterialProvider) { + Function> tlsResolver = + profile -> materialize(profile, tlsMaterialProvider); + + Map providers = new EnumMap<>(TransportType.class); + providers.put( + TransportType.APACHE, + new ApacheBlockingTransportProvider( + Optional.ofNullable(meterRegistry.getIfAvailable()), + tlsResolver, + HttpClientTransportAutoConfiguration::dynamicTargetPinning)); + providers.put(TransportType.JDK, new JdkBlockingTransportProvider(tlsResolver)); + return Map.copyOf(providers); + } + + /** + * Supplies the call-scoped pinned addresses to a DYNAMIC profile's transport. + * + *

This used to be {@code profile -> Optional.empty()} for every profile, which silently + * discarded the entire SSRF address validation: the dynamic gateway resolved the host, rejected + * forbidden answers, pinned the approved addresses — and then the transport resolved the hostname + * again and connected to whatever came back the second time. The check ran, produced a correct + * verdict, and had no effect on where the socket went. + * + *

Only DYNAMIC profiles are pinned. A trusted profile connects to a base URL an operator + * configured, not to a URL a caller supplied, so its destination is not attacker-controlled and + * pinning it would break ordinary DNS-based failover. + */ + private static Optional>> dynamicTargetPinning( + ClientProfile profile) { + if (profile.mode() != ClientMode.DYNAMIC) { + return Optional.empty(); + } + return Optional.of(CallScopedDnsPin::addressesFor); + } + + @Bean + @ConditionalOnMissingBean(name = "httpClientReactiveTransportProviders") + Map httpClientReactiveTransportProviders( + ObjectProvider meterRegistry, TlsMaterialProvider tlsMaterialProvider) { + Map providers = new EnumMap<>(TransportType.class); + providers.put( + TransportType.REACTOR_NETTY, + new ReactorNettyTransportProvider( + Optional.ofNullable(meterRegistry.getIfAvailable()), + profile -> materialize(profile, tlsMaterialProvider), + HttpClientTransportAutoConfiguration::dynamicTargetPinning)); + return Map.copyOf(providers); + } + + /** Only a profile that names a TLS profile gets custom material; others use the JVM defaults. */ + private Optional materialize( + ClientProfile profile, TlsMaterialProvider provider) { + return profile + .tls() + .profileId() + .filter( + ignored -> + profile.tls().trustMaterialReference().isPresent() + || profile.tls().keyMaterialReference().isPresent()) + .map(id -> provider.materialize(TlsProfile.from(profile.tls(), new TlsProfileId(id)))); + } +} diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpServiceClientAutoConfiguration.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpServiceClientAutoConfiguration.java new file mode 100644 index 00000000..c949c393 --- /dev/null +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpServiceClientAutoConfiguration.java @@ -0,0 +1,90 @@ +package dev.caskeleton.bootstrap.autoconfigure.httpclient; + +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry; +import dev.caskeleton.adapter.outbound.httpclient.restclient.BlockingAttemptExecutor; +import dev.caskeleton.adapter.outbound.httpclient.restclient.BlockingStreamingGateway; +import dev.caskeleton.adapter.outbound.httpclient.restclient.DefaultGenericHttpGateway; +import dev.caskeleton.adapter.outbound.httpclient.restclient.GenericHttpGateway; +import dev.caskeleton.adapter.outbound.httpclient.service.DefaultHttpServiceRegistry; +import dev.caskeleton.adapter.outbound.httpclient.service.DefaultReactiveHttpServiceRegistry; +import dev.caskeleton.adapter.outbound.httpclient.service.HttpServiceRegistry; +import dev.caskeleton.adapter.outbound.httpclient.service.ReactiveHttpServiceRegistry; +import dev.caskeleton.adapter.outbound.httpclient.webclient.DefaultReactiveHttpGateway; +import dev.caskeleton.adapter.outbound.httpclient.webclient.DefaultReactiveSseGateway; +import dev.caskeleton.adapter.outbound.httpclient.webclient.ReactiveAttemptExecutor; +import dev.caskeleton.adapter.outbound.httpclient.webclient.ReactiveSseGateway; +import dev.caskeleton.adapter.outbound.httpclient.webclient.ReactiveStreamingGateway; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Publishes the caller-facing entry points (design §9, §27.1). + * + *

H1 typed registries are the intended default; the H2 gateways are published beside them for + * the genuinely dynamic cases. H3 lives in its own configuration with its own policy, so it cannot + * be reached by accident from here. + */ +@Configuration(proxyBeanMethods = false) +public class HttpServiceClientAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + BlockingAttemptExecutor httpClientBlockingAttemptExecutor() { + return new BlockingAttemptExecutor(); + } + + @Bean + @ConditionalOnMissingBean + ReactiveAttemptExecutor httpClientReactiveAttemptExecutor() { + return new ReactiveAttemptExecutor(); + } + + @Bean + @ConditionalOnMissingBean + GenericHttpGateway genericHttpGateway( + ClientRuntimeRegistry runtimes, BlockingAttemptExecutor executor) { + return new DefaultGenericHttpGateway(runtimes, executor); + } + + @Bean + @ConditionalOnMissingBean + DefaultReactiveHttpGateway reactiveHttpGateway( + ClientRuntimeRegistry runtimes, ReactiveAttemptExecutor executor) { + return new DefaultReactiveHttpGateway(runtimes, executor); + } + + @Bean + @ConditionalOnMissingBean + HttpServiceRegistry httpServiceRegistry( + ClientRuntimeRegistry runtimes, GenericHttpGateway genericHttpGateway) { + // The typed registry runs its calls through the generic gateway, so a typed client and a + // generic exchange are the same execution path rather than two that resemble each other. + return new DefaultHttpServiceRegistry(runtimes, genericHttpGateway); + } + + @Bean + @ConditionalOnMissingBean + ReactiveHttpServiceRegistry reactiveHttpServiceRegistry( + ClientRuntimeRegistry runtimes, DefaultReactiveHttpGateway reactiveHttpGateway) { + return new DefaultReactiveHttpServiceRegistry(runtimes, reactiveHttpGateway); + } + + @Bean + @ConditionalOnMissingBean + BlockingStreamingGateway blockingStreamingGateway(ClientRuntimeRegistry runtimes) { + return new BlockingStreamingGateway(runtimes); + } + + @Bean + @ConditionalOnMissingBean + ReactiveStreamingGateway reactiveStreamingGateway(ClientRuntimeRegistry runtimes) { + return new ReactiveStreamingGateway(runtimes); + } + + @Bean + @ConditionalOnMissingBean + ReactiveSseGateway reactiveSseGateway(ClientRuntimeRegistry runtimes) { + return new DefaultReactiveSseGateway(runtimes); + } +} diff --git a/src/app-bootstrap/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/app-bootstrap/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..4778e055 --- /dev/null +++ b/src/app-bootstrap/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +dev.caskeleton.bootstrap.autoconfigure.fileserver.FileserverPlatformAutoConfiguration +dev.caskeleton.bootstrap.autoconfigure.httpclient.HttpClientPlatformAutoConfiguration diff --git a/src/app-bootstrap/src/main/resources/application.yml b/src/app-bootstrap/src/main/resources/application.yml index 6c9eb66e..6345544f 100644 --- a/src/app-bootstrap/src/main/resources/application.yml +++ b/src/app-bootstrap/src/main/resources/application.yml @@ -179,8 +179,12 @@ spring: # GlobalExceptionHandler#handleMaxUploadSizeExceededException), never a raw 500. # Multipart-specific upload limits (UPLOAD_SIZE_EXCEEDED) are refined by # feature-file-resource-handling-contract. - max-file-size: ${SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE:10MB} - max-request-size: ${SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE:10MB} + # Same placeholders as app.fileserver-platform.upload.*, deliberately. Two independent + # ceilings meant the servlet container rejected at 10MB whatever the Fileserver policy said, + # so a 100MB upload failed before any Fileserver code — including its error mapping — ran, + # and the configured limit described a policy nobody could reach. + max-file-size: ${APP_FILESERVER_PLATFORM_UPLOAD_MAX_FILE_SIZE:100MB} + max-request-size: ${APP_FILESERVER_PLATFORM_UPLOAD_MAX_REQUEST_SIZE:110MB} # Jackson deserialization policy (feature-boundary-validation-mapping-contract B1). # Every request DTO crosses this boundary; the switches make malformed payloads # fail at the edge rather than silently coercing or dropping fields. @@ -231,9 +235,17 @@ management: health: # D8: never expose health details to unauthenticated callers. show-details: when-authorized - # redisRequired exists only when a correctness role is bound. Keep the static group - # fail-closed for known names while allowing an absent conditional contributor. - validate-group-membership: false + # Membership is validated. With validation off, a group naming a contributor that does not + # exist is silently dropped and readiness reports UP while proving nothing about the + # dependency it claims to gate on — a false green that survives exactly as long as nobody + # checks. On, the cost is that every name below must resolve, in every deployment. + # + # That cost is paid by construction rather than by convention: the groups below name only + # unconditional contributors, and the one conditional member — `redisRequired` — is appended + # by RedisReadinessGroupPostProcessor from the same predicate that creates the bean. A + # contributor can therefore be named only where it exists, and a misspelled one still fails + # startup. + validate-group-membership: true # feature-runtime-health-lifecycle-contract: expose the Kubernetes-ready # liveness/readiness/startup probe paths. probes: @@ -245,11 +257,20 @@ management: liveness: include: livenessState # Readiness: ready to serve traffic AND all REQUIRED dependencies up. - # Required: db and redisRequired (only COORDINATION/SESSION role bindings). - # redisOptional is deliberately excluded: a CACHE outage is reported as degraded detail - # but never turns a healthy JVM or an otherwise-ready pod unavailable. + # Every name here must resolve to a contributor that exists (validate-group-membership + # above). A correctness-role Redis contributor belongs in this list and is added when the + # Redis runtime composition creates one; naming it before then produced a readiness probe + # that reported UP without ever checking Redis. + # `redisRequired` is NOT listed here, and must not be: group membership does not tolerate a + # conditional contributor being absent. Boot validates the group against the contributors + # that exist, so naming it statically made every Redis-off and cache-only deployment fail at + # startup with "Included health contributor 'redisRequired' in group 'readiness' does not + # exist". RedisReadinessGroupPostProcessor appends it to this list — from the same predicate + # that creates the bean — when Redis is on and a correctness role selected it. + # `redisOptional` is never appended: a cache outage is reported as degraded detail and never + # turns a healthy JVM or an otherwise-ready pod unavailable. readiness: - include: readinessState,db,redisRequired + include: readinessState,db # Startup: startup/migration validation complete. # readinessState acts as the startup completion gate — it flips UP only # after the context is fully initialized (Flyway migration included). @@ -304,42 +325,33 @@ ca-skeleton: # definitions alone are inert, and the current NOT_IMPLEMENTED readiness card rejects ACTIVE # before any client/executor/pool resource can be created. capabilities: + # Every capability below renders its keys under one namespace — app.redis.namespace, which is + # {environment}:{service}:{domain}. The per-capability namespace-application / + # namespace-environment pairs that used to live here are gone: four capabilities each joining + # two free-form tokens in their own order produced four different key prefixes, and the ACL + # pattern meant to fence the deployment in matched none of them. cache: - # Canonical semantic cache activation. Disabled by default; "redis" requires an active - # ca-skeleton.providers.redis.roles.cache binding and resolves HMAC material by reference. + # Canonical semantic cache activation. Disabled by default; "redis" composes + # RedisCacheRegionAdapter for the region named below and resolves HMAC material by reference. bindings: default: ${APP_CACHE_CANONICAL_DEFAULT_PROVIDER:disabled} - regions: - default: - key-hmac-secret-reference: secret://environment/APP_CACHE_REDIS_KEY_HMAC_SECRET - namespace-application: ${APP_NAME:ca-skeleton} - namespace-environment: ${APP_CACHE_REDIS_NAMESPACE_ENVIRONMENT:local} - semantic-region: ${APP_CACHE_REDIS_SEMANTIC_REGION:default} - hash-key-version: 1 - key-version: 1 - policy-revision: canonical-default-r1 - positive-soft-ttl: ${APP_CACHE_REDIS_POSITIVE_SOFT_TTL:240s} - positive-hard-ttl: ${APP_CACHE_DEFAULT_TTL:300s} - negative-ttl: ${APP_CACHE_NEGATIVE_TTL:60s} - ttl-jitter: ${APP_CACHE_REDIS_TTL_JITTER:0.10} - maximum-value-bytes: 61440 - l1: - enabled: ${APP_CACHE_REDIS_L1_ENABLED:false} - maximum-entries: ${APP_CACHE_REDIS_L1_MAXIMUM_ENTRIES:10000} - maximum-weight-bytes: ${APP_CACHE_REDIS_L1_MAXIMUM_WEIGHT_BYTES:67108864} - maximum-entry-weight-bytes: ${APP_CACHE_REDIS_L1_MAXIMUM_ENTRY_WEIGHT_BYTES:1048576} - time-to-live: ${APP_CACHE_REDIS_L1_TTL:30s} - generation-recheck-interval: ${APP_CACHE_REDIS_L1_GENERATION_RECHECK_INTERVAL:5s} - invalidation-queue-capacity: ${APP_CACHE_REDIS_L1_INVALIDATION_QUEUE_CAPACITY:1024} + semantic-region: ${APP_CACHE_REDIS_SEMANTIC_REGION:default} + key-version: 1 + key-hmac-secret-reference: secret://environment/APP_CACHE_REDIS_KEY_HMAC_SECRET + command-timeout: ${APP_CACHE_REDIS_COMMAND_TIMEOUT:200ms} + positive-soft-ttl: ${APP_CACHE_REDIS_POSITIVE_SOFT_TTL:30s} + positive-hard-ttl: ${APP_CACHE_REDIS_POSITIVE_HARD_TTL:5m} + negative-ttl: ${APP_CACHE_REDIS_NEGATIVE_TTL:10s} + # Floor on the hard TTL. Below it, entries expire faster than the round trip that wrote them: + # every read misses and every miss writes. + minimum-hard-ttl: ${APP_CACHE_REDIS_MINIMUM_HARD_TTL:1s} idempotency: # disabled | jdbc | redis. JDBC is the existing V1 provider; Redis is the owner-safe V2 # provider. They are mutually exclusive and no V1-to-V2 facade is inferred. provider: ${APP_IDEMPOTENCY_PROVIDER:jdbc} key-hmac-secret-reference: secret://environment/APP_IDEMPOTENCY_REDIS_KEY_HMAC_SECRET - namespace-application: ${APP_NAME:ca-skeleton} - namespace-environment: ${APP_IDEMPOTENCY_REDIS_NAMESPACE_ENVIRONMENT:local} - hash-key-version: 1 key-version: 1 + command-timeout: ${APP_IDEMPOTENCY_REDIS_COMMAND_TIMEOUT:200ms} processing-lease: ${APP_IDEMPOTENCY_PROCESSING_LEASE:30s} replay-ttl: ${APP_IDEMPOTENCY_TTL:24h} failure-retention: ${APP_IDEMPOTENCY_FAILURE_RETENTION:24h} @@ -349,10 +361,12 @@ ca-skeleton: # disabled | redis. This is EFFICIENCY_ONLY and never supplies fencing. provider: ${APP_LEASE_PROVIDER:disabled} key-hmac-secret-reference: secret://environment/APP_LEASE_REDIS_KEY_HMAC_SECRET - namespace-application: ${APP_NAME:ca-skeleton} - namespace-environment: ${APP_LEASE_REDIS_NAMESPACE_ENVIRONMENT:local} - hash-key-version: 1 key-version: 1 + command-timeout: ${APP_LEASE_REDIS_COMMAND_TIMEOUT:200ms} + contention-retry-after: ${APP_LEASE_REDIS_CONTENTION_RETRY_AFTER:50ms} + # How much shorter than the server's TTL this holder considers its lease valid. The two + # clocks are not the same clock, and a holder that measured the full TTL locally would still + # believe it held a lease the server had already handed to somebody else. drift-budget: ${APP_LEASE_REDIS_DRIFT_BUDGET:10ms} rate-limit: # disabled | redis. This is the sole outbound provider activation selector. @@ -360,11 +374,8 @@ ca-skeleton: failure-policy: ${APP_RATE_LIMIT_FAILURE_POLICY:fail-closed} default-policy-id: ${APP_RATE_LIMIT_DEFAULT_POLICY_ID:api-default} failure-retry-after: ${APP_RATE_LIMIT_FAILURE_RETRY_AFTER:100ms} - hash-key-version: ${APP_RATE_LIMIT_HASH_KEY_VERSION:1} key-version: ${APP_RATE_LIMIT_KEY_VERSION:1} - key-hmac-secret-reference: secret://environment/APP_RATE_LIMIT_REDIS_KEY_HMAC_SECRET - namespace-application: ${APP_NAME:ca-skeleton} - namespace-environment: ${APP_RATE_LIMIT_REDIS_NAMESPACE_ENVIRONMENT:local} + command-timeout: ${APP_RATE_LIMIT_REDIS_COMMAND_TIMEOUT:200ms} policies: api-default: revision: ${APP_RATE_LIMIT_POLICY_REVISION:v1} @@ -378,48 +389,6 @@ ca-skeleton: cleanup-grace: ${APP_RATE_LIMIT_CLEANUP_GRACE:5s} maximum-clock-regression: ${APP_RATE_LIMIT_MAXIMUM_CLOCK_REGRESSION:250ms} security: - redis-session: - key-hmac-secret-reference: secret://environment/APP_SESSION_REDIS_KEY_HMAC_SECRET - namespace-application: ${APP_NAME:ca-skeleton} - namespace-environment: ${APP_SESSION_REDIS_NAMESPACE_ENVIRONMENT:local} - hash-key-version: 1 - key-version: 1 - idle-timeout: ${APP_SESSION_IDLE_TIMEOUT:30m} - absolute-lifetime: ${APP_SESSION_ABSOLUTE_LIFETIME:8h} - touch-interval: ${APP_SESSION_TOUCH_INTERVAL:1m} - tombstone-time-to-live: ${APP_SESSION_TOMBSTONE_TTL:5m} - maximum-envelope-bytes: ${APP_SESSION_MAXIMUM_ENVELOPE_BYTES:32768} - maximum-attributes: ${APP_SESSION_MAXIMUM_ATTRIBUTES:64} - maximum-scalar-bytes: ${APP_SESSION_MAXIMUM_SCALAR_BYTES:8192} - http-client: - expected-state: DISABLED - bindings: {} - providers: - http-client: {} - redis: - # Definitions alone are inert. Environment-specific configuration must bind roles. - legacy-migration-enabled: false - deployments: {} - roles: {} - runtime: - client-name: canonical-redis - connect-timeout: 2s - tls-handshake-timeout: 3s - acquire-timeout: 2s - command-timeout: 2s - overall-timeout: 5s - shutdown-timeout: 3s - maximum-queued-commands: 64 - cluster-maximum-redirects: 5 - cluster-topology-refresh-period: 30s - maximum-in-flight-commands: 64 - maximum-command-bytes: 65536 - maximum-in-flight-bytes: 4194304 - route-drain-timeout: 6s - default-write-ttl: 5m - sentinel-discovery-refresh-period: ${APP_REDIS_SENTINEL_DISCOVERY_REFRESH_PERIOD:30s} - semantic-probe-minimum-interval: ${APP_REDIS_SEMANTIC_PROBE_MINIMUM_INTERVAL:5s} - semantic-probe-maximum-staleness: ${APP_REDIS_SEMANTIC_PROBE_MAXIMUM_STALENESS:15s} bootstrap: # required, non-blank — startup fails if blank (see BootstrapSettings) app-name: ${APP_NAME} @@ -602,6 +571,12 @@ ca-skeleton: # (which needs its project-supplied integration client bean). Domain namespace, NOT a # generic `app.adapter.*` prefix (branch-note §Audit A1). Env keys are the registry SSOT. app: + # The one global Redis switch. False (the default) means no Redis settings are bound, no Redis + # credential is required, no client, connection, thread or health contributor is created, and no + # Redis-specific resource is read. There is deliberately no second master switch: a role such as + # cache or session selects *which* Redis capabilities compose, never *whether* Redis exists. + redis: + enabled: ${APP_REDIS_ENABLED:false} # Fileserver R2 exact destination/provider composition. Disabled by default: while false, # these blank attestation placeholders do not create directories, probe a filesystem, or # contribute FilePublicationPort. Enabling fails closed unless every local-persistent @@ -629,6 +604,107 @@ app: mount-sentinel-sha256: ${APP_FILESERVER_LOCAL_MOUNT_SENTINEL_SHA256:} expected-owner: ${APP_FILESERVER_LOCAL_EXPECTED_OWNER:} maximum-root-mode: "0750" + # HTTP Fileserver platform — a different capability from app.fileserver above, which publishes + # tabular exports. This one serves upload/download/lifecycle routes over HTTP and owns its own + # namespace so the two cannot be switched on by accident together. + # + # While enabled=false none of the detail below is bound: FileserverPlatformAutoConfiguration + # binds the block itself, and it is not processed until the master switch is true. Unknown keys + # under this prefix are refused rather than ignored. + # Outbound HTTP Client platform. Only the master switch lives here. + # + # The per-client surface is an indexed list, and templating one element would materialise a + # nameless client in every deployment — which the settings' own aggregate validation refuses, so + # the template could not be left in place. Clients are therefore declared straight from the + # environment as APP_HTTPCLIENT_CLIENTS_0_*, registered in + # docs/httpclient/env-fields.yaml, and an APP_HTTPCLIENT_ variable absent from that + # registry fails startup rather than being ignored. + # + # While enabled=false none of it is bound: HttpClientPlatformAutoConfiguration binds the block + # itself and is not processed until the master switch is true. + httpclient: + enabled: ${APP_HTTPCLIENT_ENABLED:false} + fileserver-platform: + enabled: ${APP_FILESERVER_PLATFORM_ENABLED:false} + # Writer-lease owner. Must be unique per instance in a multi-instance deployment; the startup + # gate treats the default as single-instance. + instance-id: ${APP_FILESERVER_PLATFORM_INSTANCE_ID:local-node} + default-namespace: ${APP_FILESERVER_PLATFORM_DEFAULT_NAMESPACE:default} + storage: + # Absolute, on its own volume, and never under a web or configuration root. A relative path + # resolves against the process working directory, which differs between a container and a + # test, so it is refused. + root: ${APP_FILESERVER_PLATFORM_STORAGE_ROOT:/var/lib/backend/files} + publish-mode: ${APP_FILESERVER_PLATFORM_STORAGE_PUBLISH_MODE:atomic-move-preferred} + buffer-size: ${APP_FILESERVER_PLATFORM_STORAGE_BUFFER_SIZE:128KB} + forbidden-root-ancestors: ${APP_FILESERVER_PLATFORM_STORAGE_FORBIDDEN_ROOT_ANCESTORS:/app,/etc,/usr/share/nginx/html} + upload: + # Shared with spring.servlet.multipart below through the same placeholder. Two independent + # limits would let the servlet container reject an upload the Fileserver policy allows, + # before any Fileserver code — including its error mapping — ever runs. + max-file-size: ${APP_FILESERVER_PLATFORM_UPLOAD_MAX_FILE_SIZE:100MB} + max-request-size: ${APP_FILESERVER_PLATFORM_UPLOAD_MAX_REQUEST_SIZE:110MB} + initial-reservation: ${APP_FILESERVER_PLATFORM_UPLOAD_INITIAL_RESERVATION:8MB} + max-parts: ${APP_FILESERVER_PLATFORM_UPLOAD_MAX_PARTS:16} + ttl: ${APP_FILESERVER_PLATFORM_UPLOAD_TTL:1h} + reservation-ttl: ${APP_FILESERVER_PLATFORM_UPLOAD_RESERVATION_TTL:24h} + lease-duration: ${APP_FILESERVER_PLATFORM_UPLOAD_LEASE_DURATION:30s} + require-content-length: ${APP_FILESERVER_PLATFORM_UPLOAD_REQUIRE_CONTENT_LENGTH:false} + download: + cache-control: ${APP_FILESERVER_PLATFORM_DOWNLOAD_CACHE_CONTROL:private, no-store} + inline-allowed: ${APP_FILESERVER_PLATFORM_DOWNLOAD_INLINE_ALLOWED:false} + max-ranges: ${APP_FILESERVER_PLATFORM_DOWNLOAD_MAX_RANGES:1} + # Applies to the single-range profile too, so the ceiling is not inert by default. + max-range-bytes: ${APP_FILESERVER_PLATFORM_DOWNLOAD_MAX_RANGE_BYTES:100MB} + zero-copy-enabled: ${APP_FILESERVER_PLATFORM_DOWNLOAD_ZERO_COPY_ENABLED:true} + zero-copy-minimum-bytes: ${APP_FILESERVER_PLATFORM_DOWNLOAD_ZERO_COPY_MINIMUM_BYTES:16MB} + transfer: + core-size: ${APP_FILESERVER_PLATFORM_TRANSFER_CORE_SIZE:8} + max-size: ${APP_FILESERVER_PLATFORM_TRANSFER_MAX_SIZE:32} + queue-capacity: ${APP_FILESERVER_PLATFORM_TRANSFER_QUEUE_CAPACITY:64} + await-seconds: ${APP_FILESERVER_PLATFORM_TRANSFER_AWAIT_SECONDS:300} + security: + # required | role-based | unenforced. `required` has no built-in policy and fails startup + # unless the deployment supplies a FileAccessPolicy bean; `unenforced` is refused under a + # production profile. + access-policy: ${APP_FILESERVER_PLATFORM_SECURITY_ACCESS_POLICY:required} + read-roles: ${APP_FILESERVER_PLATFORM_SECURITY_READ_ROLES:ROLE_FILE_READ} + write-roles: ${APP_FILESERVER_PLATFORM_SECURITY_WRITE_ROLES:ROLE_FILE_WRITE} + admin-roles: ${APP_FILESERVER_PLATFORM_SECURITY_ADMIN_ROLES:ROLE_FILE_ADMIN} + verification: + timeout: ${APP_FILESERVER_PLATFORM_VERIFICATION_TIMEOUT:5s} + require-media-type-verdict: ${APP_FILESERVER_PLATFORM_VERIFICATION_REQUIRE_MEDIA_TYPE_VERDICT:false} + inline-safe-profile: ${APP_FILESERVER_PLATFORM_VERIFICATION_INLINE_SAFE_PROFILE:false} + quota: + instance-upload-permits: ${APP_FILESERVER_PLATFORM_QUOTA_INSTANCE_UPLOAD_PERMITS:16} + scope-upload-permits: ${APP_FILESERVER_PLATFORM_QUOTA_SCOPE_UPLOAD_PERMITS:4} + direct-download-permits: ${APP_FILESERVER_PLATFORM_QUOTA_DIRECT_DOWNLOAD_PERMITS:64} + soft-high-water: ${APP_FILESERVER_PLATFORM_QUOTA_SOFT_HIGH_WATER:0.70} + hard-high-water: ${APP_FILESERVER_PLATFORM_QUOTA_HARD_HIGH_WATER:0.85} + admin: + enabled: ${APP_FILESERVER_PLATFORM_ADMIN_ENABLED:false} + orphan-minimum-age: ${APP_FILESERVER_PLATFORM_ADMIN_ORPHAN_MINIMUM_AGE:1h} + cleanup: + enabled: ${APP_FILESERVER_PLATFORM_CLEANUP_ENABLED:false} + interval: ${APP_FILESERVER_PLATFORM_CLEANUP_INTERVAL:60s} + max-items: ${APP_FILESERVER_PLATFORM_CLEANUP_MAX_ITEMS:100} + max-bytes: ${APP_FILESERVER_PLATFORM_CLEANUP_MAX_BYTES:1GB} + retry-backoff: ${APP_FILESERVER_PLATFORM_CLEANUP_RETRY_BACKOFF:5m} + tus: + enabled: ${APP_FILESERVER_PLATFORM_TUS_ENABLED:false} + httpbis-draft12: + # Unratified protocol; the contract can change without notice. + enabled: ${APP_FILESERVER_PLATFORM_HTTPBIS_DRAFT12_ENABLED:false} + nginx: + enabled: ${APP_FILESERVER_PLATFORM_NGINX_ENABLED:false} + internal-prefix: ${APP_FILESERVER_PLATFORM_NGINX_INTERNAL_PREFIX:/__files/} + object-suffix: ${APP_FILESERVER_PLATFORM_NGINX_OBJECT_SUFFIX:.bin} + minimum-size: ${APP_FILESERVER_PLATFORM_NGINX_MINIMUM_SIZE:16MB} + observability: + metrics-enabled: ${APP_FILESERVER_PLATFORM_OBSERVABILITY_METRICS_ENABLED:true} + # Secret. Keyed HMAC over file identifiers; an unkeyed digest of an enumerable id is + # reversible, so startup fails while metrics are on and this is blank. + fingerprint-key: ${APP_FILESERVER_PLATFORM_OBSERVABILITY_FINGERPRINT_KEY:} rate-limit: # Inbound HTTP enforcement is a separate axis from outbound provider activation. # enabled=true with no exact EdgeRateLimitPort fails fast; it never installs a local fallback. @@ -638,39 +714,6 @@ app: caller-deadline-budget: 2s # remote-addr-only | forwarded-headers-trusted (trusted ingress only) client-ip-mode: ${APP_RATE_LIMIT_CLIENT_IP_MODE:remote-addr-only} - cache: - redis: - # true | false (boolean_strict). Redis cache adapter on/off. - enabled: ${APP_CACHE_REDIS_ENABLED} - # managed = module-owned Lettuce runtime; external = project-supplied RedisClient bean. - client-mode: ${APP_CACHE_REDIS_CLIENT_MODE:managed} - host: ${APP_CACHE_REDIS_HOST:} - port: ${APP_CACHE_REDIS_PORT:6379} - password: ${APP_CACHE_REDIS_PASSWORD:} - # Base64-encoded, at least 32 decoded bytes. Required when the managed runtime is enabled. - key-hmac-secret: ${APP_CACHE_REDIS_KEY_HMAC_SECRET:} - command-timeout: ${APP_CACHE_REDIS_COMMAND_TIMEOUT:2s} - maximum-queued-commands: ${APP_CACHE_REDIS_MAXIMUM_QUEUED_COMMANDS:8} - maximum-in-flight-bytes: ${APP_CACHE_REDIS_MAXIMUM_IN_FLIGHT_BYTES:16777216} - positive-ttl: ${APP_CACHE_DEFAULT_TTL:300s} - # Blank derives 80% of positive-ttl in typed settings. - positive-soft-ttl: ${APP_CACHE_REDIS_POSITIVE_SOFT_TTL:} - negative-ttl: ${APP_CACHE_NEGATIVE_TTL:60s} - ttl-jitter: ${APP_CACHE_REDIS_TTL_JITTER:0.10} - minimum-hard-ttl: ${APP_CACHE_REDIS_MINIMUM_HARD_TTL:1s} - namespace-application: ${APP_NAME:ca-skeleton} - namespace-environment: ${APP_CACHE_REDIS_NAMESPACE_ENVIRONMENT:local} - semantic-region: ${APP_CACHE_REDIS_SEMANTIC_REGION:default} - maximum-value-bytes: ${APP_CACHE_REDIS_MAXIMUM_VALUE_BYTES:1048576} - # Optional cache-only L1. Never reuse for session, idempotency or strict rate-limit state. - l1: - enabled: ${APP_CACHE_REDIS_L1_ENABLED:false} - maximum-entries: ${APP_CACHE_REDIS_L1_MAXIMUM_ENTRIES:10000} - maximum-weight-bytes: ${APP_CACHE_REDIS_L1_MAXIMUM_WEIGHT_BYTES:67108864} - maximum-entry-weight-bytes: ${APP_CACHE_REDIS_L1_MAXIMUM_ENTRY_WEIGHT_BYTES:1048576} - time-to-live: ${APP_CACHE_REDIS_L1_TTL:30s} - generation-recheck-interval: ${APP_CACHE_REDIS_L1_GENERATION_RECHECK_INTERVAL:5s} - invalidation-queue-capacity: ${APP_CACHE_REDIS_L1_INVALIDATION_QUEUE_CAPACITY:1024} # Logical-cache-name → backendId routing (CacheStoreRouter). No keys by default — # forks add e.g. `bindings: { worklog: redis }` or env APP_CACHE_BINDINGS_WORKLOG=redis. # A binding to a backend that is not enabled fails startup (Layer 3 moved to router). diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java index 99ce158e..375a03f4 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java @@ -4,19 +4,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import dev.caskeleton.adapter.outbound.cache.CacheRouterConfig; -import dev.caskeleton.adapter.outbound.cache.core.CacheBackend; -import dev.caskeleton.adapter.outbound.cache.core.CacheStore; -import dev.caskeleton.adapter.outbound.cache.core.CacheStoreRouter; -import dev.caskeleton.adapter.outbound.cache.redis.RedisCacheAdapterConfig; -import dev.caskeleton.adapter.outbound.cache.redis.RedisClient; -import dev.caskeleton.adapter.outbound.cache.redis.RedisRateLimitConfig; import dev.caskeleton.adapter.outbound.fileserver.FileExportConfig; import dev.caskeleton.adapter.outbound.fileserver.FileserverR2Config; -import dev.caskeleton.adapter.outbound.httpclient.OutboundHttpClient; -import dev.caskeleton.adapter.outbound.httpclient.OutboundHttpShutdownGuard; -import dev.caskeleton.adapter.outbound.httpclient.activation.ResolvedHttpClientCapability; -import dev.caskeleton.adapter.outbound.httpclient.resilience.OutboundHttpResilience; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry; import dev.caskeleton.adapter.outbound.messaging.MessagingConfig; import dev.caskeleton.adapter.outbound.messaging.core.DisabledMessagePublisher; import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker; @@ -41,29 +31,25 @@ import dev.caskeleton.application.notification.Notification; import dev.caskeleton.application.notification.NotificationPort; import dev.caskeleton.application.outbox.OutboxMessagePublishPort; import dev.caskeleton.application.outbox.OutboxRelayFailureReportPort; -import dev.caskeleton.bootstrap.httpclient.HttpClientCompositionConfig; +import dev.caskeleton.bootstrap.autoconfigure.httpclient.HttpClientPlatformAutoConfiguration; import dev.caskeleton.shared.error.AdapterDisabledException; import dev.caskeleton.shared.ratelimit.EdgeRateLimitPort; -import java.util.Optional; import org.junit.jupiter.api.Test; import org.springframework.boot.autoconfigure.AutoConfigurations; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** * Layer 1 (D2) — {@code @ConditionalOnProperty} bean-gating contract (required_test {@code - * adapter-contract:{redis,kafka,slack-webhook,google-email}-disabled-default}). + * adapter-contract:{kafka,slack-webhook,google-email}-disabled-default}). * *

Asserts that with the default (env absent → disabled) the real adapter bean count is 0 and the * router fails fast on unbound access; and that flipping the enable flag (with the integration - * client supplied) registers the real adapter and routes correctly. Also validates OCP: a second - * backend plugs in via new files only without changing existing configs. + * client supplied) registers the real adapter and routes correctly. * - *

Notification: uses the router-fail-fast shape (mirrors cache D4). No per-channel {@code - * Disabled*Notifier} sentinel — unbound route → {@link AdapterDisabledException} from {@link - * RoutingNotifier}. + *

Notification: uses the router-fail-fast shape. No per-channel {@code Disabled*Notifier} + * sentinel — unbound route → {@link AdapterDisabledException} from {@link RoutingNotifier}. */ class OptionalAdapterBeanGatingTest { @@ -72,20 +58,19 @@ class OptionalAdapterBeanGatingTest { private final ApplicationContextRunner runner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of()) + // Registered as a real auto-configuration entry, which is how the composition root + // reaches it: the HTTP Client platform's off state is a property of that entry, not of a + // hand-assembled subset of its children. + .withConfiguration(AutoConfigurations.of(HttpClientPlatformAutoConfiguration.class)) .withUserConfiguration( OutboundSupportConfig.class, MessagingConfig.class, KafkaAdapterConfig.class, - RedisCacheAdapterConfig.class, - RedisRateLimitConfig.class, - CacheRouterConfig.class, NotificationConfig.class, SlackNotificationAdapterConfig.class, GoogleEmailNotificationAdapterConfig.class, FileExportConfig.class, FileserverR2Config.class, - HttpClientCompositionConfig.class, StubClientsConfig.class); @Test @@ -97,16 +82,14 @@ class OptionalAdapterBeanGatingTest { // real provider beans absent (Layer 1 — disabled, contributes nothing) assertThat(context.getBeansOfType(MessageBroker.class)).isEmpty(); - assertThat(context.getBeansOfType(CacheStore.class)).isEmpty(); assertThat(context.getBeansOfType(EdgeRateLimitPort.class)).isEmpty(); - assertThat(context.containsBean("distributedRateLimiter")).isFalse(); assertThat(context.getBeansOfType(NotificationProvider.class)).isEmpty(); assertThat(context.getBeansOfType(FilePublicationPort.class)).isEmpty(); - assertThat(context.getBeansOfType(OutboundHttpClient.class)).isEmpty(); - assertThat(context.getBeansOfType(OutboundHttpShutdownGuard.class)).isEmpty(); - assertThat(context.getBeansOfType(OutboundHttpResilience.class)).isEmpty(); - assertThat(context.getBean(ResolvedHttpClientCapability.class).state()) - .isEqualTo(ResolvedHttpClientCapability.State.DISABLED_VERIFIED); + // Not "an empty registry". With app.httpclient.enabled unset there is no registry, no + // transport provider and no gateway at all. The previous assertion — a registry bean + // holding no profiles — was what made the capability mandatory-with-a-switch rather than + // optional: the pool, TLS and credential machinery was still assembled around it. + assertThat(context.getBeansOfType(ClientRuntimeRegistry.class)).isEmpty(); // messaging: fail-fast sentinels satisfy the ports (Layer 3 fallback) assertThat(context.getBean(MessagePublisher.class)) @@ -117,11 +100,6 @@ class OptionalAdapterBeanGatingTest { assertThat(context.getBean(OutboxRelayFailureReportPort.class)) .isInstanceOf(Slf4jOutboxRelayFailureReportAdapter.class); - // cache D4: zero backends boot fine, unwired access fails fast in the router - CacheStoreRouter cacheRouter = context.getBean(CacheStoreRouter.class); - assertThatThrownBy(() -> cacheRouter.get("worklog", "k")) - .isInstanceOf(AdapterDisabledException.class); - // notification D4: zero providers boot fine, unbound route fails fast in RoutingNotifier NotificationPort notificationPort = context.getBean(NotificationPort.class); assertThat(notificationPort).isInstanceOf(RoutingNotifier.class); @@ -153,34 +131,6 @@ class OptionalAdapterBeanGatingTest { }); } - @Test - void legacyRedisEnableWithoutExplicitMigrationModeCreatesNoBackend() { - runner - .withPropertyValues("app.cache.redis.enabled=true", "app.cache.redis.client-mode=external") - .run( - context -> { - assertThat(context).hasNotFailed(); - assertThat(context.getBeansOfType(CacheBackend.class)).isEmpty(); - }); - } - - @Test - void redisEnabledContributesTheBackendAndRoutesBoundLogicalCaches() { - runner - .withPropertyValues( - "app.cache.redis.enabled=true", - "app.cache.redis.client-mode=external", - "ca-skeleton.providers.redis.legacy-migration-enabled=true", - "app.cache.bindings.worklog=redis") - .run( - context -> { - assertThat(context).hasNotFailed(); - assertThat(context.getBeansOfType(CacheStore.class)).hasSize(1); - CacheStoreRouter router = context.getBean(CacheStoreRouter.class); - assertThat(router.get("worklog", "k")).isEmpty(); - }); - } - @Test void slackWebhookEnabledContributesTheProviderAndRoutesBoundNotifications() { runner @@ -216,36 +166,6 @@ class OptionalAdapterBeanGatingTest { }); } - @Test - void aSecondBackendPlugsInWithNewFilesOnlyAndBothRouteByLogicalName() { - // OCP proof: SecondBackendConfig simulates a future backend added as a NEW config - // only — RedisCacheAdapterConfig / CacheRouterConfig are not touched. - runner - .withUserConfiguration(SecondBackendConfig.class) - .withPropertyValues( - "app.cache.redis.enabled=true", - "app.cache.redis.client-mode=external", - "ca-skeleton.providers.redis.legacy-migration-enabled=true", - "app.cache.test-second.enabled=true", - "app.cache.bindings.worklog=redis", - "app.cache.bindings.session=test-second") - .run( - context -> { - assertThat(context).hasNotFailed(); - assertThat(context.getBeansOfType(CacheStore.class)).hasSize(2); - CacheStoreRouter router = context.getBean(CacheStoreRouter.class); - assertThat(router.get("session", "k")).contains("from-second-backend"); - assertThat(router.get("worklog", "k")).isEmpty(); - }); - } - - @Test - void aBindingToADisabledBackendFailsStartup() { - runner - .withPropertyValues("app.cache.bindings.worklog=redis") - .run(context -> assertThat(context).hasFailed()); - } - @Test void aRouteBindingToADisabledProviderFailsStartup() { // configuration contradiction: route references a provider that contributed no bean @@ -254,30 +174,6 @@ class OptionalAdapterBeanGatingTest { .run(context -> assertThat(context).hasFailed()); } - /** Simulates a forking project's additional cache backend — new files only. */ - @Configuration - static class SecondBackendConfig { - - @Bean - @ConditionalOnProperty(name = "app.cache.test-second.enabled", havingValue = "true") - CacheBackend secondBackend() { - return new CacheBackend() { - @Override - public String backendId() { - return "test-second"; - } - - @Override - public Optional get(String key) { - return Optional.of("from-second-backend"); - } - - @Override - public void put(String key, String value) {} - }; - } - } - /** Supplies the integration-seam client beans an enabled adapter requires. */ @Configuration static class StubClientsConfig { @@ -287,19 +183,6 @@ class OptionalAdapterBeanGatingTest { return message -> {}; } - @Bean - RedisClient redisClient() { - return new RedisClient() { - @Override - public Optional read(String key) { - return Optional.empty(); - } - - @Override - public void write(String key, String value) {} - }; - } - @Bean SlackClient slackClient() { return notification -> {}; diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientAutoConfigurationTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientAutoConfigurationTest.java new file mode 100644 index 00000000..305f6013 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientAutoConfigurationTest.java @@ -0,0 +1,140 @@ +package dev.caskeleton.bootstrap.autoconfigure.httpclient; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName; +import dev.caskeleton.adapter.outbound.httpclient.dynamic.DynamicCredentialBinding; +import dev.caskeleton.adapter.outbound.httpclient.dynamic.DynamicTargetGateway; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry; +import dev.caskeleton.adapter.outbound.httpclient.restclient.GenericHttpGateway; +import dev.caskeleton.adapter.outbound.httpclient.service.HttpServiceRegistry; +import dev.caskeleton.adapter.outbound.httpclient.service.ReactiveHttpServiceRegistry; +import dev.caskeleton.adapter.outbound.httpclient.webclient.ReactiveClientRuntime; +import dev.caskeleton.adapter.outbound.httpclient.webclient.ReactiveSseGateway; +import java.time.Clock; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * What an enabled platform publishes. + * + *

The off-state contract lives in {@link HttpClientPlatformActivationTest}; every runner here is + * explicitly enabled, because "these beans exist" is only a meaningful claim once a deployment has + * asked for them. + */ +class HttpClientAutoConfigurationTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(HttpClientPlatformAutoConfiguration.class)) + .withUserConfiguration(SupportingBeans.class) + .withPropertyValues("app.httpclient.enabled=true"); + + @Test + void publishesEveryCallerFacingEntryPoint() { + runner + .withPropertyValues(paymentClient()) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(HttpServiceRegistry.class); + assertThat(context).hasSingleBean(ReactiveHttpServiceRegistry.class); + assertThat(context).hasSingleBean(GenericHttpGateway.class); + assertThat(context).hasSingleBean(ReactiveSseGateway.class); + assertThat(context).hasSingleBean(DynamicTargetGateway.class); + }); + } + + @Test + void bindsADynamicTargetPolicyWithoutAnyInheritedCredential() { + runner + .withPropertyValues(paymentClient()) + .withPropertyValues( + "app.httpclient.dynamic-targets[0].name=webhook", + "app.httpclient.dynamic-targets[0].allowed-schemes[0]=https", + "app.httpclient.dynamic-targets[0].allowed-ports[0]=443", + "app.httpclient.dynamic-targets[0].max-redirect-hops=1") + .run( + context -> { + assertThat(context).hasNotFailed(); + HttpClientPlatformSettings settings = + context.getBean(HttpClientPlatformSettings.class); + assertThat(settings.dynamicTargets()) + .singleElement() + .satisfies( + target -> { + assertThat(target.name()).isEqualTo("webhook"); + assertThat(target.tracePropagation()).isFalse(); + }); + }); + } + + @Test + void reactiveProfilesProduceAReactiveRuntime() { + runner + .withPropertyValues( + "app.httpclient.clients[0].name=events", + "app.httpclient.clients[0].base-url=https://events.test", + "app.httpclient.clients[0].allowed-hosts[0]=events.test", + "app.httpclient.clients[0].allowed-ports[0]=443", + "app.httpclient.clients[0].api=WEB_CLIENT", + "app.httpclient.clients[0].transport=REACTOR_NETTY", + "app.httpclient.clients[0].request.max-body-bytes=1024", + "app.httpclient.clients[0].tls.profile-id=events") + .run( + context -> { + assertThat(context).hasNotFailed(); + ClientRuntimeRegistry registry = context.getBean(ClientRuntimeRegistry.class); + assertThat(registry.current(new ClientProfileName("events"))) + .isInstanceOf(ReactiveClientRuntime.class); + }); + } + + @Test + void actuatorReportExposesNoTargetOrCredentialDetail() { + runner + .withPropertyValues(paymentClient()) + .run( + context -> { + Map report = + new HttpClientActuatorEndpoint(context.getBean(ClientRuntimeRegistry.class)) + .clients(); + assertThat(report).containsKey("payment"); + assertThat(report.toString()) + .doesNotContain("https://payment.test") + .doesNotContain("secret") + .doesNotContain("Bearer"); + }); + } + + private static String[] paymentClient() { + return new String[] { + "app.httpclient.clients[0].name=payment", + "app.httpclient.clients[0].base-url=https://payment.test", + "app.httpclient.clients[0].allowed-hosts[0]=payment.test", + "app.httpclient.clients[0].allowed-ports[0]=443", + "app.httpclient.clients[0].request.max-body-bytes=1024", + "app.httpclient.clients[0].tls.profile-id=payment" + }; + } + + /** Dynamic credential bindings are an explicit, empty-by-default list (design §9.4). */ + @Configuration(proxyBeanMethods = false) + static class SupportingBeans { + + @Bean + Clock clock() { + return Clock.systemUTC(); + } + + @Bean + List dynamicCredentialBindings() { + return List.of(); + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformActivationTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformActivationTest.java new file mode 100644 index 00000000..67910f33 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformActivationTest.java @@ -0,0 +1,253 @@ +package dev.caskeleton.bootstrap.autoconfigure.httpclient; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.httpclient.auth.CredentialProviderRegistry; +import dev.caskeleton.adapter.outbound.httpclient.dynamic.DynamicCredentialBinding; +import dev.caskeleton.adapter.outbound.httpclient.dynamic.DynamicTargetGateway; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry; +import dev.caskeleton.adapter.outbound.httpclient.resilience.ResilienceRegistry; +import dev.caskeleton.adapter.outbound.httpclient.restclient.GenericHttpGateway; +import dev.caskeleton.adapter.outbound.httpclient.security.TlsMaterialProvider; +import dev.caskeleton.adapter.outbound.httpclient.service.HttpServiceRegistry; +import dev.caskeleton.adapter.outbound.httpclient.service.ReactiveHttpServiceRegistry; +import dev.caskeleton.adapter.outbound.httpclient.webclient.ReactiveSseGateway; +import java.time.Clock; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * The HTTP Client platform exists only where a deployment asked for it. + * + *

Every case here was reachable before this boundary existed. A service that never made an + * outbound call still built transport providers, credential providers, a resilience registry and + * five caller-facing gateways, and still read — and could still be failed by — HTTP configuration + * it had never written. The switch that was supposed to control that did not exist at all: the nine + * configurations sat inside the composition root's component scan, so they were assembled + * unconditionally. + * + *

The runner registers the platform the same way the application does, through its + * auto-configuration entry, so what is under test is the real activation path rather than a + * hand-assembled approximation of it. + */ +class HttpClientPlatformActivationTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(HttpClientPlatformAutoConfiguration.class)) + .withUserConfiguration(SupportingBeans.class); + + @Test + @DisplayName("an absent toggle holds no HTTP bean at all") + void theCapabilityIsAbsentUntilItIsExplicitlyEnabled() { + runner.run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(HttpClientPlatformSettings.class); + assertThat(context).doesNotHaveBean(ClientRuntimeRegistry.class); + assertThat(context).doesNotHaveBean(GenericHttpGateway.class); + assertThat(context).doesNotHaveBean(HttpServiceRegistry.class); + assertThat(context).doesNotHaveBean(ReactiveHttpServiceRegistry.class); + assertThat(context).doesNotHaveBean(ReactiveSseGateway.class); + assertThat(context).doesNotHaveBean(DynamicTargetGateway.class); + assertThat(context).doesNotHaveBean(ResilienceRegistry.class); + assertThat(context).doesNotHaveBean(CredentialProviderRegistry.class); + assertThat(context).doesNotHaveBean(TlsMaterialProvider.class); + assertThat(context).doesNotHaveBean(HttpClientActuatorEndpoint.class); + }); + } + + @Test + @DisplayName("a false toggle with a full valid profile still holds nothing") + void aValidProfileDoesNothingWhileTheSwitchIsOff() { + runner + .withPropertyValues("app.httpclient.enabled=false") + .withPropertyValues(validPaymentClient()) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(HttpClientPlatformSettings.class); + assertThat(context).doesNotHaveBean(ClientRuntimeRegistry.class); + }); + } + + /** + * A malformed detail setting must not fail a deployment that never wanted the capability. + * + *

This is the whole reason the settings are bound inside the gated auto-configuration rather + * than by the global properties scan: a scanned properties class binds — and rejects — regardless + * of the master switch, which turns an optional capability into a mandatory one. + */ + @Test + @DisplayName("malformed detail settings cannot fail a deployment that never enabled the platform") + void detailSettingsAreNotBoundWhileTheCapabilityIsOff() { + runner + .withPropertyValues( + "app.httpclient.clients[0].name=payment", + "app.httpclient.clients[0].timeout.total-call=not-a-duration", + "app.httpclient.clients[0].transport=NOT_A_TRANSPORT", + "app.httpclient.clients[0].request.max-body-bytes=not-a-number") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(HttpClientPlatformSettings.class); + }); + } + + /** + * Spring's property condition treats anything that is not the expected value as "no". + * + *

So {@code enabled=yes} silently disables the platform. A deployment that meant to turn + * outbound HTTP on then fails on its first call with a missing-bean error, and nothing in the + * startup log mentions the toggle. Asserting it here at least makes the semantics deliberate: an + * unusable toggle is off, never on. + */ + @Test + @DisplayName("a toggle that is not a strict boolean does not enable the platform") + void aToggleThatIsNotABooleanDoesNotEnableTheCapability() { + for (String unusable : List.of("yes", "1", "on", "")) { + runner + .withPropertyValues("app.httpclient.enabled=" + unusable) + .withPropertyValues(validPaymentClient()) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(ClientRuntimeRegistry.class); + }); + } + } + + @Test + @DisplayName("enabling it assembles exactly the declared runtimes") + void enablingItAssemblesTheDeclaredRuntimes() { + enabled() + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(HttpClientPlatformSettings.class); + assertThat(context).hasSingleBean(ClientRuntimeRegistry.class); + assertThat(context).hasSingleBean(GenericHttpGateway.class); + assertThat(context).hasSingleBean(HttpServiceRegistry.class); + assertThat(context).hasSingleBean(DynamicTargetGateway.class); + assertThat(context.getBean(ClientRuntimeRegistry.class).names()) + .singleElement() + .satisfies(name -> assertThat(name.value()).isEqualTo("payment")); + }); + } + + /** + * A misspelled key is a configuration error, not a silently ignored one. + * + *

Dropping {@code timeout.total-call} leaves the client running the default four-second budget + * while the configuration says otherwise — the kind of divergence an outbound call platform + * should never make an operator discover from an incident. + */ + @Test + @DisplayName("an unknown key under the prefix is refused rather than ignored") + void anUnknownKeyUnderThePrefixIsRefused() { + enabled() + .withPropertyValues("app.httpclient.clients[0].timeuot.total-call=9s") + .run(context -> assertThat(context).hasFailed()); + } + + @Test + @DisplayName("an enabled platform with no client fails startup with the declared code") + void anEnabledPlatformWithoutAnyClientFailsStartup() { + runner + .withPropertyValues("app.httpclient.enabled=true") + .run( + context -> + assertThat(context) + .hasFailed() + .getFailure() + .hasStackTraceContaining("HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS")); + } + + @Test + @DisplayName("two clients whose names collide in the environment fail startup") + void collidingClientNamesFailStartup() { + enabled() + .withPropertyValues( + "app.httpclient.clients[1].name=payment_api", + "app.httpclient.clients[1].base-url=https://payment.test", + "app.httpclient.clients[1].allowed-hosts[0]=payment.test", + "app.httpclient.clients[1].allowed-ports[0]=443", + "app.httpclient.clients[1].request.max-body-bytes=1048576", + "app.httpclient.clients[1].tls.profile-id=payment", + "app.httpclient.clients[0].name=payment-api") + .run( + context -> + assertThat(context) + .hasFailed() + .getFailure() + .hasStackTraceContaining("normalise to the same environment variable segment")); + } + + @Test + @DisplayName("a dynamic target policy is registered under the name it carries") + void aDynamicTargetPolicyIsRegisteredUnderItsOwnName() { + enabled() + .withPropertyValues( + "app.httpclient.dynamic-targets[0].name=webhook", + "app.httpclient.dynamic-targets[0].allowed-schemes[0]=https", + "app.httpclient.dynamic-targets[0].allowed-ports[0]=443", + "app.httpclient.dynamic-targets[0].max-redirect-hops=1") + .run( + context -> { + assertThat(context).hasNotFailed(); + HttpClientPlatformSettings settings = + context.getBean(HttpClientPlatformSettings.class); + assertThat(settings.dynamicTargets()) + .singleElement() + .satisfies( + target -> { + assertThat(target.name()).isEqualTo("webhook"); + assertThat(target.tracePropagation()).isFalse(); + }); + }); + } + + private ApplicationContextRunner enabled() { + return runner + .withPropertyValues("app.httpclient.enabled=true") + .withPropertyValues(validPaymentClient()); + } + + private static String[] validPaymentClient() { + return new String[] { + "app.httpclient.clients[0].name=payment", + "app.httpclient.clients[0].base-url=https://payment.test", + "app.httpclient.clients[0].allowed-hosts[0]=payment.test", + "app.httpclient.clients[0].allowed-ports[0]=443", + "app.httpclient.clients[0].request.max-body-bytes=1048576", + "app.httpclient.clients[0].tls.profile-id=payment" + }; + } + + /** + * The collaborators the composition root normally supplies. + * + *

The {@link Clock} is the interesting one. It used to come from the platform's own resilience + * configuration, which meant every deployment inherited its clock from a capability it might not + * use; behind the master switch that would have removed the application's clock whenever outbound + * HTTP was off. It now comes from the composition root, and this fixture stands in for it. + */ + @Configuration(proxyBeanMethods = false) + static class SupportingBeans { + + @Bean + Clock clock() { + return Clock.systemUTC(); + } + + @Bean + List dynamicCredentialBindings() { + return List.of(); + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformEnvManifestTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformEnvManifestTest.java new file mode 100644 index 00000000..72cac24b --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformEnvManifestTest.java @@ -0,0 +1,205 @@ +package dev.caskeleton.bootstrap.autoconfigure.httpclient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.core.env.SystemEnvironmentPropertySource; + +/** + * Closes the loop between the settings tree, its documented environment surface, and the binder. + * + *

"Every setting is managed through the environment" is two claims at once: that each field has + * an environment form, and that each documented name still maps to a field. Nothing enforced + * either. A field added to a nested record acquired no documentation, and a documented name whose + * field had been renamed kept being published to operators who would set it and see nothing happen. + * + *

The Gradle {@code verifyEnvKeys} check cannot cover this: it compares three text files, which + * proves a key is declared consistently but not that anything reads it. Only {@code + * APP_HTTPCLIENT_ENABLED} goes through that check, because it is the one key with a + * deployment-independent value; the per-client surface is indexed and per-deployment, and + * templating it in {@code application.yml} would materialise a nameless client in every deployment + * — which the settings' own aggregate validation correctly refuses. + * + *

The names go in as real environment variables through a {@link + * SystemEnvironmentPropertySource} rather than as hand-translated property names, so what is under + * test is the mapping the runtime actually performs. + */ +class HttpClientPlatformEnvManifestTest { + + private static final String MANIFEST = "docs/httpclient/env-fields.yaml"; + + @Test + @DisplayName("the manifest and the settings tree describe the same fields") + void theManifestAndTheSettingsTreeAgree() { + Map derived = HttpClientEnvironmentKeys.fieldToEnvTemplate(); + + assertThat(derived).as("the derivation itself must find something").isNotEmpty(); + assertThat(manifest()) + .as( + "%s must list exactly the leaf fields of HttpClientPlatformSettings, and map each to " + + "the environment name the platform accepts. Derived:%n%s", + MANIFEST, asYaml(derived)) + .containsExactlyInAnyOrderEntriesOf(derived); + } + + @Test + @DisplayName("a client declared purely through environment variables binds") + void aClientDeclaredThroughTheEnvironmentBinds() { + Map variables = new LinkedHashMap<>(); + variables.put("APP_HTTPCLIENT_ENABLED", "true"); + variables.put("APP_HTTPCLIENT_CLIENTS_0_NAME", "payment"); + variables.put("APP_HTTPCLIENT_CLIENTS_0_BASE_URL", "https://payment.test"); + variables.put("APP_HTTPCLIENT_CLIENTS_0_ALLOWED_HOSTS_0", "payment.test"); + variables.put("APP_HTTPCLIENT_CLIENTS_0_ALLOWED_PORTS_0", "443"); + variables.put("APP_HTTPCLIENT_CLIENTS_0_REQUEST_MAX_BODY_BYTES", "1048576"); + variables.put("APP_HTTPCLIENT_CLIENTS_0_TIMEOUT_TOTAL_CALL", "6s"); + variables.put("APP_HTTPCLIENT_CLIENTS_0_PROTOCOLS_0", "HTTP_2"); + variables.put("APP_HTTPCLIENT_CLIENTS_0_TLS_PROFILE_ID", "payment"); + variables.put("APP_HTTPCLIENT_DYNAMIC_TARGETS_0_NAME", "webhook"); + variables.put("APP_HTTPCLIENT_DYNAMIC_TARGETS_0_ALLOWED_SCHEMES_0", "https"); + + HttpClientPlatformSettings bound = bind(variables); + + assertThat(bound.enabled()).isTrue(); + assertThat(bound.clients()) + .singleElement() + .satisfies( + client -> { + assertThat(client.name()).isEqualTo("payment"); + assertThat(client.baseUrl()).isEqualTo("https://payment.test"); + assertThat(client.allowedHosts()).containsExactly("payment.test"); + assertThat(client.allowedPorts()).containsExactly(443); + assertThat(client.protocols()).containsExactly("HTTP_2"); + assertThat(client.request().maxBodyBytes()).isEqualTo(1048576L); + assertThat(client.timeout().totalCall()).hasSeconds(6); + assertThat(client.tls().profileId()).isEqualTo("payment"); + }); + assertThat(bound.dynamicTargets()) + .singleElement() + .satisfies( + target -> { + assertThat(target.name()).isEqualTo("webhook"); + assertThat(target.allowedSchemes()).containsExactly("https"); + }); + } + + /** Two clients from the environment must stay two clients, not one merged one. */ + @Test + @DisplayName("indexed clients keep their identity through the environment") + void twoIndexedClientsRemainDistinct() { + Map variables = new LinkedHashMap<>(); + variables.put("APP_HTTPCLIENT_ENABLED", "true"); + variables.put("APP_HTTPCLIENT_CLIENTS_0_NAME", "payment"); + variables.put("APP_HTTPCLIENT_CLIENTS_1_NAME", "search"); + + assertThat(bind(variables).clients()) + .extracting(HttpClientPlatformSettings.ClientSettings::name) + .containsExactly("payment", "search"); + } + + /** + * The check that strict binding cannot perform. + * + *

Spring enumerates an environment variable with dots where the property name has hyphens, so + * the unbound-element handler cannot tell a misspelling from a correct name. Left to it, this + * typo would silently leave the client on its default budget. + */ + @Test + @DisplayName("a misspelled environment variable is refused") + void aMisspelledEnvironmentVariableIsRefused() { + Map variables = new LinkedHashMap<>(); + variables.put("APP_HTTPCLIENT_ENABLED", "true"); + variables.put("APP_HTTPCLIENT_CLIENTS_0_NAME", "payment"); + variables.put("APP_HTTPCLIENT_CLIENTS_0_TIMEUOT_TOTAL_CALL", "6s"); + + assertThatThrownBy(() -> bind(variables)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("APP_HTTPCLIENT_CLIENTS_0_TIMEUOT_TOTAL_CALL"); + } + + @Test + @DisplayName("src/.env ships the platform disabled") + void theShippedEnvironmentKeepsThePlatformOff() { + assertThat(readLines(repositoryRoot().resolve("src/.env"))) + .as("src/.env must ship the master switch, and ship it off") + .anySatisfy(line -> assertThat(line.trim()).isEqualTo("APP_HTTPCLIENT_ENABLED=false")); + } + + /** Binds through the production binder, so the test exercises the real strictness rules. */ + private static HttpClientPlatformSettings bind(Map variables) { + StandardEnvironment environment = new StandardEnvironment(); + environment + .getPropertySources() + .addFirst( + new SystemEnvironmentPropertySource( + StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME, variables)); + return HttpClientPlatformSettingsBinder.bind(environment); + } + + /** Renders the derivation as the manifest body, so a failure tells you what to write. */ + private static String asYaml(Map entries) { + StringBuilder yaml = new StringBuilder("fields:\n"); + entries.forEach( + (field, env) -> + yaml.append(" - field: ") + .append(field) + .append('\n') + .append(" env: ") + .append(env) + .append('\n')); + return yaml.toString(); + } + + /** Reads the manifest without a YAML parser: it is a flat two-key list by construction. */ + private static Map manifest() { + Path path = repositoryRoot().resolve(MANIFEST); + if (!Files.exists(path)) { + return Map.of(); + } + Map entries = new LinkedHashMap<>(); + String field = null; + for (String line : readLines(path)) { + String trimmed = line.trim(); + if (trimmed.startsWith("- field:")) { + field = trimmed.substring("- field:".length()).trim(); + } else if (trimmed.startsWith("env:") && field != null) { + entries.put(field, trimmed.substring("env:".length()).trim()); + field = null; + } + } + return entries; + } + + /** The module's working directory is {@code src/app-bootstrap} under Gradle. */ + private static Path repositoryRoot() { + Path candidate = Path.of(System.getProperty("user.dir")).toAbsolutePath(); + List tried = new ArrayList<>(); + for (int depth = 0; depth < 4 && candidate != null; depth++) { + tried.add(candidate); + if (Files.exists(candidate.resolve("docs/registries/env-keys.yaml"))) { + return candidate; + } + candidate = candidate.getParent(); + } + throw new AssertionError("repository root not found from " + tried); + } + + private static List readLines(Path path) { + try { + return Files.readAllLines(path); + } catch (IOException exception) { + throw new UncheckedIOException(path + " could not be read", exception); + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformSettingsTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformSettingsTest.java new file mode 100644 index 00000000..6318bc1a --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformSettingsTest.java @@ -0,0 +1,124 @@ +package dev.caskeleton.bootstrap.autoconfigure.httpclient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The aggregate rules a single client's own fields cannot express. + * + *

Constructed directly rather than through a context, because these are invariants of the value + * itself: a test that built them by hand and got an object back would be the bug. + */ +class HttpClientPlatformSettingsTest { + + @Test + @DisplayName("an enabled platform with no client is a startup failure, not an idle platform") + void anEnabledPlatformWithoutAnyClientIsRefused() { + assertThatThrownBy(() -> new HttpClientPlatformSettings(true, List.of(), List.of())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS"); + } + + @Test + @DisplayName("a disabled platform with no client is the normal case") + void aDisabledPlatformWithoutAnyClientIsFine() { + assertThat(new HttpClientPlatformSettings(false, List.of(), List.of()).clients()).isEmpty(); + } + + @Test + @DisplayName("null lists bind as empty rather than exploding later") + void absentListsBecomeEmptyOnes() { + HttpClientPlatformSettings settings = new HttpClientPlatformSettings(false, null, null); + + assertThat(settings.clients()).isEmpty(); + assertThat(settings.dynamicTargets()).isEmpty(); + } + + @Test + @DisplayName("two clients with the same name are refused") + void duplicateClientNamesAreRefused() { + assertThatThrownBy( + () -> + new HttpClientPlatformSettings( + true, List.of(client("payment"), client("payment")), List.of())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("more than once") + .hasMessageContaining("payment"); + } + + /** + * Two names that are distinct as properties but identical as environment variables. + * + *

{@code payment-api} and {@code payment_api} both render as {@code PAYMENT_API}. Under the + * previous map-keyed shape one would have overwritten the other with nothing said about it, so a + * deployment could believe it had configured two upstreams and be calling one of them twice. + */ + @Test + @DisplayName("client names that collide once normalised for the environment are refused") + void environmentCollidingClientNamesAreRefused() { + assertThatThrownBy( + () -> + new HttpClientPlatformSettings( + true, List.of(client("payment-api"), client("payment_api")), List.of())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("normalise to the same environment variable segment") + .hasMessageContaining("PAYMENTAPI"); + } + + @Test + @DisplayName("a client without a name is refused") + void anUnnamedClientIsRefused() { + assertThatThrownBy(() -> new HttpClientPlatformSettings(true, List.of(client(" ")), List.of())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("name must be non-blank"); + } + + @Test + @DisplayName("the same rules apply to dynamic target policies") + void dynamicTargetNamesFollowTheSameRules() { + assertThatThrownBy( + () -> + new HttpClientPlatformSettings( + false, List.of(), List.of(dynamicTarget("web-hook"), dynamicTarget("webhook")))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("dynamic-targets"); + } + + /** + * A client whose only meaningful field here is its name. + * + *

The aggregate rules read nothing else, and filling in nineteen components would state a + * dependency this test does not have. + */ + private static HttpClientPlatformSettings.ClientSettings client(String name) { + return new HttpClientPlatformSettings.ClientSettings( + name, + "TRUSTED", + null, + List.of(), + List.of(), + "REST_CLIENT", + "APACHE", + List.of("HTTP_1_1"), + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); + } + + private static HttpClientPlatformSettings.DynamicTargetSettings dynamicTarget(String name) { + return new HttpClientPlatformSettings.DynamicTargetSettings( + name, List.of("https"), List.of(443), List.of(), List.of(), 0, false, List.of()); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/ReactiveAuthenticationContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/ReactiveAuthenticationContractTest.java new file mode 100644 index 00000000..d1fa1ef8 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/ReactiveAuthenticationContractTest.java @@ -0,0 +1,144 @@ +package dev.caskeleton.bootstrap.autoconfigure.httpclient; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.httpclient.auth.CredentialType; +import dev.caskeleton.adapter.outbound.httpclient.auth.ReactiveCredentialProviderRegistry; +import dev.caskeleton.adapter.outbound.httpclient.auth.ReactiveRequestCredentialProvider; +import dev.caskeleton.adapter.outbound.httpclient.dynamic.DynamicCredentialBinding; +import java.time.Clock; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * A reactive profile gets the credential it declared, or refuses to start. + * + *

It used to get neither. The composition root wired {@code NoAuthCredentialProvider} as the one + * reactive provider for every profile, so a WEB_CLIENT profile declaring BASIC, an API key or a + * static bearer started cleanly and then sent every request anonymously. The 401 that came back was + * indistinguishable from an upstream problem. + */ +class ReactiveAuthenticationContractTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(HttpClientPlatformAutoConfiguration.class)) + .withUserConfiguration(SupportingBeans.class) + .withPropertyValues("app.httpclient.enabled=true"); + + @Test + @DisplayName("the reactive provider is a registry, not a hard-wired no-auth provider") + void theReactiveProviderServesEveryNonBlockingMechanism() { + runner + .withPropertyValues(reactiveClient("NONE")) + .run( + context -> { + assertThat(context).hasNotFailed(); + ReactiveRequestCredentialProvider provider = + context.getBean(ReactiveRequestCredentialProvider.class); + + assertThat(provider).isInstanceOf(ReactiveCredentialProviderRegistry.class); + ReactiveCredentialProviderRegistry registry = + (ReactiveCredentialProviderRegistry) provider; + assertThat(registry.supports(CredentialType.BASIC)).isTrue(); + assertThat(registry.supports(CredentialType.API_KEY_HEADER)).isTrue(); + assertThat(registry.supports(CredentialType.STATIC_BEARER)).isTrue(); + // No non-blocking token load exists, so this one must not be quietly present either. + assertThat(registry.supports(CredentialType.OAUTH2_CLIENT_CREDENTIALS)).isFalse(); + }); + } + + @Test + @DisplayName("a reactive profile declaring a supported mechanism starts") + void aReactiveProfileWithASupportedMechanismStarts() { + runner + .withPropertyValues(reactiveClient("STATIC_BEARER")) + .withPropertyValues( + "app.httpclient.clients[0].authentication.secret-reference=secret://env/EVENTS_TOKEN") + .run(context -> assertThat(context).hasNotFailed()); + } + + /** + * The combination with no non-blocking implementation fails at startup. + * + *

Refusing to start is the only honest outcome: the alternative that shipped was a context + * that started and sent nothing. + */ + @Test + @DisplayName("a reactive profile declaring OAuth2 is refused at startup") + void aReactiveProfileDeclaringOauthIsRefused() { + runner + .withPropertyValues(reactiveClient("OAUTH2_CLIENT_CREDENTIALS")) + .withPropertyValues("app.httpclient.clients[0].authentication.registration-id=events") + .run( + context -> + assertThat(context) + .hasFailed() + .getFailure() + .hasStackTraceContaining("REACTIVE_AUTHENTICATION_UNSUPPORTED")); + } + + /** + * The new rule is about the reactive path only. + * + *

A blocking OAuth2 profile does fail in this context, but for its own long-standing reason: + * the runner supplies no {@code OAuth2AuthorizedClientManager}, so the blocking registry has no + * provider to register. The distinction matters — asserting merely "it failed" would let the + * reactive rule start rejecting blocking profiles too without anything noticing. + */ + @Test + @DisplayName("OAuth2 on a blocking profile is not rejected by the reactive rule") + void oauthOnABlockingProfileIsUnaffected() { + runner + .withPropertyValues( + "app.httpclient.clients[0].name=payment", + "app.httpclient.clients[0].base-url=https://payment.test", + "app.httpclient.clients[0].allowed-hosts[0]=payment.test", + "app.httpclient.clients[0].allowed-ports[0]=443", + "app.httpclient.clients[0].request.max-body-bytes=1024", + "app.httpclient.clients[0].tls.profile-id=payment", + "app.httpclient.clients[0].authentication.type=OAUTH2_CLIENT_CREDENTIALS", + "app.httpclient.clients[0].authentication.registration-id=payment") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat( + org.assertj.core.util.Throwables.getStackTrace(context.getStartupFailure())) + .contains("no credential provider is registered") + .doesNotContain("REACTIVE_AUTHENTICATION_UNSUPPORTED"); + }); + } + + private static String[] reactiveClient(String authenticationType) { + return new String[] { + "app.httpclient.clients[0].name=events", + "app.httpclient.clients[0].base-url=https://events.test", + "app.httpclient.clients[0].allowed-hosts[0]=events.test", + "app.httpclient.clients[0].allowed-ports[0]=443", + "app.httpclient.clients[0].api=WEB_CLIENT", + "app.httpclient.clients[0].transport=REACTOR_NETTY", + "app.httpclient.clients[0].request.max-body-bytes=1024", + "app.httpclient.clients[0].tls.profile-id=events", + "app.httpclient.clients[0].authentication.type=" + authenticationType + }; + } + + @Configuration(proxyBeanMethods = false) + static class SupportingBeans { + + @Bean + Clock clock() { + return Clock.systemUTC(); + } + + @Bean + List dynamicCredentialBindings() { + return List.of(); + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/UnsafeStartupConfigurationTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/UnsafeStartupConfigurationTest.java new file mode 100644 index 00000000..84b17a7d --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/UnsafeStartupConfigurationTest.java @@ -0,0 +1,146 @@ +package dev.caskeleton.bootstrap.autoconfigure.httpclient; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.httpclient.dynamic.DynamicCredentialBinding; +import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry; +import dev.caskeleton.adapter.outbound.httpclient.service.HttpServiceRegistry; +import java.time.Clock; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Configurations an operator can write but must not be allowed to run. + * + *

Each case is refused before any runtime resource exists, which is the point: a rejected + * configuration must not have opened a connection pool, built a TLS context or resolved a + * credential on its way to being rejected. + */ +class UnsafeStartupConfigurationTest { + + private static final String[] VALID_PAYMENT_PROFILE = { + "app.httpclient.enabled=true", + "app.httpclient.clients[0].name=payment", + "app.httpclient.clients[0].base-url=https://payment.test", + "app.httpclient.clients[0].allowed-hosts[0]=payment.test", + "app.httpclient.clients[0].allowed-ports[0]=443", + "app.httpclient.clients[0].transport=APACHE", + "app.httpclient.clients[0].request.max-body-bytes=1048576", + "app.httpclient.clients[0].tls.profile-id=payment" + }; + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(HttpClientPlatformAutoConfiguration.class)) + .withUserConfiguration(SupportingBeans.class); + + @Test + void productionTrustAllConfigurationFailsStartup() { + runner + .withPropertyValues(VALID_PAYMENT_PROFILE) + .withPropertyValues( + "spring.profiles.active=prod", "app.httpclient.clients[0].tls.trust-all=true") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).hasMessageContaining("TRUST_ALL_FORBIDDEN"); + }); + } + + @Test + void productionPlaintextTargetFailsStartup() { + runner + .withPropertyValues( + "spring.profiles.active=prod", + "app.httpclient.enabled=true", + "app.httpclient.clients[0].name=payment", + "app.httpclient.clients[0].base-url=http://payment.test", + "app.httpclient.clients[0].allowed-hosts[0]=payment.test", + "app.httpclient.clients[0].allowed-ports[0]=80", + "app.httpclient.clients[0].request.max-body-bytes=1024", + "app.httpclient.clients[0].tls.profile-id=payment") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasMessageContaining("PLAINTEXT_PRODUCTION_TARGET"); + }); + } + + @Test + void productionSimpleRequestFactoryFailsStartup() { + runner + .withPropertyValues(VALID_PAYMENT_PROFILE) + .withPropertyValues( + "spring.profiles.active=prod", "app.httpclient.clients[0].transport=SIMPLE") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasMessageContaining("PRODUCTION_SIMPLE_FACTORY_FORBIDDEN"); + }); + } + + @Test + void unacknowledgedHttp3FailsStartup() { + runner + .withPropertyValues(VALID_PAYMENT_PROFILE) + .withPropertyValues("app.httpclient.clients[0].protocols[0]=HTTP_3") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasMessageContaining("HTTP3_STABLE_FORBIDDEN"); + }); + } + + @Test + void dynamicProfileWithADefaultCredentialFailsStartup() { + runner + .withPropertyValues(VALID_PAYMENT_PROFILE) + .withPropertyValues( + "app.httpclient.clients[0].mode=DYNAMIC", + "app.httpclient.clients[0].authentication.type=OAUTH2_CLIENT_CREDENTIALS", + "app.httpclient.clients[0].authentication.registration-id=payment") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasMessageContaining("DYNAMIC_DEFAULT_CREDENTIAL_FORBIDDEN"); + }); + } + + @Test + void bindsNamedProfileAndCreatesTypedRegistry() { + runner + .withPropertyValues(VALID_PAYMENT_PROFILE) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(ClientRuntimeRegistry.class); + assertThat(context).hasSingleBean(HttpServiceRegistry.class); + assertThat(context.getBean(ClientRuntimeRegistry.class).names()) + .singleElement() + .satisfies(name -> assertThat(name.value()).isEqualTo("payment")); + }); + } + + /** The collaborators the composition root normally supplies. */ + @Configuration(proxyBeanMethods = false) + static class SupportingBeans { + + @Bean + Clock clock() { + return Clock.systemUTC(); + } + + @Bean + List dynamicCredentialBindings() { + return List.of(); + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ConditionalTransportQualificationContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ConditionalTransportQualificationContractTest.java new file mode 100644 index 00000000..7618794e --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ConditionalTransportQualificationContractTest.java @@ -0,0 +1,688 @@ +package dev.caskeleton.bootstrap.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.bootstrap.contract.support.RepositoryContractResources; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; + +class ConditionalTransportQualificationContractTest { + + // 29 pre-existing controls plus the eight release-blocking HTTP Client Platform gates + // registered in .github/ci-gate-matrix.yml (design §38). + private static final int EXPECTED_GATE_COUNT = 38; + + /** + * Filler gates a well-formed fixture needs beside its one target gate. + * + *

Derived rather than written down. The validator refuses a matrix whose size differs from its + * own embedded count, so a fixture built from a stale literal fails for the wrong reason and + * hides whatever the test was actually about. + */ + private static final int FILLER_GATE_COUNT = EXPECTED_GATE_COUNT - 1; + + private static final Duration VALIDATOR_TIMEOUT = Duration.ofSeconds(10); + private static final Set EXPECTED_GATE_FIELDS = + Set.of("id", "release_blocking", "mechanism", "ref", "workflow", "job", "execution"); + private static final Set ALLOWED_MECHANISMS = + Set.of( + "gradle-custom-task", + "gradle-plugin-task", + "contract-test", + "workflow-job", + "delegated-pending"); + private static final Set ALLOWED_EXECUTIONS = Set.of("check", "explicit", "job"); + private static final Set EXPECTED_GATE_IDS = + Set.of( + "format-lint", + "unit-and-contract-tests", + "conditional-transport-qualification", + "clean-architecture-dependencies", + "environment-contract", + "one-type-per-file", + "readme-command-drift", + "trivy-suppression-governance", + "quarantine-sunset", + "public-path-snapshot", + "dependency-locks", + "architecture-contract-test", + "sample-off", + "gate-matrix-lint", + "redis-sdk", + "jpa-candidate-evidence", + "jpa-r2-evidence", + "quality-release-gate", + "flaky-quarantine", + "dependency-review", + "dependency-submission", + "filesystem-vulnerability-scan", + "documentation-links", + "object-storage-minio-managed-contract", + "poster-image-migration", + "object-storage-minio-managed-fault", + "object-storage-aws-protected-qualification", + "redis-sdk-support-matrix", + "redis-sdk-topology-evidence", + // HTTP Client Platform release gates (design §38). + "httpclient-stable-contract", + "httpclient-security-suite", + "httpclient-fault-injection", + "httpclient-performance-certification", + "httpclient-spring62-api-surface", + "httpclient-spring62-runtime", + "httpclient-spring70-compatibility", + "httpclient-documentation-drift", + "httpclient-event-loop-blocking"); + + @Test + void ownerQualificationsNameEveryRequiredWireClassAndRootOnlyAggregates() throws IOException { + Path root = repositoryRoot(); + String convention = + Files.readString(root.resolve("src/gradle/strict-qualification-test.gradle")); + String rootBuild = Files.readString(root.resolve("src/build.gradle")); + String graphql = Files.readString(root.resolve("src/adapter/inbound/graphql/build.gradle")); + String grpc = Files.readString(root.resolve("src/adapter/inbound/grpc/build.gradle")); + String websocket = Files.readString(root.resolve("src/adapter/inbound/websocket/build.gradle")); + + assertThat(convention) + .contains("failOnNoMatchingTests = true") + .contains("failOnNoDiscoveredTests = true") + .contains("forbids skipped tests") + .contains("verifyRequiredJUnitClasses"); + assertThat(graphql) + .contains("registerStrictQualificationTest") + .contains("dev.caskeleton.adapter.inbound.graphql.GraphqlHttpBoundaryQualificationTest"); + assertThat(grpc) + .contains("registerStrictQualificationTest") + .contains("dev.caskeleton.adapter.inbound.grpc.GrpcSafeActivationTest") + .contains("dev.caskeleton.adapter.inbound.grpc.GrpcP1BoundaryWireTest"); + assertThat(websocket) + .contains("registerStrictQualificationTest") + .contains("dev.caskeleton.adapter.inbound.websocket.WebSocketBoundaryQualificationTest"); + assertThat(rootBuild) + .contains("tasks.register('conditionalTransportQualification')") + .contains(":adapter:inbound:graphql:graphqlTransportQualificationTest") + .contains(":adapter:inbound:grpc:grpcTransportQualificationTest") + .contains(":adapter:inbound:websocket:websocketTransportQualificationTest") + .doesNotContain("registerConditionalTransportQualificationTest") + .doesNotContain("GraphqlHttpBoundaryQualificationTest") + .doesNotContain("GrpcP1BoundaryWireTest") + .doesNotContain("WebSocketBoundaryQualificationTest"); + } + + @Test + void releaseBlockingQualityJobAndGateMatrixInvokeTheAggregate() throws IOException { + Path root = repositoryRoot(); + String workflow = Files.readString(root.resolve(".github/workflows/ci-quality-gates.yml")); + String matrix = Files.readString(root.resolve(".github/ci-gate-matrix.yml")); + String validator = Files.readString(root.resolve(".github/scripts/verify-gate-matrix.sh")); + + assertThat(workflow) + .contains("./gradlew conditionalTransportQualification") + .doesNotContain("conditionalTransportQualification --continue"); + assertThat(matrix) + .contains("id: conditional-transport-qualification") + .contains("ref: conditionalTransportQualification") + .contains("job: quality-gates") + .contains("execution: explicit"); + assertThat(validator).contains("readonly EXPECTED_GATE_COUNT=" + EXPECTED_GATE_COUNT); + } + + @Test + void explicitFixtureRootRunsTheActualValidatorOutsideItsScriptLocation(@TempDir Path tempDir) + throws IOException { + Path fixtureRoot = + writeFixture( + tempDir.resolve("fixture"), "Run target", "./gradlew targetGate", FILLER_GATE_COUNT); + + ScriptResult result = runValidator(fixtureRoot); + + assertThat(result.exitCode()).isZero(); + assertThat(result.output()) + .contains( + "gate-matrix-lint: " + + EXPECTED_GATE_COUNT + + " gates, " + + EXPECTED_GATE_COUNT + + " verified, 0 delegated-pending") + .contains("gate-matrix-lint: OK"); + } + + @Test + void defaultModeValidatesTheRealRepositoryAndRetainsItsLocationGuard() throws IOException { + Path root = repositoryRoot(); + String validator = Files.readString(validatorPath()); + + ScriptResult result = runScript(root, List.of()); + + assertThat(result.exitCode()).isZero(); + assertThat(result.output()).contains("gate-matrix-lint: OK"); + assertThat(validator) + .contains("EXPECTED_SCRIPT_DIR") + .contains("script location must be repository .github/scripts directory"); + } + + @Test + void defaultModeRejectsARelocatedScript(@TempDir Path tempDir) throws IOException { + Path fixtureRoot = + writeFixture( + tempDir.resolve("fixture"), "Run target", "./gradlew targetGate", FILLER_GATE_COUNT); + Files.createDirectories(fixtureRoot.resolve(".github/scripts")); + Path relocatedScript = fixtureRoot.resolve("relocated-verify-gate-matrix.sh"); + Files.copy(validatorPath(), relocatedScript); + ScriptResult gitInit = + runCommand(fixtureRoot, List.of("git", "init", "--quiet", fixtureRoot.toString())); + assertThat(gitInit.exitCode()).isZero(); + + ScriptResult result = runScriptAt(relocatedScript, fixtureRoot, List.of()); + + assertThat(result.exitCode()).isNotZero(); + assertThat(result.output()) + .contains("script location must be repository .github/scripts directory"); + } + + @Test + void validatorRejectsMoreThanOneRepositoryRootArgument(@TempDir Path tempDir) throws IOException { + Path fixtureRoot = + writeFixture( + tempDir.resolve("fixture"), "Run target", "./gradlew targetGate", FILLER_GATE_COUNT); + + ScriptResult result = runScript(fixtureRoot, List.of(fixtureRoot.toString(), "extra")); + + assertThat(result.exitCode()).isNotZero(); + assertThat(result.output()).contains("expected zero arguments or one repository root"); + } + + @Test + void validatorRejectsMissingRepositoryRootAndMatrix(@TempDir Path tempDir) throws IOException { + Path missingRoot = tempDir.resolve("missing-root"); + ScriptResult missingRootResult = runScript(tempDir, List.of(missingRoot.toString())); + assertThat(missingRootResult.exitCode()).isNotZero(); + assertThat(missingRootResult.output()) + .contains("repository root is not a directory: " + missingRoot); + + Path emptyRoot = tempDir.resolve("empty-root"); + Files.createDirectories(emptyRoot); + ScriptResult missingMatrixResult = runValidator(emptyRoot); + assertThat(missingMatrixResult.exitCode()).isNotZero(); + assertThat(missingMatrixResult.output()) + .contains("missing " + emptyRoot.resolve(".github/ci-gate-matrix.yml")); + } + + @Test + void deceptiveStepNameAndEchoDoNotSatisfyExplicitExecution(@TempDir Path tempDir) + throws IOException { + Path fixtureRoot = + writeFixture( + tempDir.resolve("fixture"), "./gradlew targetGate", "echo disabled", FILLER_GATE_COUNT); + + ScriptResult result = runValidator(fixtureRoot); + + assertRejectedAsNotExplicit(result); + } + + @Test + void differentProjectTaskWithTheSameNameDoesNotSatisfyExplicitExecution(@TempDir Path tempDir) + throws IOException { + Path fixtureRoot = + writeFixture( + tempDir.resolve("fixture"), + "Run target", + "./gradlew :other:targetGate", + FILLER_GATE_COUNT); + + ScriptResult result = runValidator(fixtureRoot); + + assertRejectedAsNotExplicit(result); + } + + @Test + void shorthandRunStepSatisfiesExplicitExecution(@TempDir Path tempDir) throws IOException { + Path fixtureRoot = + writeFixture( + tempDir.resolve("fixture"), "Run target", "./gradlew targetGate", FILLER_GATE_COUNT); + replace( + fixtureRoot.resolve(".github/workflows/fixture.yml"), + " - name: Run target\n run: ./gradlew targetGate\n", + " - run: ./gradlew targetGate\n"); + + ScriptResult result = runValidator(fixtureRoot); + + assertThat(result.exitCode()).isZero(); + assertThat(result.output()).contains("gate-matrix-lint: OK"); + } + + @Test + void leafCheckDoesNotSatisfyTheRequiredRootCheck(@TempDir Path tempDir) throws IOException { + Path fixtureRoot = + writeCheckFixture(tempDir.resolve("fixture"), "./gradlew :app-bootstrap:check", true); + + ScriptResult result = runValidator(fixtureRoot); + + assertThat(result.exitCode()).isNotZero(); + assertThat(result.output()) + .contains("gate 'target-gate' expects Gradle check in job 'target-job'"); + } + + @Test + void suppressionAndNonExecutionArgumentsDoNotSatisfyExplicitExecution(@TempDir Path tempDir) + throws IOException { + List rejectedCommands = + List.of( + "./gradlew targetGate --dry-run", + "./gradlew targetGate -m", + "./gradlew targetGate -x targetGate", + "./gradlew targetGate --exclude-task targetGate", + "./gradlew targetGate \"--dry-run\"", + "./gradlew targetGate \\--dry-run", + "./gradlew targetGate --help", + "./gradlew targetGate --status"); + + for (int index = 0; index < rejectedCommands.size(); index++) { + Path fixtureRoot = + writeFixture( + tempDir.resolve("fixture-" + index), + "Run target", + rejectedCommands.get(index), + FILLER_GATE_COUNT); + + ScriptResult result = runValidator(fixtureRoot); + + assertThat(result.output()).as("command: %s", rejectedCommands.get(index)).isNotBlank(); + assertRejectedAsNotExplicit(result); + } + } + + @Test + void validatorRejectsWrongCountDuplicateIdUnregisteredTaskAndMissingJob(@TempDir Path tempDir) + throws IOException { + Path shortMatrix = + writeFixture( + tempDir.resolve("short-matrix"), + "Run target", + "./gradlew targetGate", + FILLER_GATE_COUNT - 1); + ScriptResult shortMatrixResult = runValidator(shortMatrix); + assertThat(shortMatrixResult.exitCode()).isNotZero(); + assertThat(shortMatrixResult.output()) + .contains( + "matrix has " + (EXPECTED_GATE_COUNT - 1) + " gates; expected " + EXPECTED_GATE_COUNT); + + Path duplicateId = + writeFixture( + tempDir.resolve("duplicate-id"), + "Run target", + "./gradlew targetGate", + FILLER_GATE_COUNT); + replace( + duplicateId.resolve(".github/ci-gate-matrix.yml"), "id: filler-gate-01", "id: target-gate"); + ScriptResult duplicateResult = runValidator(duplicateId); + assertThat(duplicateResult.exitCode()).isNotZero(); + assertThat(duplicateResult.output()).contains("duplicate gate id 'target-gate'"); + + Path unregisteredTask = + writeFixture( + tempDir.resolve("unregistered-task"), + "Run target", + "./gradlew targetGate", + FILLER_GATE_COUNT); + Files.writeString(unregisteredTask.resolve("src/sample/build.gradle"), "plugins {}\n"); + ScriptResult unregisteredResult = runValidator(unregisteredTask); + assertThat(unregisteredResult.exitCode()).isNotZero(); + assertThat(unregisteredResult.output()) + .contains("gate 'target-gate' references unregistered Gradle task 'targetGate'"); + + Path unrelatedName = + writeFixture( + tempDir.resolve("unrelated-name"), + "Run target", + "./gradlew targetGate", + FILLER_GATE_COUNT); + Files.writeString( + unrelatedName.resolve("src/sample/build.gradle"), + "someUnrelatedConfiguration {\n name: 'targetGate'\n}\n"); + ScriptResult unrelatedNameResult = runValidator(unrelatedName); + assertThat(unrelatedNameResult.exitCode()).isNotZero(); + assertThat(unrelatedNameResult.output()) + .contains("gate 'target-gate' references unregistered Gradle task 'targetGate'"); + + Path missingJob = + writeFixture( + tempDir.resolve("missing-job"), + "Run target", + "./gradlew targetGate", + FILLER_GATE_COUNT); + replace( + missingJob.resolve(".github/ci-gate-matrix.yml"), "job: target-job", "job: missing-job"); + ScriptResult missingJobResult = runValidator(missingJob); + assertThat(missingJobResult.exitCode()).isNotZero(); + assertThat(missingJobResult.output()) + .contains("gate 'target-gate' references missing job 'missing-job' in 'fixture.yml'"); + } + + @Test + void validatorRejectsUnsafeCustomTaskRefAndMissingCheckWiring(@TempDir Path tempDir) + throws IOException { + Path unsafeRef = + writeFixture( + tempDir.resolve("unsafe-ref"), "Run target", "./gradlew targetGate", FILLER_GATE_COUNT); + replace( + unsafeRef.resolve(".github/ci-gate-matrix.yml"), "ref: targetGate", "ref: targetGate.*"); + ScriptResult unsafeRefResult = runValidator(unsafeRef); + assertThat(unsafeRefResult.exitCode()).isNotZero(); + assertThat(unsafeRefResult.output()) + .contains("gate 'target-gate' has unsafe Gradle custom task ref 'targetGate.*'"); + + Path missingWiring = + writeCheckFixture(tempDir.resolve("missing-wiring"), "./gradlew check", false); + ScriptResult missingWiringResult = runValidator(missingWiring); + assertThat(missingWiringResult.exitCode()).isNotZero(); + assertThat(missingWiringResult.output()) + .contains("gate 'target-gate' task 'targetGate' exists but is not wired into Gradle check"); + } + + @Test + void validatorDoesNotInterpretCustomTaskOrPluginRefsAsRegularExpressions(@TempDir Path tempDir) + throws IOException { + Path dottedTask = + writeFixture( + tempDir.resolve("dotted-task"), "Run target", "./gradlew foo.bar", FILLER_GATE_COUNT); + replace(dottedTask.resolve(".github/ci-gate-matrix.yml"), "ref: targetGate", "ref: foo.bar"); + Files.writeString(dottedTask.resolve("src/sample/build.gradle"), "tasks.register('fooXbar')\n"); + ScriptResult dottedTaskResult = runValidator(dottedTask); + assertThat(dottedTaskResult.exitCode()).isNotZero(); + assertThat(dottedTaskResult.output()) + .contains("gate 'target-gate' has unsafe Gradle custom task ref 'foo.bar'"); + + Path unsafePlugin = + writeCheckFixture(tempDir.resolve("unsafe-plugin"), "./gradlew check", true); + replace( + unsafePlugin.resolve(".github/ci-gate-matrix.yml"), + "mechanism: gradle-custom-task", + "mechanism: gradle-plugin-task"); + replace( + unsafePlugin.resolve(".github/ci-gate-matrix.yml"), + "ref: targetGate", + "ref: com.diffplug.*@spotlessCheck"); + Files.writeString( + unsafePlugin.resolve("src/sample/build.gradle"), + "plugins { id 'com.diffplug.unrelated' }\n"); + ScriptResult unsafePluginResult = runValidator(unsafePlugin); + assertThat(unsafePluginResult.exitCode()).isNotZero(); + assertThat(unsafePluginResult.output()) + .contains( + "gate 'target-gate' has unsafe Gradle plugin task ref " + + "'com.diffplug.*@spotlessCheck'"); + } + + @Test + void realGateMatrixHasTheExactSafeSchema() throws IOException { + LoaderOptions options = new LoaderOptions(); + options.setAllowDuplicateKeys(false); + options.setMaxAliasesForCollections(0); + Object loaded = + new Yaml(new SafeConstructor(options)) + .load(Files.readString(repositoryRoot().resolve(".github/ci-gate-matrix.yml"))); + + assertThat(loaded).isInstanceOf(Map.class); + Map root = (Map) loaded; + assertThat(root.keySet().stream().map(String::valueOf).toList()).containsExactly("gates"); + assertThat(root.get("gates")).isInstanceOf(List.class); + List gates = (List) root.get("gates"); + assertThat(gates).hasSize(EXPECTED_GATE_COUNT); + + Set ids = new LinkedHashSet<>(); + for (Object rawGate : gates) { + assertThat(rawGate).isInstanceOf(Map.class); + Map gate = (Map) rawGate; + assertThat(gate).hasSize(EXPECTED_GATE_FIELDS.size()); + assertThat(gate.keySet().stream().map(String::valueOf).toList()) + .containsExactlyInAnyOrderElementsOf(EXPECTED_GATE_FIELDS); + + String id = requireString(gate, "id"); + assertThat(id).matches("[a-z0-9]+(?:-[a-z0-9]+)*"); + assertThat(ids.add(id)).as("unique gate id: %s", id).isTrue(); + assertThat(requireString(gate, "mechanism")).isIn(ALLOWED_MECHANISMS); + assertThat(requireString(gate, "execution")).isIn(ALLOWED_EXECUTIONS); + assertThat(requireString(gate, "ref")).isNotBlank(); + assertThat(requireString(gate, "workflow")).endsWith(".yml"); + assertThat(requireString(gate, "job")).isNotBlank(); + + Object releaseBlocking = gate.get("release_blocking"); + assertThat(releaseBlocking).isInstanceOfAny(Boolean.class, String.class); + String releaseBlockingValue = String.valueOf(releaseBlocking); + assertThat(releaseBlockingValue).isIn("true", "false", "conditional"); + if (releaseBlocking instanceof String) { + assertThat(releaseBlocking).isEqualTo("conditional"); + } + } + assertThat(ids).containsExactlyInAnyOrderElementsOf(EXPECTED_GATE_IDS); + + Map posterGate = + gates.stream() + .map(Map.class::cast) + .filter(gate -> "poster-image-migration".equals(gate.get("id"))) + .findFirst() + .orElseThrow(() -> new AssertionError("missing Poster image migration gate")); + assertThat(requireString(posterGate, "ref")).isEqualTo("posterImageMigrationTest"); + assertThat(requireString(posterGate, "workflow")).isEqualTo("object-storage-qualification.yml"); + assertThat(requireString(posterGate, "job")).isEqualTo("poster-image-v7-migration"); + assertThat(requireString(posterGate, "execution")).isEqualTo("explicit"); + } + + private static void assertRejectedAsNotExplicit(ScriptResult result) { + assertThat(result.exitCode()).isNotZero(); + assertThat(result.output()) + .contains("gate 'target-gate' task 'targetGate' is not explicit in job 'target-job'"); + } + + private static String requireString(Map gate, String field) { + Object value = gate.get(field); + assertThat(value).as("field %s", field).isInstanceOf(String.class); + return (String) value; + } + + private static Path writeFixture( + Path root, String targetStepName, String targetCommand, int fillerGateCount) + throws IOException { + Path workflows = root.resolve(".github/workflows"); + Files.createDirectories(workflows); + Files.createDirectories(root.resolve("src/sample")); + Files.writeString(root.resolve("src/sample/build.gradle"), "tasks.register('targetGate')\n"); + + StringBuilder workflow = + new StringBuilder() + .append("name: fixture\n") + .append("on: [push]\n") + .append("jobs:\n") + .append(" target-job:\n") + .append(" runs-on: ubuntu-latest\n") + .append(" steps:\n") + .append(" - name: ") + .append(targetStepName) + .append("\n") + .append(" run: ") + .append(targetCommand) + .append("\n"); + for (int index = 1; index <= FILLER_GATE_COUNT; index++) { + workflow + .append(" filler-job-") + .append(twoDigits(index)) + .append(":\n") + .append(" runs-on: ubuntu-latest\n") + .append(" steps:\n") + .append(" - run: echo filler\n"); + } + Files.writeString(workflows.resolve("fixture.yml"), workflow); + + StringBuilder matrix = + new StringBuilder() + .append("gates:\n") + .append(" - id: target-gate\n") + .append(" release_blocking: true\n") + .append(" mechanism: gradle-custom-task\n") + .append(" ref: targetGate\n") + .append(" workflow: fixture.yml\n") + .append(" job: target-job\n") + .append(" execution: explicit\n"); + for (int index = 1; index <= fillerGateCount; index++) { + String suffix = twoDigits(index); + matrix + .append(" - id: filler-gate-") + .append(suffix) + .append("\n") + .append(" release_blocking: false\n") + .append(" mechanism: workflow-job\n") + .append(" ref: filler-job-") + .append(suffix) + .append("\n") + .append(" workflow: fixture.yml\n") + .append(" job: filler-job-") + .append(suffix) + .append("\n") + .append(" execution: job\n"); + } + Files.writeString(root.resolve(".github/ci-gate-matrix.yml"), matrix); + return root; + } + + private static Path writeCheckFixture(Path root, String checkCommand, boolean wireIntoCheck) + throws IOException { + Path fixtureRoot = writeFixture(root, "Run check", checkCommand, FILLER_GATE_COUNT); + replace( + fixtureRoot.resolve(".github/ci-gate-matrix.yml"), + "execution: explicit", + "execution: check"); + if (wireIntoCheck) { + Files.writeString( + fixtureRoot.resolve("src/sample/build.gradle"), + """ + tasks.register('targetGate') + tasks.named('check') { dependsOn tasks.named('targetGate') } + """); + } + return fixtureRoot; + } + + private static ScriptResult runValidator(Path fixtureRoot) throws IOException { + return runScript(fixtureRoot, List.of(fixtureRoot.toString())); + } + + private static ScriptResult runScript(Path workingDirectory, List arguments) + throws IOException { + return runScriptAt(validatorPath(), workingDirectory, arguments); + } + + private static ScriptResult runScriptAt( + Path script, Path workingDirectory, List arguments) throws IOException { + List command = new ArrayList<>(); + command.add("bash"); + command.add(script.toString()); + command.addAll(arguments); + return runCommand(workingDirectory, command); + } + + private static ScriptResult runCommand(Path workingDirectory, List command) + throws IOException { + Path outputFile = Files.createTempFile("gate-matrix-validator-", ".log"); + Process process = null; + try { + process = + new ProcessBuilder(command) + .directory(workingDirectory.toFile()) + .redirectErrorStream(true) + .redirectOutput(outputFile.toFile()) + .start(); + boolean finished; + try { + finished = process.waitFor(VALIDATOR_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + } catch (InterruptedException exception) { + terminateAndWait(process); + Thread.currentThread().interrupt(); + throw new AssertionError("interrupted while waiting for gate matrix validator", exception); + } + if (!finished) { + terminateAndWait(process); + throw new AssertionError("gate matrix validator exceeded " + VALIDATOR_TIMEOUT); + } + return new ScriptResult(process.exitValue(), Files.readString(outputFile)); + } finally { + if (process != null && process.isAlive()) { + terminateAndWait(process); + } + Files.deleteIfExists(outputFile); + } + } + + private static void terminateAndWait(Process process) { + List descendants = process.descendants().toList(); + descendants.forEach(ProcessHandle::destroy); + process.destroy(); + List processTree = new ArrayList<>(descendants); + processTree.add(process.toHandle()); + boolean interrupted = false; + try { + if (awaitExit(processTree)) { + return; + } + } catch (InterruptedException exception) { + interrupted = true; + } + processTree.stream().filter(ProcessHandle::isAlive).forEach(ProcessHandle::destroyForcibly); + try { + awaitExit(processTree); + } catch (InterruptedException exception) { + interrupted = true; + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + + private static boolean awaitExit(List processTree) throws InterruptedException { + CompletableFuture[] exits = + processTree.stream().map(ProcessHandle::onExit).toArray(CompletableFuture[]::new); + try { + CompletableFuture.allOf(exits).get(2, TimeUnit.SECONDS); + return true; + } catch (ExecutionException | TimeoutException exception) { + return false; + } + } + + private static void replace(Path path, String target, String replacement) throws IOException { + String original = Files.readString(path); + assertThat(original).contains(target); + Files.writeString(path, original.replaceFirst(target, replacement)); + } + + private static String twoDigits(int value) { + return String.format("%02d", value); + } + + private static Path validatorPath() { + return repositoryRoot().resolve(".github/scripts/verify-gate-matrix.sh"); + } + + private static Path repositoryRoot() { + return RepositoryContractResources.fromSystemProperty().repositoryRoot(); + } + + private record ScriptResult(int exitCode, String output) {} +} diff --git a/src/build.gradle b/src/build.gradle index 1f3d883a..9e1d08c3 100644 --- a/src/build.gradle +++ b/src/build.gradle @@ -174,27 +174,87 @@ ext.protobufVersion = '3.25.5' // approach above and keeping the strict-locking blast radius to the objectstorage module alone. ext.awsSdkVersion = '2.30.0' -Closure isTraceableArchiveFor = { Jar archiveTask, String fileName -> - String baseName = java.util.regex.Pattern.quote(archiveTask.archiveBaseName.get()) - String classifier = archiveTask.archiveClassifier.orNull - String classifierPart = classifier == null || classifier.isBlank() - ? '' - : "-${java.util.regex.Pattern.quote(classifier)}" - fileName ==~ /^${baseName}-\d+\.\d+\.\d+\+[0-9a-f]{7,40}${classifierPart}\.jar$/ -} +apply from: "${rootProject.projectDir}/gradle/archive-hygiene.gradle" +apply from: "${rootProject.projectDir}/gradle/public-path-snapshot.gradle" +apply from: "${rootProject.projectDir}/gradle/runtime-membership.gradle" +apply from: "${rootProject.projectDir}/gradle/junit-evidence.gradle" +apply from: "${rootProject.projectDir}/gradle/test-jvm-agents.gradle" -Closure> staleTraceableArchivesFor = { Jar archiveTask -> - File outputDir = archiveTask.destinationDirectory.get().asFile - if (!outputDir.isDirectory()) { - return [] +Closure> spotBugsAnalysisFailures = { File reportFile -> + List failures = [] + if (!reportFile.isFile()) { + failures << "missing XML report ${reportFile}" + return failures } - - String currentName = archiveTask.archiveFileName.get() - List stale = outputDir.listFiles({ File ignored, String fileName -> - isTraceableArchiveFor(archiveTask, fileName) && fileName != currentName - } as FilenameFilter)?.toList() ?: [] - stale.sort { it.name } + try { + XmlSlurper parser = new XmlSlurper(false, false) + parser.setFeature('http://apache.org/xml/features/disallow-doctype-decl', true) + def report = parser.parse(reportFile) + def errors = report.Errors + if (errors.size() != 1) { + failures << "expected one Errors element in ${reportFile.name}" + return failures + } + def errorsElement = errors[0] + errorsElement.MissingClass.each { missingClass -> + String className = missingClass.text().trim() + failures << "missing analysis class ${className.isBlank() ? '' : className}" + } + errorsElement.Error.each { error -> + String message = error.ErrorMessage.text().trim() + failures << "analysis error ${message.isBlank() ? '' : message}" + } + [missingClasses: errorsElement.MissingClass.size(), errors: errorsElement.Error.size()].each { + String attribute, int observed -> + String declared = errorsElement.attributes()[attribute]?.toString() + if (!(declared ==~ /\d+/)) { + failures << "invalid ${attribute} count '${declared}'" + } else if (declared.toInteger() > observed) { + failures << "${declared} ${attribute} reported but only ${observed} detailed" + } + } + } catch (Exception ex) { + failures << "unreadable XML report: ${ex.message}" + } + failures } +ext.spotBugsAnalysisFailures = spotBugsAnalysisFailures + +def verifySpotBugsAnalysisFailureContract = + tasks.register('verifySpotBugsAnalysisFailureContract') { + group = 'verification' + description = 'Proves SpotBugs missing classes and analysis errors fail closed without promoting advisory bug findings.' + notCompatibleWithConfigurationCache( + 'Exercises the root-owned SpotBugs XML verifier at execution time') + outputs.upToDateWhen { false } + doLast { + if (!rootProject.ext.has('spotBugsAnalysisFailures')) { + throw new GradleException( + 'verifySpotBugsAnalysisFailureContract: analysis report verifier is not configured') + } + Closure> analysisFailures = + rootProject.ext.spotBugsAnalysisFailures as Closure> + Map fixtures = [ + clean : '', + missing : 'fixture.MissingType', + error : 'fixture analysis error', + advisory: '' + ] + Map> results = fixtures.collectEntries { String name, String xml -> + File fixture = new File(temporaryDir, "${name}.xml") + fixture.setText(xml, 'UTF-8') + [(name): analysisFailures(fixture)] + } + if (!results.clean.isEmpty() || !results.advisory.isEmpty() || + !results.missing.any { it.contains('fixture.MissingType') } || + !results.error.any { it.contains('fixture analysis error') }) { + throw new GradleException( + "verifySpotBugsAnalysisFailureContract: unexpected fixture results ${results}") + } + logger.lifecycle( + 'verifySpotBugsAnalysisFailureContract: OK — clean and advisory bug-only reports pass; missing classes and analysis errors fail closed.') + } + } allprojects { group = 'dev.caskeleton' @@ -246,14 +306,6 @@ configure(subprojects.findAll { it.childProjects.isEmpty() }) { 'Build-Revision': rootProject.ext.sourceRevision ) } - doFirst { - staleTraceableArchivesFor(it).each { File stale -> - logger.lifecycle("${path}: deleting stale traceable archive ${stale.name}") - if (!stale.delete()) { - throw new GradleException("${path}: failed to delete stale traceable archive ${stale}") - } - } - } } // Official Gradle pattern: resolve every resolvable configuration while --write-locks is set. @@ -287,7 +339,11 @@ configure(subprojects.findAll { it.childProjects.isEmpty() }) { // binding (rationale in README.md). ErrorProne (D5) hooks the same compile tasks: it // auto-injects the JDK 16+ --add-exports/--add-opens forking args, so none are added here. tasks.withType(JavaCompile).configureEach { - options.compilerArgs << '-parameters' + ['-parameters', '-Werror', '-Xlint:deprecation', '-Xlint:unchecked'].each { String compilerArg -> + if (!options.compilerArgs.contains(compilerArg)) { + options.compilerArgs.add(compilerArg) + } + } options.errorprone { disableWarningsInGeneratedCode = true // D5 — MapStruct/Lombok generated code (errorprone README C5) } @@ -313,10 +369,7 @@ configure(subprojects.findAll { it.childProjects.isEmpty() }) { // not a signal to print on every migration/build run. maxWarnings = Integer.MAX_VALUE } - // §4 routing — checkstyleMain blocking; checkstyleTest warning-only (test-helper exception). - tasks.named('checkstyleTest') { - ignoreFailures = true - } + // §4 routing — Checkstyle findings in both main and test sources are blocking. // D3/D4 — bytecode bug finder; FindSecBugs plugin loaded via spotbugsPlugins below. // reportLevel='high' implements §4 "blocking (high priority)": only high-confidence findings @@ -329,16 +382,39 @@ configure(subprojects.findAll { it.childProjects.isEmpty() }) { reportLevel = com.github.spotbugs.snom.Confidence.valueOf('HIGH') excludeFilter = rootProject.file('config/spotbugs/exclude.xml') } + sourceSets.configureEach { sourceSet -> + String taskName = "spotbugs${sourceSet.name.capitalize()}" + tasks.named(taskName, com.github.spotbugs.snom.SpotBugsTask) { + auxClassPaths.from(sourceSet.runtimeClasspath - sourceSet.output) + def xmlAnalysisReport = reports.maybeCreate('xml') + xmlAnalysisReport.required.set(true) + doLast { + List analysisFailures = + spotBugsAnalysisFailures(xmlAnalysisReport.outputLocation.get().asFile) + if (!analysisFailures.isEmpty()) { + throw new GradleException( + "${path}: SpotBugs analysis incomplete:\n " + + analysisFailures.join('\n ')) + } + } + } + } // SpotBugs 4.10.2 needs commons-lang3 3.20.0 (uses org.apache.commons.lang3.Strings); the // Spring Boot BOM otherwise pins commons-lang3 to 3.17.0 — and io.spring.dependency-management // overrides resolutionStrategy.force — so the analysis worker crashes with NoClassDefFoundError. // Override the BOM-managed version property (the documented Spring mechanism). No production // module imports commons.lang3, so this only affects the SpotBugs tool classpath in practice. ext['commons-lang3.version'] = '3.20.0' - // §4 routing — spotbugsMain blocking; spotbugsTest warning-only (test-source trade-off). - tasks.named('spotbugsTest') { - ignoreFailures = true - } + // Netty security floor. The Spring Boot BOM pinned 4.2.7.Final, which sits inside two published + // advisory ranges that reach productionRuntimeClasspath, not just a test tool classpath: + // - CVE-2026-42577, netty-transport-native-epoll >=4.2.0,<4.2.13 (GHSA-rwm7-x88c-3g2p) + // - CVE-2026-59901, netty-codec-compression >=4.2.0,<4.2.16 (GHSA-558v-64gr-wgg4) + // Netty is shared runtime surface here — HTTP, Reactor Netty and the Redis driver all sit on it + // — so the fix is the BOM-managed version property rather than a per-artifact exclusion, and it + // is the latest 4.2 patch rather than the exact advisory floor. Regenerate every lockfile after + // changing this (`./gradlew resolveAndLockAll --write-locks`). + ext['netty.version'] = '4.2.17.Final' + // §4 routing — SpotBugs findings in both main and test sources are blocking. dependencyManagement { imports { @@ -392,7 +468,9 @@ configure(subprojects.findAll { it.childProjects.isEmpty() }) { } tasks.named('check') { + dependsOn verifySpotBugsAnalysisFailureContract dependsOn rootProject.tasks.named('verifyCleanArchitectureDependencies') + dependsOn rootProject.tasks.named('verifyRuntimeModuleMembership') dependsOn rootProject.tasks.named('verifyEnvKeys') dependsOn rootProject.tasks.named('verifyNoStaleTraceableJars') dependsOn rootProject.tasks.named('verifyOneTypePerFile') @@ -401,74 +479,41 @@ configure(subprojects.findAll { it.childProjects.isEmpty() }) { } } +Map> conditionalTransportEvidence = [ + 'conditional-transport-graphql': + project(':adapter:inbound:graphql').layout.buildDirectory.dir( + 'test-results/graphqlTransportQualificationTest'), + 'conditional-transport-grpc': + project(':adapter:inbound:grpc').layout.buildDirectory.dir( + 'test-results/grpcTransportQualificationTest'), + 'conditional-transport-websocket': + project(':adapter:inbound:websocket').layout.buildDirectory.dir( + 'test-results/websocketTransportQualificationTest'), + 'conditional-transport-composition': + project(':app-bootstrap').layout.buildDirectory.dir( + 'test-results/conditionalTransportCompositionTest') +] +tasks.register('conditionalTransportQualification') { + group = 'verification' + description = 'Runs the exact no-skip GraphQL, gRPC, and WebSocket P1 qualification evidence.' + dependsOn ':adapter:inbound:graphql:graphqlTransportQualificationTest' + dependsOn ':adapter:inbound:grpc:grpcTransportQualificationTest' + dependsOn ':adapter:inbound:websocket:websocketTransportQualificationTest' + dependsOn ':app-bootstrap:conditionalTransportCompositionTest' + dependsOn tasks.named('verifyRuntimeModuleMembership') + inputs.files(conditionalTransportEvidence.values()) + doLast { + conditionalTransportEvidence.each { String evidenceName, Provider directory -> + rootProject.ext.verifyNoSkipJUnitXml( + evidenceName, directory.get().asFile) + } + } +} + // Task 6 replaces only the contract/schema skeletons with real, no-match-failing Test lanes. // The manifest is payload-free and is rebuilt only after exact source/artifact/profile properties // and every selected Task 3-6 test have passed in the current invocation. def messagingEvidenceResultRoot = layout.buildDirectory.dir('test-results/messaging-evidence') -def registerMessagingQualificationTest = { - Project owner, String taskName, List patterns, String resultDirectory -> - owner.tasks.register(taskName, Test) { - group = 'verification' - description = 'Runs exact Messaging Task 3-6 qualification tests without broad discovery.' - testClassesDirs = owner.sourceSets.test.output.classesDirs - classpath = owner.sourceSets.test.runtimeClasspath - useJUnitPlatform() - filter { - patterns.each { includeTestsMatching(it) } - failOnNoMatchingTests = true - } - failOnNoDiscoveredTests = true - reports.junitXml.required = true - reports.junitXml.outputLocation = - messagingEvidenceResultRoot.map { it.dir(resultDirectory) } - reports.html.required = false - binaryResultsDirectory = - layout.buildDirectory.dir("test-results/messaging-evidence-binary/${resultDirectory}") - outputs.upToDateWhen { false } - jvmArgs '-Duser.timezone=UTC' - } -} - -def messagingApplicationQualification = registerMessagingQualificationTest( - project(':application-core'), - 'messagingApplicationContractQualificationTest', - [ - 'dev.caskeleton.application.messaging.contract.IntegrationEventContractContributionTest', - 'dev.caskeleton.application.messaging.event.IntegrationEventDraftTest', - 'dev.caskeleton.application.messaging.event.ValidatedIntegrationEventTest' - ], - 'application') -def messagingSharedQualification = registerMessagingQualificationTest( - project(':shared-contract'), - 'messagingSharedSchemaQualificationTest', - ['dev.caskeleton.shared.contract.messaging.MessagingEnvelopeSchemaResourceTest'], - 'shared') -def messagingSampleQualification = registerMessagingQualificationTest( - project(':sample-portfolio'), - 'messagingSampleContractQualificationTest', - ['dev.caskeleton.sample.portfolio.application.event.WorkLogReservedContractContributionTest'], - 'sample') -def messagingCompiledQualification = registerMessagingQualificationTest( - project(':adapter:outbound:messaging'), - 'messagingCompiledContractsQualificationTest', - [ - 'dev.caskeleton.adapter.outbound.messaging.config.MessagingCapabilityCardRegistryTest', - 'dev.caskeleton.adapter.outbound.messaging.contract.ContractCatalogCompilerTest', - 'dev.caskeleton.adapter.outbound.messaging.contract.ContractCatalogDigestTest', - 'dev.caskeleton.adapter.outbound.messaging.destination.DestinationBindingCompilerTest', - 'dev.caskeleton.adapter.outbound.messaging.destination.PartitionKeyV1Test' - ], - 'compiled') -def messagingJsonSchemaQualification = registerMessagingQualificationTest( - project(':adapter:outbound:messaging'), - 'messagingJsonSchemaV1QualificationTest', - [ - 'dev.caskeleton.adapter.outbound.messaging.envelope.LocalJsonSchemaRegistryTest', - 'dev.caskeleton.adapter.outbound.messaging.envelope.JsonSchemaIntegrationEventEncoderTest', - 'dev.caskeleton.adapter.outbound.messaging.envelope.EnvelopeAdversarialCorpusTest', - 'dev.caskeleton.adapter.outbound.messaging.qualification.MessagingEvidenceManifestSchemaValidatorTest' - ], - 'json-schema') def messagingEvidenceFile = layout.buildDirectory.file( 'messaging-evidence/contracts-schema/manifest.json') @@ -524,18 +569,6 @@ def prepareMessagingContractEvidence = tasks.register('prepareMessagingContractE } } -[ - messagingApplicationQualification, - messagingSharedQualification, - messagingSampleQualification, - messagingCompiledQualification, - messagingJsonSchemaQualification -].each { - it.configure { - dependsOn prepareMessagingContractEvidence - } -} - def messagingEvidenceFromXml = { List resultDirectories -> List> cases = [] resultDirectories.each { String directory -> @@ -707,8 +740,8 @@ def writeMessagingEvidence = { def verifyMessagingJsonSchemaV1 = tasks.register('verifyMessagingJsonSchemaV1') { group = 'verification' description = 'Qualifies the deterministic local Draft 2020-12 envelope candidate.' - dependsOn messagingJsonSchemaQualification - dependsOn project(':adapter:outbound:messaging').tasks.named('verifyJsonSchemaRuntimeGraph') + dependsOn ':adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest' + dependsOn ':adapter:outbound:messaging:verifyJsonSchemaRuntimeGraph' outputs.file(messagingEvidenceFile) outputs.upToDateWhen { false } doLast { @@ -745,12 +778,12 @@ def verifyMessagingContracts = tasks.register('verifyMessagingContracts') { group = 'verification' description = 'Qualifies the closed Task 3-6 contract, catalog, binding and schema candidate.' dependsOn validateMessagingJsonSchemaV1EvidenceManifestSchema - dependsOn messagingApplicationQualification - dependsOn messagingSharedQualification - dependsOn messagingSampleQualification - dependsOn messagingCompiledQualification - dependsOn messagingJsonSchemaQualification - dependsOn project(':adapter:outbound:messaging').tasks.named('verifyJsonSchemaRuntimeGraph') + dependsOn ':application-core:messagingApplicationContractQualificationTest' + dependsOn ':shared-contract:messagingSharedSchemaQualificationTest' + dependsOn ':sample-portfolio:messagingSampleContractQualificationTest' + dependsOn ':adapter:outbound:messaging:messagingCompiledContractsQualificationTest' + dependsOn ':adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest' + dependsOn ':adapter:outbound:messaging:verifyJsonSchemaRuntimeGraph' outputs.file(messagingEvidenceFile) outputs.upToDateWhen { false } doLast { @@ -802,53 +835,6 @@ tasks.register('verifyDependencyLocks') { dependsOn subprojects.findAll { it.childProjects.isEmpty() }.collect { it.tasks.named('verifyDependencyLocks') } } -tasks.register('cleanStaleTraceableJars') { - group = 'build' - description = 'Deletes older git-revision JARs from build/libs so IDE runtime classpaths cannot load stale module artifacts.' - - doLast { - int deleted = 0 - subprojects.each { sub -> - sub.tasks.withType(Jar).each { Jar archiveTask -> - staleTraceableArchivesFor(archiveTask).each { File stale -> - if (!stale.delete()) { - throw new GradleException("cleanStaleTraceableJars: failed to delete ${stale}") - } - deleted++ - logger.lifecycle("cleanStaleTraceableJars: deleted ${stale}") - } - } - } - logger.lifecycle("cleanStaleTraceableJars: deleted ${deleted} stale archive(s).") - } -} - -tasks.register('verifyNoStaleTraceableJars') { - group = 'verification' - description = 'Verifies build/libs does not retain older git-revision JARs that can poison IDE runtime classpaths.' - dependsOn tasks.named('cleanStaleTraceableJars') - - doLast { - List violations = [] - subprojects.each { sub -> - sub.tasks.withType(Jar).each { Jar archiveTask -> - List staleJars = staleTraceableArchivesFor(archiveTask).collect { it.name } - - if (!staleJars.isEmpty()) { - violations << ":${sub.name}:${archiveTask.name}: stale JAR(s) ${staleJars}; current archive is ${archiveTask.archiveFileName.get()}" - } - } - } - - if (!violations.isEmpty()) { - throw new GradleException( - "verifyNoStaleTraceableJars: ${violations.size()} module(s) retain old traceable JARs.\n " + - violations.join('\n ')) - } - logger.lifecycle('verifyNoStaleTraceableJars: OK — no stale traceable JARs in build/libs.') - } -} - // feature-developer-experience-contract D3 — one ordered first-run entrypoint. Each stage is a // separate task so the task name and exit code identify the failed phase without log archaeology. def repositoryDir = rootProject.projectDir.parentFile @@ -1161,6 +1147,7 @@ Set expectedJpaReadinessCardIds = [ 'jpa-outbox-polling-delivery-v2', 'jpa-outbox-cdc-retention-v1', 'jpa-inbox-same-store-v1', + 'jpa-fileserver-metadata-v1', 'jpa-primary-replica', 'jpa-tenant-discriminator-rls', 'jpa-jdbc-efficiency-coordination' @@ -1172,6 +1159,7 @@ Set expectedJpaOwnedMigrationCardIds = [ 'jpa-outbox-storage-v2', 'jpa-outbox-polling-delivery-v2', 'jpa-inbox-same-store-v1', + 'jpa-fileserver-metadata-v1', 'jpa-tenant-discriminator-rls', 'jpa-jdbc-efficiency-coordination' ] as Set @@ -1757,12 +1745,14 @@ configure(subprojects.findAll { it.childProjects.isEmpty() }) { } } +Project applicationCoreProject = project(':application-core') def verifyApplicationCoreDependencyPurity = tasks.register('verifyApplicationCoreDependencyPurity') { group = 'verification' description = 'Verifies application-core has only project production dependencies and no diagnostic frameworks on application classpaths.' + notCompatibleWithConfigurationCache('Inspects project configurations at execution time') doLast { - Project application = project(':application-core') + Project application = applicationCoreProject List violations = [] ['api', 'implementation', 'compileOnly', 'runtimeOnly'].each { configurationName -> @@ -1808,1385 +1798,10 @@ def verifyApplicationCoreDependencyPurity = tasks.register('verifyApplicationCor } } -project(':application-core').tasks.named('check') { +applicationCoreProject.tasks.named('check') { dependsOn verifyApplicationCoreDependencyPurity } -Closure>> loadRedisReadinessCards = { File registryFile -> - if (!registryFile.isFile()) { - throw new GradleException("Missing Redis readiness registry: ${registryFile}") - } - Map> cards = new LinkedHashMap<>() - String currentCard = null - boolean rootSeen = false - boolean readingEvidence = false - int lineNumber = 0 - registryFile.eachLine('UTF-8') { String raw -> - lineNumber++ - if (raw.contains('\t')) { - throw new GradleException( - "Malformed Redis readiness registry at line ${lineNumber}: tabs are not allowed") - } - String line = raw.stripTrailing() - if (line.isBlank() || line.stripLeading().startsWith('#')) { - return - } - if (!rootSeen) { - if (line != 'cards:') { - throw new GradleException( - "Malformed Redis readiness registry at line ${lineNumber}: expected cards:") - } - rootSeen = true - return - } - def cardMatch = line =~ /^ ([a-z][a-z0-9-]+):$/ - if (cardMatch.matches()) { - currentCard = cardMatch.group(1) - if (cards.containsKey(currentCard)) { - throw new GradleException( - "Malformed Redis readiness registry at line ${lineNumber}: duplicate card ID ${currentCard}") - } - cards[currentCard] = [requiredEvidence: []] - readingEvidence = false - return - } - if (currentCard == null) { - throw new GradleException( - "Malformed Redis readiness registry at line ${lineNumber}: card field found before a card ID") - } - if (line.startsWith(' - ')) { - if (!readingEvidence) { - throw new GradleException( - "Malformed Redis readiness registry at line ${lineNumber}: list item is only valid under required-evidence") - } - String evidence = line.substring(8) - if (!(evidence in [ - 'standalone', - 'security', - 'sentinel', - 'cluster', - 'fault', - 'compatibility', - 'selected-topology' - ])) { - throw new GradleException( - "Malformed Redis readiness registry at line ${lineNumber}: unsupported required evidence ${evidence}") - } - if ((cards[currentCard].requiredEvidence as List).contains(evidence)) { - throw new GradleException( - "Malformed Redis readiness registry at line ${lineNumber}: duplicate evidence ${evidence}") - } - (cards[currentCard].requiredEvidence as List) << evidence - return - } - if (!line.startsWith(' ') || line.startsWith(' ')) { - throw new GradleException( - "Malformed Redis readiness registry at line ${lineNumber}: unsupported indentation") - } - readingEvidence = false - String field = line.substring(4) - if (field == 'required-evidence:') { - if (cards[currentCard].evidenceDeclared == true) { - throw new GradleException( - "Malformed Redis readiness registry at line ${lineNumber}: duplicate required-evidence field") - } - cards[currentCard].evidenceDeclared = true - readingEvidence = true - return - } - int separator = field.indexOf(': ') - if (separator < 1) { - throw new GradleException( - "Malformed Redis readiness registry at line ${lineNumber}: expected field: value") - } - String name = field.substring(0, separator) - String value = field.substring(separator + 2) - if (value.isBlank()) { - throw new GradleException( - "Malformed Redis readiness registry at line ${lineNumber}: ${name} must not be blank") - } - switch (name) { - case 'state': - if (cards[currentCard].state != null) { - throw new GradleException( - "Malformed Redis readiness registry at line ${lineNumber}: duplicate state field") - } - if (!(value in ['selected', 'implemented-candidate', 'not-implemented'])) { - throw new GradleException( - "Malformed Redis readiness registry at line ${lineNumber}: unsupported state ${value}") - } - cards[currentCard].state = value - break - case 'selected-topology': - if (cards[currentCard].selectedTopology != null) { - throw new GradleException( - "Malformed Redis readiness registry at line ${lineNumber}: duplicate selected-topology field") - } - if (!(value in ['standalone', 'sentinel', 'cluster'])) { - throw new GradleException( - "Malformed Redis readiness registry at line ${lineNumber}: unsupported selected-topology ${value}") - } - cards[currentCard].selectedTopology = value - break - default: - throw new GradleException( - "Malformed Redis readiness registry at line ${lineNumber}: unknown field ${name}") - } - } - if (!rootSeen) { - throw new GradleException('Redis readiness registry is missing cards:') - } - Set expected = [ - 'redis-cache', - 'redis-edge-rate-limit', - 'redis-request-replay-idempotency', - 'redis-cache-refresh-soft-lease', - 'redis-fenced-coordination', - 'redis-session' - ] as Set - if (cards.keySet() != expected) { - throw new GradleException( - "Redis readiness registry cards must be exactly ${expected}; got ${cards.keySet()}") - } - cards.each { String cardId, Map card -> - if (card.state == null) { - throw new GradleException("Redis readiness card ${cardId} has no valid state") - } - if (card.state == 'not-implemented') { - if (card.selectedTopology != null || - card.evidenceDeclared == true || - !(card.requiredEvidence as List).isEmpty()) { - throw new GradleException( - "Redis readiness card ${cardId} is not-implemented and must not declare topology or evidence") - } - return - } - if (card.selectedTopology == null || (card.requiredEvidence as List).isEmpty()) { - throw new GradleException( - "Redis readiness card ${cardId} requires topology and evidence") - } - if (!(card.requiredEvidence as List).contains('selected-topology')) { - throw new GradleException( - "Redis readiness card ${cardId} required-evidence must include selected-topology") - } - } - cards -} - -Map> redisReadinessCards = loadRedisReadinessCards( - rootProject.file('config/redis/readiness-cards.yaml')) -ext.redisReadinessCards = redisReadinessCards -Map redisReadinessTaskStems = [ - 'redis-cache' : 'Cache', - 'redis-edge-rate-limit' : 'RateLimit', - 'redis-request-replay-idempotency' : 'Idempotency', - 'redis-cache-refresh-soft-lease' : 'SoftLease', - 'redis-fenced-coordination' : 'FencedCoordination', - 'redis-session' : 'Session' -] -Map redisEvidenceTaskStems = [ - standalone : 'Standalone', - security : 'Security', - sentinel : 'Sentinel', - cluster : 'Cluster', - fault : 'Fault', - compatibility: 'Compatibility' -] -Map redisPublicReadinessTasks = [ - 'redis-cache' : 'redisCacheReadiness', - 'redis-edge-rate-limit' : 'redisRateLimitReadiness', - 'redis-request-replay-idempotency' : 'redisIdempotencyReadiness', - 'redis-cache-refresh-soft-lease' : 'redisSoftLeaseReadiness', - 'redis-fenced-coordination' : 'redisFencedCoordinationReadiness', - 'redis-session' : 'redisSessionReadiness' -] - -Map> redisCapabilityMetadata = [ - 'redis-cache' : [ - providerIds : ['redis'], - roles : ['CACHE'], - programs : [ - 'set-if-absent-with-ttl-v1', - 'replace-if-observed-with-ttl-v1', - 'region-generation-init-v1', - 'region-generation-bump-v1' - ], - keyVersions : ['cache-key-v1'], - codecVersions: ['cache-envelope-v2'], - guarantees : ['bounded standalone semantic cache and generation fencing'], - nonGuarantees: [ - 'no multi-process L1 coherence or HA topology qualification', - 'runtime-resolved image attestation and actual fault event timeline are not captured' - ], - requiredSettings: [ - [name: 'ca-skeleton.capabilities.cache.bindings.default', type: 'enum', - constraint: 'disabled or redis; redis is required to activate this card'], - [name: 'ca-skeleton.providers.redis.roles.cache', type: 'role-binding', - constraint: 'optional CACHE role with finite timeouts and bounds'], - [name: 'ca-skeleton.capabilities.cache.regions.default.key-hmac-secret-reference', - type: 'secret-reference-name', - constraint: 'nonblank reference name; resolved secret value is never evidence'] - ] - ], - 'redis-edge-rate-limit' : [ - providerIds : ['redis'], - roles : ['COORDINATION'], - programs : [ - 'rate-fixed-window-v2', - 'rate-sliding-counter-v2', - 'rate-token-bucket-v2' - ], - keyVersions : ['rate-limit-key-v1'], - codecVersions: ['rate-limit-reply-v2'], - guarantees : ['atomic standalone quota evaluation with bounded state'], - nonGuarantees: [ - 'no Sentinel, Cluster, failover, or R3 qualification', - 'runtime-resolved image attestation and actual fault event timeline are not captured' - ], - requiredSettings: [ - [name: 'ca-skeleton.capabilities.rate-limit.provider', type: 'enum', - constraint: 'disabled or redis; checked-in registry remains release authority'], - [name: 'ca-skeleton.providers.redis.roles.coordination', - type: 'role-binding', - constraint: 'required COORDINATION role bound to one deployment'], - [name: 'ca-skeleton.capabilities.rate-limit.key-hmac-secret-reference', - type: 'secret-reference-name', - constraint: 'nonblank reference name; resolved secret value is never evidence'] - ] - ], - 'redis-request-replay-idempotency' : [ - providerIds : ['redis'], - roles : ['COORDINATION'], - programs : [ - 'idempotency-claim-v1', - 'idempotency-start-v1', - 'idempotency-renew-v1', - 'idempotency-complete-v1', - 'idempotency-fail-v1', - 'idempotency-release-v1', - 'idempotency-inspect-v1' - ], - keyVersions : ['idempotency-key-v1'], - codecVersions: ['idempotency-program-schema-v2'], - guarantees : ['standalone request replay state transitions'], - nonGuarantees: [ - 'no cross-store exactly-once guarantee', - 'runtime-resolved image attestation and actual fault event timeline are not captured' - ], - requiredSettings: [ - [name: 'ca-skeleton.capabilities.idempotency.provider', type: 'enum', - constraint: 'provider selection is explicit and exclusive'], - [name: 'ca-skeleton.providers.redis.roles.coordination', - type: 'role-binding', - constraint: 'required COORDINATION role bound to one deployment'], - [name: 'ca-skeleton.capabilities.idempotency.key-hmac-secret-reference', - type: 'secret-reference-name', - constraint: 'nonblank reference name; resolved secret value is never evidence'] - ] - ], - 'redis-cache-refresh-soft-lease' : [ - providerIds : ['redis'], - roles : ['CACHE'], - programs : ['cache-refresh-claim-v1', 'compare-and-delete-v1'], - keyVersions : ['cache-refresh-key-v1'], - codecVersions: ['cache-refresh-owner-v1'], - guarantees : ['bounded duplicate refresh suppression'], - nonGuarantees: [ - 'not a correctness lock and no fencing token', - 'runtime-resolved image attestation and actual fault event timeline are not captured' - ], - requiredSettings: [ - [name: 'ca-skeleton.capabilities.cache.bindings.default', type: 'enum', - constraint: 'disabled or redis; redis activates the cache-owned soft lease'], - [name: 'ca-skeleton.providers.redis.roles.cache', type: 'role-binding', - constraint: 'optional CACHE role with finite timeouts and bounds'], - [name: 'ca-skeleton.capabilities.cache.regions.default.key-hmac-secret-reference', - type: 'secret-reference-name', - constraint: 'nonblank reference name; resolved secret value is never evidence'] - ] - ], - 'redis-fenced-coordination' : [ - providerIds : [], - roles : ['COORDINATION'], - programs : [], - keyVersions : [], - codecVersions: [], - guarantees : [], - nonGuarantees: ['provider and stale fencing-token rejection are not implemented'], - requiredSettings: [] - ], - 'redis-session' : [ - providerIds : ['redis-session'], - roles : ['SESSION'], - programs : [ - 'session-create-v1', - 'session-inspect-v1', - 'session-save-if-live-v1', - 'session-touch-if-live-v1', - 'session-tombstone-and-delete-v1', - 'session-rotate-v1' - ], - keyVersions : ['session-key-v1'], - codecVersions: ['session-envelope-v2', 'session-envelope-v1-read'], - guarantees : ['standalone versioned session repository and stale-save rejection'], - nonGuarantees: [ - 'same-JVM two-client evidence is not multi-process or pod qualification', - 'runtime-resolved image attestation and actual fault event timeline are not captured' - ], - requiredSettings: [ - [name: 'ca-skeleton.security.auth-mode', type: 'enum', - constraint: 'session mode is explicit'], - [name: 'ca-skeleton.providers.redis.roles.session', type: 'role-binding', - constraint: 'required SESSION role bound to one standalone deployment'], - [name: 'ca-skeleton.capabilities.security.redis-session.key-hmac-secret-reference', - type: 'secret-reference-name', - constraint: 'nonblank reference name; resolved secret value is never evidence'] - ] - ] -] -if (redisCapabilityMetadata.keySet() != redisReadinessCards.keySet()) { - throw new GradleException( - "Redis capability metadata IDs must equal readiness cards; metadata=${redisCapabilityMetadata.keySet()}, cards=${redisReadinessCards.keySet()}") -} -ext.redisCapabilityMetadata = redisCapabilityMetadata - -def fullGitRevision = providers.exec { - commandLine 'git', 'rev-parse', 'HEAD' - ignoreExitValue = true -}.standardOutput.asText.map { it.trim() } -String checkedOutHeadRevision = fullGitRevision.getOrElse('') -if (!(checkedOutHeadRevision ==~ /[0-9a-f]{40}/)) { - throw new GradleException( - 'Redis evidence requires an exact 40-character checked-out Git HEAD.') -} -String redisEvidenceSourceRevision = providers.environmentVariable('GITHUB_SHA') - .orElse(providers.environmentVariable('GIT_SHA')) - .getOrElse(checkedOutHeadRevision) -if (!(redisEvidenceSourceRevision ==~ /[0-9a-f]{40}/)) { - throw new GradleException( - 'Redis evidence requires the exact 40-character commit SHA from Git or GITHUB_SHA/GIT_SHA.') -} -if (redisEvidenceSourceRevision != checkedOutHeadRevision) { - throw new GradleException( - "Redis evidence source revision ${redisEvidenceSourceRevision} does not equal checked-out HEAD ${checkedOutHeadRevision}.") -} -ext.redisEvidenceSourceRevision = redisEvidenceSourceRevision -def gitStatusPorcelain = providers.exec { - commandLine 'git', 'status', '--porcelain', '--untracked-files=normal' - ignoreExitValue = true -}.standardOutput.asText.map { it } -String redisEvidenceSourceTreeState = gitStatusPorcelain.getOrElse('').isBlank() - ? 'CLEAN' - : 'DIRTY' -ext.redisEvidenceSourceTreeState = redisEvidenceSourceTreeState - -Closure redisSha256 = { File file -> - if (!file.isFile()) { - throw new GradleException("Redis evidence digest input is missing: ${file}") - } - MessageDigest digest = MessageDigest.getInstance('SHA-256') - file.withInputStream { stream -> - byte[] buffer = new byte[8192] - int read - while ((read = stream.read(buffer)) >= 0) { - if (read > 0) { - digest.update(buffer, 0, read) - } - } - } - "sha256:${digest.digest().encodeHex()}" -} - -Closure redisAggregateSha256 = { Collection files -> - MessageDigest digest = MessageDigest.getInstance('SHA-256') - List sortedFiles = files.toSorted { - rootProject.projectDir.toPath().relativize(it.toPath()).toString() - } - Set paths = new LinkedHashSet<>() - sortedFiles.each { File file -> - if (!file.isFile()) { - throw new GradleException("Redis evidence digest input is missing: ${file}") - } - if (java.nio.file.Files.isSymbolicLink(file.toPath())) { - throw new GradleException("Redis evidence digest input must not be a symlink: ${file}") - } - java.nio.file.Path normalized = file.toPath().toAbsolutePath().normalize() - java.nio.file.Path root = rootProject.projectDir.toPath().toAbsolutePath().normalize() - if (!normalized.startsWith(root)) { - throw new GradleException("Redis evidence digest input escapes the source root: ${file}") - } - String relative = root.relativize(normalized).toString() - if (!paths.add(relative)) { - throw new GradleException("Duplicate Redis evidence digest path: ${relative}") - } - byte[] pathBytes = relative.getBytes('UTF-8') - byte[] contentBytes = file.bytes - digest.update(java.nio.ByteBuffer.allocate(Long.BYTES).putLong(pathBytes.length).array()) - digest.update(pathBytes) - digest.update(java.nio.ByteBuffer.allocate(Long.BYTES).putLong(contentBytes.length).array()) - digest.update(contentBytes) - } - "sha256:${digest.digest().encodeHex()}" -} - -Closure> redisEvidenceDigests = { - File registry = rootProject.file('config/redis/readiness-cards.yaml') - File images = rootProject.file('gradle/redis-test-images.properties') - File redisResourceRoot = rootProject.file( - 'adapter/outbound/cache-redis/src/main/resources') - File redisResourceDirectory = new File(redisResourceRoot, 'redis') - List programAssets = fileTree(redisResourceDirectory) { - include '*.json' - include 'scripts/*.lua' - }.files.toList() - Set referencedScripts = new LinkedHashSet<>() - Closure validateScriptReferences - validateScriptReferences = { Object node -> - if (node instanceof Map) { - Map object = node as Map - if (object.containsKey('scriptResource') || object.containsKey('sha256')) { - if (!(object.scriptResource instanceof String) || - !(object.sha256 instanceof String) || - !(object.sha256 ==~ /[0-9a-f]{64}/)) { - throw new GradleException( - 'Redis program metadata must pair scriptResource with lowercase SHA-256') - } - File script = new File(redisResourceRoot, object.scriptResource as String) - java.nio.file.Path normalized = script.toPath().toAbsolutePath().normalize() - java.nio.file.Path resourceRoot = redisResourceRoot.toPath() - .toAbsolutePath().normalize() - if (!normalized.startsWith(resourceRoot) || - !script.isFile() || - java.nio.file.Files.isSymbolicLink(script.toPath())) { - throw new GradleException( - "Redis program script reference is missing or escapes resources: ${object.scriptResource}") - } - String actual = redisSha256(script).substring('sha256:'.length()) - if (actual != object.sha256) { - throw new GradleException( - "Redis program script digest mismatch for ${object.scriptResource}") - } - referencedScripts.add(script.canonicalFile) - } - object.values().each { validateScriptReferences(it) } - } else if (node instanceof Collection) { - (node as Collection).each { validateScriptReferences(it) } - } - } - programAssets.findAll { it.name.endsWith('.json') }.each { File manifest -> - validateScriptReferences(new JsonSlurper().parse(manifest)) - } - Set allScripts = programAssets.findAll { - it.name.endsWith('.lua') - }.collect { it.canonicalFile } as Set - if (referencedScripts != allScripts) { - throw new GradleException( - "Redis program bundle scripts must be referenced exactly; missing=${allScripts - referencedScripts}, unknown=${referencedScripts - allScripts}") - } - Map safeConfigurationProjection = redisCapabilityMetadata.collectEntries { - String cardId, Map metadata -> - [(cardId): [ - readiness : redisReadinessCards[cardId].state, - selectedTopology: redisReadinessCards[cardId].selectedTopology, - providerIds : metadata.providerIds, - roles : metadata.roles, - programIds : metadata.programs, - keyVersions : metadata.keyVersions, - codecVersions : metadata.codecVersions, - guarantees : metadata.guarantees, - nonGuarantees : metadata.nonGuarantees, - requiredSettings: metadata.requiredSettings, - evidenceProfile : redisReadinessCards[cardId].requiredEvidence - ]] - } - byte[] projectionBytes = JsonOutput.toJson(safeConfigurationProjection).getBytes('UTF-8') - String configurationDigest = "sha256:${MessageDigest.getInstance('SHA-256') - .digest(projectionBytes).encodeHex()}" - [ - registrySha256 : redisSha256(registry), - imageRegistrySha256: redisSha256(images), - programSetSha256 : redisAggregateSha256(programAssets), - configurationSha256: configurationDigest - ] -} -ext.redisEvidenceDigests = redisEvidenceDigests - -def redisControlDirectory = layout.buildDirectory.dir('redis-evidence/control') -def redisCiMatrixFile = layout.buildDirectory.file( - 'redis-evidence/control/redis-readiness-matrix.json') -def verifyRedisReadinessRegistryStrictness = tasks.register( - 'verifyRedisReadinessRegistryStrictness') { - group = 'redis verification' - description = 'Runs malformed registry fixtures through the CI/build canonical strict parser.' - inputs.file rootProject.file('config/redis/readiness-cards.yaml') - doLast { - String canonical = rootProject.file('config/redis/readiness-cards.yaml').getText('UTF-8') - Map malformed = [ - wrongRoot: canonical.replaceFirst('cards:', 'capabilities:'), - duplicateCard: canonical.replace( - ' redis-cache:\n', - ' redis-cache:\n redis-cache:\n'), - duplicateField: canonical.replaceFirst( - ' state: implemented-candidate', - ' state: implemented-candidate\n state: implemented-candidate'), - unknownField: canonical.replaceFirst( - ' state: implemented-candidate', - ' unknown-field: value\n state: implemented-candidate'), - invalidState: canonical.replaceFirst( - 'state: implemented-candidate', - 'state: candidate'), - invalidTopology: canonical.replaceFirst( - 'selected-topology: standalone', - 'selected-topology: replicated'), - invalidEvidence: canonical.replaceFirst( - ' - standalone', - ' - unsupported'), - duplicateEvidence: canonical.replaceFirst( - ' - standalone', - ' - standalone\n - standalone'), - notImplementedMetadata: canonical.replace( - ' redis-fenced-coordination:\n state: not-implemented\n', - ' redis-fenced-coordination:\n' + - ' state: not-implemented\n' + - ' selected-topology: standalone\n' + - ' required-evidence:\n' + - ' - selected-topology\n'), - notImplementedEmptyEvidence: canonical.replace( - ' redis-fenced-coordination:\n state: not-implemented\n', - ' redis-fenced-coordination:\n' + - ' state: not-implemented\n' + - ' required-evidence:\n'), - missingTopologyMarker: canonical.replaceFirst( - ' - selected-topology\\n', - ''), - unsupportedIndentation: canonical.replaceFirst( - ' state: implemented-candidate', - ' state: implemented-candidate'), - extraCard: canonical + - ' redis-unknown:\n' + - ' state: not-implemented\n', - missingCard: canonical.replace( - ' redis-fenced-coordination:\n state: not-implemented\n', - ''), - blankCard: canonical.replaceFirst( - ' redis-cache:', - ' :') - ] - malformed.each { String fixtureName, String fixtureText -> - File fixture = new File(temporaryDir, "${fixtureName}.yaml") - fixture.setText(fixtureText, 'UTF-8') - boolean rejected = false - try { - loadRedisReadinessCards(fixture) - } catch (GradleException expected) { - rejected = true - } - if (!rejected) { - throw new GradleException( - "Strict Redis readiness parser accepted malformed fixture ${fixtureName}") - } - } - logger.lifecycle( - "verifyRedisReadinessRegistryStrictness: rejected ${malformed.size()} malformed fixtures") - } -} -def verifyRedisCapabilityMetadata = tasks.register('verifyRedisCapabilityMetadata') { - group = 'redis verification' - description = 'Validates generated card provider, program, setting, and non-guarantee truth.' - File redisSource = rootProject.file( - 'adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis') - inputs.files fileTree(redisSource) { - include '**/*.java' - } - inputs.files fileTree(rootProject.file( - 'adapter/outbound/cache-redis/src/main/resources/redis')) { - include '*.json' - } - doLast { - Map> expectedProviders = [ - 'redis-cache' : ['redis'], - 'redis-edge-rate-limit' : ['redis'], - 'redis-request-replay-idempotency': ['redis'], - 'redis-cache-refresh-soft-lease' : ['redis'], - 'redis-fenced-coordination' : [], - 'redis-session' : ['redis-session'] - ] - redisCapabilityMetadata.each { String cardId, Map metadata -> - if (metadata.providerIds != expectedProviders[cardId]) { - throw new GradleException( - "${cardId}: capability provider IDs do not match canonical selection values") - } - } - String canonicalConfig = new File(redisSource, 'RedisCanonicalConfig.java') - .getText('UTF-8') - Map selectionContracts = [ - 'redis-cache' : - '"ca-skeleton.capabilities.cache.bindings.default", "redis"', - 'redis-edge-rate-limit' : - '"ca-skeleton.capabilities.rate-limit.provider", "redis"', - 'redis-request-replay-idempotency': - '"ca-skeleton.capabilities.idempotency.provider", "redis"', - 'redis-cache-refresh-soft-lease' : - '"ca-skeleton.capabilities.cache.bindings.default", "redis"', - 'redis-session' : - '"ca-skeleton.security.auth-mode", "redis-session"' - ] - selectionContracts.each { String cardId, String sourceContract -> - if (!canonicalConfig.contains(sourceContract)) { - throw new GradleException( - "${cardId}: canonical provider selection contract is missing: ${sourceContract}") - } - } - String programIdSource = new File(redisSource, 'RedisProgramId.java').getText('UTF-8') - def programMatcher = programIdSource =~ /(?s)([A-Z][A-Z0-9_]+)\s*\(\s*"([^"]+)"/ - Map enumByExternalId = new LinkedHashMap<>() - programMatcher.each { ignored, String enumName, String externalId -> - enumByExternalId[externalId] = enumName - } - Set claimedPrograms = redisCapabilityMetadata.values().collectMany { - it.programs as List - } as Set - Set unknownPrograms = claimedPrograms.findAll { - !enumByExternalId.containsKey(it) - } as Set - if (!unknownPrograms.isEmpty()) { - throw new GradleException( - "Redis capability cards claim unknown program IDs: ${unknownPrograms}") - } - Map> implementationSources = [ - 'redis-cache': [ - 'RedisStringCacheRegion.java', - 'RedisCacheConsistencyStore.java', - 'RedisAtomicPrimitives.java' - ], - 'redis-edge-rate-limit': [ - 'RedisEdgeRateLimitProvider.java' - ], - 'redis-request-replay-idempotency': [ - 'RedisIdempotencyStoreProvider.java' - ], - 'redis-cache-refresh-soft-lease': [ - 'RedisCacheRefreshCoordinator.java' - ], - 'redis-session': [ - 'RedisLuaVersionedSessionStore.java' - ] - ] - implementationSources.each { String cardId, List sources -> - String implementation = sources.collect { - new File(redisSource, it).getText('UTF-8') - }.join('\n') - (redisCapabilityMetadata[cardId].programs as List).each { String programId -> - String enumName = enumByExternalId[programId] - if (!implementation.contains("RedisProgramId.${enumName}")) { - throw new GradleException( - "${cardId}: claimed program ${programId} is not referenced by its implementation") - } - } - } - Map settingSourceContracts = [ - 'ca-skeleton.capabilities.cache.regions.default.key-hmac-secret-reference': - 'RedisCanonicalCacheSettings.java', - 'ca-skeleton.capabilities.rate-limit.key-hmac-secret-reference': - 'RedisRateLimitSettings.java', - 'ca-skeleton.capabilities.idempotency.key-hmac-secret-reference': - 'RedisIdempotencySettings.java', - 'ca-skeleton.capabilities.security.redis-session.key-hmac-secret-reference': - 'RedisSessionSettings.java' - ] - settingSourceContracts.each { String settingName, String sourceName -> - String source = new File(redisSource, sourceName).getText('UTF-8') - String prefix = settingName.substring(0, settingName.lastIndexOf('.')) - if (!source.contains("@ConfigurationProperties(prefix = \"${prefix}\")") || - !source.contains('String keyHmacSecretReference')) { - throw new GradleException( - "Capability card setting is not backed by typed settings: ${settingName}") - } - } - List declaredSettingNames = redisCapabilityMetadata.values().collectMany { - (it.requiredSettings as List>).collect { setting -> setting.name } - } - if (declaredSettingNames.any { it.contains('.roles.CACHE') }) { - throw new GradleException( - 'Generated Redis setting names must use canonical lowercase map keys') - } - Map> expectedSettingNames = [ - 'redis-cache': [ - 'ca-skeleton.capabilities.cache.bindings.default', - 'ca-skeleton.providers.redis.roles.cache', - 'ca-skeleton.capabilities.cache.regions.default.key-hmac-secret-reference' - ] as Set, - 'redis-edge-rate-limit': [ - 'ca-skeleton.capabilities.rate-limit.provider', - 'ca-skeleton.providers.redis.roles.coordination', - 'ca-skeleton.capabilities.rate-limit.key-hmac-secret-reference' - ] as Set, - 'redis-request-replay-idempotency': [ - 'ca-skeleton.capabilities.idempotency.provider', - 'ca-skeleton.providers.redis.roles.coordination', - 'ca-skeleton.capabilities.idempotency.key-hmac-secret-reference' - ] as Set, - 'redis-cache-refresh-soft-lease': [ - 'ca-skeleton.capabilities.cache.bindings.default', - 'ca-skeleton.providers.redis.roles.cache', - 'ca-skeleton.capabilities.cache.regions.default.key-hmac-secret-reference' - ] as Set, - 'redis-fenced-coordination': [] as Set, - 'redis-session': [ - 'ca-skeleton.security.auth-mode', - 'ca-skeleton.providers.redis.roles.session', - 'ca-skeleton.capabilities.security.redis-session.key-hmac-secret-reference' - ] as Set - ] - redisCapabilityMetadata.each { String cardId, Map metadata -> - Set actual = (metadata.requiredSettings as List>) - .collect { it.name } as Set - if (actual != expectedSettingNames[cardId]) { - throw new GradleException( - "${cardId}: generated required settings are incomplete or non-canonical; expected=${expectedSettingNames[cardId]}, actual=${actual}") - } - } - String bootstrapConfiguration = rootProject.file( - 'app-bootstrap/src/main/resources/application.yml').getText('UTF-8') - if (!bootstrapConfiguration.contains( - 'ca-skeleton.providers.redis.roles.cache')) { - throw new GradleException( - 'Canonical lowercase ca-skeleton.providers.redis.roles.cache binding is missing') - } - Map fenced = redisCapabilityMetadata['redis-fenced-coordination'] - if (!(fenced.providerIds as List).isEmpty() || - !(fenced.programs as List).isEmpty() || - !(fenced.guarantees as List).isEmpty()) { - throw new GradleException( - 'redis-fenced-coordination must not claim a provider, program, or guarantee') - } - } -} -tasks.register('writeRedisCiMatrix') { - group = 'redis verification' - description = 'Writes the strict, deterministic Redis readiness matrix consumed by CI.' - dependsOn verifyRedisCapabilityMetadata - dependsOn verifyRedisReadinessRegistryStrictness - inputs.file rootProject.file('config/redis/readiness-cards.yaml') - inputs.file rootProject.file('gradle/redis-test-images.properties') - inputs.files fileTree(rootProject.file( - 'adapter/outbound/cache-redis/src/main/resources/redis')) { - include '*.json' - include 'scripts/*.lua' - } - outputs.dir redisControlDirectory - outputs.upToDateWhen { false } - doLast { - Closure> resolvedEvidence = { Map card -> - Set resolved = new LinkedHashSet<>(card.requiredEvidence as List) - if (resolved.remove('selected-topology')) { - resolved.add(card.selectedTopology as String) - } - resolved.toList().sort() - } - List> selected = [] - List> candidates = [] - redisReadinessCards.each { String cardId, Map card -> - Map entry = [ - cardId : cardId, - readinessTask : redisPublicReadinessTasks[cardId], - selectedTopology : card.selectedTopology, - resolvedEvidence : resolvedEvidence(card) - ] - if (card.state == 'selected') { - selected << entry - } - if (card.state in ['selected', 'implemented-candidate']) { - candidates << entry - } - } - selected.sort { it.cardId } - candidates.sort { it.cardId } - Map matrix = [ - schemaVersion : 1, - sourceRevision : redisEvidenceSourceRevision, - sourceTreeState : redisEvidenceSourceTreeState, - releaseQualification : 'NOT_CLAIMED', - registryDigest : redisEvidenceDigests().registrySha256, - imageRegistryDigest : redisEvidenceDigests().imageRegistrySha256, - programBundleDigest : redisEvidenceDigests().programSetSha256, - configProjectionDigest: redisEvidenceDigests().configurationSha256, - selectedCount : selected.size(), - selected : selected, - implementedCandidates : candidates, - topologyJobs : [ - sentinel: candidates.any { it.selectedTopology == 'sentinel' }, - cluster : candidates.any { it.selectedTopology == 'cluster' } - ] - ] - File controlDirectory = redisControlDirectory.get().asFile - controlDirectory.mkdirs() - File output = redisCiMatrixFile.get().asFile - output.setText(JsonOutput.prettyPrint(JsonOutput.toJson(matrix)) + '\n', 'UTF-8') - File cardsDirectory = new File(controlDirectory, 'capability-cards') - cardsDirectory.mkdirs() - Map digests = redisEvidenceDigests() - redisReadinessCards.keySet().toList().sort().each { String cardId -> - Map card = redisReadinessCards[cardId] - Map metadata = redisCapabilityMetadata[cardId] - Map generatedCard = [ - schemaVersion : 1, - cardId : cardId, - readiness : card.state, - selectionResult : card.state == 'selected' ? 'SELECTED' : 'NOT_SELECTED', - releaseQualification: 'NOT_CLAIMED', - promotionTopology : card.selectedTopology, - sourceRevision : redisEvidenceSourceRevision, - sourceTreeState : redisEvidenceSourceTreeState, - digests : digests, - minimumRedisVersion : '7.2', - providerIds : metadata.providerIds, - roles : metadata.roles, - programIds : metadata.programs, - keyVersions : metadata.keyVersions, - codecVersions : metadata.codecVersions, - guarantees : metadata.guarantees, - nonGuarantees : metadata.nonGuarantees, - requiredSettings : metadata.requiredSettings, - evidenceProfile : card.requiredEvidence - ] - new File(cardsDirectory, "${cardId}.json").setText( - JsonOutput.prettyPrint(JsonOutput.toJson(generatedCard)) + '\n', 'UTF-8') - } - List controlFiles = [output] - controlFiles.addAll(cardsDirectory.listFiles().toList()) - File checksumFile = new File(controlDirectory, 'checksums.sha256') - checksumFile.setText(controlFiles.toSorted { it.name }.collect { File file -> - String relative = controlDirectory.toPath().relativize(file.toPath()).toString() - "${redisSha256(file).substring('sha256:'.length())} ${relative}" - }.join('\n') + '\n', 'UTF-8') - logger.lifecycle("writeRedisCiMatrix: ${output}") - } -} - -def verifyRedisSelectedEvidenceArtifacts = tasks.register( - 'verifyRedisSelectedEvidenceArtifacts') { - group = 'redis verification' - description = 'Reconciles downloaded sanitized evidence for every selected Redis card.' - dependsOn tasks.named('writeRedisCiMatrix') - doLast { - File expectedControlDirectory = redisControlDirectory.get().asFile - String controlDirectoryProperty = providers.gradleProperty('redisControlDirectory') - .getOrElse('') - File suppliedControlDirectory = controlDirectoryProperty.isBlank() - ? expectedControlDirectory - : rootProject.file(controlDirectoryProperty) - if (!suppliedControlDirectory.isDirectory()) { - throw new GradleException( - "Redis readiness control directory is missing: ${suppliedControlDirectory}") - } - Set expectedControlPaths = [ - 'redis-readiness-matrix.json', - 'checksums.sha256' - ] as Set - redisReadinessCards.keySet().each { - expectedControlPaths.add("capability-cards/${it}.json") - } - List suppliedControlFiles = fileTree(suppliedControlDirectory).files.toList() - Set suppliedControlPaths = suppliedControlFiles.collect { - suppliedControlDirectory.toPath().relativize(it.toPath()).toString() - } as Set - if (suppliedControlPaths != expectedControlPaths) { - throw new GradleException( - "Redis control artifact file set mismatch; expected=${expectedControlPaths}, actual=${suppliedControlPaths}") - } - suppliedControlFiles.each { File file -> - if (file.length() > 1_048_576L || - java.nio.file.Files.isSymbolicLink(file.toPath()) || - !file.toPath().toRealPath().startsWith( - suppliedControlDirectory.toPath().toRealPath())) { - throw new GradleException( - "Redis control artifact is oversized, symlinked, or path-escaping: ${file}") - } - } - File suppliedChecksums = new File(suppliedControlDirectory, 'checksums.sha256') - Map checksumEntries = new LinkedHashMap<>() - suppliedChecksums.eachLine('UTF-8') { String line -> - def match = line =~ /^([0-9a-f]{64}) ([a-z0-9.\/-]+)$/ - if (!match.matches() || - checksumEntries.put(match.group(2), match.group(1)) != null) { - throw new GradleException( - "Malformed or duplicate Redis control checksum line: ${line}") - } - } - Set checksummedPaths = new LinkedHashSet<>(expectedControlPaths) - checksummedPaths.remove('checksums.sha256') - if (checksumEntries.keySet() != checksummedPaths) { - throw new GradleException( - "Redis control checksum set mismatch; expected=${checksummedPaths}, actual=${checksumEntries.keySet()}") - } - checksumEntries.each { String relative, String expectedSha -> - String actualSha = redisSha256( - new File(suppliedControlDirectory, relative)) - .substring('sha256:'.length()) - if (actualSha != expectedSha) { - throw new GradleException( - "Redis control checksum mismatch for ${relative}") - } - } - Map expectedMatrix = new JsonSlurper().parse( - redisCiMatrixFile.get().asFile) as Map - Map suppliedMatrix = new JsonSlurper().parse( - new File(suppliedControlDirectory, 'redis-readiness-matrix.json')) as Map - if (suppliedMatrix != expectedMatrix || - suppliedMatrix.sourceRevision != redisEvidenceSourceRevision || - suppliedMatrix.sourceTreeState != redisEvidenceSourceTreeState || - suppliedMatrix.releaseQualification != 'NOT_CLAIMED') { - throw new GradleException( - 'Downloaded Redis control matrix does not match this exact source revision and registry') - } - redisReadinessCards.each { String cardId, Map card -> - Map expectedCard = new JsonSlurper().parse( - new File(expectedControlDirectory, "capability-cards/${cardId}.json")) - as Map - Map suppliedCard = new JsonSlurper().parse( - new File(suppliedControlDirectory, "capability-cards/${cardId}.json")) - as Map - if (suppliedCard != expectedCard || - suppliedCard.releaseQualification != 'NOT_CLAIMED' || - suppliedCard.sourceTreeState != redisEvidenceSourceTreeState || - suppliedCard.readiness != card.state) { - throw new GradleException( - "Downloaded Redis capability card is stale or malformed: ${cardId}") - } - } - Map> selectedCards = redisReadinessCards.findAll { - ignored, card -> card.state == 'selected' - } - if ((suppliedMatrix.selectedCount as Number).intValue() != selectedCards.size()) { - throw new GradleException( - 'Redis control selectedCount does not match the strict checked-in registry') - } - String ciResultFileProperty = providers.gradleProperty('redisCiResultFile') - .getOrElse('') - if (!ciResultFileProperty.isBlank()) { - File ciResultFile = rootProject.file(ciResultFileProperty) - if (!ciResultFile.isFile() || - ciResultFile.length() > 65_536L || - java.nio.file.Files.isSymbolicLink(ciResultFile.toPath())) { - throw new GradleException( - "Redis CI result artifact is missing, oversized, or symlinked: ${ciResultFile}") - } - Map ciResult = new JsonSlurper().parse(ciResultFile) - as Map - if (ciResult.keySet() != [ - 'schemaVersion', - 'runId', - 'selectedCount', - 'selectedJobResult', - 'selectedArtifactNames' - ] as Set || - ciResult.schemaVersion != 1 || - ciResult.selectedCount != selectedCards.size() || - !(ciResult.runId ==~ /[1-9][0-9]{0,19}/)) { - throw new GradleException( - 'Redis CI result artifact has malformed count, run, or schema metadata') - } - String expectedJobResult = selectedCards.isEmpty() ? 'skipped' : 'success' - Set expectedArtifactNames = selectedCards.keySet().collect { - "redis-selected-${it}" - } as Set - Set actualArtifactNames = ciResult.selectedArtifactNames as Set - if (ciResult.selectedJobResult != expectedJobResult || - actualArtifactNames != expectedArtifactNames || - (ciResult.selectedArtifactNames as List).size() != - actualArtifactNames.size()) { - throw new GradleException( - "Redis CI selected job/artifact inventory mismatch; expectedResult=${expectedJobResult}, actualResult=${ciResult.selectedJobResult}, expectedArtifacts=${expectedArtifactNames}, actualArtifacts=${actualArtifactNames}") - } - } else if (!selectedCards.isEmpty() || - providers.environmentVariable('GITHUB_ACTIONS').getOrElse('') == 'true') { - throw new GradleException( - 'Redis CI/future selected reconciliation requires -PredisCiResultFile=') - } - String evidenceDirectoryProperty = providers.gradleProperty('redisEvidenceDirectory') - .getOrElse('') - if (selectedCards.isEmpty()) { - if (!evidenceDirectoryProperty.isBlank()) { - File unexpectedDirectory = rootProject.file(evidenceDirectoryProperty) - if (unexpectedDirectory.isDirectory() && - !fileTree(unexpectedDirectory).matching { - include '**/manifest.json' - }.files.isEmpty()) { - throw new GradleException( - 'No Redis card is selected but downloaded selected evidence manifests were supplied') - } - } - logger.lifecycle( - 'verifyRedisSelectedEvidenceArtifacts: selectedCount=0; explicit no-evidence branch, no R2 claim.') - return - } - if (evidenceDirectoryProperty.isBlank()) { - throw new GradleException( - 'Selected Redis cards require -PredisEvidenceDirectory=') - } - File evidenceDirectory = rootProject.file(evidenceDirectoryProperty) - if (!evidenceDirectory.isDirectory()) { - throw new GradleException( - "Redis selected evidence directory is missing: ${evidenceDirectory}") - } - Set allowedEvidenceFileNames = [ - 'manifest.json', - 'capability-card.json', - 'topology-fault-timeline.json' - ] as Set - List evidenceFiles = fileTree(evidenceDirectory).files.toList() - evidenceFiles.each { File file -> - if (!allowedEvidenceFileNames.contains(file.name) || - file.length() > 1_048_576L || - java.nio.file.Files.isSymbolicLink(file.toPath()) || - !file.toPath().toRealPath().startsWith( - evidenceDirectory.toPath().toRealPath())) { - throw new GradleException( - "Redis selected artifact contains an unexpected, oversized, symlinked, or path-escaping file: ${file}") - } - } - Set manifestParents = evidenceFiles.findAll { - it.name == 'manifest.json' - }.collect { - it.parentFile.canonicalFile - } as Set - if (evidenceFiles.size() != manifestParents.size() * allowedEvidenceFileNames.size() || - evidenceFiles.any { - !manifestParents.contains(it.parentFile.canonicalFile) - } || - manifestParents.any { File parent -> - parent.listFiles().findAll { it.isFile() }.collect { - it.name - } as Set != allowedEvidenceFileNames || - parent.listFiles().any { it.isDirectory() } - }) { - throw new GradleException( - 'Redis selected evidence must be an exact set of three allowlisted files per manifest parent') - } - List> manifests = fileTree(evidenceDirectory).matching { - include '**/manifest.json' - }.files.toSorted().collect { File manifest -> - Map parsed = new JsonSlurper().parse(manifest) as Map - parsed.__file = manifest - parsed - } - if (manifests.any { it.cardId == null }) { - throw new GradleException( - 'Downloaded selected evidence must not contain generic or unowned manifests') - } - Map expectedDigests = redisEvidenceDigests() - Set manifestFields = [ - 'schemaVersion', - 'taskPath', - 'tagExpression', - 'cardId', - 'cardState', - 'selectedTopology', - 'evidenceCategory', - 'outcome', - 'tests', - 'runtimeImageAttestation', - 'actualEventTimeline', - 'releaseQualification', - 'sourceRevision', - 'sourceTreeState', - 'digests', - 'companionSha256' - ] as Set - Set testFields = [ - 'discovered', - 'executed', - 'passed', - 'failed', - 'errors', - 'skipped' - ] as Set - Set capabilityFields = [ - 'schemaVersion', - 'cardId', - 'readiness', - 'releaseQualification', - 'promotionTopology', - 'sourceRevision', - 'sourceTreeState', - 'digests', - 'minimumRedisVersion', - 'providerIds', - 'roles', - 'programIds', - 'keyVersions', - 'codecVersions', - 'guarantees', - 'nonGuarantees', - 'requiredSettings', - 'evidenceProfile' - ] as Set - Set timelineFields = [ - 'schemaVersion', - 'taskName', - 'cardId', - 'topology', - 'evidence', - 'timelineKind', - 'actualEventTimeline', - 'sourceRevision', - 'sourceTreeState', - 'digests', - 'events' - ] as Set - selectedCards.each { String cardId, Map card -> - Set expectedEvidence = new LinkedHashSet<>( - card.requiredEvidence as List) - if (expectedEvidence.remove('selected-topology')) { - expectedEvidence.add(card.selectedTopology as String) - } - List> cardManifests = manifests.findAll { - it.cardId == cardId - } - List> capabilityManifests = cardManifests.findAll { - it.evidenceCategory == null - } - if (capabilityManifests.size() != 1) { - throw new GradleException( - "${cardId}: expected exactly one capability manifest; got ${capabilityManifests.size()}") - } - expectedEvidence.each { String evidence -> - List> matching = cardManifests.findAll { - it.evidenceCategory == evidence - } - if (matching.size() != 1) { - throw new GradleException( - "${cardId}/${evidence}: expected exactly one evidence manifest; got ${matching.size()}") - } - } - Set actualEvidence = cardManifests.findAll { - it.evidenceCategory != null - }.collect { it.evidenceCategory as String } as Set - if (actualEvidence != expectedEvidence) { - throw new GradleException( - "${cardId}: evidence mismatch; expected=${expectedEvidence}, actual=${actualEvidence}") - } - cardManifests.each { Map manifest -> - File manifestFile = manifest.__file as File - Set actualManifestFields = new LinkedHashSet<>(manifest.keySet()) - actualManifestFields.remove('__file') - Map tests = manifest.tests as Map - if (actualManifestFields != manifestFields || - tests == null || - tests.keySet() != testFields || - manifest.schemaVersion != 1 || - manifest.outcome != 'executed' || - (tests.discovered as Number).longValue() <= 0L || - (tests.executed as Number).longValue() <= 0L || - (tests.passed as Number).longValue() <= 0L || - (tests.failed as Number).longValue() != 0L || - (tests.errors as Number).longValue() != 0L || - (tests.skipped as Number).longValue() != 0L) { - throw new GradleException( - "${manifestFile}: malformed, non-executed, zero-test, failed, or skipped Redis evidence manifest") - } - if (manifest.cardState != 'selected' || - manifest.selectedTopology != card.selectedTopology || - manifest.sourceRevision != redisEvidenceSourceRevision || - manifest.sourceTreeState != redisEvidenceSourceTreeState || - manifest.releaseQualification != 'NOT_CLAIMED' || - manifest.digests != expectedDigests) { - throw new GradleException( - "${manifestFile}: stale registry/topology/source/digest metadata") - } - String cardStem = redisReadinessTaskStems[cardId] - String expectedTag - String expectedTask - if (manifest.evidenceCategory == null) { - expectedTag = "card-${cardId}" - expectedTask = - ":adapter:outbound:cache-redis:redis${cardStem}CapabilityTest" - } else { - String evidence = manifest.evidenceCategory as String - String evidenceStem = redisEvidenceTaskStems[evidence] - expectedTag = "card-${cardId} & redis-${evidence}" - expectedTask = - ":adapter:outbound:cache-redis:redis${cardStem}${evidenceStem}EvidenceTest" - } - if (manifest.tagExpression != expectedTag || manifest.taskPath != expectedTask) { - throw new GradleException( - "${manifestFile}: wrong task path or exact tag intersection") - } - File capabilityFile = new File(manifestFile.parentFile, 'capability-card.json') - File timelineFile = new File( - manifestFile.parentFile, 'topology-fault-timeline.json') - if (!capabilityFile.isFile() || !timelineFile.isFile()) { - throw new GradleException( - "${manifestFile}: missing sanitized evidence companions") - } - Map companionSha = manifest.companionSha256 as Map - if (companionSha?.keySet() != [ - 'capabilityCardSha256', - 'timelineSha256' - ] as Set || - companionSha.capabilityCardSha256 != - redisSha256(capabilityFile).substring('sha256:'.length()) || - companionSha.timelineSha256 != - redisSha256(timelineFile).substring('sha256:'.length())) { - throw new GradleException( - "${manifestFile}: companion checksum mismatch") - } - Map capability = new JsonSlurper().parse( - capabilityFile) as Map - Map timeline = new JsonSlurper().parse( - timelineFile) as Map - Map metadata = redisCapabilityMetadata[cardId] - if (capability.keySet() != capabilityFields || - capability.schemaVersion != 1 || - capability.cardId != cardId || - capability.readiness != 'selected' || - capability.releaseQualification != 'NOT_CLAIMED' || - capability.promotionTopology != card.selectedTopology || - capability.sourceRevision != redisEvidenceSourceRevision || - capability.sourceTreeState != redisEvidenceSourceTreeState || - capability.digests != expectedDigests || - capability.minimumRedisVersion != '7.2' || - capability.providerIds != metadata.providerIds || - capability.roles != metadata.roles || - capability.programIds != metadata.programs || - capability.keyVersions != metadata.keyVersions || - capability.codecVersions != metadata.codecVersions || - capability.guarantees != metadata.guarantees || - capability.nonGuarantees != metadata.nonGuarantees || - capability.requiredSettings != metadata.requiredSettings || - capability.evidenceProfile != card.requiredEvidence) { - throw new GradleException( - "${capabilityFile}: malformed or stale generated capability card") - } - if (timeline.keySet() != timelineFields || - timeline.schemaVersion != 1 || - timeline.taskName != manifest.taskPath.tokenize(':').last() || - timeline.cardId != cardId || - timeline.topology != card.selectedTopology || - timeline.evidence != manifest.evidenceCategory || - timeline.sourceRevision != redisEvidenceSourceRevision || - timeline.sourceTreeState != redisEvidenceSourceTreeState || - timeline.digests != expectedDigests || - !(timeline.events instanceof List)) { - throw new GradleException( - "${timelineFile}: malformed or stale sanitized timeline") - } - if (manifest.evidenceCategory != null && - (manifest.runtimeImageAttestation != 'CAPTURED' || - manifest.actualEventTimeline != 'CAPTURED' || - timeline.actualEventTimeline != 'CAPTURED' || - manifest.sourceTreeState != 'CLEAN')) { - throw new GradleException( - "${manifestFile}: selected promotion is blocked until actual-used image attestation and actual event timeline are captured") - } - } - Set capabilityCardDigests = cardManifests.collect { - Map companion = it.companionSha256 as Map - companion.capabilityCardSha256 as String - } as Set - if (capabilityCardDigests.size() != 1) { - throw new GradleException( - "${cardId}: capability card must be byte-identical across all evidence tasks") - } - } - Set unknownSelectedCards = manifests.findAll { - it.cardId != null - }.collect { it.cardId as String }.findAll { - !selectedCards.containsKey(it) - } as Set - if (!unknownSelectedCards.isEmpty()) { - throw new GradleException( - "Downloaded selected evidence contains unselected cards: ${unknownSelectedCards}") - } - int expectedManifestCount = selectedCards.collect { String ignored, Map card -> - Set resolved = new LinkedHashSet<>(card.requiredEvidence as List) - if (resolved.remove('selected-topology')) { - resolved.add(card.selectedTopology as String) - } - 1 + resolved.size() - }.sum() as int - if (manifests.size() != expectedManifestCount) { - throw new GradleException( - "Downloaded selected evidence manifest count mismatch; expected=${expectedManifestCount}, actual=${manifests.size()}") - } - logger.lifecycle( - "verifyRedisSelectedEvidenceArtifacts: reconciled selected cards ${selectedCards.keySet()}") - } -} - -redisPublicReadinessTasks.each { String cardId, String taskName -> - Map card = redisReadinessCards[cardId] - tasks.register(taskName) { - group = 'redis verification' - description = "Qualifies checked-in Redis capability card ${cardId}." - if (card.state != 'not-implemented') { - String cardStem = redisReadinessTaskStems[cardId] - dependsOn project(':adapter:outbound:cache-redis').tasks.named( - "redis${cardStem}CapabilityTest") - Set evidence = new LinkedHashSet<>(card.requiredEvidence as List) - if (evidence.remove('selected-topology')) { - evidence.add(card.selectedTopology as String) - } - evidence.each { String category -> - String evidenceStem = redisEvidenceTaskStems[category] - if (evidenceStem == null) { - throw new GradleException( - "Redis readiness card ${cardId} has unknown evidence ${category}") - } - dependsOn project(':adapter:outbound:cache-redis').tasks.named( - "redis${cardStem}${evidenceStem}EvidenceTest") - } - } - doLast { - if (card.state == 'not-implemented') { - logger.lifecycle("${cardId}: not selected (state=not-implemented)") - } else { - logger.lifecycle( - "${cardId}: ${card.state} evidence passed for topology ${card.selectedTopology}") - } - } - } -} - -tasks.register('redisProductionReadiness') { - group = 'redis verification' - description = 'Runs the checked-in selected Redis card release gate and architecture contracts.' - dependsOn project(':application-core').tasks.named('redisPolicyContractTest') - dependsOn project(':shared-contract').tasks.named('edgeRateLimitContractTest') - dependsOn project(':app-bootstrap').tasks.named('redisCompositionTest') - dependsOn tasks.named('verifyCleanArchitectureDependencies') - dependsOn tasks.named('verifyEnvKeys') - dependsOn tasks.named('verifyPublicPathSnapshot') - dependsOn tasks.named('verifyConfigurationPropertiesProcessor') - dependsOn verifyRedisSelectedEvidenceArtifacts - redisReadinessCards.findAll { ignored, card -> card.state == 'selected' }.each { - String cardId, Map ignored -> - dependsOn tasks.named(redisPublicReadinessTasks[cardId]) - } - doLast { - List selected = redisReadinessCards.findAll { - ignored, card -> card.state == 'selected' - }.keySet().toList() - if (selected.isEmpty()) { - logger.lifecycle( - 'redisProductionReadiness: no selected card; verified provider-disabled composition and no release R2 claim.') - } else { - logger.lifecycle("redisProductionReadiness: selected cards passed ${selected}") - } - } -} - -tasks.register('redisAllImplementedCandidates') { - group = 'redis verification' - description = 'Runs selected and implemented-candidate Redis cards without changing release labels.' - redisReadinessCards.findAll { - ignored, card -> card.state in ['selected', 'implemented-candidate'] - }.each { String cardId, Map ignored -> - dependsOn tasks.named(redisPublicReadinessTasks[cardId]) - } -} - def verifyConfigurationPropertiesProcessor = tasks.register('verifyConfigurationPropertiesProcessor') { group = 'verification' description = 'Verifies every registered leaf declares the Spring configuration processor exactly when its main source owns @ConfigurationProperties.' @@ -3298,8 +1913,14 @@ tasks.register('verifyEnvKeys') { File envFile = file("${rootProject.projectDir}/.env") File appYml = file("${rootProject.projectDir}/app-bootstrap/src/main/resources/application.yml") File registryFile = file("${rootProject.projectDir}/../docs/registries/env-keys.yaml") + // Check E reads the annotation processor's output, so the owning module has to have been + // compiled. Without this the check would quietly cover nothing on a clean checkout. + File redisSdkMetadata = file("${rootProject.projectDir}/adapter/outbound/cache-redis/build/" + + 'classes/java/main/META-INF/spring-configuration-metadata.json') + dependsOn ':adapter:outbound:cache-redis:compileJava' inputs.files(envFile, appYml, registryFile) + inputs.file(redisSdkMetadata).optional() doLast { if (!envFile.exists()) { @@ -3380,76 +2001,152 @@ tasks.register('verifyEnvKeys') { "secret references are included): ${unregisteredApplicationReferences}") } + // E. Typed properties that are deliberately absent from application.yml and src/.env. + // + // Checks A–D compare three text files, so a property that exists only as a typed + // @ConfigurationProperties field is invisible to them: the Redis SDK shipped 34 settings + // with no registered env name at all and verifyEnvKeys passed. Conditionally-composed + // adapters cannot be fixed by adding their settings to application.yml — that is what + // would make a Redis-free deployment carry Redis configuration — so the third SSOT for + // them is the annotation processor's own metadata, compared against the registry in both + // directions: a typed property with no row, and a row naming a property that no longer + // exists, are both failures. + Map metadataScopes = [ + 'app.redis.': 'adapter/outbound/cache-redis' + ] + Set typedProperties = new TreeSet<>() + Set missingMetadata = new TreeSet<>() + metadataScopes.each { propertyPrefix, modulePath -> + File metadata = file( + "${rootProject.projectDir}/${modulePath}/build/classes/java/main/" + + 'META-INF/spring-configuration-metadata.json') + if (!metadata.exists()) { + missingMetadata << "${propertyPrefix} (${metadata})".toString() + return + } + def parsed = new groovy.json.JsonSlurper().parse(metadata) + (parsed.properties ?: []).each { property -> + if (property.name?.startsWith(propertyPrefix)) { + typedProperties << property.name.toString() + } + } + } + if (!missingMetadata.isEmpty()) { + throw new GradleException( + 'verifyEnvKeys: configuration metadata is missing for ' + missingMetadata + + ' — run the owning module\'s compileJava first (the annotation ' + + 'processor writes it), or the typed-property check silently covers ' + + 'nothing.') + } + + def registryPropertyPattern = ~/^\s*property:\s*(\S+)/ + Set registryProperties = registryFile.readLines().findResults { String line -> + def m = registryPropertyPattern.matcher(line) + m.find() ? m.group(1) : null + }.toSet() + + Set unregisteredTypedProperties = new TreeSet<>(typedProperties - registryProperties) + if (!unregisteredTypedProperties.isEmpty()) { + throw new GradleException( + 'verifyEnvKeys: typed configuration properties absent from ' + + "docs/registries/env-keys.yaml: ${unregisteredTypedProperties} — every " + + 'bindable property needs a registry row carrying its official env ' + + 'name, type, default, secret classification and required_when.') + } + + Set scopedRegistryProperties = registryProperties.findAll { String property -> + metadataScopes.keySet().any { property.startsWith(it) } + }.toSet() + Set orphanedRegistryProperties = + new TreeSet<>(scopedRegistryProperties - typedProperties) + if (!orphanedRegistryProperties.isEmpty()) { + throw new GradleException( + 'verifyEnvKeys: docs/registries/env-keys.yaml declares properties that no ' + + "typed settings class binds any more: ${orphanedRegistryProperties} — " + + 'remove the row or restore the property.') + } + + // F. Every registered key has a consumer, or says out loud that it does not. + // Checks A-E each compare two SSOTs, and a row that appears in none of them falls + // through all of them: APP_CACHE_REDIS_TRUST_PEM and four namespace keys sat in the + // registry with no typed property, no application.yml reference and no .env entry, + // documented as if a deployment could still use them. A key nothing reads is worse + // than an undocumented one — an operator sets it, nothing happens, and the + // configuration looks correct. + Map> registryRows = [:] + String currentRow = null + registryFile.readLines().each { String line -> + def nameMatch = (line =~ /^\s*- name: (APP_[A-Z0-9_]+)/) + if (nameMatch.find()) { + currentRow = nameMatch.group(1) + registryRows[currentRow] = [:] + return + } + if (currentRow == null) { + return + } + def fieldMatch = (line =~ /^\s*([a-z_]+):\s*(\S.*)?$/) + if (fieldMatch.find()) { + registryRows[currentRow][fieldMatch.group(1)] = (fieldMatch.group(2) ?: '').trim() + } + } + Set consumed = new TreeSet<>() + consumed.addAll(applicationAppReferences) + consumed.addAll(envAppKeys) + // A key can be read in ways checks A-D never look at: a module's own application.yml (the + // sample's, for one) and Java that names a secret directly, as SecretSourceValidator does. + // Counting only the composition root's yaml would report those as orphans, which is the + // opposite failure — a check that cries wolf gets an exclusion list and then gets ignored. + def appKeyPattern = ~/APP_[A-Z][A-Z0-9_]*/ + rootProject.projectDir.eachFileRecurse { File candidate -> + if (!candidate.isFile()) { + return + } + boolean interesting = + (candidate.name == 'application.yml' && candidate.path.contains('/main/')) || + (candidate.name.endsWith('.java') && candidate.path.contains('/src/main/')) + if (!interesting) { + return + } + def matcher = appKeyPattern.matcher(candidate.text) + while (matcher.find()) { + consumed << matcher.group() + } + } + Set unconsumed = new TreeSet<>(registryRows.keySet().findAll { String name -> + Map row = registryRows[name] + !consumed.contains(name) && + !row.containsKey('property') && + row['deprecated_orphaned'] != 'true' + }) + // Enforced for the surfaces this branch owns; reported for the rest. A key nothing reads is + // a defect wherever it lives, but silently adopting another feature's backlog into a + // blocking gate is how a gate acquires an exclusion list. The rest are named on every run so + // they cannot be forgotten, and their owning branch turns them into failures here. + def enforcedPrefixes = ['APP_REDIS_', 'APP_CACHE_REDIS_', 'APP_RATE_LIMIT_REDIS_', + 'APP_IDEMPOTENCY_REDIS_', 'APP_LEASE_REDIS_', 'APP_SESSION_REDIS_'] + Set unconsumedOwned = + new TreeSet<>(unconsumed.findAll { String name -> enforcedPrefixes.any { name.startsWith(it) } }) + if (!unconsumedOwned.isEmpty()) { + throw new GradleException( + 'verifyEnvKeys: registered Redis keys that nothing reads — no typed property, ' + + 'no application.yml reference, no src/.env entry, no Java consumer, ' + + "and not marked deprecated_orphaned: ${unconsumedOwned}. Wire the key " + + 'to a consumer, or mark the row deprecated_orphaned with a ' + + 'removal_deadline so a deployment still setting it is told rather ' + + 'than silently ignored.') + } + Set unconsumedElsewhere = new TreeSet<>(unconsumed - unconsumedOwned) + if (!unconsumedElsewhere.isEmpty()) { + logger.warn('verifyEnvKeys: registered keys outside the Redis surface that nothing ' + + "reads yet: ${unconsumedElsewhere} — owned by the branch that registered them.") + } + logger.lifecycle("verifyEnvKeys: OK — ${envKeys.size()} env keys, " + "${requiredPlaceholders.size()} required placeholders covered, " + - "${applicationAppReferences.size()} application APP_ references registered.") - } -} - -// verifyPublicPathSnapshot — fail the build on an unapproved change to the deny-by-default -// public path surface (SECURITY_PUBLIC_PATHS). Approve with -PapprovePublicPathChange. -// Rationale and the snapshot-vs-reflection decision are in README.md. -tasks.register('verifyPublicPathSnapshot') { - group = 'verification' - description = 'Fails on an unapproved change to the deny-by-default public path surface.' - - File envFile = file("${rootProject.projectDir}/.env") - File snapshotFile = file("${rootProject.projectDir}/../docs/security/public-paths-snapshot.txt") - boolean approved = project.hasProperty('approvePublicPathChange') - - inputs.file(envFile) - inputs.property('approved', approved) - - doLast { - if (!envFile.exists()) { - throw new GradleException("verifyPublicPathSnapshot: missing ${envFile}") - } - - def valuePattern = ~/^SECURITY_PUBLIC_PATHS=(.*)$/ - String raw = envFile.readLines().findResult { String line -> - def m = valuePattern.matcher(line) - m.matches() ? m.group(1) : null - } ?: '' - - List publicPaths = raw.split(',') - .collect { it.trim() } - .findAll { !it.isEmpty() } - .toSorted() - - String header = - "# feature-security-operational-baseline D5 — deny-by-default public path snapshot.\n" + - "# SSOT: SECURITY_PUBLIC_PATHS (src/.env) -> SecurityConfig permitAll(); anyRequest authenticated.\n" + - "# Regenerate after review with: ./gradlew verifyPublicPathSnapshot -PapprovePublicPathChange\n" - String canonical = header + (publicPaths.isEmpty() ? "" : publicPaths.join('\n') + '\n') - - if (!snapshotFile.exists()) { - snapshotFile.parentFile.mkdirs() - snapshotFile.text = canonical - logger.lifecycle("verifyPublicPathSnapshot: snapshot created at ${snapshotFile} " + - "(${publicPaths.size()} public path(s)). Review and commit it.") - return - } - - String existing = snapshotFile.text - if (existing == canonical) { - logger.lifecycle("verifyPublicPathSnapshot: OK — ${publicPaths.size()} public path(s) unchanged.") - return - } - - if (approved) { - snapshotFile.text = canonical - logger.lifecycle("verifyPublicPathSnapshot: snapshot updated (approved). " + - "Now ${publicPaths.size()} public path(s).") - return - } - - throw new GradleException( - "verifyPublicPathSnapshot: the deny-by-default public path surface changed.\n" + - " expected (snapshot):\n${existing}\n" + - " actual (SECURITY_PUBLIC_PATHS):\n${canonical}\n" + - "A protected endpoint may now be public. If this change is intended, get it reviewed " + - "(security:public-path-change) and regenerate with:\n" + - " ./gradlew verifyPublicPathSnapshot -PapprovePublicPathChange") + "${applicationAppReferences.size()} application APP_ references registered, " + + "${typedProperties.size()} typed properties registered, " + + "${registryRows.size() - unconsumed.size()} rows with a consumer or a deprecation.") } }