refactor(build,ci): CI를 단계별로 재편하고 감사 잔여 114건 정리
빌드·CI 레이어 전수 감사(133건) 중 Track A 에서 처리한 E등급 19건을 뺀
나머지를 마무리한다. 한 커밋인 이유는 서로 얽혀 있기 때문이다 — 워크플로가
`checkstyleMain` 을 부르려면 루트가 먼저 Checkstyle 을 붙여야 하고, 모듈 43개가
convention 을 적용하려면 build-logic 이 먼저 그 플러그인을 등록해야 한다.
영역별로 쪼개면 중간 커밋이 빌드되지 않는다.
## CI 단계 분리 (워크플로 29파일 3,360줄 → 19파일 2,692줄, 잡 69 → 64)
모듈이 아니라 단계로 자른다. 기존 28개는 `web-*` `jpa-*` 처럼 모듈로 갈려
있어서 같은 일이 파일마다 중복됐다.
- stage 1 `ci-quality-gates.yml` + `pr-adapters.yml`(신규, 잡 단위 경로 필터) — pull_request
- stage 2 `integration-main.yml`(신규) — push:main + 03:00. 문서 게이트는 여기에 둔다
- stage 3 `release.yml`(신규, 릴리스 워크플로 7개 중 5개 흡수) — push: tags v*
setup 블록 59회 복붙 → `.github/actions/setup-gradle-java` 1개(잡당 13줄 → 5줄).
잡 8개 삭제, 각각 대체 잡을 확인했다. `verifyCleanArchitectureDependencies` 실행
횟수가 태그당 9 → 6, PR당 8 → 4 로 줄었다.
## 컨테이너 릴리스 신설
이미지를 만드는 것이 아무것도 없었다. Dockerfile 은 있었지만
build-push-action / bootBuildImage / jib 사용처가 0건이고, `*-release.yml` 8개는
테스트 후 아티팩트만 올렸다 — 이름만 릴리스였다.
Boot 레이어드 추출 + thin-JAR 엔트리포인트로 Dockerfile 을 고치고 릴리스
워크플로에 이미지 빌드·푸시·SBOM·스캔을 넣었다. 로컬 빌드로 검증했다:
레지스트리 content 241MB, 기동 3.7초, uid 1000, 헬스체크 200.
코드만 바뀐 릴리스는 7.68MB 만 재푸시한다(이전이라면 156MB).
CI 는 배포하지 않는다 — 매니페스트와 ArgoCD 는 별도 repo 로 간다.
## 게이트 정리
- gate-matrix 의 개수 고정 해제: `EXPECTED_GATE_COUNT=49` 와 하드코딩된 49개 id
목록을 지우고 불변식으로 대체(필드·enum, 워크플로/잡 실재, id 중복,
`release_blocking: true` 는 실제로 release-gate 의 needs 여야 함).
행을 추가하려면 테스트부터 고쳐야 하던 구조를 풀었다. 커버리지 8/28 → 28/28
- 문서 게이트 4개를 `check` 에서 떼어 `verifyDocumentationContracts` 로 묶고
stage 2 에 배치. 어겨도 런타임은 멀쩡하므로 개발을 막지 않는다
- `verifyOneTypePerFile`(정규식 Java 파싱, 126파일 미탐) → Checkstyle
`OneTopLevelClass` + `OuterTypeFilename`. main 위반 0건, test 의 fixture 29건은
정책을 넓히지 않고 suppressions 에 사유와 함께 명시 제외
- leaf 하나의 `check` 가 끌고 오던 저장소 전역 게이트 18개를 재배치.
`:domain-core:check` 가 13 태스크 11초로 끝난다
- convention 플러그인 2개 신설(`ca.platform-module`, `ca.grpc-platform-module`),
플랫폼 모듈 43개에 적용. 손수 짠 Test 태스크 17개를 `strictTestLanes` 로 전환
(태스크 이름 전부 보존 — CI 가 이름으로 부른다)
- `ca.api-surface` 의 정규식 Java 파서를 javac parse-only 로 교체
(기존 베이스라인 3개와 바이트 동일 확인)
- 죽은 태스크 5개 삭제, `src/gradle` 1,713 → 1,440줄, 모듈 build.gradle
3,072 → 2,977줄
## 검사가 검사를 못 하고 있던 것들
- 11개 계약 테스트가 gitignore 된 `src/.env` 를 요구했다. `.gitignore` 자신이
"examples beside it are the tracked contract, never a real one" 이라고 적어둔
규칙과 어긋난다. 깨끗한 체크아웃에는 그 파일이 없으므로 CI 에서 돌 수 없었다.
추적되는 `.env.example` 로 돌린다
- **`.env.local.example` 이 5432 를 가리키는데 compose 는 5433 을 게시한다.**
이 파일을 복사해 시작하는 신규 개발자는 DB 연결에 실패한다. 이걸 잡으라고
만든 테스트가 추적 안 되는 파일을 읽어서, 이미 설정이 끝난 머신에서만 돌고
정작 처음 받는 사람에겐 아무 검사도 안 하고 있었다. 포트를 고치고 테스트를
추적 파일로 돌렸다
- `MongoModuleBoundaryTest` 의 `DO_NOT_INCLUDE_JARS` 때문에 임포트가 0개가 되어
규칙 10개가 "failed to check any classes" 로 실패 중이었다. 이 레인에서는
모듈 자기 클래스가 jar 로 올라온다. `importPackages(ROOT)` 가 이미 서드파티를
거르므로 옵션은 불필요했다
- `ReleaseManifestTaskExistenceTest` 가 build 파일 텍스트에서 `tasks.register(`
만 찾아, convention 의 `lane('...')` 로 바뀐 태스크를 미등록으로 오판했다
- `ProfileSeparationContractTest` 는 런처가 주입하는 `src/.env` 가 맞는 대상이라
그대로 두되, 파일이 없으면 명시적으로 skip 한다 — "안 돌았다" 가 "통과했다"
로 읽히지 않게
## 검증 (전부 깨끗한 체크아웃에서, 커밋 전에 실행)
`verify-gradle-wrapper.sh` PASS · `verify-gate-matrix.sh` OK(drift 0) ·
워크플로 YAML 전수 파싱 OK · actionlint 지적 0 · `gradlew help` ·
`verifyCleanArchitectureDependencies` · `build-logic test` ·
`:app-bootstrap:test` **1001 tests 실패 0 스킵 5** · `:domain-core:check` ·
`verifyDocumentationContracts`.
## 남은 문제
- 첫 `v*` 태그는 이미지 취약점 스캔에서 실패한다(CRITICAL/HIGH 9건:
ubuntu 베이스 2, tomcat-embed-core 3, amqp-client 3, httpcore5 2).
억제를 넣지 않았다 — 릴리스 1회차를 초록으로 만들려고 임계값을 내리면
게이트가 장식이 된다. 의존성·베이스 갱신이 선행돼야 한다
- `fileserver-v*` / `web-v*` / `websocket-v*` 태그는 이제 아무 run 도 만들지
않는다(배포 단위가 하나라는 결정에 따른 것)
- main push 마다 무거운 레인 3개가 새로 돈다 — 러너 분이 늘어난다
- `ProfileSeparationContractTest` 가 찾아낸 4개 값(cache command-timeout,
cache positive-soft-ttl, idempotency provider, rate-limit command-timeout)이
`.env.example` 과 인라인 기본값 사이에서 갈린다. 런타임 설정 판단이라
건드리지 않았다
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1535481794
commit
9bc2e75fe5
@@ -101,14 +101,20 @@ adaptation rationale are in
|
||||
Focused tests use fakes for contract, key, catalog, and typed-facade behavior. R1/R2 promotion
|
||||
requires a separate real Redis service lane; it may never be silently skipped when selected.
|
||||
|
||||
`redisTopologyTest` is the only real-server lane. It is opt-in and fail-closed in seven ways: the
|
||||
lane must be one of `standalone`, `sentinel`, `cluster`, `tls`; the endpoint properties must be
|
||||
present (`sentinel` additionally needs `redis.topology.master`, `tls` needs
|
||||
`redis.topology.trust-material`); a test class carrying the lane's tag must exist; a run that
|
||||
executes zero tests fails; the classes the lane exists to run must actually have run; the executed
|
||||
count must reach the lane's declared floor; and a skipped test fails the run rather than counting
|
||||
as executed. `tls` is a lane, not a deployment mode — its shape is standalone and the task maps it
|
||||
so, because what it qualifies is the transport.
|
||||
`redisTopologyTest` is the only real-server lane, declared through `ca.strict-test-lane`. It is
|
||||
opt-in and fail-closed in five ways: the lane must be one of `standalone`, `sentinel`, `cluster`,
|
||||
`tls`; the endpoint properties must be present (`sentinel` additionally needs
|
||||
`redis.topology.master`, `tls` needs `redis.topology.trust-material`); a run that discovers or
|
||||
executes zero tests fails (the convention, not this leaf, owns that); the classes the lane exists to
|
||||
run must actually have run; and a skipped test fails the run rather than counting as executed.
|
||||
`tls` is a lane, not a deployment mode — its shape is standalone and the task maps it so, because
|
||||
what it qualifies is the transport.
|
||||
|
||||
Two former checks are gone. A scan of the test sources for the literal text `@Tag("redis-topology")`
|
||||
only improved the message for a failure `failOnNoDiscoveredTests` already produced, and a comment
|
||||
containing the same text satisfied it. A hand-maintained per-mode floor on the executed count
|
||||
(20/20/24/4) had to be edited whenever a case was added or removed, and the thing it failed on —
|
||||
"coverage shrank" — is not a runtime, deployment, data, security or compile failure.
|
||||
|
||||
## Composition
|
||||
|
||||
|
||||
@@ -4,12 +4,19 @@
|
||||
// registry outranks that layout, so the module boundaries are packages under
|
||||
// dev.caskeleton.adapter.outbound.cache.redis.sdk and RedisSdkModuleBoundaryTest enforces them.
|
||||
dependencies {
|
||||
// Registered edges the semantic port adapters need. The SDK itself imports nothing from them
|
||||
// today (0 imports across main source) — the semantic cache/session/idempotency/rate-limit
|
||||
// adapters that did were removed and are restored by Phase E of
|
||||
// docs/superpowers/plans/2026-08-10-redis-optionality-and-composition.md. They stay declared
|
||||
// because that restoration is the module's stated responsibility, not because anything here
|
||||
// compiles against them.
|
||||
// Registered edges the semantic port adapters need. The SDK's *main* source imports nothing from
|
||||
// them today — the semantic cache/session/idempotency/rate-limit adapters that did were removed
|
||||
// and are restored by Phase E of
|
||||
// docs/superpowers/plans/2026-08-10-redis-optionality-and-composition.md.
|
||||
//
|
||||
// Two of the three are nonetheless load-bearing right now, which the earlier wording hid: this
|
||||
// leaf declares no `testImplementation project(...)`, so `test` reaches
|
||||
// dev.caskeleton.application.* and dev.caskeleton.shared.* through these `implementation` edges
|
||||
// alone. Dropping either breaks compileTestJava today, not at Phase E.
|
||||
//
|
||||
// ':adapter:outbound:support' is the one with no reference in any source set; it stays for the
|
||||
// stated restoration reason, and that is the only one of the three for which that reason is
|
||||
// doing the work.
|
||||
implementation project(':application-core')
|
||||
implementation project(':shared-contract')
|
||||
implementation project(':adapter:outbound:support')
|
||||
@@ -39,8 +46,6 @@ dependencies {
|
||||
// Zero imports.
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
|
||||
|
||||
// The topology lane is opt-in and fail-closed. The default unit task excludes it, and selecting it
|
||||
// without an endpoint is an error rather than a skip: a topology test that silently passes because
|
||||
// it never connected is worse than not having one.
|
||||
@@ -68,9 +73,20 @@ tasks.named('test') {
|
||||
def REDIS_TOPOLOGY_MODES = ['standalone', 'sentinel', 'cluster', 'tls'] as Set
|
||||
def REDIS_TOPOLOGY_DEPLOYMENT_MODE = ['standalone': 'standalone', 'sentinel': 'sentinel',
|
||||
'cluster': 'cluster', 'tls': 'standalone']
|
||||
// The classes each lane exists to run, and the floor below which its coverage has shrunk. Both are
|
||||
// declarations rather than observations: a lane that lost a class to a rename, or lost half its
|
||||
// cases to a filter, otherwise still reports success.
|
||||
// The classes each lane exists to run. A declaration, not an observation: a lane that lost a class
|
||||
// to a rename otherwise still reports success on whatever remains.
|
||||
//
|
||||
// This list stays hand-written and that is a deliberate refusal, not an oversight. The repository's
|
||||
// own mechanism for "a named test must actually have run" is `strictTestLanes { lane { requires(…) } }`,
|
||||
// and ca.strict-test-lane refuses a lane that declares both a tag and required tests — "pick one
|
||||
// selection". This lane's selection is a tag expression computed from the mode, so `requires` is not
|
||||
// available to it. Deriving the list instead would mean reading @Tag off the compiled test classes,
|
||||
// which is a bytecode dependency a leaf build file should not grow.
|
||||
//
|
||||
// What did go: REDIS_TOPOLOGY_MINIMUM_TESTS, a per-mode floor of 20/20/24/4 that had to be edited
|
||||
// whenever a case was added or removed, and whose failure sentence was "coverage shrank" — not a
|
||||
// runtime, deployment, data, security or compile failure. The class list below covers the case that
|
||||
// mattered (a lane class silently leaving the lane); the number did not add a second one.
|
||||
def REDIS_TOPOLOGY_REQUIRED_CLASSES = [
|
||||
'standalone': ['LiveRedisCompositionTest', 'LiveRedisSemanticPortsTest',
|
||||
'RedisTopologyContractTest', 'LiveRedisGuardrailTest'],
|
||||
@@ -80,123 +96,99 @@ def REDIS_TOPOLOGY_REQUIRED_CLASSES = [
|
||||
'LiveRedisClusterTransactionTest', 'LiveRedisSemanticPortsTest'],
|
||||
'tls' : ['LiveRedisTlsTest'],
|
||||
]
|
||||
def REDIS_TOPOLOGY_MINIMUM_TESTS = ['standalone': 20, 'sentinel': 20, 'cluster': 24, 'tls': 4]
|
||||
|
||||
tasks.register('redisTopologyTest', Test) {
|
||||
description = 'Runs the Redis SDK contracts against a real topology declared in infra/redis-sdk.'
|
||||
group = 'verification'
|
||||
testClassesDirs = sourceSets.test.output.classesDirs
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
// Never up to date. This task's result depends on a server outside the build, so Gradle's
|
||||
// inputs say nothing about whether it would still pass: re-running it against a lane that was
|
||||
// restarted, reconfigured, or promoted reports the previous run's verdict as the current one.
|
||||
// That is the same silent-pass failure mode the fail-closed endpoint check exists to prevent.
|
||||
outputs.upToDateWhen { false }
|
||||
def declaredMode = (project.findProperty('redis.topology.mode') ?: 'unset').toString().toLowerCase()
|
||||
useJUnitPlatform {
|
||||
includeTags "redis-topology & lane-${declaredMode}".toString()
|
||||
}
|
||||
// A filter that matches nothing is a configuration mistake, never a pass.
|
||||
failOnNoDiscoveredTests = true
|
||||
['redis.topology.host', 'redis.topology.port',
|
||||
'redis.topology.master', 'redis.topology.username', 'redis.topology.password',
|
||||
'redis.topology.trust-material']
|
||||
.each { key ->
|
||||
if (project.hasProperty(key)) {
|
||||
systemProperty key, project.property(key)
|
||||
String declaredMode = (project.findProperty('redis.topology.mode') ?: 'unset').toString().toLowerCase()
|
||||
|
||||
// Declared through the convention rather than hand-rolled. `ca.strict-test-lane` owns
|
||||
// testClassesDirs, classpath, the tag filter, failOnNoDiscoveredTests, the refusal to serve an
|
||||
// up-to-date result, and the "executed nothing" check — the same six things this task spelled out.
|
||||
// What stays here is what is true of this lane only: the mode allowlist, the endpoint properties and
|
||||
// the class-coverage check.
|
||||
//
|
||||
// The @Tag source-text scan that used to sit in `doFirst` is gone. It read every .java file in the
|
||||
// test source set looking for the literal strings `@Tag("redis-topology")` and `@Tag("lane-<mode>")`,
|
||||
// which a comment satisfied and a tag held in a constant defeated — and by its own comment it only
|
||||
// improved the message for a failure `failOnNoDiscoveredTests` already produces.
|
||||
strictTestLanes {
|
||||
lane('redisTopologyTest') {
|
||||
tag = "redis-topology & lane-${declaredMode}".toString()
|
||||
description = 'Runs the Redis SDK contracts against a real topology declared in infra/redis-sdk.'
|
||||
customize = { test ->
|
||||
['redis.topology.host', 'redis.topology.port',
|
||||
'redis.topology.master', 'redis.topology.username', 'redis.topology.password',
|
||||
'redis.topology.trust-material']
|
||||
.each { String key ->
|
||||
if (project.hasProperty(key)) {
|
||||
test.systemProperty key, project.property(key)
|
||||
}
|
||||
}
|
||||
// The lane name and the deployment mode are different things, and only the TLS lane makes
|
||||
// that visible: its shape is standalone, so the tests must see `standalone` while the tag
|
||||
// filter and the required properties come from the lane. Passing the lane name through as
|
||||
// the mode would fail RedisDeploymentMode.valueOf on a value that is not a topology.
|
||||
test.systemProperty 'redis.topology.mode',
|
||||
REDIS_TOPOLOGY_DEPLOYMENT_MODE.getOrDefault(declaredMode, declaredMode)
|
||||
test.systemProperty 'redis.topology.tls', (declaredMode == 'tls').toString()
|
||||
|
||||
// Executed, not merely reported. `afterTest` fires for a skipped test too, so counting
|
||||
// every callback would let a lane whose tests all skipped satisfy the checks below.
|
||||
def skipped = new java.util.concurrent.atomic.AtomicInteger()
|
||||
def classes = java.util.Collections.synchronizedSet(new java.util.LinkedHashSet<String>())
|
||||
test.afterTest { descriptor, result ->
|
||||
if (result.resultType == org.gradle.api.tasks.testing.TestResult.ResultType.SKIPPED) {
|
||||
skipped.incrementAndGet()
|
||||
} else {
|
||||
classes.add(descriptor.className.tokenize('.').last())
|
||||
}
|
||||
}
|
||||
// The lane name and the deployment mode are different things, and only the TLS lane makes that
|
||||
// visible: its shape is standalone, so the tests must see `standalone` while the tag filter and
|
||||
// the required properties come from the lane. Passing the lane name through as the mode would
|
||||
// fail RedisDeploymentMode.valueOf on a value that is not a topology.
|
||||
systemProperty 'redis.topology.mode', REDIS_TOPOLOGY_DEPLOYMENT_MODE.getOrDefault(declaredMode, declaredMode)
|
||||
systemProperty 'redis.topology.tls', (declaredMode == 'tls').toString()
|
||||
|
||||
// Executed, not merely reported. `afterTest` fires for a skipped test too, so counting every
|
||||
// callback meant a lane whose tests all skipped could still satisfy the "ran something" check —
|
||||
// the exact green-for-nothing this gate exists to prevent, one level further in.
|
||||
def executed = new java.util.concurrent.atomic.AtomicInteger()
|
||||
def skipped = new java.util.concurrent.atomic.AtomicInteger()
|
||||
def classes = java.util.Collections.synchronizedSet(new java.util.LinkedHashSet<String>())
|
||||
afterTest { descriptor, result ->
|
||||
if (result.resultType == org.gradle.api.tasks.testing.TestResult.ResultType.SKIPPED) {
|
||||
skipped.incrementAndGet()
|
||||
} else {
|
||||
executed.incrementAndGet()
|
||||
classes.add(descriptor.className.tokenize('.').last())
|
||||
}
|
||||
}
|
||||
test.doFirst {
|
||||
if (!REDIS_TOPOLOGY_MODES.contains(declaredMode)) {
|
||||
throw new GradleException(
|
||||
"redisTopologyTest was selected with redis.topology.mode='${declaredMode}'; " +
|
||||
'the supported modes are ' + REDIS_TOPOLOGY_MODES.sort().join(', ') +
|
||||
'. An unrecognised mode selects no test and would otherwise report success.')
|
||||
}
|
||||
def required = ['redis.topology.host', 'redis.topology.port']
|
||||
if (declaredMode == 'sentinel') {
|
||||
required += 'redis.topology.master'
|
||||
}
|
||||
if (declaredMode == 'tls') {
|
||||
// Without the trust material the client would have to disable verification to
|
||||
// connect, and a TLS lane that trusts anything qualifies nothing.
|
||||
required += 'redis.topology.trust-material'
|
||||
}
|
||||
def missing = required.findAll { !project.hasProperty(it) }
|
||||
if (!missing.isEmpty()) {
|
||||
throw new GradleException(
|
||||
'redisTopologyTest was selected without ' + missing.join(', ') +
|
||||
'; start a lane from infra/redis-sdk and pass -P<key>=<value>.')
|
||||
}
|
||||
}
|
||||
|
||||
doFirst {
|
||||
if (!REDIS_TOPOLOGY_MODES.contains(declaredMode)) {
|
||||
throw new GradleException(
|
||||
"redisTopologyTest was selected with redis.topology.mode='${declaredMode}'; " +
|
||||
'the supported modes are ' + REDIS_TOPOLOGY_MODES.sort().join(', ') +
|
||||
'. An unrecognised mode selects no test and would otherwise report success.')
|
||||
test.doLast {
|
||||
// What a lane must cover, named rather than counted by accident. A tag filter matching
|
||||
// one trivial class satisfied "ran something" while the class the lane exists for had
|
||||
// been renamed out of the filter, and nothing said so.
|
||||
def absent = REDIS_TOPOLOGY_REQUIRED_CLASSES[declaredMode].findAll {
|
||||
!classes.contains(it)
|
||||
}
|
||||
if (!absent.isEmpty()) {
|
||||
throw new GradleException(
|
||||
"redisTopologyTest ran the ${declaredMode} lane without ${absent.join(', ')}. " +
|
||||
'These classes are what the lane qualifies; a run that skipped them proves ' +
|
||||
'less than the lane claims.')
|
||||
}
|
||||
if (skipped.get() > 0) {
|
||||
throw new GradleException(
|
||||
"redisTopologyTest skipped ${skipped.get()} test(s) on the ${declaredMode} " +
|
||||
'lane. A qualification lane has no conditional coverage: what it cannot prove ' +
|
||||
'must not be selected, and what is selected must run.')
|
||||
}
|
||||
test.logger.lifecycle(
|
||||
"redisTopologyTest: ${declaredMode} lane covered ${classes.size()} class(es).")
|
||||
}
|
||||
}
|
||||
def required = ['redis.topology.host', 'redis.topology.port']
|
||||
if (declaredMode == 'sentinel') {
|
||||
required += 'redis.topology.master'
|
||||
}
|
||||
if (declaredMode == 'tls') {
|
||||
// Without the trust material the client would have to disable verification to connect,
|
||||
// and a TLS lane that trusts anything qualifies nothing.
|
||||
required += 'redis.topology.trust-material'
|
||||
}
|
||||
def missing = required.findAll { !project.hasProperty(it) }
|
||||
if (!missing.isEmpty()) {
|
||||
throw new GradleException(
|
||||
'redisTopologyTest was selected without ' + missing.join(', ') +
|
||||
'; start a lane from infra/redis-sdk and pass -P<key>=<value>.')
|
||||
}
|
||||
// The lane's tag must actually exist in the compiled suite. failOnNoDiscoveredTests catches
|
||||
// an empty run, but this names the cause — a renamed or deleted lane class — instead of
|
||||
// leaving an operator to guess whether the filter or the server is at fault.
|
||||
def laneTag = "lane-${declaredMode}"
|
||||
def tagged = sourceSets.test.allJava.matching { include '**/*.java' }.files.any { file ->
|
||||
def text = file.text
|
||||
text.contains('@Tag("redis-topology")') && text.contains("@Tag(\"${laneTag}\")")
|
||||
}
|
||||
if (!tagged) {
|
||||
throw new GradleException(
|
||||
"redisTopologyTest found no test class tagged 'redis-topology' and " +
|
||||
"'${laneTag}'. The ${declaredMode} lane has no coverage to run, so a green " +
|
||||
'result would prove nothing.')
|
||||
}
|
||||
}
|
||||
|
||||
doLast {
|
||||
if (executed.get() < 1) {
|
||||
throw new GradleException(
|
||||
"redisTopologyTest completed without executing a single test for the " +
|
||||
"${declaredMode} lane. A qualification lane that runs nothing must not report " +
|
||||
'success.')
|
||||
}
|
||||
// What a lane must cover, named rather than counted by accident. A tag filter matching one
|
||||
// trivial class satisfied "ran something" while the class the lane exists for had been
|
||||
// renamed out of the filter, and nothing said so.
|
||||
def required = REDIS_TOPOLOGY_REQUIRED_CLASSES[declaredMode]
|
||||
def absent = required.findAll { !classes.contains(it) }
|
||||
if (!absent.isEmpty()) {
|
||||
throw new GradleException(
|
||||
"redisTopologyTest ran the ${declaredMode} lane without ${absent.join(', ')}. " +
|
||||
'These classes are what the lane qualifies; a run that skipped them proves ' +
|
||||
'less than the lane claims.')
|
||||
}
|
||||
def floor = REDIS_TOPOLOGY_MINIMUM_TESTS[declaredMode]
|
||||
if (executed.get() < floor) {
|
||||
throw new GradleException(
|
||||
"redisTopologyTest executed ${executed.get()} tests for the ${declaredMode} " +
|
||||
"lane, below the declared floor of ${floor}. Coverage that silently shrank is " +
|
||||
'a gate that silently weakened.')
|
||||
}
|
||||
if (skipped.get() > 0) {
|
||||
throw new GradleException(
|
||||
"redisTopologyTest skipped ${skipped.get()} test(s) on the ${declaredMode} " +
|
||||
'lane. A qualification lane has no conditional coverage: what it cannot prove ' +
|
||||
'must not be selected, and what is selected must run.')
|
||||
}
|
||||
logger.lifecycle("redisTopologyTest: ${declaredMode} lane executed ${executed.get()} tests.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ tasks.named('test', Test) {
|
||||
// and fails closed without it, and the BlockHound lane rewrites core JDK bytecode, which must
|
||||
// not be imposed on every unit run.
|
||||
useJUnitPlatform {
|
||||
excludeTags 'quarantine', 'httpclient-fault', 'httpclient-blockhound'
|
||||
excludeTags 'httpclient-fault', 'httpclient-blockhound'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +200,14 @@ strictTestLanes {
|
||||
description = 'Runs the SSRF, credential-leak, and cardinality suite (design §28.5, §28.7).'
|
||||
customize = { test -> applyContractSelection(test) }
|
||||
}
|
||||
// Spring 6.2 / 7.0 compatibility lanes. This repository's Spring Boot 4.0 baseline pins Spring
|
||||
// Framework 7, so the 6.2 lane verifies the *API surface* the common packages compile against
|
||||
// rather than executing on a 6.2 distribution; the limitation is recorded in
|
||||
// docs/httpclient/support-matrix.md instead of being hidden behind a green check.
|
||||
lane('spring62ApiSurfaceScan') {
|
||||
tag = 'httpclient-spring62-surface'
|
||||
description = 'Scans the common packages for Spring 6.2 API-surface confinement. NOT a 6.2 runtime.'
|
||||
}
|
||||
// Its own source set rather than a tag, so the source set is the selection.
|
||||
lane('httpClientPerformanceTest') {
|
||||
sourceSet = 'httpClientPerformanceTest'
|
||||
@@ -216,11 +224,15 @@ strictTestLanes {
|
||||
'(design §28.3).'
|
||||
customize = { test ->
|
||||
applyContractSelection(test)
|
||||
// The upstream image is mutable by default. Passing a digest here is what makes a red
|
||||
// fault run attributable to this repository rather than to someone else's image push.
|
||||
// The upstream image is mutable by default. A digest here is what makes a red fault run
|
||||
// attributable to this repository rather than to someone else's image push — and the
|
||||
// default said `:latest`, which is the exact thing this comment forbade. The digest is
|
||||
// the registry manifest digest of the image the lane has been running.
|
||||
test.systemProperty 'httpclient.fault.httpbin.image',
|
||||
(project.findProperty('httpclient.fault.httpbin.image')
|
||||
?: 'kennethreitz/httpbin:latest').toString()
|
||||
?: 'kennethreitz/httpbin@sha256:' +
|
||||
'599fe5e5073102dbb0ee3dbb65f049dab44fa9fc251f6835c9990f8fb196a72b')
|
||||
.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,20 +246,6 @@ tasks.register('jmh', JavaExec) {
|
||||
args '-rf', 'json', '-rff', layout.buildDirectory.file('reports/jmh/result.json').get().asFile.absolutePath
|
||||
}
|
||||
|
||||
// Spring 6.2 / 7.0 compatibility lanes. This repository's Spring Boot 4.0 baseline pins Spring
|
||||
// Framework 7, so the 6.2 lane verifies the *API surface* the common packages compile against
|
||||
// rather than executing on a 6.2 distribution; the limitation is recorded in
|
||||
// docs/httpclient/support-matrix.md instead of being hidden behind a green check.
|
||||
tasks.register('spring62ApiSurfaceScan', Test) {
|
||||
group = 'verification'
|
||||
description = 'Scans the common packages for Spring 6.2 API-surface confinement. NOT a 6.2 runtime.'
|
||||
testClassesDirs = sourceSets.test.output.classesDirs
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
useJUnitPlatform { includeTags 'httpclient-spring62-surface' }
|
||||
failOnNoDiscoveredTests = true
|
||||
outputs.upToDateWhen { false }
|
||||
}
|
||||
|
||||
// Which lanes gate an ordinary build, and which do not.
|
||||
//
|
||||
// The specialised lanes existed but hung off nothing: `check` ran only `test`, so the SSRF suite,
|
||||
|
||||
@@ -14,7 +14,3 @@ tasks.withType(GroovyCompile).configureEach {
|
||||
groovyOptions.encoding = 'UTF-8'
|
||||
options.encoding = 'UTF-8'
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
options.encoding = 'UTF-8'
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ dependencies {
|
||||
implementation 'org.slf4j:slf4j-api'
|
||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||
}
|
||||
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
|
||||
tasks.withType(Test).configureEach {
|
||||
systemProperty 'messaging.commonEvidenceSchema',
|
||||
rootProject.file('config/messaging/evidence/build-evidence-manifest-v1.schema.json')
|
||||
|
||||
@@ -48,7 +48,6 @@ dependencies {
|
||||
|
||||
testImplementation 'io.projectreactor:reactor-test'
|
||||
}
|
||||
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
|
||||
|
||||
// The exclusion above, stated as something the build verifies rather than something a comment
|
||||
// asserts. verifyDependencyPolicy resolves runtimeClasspath and fails if the coordinate is present.
|
||||
|
||||
@@ -2,17 +2,30 @@
|
||||
// provider. Canonical app.object-storage activation is disabled by default. The old whole-byte[]
|
||||
// filesystem/S3 adapters remain isolated, explicit legacy compatibility only.
|
||||
//
|
||||
// software.amazon.awssdk:* versions are NOT managed by the Spring Boot BOM, and this repo has no
|
||||
// version catalog, so the AWS SDK v2 BOM platform is imported HERE (module scope) using the root
|
||||
// `ext.awsSdkVersion` SSOT — this keeps the strict-locking blast radius to this module (the shared
|
||||
// root dependencyManagement block stays awssdk-free), mirroring the grpc module's grpc-bom import.
|
||||
// software.amazon.awssdk:* versions are NOT managed by the Spring Boot BOM, so the AWS SDK v2 BOM
|
||||
// platform is imported HERE (module scope) using the root `ext.awsSdkVersion` SSOT — this keeps the
|
||||
// strict-locking blast radius to this module (the shared root dependencyManagement block stays
|
||||
// awssdk-free), mirroring the grpc module's grpc-bom import.
|
||||
//
|
||||
// This sentence used to end "and this repo has no version catalog". That is false, and this file
|
||||
// disproves it twice below with `libs.archunit.junit5` and `libs.jqwik`. Module scope is a locking
|
||||
// decision; the catalog just has no awssdk entry.
|
||||
description = 'Outbound adapter: object storage (S3/MinIO + local filesystem)'
|
||||
|
||||
|
||||
// The three qualification source sets compile against `main` only.
|
||||
//
|
||||
// They used to name `'test'` as well, which is what ADR-BUILD-001's testFixtures migration exists to
|
||||
// remove: a source set that reaches into another source set's output directory instead of a
|
||||
// consumable variant. The migration's answer elsewhere in this repository is `java-test-fixtures`,
|
||||
// and that is deliberately NOT what happened here, because there is nothing to publish — these three
|
||||
// source sets import no type from `dev.caskeleton` at all (they drive MinIO and S3 through the AWS
|
||||
// SDK), so the `test` edge was carrying nothing and a `testFixtures` variant would have been an
|
||||
// empty one. Verified by compiling all three with the edge removed.
|
||||
strictTestLanes {
|
||||
sourceSet('objectStorageMinioContractTest') { compilesAgainst 'main', 'test' }
|
||||
sourceSet('objectStorageMinioFaultTest') { compilesAgainst 'main', 'test' }
|
||||
sourceSet('objectStorageAwsQualificationTest') { compilesAgainst 'main', 'test' }
|
||||
sourceSet('objectStorageMinioContractTest') { compilesAgainst 'main' }
|
||||
sourceSet('objectStorageMinioFaultTest') { compilesAgainst 'main' }
|
||||
sourceSet('objectStorageAwsQualificationTest') { compilesAgainst 'main' }
|
||||
}
|
||||
|
||||
dependencyManagement {
|
||||
|
||||
@@ -99,89 +99,127 @@ dependencies {
|
||||
jpaPlatformPerformanceTestRuntimeOnly 'org.postgresql:postgresql'
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
|
||||
|
||||
def registerPostgreSqlReadinessTest = { String taskName, String testClass ->
|
||||
tasks.register(taskName, Test) {
|
||||
group = 'verification'
|
||||
// The fourteen no-skip PostgreSQL readiness lanes, declared rather than assembled.
|
||||
//
|
||||
// They were fourteen calls to a local `tasks.register(..., Test)` factory that re-spelled the five
|
||||
// lines `ca.strict-test-lane` owns. The convention adds what the factory could not: naming the test
|
||||
// through `requires(...)` turns on `failOnNoMatchingTests` AND the post-run check that the named
|
||||
// selector actually executed, so a renamed readiness class fails its lane instead of leaving it
|
||||
// with nothing to run.
|
||||
String readinessPackage = 'dev.caskeleton.adapter.outbound.persistence.readiness'
|
||||
Map<String, String> postgresqlReadinessLanes = [
|
||||
postgresqlLifecycleIntegrationTest : 'PostgreSqlLifecycleIntegrationTest',
|
||||
postgresqlSecurityBaselineIntegrationTest : 'PostgreSqlSecurityBaselineIntegrationTest',
|
||||
postgresqlMigrationIntegrationTest : 'PostgreSqlMigrationIntegrationTest',
|
||||
postgresqlTransactionIntegrationTest : 'PostgreSqlTransactionIntegrationTest',
|
||||
postgresqlAggregateIntegrationTest : 'PostgreSqlAggregateIntegrationTest',
|
||||
postgresqlQueryIntegrationTest : 'PostgreSqlQueryIntegrationTest',
|
||||
postgresqlIdempotencyIntegrationTest : 'PostgreSqlIdempotencyIntegrationTest',
|
||||
postgresqlOutboxStorageIntegrationTest : 'PostgreSqlOutboxStorageIntegrationTest',
|
||||
postgresqlOutboxPollingIntegrationTest : 'PostgreSqlOutboxPollingIntegrationTest',
|
||||
postgresqlInboxIntegrationTest : 'PostgreSqlInboxIntegrationTest',
|
||||
postgresqlFileserverMigrationIntegrationTest : 'PostgreSqlFileserverMigrationIntegrationTest',
|
||||
postgresqlFileserverMetadataIntegrationTest : 'PostgreSqlFileserverMetadataStoreIntegrationTest',
|
||||
postgresqlFileserverReclamationIntegrationTest : 'PostgreSqlFileserverReclamationIntegrationTest',
|
||||
// The notification stream is opt-in and lives outside the default Flyway location, so "is it
|
||||
// applied and promoted" is a real deployment question with a real wrong answer. This lane
|
||||
// asks it against a real server; the entity-scan half is a unit test.
|
||||
postgresqlNotificationSchemaActivationIntegrationTest :
|
||||
'PostgreSqlNotificationSchemaActivationIntegrationTest'
|
||||
]
|
||||
postgresqlReadinessLanes.each { String taskName, String simpleName ->
|
||||
String testClass = "${readinessPackage}.${simpleName}"
|
||||
strictTestLanes.lane(taskName) {
|
||||
sourceSet = 'postgresqlIntegrationTest'
|
||||
description = "Runs the no-skip real PostgreSQL readiness scenario ${testClass}."
|
||||
testClassesDirs = sourceSets.postgresqlIntegrationTest.output.classesDirs
|
||||
classpath = sourceSets.postgresqlIntegrationTest.runtimeClasspath
|
||||
useJUnitPlatform()
|
||||
filter {
|
||||
includeTestsMatching testClass
|
||||
requires(testClass)
|
||||
customize = { test ->
|
||||
test.jvmArgs(
|
||||
'-Duser.timezone=UTC',
|
||||
"-Djpa.evidence.postgresql.image=${jpaPostgreSqlEvidenceImage}")
|
||||
}
|
||||
failOnNoDiscoveredTests = true
|
||||
outputs.upToDateWhen { false }
|
||||
jvmArgs(
|
||||
'-Duser.timezone=UTC',
|
||||
"-Djpa.evidence.postgresql.image=${jpaPostgreSqlEvidenceImage}")
|
||||
}
|
||||
}
|
||||
|
||||
def postgresqlLifecycleIntegrationTest = registerPostgreSqlReadinessTest(
|
||||
'postgresqlLifecycleIntegrationTest',
|
||||
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlLifecycleIntegrationTest')
|
||||
def postgresqlSecurityBaselineIntegrationTest = registerPostgreSqlReadinessTest(
|
||||
'postgresqlSecurityBaselineIntegrationTest',
|
||||
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlSecurityBaselineIntegrationTest')
|
||||
def postgresqlMigrationIntegrationTest = registerPostgreSqlReadinessTest(
|
||||
'postgresqlMigrationIntegrationTest',
|
||||
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlMigrationIntegrationTest')
|
||||
def postgresqlTransactionIntegrationTest = registerPostgreSqlReadinessTest(
|
||||
'postgresqlTransactionIntegrationTest',
|
||||
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlTransactionIntegrationTest')
|
||||
def postgresqlAggregateIntegrationTest = registerPostgreSqlReadinessTest(
|
||||
'postgresqlAggregateIntegrationTest',
|
||||
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlAggregateIntegrationTest')
|
||||
def postgresqlQueryIntegrationTest = registerPostgreSqlReadinessTest(
|
||||
'postgresqlQueryIntegrationTest',
|
||||
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlQueryIntegrationTest')
|
||||
def postgresqlIdempotencyIntegrationTest = registerPostgreSqlReadinessTest(
|
||||
'postgresqlIdempotencyIntegrationTest',
|
||||
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlIdempotencyIntegrationTest')
|
||||
def postgresqlOutboxStorageIntegrationTest = registerPostgreSqlReadinessTest(
|
||||
'postgresqlOutboxStorageIntegrationTest',
|
||||
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlOutboxStorageIntegrationTest')
|
||||
def postgresqlOutboxPollingIntegrationTest = registerPostgreSqlReadinessTest(
|
||||
'postgresqlOutboxPollingIntegrationTest',
|
||||
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlOutboxPollingIntegrationTest')
|
||||
def postgresqlInboxIntegrationTest = registerPostgreSqlReadinessTest(
|
||||
'postgresqlInboxIntegrationTest',
|
||||
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlInboxIntegrationTest')
|
||||
def postgresqlFileserverMigrationIntegrationTest = registerPostgreSqlReadinessTest(
|
||||
'postgresqlFileserverMigrationIntegrationTest',
|
||||
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlFileserverMigrationIntegrationTest')
|
||||
def postgresqlFileserverMetadataIntegrationTest = registerPostgreSqlReadinessTest(
|
||||
'postgresqlFileserverMetadataIntegrationTest',
|
||||
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlFileserverMetadataStoreIntegrationTest')
|
||||
def postgresqlFileserverReclamationIntegrationTest = registerPostgreSqlReadinessTest(
|
||||
'postgresqlFileserverReclamationIntegrationTest',
|
||||
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlFileserverReclamationIntegrationTest')
|
||||
// The notification stream is opt-in and lives outside the default Flyway location, so "is it
|
||||
// applied and promoted" is a real deployment question with a real wrong answer. This lane asks it
|
||||
// against a real server; the entity-scan half is a unit test.
|
||||
def postgresqlNotificationSchemaActivationIntegrationTest = registerPostgreSqlReadinessTest(
|
||||
'postgresqlNotificationSchemaActivationIntegrationTest',
|
||||
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlNotificationSchemaActivationIntegrationTest')
|
||||
// The rule verifyJpaSecurityFixtures was reaching for is now a selector.
|
||||
//
|
||||
// It read PostgreSqlSecurityBaselineIntegrationTest.java as text and failed when three strings were
|
||||
// absent from it — which three strings sitting in a comment would have satisfied, and which said
|
||||
// nothing about whether the scenario ran. Naming the method on the lane means the convention fails
|
||||
// when the runtime-role denial scenario is renamed or stops executing.
|
||||
strictTestLanes.lanes.named('postgresqlSecurityBaselineIntegrationTest').configure { lane ->
|
||||
lane.requires("${readinessPackage}.PostgreSqlSecurityBaselineIntegrationTest" +
|
||||
'.runtimeRoleCannotCreateInApplicationSchema')
|
||||
}
|
||||
|
||||
def verifyJpaSqlConstructionSafety = tasks.register('verifyJpaSqlConstructionSafety') {
|
||||
// The task itself stays, and its name is not negotiable: config/jpa/readiness-cards.yaml lists it as
|
||||
// a support task of the `jpa-security-baseline` card, and gradle/jpa-evidence.gradle resolves every
|
||||
// listed path through `tasks.findByName` and fails the build when one is missing. What changed is
|
||||
// what it checks. Grepping the fixture's source text for 'runtimeRoleCannotCreateInApplicationSchema',
|
||||
// 'assertDockerAvailable' and '42501' passed on three strings in a comment and proved nothing about
|
||||
// execution; the lane's `requires(...)` above is the thing that now enforces the scenario, so this
|
||||
// task verifies that the enforcement is declared rather than re-deriving it from source text.
|
||||
tasks.register('verifyJpaSecurityFixtures') {
|
||||
group = 'verification'
|
||||
description = 'Rejects concatenated SQL construction and non-parameterized PostgreSQL timeout configuration.'
|
||||
File vendorSource = file('src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql')
|
||||
inputs.dir(vendorSource)
|
||||
description = 'Verifies the no-skip PostgreSQL security lane still names the runtime-role namespace denial scenario.'
|
||||
String laneName = 'postgresqlSecurityBaselineIntegrationTest'
|
||||
String requiredSelector = "${readinessPackage}.PostgreSqlSecurityBaselineIntegrationTest" +
|
||||
'.runtimeRoleCannotCreateInApplicationSchema'
|
||||
// A live reference to the lane spec's own list, captured at configuration time. Reading it in
|
||||
// `doLast` therefore sees the final declaration without touching `Task.project` at execution
|
||||
// time, which Gradle 9 deprecates and the `--warning-mode=fail` gates reject.
|
||||
List<String> declaredSelectors = strictTestLanes.lanes.getByName(laneName).requiredTests
|
||||
doLast {
|
||||
if (!declaredSelectors.contains(requiredSelector)) {
|
||||
throw new GradleException(
|
||||
"verifyJpaSecurityFixtures: strict test lane '${laneName}' no longer requires " +
|
||||
"'${requiredSelector}'. Without that selector the lane can run the " +
|
||||
'security baseline class with the runtime-role namespace denial scenario ' +
|
||||
"renamed or deleted and still report success. It requires ${declaredSelectors}.")
|
||||
}
|
||||
logger.lifecycle(
|
||||
"verifyJpaSecurityFixtures: OK — '${laneName}' requires the runtime-role namespace denial scenario.")
|
||||
}
|
||||
}
|
||||
|
||||
// verifyJpaSqlConstructionSafety keeps its name for the same registry reason, and gives up the half
|
||||
// of its job that a real tool already does.
|
||||
//
|
||||
// It used to also match `(createNativeQuery|queryForObject|update)\s*\([^;]*"\s*\+` against Java
|
||||
// source text: a regex that matches any method named `update`, and that stops at the first `;`
|
||||
// inside a string literal, so it over- and under-reported at once. Concatenated SQL is covered
|
||||
// repo-wide and inter-procedurally by FindSecBugs, which the root build puts on every leaf
|
||||
// (`spotbugsPlugins libs.findsecbugs.plugin`) with SpotBugs' `ignoreFailures` left at its blocking
|
||||
// default and no SQL_INJECTION / SQL_NONCONSTANT exclusion in config/spotbugs/exclude.xml. A
|
||||
// bytecode dataflow check with no package restriction is strictly better than that regex, so the
|
||||
// regex is gone rather than duplicated.
|
||||
//
|
||||
// What no tool covers is the PostgreSQL-specific rule: a `set_config` value must be bound, never
|
||||
// interpolated, because that value carries the tenant id and the search_path. That check stays, and
|
||||
// two things about it changed. It no longer parses Java — it matches the SQL token `set_config('`
|
||||
// and asks whether the same line binds a parameter. And it scans the whole main source root: it was
|
||||
// pinned to `.../persistence/postgresql`, which is why it never saw the two real call sites in
|
||||
// experimental/rls/RlsTenantSessionBinder.java and
|
||||
// experimental/schema/SchemaMultiTenantConnectionProvider.java.
|
||||
tasks.register('verifyJpaSqlConstructionSafety') {
|
||||
group = 'verification'
|
||||
description = 'Rejects non-parameterized PostgreSQL set_config values anywhere in this leaf.'
|
||||
File mainSource = file('src/main/java')
|
||||
inputs.dir(mainSource)
|
||||
doLast {
|
||||
List<String> violations = []
|
||||
vendorSource.eachFileRecurse { File source ->
|
||||
mainSource.eachFileRecurse { File source ->
|
||||
if (!source.name.endsWith('.java')) {
|
||||
return
|
||||
}
|
||||
String text = source.getText('UTF-8')
|
||||
def concatenatedSql = text =~ /(?s)(createNativeQuery|queryForObject|update)\s*\([^;]*"\s*\+/
|
||||
if (concatenatedSql.find()) {
|
||||
violations << "${source}: concatenated SQL construction"
|
||||
}
|
||||
source.readLines().eachWithIndex { String line, int index ->
|
||||
String trimmed = line.trim()
|
||||
// Javadoc and line comments mention set_config to explain why it is used; a comment
|
||||
// is not a call site, and treating one as a violation is how a correct build turns
|
||||
// red for a sentence.
|
||||
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) {
|
||||
return
|
||||
}
|
||||
if (line.contains("set_config('") && !line.contains('?')) {
|
||||
violations << "${source}:${index + 1}: set_config value is not parameterized"
|
||||
}
|
||||
@@ -193,36 +231,13 @@ def verifyJpaSqlConstructionSafety = tasks.register('verifyJpaSqlConstructionSaf
|
||||
violations.join('\n '))
|
||||
}
|
||||
logger.lifecycle(
|
||||
'verifyJpaSqlConstructionSafety: OK — no concatenated SQL construction and all set_config values are parameterized.')
|
||||
"verifyJpaSqlConstructionSafety: OK — every set_config value in ${mainSource} binds a parameter.")
|
||||
}
|
||||
}
|
||||
|
||||
def verifyJpaSecurityFixtures = tasks.register('verifyJpaSecurityFixtures') {
|
||||
group = 'verification'
|
||||
description = 'Verifies the no-skip PostgreSQL security fixture covers runtime-role namespace denial.'
|
||||
File fixture = file(
|
||||
'src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlSecurityBaselineIntegrationTest.java')
|
||||
inputs.file(fixture)
|
||||
doLast {
|
||||
if (!fixture.isFile()) {
|
||||
throw new GradleException("verifyJpaSecurityFixtures: missing ${fixture}")
|
||||
}
|
||||
String text = fixture.getText('UTF-8')
|
||||
['runtimeRoleCannotCreateInApplicationSchema', 'assertDockerAvailable', '42501'].each {
|
||||
String required ->
|
||||
if (!text.contains(required)) {
|
||||
throw new GradleException(
|
||||
"verifyJpaSecurityFixtures: ${fixture} is missing '${required}'")
|
||||
}
|
||||
}
|
||||
logger.lifecycle(
|
||||
'verifyJpaSecurityFixtures: OK — no-skip Docker and runtime-role namespace denial fixtures are present.')
|
||||
}
|
||||
}
|
||||
|
||||
postgresqlSecurityBaselineIntegrationTest.configure {
|
||||
dependsOn verifyJpaSqlConstructionSafety
|
||||
dependsOn verifyJpaSecurityFixtures
|
||||
tasks.named('postgresqlSecurityBaselineIntegrationTest') {
|
||||
// Cross-leaf task edge: see the handoff. verifyCleanArchitectureDependencies inspects
|
||||
// configurations, not the task graph, so this edge is invisible to it.
|
||||
dependsOn project(':adapter:inbound:web').tasks.named('jpaPersistenceRedactionContractTest')
|
||||
}
|
||||
|
||||
@@ -233,46 +248,33 @@ postgresqlSecurityBaselineIntegrationTest.configure {
|
||||
// Every lane fails closed. `failOnNoDiscoveredTests` matters more here than usual: a selected lane
|
||||
// that discovers nothing reports success, and a contract suite that silently stopped running is
|
||||
// indistinguishable from one that passes.
|
||||
Closure<Void> registerJpaPlatformLane = { String taskName, String tag, String description ->
|
||||
tasks.register(taskName, Test) {
|
||||
group = 'verification'
|
||||
it.description = description
|
||||
testClassesDirs = sourceSets.postgresqlIntegrationTest.output.classesDirs
|
||||
classpath = sourceSets.postgresqlIntegrationTest.runtimeClasspath
|
||||
useJUnitPlatform {
|
||||
includeTags tag
|
||||
Map<String, List<String>> jpaPlatformLanes = [
|
||||
jpaPlatformContractTest : ['jpa-contract',
|
||||
'Runs the JPA platform contract suite against real PostgreSQL (design §40).'],
|
||||
jpaPlatformMigrationTest : ['jpa-migration',
|
||||
'Runs the Flyway upgrade snapshot scenarios (design §31).'],
|
||||
jpaPlatformFailureTest : ['jpa-failure',
|
||||
'Reproduces deadlock, serialization, and commit-ambiguity failures (design §39).'],
|
||||
jpaPlatformQueryPlanTest : ['jpa-queryplan',
|
||||
'Asserts query plan structure and planner estimate error (design §33).'],
|
||||
jpaPlatformSecurityTest : ['jpa-security',
|
||||
'Verifies runtime role privileges and search_path safety (design §36).']
|
||||
]
|
||||
jpaPlatformLanes.each { String taskName, List<String> spec ->
|
||||
strictTestLanes.lane(taskName) {
|
||||
sourceSet = 'postgresqlIntegrationTest'
|
||||
tag = spec[0]
|
||||
description = spec[1]
|
||||
customize = { test ->
|
||||
test.jvmArgs('-Duser.timezone=UTC')
|
||||
// The Stable matrix selection. An unknown or empty value is an error in
|
||||
// PostgreSqlVersion.parseSelection rather than an empty run.
|
||||
test.systemProperty 'jpa.matrix.versions',
|
||||
(project.findProperty('jpa.matrix.versions') ?: '16').toString()
|
||||
}
|
||||
failOnNoDiscoveredTests = true
|
||||
outputs.upToDateWhen { false }
|
||||
jvmArgs('-Duser.timezone=UTC')
|
||||
// The Stable matrix selection. An unknown or empty value is an error in
|
||||
// PostgreSqlVersion.parseSelection rather than an empty run.
|
||||
systemProperty 'jpa.matrix.versions',
|
||||
(project.findProperty('jpa.matrix.versions') ?: '16').toString()
|
||||
}
|
||||
}
|
||||
|
||||
def jpaPlatformContractTest = registerJpaPlatformLane(
|
||||
'jpaPlatformContractTest',
|
||||
'jpa-contract',
|
||||
'Runs the JPA platform contract suite against real PostgreSQL (design §40).')
|
||||
def jpaPlatformMigrationTest = registerJpaPlatformLane(
|
||||
'jpaPlatformMigrationTest',
|
||||
'jpa-migration',
|
||||
'Runs the Flyway upgrade snapshot scenarios (design §31).')
|
||||
def jpaPlatformFailureTest = registerJpaPlatformLane(
|
||||
'jpaPlatformFailureTest',
|
||||
'jpa-failure',
|
||||
'Reproduces deadlock, serialization, and commit-ambiguity failures (design §39).')
|
||||
def jpaPlatformQueryPlanTest = registerJpaPlatformLane(
|
||||
'jpaPlatformQueryPlanTest',
|
||||
'jpa-queryplan',
|
||||
'Asserts query plan structure and planner estimate error (design §33).')
|
||||
def jpaPlatformSecurityTest = registerJpaPlatformLane(
|
||||
'jpaPlatformSecurityTest',
|
||||
'jpa-security',
|
||||
'Verifies runtime role privileges and search_path safety (design §36).')
|
||||
|
||||
// The pool behaviour contract. Named for what it does.
|
||||
//
|
||||
// It was `jpaPlatformPerformanceTest`, described as certifying pool and REQUIRES_NEW pressure, and
|
||||
@@ -289,15 +291,12 @@ def jpaPlatformSecurityTest = registerJpaPlatformLane(
|
||||
// — and this name does not promise a number nobody measured. A real performance gate needs a
|
||||
// dedicated runner, warmup and sample counts, and recorded thresholds; when that exists it belongs
|
||||
// in a lane of its own rather than behind a boolean on this one.
|
||||
def jpaPlatformPoolContractTest = tasks.register('jpaPlatformPoolContractTest', Test) {
|
||||
group = 'verification'
|
||||
description = 'Verifies Hikari pool and REQUIRES_NEW connection behaviour (design §38).'
|
||||
testClassesDirs = sourceSets.jpaPlatformPerformanceTest.output.classesDirs
|
||||
classpath = sourceSets.jpaPlatformPerformanceTest.runtimeClasspath
|
||||
useJUnitPlatform()
|
||||
failOnNoDiscoveredTests = true
|
||||
outputs.upToDateWhen { false }
|
||||
jvmArgs('-Duser.timezone=UTC')
|
||||
strictTestLanes {
|
||||
lane('jpaPlatformPoolContractTest') {
|
||||
sourceSet = 'jpaPlatformPerformanceTest'
|
||||
description = 'Verifies Hikari pool and REQUIRES_NEW connection behaviour (design §38).'
|
||||
customize = { test -> test.jvmArgs('-Duser.timezone=UTC') }
|
||||
}
|
||||
}
|
||||
|
||||
// The JPA release gate (design §41). Aggregates every lane whose absence would let one of the
|
||||
@@ -306,12 +305,8 @@ tasks.register('jpaPlatformReleaseGate') {
|
||||
group = 'verification'
|
||||
description = 'Runs every JPA platform lane required for a release (design §41).'
|
||||
dependsOn tasks.named('test')
|
||||
dependsOn jpaPlatformContractTest
|
||||
dependsOn jpaPlatformMigrationTest
|
||||
dependsOn jpaPlatformFailureTest
|
||||
dependsOn jpaPlatformQueryPlanTest
|
||||
dependsOn jpaPlatformSecurityTest
|
||||
dependsOn jpaPlatformPoolContractTest
|
||||
jpaPlatformLanes.keySet().each { String laneName -> dependsOn tasks.named(laneName) }
|
||||
dependsOn tasks.named('jpaPlatformPoolContractTest')
|
||||
}
|
||||
|
||||
// The unit lane reads three files that are not Java sources: the release registry and its two
|
||||
|
||||
@@ -105,8 +105,7 @@ Closure<Void> applyMongoImageSelection = { task ->
|
||||
// comment.
|
||||
tasks.named('test', Test) {
|
||||
useJUnitPlatform {
|
||||
excludeTags 'quarantine',
|
||||
'mongodb-contract',
|
||||
excludeTags 'mongodb-contract',
|
||||
'mongodb-replicaset',
|
||||
'mongodb-failover',
|
||||
'mongodb-migration',
|
||||
@@ -149,6 +148,12 @@ strictTestLanes {
|
||||
customize = { test -> applyMongoImageSelection(test) }
|
||||
}
|
||||
|
||||
lane('mongoStableContractTest') {
|
||||
tag = 'mongodb-contract'
|
||||
description = 'Hermetic stable contract suite: manifests, guardrails, retry scopes, ' +
|
||||
'redaction (design §30).'
|
||||
}
|
||||
|
||||
// Driven by its own source set rather than a tag: for this shape the source set is the
|
||||
// selection, so the convention asks for no tag.
|
||||
lane('mongoPerformanceTest') {
|
||||
@@ -259,16 +264,6 @@ tasks.register('verifyMongoReleaseContractLanes') {
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('mongoStableContractTest', Test) {
|
||||
group = 'verification'
|
||||
description = 'Hermetic stable contract suite: manifests, guardrails, retry scopes, ' +
|
||||
'redaction (design §30).'
|
||||
testClassesDirs = sourceSets.test.output.classesDirs
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
useJUnitPlatform { includeTags 'mongodb-contract' }
|
||||
failOnNoDiscoveredTests = true
|
||||
outputs.upToDateWhen { false }
|
||||
}
|
||||
|
||||
// verifyMongoApiSurface — every public type this leaf exposes is a committed decision.
|
||||
//
|
||||
|
||||
+8
-1
@@ -104,10 +104,17 @@ class MongoModuleBoundaryTest {
|
||||
java.util.Map.entry("architecture", java.util.Set.of("api")),
|
||||
java.util.Map.entry("<root>", java.util.Set.of("api", "autoconfigure")));
|
||||
|
||||
// DO_NOT_INCLUDE_JARS is deliberately NOT set. On this lane the module's own production classes
|
||||
// reach the test classpath as a jar rather than as build/classes/java/main, so excluding jars
|
||||
// emptied the import and every rule below failed with "failed to check any classes" — ten rules
|
||||
// that read as architecture enforcement while enforcing nothing.
|
||||
//
|
||||
// Restricting the import to ROOT is what keeps third-party jars out; the option was never what
|
||||
// made this scan cheap. The rules are also left WITHOUT allowEmptyShould, so an empty import
|
||||
// keeps failing loudly instead of passing green.
|
||||
private static final JavaClasses PLATFORM =
|
||||
new ClassFileImporter()
|
||||
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
|
||||
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS)
|
||||
.importPackages(ROOT);
|
||||
|
||||
@Test
|
||||
|
||||
@@ -4,7 +4,3 @@ dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-autoconfigure'
|
||||
implementation 'org.slf4j:slf4j-api'
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
options.encoding = 'UTF-8'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user