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:
co-authored by
Claude Opus 5
parent
1a3b560678
commit
5f10b791d3
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user