# Conflicts: # .github/ci-gate-matrix.yml # .github/scripts/verify-gate-matrix.sh # .github/workflows/ci-quality-gates.yml # src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java
3269 lines
161 KiB
Groovy
3269 lines
161 KiB
Groovy
import groovy.json.JsonSlurper
|
|
import groovy.json.JsonOutput
|
|
import org.gradle.api.artifacts.dsl.LockMode
|
|
import org.gradle.api.artifacts.component.ModuleComponentIdentifier
|
|
import org.gradle.api.tasks.bundling.AbstractArchiveTask
|
|
import org.gradle.api.tasks.bundling.Jar
|
|
import java.security.MessageDigest
|
|
|
|
plugins {
|
|
id 'org.springframework.boot' version '4.0.0' apply false
|
|
id 'io.spring.dependency-management' version '1.1.6' apply false
|
|
// feature-static-analysis-quality-contract — static analysis / code quality baseline.
|
|
id 'com.diffplug.spotless' version '8.6.0' apply false // D1 formatter (google-java-format)
|
|
id 'com.github.spotbugs' version '6.5.6' apply false // D3 bytecode bug finder (+ D4 FindSecBugs)
|
|
id 'net.ltgt.errorprone' version '5.1.0' apply false // D5 compile-time checker
|
|
}
|
|
|
|
// feature-build-release-supply-chain-contract D1/D9 — every archive carries an exact SemVer
|
|
// release coordinate plus the source revision that produced it. The MAJOR.MINOR.PATCH base can be
|
|
// supplied with -PreleaseVersion or RELEASE_VERSION. The revision can be supplied with
|
|
// -PgitRevision, GIT_SHA, or GITHUB_SHA; local builds read the current Git commit.
|
|
String releaseVersion = providers.gradleProperty('releaseVersion')
|
|
.orElse(providers.environmentVariable('RELEASE_VERSION'))
|
|
.getOrElse('0.0.1')
|
|
if (!(releaseVersion ==~ /\d+\.\d+\.\d+/)) {
|
|
throw new GradleException(
|
|
"releaseVersion must be MAJOR.MINOR.PATCH without a leading 'v', pre-release, or build metadata; got '${releaseVersion}'.")
|
|
}
|
|
|
|
def localGitRevision = providers.exec {
|
|
commandLine 'git', 'rev-parse', '--short=12', 'HEAD'
|
|
ignoreExitValue = true
|
|
}.standardOutput.asText.map { it.trim() }
|
|
String sourceRevision = providers.gradleProperty('gitRevision')
|
|
.orElse(providers.environmentVariable('GIT_SHA'))
|
|
.orElse(providers.environmentVariable('GITHUB_SHA'))
|
|
.orElse(localGitRevision)
|
|
.getOrElse('')
|
|
if (!(sourceRevision ==~ /(?i)[0-9a-f]{7,40}/)) {
|
|
throw new GradleException(
|
|
"A 7-40 character hexadecimal source revision is required; use -PgitRevision=<sha> when Git metadata is unavailable.")
|
|
}
|
|
sourceRevision = sourceRevision.toLowerCase(Locale.ROOT).take(12)
|
|
String traceableVersion = "${releaseVersion}+${sourceRevision}"
|
|
|
|
ext.releaseVersion = releaseVersion
|
|
ext.sourceRevision = sourceRevision
|
|
ext.traceableVersion = traceableVersion
|
|
|
|
// Inbound gRPC adapter (adapter:inbound:grpc) — the Spring Boot BOM does NOT manage io.grpc:* or
|
|
// protobuf versions, and this repo has no version catalog. Pin them here as the single SSOT so the
|
|
// grpc module (and the future sample grpc feature) import io.grpc:grpc-bom + protobuf-bom as
|
|
// platforms at MODULE scope (not the shared dependencyManagement block below) — keeping the
|
|
// strict-locking blast radius to the grpc module alone.
|
|
ext.grpcVersion = '1.68.1'
|
|
ext.protobufVersion = '3.25.5'
|
|
|
|
// Outbound objectstorage adapter (adapter:outbound:objectstorage) — the Spring Boot BOM does NOT
|
|
// manage software.amazon.awssdk:* versions, and this repo has no version catalog. Pin the AWS SDK
|
|
// v2 BOM here as the single SSOT so the objectstorage module imports software.amazon.awssdk:bom as
|
|
// a platform at MODULE scope (not the shared dependencyManagement block below) — mirroring the grpc
|
|
// approach above and keeping the strict-locking blast radius to the objectstorage module alone.
|
|
ext.awsSdkVersion = '2.30.0'
|
|
|
|
Closure<Boolean> 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$/
|
|
}
|
|
|
|
Closure<List<File>> staleTraceableArchivesFor = { Jar archiveTask ->
|
|
File outputDir = archiveTask.destinationDirectory.get().asFile
|
|
if (!outputDir.isDirectory()) {
|
|
return []
|
|
}
|
|
|
|
String currentName = archiveTask.archiveFileName.get()
|
|
List<File> stale = outputDir.listFiles({ File ignored, String fileName ->
|
|
isTraceableArchiveFor(archiveTask, fileName) && fileName != currentName
|
|
} as FilenameFilter)?.toList() ?: []
|
|
stale.sort { it.name }
|
|
}
|
|
|
|
allprojects {
|
|
group = 'dev.caskeleton'
|
|
version = rootProject.ext.traceableVersion
|
|
|
|
repositories {
|
|
mavenCentral()
|
|
}
|
|
}
|
|
|
|
configure(subprojects.findAll { it.childProjects.isEmpty() }) {
|
|
apply plugin: 'java'
|
|
apply plugin: 'io.spring.dependency-management'
|
|
|
|
// feature-static-analysis-quality-contract — apply the static analysis baseline to every
|
|
// module (D8: extend the existing subprojects {} block rather than a convention plugin).
|
|
apply plugin: 'com.diffplug.spotless' // D1 formatter
|
|
apply plugin: 'checkstyle' // D2 style linter (Gradle built-in — no plugins{} id)
|
|
apply plugin: 'com.github.spotbugs' // D3 bytecode bug finder
|
|
apply plugin: 'net.ltgt.errorprone' // D5 compile-time checker
|
|
|
|
java {
|
|
toolchain {
|
|
languageVersion = JavaLanguageVersion.of(21)
|
|
}
|
|
}
|
|
|
|
// D8 — Gradle-default <project>/gradle.lockfile files are Renovate-compatible. STRICT means a
|
|
// missing or stale lock state fails resolution instead of silently selecting a new version.
|
|
dependencyLocking {
|
|
lockAllConfigurations()
|
|
lockMode = LockMode.STRICT
|
|
}
|
|
|
|
// D10 — normalize every archive, including Spring Boot's BootJar. Fixed timestamps/order and
|
|
// permissions remove host filesystem, locale-adjacent, and umask entropy from archive bytes.
|
|
tasks.withType(AbstractArchiveTask).configureEach {
|
|
preserveFileTimestamps = false
|
|
reproducibleFileOrder = true
|
|
dirPermissions { unix('755') }
|
|
filePermissions { unix('644') }
|
|
}
|
|
|
|
// D1/D9 — a JAR is independently traceable even when copied out of its container/release.
|
|
tasks.withType(Jar).configureEach {
|
|
manifest {
|
|
attributes(
|
|
'Implementation-Version': project.version.toString(),
|
|
'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.
|
|
// This captures transitive compile/test/analysis dependencies, not only direct declarations.
|
|
tasks.register('resolveAndLockAll') {
|
|
group = 'build setup'
|
|
description = 'Resolves every configuration and writes this project\'s dependency lock state.'
|
|
notCompatibleWithConfigurationCache('Filters configurations at execution time')
|
|
doFirst {
|
|
if (!gradle.startParameter.writeDependencyLocks) {
|
|
throw new GradleException("${path} requires the --write-locks command-line flag.")
|
|
}
|
|
}
|
|
doLast {
|
|
configurations.findAll { it.canBeResolved }.each { it.resolve() }
|
|
}
|
|
}
|
|
|
|
// Unlike Gradle's diagnostic `dependencies` report, this task performs strict resolution and
|
|
// propagates a missing/stale lock entry as a non-zero build failure.
|
|
tasks.register('verifyDependencyLocks') {
|
|
group = 'verification'
|
|
description = 'Resolves every configuration and fails when strict dependency locks drift.'
|
|
notCompatibleWithConfigurationCache('Filters configurations at execution time')
|
|
doLast {
|
|
configurations.findAll { it.canBeResolved }.each { it.resolve() }
|
|
}
|
|
}
|
|
|
|
// Keep method parameter names in bytecode for Spring MVC @PathVariable/@RequestParam
|
|
// 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'
|
|
options.errorprone {
|
|
disableWarningsInGeneratedCode = true // D5 — MapStruct/Lombok generated code (errorprone README C5)
|
|
}
|
|
}
|
|
|
|
// D1 — google-java-format owns formatting + import order; spotlessApply auto-fixes,
|
|
// spotlessCheck (wired into check) verifies. CI must NEVER run spotlessApply.
|
|
spotless {
|
|
java {
|
|
googleJavaFormat('1.35.0')
|
|
importOrder()
|
|
removeUnusedImports()
|
|
}
|
|
}
|
|
|
|
// D2 — naming + logical ruleset; formatter-owned modules suppressed in the XML.
|
|
checkstyle {
|
|
toolVersion = '13.5.0'
|
|
configFile = rootProject.file('config/checkstyle/checkstyle.xml')
|
|
configDirectory = rootProject.file('config/checkstyle')
|
|
ignoreFailures = false
|
|
// No warning-tier checks in the default build. Javadoc coverage is a documentation backlog,
|
|
// 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
|
|
}
|
|
|
|
// D3/D4 — bytecode bug finder; FindSecBugs plugin loaded via spotbugsPlugins below.
|
|
// reportLevel='high' implements §4 "blocking (high priority)": only high-confidence findings
|
|
// block, which keeps the gate signal-rich (the medium tier is dominated by EI_EXPOSE_REP
|
|
// defensive-copy noise on DI'd collaborators). effort left at default (UNSUPPORTED_IMPL_DECISION
|
|
// — strictness is a user trade-off; default is functionally valid). Confirmed false positives
|
|
// go in config/spotbugs/exclude.xml.
|
|
spotbugs {
|
|
toolVersion = '4.10.2'
|
|
reportLevel = com.github.spotbugs.snom.Confidence.valueOf('HIGH')
|
|
excludeFilter = rootProject.file('config/spotbugs/exclude.xml')
|
|
}
|
|
// 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
|
|
}
|
|
|
|
dependencyManagement {
|
|
imports {
|
|
mavenBom org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES
|
|
}
|
|
}
|
|
|
|
dependencies {
|
|
if (project.path in [':domain-core', ':application-core', ':shared-contract']) {
|
|
testImplementation 'org.junit.jupiter:junit-jupiter'
|
|
testImplementation 'org.assertj:assertj-core'
|
|
} else {
|
|
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
|
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
|
|
}
|
|
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
|
|
|
spotbugsPlugins 'com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0' // D4 code-level security
|
|
errorprone 'com.google.errorprone:error_prone_core:2.49.0' // D5 compile-time checker
|
|
}
|
|
|
|
// feature-ci-quality-gates-contract §4 (D7) — the main release gate EXCLUDES the flaky
|
|
// quarantine bucket so a quarantined test can never block merge. Quarantined tests carry
|
|
// JUnit's built-in @Tag("quarantine"); they run separately via `quarantineTest` (non-blocking)
|
|
// and their 14-day sunset is enforced by verifyQuarantineSunset. With zero quarantined tests
|
|
// (the skeleton default) excludeTags is a no-op.
|
|
tasks.named('test') {
|
|
useJUnitPlatform {
|
|
excludeTags 'quarantine'
|
|
}
|
|
}
|
|
|
|
// feature-ci-quality-gates-contract §4 (D7/D9) — flaky quarantine bucket. Runs ONLY
|
|
// @Tag("quarantine") tests, isolated from `check`, never blocking the build (ignoreFailures).
|
|
// failOnNoDiscoveredTests=false so the empty bucket (skeleton ships zero flaky tests) passes.
|
|
tasks.register('quarantineTest', Test) {
|
|
group = 'verification'
|
|
description = 'Flaky-test quarantine bucket (feature-ci-quality-gates-contract §4): runs only ' +
|
|
'@Tag("quarantine") tests, non-blocking, isolated from the release gate.'
|
|
testClassesDirs = sourceSets.test.output.classesDirs
|
|
classpath = sourceSets.test.runtimeClasspath
|
|
useJUnitPlatform {
|
|
includeTags 'quarantine'
|
|
}
|
|
ignoreFailures = true
|
|
failOnNoDiscoveredTests = false
|
|
// Always re-run; a flaky bucket must never serve a stale UP-TO-DATE result.
|
|
outputs.upToDateWhen { false }
|
|
// Pin UTC like the main test task for host-locale independence.
|
|
jvmArgs '-Duser.timezone=UTC'
|
|
}
|
|
|
|
tasks.named('check') {
|
|
dependsOn rootProject.tasks.named('verifyCleanArchitectureDependencies')
|
|
dependsOn rootProject.tasks.named('verifyEnvKeys')
|
|
dependsOn rootProject.tasks.named('verifyNoStaleTraceableJars')
|
|
dependsOn rootProject.tasks.named('verifyOneTypePerFile')
|
|
dependsOn rootProject.tasks.named('verifyTrivyignore')
|
|
dependsOn rootProject.tasks.named('verifyQuarantineSunset')
|
|
}
|
|
}
|
|
|
|
// One explicit command regenerates every module's Gradle-default lockfile.
|
|
tasks.register('resolveAndLockAll') {
|
|
group = 'build setup'
|
|
description = 'Regenerates dependency locks for all subprojects (requires --write-locks).'
|
|
dependsOn subprojects.findAll { it.childProjects.isEmpty() }.collect { it.tasks.named('resolveAndLockAll') }
|
|
}
|
|
|
|
tasks.register('verifyDependencyLocks') {
|
|
group = 'verification'
|
|
description = 'Verifies strict dependency lock state for all subprojects.'
|
|
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<String> violations = []
|
|
subprojects.each { sub ->
|
|
sub.tasks.withType(Jar).each { Jar archiveTask ->
|
|
List<String> 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
|
|
def baseComposeFile = new File(repositoryDir, 'docker-compose.yml')
|
|
def localComposeFile = new File(repositoryDir, 'docker-compose.local.yml')
|
|
def composeCommand = ['docker', 'compose', '-f', baseComposeFile.absolutePath,
|
|
'-f', localComposeFile.absolutePath]
|
|
|
|
def bootstrapCompile = tasks.register('bootstrapCompile') {
|
|
group = 'developer experience'
|
|
description = 'Stage 1/5: compiles every main and test source set as a local sanity check.'
|
|
dependsOn subprojects.findAll { it.childProjects.isEmpty() }.collect { it.tasks.named('compileTestJava') }
|
|
}
|
|
|
|
def bootstrapDockerPreflight = tasks.register('bootstrapDockerPreflight', Exec) {
|
|
group = 'developer experience'
|
|
description = 'Checks that the Docker CLI can reach a running Docker daemon.'
|
|
commandLine 'docker', 'info'
|
|
ignoreExitValue = true
|
|
standardOutput = new ByteArrayOutputStream()
|
|
errorOutput = new ByteArrayOutputStream()
|
|
doLast {
|
|
if (executionResult.get().exitValue != 0) {
|
|
throw new GradleException(
|
|
'bootstrap: Docker가 필요합니다. Docker Desktop/daemon을 시작한 뒤 ' +
|
|
'`docker info`가 성공하는지 확인하세요.\n' + errorOutput.toString())
|
|
}
|
|
}
|
|
}
|
|
bootstrapDockerPreflight.configure { dependsOn bootstrapCompile }
|
|
|
|
def bootstrapDependencies = tasks.register('bootstrapDependencies', Exec) {
|
|
group = 'developer experience'
|
|
description = 'Stage 2/5: starts the local PostgreSQL dependency and waits for readiness.'
|
|
commandLine composeCommand + ['up', '-d', '--wait', 'db']
|
|
}
|
|
bootstrapDependencies.configure { dependsOn bootstrapCompile }
|
|
bootstrapDependencies.configure { dependsOn bootstrapDockerPreflight }
|
|
|
|
def bootstrapMigrateAndStart = tasks.register('bootstrapMigrateAndStart', Exec) {
|
|
group = 'developer experience'
|
|
description = 'Stage 3/5: builds/starts the app; startup Flyway must finish before health is ready.'
|
|
commandLine composeCommand + ['up', '-d', '--build', '--wait', 'app']
|
|
}
|
|
bootstrapMigrateAndStart.configure { dependsOn bootstrapDependencies }
|
|
|
|
def bootstrapSampleContract = tasks.register('bootstrapSampleContract') {
|
|
group = 'developer experience'
|
|
description = 'Stage 4/5: verifies the delegated sample production-isolation/build contract.'
|
|
dependsOn project(':app-bootstrap').tasks.named('bootstrapSampleContract')
|
|
}
|
|
bootstrapSampleContract.configure { dependsOn bootstrapMigrateAndStart }
|
|
|
|
def bootstrapSmoke = tasks.register('bootstrapSmoke') {
|
|
group = 'developer experience'
|
|
description = 'Stage 5/5: requires HTTP 200 and status=UP from GET /api/healthcheck.'
|
|
doLast {
|
|
URI endpoint = URI.create('http://localhost:8080/api/healthcheck')
|
|
long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(60)
|
|
String lastFailure = 'no response'
|
|
while (System.nanoTime() < deadline) {
|
|
HttpURLConnection connection = null
|
|
try {
|
|
connection = (HttpURLConnection) endpoint.toURL().openConnection()
|
|
connection.connectTimeout = 2_000
|
|
connection.readTimeout = 2_000
|
|
connection.requestMethod = 'GET'
|
|
int status = connection.responseCode
|
|
String body = status >= 200 && status < 400 ? connection.inputStream.text :
|
|
connection.errorStream?.text
|
|
if (status == 200 && body != null && body.contains('"status":"UP"')) {
|
|
logger.lifecycle('bootstrapSmoke: OK — GET /api/healthcheck returned HTTP 200 and status=UP.')
|
|
return
|
|
}
|
|
lastFailure = "HTTP ${status}: ${body}"
|
|
} catch (IOException ex) {
|
|
lastFailure = ex.message
|
|
} finally {
|
|
connection?.disconnect()
|
|
}
|
|
sleep(1_000)
|
|
}
|
|
throw new GradleException(
|
|
"bootstrapSmoke: /api/healthcheck did not become healthy within 60s; last result: ${lastFailure}")
|
|
}
|
|
}
|
|
bootstrapSmoke.configure { dependsOn bootstrapSampleContract }
|
|
|
|
tasks.register('bootstrap') {
|
|
group = 'developer experience'
|
|
description = 'Runs the complete five-stage local bootstrap contract.'
|
|
dependsOn bootstrapSmoke
|
|
}
|
|
|
|
// feature-developer-experience-contract D4 — README is an entrypoint, not an unchecked second
|
|
// build script. Validate only executable command blocks (`bash`/`sh`); prose examples stay prose.
|
|
tasks.register('verifyReadmeCommands') {
|
|
group = 'verification'
|
|
description = 'Verifies README Gradle/Compose/Make commands refer to real tasks, files, and targets.'
|
|
|
|
File readmeFile = project.hasProperty('readmeFile')
|
|
? file(project.property('readmeFile'))
|
|
: new File(repositoryDir, 'README.md')
|
|
inputs.file(readmeFile)
|
|
|
|
doLast {
|
|
if (!readmeFile.isFile()) {
|
|
throw new GradleException("verifyReadmeCommands: missing ${readmeFile}")
|
|
}
|
|
|
|
List<String> violations = []
|
|
boolean inShellBlock = false
|
|
readmeFile.eachLine { String rawLine, int lineNumber ->
|
|
String line = rawLine.trim()
|
|
if (line == '```bash' || line == '```sh') {
|
|
inShellBlock = true
|
|
return
|
|
}
|
|
if (line == '```' && inShellBlock) {
|
|
inShellBlock = false
|
|
return
|
|
}
|
|
if (!inShellBlock || line.isEmpty() || line.startsWith('#')) {
|
|
return
|
|
}
|
|
|
|
int gradleIndex = line.indexOf('./gradlew ')
|
|
if (gradleIndex >= 0) {
|
|
List<String> tokens = line.substring(gradleIndex + './gradlew '.length())
|
|
.split(/\s+/).toList()
|
|
List<String> requestedTasks = []
|
|
for (String token : tokens) {
|
|
if (token.startsWith('-') || token in ['&&', '||', '|']) {
|
|
break
|
|
}
|
|
requestedTasks << token
|
|
}
|
|
if (requestedTasks.isEmpty()) {
|
|
violations << "${readmeFile}:${lineNumber}: ./gradlew command has no task"
|
|
}
|
|
requestedTasks.each { String taskPath ->
|
|
boolean exists
|
|
if (taskPath.startsWith(':') && taskPath.count(':') >= 2) {
|
|
int lastSeparator = taskPath.lastIndexOf(':')
|
|
String projectPath = taskPath.substring(0, lastSeparator)
|
|
String taskName = taskPath.substring(lastSeparator + 1)
|
|
Project targetProject = rootProject.findProject(projectPath)
|
|
exists = targetProject != null && targetProject.tasks.findByName(taskName) != null
|
|
} else {
|
|
exists = !rootProject.getTasksByName(taskPath, true).isEmpty()
|
|
}
|
|
if (!exists) {
|
|
violations << "${readmeFile}:${lineNumber}: unknown Gradle task '${taskPath}' in `${line}`"
|
|
}
|
|
}
|
|
}
|
|
|
|
if (line.startsWith('docker compose ')) {
|
|
List<String> tokens = line.split(/\s+/).toList()
|
|
int index = 2
|
|
while (index < tokens.size() && tokens[index].startsWith('-')) {
|
|
String option = tokens[index++]
|
|
if (option in ['-f', '--file', '--env-file', '-p', '--project-name']) {
|
|
if (index >= tokens.size()) {
|
|
violations << "${readmeFile}:${lineNumber}: '${option}' has no value in `${line}`"
|
|
break
|
|
}
|
|
String value = tokens[index++]
|
|
if (option in ['-f', '--file']) {
|
|
File composeFile = new File(repositoryDir, value)
|
|
if (!composeFile.isFile()) {
|
|
violations << "${readmeFile}:${lineNumber}: missing Compose file '${value}'"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Set<String> supported = ['build', 'config', 'down', 'logs', 'ps', 'pull', 'restart',
|
|
'run', 'start', 'stop', 'up'] as Set
|
|
if (index >= tokens.size() || !supported.contains(tokens[index])) {
|
|
String actual = index < tokens.size() ? tokens[index] : '<missing>'
|
|
violations << "${readmeFile}:${lineNumber}: unsupported Compose subcommand '${actual}'"
|
|
}
|
|
}
|
|
|
|
if (line.startsWith('make ')) {
|
|
File makefile = new File(repositoryDir, 'Makefile')
|
|
String target = line.substring('make '.length()).split(/\s+/)[0]
|
|
if (!makefile.isFile()) {
|
|
violations << "${readmeFile}:${lineNumber}: make command documented but Makefile is absent"
|
|
} else if (!(makefile.text =~ /(?m)^${java.util.regex.Pattern.quote(target)}\s*:/).find()) {
|
|
violations << "${readmeFile}:${lineNumber}: unknown Make target '${target}'"
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!violations.isEmpty()) {
|
|
throw new GradleException(
|
|
"verifyReadmeCommands: ${violations.size()} command drift violation(s):\n " +
|
|
violations.join('\n '))
|
|
}
|
|
logger.lifecycle("verifyReadmeCommands: OK — executable commands in ${readmeFile} resolve.")
|
|
}
|
|
}
|
|
|
|
configure(subprojects.findAll { it.childProjects.isEmpty() }) {
|
|
tasks.named('check') {
|
|
dependsOn rootProject.tasks.named('verifyReadmeCommands')
|
|
}
|
|
}
|
|
|
|
tasks.register('verifyCleanArchitectureDependencies') {
|
|
group = 'verification'
|
|
description = 'Verifies Clean Architecture project dependency direction.'
|
|
|
|
File moduleRegistryFile = new File(rootProject.projectDir, 'config/architecture/modules.json')
|
|
inputs.file(moduleRegistryFile)
|
|
|
|
doLast {
|
|
if (!moduleRegistryFile.isFile()) {
|
|
throw new GradleException("Missing module registry: ${moduleRegistryFile}")
|
|
}
|
|
def moduleRegistry = new JsonSlurper().parse(moduleRegistryFile)
|
|
if (!(moduleRegistry.modules instanceof List) || moduleRegistry.modules.isEmpty()) {
|
|
throw new GradleException("Module registry has no modules: ${moduleRegistryFile}")
|
|
}
|
|
|
|
Map<String, Object> modulesById = moduleRegistry.modules.collectEntries { module ->
|
|
[(module.id as String): module]
|
|
}
|
|
if (modulesById.size() != moduleRegistry.modules.size()) {
|
|
throw new GradleException('Module registry contains duplicate module ids.')
|
|
}
|
|
|
|
Map<String, Set<String>> allowedProjectDependencies = moduleRegistry.modules.collectEntries { module ->
|
|
String moduleName = (module.gradle_path as String).replaceFirst('^:', '')
|
|
Set<String> allowed = (module.allowed_dependencies as List).collect { dependencyId ->
|
|
def dependency = modulesById[dependencyId as String]
|
|
if (dependency == null) {
|
|
throw new GradleException(
|
|
"Module registry '${module.id}' references unknown allowed dependency id '${dependencyId}'.")
|
|
}
|
|
(dependency.gradle_path as String).replaceFirst('^:', '')
|
|
}.toSet()
|
|
[(moduleName): allowed]
|
|
}
|
|
|
|
Set<String> declaredModules = subprojects.findAll { it.childProjects.isEmpty() }
|
|
.collect { it.path.replaceFirst('^:', '') }.toSet()
|
|
Set<String> governedModules = allowedProjectDependencies.keySet()
|
|
Set<String> missingFromBuild = governedModules - declaredModules
|
|
Set<String> missingFromPolicy = declaredModules - governedModules
|
|
|
|
if (!missingFromBuild.isEmpty()) {
|
|
throw new GradleException(
|
|
"Clean Architecture dependency policy references missing Gradle modules ${missingFromBuild}. " +
|
|
"Declared modules are ${declaredModules}."
|
|
)
|
|
}
|
|
|
|
if (!missingFromPolicy.isEmpty()) {
|
|
throw new GradleException(
|
|
"Gradle modules ${missingFromPolicy} are not covered by verifyCleanArchitectureDependencies. " +
|
|
"Add an explicit dependency policy before using them."
|
|
)
|
|
}
|
|
|
|
allowedProjectDependencies.each { moduleName, allowed ->
|
|
Project module = rootProject.project(":${moduleName}")
|
|
Set<String> actual = ['api', 'implementation', 'compileOnly', 'runtimeOnly']
|
|
.collect { configurationName -> module.configurations.findByName(configurationName) }
|
|
.findAll { it != null }
|
|
.collectMany { configuration ->
|
|
configuration.dependencies.withType(ProjectDependency).collect { dependency ->
|
|
dependency.path.replaceFirst('^:', '')
|
|
}
|
|
}
|
|
.toSet()
|
|
|
|
if (moduleName != 'sample-portfolio' && actual.contains('sample-portfolio')) {
|
|
throw new GradleException(
|
|
"Module ':${moduleName}' has a forbidden production dependency on " +
|
|
"':sample-portfolio'. The sample module may only be consumed through " +
|
|
"non-production fixture configurations."
|
|
)
|
|
}
|
|
|
|
Set<String> forbidden = actual - allowed
|
|
if (!forbidden.isEmpty()) {
|
|
throw new GradleException(
|
|
"Module ':${moduleName}' has forbidden project dependencies ${forbidden}. " +
|
|
"Allowed dependencies are ${allowed}. " +
|
|
"Production modules must not depend on ':sample-portfolio'; " +
|
|
"all project edges must be explicitly registered."
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Set<String> expectedJpaReadinessCardIds = [
|
|
'jpa-observability-lifecycle',
|
|
'jpa-security-baseline',
|
|
'jpa-flyway-migration',
|
|
'jpa-transaction-runtime',
|
|
'jpa-aggregate-store',
|
|
'jpa-query-model',
|
|
'jpa-primary-foundation',
|
|
'jpa-idempotency-owner-safe-v2',
|
|
'jpa-outbox-storage-v2',
|
|
'jpa-outbox-polling-delivery-v2',
|
|
'jpa-outbox-cdc-retention-v1',
|
|
'jpa-inbox-same-store-v1',
|
|
'jpa-primary-replica',
|
|
'jpa-tenant-discriminator-rls',
|
|
'jpa-jdbc-efficiency-coordination'
|
|
] as Set
|
|
|
|
Set<String> expectedJpaOwnedMigrationCardIds = [
|
|
'jpa-flyway-migration',
|
|
'jpa-idempotency-owner-safe-v2',
|
|
'jpa-outbox-storage-v2',
|
|
'jpa-outbox-polling-delivery-v2',
|
|
'jpa-inbox-same-store-v1',
|
|
'jpa-tenant-discriminator-rls',
|
|
'jpa-jdbc-efficiency-coordination'
|
|
] as Set
|
|
|
|
Closure<List<String>> validateJpaReadinessRegistry = {
|
|
Map<String, Object> registry,
|
|
String rawRegistry,
|
|
Closure<Boolean> taskExists ->
|
|
List<String> violations = []
|
|
Set<String> rootKeys = registry.keySet().collect { it as String }.toSet()
|
|
Set<String> expectedRootKeys = ['schema-version', 'legacy-adoption', 'cards'] as Set
|
|
if (rootKeys != expectedRootKeys) {
|
|
violations << "root keys must be exactly ${expectedRootKeys}; got ${rootKeys}"
|
|
}
|
|
if (registry['schema-version'] != 1) {
|
|
violations << "schema-version must be integer 1; got ${registry['schema-version']}"
|
|
}
|
|
|
|
Map<String, Object> legacy = registry['legacy-adoption'] instanceof Map
|
|
? registry['legacy-adoption'] as Map<String, Object>
|
|
: [:]
|
|
Set<String> expectedLegacyKeys = [
|
|
'state',
|
|
'location',
|
|
'history-table',
|
|
'immutable-applied-versions',
|
|
'allowed-origin'
|
|
] as Set
|
|
if (legacy.keySet().collect { it as String }.toSet() != expectedLegacyKeys) {
|
|
violations << "legacy-adoption keys must be exactly ${expectedLegacyKeys}"
|
|
}
|
|
if (legacy.state != 'transition-only') {
|
|
violations << "legacy-adoption.state must be transition-only"
|
|
}
|
|
if (legacy.location != 'db/migration/postgresql') {
|
|
violations << "legacy-adoption.location must be db/migration/postgresql"
|
|
}
|
|
if (legacy['history-table'] != 'flyway_schema_history') {
|
|
violations << "legacy-adoption.history-table must be flyway_schema_history"
|
|
}
|
|
if (legacy['immutable-applied-versions'] != [1, 3, 4, 5]) {
|
|
violations << "legacy-adoption immutable versions must be exactly [1, 3, 4, 5]"
|
|
}
|
|
if (legacy['allowed-origin'] != 'LEGACY_ADOPTED') {
|
|
violations << "legacy-adoption.allowed-origin must be LEGACY_ADOPTED"
|
|
}
|
|
|
|
Map<String, Object> cards = registry.cards instanceof Map
|
|
? registry.cards as Map<String, Object>
|
|
: [:]
|
|
Set<String> actualCardIds = cards.keySet().collect { it as String }.toSet()
|
|
Set<String> missingCards = expectedJpaReadinessCardIds - actualCardIds
|
|
Set<String> unknownCards = actualCardIds - expectedJpaReadinessCardIds
|
|
if (!missingCards.isEmpty()) {
|
|
violations << "missing card ids ${missingCards.toSorted()}"
|
|
}
|
|
if (!unknownCards.isEmpty()) {
|
|
violations << "unknown card ids ${unknownCards.toSorted()}"
|
|
}
|
|
|
|
List<String> rawCardKeys = []
|
|
def rawCardKeyMatcher = rawRegistry =~ /"(?<card>jpa-[a-z0-9.-]+)"\s*:/
|
|
while (rawCardKeyMatcher.find()) {
|
|
rawCardKeys << rawCardKeyMatcher.group('card')
|
|
}
|
|
Set<String> duplicateRawCardKeys = rawCardKeys.countBy { it }.findAll {
|
|
String ignored, Integer count -> count > 1
|
|
}.keySet()
|
|
if (!duplicateRawCardKeys.isEmpty()) {
|
|
violations << "duplicate raw card keys ${duplicateRawCardKeys.toSorted()}"
|
|
}
|
|
|
|
Set<String> allowedCardKeys = [
|
|
'state',
|
|
'schema-stream',
|
|
'prerequisites',
|
|
'external-prerequisites',
|
|
'readiness-task',
|
|
'support-tasks',
|
|
'required-evidence',
|
|
'evidence',
|
|
'dispatch-modes',
|
|
'migration'
|
|
] as Set
|
|
Set<String> allowedStates = ['selected', 'implemented-candidate', 'not-implemented'] as Set
|
|
Set<String> allowedSchemaStreams = ['none', 'owned', 'contributes-to-core'] as Set
|
|
Map<String, String> taskOwners = [:]
|
|
Map<String, String> migrationLocationOwners = [:]
|
|
Map<String, String> migrationHistoryOwners = [:]
|
|
Map<String, String> evidenceSelectorOwners = [:]
|
|
Set<String> actualOwnedMigrationCards = []
|
|
|
|
cards.each { String cardId, Object rawCard ->
|
|
if (!(rawCard instanceof Map)) {
|
|
violations << "${cardId}: card value must be an object"
|
|
return
|
|
}
|
|
Map<String, Object> card = rawCard as Map<String, Object>
|
|
Set<String> unknownKeys = card.keySet().collect { it as String }.toSet() - allowedCardKeys
|
|
if (!unknownKeys.isEmpty()) {
|
|
violations << "${cardId}: unknown keys ${unknownKeys.toSorted()}"
|
|
}
|
|
|
|
String state = card.state as String
|
|
String schemaStream = card['schema-stream'] as String
|
|
if (!allowedStates.contains(state)) {
|
|
violations << "${cardId}: invalid state '${state}'"
|
|
}
|
|
if (!allowedSchemaStreams.contains(schemaStream)) {
|
|
violations << "${cardId}: invalid schema-stream '${schemaStream}'"
|
|
}
|
|
|
|
if (!(card.prerequisites instanceof List)) {
|
|
violations << "${cardId}: prerequisites must be a list"
|
|
}
|
|
List<String> prerequisites = card.prerequisites instanceof List
|
|
? (card.prerequisites as List).collect { it as String }
|
|
: []
|
|
if (prerequisites.toSet().size() != prerequisites.size()) {
|
|
violations << "${cardId}: duplicate prerequisites ${prerequisites}"
|
|
}
|
|
prerequisites.each { String prerequisite ->
|
|
if (!cards.containsKey(prerequisite)) {
|
|
violations << "${cardId}: unknown prerequisite '${prerequisite}'"
|
|
} else if (state == 'selected' &&
|
|
((cards[prerequisite] as Map).state as String) != 'selected') {
|
|
violations << "${cardId}: selected card requires non-selected '${prerequisite}'"
|
|
}
|
|
}
|
|
|
|
String readinessTask = card['readiness-task'] as String
|
|
if (readinessTask == null || !readinessTask.startsWith(':')) {
|
|
violations << "${cardId}: readiness-task must be an absolute Gradle task path"
|
|
}
|
|
List<String> supportTasks = card['support-tasks'] instanceof List
|
|
? (card['support-tasks'] as List).collect { it as String }
|
|
: []
|
|
if (supportTasks.toSet().size() != supportTasks.size()) {
|
|
violations << "${cardId}: duplicate support-tasks ${supportTasks}"
|
|
}
|
|
([readinessTask] + supportTasks).findAll { it != null }.each { String taskPath ->
|
|
if (!taskPath.startsWith(':')) {
|
|
violations << "${cardId}: task '${taskPath}' must be an absolute Gradle task path"
|
|
return
|
|
}
|
|
String previousOwner = taskOwners.putIfAbsent(taskPath, cardId)
|
|
if (previousOwner != null) {
|
|
violations << "duplicate task '${taskPath}' owned by ${previousOwner} and ${cardId}"
|
|
}
|
|
if (state == 'selected' && !taskExists(taskPath)) {
|
|
violations << "${cardId}: selected task does not exist '${taskPath}'"
|
|
}
|
|
}
|
|
|
|
List<String> requiredEvidence = card['required-evidence'] instanceof List
|
|
? (card['required-evidence'] as List).collect { it as String }
|
|
: []
|
|
if (requiredEvidence.isEmpty()) {
|
|
violations << "${cardId}: required-evidence must be a non-empty list"
|
|
} else {
|
|
if (requiredEvidence.toSet().size() != requiredEvidence.size()) {
|
|
violations << "${cardId}: duplicate required-evidence ${requiredEvidence}"
|
|
}
|
|
if (!requiredEvidence.contains('no-skip')) {
|
|
violations << "${cardId}: required-evidence must include no-skip"
|
|
}
|
|
}
|
|
|
|
Object migrationNode = card.migration
|
|
Set<String> allowedEvidenceClaims = requiredEvidence
|
|
.findAll { String requirement -> requirement != 'no-skip' }
|
|
.toSet()
|
|
Map<String, Object> migrationForEvidence = migrationNode instanceof Map
|
|
? migrationNode as Map<String, Object>
|
|
: [:]
|
|
Object lifecycleEvidenceNode = migrationForEvidence['lifecycle-evidence']
|
|
if (lifecycleEvidenceNode instanceof List) {
|
|
(lifecycleEvidenceNode as List).each {
|
|
Object lifecycle ->
|
|
allowedEvidenceClaims <<
|
|
"migration-lifecycle:${lifecycle as String}".toString()
|
|
}
|
|
}
|
|
|
|
Object evidenceNode = card.evidence
|
|
if (state == 'not-implemented') {
|
|
if (evidenceNode != null) {
|
|
violations << "${cardId}: not-implemented card forbids evidence"
|
|
}
|
|
} else if (!(evidenceNode instanceof Map)) {
|
|
violations << "${cardId}: active card requires evidence"
|
|
} else {
|
|
Map<String, Object> evidence = evidenceNode as Map<String, Object>
|
|
Set<String> evidenceKeys = evidence.keySet().collect { it as String }.toSet()
|
|
Set<String> expectedEvidenceKeys = ['scenarios', 'task-claims'] as Set
|
|
if (evidenceKeys != expectedEvidenceKeys) {
|
|
violations << "${cardId}: evidence keys must be exactly ${expectedEvidenceKeys}"
|
|
}
|
|
|
|
List<Object> scenarios = evidence.scenarios instanceof List
|
|
? evidence.scenarios as List<Object>
|
|
: []
|
|
if (!(evidence.scenarios instanceof List)) {
|
|
violations << "${cardId}: evidence scenarios must be a list"
|
|
}
|
|
List<Object> taskClaims = evidence['task-claims'] instanceof List
|
|
? evidence['task-claims'] as List<Object>
|
|
: []
|
|
if (!(evidence['task-claims'] instanceof List)) {
|
|
violations << "${cardId}: evidence task-claims must be a list"
|
|
}
|
|
if (scenarios.isEmpty() && taskClaims.isEmpty()) {
|
|
violations << "${cardId}: evidence must contain a scenario or task claim"
|
|
}
|
|
|
|
scenarios.eachWithIndex { Object rawScenario, int index ->
|
|
if (!(rawScenario instanceof Map)) {
|
|
violations << "${cardId}: evidence scenario ${index} must be an object"
|
|
return
|
|
}
|
|
Map<String, Object> scenario = rawScenario as Map<String, Object>
|
|
Set<String> scenarioKeys =
|
|
scenario.keySet().collect { it as String }.toSet()
|
|
if (scenarioKeys != ['selector', 'covers'] as Set) {
|
|
violations << "${cardId}: evidence scenario ${index} has invalid keys ${scenarioKeys}"
|
|
}
|
|
String selector = scenario.selector as String
|
|
if (selector == null ||
|
|
!(selector ==~ /dev\.caskeleton\.[A-Za-z0-9_.]+\#[A-Za-z][A-Za-z0-9_]*/)) {
|
|
violations << "${cardId}: invalid evidence selector '${selector}'"
|
|
} else {
|
|
String previousOwner = evidenceSelectorOwners.putIfAbsent(selector, cardId)
|
|
if (previousOwner != null) {
|
|
violations << "duplicate evidence selector '${selector}' owned by " +
|
|
"${previousOwner} and ${cardId}"
|
|
}
|
|
}
|
|
List<String> covers = scenario.covers instanceof List
|
|
? (scenario.covers as List).collect { it as String }
|
|
: []
|
|
if (covers.isEmpty()) {
|
|
violations << "${cardId}: evidence scenario ${index} covers must be non-empty"
|
|
}
|
|
if (covers.toSet().size() != covers.size()) {
|
|
violations << "${cardId}: evidence scenario ${index} has duplicate covers ${covers}"
|
|
}
|
|
covers.each { String claim ->
|
|
if (!allowedEvidenceClaims.contains(claim)) {
|
|
violations << "${cardId}: evidence covers unknown requirement '${claim}'"
|
|
}
|
|
}
|
|
}
|
|
|
|
Set<String> ownedTasks = ([readinessTask] + supportTasks)
|
|
.findAll { it != null }
|
|
.toSet()
|
|
taskClaims.eachWithIndex { Object rawClaim, int index ->
|
|
if (!(rawClaim instanceof Map)) {
|
|
violations << "${cardId}: evidence task claim ${index} must be an object"
|
|
return
|
|
}
|
|
Map<String, Object> claim = rawClaim as Map<String, Object>
|
|
Set<String> claimKeys = claim.keySet().collect { it as String }.toSet()
|
|
if (claimKeys != ['task', 'covers'] as Set) {
|
|
violations << "${cardId}: evidence task claim ${index} has invalid keys ${claimKeys}"
|
|
}
|
|
String taskPath = claim.task as String
|
|
if (!ownedTasks.contains(taskPath)) {
|
|
violations << "${cardId}: evidence task claim is not owned by card '${taskPath}'"
|
|
}
|
|
List<String> covers = claim.covers instanceof List
|
|
? (claim.covers as List).collect { it as String }
|
|
: []
|
|
if (covers.isEmpty()) {
|
|
violations << "${cardId}: evidence task claim ${index} covers must be non-empty"
|
|
}
|
|
if (covers.toSet().size() != covers.size()) {
|
|
violations << "${cardId}: evidence task claim ${index} has duplicate covers ${covers}"
|
|
}
|
|
covers.each { String evidenceClaim ->
|
|
if (!allowedEvidenceClaims.contains(evidenceClaim)) {
|
|
violations << "${cardId}: evidence covers unknown requirement '${evidenceClaim}'"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (schemaStream == 'owned') {
|
|
actualOwnedMigrationCards << cardId
|
|
if (!(migrationNode instanceof Map)) {
|
|
violations << "${cardId}: owned schema-stream requires migration"
|
|
}
|
|
} else if (migrationNode != null) {
|
|
violations << "${cardId}: schema-stream ${schemaStream} forbids migration"
|
|
}
|
|
|
|
if (migrationNode instanceof Map) {
|
|
Map<String, Object> migration = migrationNode as Map<String, Object>
|
|
Set<String> expectedMigrationKeys = [
|
|
'location',
|
|
'history-table',
|
|
'required-core-epoch',
|
|
'feature-revision',
|
|
'lifecycle-evidence'
|
|
] as Set
|
|
Set<String> migrationKeys = migration.keySet().collect { it as String }.toSet()
|
|
if (migrationKeys != expectedMigrationKeys) {
|
|
violations << "${cardId}: migration keys must be exactly ${expectedMigrationKeys}"
|
|
}
|
|
|
|
String location = migration.location as String
|
|
String historyTable = migration['history-table'] as String
|
|
if (location == null || !(location ==~ /db\/migration\/jpa\/[a-z0-9-]+/)) {
|
|
violations << "${cardId}: invalid migration location '${location}'"
|
|
} else {
|
|
String previousOwner = migrationLocationOwners.putIfAbsent(location, cardId)
|
|
if (previousOwner != null) {
|
|
violations << "duplicate migration location '${location}' for ${previousOwner} and ${cardId}"
|
|
}
|
|
}
|
|
if (historyTable == null || !(historyTable ==~ /flyway_jpa_[a-z0-9_]+_history/)) {
|
|
violations << "${cardId}: invalid migration history-table '${historyTable}'"
|
|
} else {
|
|
String previousOwner = migrationHistoryOwners.putIfAbsent(historyTable, cardId)
|
|
if (previousOwner != null) {
|
|
violations << "duplicate migration history-table '${historyTable}' for ${previousOwner} and ${cardId}"
|
|
}
|
|
}
|
|
|
|
Object coreEpoch = migration['required-core-epoch']
|
|
Object featureRevision = migration['feature-revision']
|
|
if (!(coreEpoch instanceof Integer) || (coreEpoch as Integer) < 0) {
|
|
violations << "${cardId}: required-core-epoch must be a non-negative integer"
|
|
}
|
|
if (!(featureRevision instanceof Integer) || (featureRevision as Integer) <= 0) {
|
|
violations << "${cardId}: feature-revision must be a positive integer"
|
|
}
|
|
List<String> lifecycleEvidence = migration['lifecycle-evidence'] instanceof List
|
|
? (migration['lifecycle-evidence'] as List).collect { it as String }
|
|
: []
|
|
if (lifecycleEvidence.isEmpty()) {
|
|
violations << "${cardId}: lifecycle-evidence must be a non-empty list"
|
|
} else if (lifecycleEvidence.toSet().size() != lifecycleEvidence.size()) {
|
|
violations << "${cardId}: duplicate lifecycle-evidence ${lifecycleEvidence}"
|
|
}
|
|
}
|
|
|
|
if (card['external-prerequisites'] != null) {
|
|
if (!(card['external-prerequisites'] instanceof List)) {
|
|
violations << "${cardId}: external-prerequisites must be a list"
|
|
} else {
|
|
(card['external-prerequisites'] as List).eachWithIndex {
|
|
Object rawExternal, int index ->
|
|
if (!(rawExternal instanceof Map)) {
|
|
violations << "${cardId}: external prerequisite ${index} must be an object"
|
|
return
|
|
}
|
|
Map<String, Object> external = rawExternal as Map<String, Object>
|
|
Set<String> externalKeys = external.keySet()
|
|
.collect { it as String }
|
|
.toSet()
|
|
if (externalKeys != ['registry', 'card-id', 'minimum-readiness'] as Set) {
|
|
violations << "${cardId}: external prerequisite ${index} has invalid keys ${externalKeys}"
|
|
}
|
|
if (!((external.registry as String)?.startsWith('src/config/'))) {
|
|
violations << "${cardId}: external prerequisite ${index} has invalid registry"
|
|
}
|
|
if (!((external['card-id'] as String) ==~ /[a-z0-9.-]+/)) {
|
|
violations << "${cardId}: external prerequisite ${index} has invalid card-id"
|
|
}
|
|
if (!((external['minimum-readiness'] as String) ==~ /R[0-3]/)) {
|
|
violations << "${cardId}: external prerequisite ${index} has invalid minimum-readiness"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (actualOwnedMigrationCards != expectedJpaOwnedMigrationCardIds) {
|
|
violations << "owned migration cards must be exactly ${expectedJpaOwnedMigrationCardIds}; " +
|
|
"got ${actualOwnedMigrationCards}"
|
|
}
|
|
|
|
Map<String, Integer> visitState = [:].withDefault { 0 }
|
|
Closure<Void> visitCard
|
|
visitCard = { String cardId ->
|
|
if (visitState[cardId] == 1) {
|
|
violations << "readiness prerequisite cycle includes '${cardId}'"
|
|
return
|
|
}
|
|
if (visitState[cardId] == 2 || !cards.containsKey(cardId)) {
|
|
return
|
|
}
|
|
visitState[cardId] = 1
|
|
Map<String, Object> card = cards[cardId] as Map<String, Object>
|
|
if (card.prerequisites instanceof List) {
|
|
(card.prerequisites as List).each { Object prerequisite ->
|
|
visitCard(prerequisite as String)
|
|
}
|
|
}
|
|
visitState[cardId] = 2
|
|
}
|
|
cards.keySet().each { Object cardId -> visitCard(cardId as String) }
|
|
|
|
boolean pollingSelected =
|
|
((cards['jpa-outbox-polling-delivery-v2'] as Map)?.state as String) == 'selected'
|
|
boolean cdcSelected =
|
|
((cards['jpa-outbox-cdc-retention-v1'] as Map)?.state as String) == 'selected'
|
|
if (pollingSelected && cdcSelected) {
|
|
violations << 'polling and CDC outbox delivery cards cannot both be selected'
|
|
}
|
|
|
|
violations
|
|
}
|
|
|
|
Closure<Boolean> jpaTaskExists = { String absoluteTaskPath ->
|
|
int separator = absoluteTaskPath.lastIndexOf(':')
|
|
if (separator < 0 || separator == absoluteTaskPath.length() - 1) {
|
|
return false
|
|
}
|
|
String projectPath = separator == 0 ? ':' : absoluteTaskPath.substring(0, separator)
|
|
String taskName = absoluteTaskPath.substring(separator + 1)
|
|
Project targetProject = rootProject.findProject(projectPath)
|
|
targetProject != null && targetProject.tasks.findByName(taskName) != null
|
|
}
|
|
|
|
def verifyJpaReadinessRegistryContract = tasks.register('verifyJpaReadinessRegistryContract') {
|
|
group = 'verification'
|
|
description = 'Mutation-tests the fail-closed JPA readiness registry validator.'
|
|
|
|
File registryFile = file("${rootProject.projectDir}/config/jpa/readiness-cards.yaml")
|
|
inputs.file(registryFile)
|
|
|
|
doLast {
|
|
String raw = registryFile.getText('UTF-8')
|
|
Map<String, Object> baseline = new JsonSlurper().parseText(raw) as Map<String, Object>
|
|
|
|
Closure<Map<String, Object>> copyRegistry = {
|
|
new JsonSlurper().parseText(JsonOutput.toJson(baseline)) as Map<String, Object>
|
|
}
|
|
Closure<Void> expectViolation = {
|
|
String scenario,
|
|
String expectedText,
|
|
Closure<Void> mutation,
|
|
Closure<Boolean> taskExists = { String ignored -> true } ->
|
|
Map<String, Object> candidate = copyRegistry()
|
|
mutation(candidate)
|
|
List<String> candidateViolations = validateJpaReadinessRegistry(
|
|
candidate,
|
|
JsonOutput.toJson(candidate),
|
|
taskExists)
|
|
if (!candidateViolations.any { String violation ->
|
|
violation.contains(expectedText)
|
|
}) {
|
|
throw new GradleException(
|
|
"verifyJpaReadinessRegistryContract: scenario '${scenario}' did not " +
|
|
"produce '${expectedText}'; got ${candidateViolations}")
|
|
}
|
|
}
|
|
|
|
expectViolation('unknown-card', 'unknown card ids', { Map<String, Object> candidate ->
|
|
(candidate.cards as Map)['jpa-primary-foundation-alias'] =
|
|
(candidate.cards as Map)['jpa-primary-foundation']
|
|
})
|
|
expectViolation('duplicate-task', 'duplicate task', { Map<String, Object> candidate ->
|
|
((candidate.cards as Map)['jpa-security-baseline'] as Map)['readiness-task'] =
|
|
((candidate.cards as Map)['jpa-observability-lifecycle'] as Map)['readiness-task']
|
|
})
|
|
expectViolation('missing-prerequisite', 'unknown prerequisite', {
|
|
Map<String, Object> candidate ->
|
|
((candidate.cards as Map)['jpa-security-baseline'] as Map).prerequisites =
|
|
['jpa-does-not-exist']
|
|
})
|
|
expectViolation('cycle', 'prerequisite cycle', { Map<String, Object> candidate ->
|
|
((candidate.cards as Map)['jpa-observability-lifecycle'] as Map).prerequisites =
|
|
['jpa-security-baseline']
|
|
})
|
|
expectViolation('duplicate-location', 'duplicate migration location', {
|
|
Map<String, Object> candidate ->
|
|
(((candidate.cards as Map)['jpa-idempotency-owner-safe-v2'] as Map).migration
|
|
as Map).location = 'db/migration/jpa/core'
|
|
})
|
|
expectViolation(
|
|
'missing-selected-task',
|
|
'selected task does not exist',
|
|
{ Map<String, Object> ignored -> },
|
|
{ String taskPath ->
|
|
taskPath !=
|
|
':adapter:outbound:persistence-jpa:postgresqlLifecycleIntegrationTest'
|
|
})
|
|
expectViolation('missing-active-evidence', 'active card requires evidence', {
|
|
Map<String, Object> candidate ->
|
|
((candidate.cards as Map)['jpa-observability-lifecycle'] as Map)
|
|
.remove('evidence')
|
|
})
|
|
expectViolation('unknown-evidence-requirement', 'evidence covers unknown requirement', {
|
|
Map<String, Object> candidate ->
|
|
((candidate.cards as Map)['jpa-observability-lifecycle'] as Map).evidence = [
|
|
scenarios: [[
|
|
selector: 'dev.caskeleton.ReadinessTest#startsPostgreSql',
|
|
covers: ['not-a-card-requirement']
|
|
]],
|
|
'task-claims': []
|
|
]
|
|
})
|
|
expectViolation('duplicate-evidence-selector', 'duplicate evidence selector', {
|
|
Map<String, Object> candidate ->
|
|
Map<String, Object> card =
|
|
(candidate.cards as Map)['jpa-observability-lifecycle'] as Map<String, Object>
|
|
card.evidence = [
|
|
scenarios: [
|
|
[
|
|
selector: 'dev.caskeleton.ReadinessTest#startsPostgreSql',
|
|
covers: ['real-postgresql']
|
|
],
|
|
[
|
|
selector: 'dev.caskeleton.ReadinessTest#startsPostgreSql',
|
|
covers: ['lifecycle']
|
|
]
|
|
],
|
|
'task-claims': []
|
|
]
|
|
})
|
|
expectViolation('unknown-evidence-task', 'evidence task claim is not owned by card', {
|
|
Map<String, Object> candidate ->
|
|
((candidate.cards as Map)['jpa-primary-foundation'] as Map).evidence = [
|
|
scenarios: [],
|
|
'task-claims': [[
|
|
task: ':test',
|
|
covers: ['architecture']
|
|
]]
|
|
]
|
|
})
|
|
|
|
logger.lifecycle(
|
|
'verifyJpaReadinessRegistryContract: OK — unknown card, duplicate task, ' +
|
|
'missing prerequisite, cycle, duplicate migration ownership, missing ' +
|
|
'selected task, and malformed evidence ownership all fail closed.')
|
|
}
|
|
}
|
|
|
|
def verifyJpaReadinessRegistry = tasks.register('verifyJpaReadinessRegistry') {
|
|
group = 'verification'
|
|
description = 'Validates the JPA readiness card, prerequisite, task, and migration registry.'
|
|
dependsOn verifyJpaReadinessRegistryContract
|
|
|
|
File registryFile = file("${rootProject.projectDir}/config/jpa/readiness-cards.yaml")
|
|
inputs.file(registryFile)
|
|
|
|
doLast {
|
|
if (!registryFile.isFile()) {
|
|
throw new GradleException(
|
|
"verifyJpaReadinessRegistry: missing registry ${registryFile}")
|
|
}
|
|
String raw = registryFile.getText('UTF-8')
|
|
Map<String, Object> registry
|
|
try {
|
|
registry = new JsonSlurper().parseText(raw) as Map<String, Object>
|
|
} catch (RuntimeException ex) {
|
|
throw new GradleException(
|
|
"verifyJpaReadinessRegistry: registry is not valid JSON-compatible YAML",
|
|
ex)
|
|
}
|
|
|
|
List<String> violations =
|
|
validateJpaReadinessRegistry(registry, raw, jpaTaskExists)
|
|
if (!violations.isEmpty()) {
|
|
throw new GradleException(
|
|
"verifyJpaReadinessRegistry: ${violations.size()} violation(s):\n " +
|
|
violations.toSorted().join('\n '))
|
|
}
|
|
logger.lifecycle(
|
|
"verifyJpaReadinessRegistry: OK — ${expectedJpaReadinessCardIds.size()} exact " +
|
|
"cards, ${expectedJpaOwnedMigrationCardIds.size()} owned migration " +
|
|
'streams, acyclic prerequisites, unique tasks/locations/history tables, ' +
|
|
'and selected task existence verified.')
|
|
}
|
|
}
|
|
|
|
configure(subprojects.findAll { it.childProjects.isEmpty() }) {
|
|
tasks.named('check') {
|
|
dependsOn verifyJpaReadinessRegistry
|
|
}
|
|
}
|
|
|
|
def verifyApplicationCoreDependencyPurity = tasks.register('verifyApplicationCoreDependencyPurity') {
|
|
group = 'verification'
|
|
description = 'Verifies application-core has only project production dependencies and no diagnostic frameworks on application classpaths.'
|
|
|
|
doLast {
|
|
Project application = project(':application-core')
|
|
List<String> violations = []
|
|
|
|
['api', 'implementation', 'compileOnly', 'runtimeOnly'].each { configurationName ->
|
|
def configuration = application.configurations.findByName(configurationName)
|
|
if (configuration == null) {
|
|
return
|
|
}
|
|
configuration.dependencies.each { dependency ->
|
|
if (!(dependency instanceof ProjectDependency)) {
|
|
violations << "${configurationName}: non-project production dependency " +
|
|
"${dependency.group ?: '<no-group>'}:${dependency.name}"
|
|
}
|
|
}
|
|
}
|
|
|
|
Closure<Boolean> forbiddenGroup = { String groupName ->
|
|
groupName != null && (
|
|
groupName.startsWith('org.springframework') ||
|
|
groupName == 'org.slf4j' ||
|
|
groupName == 'ch.qos.logback' ||
|
|
groupName == 'org.apache.logging.log4j' ||
|
|
groupName == 'io.micrometer')
|
|
}
|
|
['compileClasspath', 'runtimeClasspath', 'testCompileClasspath', 'testRuntimeClasspath']
|
|
.each { configurationName ->
|
|
def configuration = application.configurations.getByName(configurationName)
|
|
configuration.incoming.resolutionResult.allComponents.each { component ->
|
|
if (component.id instanceof ModuleComponentIdentifier &&
|
|
forbiddenGroup(component.id.group)) {
|
|
violations << "${configurationName}: forbidden resolved dependency " +
|
|
"${component.id.group}:${component.id.module}:${component.id.version}"
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!violations.isEmpty()) {
|
|
throw new GradleException(
|
|
"verifyApplicationCoreDependencyPurity: ${violations.size()} violation(s):\n " +
|
|
violations.toSorted().join('\n '))
|
|
}
|
|
logger.lifecycle(
|
|
'verifyApplicationCoreDependencyPurity: OK — application-core production declarations are project-only and application classpaths contain no Spring/logging/metrics frameworks.')
|
|
}
|
|
}
|
|
|
|
project(':application-core').tasks.named('check') {
|
|
dependsOn verifyApplicationCoreDependencyPurity
|
|
}
|
|
|
|
Closure<Map<String, Map<String, Object>>> loadRedisReadinessCards = { File registryFile ->
|
|
if (!registryFile.isFile()) {
|
|
throw new GradleException("Missing Redis readiness registry: ${registryFile}")
|
|
}
|
|
Map<String, Map<String, Object>> 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<String>).contains(evidence)) {
|
|
throw new GradleException(
|
|
"Malformed Redis readiness registry at line ${lineNumber}: duplicate evidence ${evidence}")
|
|
}
|
|
(cards[currentCard].requiredEvidence as List<String>) << 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<String> 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<String, Object> 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<String, Map<String, Object>> redisReadinessCards = loadRedisReadinessCards(
|
|
rootProject.file('config/redis/readiness-cards.yaml'))
|
|
ext.redisReadinessCards = redisReadinessCards
|
|
Map<String, String> 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<String, String> redisEvidenceTaskStems = [
|
|
standalone : 'Standalone',
|
|
security : 'Security',
|
|
sentinel : 'Sentinel',
|
|
cluster : 'Cluster',
|
|
fault : 'Fault',
|
|
compatibility: 'Compatibility'
|
|
]
|
|
Map<String, String> 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<String, Map<String, Object>> 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<String> 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<String> redisAggregateSha256 = { Collection<File> files ->
|
|
MessageDigest digest = MessageDigest.getInstance('SHA-256')
|
|
List<File> sortedFiles = files.toSorted {
|
|
rootProject.projectDir.toPath().relativize(it.toPath()).toString()
|
|
}
|
|
Set<String> 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<Map<String, String>> 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<File> programAssets = fileTree(redisResourceDirectory) {
|
|
include '*.json'
|
|
include 'scripts/*.lua'
|
|
}.files.toList()
|
|
Set<File> referencedScripts = new LinkedHashSet<>()
|
|
Closure<Void> 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<File> allScripts = programAssets.findAll {
|
|
it.name.endsWith('.lua')
|
|
}.collect { it.canonicalFile } as Set<File>
|
|
if (referencedScripts != allScripts) {
|
|
throw new GradleException(
|
|
"Redis program bundle scripts must be referenced exactly; missing=${allScripts - referencedScripts}, unknown=${referencedScripts - allScripts}")
|
|
}
|
|
Map<String, Object> safeConfigurationProjection = redisCapabilityMetadata.collectEntries {
|
|
String cardId, Map<String, Object> 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<String, String> 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<String, List<String>> 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<String, Object> 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<String, String> 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<String, String> enumByExternalId = new LinkedHashMap<>()
|
|
programMatcher.each { ignored, String enumName, String externalId ->
|
|
enumByExternalId[externalId] = enumName
|
|
}
|
|
Set<String> claimedPrograms = redisCapabilityMetadata.values().collectMany {
|
|
it.programs as List<String>
|
|
} as Set<String>
|
|
Set<String> unknownPrograms = claimedPrograms.findAll {
|
|
!enumByExternalId.containsKey(it)
|
|
} as Set<String>
|
|
if (!unknownPrograms.isEmpty()) {
|
|
throw new GradleException(
|
|
"Redis capability cards claim unknown program IDs: ${unknownPrograms}")
|
|
}
|
|
Map<String, List<String>> 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<String> sources ->
|
|
String implementation = sources.collect {
|
|
new File(redisSource, it).getText('UTF-8')
|
|
}.join('\n')
|
|
(redisCapabilityMetadata[cardId].programs as List<String>).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<String, String> 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<String> declaredSettingNames = redisCapabilityMetadata.values().collectMany {
|
|
(it.requiredSettings as List<Map<String, String>>).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<String, Set<String>> 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<String>,
|
|
'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<String>,
|
|
'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<String>,
|
|
'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<String>,
|
|
'redis-fenced-coordination': [] as Set<String>,
|
|
'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<String>
|
|
]
|
|
redisCapabilityMetadata.each { String cardId, Map<String, Object> metadata ->
|
|
Set<String> actual = (metadata.requiredSettings as List<Map<String, String>>)
|
|
.collect { it.name } as Set<String>
|
|
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<String, Object> 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<List<String>> resolvedEvidence = { Map<String, Object> card ->
|
|
Set<String> resolved = new LinkedHashSet<>(card.requiredEvidence as List<String>)
|
|
if (resolved.remove('selected-topology')) {
|
|
resolved.add(card.selectedTopology as String)
|
|
}
|
|
resolved.toList().sort()
|
|
}
|
|
List<Map<String, Object>> selected = []
|
|
List<Map<String, Object>> candidates = []
|
|
redisReadinessCards.each { String cardId, Map<String, Object> card ->
|
|
Map<String, Object> 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<String, Object> 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<String, String> digests = redisEvidenceDigests()
|
|
redisReadinessCards.keySet().toList().sort().each { String cardId ->
|
|
Map<String, Object> card = redisReadinessCards[cardId]
|
|
Map<String, Object> metadata = redisCapabilityMetadata[cardId]
|
|
Map<String, Object> 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<File> 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<String> expectedControlPaths = [
|
|
'redis-readiness-matrix.json',
|
|
'checksums.sha256'
|
|
] as Set<String>
|
|
redisReadinessCards.keySet().each {
|
|
expectedControlPaths.add("capability-cards/${it}.json")
|
|
}
|
|
List<File> suppliedControlFiles = fileTree(suppliedControlDirectory).files.toList()
|
|
Set<String> suppliedControlPaths = suppliedControlFiles.collect {
|
|
suppliedControlDirectory.toPath().relativize(it.toPath()).toString()
|
|
} as Set<String>
|
|
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<String, String> 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<String> 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<String, Object> expectedMatrix = new JsonSlurper().parse(
|
|
redisCiMatrixFile.get().asFile) as Map<String, Object>
|
|
Map<String, Object> suppliedMatrix = new JsonSlurper().parse(
|
|
new File(suppliedControlDirectory, 'redis-readiness-matrix.json')) as Map<String, Object>
|
|
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<String, Object> card ->
|
|
Map<String, Object> expectedCard = new JsonSlurper().parse(
|
|
new File(expectedControlDirectory, "capability-cards/${cardId}.json"))
|
|
as Map<String, Object>
|
|
Map<String, Object> suppliedCard = new JsonSlurper().parse(
|
|
new File(suppliedControlDirectory, "capability-cards/${cardId}.json"))
|
|
as Map<String, Object>
|
|
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<String, Map<String, Object>> 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<String, Object> ciResult = new JsonSlurper().parse(ciResultFile)
|
|
as Map<String, Object>
|
|
if (ciResult.keySet() != [
|
|
'schemaVersion',
|
|
'runId',
|
|
'selectedCount',
|
|
'selectedJobResult',
|
|
'selectedArtifactNames'
|
|
] as Set<String> ||
|
|
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<String> expectedArtifactNames = selectedCards.keySet().collect {
|
|
"redis-selected-${it}"
|
|
} as Set<String>
|
|
Set<String> actualArtifactNames = ciResult.selectedArtifactNames as Set<String>
|
|
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=<current-run inventory>')
|
|
}
|
|
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=<downloaded sanitized artifacts>')
|
|
}
|
|
File evidenceDirectory = rootProject.file(evidenceDirectoryProperty)
|
|
if (!evidenceDirectory.isDirectory()) {
|
|
throw new GradleException(
|
|
"Redis selected evidence directory is missing: ${evidenceDirectory}")
|
|
}
|
|
Set<String> allowedEvidenceFileNames = [
|
|
'manifest.json',
|
|
'capability-card.json',
|
|
'topology-fault-timeline.json'
|
|
] as Set<String>
|
|
List<File> 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<File> manifestParents = evidenceFiles.findAll {
|
|
it.name == 'manifest.json'
|
|
}.collect {
|
|
it.parentFile.canonicalFile
|
|
} as Set<File>
|
|
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<String> != 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<Map<String, Object>> manifests = fileTree(evidenceDirectory).matching {
|
|
include '**/manifest.json'
|
|
}.files.toSorted().collect { File manifest ->
|
|
Map<String, Object> parsed = new JsonSlurper().parse(manifest) as Map<String, Object>
|
|
parsed.__file = manifest
|
|
parsed
|
|
}
|
|
if (manifests.any { it.cardId == null }) {
|
|
throw new GradleException(
|
|
'Downloaded selected evidence must not contain generic or unowned manifests')
|
|
}
|
|
Map<String, String> expectedDigests = redisEvidenceDigests()
|
|
Set<String> manifestFields = [
|
|
'schemaVersion',
|
|
'taskPath',
|
|
'tagExpression',
|
|
'cardId',
|
|
'cardState',
|
|
'selectedTopology',
|
|
'evidenceCategory',
|
|
'outcome',
|
|
'tests',
|
|
'runtimeImageAttestation',
|
|
'actualEventTimeline',
|
|
'releaseQualification',
|
|
'sourceRevision',
|
|
'sourceTreeState',
|
|
'digests',
|
|
'companionSha256'
|
|
] as Set<String>
|
|
Set<String> testFields = [
|
|
'discovered',
|
|
'executed',
|
|
'passed',
|
|
'failed',
|
|
'errors',
|
|
'skipped'
|
|
] 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>
|
|
selectedCards.each { String cardId, Map<String, Object> card ->
|
|
Set<String> expectedEvidence = new LinkedHashSet<>(
|
|
card.requiredEvidence as List<String>)
|
|
if (expectedEvidence.remove('selected-topology')) {
|
|
expectedEvidence.add(card.selectedTopology as String)
|
|
}
|
|
List<Map<String, Object>> cardManifests = manifests.findAll {
|
|
it.cardId == cardId
|
|
}
|
|
List<Map<String, Object>> 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<Map<String, Object>> matching = cardManifests.findAll {
|
|
it.evidenceCategory == evidence
|
|
}
|
|
if (matching.size() != 1) {
|
|
throw new GradleException(
|
|
"${cardId}/${evidence}: expected exactly one evidence manifest; got ${matching.size()}")
|
|
}
|
|
}
|
|
Set<String> actualEvidence = cardManifests.findAll {
|
|
it.evidenceCategory != null
|
|
}.collect { it.evidenceCategory as String } as Set<String>
|
|
if (actualEvidence != expectedEvidence) {
|
|
throw new GradleException(
|
|
"${cardId}: evidence mismatch; expected=${expectedEvidence}, actual=${actualEvidence}")
|
|
}
|
|
cardManifests.each { Map<String, Object> manifest ->
|
|
File manifestFile = manifest.__file as File
|
|
Set<String> actualManifestFields = new LinkedHashSet<>(manifest.keySet())
|
|
actualManifestFields.remove('__file')
|
|
Map<String, Object> tests = manifest.tests as Map<String, Object>
|
|
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<String, Object> companionSha = manifest.companionSha256 as Map<String, Object>
|
|
if (companionSha?.keySet() != [
|
|
'capabilityCardSha256',
|
|
'timelineSha256'
|
|
] as Set<String> ||
|
|
companionSha.capabilityCardSha256 !=
|
|
redisSha256(capabilityFile).substring('sha256:'.length()) ||
|
|
companionSha.timelineSha256 !=
|
|
redisSha256(timelineFile).substring('sha256:'.length())) {
|
|
throw new GradleException(
|
|
"${manifestFile}: companion checksum mismatch")
|
|
}
|
|
Map<String, Object> capability = new JsonSlurper().parse(
|
|
capabilityFile) as Map<String, Object>
|
|
Map<String, Object> timeline = new JsonSlurper().parse(
|
|
timelineFile) as Map<String, Object>
|
|
Map<String, Object> 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<String> capabilityCardDigests = cardManifests.collect {
|
|
Map<String, Object> companion = it.companionSha256 as Map<String, Object>
|
|
companion.capabilityCardSha256 as String
|
|
} as Set<String>
|
|
if (capabilityCardDigests.size() != 1) {
|
|
throw new GradleException(
|
|
"${cardId}: capability card must be byte-identical across all evidence tasks")
|
|
}
|
|
}
|
|
Set<String> unknownSelectedCards = manifests.findAll {
|
|
it.cardId != null
|
|
}.collect { it.cardId as String }.findAll {
|
|
!selectedCards.containsKey(it)
|
|
} as Set<String>
|
|
if (!unknownSelectedCards.isEmpty()) {
|
|
throw new GradleException(
|
|
"Downloaded selected evidence contains unselected cards: ${unknownSelectedCards}")
|
|
}
|
|
int expectedManifestCount = selectedCards.collect { String ignored, Map<String, Object> card ->
|
|
Set<String> resolved = new LinkedHashSet<>(card.requiredEvidence as List<String>)
|
|
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<String, Object> 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<String> evidence = new LinkedHashSet<>(card.requiredEvidence as List<String>)
|
|
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<String, Object> ignored ->
|
|
dependsOn tasks.named(redisPublicReadinessTasks[cardId])
|
|
}
|
|
doLast {
|
|
List<String> 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<String, Object> 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.'
|
|
|
|
File moduleRegistryFile = new File(rootProject.projectDir, 'config/architecture/modules.json')
|
|
inputs.file(moduleRegistryFile)
|
|
|
|
doLast {
|
|
def moduleRegistry = new JsonSlurper().parse(moduleRegistryFile)
|
|
List<String> violations = []
|
|
def processorDeclaration = ~/^\s*annotationProcessor\s+['"]org\.springframework\.boot:spring-boot-configuration-processor['"]\s*$/
|
|
|
|
moduleRegistry.modules.each { module ->
|
|
File leafDirectory = rootProject.projectDir.parentFile.toPath()
|
|
.resolve(module.source_path as String)
|
|
.normalize()
|
|
.toFile()
|
|
File mainSource = new File(leafDirectory, 'src/main')
|
|
File buildFile = new File(leafDirectory, 'build.gradle')
|
|
|
|
int propertyAnnotationCount = 0
|
|
if (mainSource.isDirectory()) {
|
|
mainSource.eachFileRecurse { File sourceFile ->
|
|
if (sourceFile.name.endsWith('.java')) {
|
|
propertyAnnotationCount += sourceFile.text.count('@ConfigurationProperties(')
|
|
}
|
|
}
|
|
}
|
|
int processorCount = buildFile.readLines().count { String line ->
|
|
processorDeclaration.matcher(line).matches()
|
|
}
|
|
|
|
boolean ownsConfigurationProperties = propertyAnnotationCount > 0
|
|
if (ownsConfigurationProperties && processorCount != 1) {
|
|
violations << "${module.id}: ${propertyAnnotationCount} @ConfigurationProperties occurrence(s), " +
|
|
"but ${processorCount} configuration-processor declaration(s)"
|
|
} else if (!ownsConfigurationProperties && processorCount != 0) {
|
|
violations << "${module.id}: no @ConfigurationProperties occurrence, but " +
|
|
"${processorCount} configuration-processor declaration(s)"
|
|
}
|
|
}
|
|
|
|
if (!violations.isEmpty()) {
|
|
throw new GradleException(
|
|
"verifyConfigurationPropertiesProcessor: ${violations.size()} parity violation(s):\n " +
|
|
violations.toSorted().join('\n '))
|
|
}
|
|
logger.lifecycle(
|
|
"verifyConfigurationPropertiesProcessor: OK — all ${moduleRegistry.modules.size()} registered leaves have exact configuration-processor parity.")
|
|
}
|
|
}
|
|
|
|
configure(subprojects.findAll { it.childProjects.isEmpty() }) {
|
|
tasks.named('check') {
|
|
dependsOn verifyConfigurationPropertiesProcessor
|
|
}
|
|
}
|
|
|
|
// verifyOneTypePerFile — one public top-level type per file, file name == type name
|
|
// (code-conventions I6). Rationale in README.md.
|
|
tasks.register('verifyOneTypePerFile') {
|
|
group = 'verification'
|
|
description = 'code-conventions I6: one public top-level type per file; file name == type name.'
|
|
|
|
doLast {
|
|
def typeDecl = ~/^public\s+(?:final\s+|abstract\s+|sealed\s+|non-sealed\s+)*(?:class|interface|record|enum|@interface)\s+([A-Za-z0-9_]+)/
|
|
List<String> violations = []
|
|
rootProject.subprojects.each { sub ->
|
|
File mainJava = sub.file('src/main/java')
|
|
if (!mainJava.exists()) {
|
|
return
|
|
}
|
|
mainJava.eachFileRecurse { File f ->
|
|
if (!f.name.endsWith('.java') || f.name == 'package-info.java' || f.name == 'module-info.java') {
|
|
return
|
|
}
|
|
List<String> names = []
|
|
f.eachLine { String line ->
|
|
def m = (line =~ typeDecl)
|
|
if (m.find()) {
|
|
names << m.group(1)
|
|
}
|
|
}
|
|
if (names.size() > 1) {
|
|
violations << "${f.path}: ${names.size()} public top-level types ${names}".toString()
|
|
} else if (names.size() == 1) {
|
|
String expected = f.name.replaceFirst(/\.java$/, '')
|
|
if (names[0] != expected) {
|
|
violations << "${f.path}: file name != public type name (type is '${names[0]}')".toString()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (!violations.isEmpty()) {
|
|
throw new GradleException(
|
|
"verifyOneTypePerFile: ${violations.size()} violation(s) of code-conventions I6:\n " +
|
|
violations.join("\n "))
|
|
}
|
|
logger.lifecycle("verifyOneTypePerFile: OK — one public top-level type per file, names match.")
|
|
}
|
|
}
|
|
|
|
// verifyEnvKeys — keep env-keys.yaml <-> application.yml <-> src/.env in lock-step.
|
|
// Rationale in README.md.
|
|
tasks.register('verifyEnvKeys') {
|
|
group = 'verification'
|
|
description = 'Verifies application.yml APP_ references, src/.env, and env-keys.yaml stay registered.'
|
|
|
|
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")
|
|
|
|
inputs.files(envFile, appYml, registryFile)
|
|
|
|
doLast {
|
|
if (!envFile.exists()) {
|
|
throw new GradleException("verifyEnvKeys: missing ${envFile}")
|
|
}
|
|
if (!appYml.exists()) {
|
|
throw new GradleException("verifyEnvKeys: missing ${appYml}")
|
|
}
|
|
if (!registryFile.exists()) {
|
|
throw new GradleException("verifyEnvKeys: missing ${registryFile}")
|
|
}
|
|
|
|
def keyPattern = ~/^([A-Z][A-Z0-9_]*)=.*/
|
|
Set<String> envKeys = envFile.readLines().findResults { String line ->
|
|
def m = keyPattern.matcher(line)
|
|
m.matches() ? m.group(1) : null
|
|
}.toSet()
|
|
|
|
// Parse application.yml placeholders: ${VAR} is required, ${VAR:default} is optional.
|
|
Set<String> requiredPlaceholders = new TreeSet<>()
|
|
Set<String> allPlaceholders = new TreeSet<>()
|
|
def pm = (appYml.text =~ /\$\{([A-Z][A-Z0-9_]*)(:[^}]*)?\}/)
|
|
while (pm.find()) {
|
|
allPlaceholders << pm.group(1)
|
|
if (pm.group(2) == null) {
|
|
requiredPlaceholders << pm.group(1)
|
|
}
|
|
}
|
|
Set<String> environmentSecretReferences = new TreeSet<>()
|
|
def sm = (appYml.text =~ /secret:\/\/environment\/(APP_[A-Z][A-Z0-9_]*)/)
|
|
while (sm.find()) {
|
|
environmentSecretReferences << sm.group(1)
|
|
}
|
|
Set<String> applicationAppReferences = new TreeSet<>(
|
|
allPlaceholders.findAll { it.startsWith('APP_') })
|
|
applicationAppReferences.addAll(environmentSecretReferences)
|
|
|
|
// A. Every required (no inline default) placeholder must exist in .env.
|
|
Set<String> missingKeys = new TreeSet<>(requiredPlaceholders - envKeys)
|
|
if (!missingKeys.isEmpty()) {
|
|
throw new GradleException(
|
|
"verifyEnvKeys: application.yml references required env absent from src/.env: ${missingKeys}")
|
|
}
|
|
|
|
// B. Every .env key must be referenced by some application.yml placeholder.
|
|
Set<String> knownApplicationReferences = new TreeSet<>(allPlaceholders)
|
|
knownApplicationReferences.addAll(environmentSecretReferences)
|
|
Set<String> orphanedKeys = new TreeSet<>(envKeys - knownApplicationReferences)
|
|
if (!orphanedKeys.isEmpty()) {
|
|
throw new GradleException(
|
|
"verifyEnvKeys: src/.env declares keys no application.yml \${...} placeholder uses: ${orphanedKeys}")
|
|
}
|
|
|
|
// C. Every APP_ key in src/.env must be registered in env-keys.yaml (APP_-scoped;
|
|
// SPRING_* native keys are intentionally not tracked — see README.md).
|
|
def registryNamePattern = ~/^\s*- name: (APP_[A-Z0-9_]+)/
|
|
Set<String> registryAppKeys = registryFile.readLines().findResults { String line ->
|
|
def m = registryNamePattern.matcher(line)
|
|
m.find() ? m.group(1) : null
|
|
}.toSet()
|
|
|
|
Set<String> envAppKeys = envKeys.findAll { it.startsWith('APP_') }.toSet()
|
|
Set<String> unregisteredAppKeys = new TreeSet<>(envAppKeys - registryAppKeys)
|
|
if (!unregisteredAppKeys.isEmpty()) {
|
|
throw new GradleException(
|
|
"verifyEnvKeys: src/.env declares APP_ keys absent from docs/registries/env-keys.yaml " +
|
|
"(registry is the SSOT for APP_ keys): ${unregisteredAppKeys}")
|
|
}
|
|
|
|
// D. Every application-owned reference is registered, including optional placeholders
|
|
// with inline defaults and literal secret://environment/APP_* references.
|
|
Set<String> unregisteredApplicationReferences =
|
|
new TreeSet<>(applicationAppReferences - registryAppKeys)
|
|
if (!unregisteredApplicationReferences.isEmpty()) {
|
|
throw new GradleException(
|
|
"verifyEnvKeys: application.yml references APP_ keys absent from " +
|
|
"docs/registries/env-keys.yaml (optional defaults and environment " +
|
|
"secret references are included): ${unregisteredApplicationReferences}")
|
|
}
|
|
|
|
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<String> 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")
|
|
}
|
|
}
|
|
|
|
// verifyTrivyignore — feature-dependency-vulnerability-management-contract D5 (Suppression
|
|
// governance). Fails the build when a Trivy suppression entry in the repo-root .trivyignore.yaml
|
|
// lacks an expiry or a reason, is already expired, or exceeds the 90-day max window. Closes the
|
|
// 2026-05-25 ca-tmpl audit finding: a suppression could be added with no expiry/reason (a silent
|
|
// permanent bypass). This is the CI-side field check; CODEOWNERS adds the merge-time approval —
|
|
// the two controls are complementary (see .trivyignore.yaml header and src/README.md).
|
|
tasks.register('verifyTrivyignore') {
|
|
group = 'verification'
|
|
description = 'feature-dependency-vulnerability-management-contract D5: every Trivy suppression has a reason and a non-expired, bounded expiry.'
|
|
|
|
// UNSUPPORTED_IMPL_DECISION (team-policy): 90-day max suppression window. The Trivy docs only
|
|
// guarantee the `expired_at` field exists (raw/official-docs/trivy-filtering-suppression-policy
|
|
// C4); they recommend no specific bound. Trade-off: shorter forces frequent re-review; longer
|
|
// approaches a de-facto permanent ignore.
|
|
int maxWindowDays = 90
|
|
Set<String> governedSections = ['vulnerabilities', 'licenses', 'misconfigurations', 'secrets'] as Set
|
|
|
|
File suppressionFile = file("${rootProject.projectDir}/../.trivyignore.yaml")
|
|
inputs.file(suppressionFile)
|
|
|
|
doLast {
|
|
if (!suppressionFile.exists()) {
|
|
throw new GradleException("verifyTrivyignore: missing ${suppressionFile} (D5 requires the structured suppression file to exist, even if empty).")
|
|
}
|
|
|
|
java.time.LocalDate today = java.time.LocalDate.now()
|
|
java.time.LocalDate maxDate = today.plusDays(maxWindowDays)
|
|
List<String> violations = []
|
|
int entryCount = 0
|
|
|
|
String section = null
|
|
int entryIndent = -1
|
|
Map<String, String> entry = null
|
|
int entryLine = -1
|
|
|
|
Closure<Void> validate = { Map<String, String> e, String sec, int lineNo ->
|
|
entryCount++
|
|
String id = e['id']
|
|
String where = "${sec} entry at .trivyignore.yaml:${lineNo}" + (id ? " (id: ${id})" : "")
|
|
if (!id?.trim()) {
|
|
violations << "${where}: missing 'id'."
|
|
}
|
|
if (!e['statement']?.trim()) {
|
|
violations << "${where}: missing non-empty 'statement' (every suppression must record a reason)."
|
|
}
|
|
String exp = e['expired_at']?.trim()
|
|
if (!exp) {
|
|
violations << "${where}: missing 'expired_at' (a missing expiry never expires in Trivy — permanent suppression is forbidden)."
|
|
} else {
|
|
try {
|
|
java.time.LocalDate expDate = java.time.LocalDate.parse(exp)
|
|
if (!expDate.isAfter(today)) {
|
|
violations << "${where}: 'expired_at' ${exp} is not in the future (already expired — remove or renew the suppression)."
|
|
} else if (expDate.isAfter(maxDate)) {
|
|
violations << "${where}: 'expired_at' ${exp} exceeds the ${maxWindowDays}-day max window (must be on or before ${maxDate})."
|
|
}
|
|
} catch (java.time.format.DateTimeParseException ignored) {
|
|
violations << "${where}: 'expired_at' '${exp}' is not a valid ISO date (expected YYYY-MM-DD)."
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
suppressionFile.eachLine { String rawLine, int number ->
|
|
String noTab = rawLine.replace('\t', ' ')
|
|
String trimmed = noTab.trim()
|
|
if (trimmed.isEmpty() || trimmed.startsWith('#')) {
|
|
return
|
|
}
|
|
int indent = noTab.length() - noTab.replaceAll('^ +', '').length()
|
|
|
|
// Top-level section header (column 0), e.g. `vulnerabilities:` or `vulnerabilities: []`.
|
|
def sectionMatch = (trimmed =~ /^([a-z_]+):(\s*\[\s*\])?\s*$/)
|
|
if (indent == 0 && sectionMatch.find()) {
|
|
if (entry != null) { validate(entry, section, entryLine); entry = null }
|
|
section = sectionMatch.group(1)
|
|
entryIndent = -1
|
|
return
|
|
}
|
|
if (section == null || !governedSections.contains(section)) {
|
|
return
|
|
}
|
|
|
|
if (trimmed.startsWith('- ') || trimmed == '-') {
|
|
if (entryIndent == -1) {
|
|
entryIndent = indent
|
|
}
|
|
if (indent == entryIndent) {
|
|
if (entry != null) { validate(entry, section, entryLine) }
|
|
entry = [:]
|
|
entryLine = number
|
|
// A field may sit inline on the dash line, e.g. `- id: CVE-2024-0001`.
|
|
String inline = trimmed.replaceFirst(/^-\s*/, '')
|
|
int colon = inline.indexOf(':')
|
|
if (colon > 0) {
|
|
String k = inline.substring(0, colon).trim()
|
|
String v = inline.substring(colon + 1).trim().replaceAll(/^["']|["']$/, '')
|
|
if (!k.isEmpty()) { entry[k] = v }
|
|
}
|
|
}
|
|
// indent > entryIndent → nested list item (e.g. under `paths:`) → ignore.
|
|
return
|
|
}
|
|
|
|
// A `key: value` field of the current entry (one indent level deeper than the dash).
|
|
if (entry != null && indent == entryIndent + 2) {
|
|
int colon = trimmed.indexOf(':')
|
|
if (colon > 0) {
|
|
String k = trimmed.substring(0, colon).trim()
|
|
String v = trimmed.substring(colon + 1).trim().replaceAll(/^["']|["']$/, '')
|
|
entry[k] = v
|
|
}
|
|
}
|
|
}
|
|
if (entry != null) { validate(entry, section, entryLine) }
|
|
|
|
if (!violations.isEmpty()) {
|
|
throw new GradleException(
|
|
"verifyTrivyignore: ${violations.size()} Trivy suppression governance violation(s) (D5):\n " +
|
|
violations.join("\n ") +
|
|
"\nFix the .trivyignore.yaml entries, or remove them. Every suppression needs an 'id', a " +
|
|
"'statement' reason, and a future 'expired_at' within ${maxWindowDays} days.")
|
|
}
|
|
logger.lifecycle("verifyTrivyignore: OK — ${entryCount} suppression(s) validated (reason + bounded, non-expired expiry).")
|
|
}
|
|
}
|
|
|
|
// verifyQuarantineSunset — feature-ci-quality-gates-contract §4 (D7/D9, this branch is the flaky
|
|
// quarantine SSOT). The flaky-test quarantine bucket (@Tag("quarantine"), excluded from the release
|
|
// gate) is a TEMPORARY escape, not a parking lot: every quarantined test must be registered in the
|
|
// repo-root flaky-quarantine.yaml with a reason, a tracking issue, and a quarantined_since date, and
|
|
// it must leave quarantine within 14 days (the ca-tmpl compromise on the Spotify/Google/MS-vs-Fowler
|
|
// debate — company-case-study strength only, NOT an official best practice). This is the CI-side
|
|
// field + sunset check; like verifyTrivyignore it is the field-validation half and CODEOWNERS is the
|
|
// merge-approval half. Two enforcement directions:
|
|
// (a) sunset — fail when any registered quarantined_since is older than 14 days;
|
|
// (b) drift — fail when a test is @Tag("quarantine")-tagged in source but NOT registered
|
|
// (a flaky test must not escape sunset tracking by skipping the registry).
|
|
// Ships passing on the empty skeleton (zero tagged tests, `quarantined: []`).
|
|
tasks.register('verifyQuarantineSunset') {
|
|
group = 'verification'
|
|
description = 'feature-ci-quality-gates-contract §4 (D7): every @Tag("quarantine") test is registered and within its 14-day sunset.'
|
|
|
|
// UNSUPPORTED_IMPL_DECISION (company-case-study + team-policy): 14-day sunset. The Spotify/Google/MS
|
|
// case studies establish that a quarantine bucket is legitimate; the 14-day quantum and its
|
|
// automatic enforcement are ca-tmpl's own compromise (no external standard). A fork tunes
|
|
// sunsetDays here.
|
|
int sunsetDays = 14
|
|
|
|
File registryFile = file("${rootProject.projectDir}/../flaky-quarantine.yaml")
|
|
inputs.file(registryFile)
|
|
rootProject.subprojects.each { sub ->
|
|
File testJava = sub.file('src/test/java')
|
|
if (testJava.exists()) {
|
|
inputs.dir(testJava)
|
|
}
|
|
}
|
|
|
|
doLast {
|
|
if (!registryFile.exists()) {
|
|
throw new GradleException("verifyQuarantineSunset: missing ${registryFile} " +
|
|
"(D7 requires the quarantine registry to exist, even if empty: `quarantined: []`).")
|
|
}
|
|
|
|
// --- Parse the registry (line-based, same shape family as .trivyignore.yaml). -----------
|
|
// quarantined:
|
|
// - test: "fully.qualified.TestClass" | "...TestClass#method"
|
|
// quarantined_since: "YYYY-MM-DD"
|
|
// reason: "..."
|
|
// tracking_issue: "..."
|
|
List<Map<String, String>> entries = []
|
|
List<Integer> entryLines = []
|
|
boolean inSection = false
|
|
int entryIndent = -1
|
|
Map<String, String> entry = null
|
|
int entryLine = -1
|
|
|
|
Closure<Void> closeEntry = {
|
|
if (entry != null) { entries << entry; entryLines << entryLine; entry = null }
|
|
return null
|
|
}
|
|
|
|
registryFile.eachLine { String rawLine, int number ->
|
|
String noTab = rawLine.replace('\t', ' ')
|
|
String trimmed = noTab.trim()
|
|
if (trimmed.isEmpty() || trimmed.startsWith('#')) {
|
|
return
|
|
}
|
|
int indent = noTab.length() - noTab.replaceAll('^ +', '').length()
|
|
|
|
// Top-level `quarantined:` or `quarantined: []`.
|
|
def sectionMatch = (trimmed =~ /^quarantined:(\s*\[\s*\])?\s*$/)
|
|
if (indent == 0 && sectionMatch.find()) {
|
|
closeEntry()
|
|
inSection = true
|
|
entryIndent = -1
|
|
return
|
|
}
|
|
if (indent == 0) { // some other top-level key → leave the section
|
|
closeEntry()
|
|
inSection = false
|
|
return
|
|
}
|
|
if (!inSection) {
|
|
return
|
|
}
|
|
|
|
if (trimmed.startsWith('- ') || trimmed == '-') {
|
|
if (entryIndent == -1) { entryIndent = indent }
|
|
if (indent == entryIndent) {
|
|
closeEntry()
|
|
entry = [:]
|
|
entryLine = number
|
|
String inline = trimmed.replaceFirst(/^-\s*/, '')
|
|
int colon = inline.indexOf(':')
|
|
if (colon > 0) {
|
|
String k = inline.substring(0, colon).trim()
|
|
String v = inline.substring(colon + 1).trim().replaceAll(/^["']|["']$/, '')
|
|
if (!k.isEmpty()) { entry[k] = v }
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// A `key: value` field of the current entry (deeper than the dash).
|
|
if (entry != null && indent == entryIndent + 2) {
|
|
int colon = trimmed.indexOf(':')
|
|
if (colon > 0) {
|
|
String k = trimmed.substring(0, colon).trim()
|
|
String v = trimmed.substring(colon + 1).trim().replaceAll(/^["']|["']$/, '')
|
|
entry[k] = v
|
|
}
|
|
}
|
|
}
|
|
closeEntry()
|
|
|
|
// --- (a) Schema + sunset validation of every registered entry. -------------------------
|
|
java.time.LocalDate today = java.time.LocalDate.now()
|
|
List<String> violations = []
|
|
Set<String> registeredClasses = new HashSet<>()
|
|
|
|
entries.eachWithIndex { Map<String, String> e, int i ->
|
|
String test = e['test']?.trim()
|
|
String where = "flaky-quarantine.yaml:${entryLines[i]}" + (test ? " (test: ${test})" : "")
|
|
if (!test) {
|
|
violations << "${where}: missing 'test' (fully-qualified test class, optionally '#method')."
|
|
} else {
|
|
registeredClasses << test.replaceAll(/#.*$/, '')
|
|
}
|
|
if (!e['reason']?.trim()) {
|
|
violations << "${where}: missing non-empty 'reason' (why is it flaky / what is the suspected cause)."
|
|
}
|
|
if (!e['tracking_issue']?.trim()) {
|
|
violations << "${where}: missing 'tracking_issue' (the issue tracking the fix — quarantine is not a parking lot)."
|
|
}
|
|
String since = e['quarantined_since']?.trim()
|
|
if (!since) {
|
|
violations << "${where}: missing 'quarantined_since' (a missing date cannot be sunset — forbidden)."
|
|
} else {
|
|
try {
|
|
java.time.LocalDate sinceDate = java.time.LocalDate.parse(since)
|
|
if (sinceDate.isAfter(today)) {
|
|
violations << "${where}: 'quarantined_since' ${since} is in the future."
|
|
} else if (sinceDate.plusDays(sunsetDays).isBefore(today)) {
|
|
long age = java.time.temporal.ChronoUnit.DAYS.between(sinceDate, today)
|
|
violations << "${where}: quarantined ${age} days ago — past the ${sunsetDays}-day sunset. " +
|
|
"Fix and un-quarantine the test, or escalate; do not extend silently."
|
|
}
|
|
} catch (java.time.format.DateTimeParseException ignored) {
|
|
violations << "${where}: 'quarantined_since' '${since}' is not a valid ISO date (expected YYYY-MM-DD)."
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- (b) Drift: every @Tag("quarantine") test in source must be registered. -------------
|
|
def tagPattern = ~/@Tag\(\s*["']quarantine["']\s*\)/
|
|
def classPattern = ~/(?:class|interface|enum|record)\s+([A-Za-z0-9_]+)/
|
|
int taggedCount = 0
|
|
rootProject.subprojects.each { sub ->
|
|
File testJava = sub.file('src/test/java')
|
|
if (!testJava.exists()) {
|
|
return
|
|
}
|
|
testJava.eachFileRecurse { File f ->
|
|
if (!f.name.endsWith('.java')) {
|
|
return
|
|
}
|
|
String text = f.text
|
|
if (!(text =~ tagPattern)) {
|
|
return
|
|
}
|
|
taggedCount++
|
|
def cm = (text =~ classPattern)
|
|
String simpleName = cm ? cm[0][1] : f.name.replaceFirst(/\.java$/, '')
|
|
boolean registered = registeredClasses.any { it.endsWith('.' + simpleName) || it == simpleName }
|
|
if (!registered) {
|
|
violations << "${f.path}: test '${simpleName}' is @Tag(\"quarantine\") but is not registered " +
|
|
"in flaky-quarantine.yaml — a quarantined test must be tracked with a sunset date."
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!violations.isEmpty()) {
|
|
throw new GradleException(
|
|
"verifyQuarantineSunset: ${violations.size()} quarantine governance violation(s) (§4 / D7):\n " +
|
|
violations.join("\n ") +
|
|
"\nEach @Tag(\"quarantine\") test needs a flaky-quarantine.yaml entry with a 'reason', a " +
|
|
"'tracking_issue', and a 'quarantined_since' within ${sunsetDays} days.")
|
|
}
|
|
logger.lifecycle("verifyQuarantineSunset: OK — ${entries.size()} registered, ${taggedCount} tagged " +
|
|
"(${sunsetDays}-day sunset enforced).")
|
|
}
|
|
}
|