chore: record pre-existing uncommitted repository state

Snapshot of the in-flight state that already existed, identically, in both
this worktree and the main checkout before this session began: the initial
HTTP Client platform implementation (previously untracked), the redis-lab
removal, and the JPA / object-storage / notification integration work.

Kept separate from this session's HTTP Client review response, which lands
in the following commit, so the two bodies of work stay reviewable apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-11 16:48:43 +09:00
co-authored by Claude Opus 5
parent 1a3b560678
commit 5f10b791d3
1857 changed files with 130925 additions and 72491 deletions
+179 -593
View File
@@ -1,616 +1,202 @@
// Redis SDK leaf — see docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md.
//
// The design models the SDK as separate Gradle modules. This repository's fail-closed 19-leaf
// registry outranks that layout, so the module boundaries are packages under
// dev.caskeleton.adapter.outbound.cache.redis.sdk and RedisSdkModuleBoundaryTest enforces them.
dependencies {
// Registered edges the semantic port adapters need. The SDK itself imports nothing from them
// today (0 imports across main source) — the semantic cache/session/idempotency/rate-limit
// adapters that did were removed and are restored by Phase E of
// docs/superpowers/plans/2026-08-10-redis-optionality-and-composition.md. They stay declared
// because that restoration is the module's stated responsibility, not because anything here
// compiles against them.
implementation project(':application-core')
implementation project(':shared-contract')
implementation project(':adapter:outbound:support')
implementation 'org.springframework.boot:spring-boot-autoconfigure'
implementation 'org.springframework.session:spring-session-core'
implementation 'org.springframework.session:spring-session-data-redis'
implementation 'org.springframework.data:spring-data-redis'
// The role-aware health contributors are HealthIndicators; the readiness probe is the only
// place the required/optional Redis taxonomy can actually be enforced.
implementation 'org.springframework.boot:spring-boot-health'
// Boot's Health type carries Jackson annotations. Without the annotations on the compile
// classpath javac emits an 'unknown enum constant' warning, and this build is -Werror. Runtime
// does not need it from here — the app already has Jackson — so compileOnly is the honest scope.
compileOnly 'com.fasterxml.jackson.core:jackson-annotations'
implementation 'io.lettuce:lettuce-core'
implementation 'io.micrometer:micrometer-core'
// Reactor is in the public signature of sdk.api.reactive, and it was reaching this module only
// transitively through lettuce-core. A driver upgrade that stopped exposing it would have
// broken compilation of the SDK's own published API, so it is declared directly.
implementation 'io.projectreactor:reactor-core'
implementation 'org.slf4j:slf4j-api'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
// Deliberately absent:
// org.springframework.data:spring-data-redis — the SDK owns its own typed API and command
// policy on purpose; routing through Spring Data would reintroduce the untyped, unguarded
// command surface the catalog exists to prevent. Zero imports.
// io.micrometer:micrometer-core — observation leaves this leaf as RedisObservation through a
// Consumer sink; binding it to a meter registry belongs to the composition root, not here.
// Zero imports.
}
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
sourceSets {
redisTest {
java.srcDir 'src/redisTest/java'
resources.srcDir 'src/redisTest/resources'
compileClasspath += sourceSets.main.output
runtimeClasspath += sourceSets.main.output
}
}
configurations {
redisTestImplementation.extendsFrom testImplementation
redisTestCompileOnly.extendsFrom testCompileOnly
redisTestRuntimeOnly.extendsFrom testRuntimeOnly
}
dependencies {
redisTestImplementation 'org.testcontainers:testcontainers'
}
// The topology lane is opt-in and fail-closed. The default unit task excludes it, and selecting it
// without an endpoint is an error rather than a skip: a topology test that silently passes because
// it never connected is worse than not having one.
tasks.named('test') {
useJUnitPlatform {
excludeTags 'redis-service'
excludeTags 'redis-topology'
}
}
tasks.register('redisServiceTest', Test) {
// Lane selection is derived from the declared mode rather than chosen by hand. A promotion test is
// meaningless without sentinels and a cross-slot test is meaningless without a cluster, but writing
// that as a runtime assumption would turn "the lane was never started" into a green skip. Selecting
// by tag keeps the lane fail-closed: what a mode cannot prove is not selected, and what is selected
// must pass.
//
// The mode is an allowlist, not free text. Deriving the tag from an arbitrary property produced the
// worst possible result for a qualification lane: `-Predis.topology.mode=TYPO` built the tag
// `lane-typo`, matched nothing, ran zero tests and exited 0. A release gate that reports success
// for a lane it never ran is worse than no gate, so an unknown mode is an error and a run that
// executed no test is a failure.
// `tls` is a lane, not a deployment mode. Its shape is standalone; what it qualifies is the
// transport, which no other lane carries a single command over. It was reachable only by hand —
// point LiveRedisCompositionTest at the TLS compose with an ad-hoc init script — which is another
// way of saying the release gate did not cover TLS at all.
def REDIS_TOPOLOGY_MODES = ['standalone', 'sentinel', 'cluster', 'tls'] as Set
def REDIS_TOPOLOGY_DEPLOYMENT_MODE = ['standalone': 'standalone', 'sentinel': 'sentinel',
'cluster': 'cluster', 'tls': 'standalone']
// The classes each lane exists to run, and the floor below which its coverage has shrunk. Both are
// declarations rather than observations: a lane that lost a class to a rename, or lost half its
// cases to a filter, otherwise still reports success.
def REDIS_TOPOLOGY_REQUIRED_CLASSES = [
'standalone': ['LiveRedisCompositionTest', 'LiveRedisSemanticPortsTest',
'RedisTopologyContractTest', 'LiveRedisGuardrailTest'],
'sentinel' : ['LiveRedisCompositionTest', 'LiveRedisSentinelPromotionTest',
'RedisTopologyContractTest'],
'cluster' : ['LiveRedisCompositionTest', 'LiveRedisClusterTest',
'LiveRedisClusterTransactionTest', 'LiveRedisSemanticPortsTest'],
'tls' : ['LiveRedisTlsTest'],
]
def REDIS_TOPOLOGY_MINIMUM_TESTS = ['standalone': 20, 'sentinel': 20, 'cluster': 24, 'tls': 4]
tasks.register('redisTopologyTest', Test) {
description = 'Runs the Redis SDK contracts against a real topology declared in infra/redis-sdk.'
group = 'verification'
description = 'Runs the explicit real Redis standalone qualification lane.'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform {
includeTags 'redis-service'
}
['redis.test.host', 'redis.test.port'].each { propertyName ->
String propertyValue = System.getProperty(propertyName)
if (propertyValue != null) {
systemProperty propertyName, propertyValue
}
}
shouldRunAfter tasks.named('test')
}
def verifyRedisEvidenceSourcesPresent = tasks.register('verifyRedisEvidenceSourcesPresent') {
group = 'redis verification'
description = 'Fails readiness lanes when the redisTest evidence source set is empty.'
inputs.files(sourceSets.redisTest.allSource)
doLast {
Set<File> javaSources = sourceSets.redisTest.java.files.findAll {
it.isFile() && it.name.endsWith('.java')
}
if (javaSources.isEmpty()) {
throw new GradleException(
'Redis evidence source set is empty; readiness tasks must not pass as NO-SOURCE.')
}
File imageRegistry = rootProject.file('gradle/redis-test-images.properties')
if (!imageRegistry.isFile() || imageRegistry.length() == 0) {
throw new GradleException(
"Redis evidence image registry is missing or empty: ${imageRegistry}")
}
}
}
def redisCapabilityMetadata = rootProject.ext.redisCapabilityMetadata
def redisSanitizedEvidenceFileNames = [
'manifest.json',
'capability-card.json',
'topology-fault-timeline.json'
] as Set<String>
def redisSanitizedBundleSha256 = { File directory ->
java.security.MessageDigest digest = java.security.MessageDigest.getInstance('SHA-256')
redisSanitizedEvidenceFileNames.toList().sort().each { String name ->
File file = new File(directory, name)
if (!file.isFile()) {
throw new GradleException(
"Redis sanitized bundle is missing ${name}: ${directory}")
}
byte[] nameBytes = name.getBytes('UTF-8')
byte[] contentBytes = file.bytes
digest.update(java.nio.ByteBuffer.allocate(Long.BYTES).putLong(nameBytes.length).array())
digest.update(nameBytes)
digest.update(java.nio.ByteBuffer.allocate(Long.BYTES).putLong(contentBytes.length).array())
digest.update(contentBytes)
}
digest.digest().encodeHex().toString()
}
def registerRedisEvidenceTask = { String taskName, String tagExpression, String descriptionText ->
def evidenceTask = tasks.register(taskName, Test) {
group = 'redis verification'
description = descriptionText
dependsOn verifyRedisEvidenceSourcesPresent
testClassesDirs = sourceSets.redisTest.output.classesDirs
classpath = sourceSets.redisTest.runtimeClasspath
useJUnitPlatform {
includeTags tagExpression
}
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
jvmArgs '-Duser.timezone=UTC'
systemProperty 'redis.image.registry',
rootProject.file('gradle/redis-test-images.properties').absolutePath
List<Map<String, Object>> sanitizedTimeline = []
List<String> declaredTags = tagExpression.split(/\s*&\s*/).toList()
String cardTag = declaredTags.find { it.startsWith('card-') }
String cardId = cardTag == null ? null : cardTag.substring('card-'.length())
Set<String> evidenceCategories = [
'standalone',
'security',
'sentinel',
'cluster',
'fault',
'compatibility'
] as Set<String>
String evidenceCategory = declaredTags.find {
it.startsWith('redis-') && evidenceCategories.contains(it.substring('redis-'.length()))
}
if (evidenceCategory != null) {
evidenceCategory = evidenceCategory.substring('redis-'.length())
}
File evidenceDirectory = layout.buildDirectory.dir(
"redis-evidence/${taskName}").get().asFile
outputs.dir evidenceDirectory
afterTest { descriptor, result ->
String identity = "${descriptor.className ?: ''}#${descriptor.name ?: ''}"
String identityDigest = java.security.MessageDigest.getInstance('SHA-256')
.digest(identity.getBytes('UTF-8')).encodeHex().toString()
sanitizedTimeline << [
sequence : sanitizedTimeline.size() + 1,
testCaseIdSha256: identityDigest,
outcome : result.resultType.name(),
durationMillis : Math.max(0L, result.endTime - result.startTime)
]
}
afterSuite { descriptor, result ->
if (descriptor.parent != null) {
return
}
evidenceDirectory.mkdirs()
Map<String, Object> card = cardId == null
? null
: rootProject.ext.redisReadinessCards[cardId] as Map<String, Object>
Map<String, String> digests = rootProject.ext.redisEvidenceDigests()
Map<String, Object> metadata = cardId == null
? [
providerIds : [],
roles : [],
programs : [],
keyVersions : [],
codecVersions : [],
guarantees : ['cross-cutting Redis evidence lane'],
nonGuarantees : ['does not qualify a capability card by itself'],
requiredSettings: []
]
: redisCapabilityMetadata[cardId] as Map<String, Object>
Map<String, Object> capabilityCard = [
schemaVersion : 1,
cardId : cardId,
readiness : card?.state,
releaseQualification: 'NOT_CLAIMED',
promotionTopology : card?.selectedTopology,
sourceRevision : rootProject.ext.redisEvidenceSourceRevision,
sourceTreeState : rootProject.ext.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 ?: []
]
File capabilityCardFile = new File(evidenceDirectory, 'capability-card.json')
capabilityCardFile.setText(
groovy.json.JsonOutput.prettyPrint(
groovy.json.JsonOutput.toJson(capabilityCard)) + '\n',
'UTF-8')
Map<String, Object> timeline = [
schemaVersion: 1,
taskName : taskName,
cardId : cardId,
topology : card?.selectedTopology,
evidence : evidenceCategory,
timelineKind : 'SANITIZED_TEST_RESULT',
actualEventTimeline: 'NOT_CAPTURED',
sourceRevision: rootProject.ext.redisEvidenceSourceRevision,
sourceTreeState: rootProject.ext.redisEvidenceSourceTreeState,
digests : digests,
events : sanitizedTimeline
]
File timelineFile = new File(evidenceDirectory, 'topology-fault-timeline.json')
timelineFile.setText(
groovy.json.JsonOutput.prettyPrint(
groovy.json.JsonOutput.toJson(timeline)) + '\n',
'UTF-8')
Closure<String> sha256 = { File file ->
java.security.MessageDigest.getInstance('SHA-256')
.digest(file.bytes).encodeHex().toString()
}
String outcome = result.resultType.name() == 'FAILURE'
? 'failed'
: (result.testCount == 0 || result.skippedTestCount > 0
? 'skipped-with-reason'
: 'executed')
Map<String, Object> manifest = [
schemaVersion : 1,
taskPath : path,
tagExpression : tagExpression,
cardId : cardId,
cardState : card?.state,
selectedTopology : card?.selectedTopology,
evidenceCategory : evidenceCategory,
outcome : outcome,
tests : [
discovered: result.testCount,
executed : result.testCount - result.skippedTestCount,
passed : result.successfulTestCount,
failed : result.failedTestCount,
errors : 0,
skipped : result.skippedTestCount
],
runtimeImageAttestation: 'NOT_CAPTURED',
actualEventTimeline: 'NOT_CAPTURED',
releaseQualification: 'NOT_CLAIMED',
sourceRevision : rootProject.ext.redisEvidenceSourceRevision,
sourceTreeState : rootProject.ext.redisEvidenceSourceTreeState,
digests : digests,
companionSha256 : [
capabilityCardSha256: sha256(capabilityCardFile),
timelineSha256 : sha256(timelineFile)
]
]
new File(evidenceDirectory, 'manifest.json').setText(
groovy.json.JsonOutput.prettyPrint(
groovy.json.JsonOutput.toJson(manifest)) + '\n',
'UTF-8')
}
doFirst {
[
'manifest.json',
'capability-card.json',
'topology-fault-timeline.json'
].each { String generatedFile ->
new File(evidenceDirectory, generatedFile).delete()
}
layout.buildDirectory.file(
"redis-evidence-sanitizer/${taskName}.sha256").get().asFile.delete()
Set<File> matchingSources = sourceSets.redisTest.java.files.findAll { File source ->
if (!source.isFile() || !source.name.endsWith('.java')) {
return false
}
String content = source.getText('UTF-8')
declaredTags.every { String tag -> content.contains("@Tag(\"${tag}\")") }
}
if (matchingSources.isEmpty()) {
throw new GradleException(
"${taskName}: no redisTest source declares every required tag " +
"${declaredTags}; zero-evidence readiness must not pass.")
}
}
}
def sanitizerTask = tasks.register("${taskName}SanitizeEvidence") {
group = 'redis verification'
description = "Validates the bounded sanitized artifact for ${taskName} before upload."
mustRunAfter evidenceTask
File sanitizerMarker = layout.buildDirectory.file(
"redis-evidence-sanitizer/${taskName}.sha256").get().asFile
doFirst {
sanitizerMarker.delete()
}
doLast {
File evidenceDirectory = layout.buildDirectory.dir(
"redis-evidence/${taskName}").get().asFile
if (!evidenceDirectory.isDirectory()) {
throw new GradleException(
"${taskName}: sanitized evidence directory was not generated")
}
Set<String> allowedNames = redisSanitizedEvidenceFileNames
List<File> files = evidenceDirectory.listFiles()?.findAll { it.isFile() } ?: []
if (files.collect { it.name } as Set<String> != allowedNames ||
evidenceDirectory.listFiles()?.any { it.isDirectory() }) {
throw new GradleException(
"${taskName}: sanitized evidence must contain exactly ${allowedNames}")
}
files.each { File file ->
if (file.length() > 1_048_576L ||
java.nio.file.Files.isSymbolicLink(file.toPath()) ||
!file.toPath().toRealPath().startsWith(
evidenceDirectory.toPath().toRealPath())) {
throw new GradleException(
"${taskName}: oversized, symlinked, or path-escaping artifact ${file}")
}
String text = file.getText('UTF-8')
Map<String, java.util.regex.Pattern> forbidden = [
pem : java.util.regex.Pattern.compile(
'(?i)-----BEGIN [^-]*(?:PRIVATE KEY|CERTIFICATE)-----'),
aclMaterial : java.util.regex.Pattern.compile(
"(?i)(?:users\\.acl|--pass|[\"']password[\"']\\s*:)"),
uriUserInfo : java.util.regex.Pattern.compile(
'(?i)rediss?://[^\\s/@:]+:[^\\s/@]+@'),
secretReference : java.util.regex.Pattern.compile('(?i)secret://'),
rawMessageFields : java.util.regex.Pattern.compile(
'(?i)"(?:stackTrace|systemOut|systemErr|exception|containerId|host|ip|port|endpoint|rawKey|physicalKey|value|sessionId|csrf|idempotencyToken|ownerToken|operationToken)"\\s*:')
]
forbidden.each { String marker, java.util.regex.Pattern pattern ->
if (pattern.matcher(text).find()) {
throw new GradleException(
"${taskName}: sanitized artifact ${file.name} contains forbidden ${marker} material")
}
}
}
Map<String, Object> manifest = new groovy.json.JsonSlurper().parse(
new File(evidenceDirectory, 'manifest.json')) as Map<String, Object>
Map<String, Object> capability = new groovy.json.JsonSlurper().parse(
new File(evidenceDirectory, 'capability-card.json')) as Map<String, Object>
Map<String, Object> timeline = new groovy.json.JsonSlurper().parse(
new File(evidenceDirectory, 'topology-fault-timeline.json')) as Map<String, Object>
Set<String> manifestFields = [
'schemaVersion',
'taskPath',
'tagExpression',
'cardId',
'cardState',
'selectedTopology',
'evidenceCategory',
'outcome',
'tests',
'runtimeImageAttestation',
'actualEventTimeline',
'releaseQualification',
'sourceRevision',
'sourceTreeState',
'digests',
'companionSha256'
] as Set<String>
Set<String> capabilityFields = [
'schemaVersion',
'cardId',
'readiness',
'releaseQualification',
'promotionTopology',
'sourceRevision',
'sourceTreeState',
'digests',
'minimumRedisVersion',
'providerIds',
'roles',
'programIds',
'keyVersions',
'codecVersions',
'guarantees',
'nonGuarantees',
'requiredSettings',
'evidenceProfile'
] as Set<String>
Set<String> timelineFields = [
'schemaVersion',
'taskName',
'cardId',
'topology',
'evidence',
'timelineKind',
'actualEventTimeline',
'sourceRevision',
'sourceTreeState',
'digests',
'events'
] as Set<String>
if (manifest.keySet() != manifestFields ||
capability.keySet() != capabilityFields ||
timeline.keySet() != timelineFields ||
(manifest.tests as Map).keySet() != [
'discovered',
'executed',
'passed',
'failed',
'errors',
'skipped'
] as Set<String> ||
(manifest.digests as Map).keySet() != [
'registrySha256',
'imageRegistrySha256',
'programSetSha256',
'configurationSha256'
] as Set<String> ||
(manifest.companionSha256 as Map).keySet() != [
'capabilityCardSha256',
'timelineSha256'
] as Set<String>) {
throw new GradleException(
"${taskName}: sanitized evidence contains unknown or missing schema fields")
}
List<Map<String, Object>> events = timeline.events as List<Map<String, Object>>
if (events.size() > 10_000 ||
events.withIndex().any { Map<String, Object> event, int index ->
event.keySet() != [
'sequence',
'testCaseIdSha256',
'outcome',
'durationMillis'
] as Set<String> ||
event.sequence != index + 1 ||
!(event.testCaseIdSha256 ==~ /[0-9a-f]{64}/) ||
!(event.outcome in ['SUCCESS', 'FAILURE', 'SKIPPED']) ||
!(event.durationMillis instanceof Number) ||
(event.durationMillis as Number).longValue() < 0L
}) {
throw new GradleException(
"${taskName}: sanitized test summary contains malformed events")
}
if ((capability.requiredSettings as List).any {
!(it instanceof Map) ||
(it as Map).keySet() != ['name', 'type', 'constraint'] as Set<String>
}) {
throw new GradleException(
"${taskName}: capability card required settings are not a safe name/type/constraint projection")
}
Map<String, Object> tests = manifest.tests as Map<String, Object>
if (!(manifest.outcome in ['executed', 'failed', 'skipped-with-reason']) ||
events.size() != (tests.discovered as Number).intValue() ||
events.count { it.outcome == 'SUCCESS' } !=
(tests.passed as Number).intValue() ||
events.count { it.outcome == 'FAILURE' } !=
(tests.failed as Number).intValue() ||
events.count { it.outcome == 'SKIPPED' } !=
(tests.skipped as Number).intValue()) {
throw new GradleException(
"${taskName}: manifest outcome/counts do not match the sanitized test summary")
}
if (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(
"${taskName}: executed evidence must be positive with zero failure/error/skip")
}
if (manifest.outcome == 'failed' &&
(tests.failed as Number).longValue() <= 0L) {
throw new GradleException(
"${taskName}: failed evidence must retain a positive bounded failure count")
}
sanitizerMarker.parentFile.mkdirs()
String bundleSha = redisSanitizedBundleSha256(evidenceDirectory)
sanitizerMarker.setText("${bundleSha}\n", 'UTF-8')
if (manifest.outcome == 'skipped-with-reason') {
throw new GradleException(
"${taskName}: skipped or zero-executed evidence is not a passing readiness lane")
}
}
}
evidenceTask.configure {
finalizedBy sanitizerTask
}
evidenceTask
}
tasks.register('verifyRedisEvidenceArtifactsForUpload') {
group = 'redis verification'
description = 'Allows CI upload only when every generated Redis evidence directory was sanitized.'
doLast {
File evidenceRoot = layout.buildDirectory.dir('redis-evidence').get().asFile
File markerRoot = layout.buildDirectory.dir('redis-evidence-sanitizer').get().asFile
List<File> evidenceDirectories = evidenceRoot.isDirectory()
? evidenceRoot.listFiles().findAll { it.isDirectory() }
: []
if (evidenceDirectories.isEmpty()) {
throw new GradleException(
'No sanitized Redis evidence directory exists for upload')
}
Set<String> evidenceTasks = evidenceDirectories.collect { it.name } as Set<String>
Set<String> markerTasks = markerRoot.isDirectory()
? markerRoot.listFiles().findAll {
it.isFile() && it.name.endsWith('.sha256')
}.collect {
it.name.substring(0, it.name.length() - '.sha256'.length())
} as Set<String>
: [] as Set<String>
if (evidenceTasks != markerTasks) {
throw new GradleException(
"Redis evidence upload sanitizer coverage mismatch; evidence=${evidenceTasks}, markers=${markerTasks}")
}
evidenceDirectories.each { File directory ->
Set<String> files = directory.listFiles().findAll { it.isFile() }
.collect { it.name } as Set<String>
if (files != redisSanitizedEvidenceFileNames) {
throw new GradleException(
"Redis upload directory ${directory.name} is outside the sanitized allowlist")
}
String bundleSha = redisSanitizedBundleSha256(directory)
String recordedSha = new File(
markerRoot, "${directory.name}.sha256").getText('UTF-8').trim()
if (recordedSha != bundleSha) {
throw new GradleException(
"Redis upload sanitizer bundle marker is stale for ${directory.name}")
}
}
}
}
registerRedisEvidenceTask(
'redisStandaloneTest',
'redis-standalone',
'Runs real standalone Redis evidence. Docker/service absence and zero tests fail.')
registerRedisEvidenceTask(
'redisSecurityTest',
'redis-security',
'Runs Redis TLS, ACL, secret-redaction, and fail-closed security evidence.')
registerRedisEvidenceTask(
'redisSentinelTest',
'redis-sentinel',
'Runs the explicit Redis Sentinel topology evidence lane.')
registerRedisEvidenceTask(
'redisClusterTest',
'redis-cluster',
'Runs the explicit Redis Cluster topology evidence lane.')
registerRedisEvidenceTask(
'redisFaultTest',
'redis-fault',
'Runs bounded Redis outage, response-loss, memory, and recovery evidence.')
registerRedisEvidenceTask(
'redisCompatibilityTest',
'redis-compatibility',
'Runs pinned minimum/next/approved Redis compatibility evidence.')
registerRedisEvidenceTask(
'redisEfficiencyLeaseTest',
'redis-efficiency-lease',
'Runs non-fenced EFFICIENCY_ONLY lease standalone, security, fault, and compatibility qualification.')
def redisCardTags = [
redisCacheCapabilityTest : 'card-redis-cache',
redisRateLimitCapabilityTest : 'card-redis-edge-rate-limit',
redisIdempotencyCapabilityTest : 'card-redis-request-replay-idempotency',
redisSoftLeaseCapabilityTest : 'card-redis-cache-refresh-soft-lease',
redisFencedCoordinationCapabilityTest: 'card-redis-fenced-coordination',
redisSessionCapabilityTest : 'card-redis-session'
]
redisCardTags.each { String taskName, String cardTag ->
registerRedisEvidenceTask(
taskName,
cardTag,
"Runs all real-service evidence owned by Redis capability card ${cardTag}.")
}
def redisEvidenceTags = [
Standalone : 'redis-standalone',
Security : 'redis-security',
Sentinel : 'redis-sentinel',
Cluster : 'redis-cluster',
Fault : 'redis-fault',
Compatibility: 'redis-compatibility'
]
def redisCardTaskStems = [
Cache : 'card-redis-cache',
RateLimit : 'card-redis-edge-rate-limit',
Idempotency : 'card-redis-request-replay-idempotency',
SoftLease : 'card-redis-cache-refresh-soft-lease',
FencedCoordination: 'card-redis-fenced-coordination',
Session : 'card-redis-session'
]
redisCardTaskStems.each { String cardStem, String cardTag ->
redisEvidenceTags.each { String evidenceStem, String evidenceTag ->
registerRedisEvidenceTask(
"redis${cardStem}${evidenceStem}EvidenceTest",
"${cardTag} & ${evidenceTag}",
"Runs ${evidenceTag} evidence owned only by ${cardTag}.")
}
}
tasks.named('check') {
dependsOn tasks.named('redisStandaloneTest')
}
def redisLabContractDirectory = rootProject.file('../infra/redis-lab')
def redisLabContractTest = tasks.register('redisLabContractTest', Exec) {
group = 'verification'
description = 'Runs the VM-free Redis lab lifecycle and host-isolation contract with fake commands.'
workingDir rootProject.projectDir
executable 'bash'
args new File(redisLabContractDirectory, 'test/redis-lab-contract.sh').absolutePath
inputs.files(
new File(redisLabContractDirectory, 'versions.env'),
new File(redisLabContractDirectory, 'bin/redis-lab'),
new File(redisLabContractDirectory, 'cloud-init/node.yaml'),
new File(redisLabContractDirectory, 'lib/render-kubeconfig.awk'),
fileTree(new File(redisLabContractDirectory, 'test/fixtures')) {
include '**/*'
},
new File(redisLabContractDirectory, 'test/redis-lab-contract.sh'))
// Never up to date. This task's result depends on a server outside the build, so Gradle's
// inputs say nothing about whether it would still pass: re-running it against a lane that was
// restarted, reconfigured, or promoted reports the previous run's verdict as the current one.
// That is the same silent-pass failure mode the fail-closed endpoint check exists to prevent.
outputs.upToDateWhen { false }
}
def declaredMode = (project.findProperty('redis.topology.mode') ?: 'unset').toString().toLowerCase()
useJUnitPlatform {
includeTags "redis-topology & lane-${declaredMode}".toString()
}
// A filter that matches nothing is a configuration mistake, never a pass.
failOnNoDiscoveredTests = true
['redis.topology.host', 'redis.topology.port',
'redis.topology.master', 'redis.topology.username', 'redis.topology.password',
'redis.topology.trust-material']
.each { key ->
if (project.hasProperty(key)) {
systemProperty key, project.property(key)
}
}
// The lane name and the deployment mode are different things, and only the TLS lane makes that
// visible: its shape is standalone, so the tests must see `standalone` while the tag filter and
// the required properties come from the lane. Passing the lane name through as the mode would
// fail RedisDeploymentMode.valueOf on a value that is not a topology.
systemProperty 'redis.topology.mode', REDIS_TOPOLOGY_DEPLOYMENT_MODE.getOrDefault(declaredMode, declaredMode)
systemProperty 'redis.topology.tls', (declaredMode == 'tls').toString()
tasks.named('check') {
dependsOn redisLabContractTest
// Executed, not merely reported. `afterTest` fires for a skipped test too, so counting every
// callback meant a lane whose tests all skipped could still satisfy the "ran something" check —
// the exact green-for-nothing this gate exists to prevent, one level further in.
def executed = new java.util.concurrent.atomic.AtomicInteger()
def skipped = new java.util.concurrent.atomic.AtomicInteger()
def classes = java.util.Collections.synchronizedSet(new java.util.LinkedHashSet<String>())
afterTest { descriptor, result ->
if (result.resultType == org.gradle.api.tasks.testing.TestResult.ResultType.SKIPPED) {
skipped.incrementAndGet()
} else {
executed.incrementAndGet()
classes.add(descriptor.className.tokenize('.').last())
}
}
doFirst {
if (!REDIS_TOPOLOGY_MODES.contains(declaredMode)) {
throw new GradleException(
"redisTopologyTest was selected with redis.topology.mode='${declaredMode}'; " +
'the supported modes are ' + REDIS_TOPOLOGY_MODES.sort().join(', ') +
'. An unrecognised mode selects no test and would otherwise report success.')
}
def required = ['redis.topology.host', 'redis.topology.port']
if (declaredMode == 'sentinel') {
required += 'redis.topology.master'
}
if (declaredMode == 'tls') {
// Without the trust material the client would have to disable verification to connect,
// and a TLS lane that trusts anything qualifies nothing.
required += 'redis.topology.trust-material'
}
def missing = required.findAll { !project.hasProperty(it) }
if (!missing.isEmpty()) {
throw new GradleException(
'redisTopologyTest was selected without ' + missing.join(', ') +
'; start a lane from infra/redis-sdk and pass -P<key>=<value>.')
}
// The lane's tag must actually exist in the compiled suite. failOnNoDiscoveredTests catches
// an empty run, but this names the cause — a renamed or deleted lane class — instead of
// leaving an operator to guess whether the filter or the server is at fault.
def laneTag = "lane-${declaredMode}"
def tagged = sourceSets.test.allJava.matching { include '**/*.java' }.files.any { file ->
def text = file.text
text.contains('@Tag("redis-topology")') && text.contains("@Tag(\"${laneTag}\")")
}
if (!tagged) {
throw new GradleException(
"redisTopologyTest found no test class tagged 'redis-topology' and " +
"'${laneTag}'. The ${declaredMode} lane has no coverage to run, so a green " +
'result would prove nothing.')
}
}
doLast {
if (executed.get() < 1) {
throw new GradleException(
"redisTopologyTest completed without executing a single test for the " +
"${declaredMode} lane. A qualification lane that runs nothing must not report " +
'success.')
}
// What a lane must cover, named rather than counted by accident. A tag filter matching one
// trivial class satisfied "ran something" while the class the lane exists for had been
// renamed out of the filter, and nothing said so.
def required = REDIS_TOPOLOGY_REQUIRED_CLASSES[declaredMode]
def absent = required.findAll { !classes.contains(it) }
if (!absent.isEmpty()) {
throw new GradleException(
"redisTopologyTest ran the ${declaredMode} lane without ${absent.join(', ')}. " +
'These classes are what the lane qualifies; a run that skipped them proves ' +
'less than the lane claims.')
}
def floor = REDIS_TOPOLOGY_MINIMUM_TESTS[declaredMode]
if (executed.get() < floor) {
throw new GradleException(
"redisTopologyTest executed ${executed.get()} tests for the ${declaredMode} " +
"lane, below the declared floor of ${floor}. Coverage that silently shrank is " +
'a gate that silently weakened.')
}
if (skipped.get() > 0) {
throw new GradleException(
"redisTopologyTest skipped ${skipped.get()} test(s) on the ${declaredMode} " +
'lane. A qualification lane has no conditional coverage: what it cannot prove ' +
'must not be selected, and what is selected must run.')
}
logger.lifecycle("redisTopologyTest: ${declaredMode} lane executed ${executed.get()} tests.")
}
}