빌드·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>
294 lines
16 KiB
Groovy
294 lines
16 KiB
Groovy
// Shared test code as a Gradle test-fixtures variant — ADR-BUILD-001. Applied here rather than
|
|
// from the root, the way the GraphQL leaf does: only a leaf that has shared test code needs it.
|
|
apply plugin: 'java-test-fixtures'
|
|
|
|
// MongoDB Document Persistence Platform leaf — see
|
|
// docs/superpowers/specs/2026-08-11-mongodb-document-persistence-platform-design.md (design package)
|
|
// and docs/mongodb/repository-adaptation.md (how the design's 19 stable + 12 advanced library
|
|
// modules map here).
|
|
//
|
|
// The design models the platform as 19 Stable and 12 Advanced Gradle modules under
|
|
// `modules/mongodb` and `modules/mongodb-advanced`. This repository's fail-closed module registry
|
|
// (src/config/architecture/modules.json) outranks that layout, so the module boundaries are
|
|
// packages under dev.caskeleton.adapter.outbound.mongo. MongoModuleBoundaryTest holds a closed
|
|
// edge matrix — every package and what it may import — compares it against the tree for exact
|
|
// equality, and rejects any observed edge that is not declared.
|
|
//
|
|
// Driver and Spring Data MongoDB versions come from the Spring Boot BOM applied to every module in
|
|
// src/build.gradle (design §4: "개별 Driver 버전 override 금지"), so nothing here pins them.
|
|
description = 'Outbound adapter: MongoDB document persistence platform (manifests, atomic writes, ' +
|
|
'consistency profiles, guardrails, change streams)'
|
|
|
|
dependencies {
|
|
// D1/D2 imperative execution path and the mapping subsystem.
|
|
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
|
|
// D1/D2 reactive execution path: reactive template, cursors and change streams (design §20).
|
|
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb-reactive'
|
|
// The starter package registers auto-configuration and binds typed properties.
|
|
implementation 'org.springframework.boot:spring-boot-autoconfigure'
|
|
// Driver-native observability conventions (design §27) publish through Micrometer.
|
|
implementation 'io.micrometer:micrometer-core'
|
|
implementation 'org.slf4j:slf4j-api'
|
|
|
|
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
|
|
|
// The design's module dependency table is enforced as package rules, so ArchUnit is what keeps
|
|
// "packages instead of modules" from meaning "no boundary at all".
|
|
testImplementation libs.archunit.junit5
|
|
testImplementation 'io.projectreactor:reactor-test'
|
|
// Real replica set / failover / migration lanes (design §29). Test-scoped so no production
|
|
// package can reach a container fixture.
|
|
testImplementation 'org.testcontainers:testcontainers'
|
|
testImplementation 'org.testcontainers:testcontainers-junit-jupiter'
|
|
testImplementation 'org.testcontainers:testcontainers-mongodb'
|
|
testImplementation 'org.testcontainers:testcontainers-toxiproxy'
|
|
}
|
|
|
|
// Shared test code lives in src/testFixtures, per ADR-BUILD-001. This leaf is the first migration
|
|
// off `ca.testkit-publisher`: one leaf, two lanes, no cross-module consumer — the proof that the
|
|
// path works before the published testkits follow.
|
|
//
|
|
// `java-test-fixtures` creates the source set and puts it on `test`'s classpath itself, so neither a
|
|
// `sourceSet('testFixtures')` declaration nor a `testFixturesPublisher { consumedBy 'test' }` is needed.
|
|
// The performance lane is not `test` and still has to say it consumes the fixtures, which is what
|
|
// `compilesAgainst` is for — it looks a source set up rather than creating one, so naming a
|
|
// plugin-created set works unchanged.
|
|
//
|
|
// What is given up: the convention's opt-in publishing. This leaf's fixtures are now a consumable
|
|
// variant whether or not anybody asks for them. ADR-BUILD-001 records that as an accepted loss —
|
|
// unconsumed fixtures are unconsumed, and a leaf that genuinely must not offer them needs a module,
|
|
// not a third convention.
|
|
strictTestLanes {
|
|
sourceSet('mongoPerformanceTest') { compilesAgainst 'main', 'testFixtures' }
|
|
}
|
|
|
|
// The fixtures declare what they compile against, rather than inheriting the test configuration.
|
|
//
|
|
// The `testkit` source set used to extend `testImplementation`, so it silently saw every test
|
|
// library this leaf declared. That is convenient and it is also how a fixture acquires a dependency
|
|
// nobody chose for it. Listing them here is three more lines and makes the fixtures' own surface
|
|
// reviewable — the same model the GraphQL leaf already uses.
|
|
dependencies {
|
|
testFixturesImplementation libs.archunit.junit5
|
|
testFixturesImplementation 'io.projectreactor:reactor-test'
|
|
testFixturesImplementation 'org.assertj:assertj-core'
|
|
testFixturesImplementation 'org.mongodb:bson'
|
|
// The ArchUnit rule pack names Spring Data's Repository marker in a rule, so it needs the
|
|
// type on its compile classpath even though it never calls it.
|
|
testFixturesImplementation 'org.springframework.data:spring-data-commons'
|
|
testFixturesImplementation 'org.testcontainers:testcontainers'
|
|
testFixturesImplementation 'org.testcontainers:testcontainers-junit-jupiter'
|
|
testFixturesImplementation 'org.testcontainers:testcontainers-mongodb'
|
|
testFixturesImplementation 'org.testcontainers:testcontainers-toxiproxy'
|
|
testFixturesImplementation libs.toxiproxy.java
|
|
}
|
|
|
|
// Pinned server images. The design forbids `latest` for a certification lane (Task 44): a mutable
|
|
// tag makes a red run unattributable. `-PmongoPrimaryImage=` / `-PmongoCompatibilityImage=`
|
|
// override them for a one-off run.
|
|
Closure<Void> applyMongoImageSelection = { task ->
|
|
task.systemProperty 'mongodb.primary.image',
|
|
(project.findProperty('mongoPrimaryImage') ?: 'mongo:8.0.16').toString()
|
|
task.systemProperty 'mongodb.compatibility.image',
|
|
(project.findProperty('mongoCompatibilityImage') ?: 'mongo:7.0.28').toString()
|
|
task.systemProperty 'mongodb.toxiproxy.image',
|
|
(project.findProperty('mongoToxiproxyImage') ?: 'ghcr.io/shopify/toxiproxy:2.12.0').toString()
|
|
}
|
|
|
|
// Docker-backed lanes are excluded from the default unit run: they fail closed without Docker, and
|
|
// a `check` that fails on a laptop without Docker teaches people to skip `check`.
|
|
//
|
|
// `mongodb-contract` is excluded here too. It was not, and `check` depends on both `test` and
|
|
// `mongoStableContractTest`, so every one of the 382 hermetic contract tests ran twice on a fresh
|
|
// build — once in each task. The two lanes are now disjoint by construction, and
|
|
// `MongoTestLaneDisjointnessTest` asserts it against the JUnit XML rather than trusting this
|
|
// comment.
|
|
tasks.named('test', Test) {
|
|
useJUnitPlatform {
|
|
excludeTags 'mongodb-contract',
|
|
'mongodb-replicaset',
|
|
'mongodb-failover',
|
|
'mongodb-migration',
|
|
'mongodb-compatibility',
|
|
'mongodb-security-integration'
|
|
}
|
|
}
|
|
|
|
// Six lanes, declared rather than assembled. `ca.strict-test-lane` owns testClassesDirs, classpath,
|
|
// tag selection, failOnNoDiscoveredTests and the up-to-date refusal — the five lines that used to be
|
|
// copied once per lane here and again in four other leaves.
|
|
strictTestLanes {
|
|
lane('mongoReplicaSetTest') {
|
|
tag = 'mongodb-replicaset'
|
|
description = 'Single-node replica set contract lane: mapping, atomic write, transaction, ' +
|
|
'change stream (design §29).'
|
|
customize = { test -> applyMongoImageSelection(test) }
|
|
}
|
|
lane('mongoFailoverTest') {
|
|
tag = 'mongodb-failover'
|
|
description = 'Three-node replica set failover lane: primary kill, partition, unknown ' +
|
|
'commit, resume (design §29).'
|
|
customize = { test -> applyMongoImageSelection(test) }
|
|
}
|
|
lane('mongoMigrationTest') {
|
|
tag = 'mongodb-migration'
|
|
description = 'Migration lane: empty / N-1 / oldest-supported snapshots, lock, checkpoint ' +
|
|
'restart (design §12).'
|
|
customize = { test -> applyMongoImageSelection(test) }
|
|
}
|
|
lane('mongoCompatibilityTest') {
|
|
tag = 'mongodb-compatibility'
|
|
description = 'MongoDB 7.0 compatibility and 8.0 primary certification matrix (design §30).'
|
|
customize = { test -> applyMongoImageSelection(test) }
|
|
}
|
|
lane('mongoSecurityIntegrationTest') {
|
|
tag = 'mongodb-security-integration'
|
|
description = 'RBAC, TLS, injection and redaction release gate against a real server ' +
|
|
'(design §26).'
|
|
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') {
|
|
sourceSet = 'mongoPerformanceTest'
|
|
description = 'Certifies contention, aggregation spill, pagination and pool resource ' +
|
|
'bounds (design §29).'
|
|
customize = { test ->
|
|
applyMongoImageSelection(test)
|
|
// Assertions on by default. They defaulted to false, so the lane measured numbers and
|
|
// compared them to nothing — a performance gate whose bounds are never evaluated is a
|
|
// report, and the release evidence called it a certification.
|
|
test.systemProperty 'performance.assertions.enabled',
|
|
(project.findProperty('performance.assertions.enabled') ?: 'true').toString()
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// `check` gains only the hermetic lanes. The Docker-backed ones stay opt-in for the reason above.
|
|
tasks.named('check') {
|
|
dependsOn 'mongoStableContractTest', 'verifyMongoTestLaneDisjointness',
|
|
'verifyMongoReleaseContractLanes'
|
|
}
|
|
|
|
// The tag exclusion above is a claim about two task configurations. This checks the claim against
|
|
// what the two tasks actually ran, because the failure it prevents — every hermetic contract test
|
|
// executing twice per `check` — is invisible in a green build and only shows up as time.
|
|
tasks.register('verifyMongoTestLaneDisjointness') {
|
|
group = 'verification'
|
|
description = 'Fails when the unit lane and the stable contract lane execute the same test.'
|
|
dependsOn 'test', 'mongoStableContractTest'
|
|
def unitResults = layout.buildDirectory.dir('test-results/test')
|
|
def contractResults = layout.buildDirectory.dir('test-results/mongoStableContractTest')
|
|
inputs.dir(unitResults)
|
|
inputs.dir(contractResults)
|
|
outputs.file(layout.buildDirectory.file('reports/mongo-test-lane-disjointness.txt'))
|
|
doLast {
|
|
def executed = { java.io.File directory ->
|
|
def names = [] as Set
|
|
directory.listFiles({ File file -> file.name.endsWith('.xml') } as FileFilter)
|
|
?.each { file ->
|
|
new groovy.xml.XmlParser().parse(file).testcase.each { testcase ->
|
|
names << "${testcase.@classname}#${testcase.@name}".toString()
|
|
}
|
|
}
|
|
names
|
|
}
|
|
def unit = executed(unitResults.get().asFile)
|
|
def contract = executed(contractResults.get().asFile)
|
|
def overlap = unit.intersect(contract)
|
|
if (!overlap.isEmpty()) {
|
|
throw new GradleException(
|
|
"${overlap.size()} tests run in both the unit lane and the stable contract lane, " +
|
|
"so `check` executes them twice: ${overlap.take(5)}")
|
|
}
|
|
def report = outputs.files.singleFile
|
|
report.parentFile.mkdirs()
|
|
report.text = "unit=${unit.size()} contract=${contract.size()} overlap=0\n"
|
|
}
|
|
}
|
|
|
|
// Splitting the two lanes moved every tagged contract out of `test`, and the release manifest kept
|
|
// naming the lane it had left. `MongoReleaseEvidenceVerifier` resolves
|
|
// `test-results/<task>/TEST-<className>.xml`, so a contract whose class now runs somewhere else
|
|
// resolves to a file that will never exist: the Stable gate reports the transaction retry
|
|
// invariants as evidence the run failed to produce, for a suite that ran them.
|
|
//
|
|
// Checked against the XML the lanes wrote rather than against a tag table, because a tag table here
|
|
// would be a second copy of the selection above, and the copy is what drifted the first time.
|
|
tasks.register('verifyMongoReleaseContractLanes') {
|
|
group = 'verification'
|
|
description = 'Fails when a blocking release contract names a lane that did not run its class.'
|
|
dependsOn 'test', 'mongoStableContractTest'
|
|
def manifest = rootProject.file('../src/config/mongodb/release-contracts.json')
|
|
def hermeticLanes = ['test', 'mongoStableContractTest']
|
|
def resultsRoot = layout.buildDirectory.dir('test-results')
|
|
inputs.file(manifest)
|
|
inputs.dir(resultsRoot)
|
|
outputs.file(layout.buildDirectory.file('reports/mongo-release-contract-lanes.txt'))
|
|
doLast {
|
|
def contracts = new groovy.json.JsonSlurper().parse(manifest).contracts
|
|
def checked = []
|
|
def wrongLane = []
|
|
contracts.findAll { hermeticLanes.contains(it.task) }.each { contract ->
|
|
def results = resultsRoot.get().dir(contract.task).file(
|
|
"TEST-${contract.className}.xml").asFile
|
|
if (!results.isFile()) {
|
|
wrongLane << "${contract.id} names lane '${contract.task}', which did not run " +
|
|
"${contract.className}"
|
|
return
|
|
}
|
|
def suite = new groovy.xml.XmlParser().parse(results)
|
|
int executed = (suite.@tests as int) - (suite.@skipped as int)
|
|
if (executed < contract.minimumExecuted) {
|
|
wrongLane << "${contract.id} requires ${contract.minimumExecuted} executed test(s) " +
|
|
"in '${contract.task}' and the lane ran ${executed}"
|
|
}
|
|
checked << contract.id
|
|
}
|
|
if (!wrongLane.isEmpty()) {
|
|
throw new GradleException(
|
|
'the Mongo release manifest points at lanes that cannot produce its evidence: ' +
|
|
wrongLane.join('; '))
|
|
}
|
|
def report = outputs.files.singleFile
|
|
report.parentFile.mkdirs()
|
|
report.text = "hermetic release contracts verified: ${checked.join(', ')}\n"
|
|
}
|
|
}
|
|
|
|
|
|
// verifyMongoApiSurface — every public type this leaf exposes is a committed decision.
|
|
//
|
|
// 311 of this leaf's 313 production files declare a public top-level type. One jar means `public`
|
|
// is public to every adopter, so the intended split between contract and implementation — `api` is
|
|
// the surface, the rest is how it is built — is a convention the compiler does not know about.
|
|
//
|
|
// The full move of implementation packages under an `internal` root is a separate, mechanical
|
|
// change; this is what keeps the surface from growing while that is pending. A snapshot does not
|
|
// shrink anything. It makes each addition a decision somebody made in review rather than something
|
|
// discovered later by an adopter who imported it.
|
|
//
|
|
// A snapshot does not shrink the surface. It makes each addition visible in review, which is the
|
|
// prerequisite for shrinking it: the `api` and `spi` packages are the surface an adopter is meant
|
|
// to use, and everything else in this file is a candidate for becoming internal when the leaf is
|
|
// split into capability artifacts. Until then the number cannot grow by accident.
|
|
apiSurface {
|
|
label = 'Mongo'
|
|
baseline = rootProject.file('../docs/architecture/mongo-api-surface.txt')
|
|
description = 'MongoDB leaf public API surface — every public top-level type in src/main/java.'
|
|
rationale = [
|
|
'A public type in a single-jar leaf is reachable from every adopter\'s code, so',
|
|
'additions are reviewed rather than discovered. `api` is the intended external',
|
|
'surface; the rest is implementation that has not been moved under an internal',
|
|
'root yet.',
|
|
]
|
|
}
|