init: 클린 아키텍처 백엔드
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
# app-bootstrap — application entry point & composition root
|
||||
|
||||
## Registered identity
|
||||
|
||||
- Module ID: `app-bootstrap`
|
||||
- Gradle path: `:app-bootstrap`
|
||||
- Focused test: `./gradlew :app-bootstrap:test --console=plain`
|
||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||
- Registry SSOT: `.harness/project/modules.yaml`.
|
||||
|
||||
Package root: `dev.caskeleton.bootstrap`.
|
||||
|
||||
## Responsibility
|
||||
|
||||
- Spring Boot entrypoint (`CaSkeletonApplication`).
|
||||
- Runtime settings and logging bootstrap.
|
||||
- Final cross-module wiring.
|
||||
- Architecture tests that inspect all modules.
|
||||
|
||||
## Allowed
|
||||
|
||||
- Runtime leaves explicitly allowed by `.harness/project/modules.yaml`; do not duplicate the
|
||||
19-leaf dependency list here.
|
||||
- Spring Boot startup/runtime dependencies.
|
||||
- ArchUnit in tests.
|
||||
|
||||
## Forbidden
|
||||
|
||||
- Concrete business policy.
|
||||
- Controllers, use cases, repository adapters, or mappers authored directly under bootstrap.
|
||||
- Settings classes that implement business logic.
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :app-bootstrap:test
|
||||
./gradlew verifyCleanArchitectureDependencies
|
||||
```
|
||||
@@ -0,0 +1,831 @@
|
||||
# app-bootstrap — 설계 결정 참조
|
||||
|
||||
애플리케이션 진입점이자 합성 루트(composition root) 모듈. 패키지 루트: `dev.caskeleton.bootstrap`.
|
||||
|
||||
이 모듈은 비즈니스 로직을 담지 않는다. Spring Boot 기동, 런타임 설정 바인딩, 모듈 간 최종
|
||||
와이어링, 그리고 모든 모듈을 검사하는 아키텍처 테스트만 둔다. 허용/금지 의존, 책임 범위, 테스트
|
||||
명령 같은 **모듈 규칙**의 SSOT 는 [CLAUDE.md](CLAUDE.md) 다.
|
||||
|
||||
이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용 기록이다 — 코드를 읽다
|
||||
"왜 이렇게 했나"가 궁금할 때 본다. 본문은 한국어로 쓰고, 클래스·Spring API·메트릭 이름처럼
|
||||
꼭 필요한 기술 용어만 영어로 남긴다. 별도 추적 ID를 몰라도 읽히도록 결정의 배경과
|
||||
트레이드오프를 문장으로 풀어 둔다.
|
||||
|
||||
## 목차
|
||||
|
||||
- [runtime/startup — Fail-fast 시작 파이프라인](#runtimestartup--fail-fast-시작-파이프라인과-시작-실패-분류)
|
||||
- [runtime — 시크릿 소스 선택과 런타임 안전 가드](#runtime--시크릿-소스-선택과-런타임-안전-가드)
|
||||
- [concurrency — 도메인 컨텍스트 전파](#concurrency--도메인-컨텍스트-전파-전략-조립)
|
||||
- [async — 비동기 executor 컨텍스트 전파와 포화 처리](#async--비동기-executor-컨텍스트-전파와-포화-처리)
|
||||
- [idempotency — 멱등성 런타임 조립과 TTL 상한](#idempotency--멱등성-런타임-조립과-ttl-상한)
|
||||
- [lock — 분산 락 메트릭 데코레이터](#lock--분산-락-메트릭-데코레이터-배선)
|
||||
- [outbox — 트랜잭셔널 아웃박스 릴레이](#outbox--트랜잭셔널-아웃박스-릴레이-와이어링)
|
||||
- [logging — 로그 시크릿 마스킹·샘플링·가명화](#logging--로그-시크릿-마스킹샘플링가명화)
|
||||
- [metrics — 메트릭 계약 MeterFilter](#metrics--메트릭-계약-meterfilter-설치)
|
||||
- [management/security — 액추에이터 엔드포인트 보안](#managementsecurity--액추에이터-엔드포인트-보안-체인)
|
||||
- [tracing — 분산 트레이싱 wiring과 샘플링 정책](#tracing--분산-트레이싱-wiring과-샘플링-정책)
|
||||
- [settings — @ConfigurationProperties 검증 정책](#settings--configurationproperties-검증-정책)
|
||||
- [build.gradle — 의존성 구성 근거](#buildgradle--의존성-구성-근거)
|
||||
|
||||
---
|
||||
|
||||
## runtime/startup — Fail-fast 시작 파이프라인과 시작 실패 분류
|
||||
|
||||
잘못 설정된 채로 기동돼 트래픽을 받는 것보다, 기동 시점에 명확한 이유와 함께 멈추는 편이 안전하다.
|
||||
이 패키지는 그 "빨리·명확하게 실패시키기(fail-fast)"를 담당한다.
|
||||
|
||||
### FlywayProdSafetyValidator
|
||||
- **`prod` 프로파일에서 Flyway 안전장치가 꺼지지 못하도록 런타임에서 강제한다.** Flyway 옵션은
|
||||
`application.yml`에 안전값으로 고정돼 있지만(`baseline-on-migrate=false`, `out-of-order=false`,
|
||||
`clean-disabled=true`), 환경별 override 가 프로덕션에서 이 값을 조용히 되돌릴 수 있다. 이 검증기는
|
||||
그런 override 를 시작 시점에 잡아 `PROFILE_MISMATCH`(exit 71)로 부팅을 막는다.
|
||||
- **금지하는 옵션은 각각 "복구 불가능한 위험" 때문이다.** `baseline-on-migrate=true`는 누락된
|
||||
마이그레이션을 감지하는 안전망을 없애고, `out-of-order=true`는 개발자 간 마이그레이션 적용 순서
|
||||
일관성을 깨며, `clean-disabled=false`는 스키마 전체를 드롭하는 파괴적 `clean` 명령을 다시
|
||||
활성화한다.
|
||||
- **프로파일 비교는 대소문자를 무시한다.** `SPRING_PROFILES_ACTIVE=PROD`처럼 대문자 오타가 나도
|
||||
가드가 우회되지 않게 하기 위함이다.
|
||||
- **Flyway repair 는 가드 대상이 아니다.** repair 는 property 가 아니라 Flyway 명령이고 스켈레톤은
|
||||
repair 호출 경로 자체를 배선하지 않으므로, "prod 에서 repair 금지"는 런타임 트리거가 없는
|
||||
문서화된 정책으로 남는다. 부분 스키마 복구는 in-prod Flyway repair 가 아니라
|
||||
runbook(`runbook://migration/manual-recovery`)을 통한 수동 복구로 가도록 실패 메시지에 명시한다.
|
||||
(프로젝트가 repair 경로를 추가한다면 동일하게 prod 게이트 + non-prod 감사 로그를 걸어야 한다.)
|
||||
|
||||
### RequiredEnvironmentValidator
|
||||
- **datasource 연결 환경변수를 마이그레이션 실행 전에 검증해 부팅을 빨리 실패시킨다.** Flyway
|
||||
forward-only 마이그레이션은 context refresh 동안 애플리케이션 datasource 에 대해 실행된다. 연결
|
||||
환경변수가 빠져 있으면 마이그레이션은 시작 단계 구분자 없이 불투명한 driver/connection 에러로
|
||||
한참 뒤에야 실패한다. 이 검증기는 그것을 명시적인 env 검증 실패(`STARTUP_VALIDATION_FAILED`,
|
||||
exit 78)로 바꿔, 누락된 운영자용 env 키를 전부 이름으로 짚어준다.
|
||||
- **검증 범위를 datasource 3종(url/username/driver)으로 의도적으로 좁혔다.** 이것들이 이 브랜치가
|
||||
소유하는 마이그레이션 전제조건이기 때문이다. app-name(`BootstrapSettings`), issuer-uri(security
|
||||
settings) 등 다른 필수값은 각 모듈의 가드가 책임지므로 여기서 중복 검증하지 않는다.
|
||||
- **값이 없거나(blank) 공백이면 모두 "누락"으로 본다.** spring-dotenv 에서 미설정 placeholder
|
||||
`${...}`가 빈 문자열로 해석되기 때문에, null 뿐 아니라 blank 도 누락으로 처리해야 빈틈이 없다.
|
||||
- **메시지는 env 키 이름순으로 정렬해 출력한다.** Map 순회 순서와 무관하게 결정적이고 안정적인 실패
|
||||
메시지를 보장하기 위함이다.
|
||||
|
||||
### MigrationStartupConfig
|
||||
- **시작 마이그레이션 가드와 Flyway 마이그레이션 전략을 한곳에 모아 소유권을 명확히 한다.** prod
|
||||
토글 / 멀티 인스턴스 가드는 env-driven `RuntimeSafetyConfig`가 소유하고, 이 config 는 마이그레이션
|
||||
관련 가드만 배선한다.
|
||||
- **`migrationStartupRunner` 빈 이름은 반드시 유지해야 한다.** 멀티 인스턴스 모드
|
||||
(`APP_MULTI_INSTANCE_ENABLED=true`)에서 `StartupSafetyValidator`가 이 정확한 이름의 빈 존재를
|
||||
단언하기 때문이다. 동시에 이 빈은 `FlywayMigrationStrategy`로서 Spring Boot Flyway
|
||||
오토컨피규레이션이 `migrate()`를 위임하는 전략이 된다.
|
||||
|
||||
### MigrationStartupRunner
|
||||
- **`FlywayMigrationStrategy`로 등록해 context refresh 중에 마이그레이션을 직접 실행한다(오토컨피그
|
||||
기본값을 그대로 두지 않는다).** refresh 안에서 실행한다는 것은 마이그레이션이 완료되거나 부팅을
|
||||
실패시키는 일이 애플리케이션이 readiness 를 보고하기 *전에* 일어난다는 뜻이다. 따라서 절반만
|
||||
마이그레이션된 스키마가 트래픽을 받는 일이 구조적으로 없다(actuator readiness probe 의 형태
|
||||
자체는 health-lifecycle 브랜치가 소유하고, 여기서는 "마이그레이션 → ready" 순서만 책임진다).
|
||||
- **`FlywayException`을 `MigrationFailedException`(exit 70)으로 번역한다.** 일반적이고 구분 안 되는
|
||||
스택트레이스 대신 구조화된 `MIGRATION_FAILED` 로그와 표준 exit 코드를 남기기 위함이다.
|
||||
- **멀티 인스턴스에서는 in-app 마이그레이션 + Flyway 자체 schema-history 락에 의존하는 것이 기본
|
||||
전략이다.** 여러 레플리카의 동시 `migrate()`를 Flyway 의 락이 직렬화한다. 대안인 "in-app
|
||||
마이그레이션을 끄고 플랫폼 one-shot Job 으로 돌리기"는 "migration=Job"을 강제하는 공개 표준이
|
||||
없는 배포 선택이라 스켈레톤 기본값이 아니다. Job 으로 돌리는 프로젝트는 이 빈의 `migrate()`를
|
||||
no-op 으로 만들면 된다.
|
||||
|
||||
### 시작 실패 타입(StartupErrorCode / StartupPhase / StartupFailures / *Exception)
|
||||
- **`StartupErrorCode` — 네 가지 시작 실패 원인을 (레지스트리 error code, 표준 프로세스 exit 코드,
|
||||
`StartupPhase`)로 묶는다.** exit 78(env 누락/오류)과 70(마이그레이션 실패)은 sysexits 표준
|
||||
(`EX_CONFIG`, `EX_SOFTWARE`)과 정렬돼 외부적으로 방어 가능하다. 반면 exit 71(프로파일 불일치)과
|
||||
72(필수 어댑터 비활성)는 ca-tmpl 내부 규약이다 — sysexits 의 `EX_OSERR`/`EX_OSFILE`이 이 의미와
|
||||
맞지 않아, 외부 표준이 아니라 내부 룩업 테이블 값으로 정했다. exit 코드는 carrying 예외가
|
||||
`ExitCodeGenerator`를 구현하기 때문에만 JVM 종료 상태가 된다.
|
||||
- **`StartupPhase` — 실패 원인을 운영자가 로그에서 구분할 수 있게 하는 `startup.phase` 필드다.**
|
||||
시작 실패는 요청이 아직 없으므로 HTTP 에러 응답에 절대 실리지 않는다. 따라서 구조화 로그의
|
||||
`startup.phase`가 `kubectl logs`/`describe`에서 네 가지 원인을 가려내는 유일한 계약이다. wire
|
||||
이름은 dash-case(`env-validation | migration | adapter-enablement | profile-check`)이며, MDC 키가
|
||||
아니라 Logstash structured argument 로 방출되므로 snake_case MDC 키 레지스트리 규칙은 적용되지
|
||||
않는다.
|
||||
- **`StartupFailures` — 시작 실패를 던지는 단일 출처로, 예외 생성 *전에* 구조화 로그를 먼저
|
||||
방출한다.** "throw 직전 각 validator 가 직접 logger 를 호출"하는 패턴을 중앙화해, 어떤 시작 가드도
|
||||
원인/구분자 없는 실패를 던지지 못하게 한다. 로그는 `startup.phase` / `error.code` /
|
||||
`error.category` 세 필드를 Logstash structured argument 로 싣고, cause 가 있으면
|
||||
`error.root_cause.class` / `error.root_cause.message` 요약 필드만 추가한다. 원본 cause 는 반환되는
|
||||
`StartupFailureException`에 보존하지만 SLF4J throwable 인자로 넘기지 않는다. 따라서 canonical
|
||||
startup-failure 로그는 운영자가 분류할 수 있는 요약만 남기고 프레임워크/드라이버 stacktrace 를
|
||||
출력하지 않는다.
|
||||
- **`StartupFailureException` — 네 실패 원인의 베이스 타입이며 `ExitCodeGenerator`로 exit 코드를 JVM
|
||||
종료 상태로 만든다.** context refresh 가 실패하면 `SpringApplication`의 `SpringBootExceptionHandler`
|
||||
(부팅 스레드의 uncaught-exception handler 로 설치됨)가 실패 예외에서 `getExitCode()`를 읽어
|
||||
`System.exit(code)`를 호출한다 — `main()` 수정이 필요 없다. `IllegalStateException`을 상속하는
|
||||
이유는 과거 `StartupSafetyValidator`가 `IllegalStateException`을 던졌기 때문으로, 타입드 예외로
|
||||
업그레이드한 뒤에도 소스/동작 호환을 유지하기 위함이다.
|
||||
- **`StartupFailureExceptionReporter` / `StartupFailureSpringBootLogFilter` — Spring Boot 의 중복
|
||||
startup stacktrace 를 억제한다.** reporter 는 failure cause chain 에 `StartupFailureException`이
|
||||
있을 때만 `true`를 반환해 Boot 의 generic `Application run failed` 출력을 "이미 보고됨"으로
|
||||
처리한다. 이후 Boot 가 반쯤 초기화된 context 를 닫다가 `Unable to close ApplicationContext`를 다시
|
||||
남길 수 있으므로, canonical startup-failure 로그가 이미 찍힌 프로세스에서는 해당
|
||||
`org.springframework.boot.SpringApplication` 중복 메시지만 logback turbo filter 가 차단한다. 일반
|
||||
startup failure 가 아닌 예외와 다른 logger/message 는 기존 Spring Boot 로그 경로를 유지한다.
|
||||
- **`RequiredAdapterDisabledException`은 런타임-라이프사이클의 `ADAPTER_DISABLED`와 의도적으로
|
||||
구분된다.** 이쪽은 *시작* 검증(필수 어댑터/조정 빈이 꺼져 있음)이고, 후자는 비활성 옵션 어댑터에
|
||||
대한 *런타임* 호출이다.
|
||||
|
||||
---
|
||||
|
||||
## runtime — 시크릿 소스 선택과 런타임 안전 가드
|
||||
|
||||
시크릿을 어디서 읽을지(env / Vault / 클라우드 시크릿 매니저)를 인터페이스 뒤로 숨기고, 위험한
|
||||
런타임 설정값은 기동 시점에 막는다.
|
||||
|
||||
### SecretSource
|
||||
- **시크릿 해석을 인터페이스 한 겹 뒤로 숨긴 backend seam 이다.** 시크릿이 필요한 코드는 이
|
||||
인터페이스에만 의존하고, 실제 백엔드(env / Vault / AWS Secrets Manager / GCP Secret Manager)는
|
||||
`SecretSourceFactory`가 설정값으로 고른다. `RateLimiter` / `RateLimiterFactory`와 같은 패턴이다.
|
||||
백엔드를 추가하는 비용이 "새 `SecretSource` 구현 1개 + `SecretSourceStrategy` enum 값 1개 +
|
||||
factory case 1개"로 고정되고, 소비자(`SecretSourceValidator`, 향후 어댑터)는 전혀 손대지 않는다.
|
||||
- **빈 문자열은 "없음"으로 취급한다.** `resolve`가 blank 값을 `Optional.empty()`로 돌려주지 않으면,
|
||||
"값은 존재하지만 비어 있는" 시크릿이 존재 검사(presence check)를 통과해 버려 prod 부팅을 막을 수
|
||||
없게 된다.
|
||||
|
||||
### SecretSourceStrategy
|
||||
- **enum 값 하나가 곧 백엔드 하나라는 확장 규약을 못 박는다.** `ca-skeleton.secret-source.strategy`로
|
||||
바인딩되며 `ENVIRONMENT`만 기본 제공한다. 미래 백엔드(VAULT, AWS_SECRETS_MANAGER,
|
||||
GCP_SECRET_MANAGER)는 주석으로만 남겨 두어, 추가 절차가 "enum 값 + `SecretSource` 구현 + factory
|
||||
case"임을 코드에서 바로 읽히게 했다(rate-limit algorithm enum 패턴과 동일).
|
||||
|
||||
### SecretSourceSettings
|
||||
- **설정이 없어도 동작하는 기본값(ENVIRONMENT)을 박았다.** 스켈레톤은 별도 설정 없이 바로 부팅돼야
|
||||
하므로, `strategy`가 `null`이면 `ENVIRONMENT`로 보정한다. 백엔드 교체는 코드 수정이 아니라 설정
|
||||
한 줄로 끝난다.
|
||||
|
||||
### SecretSourceConfig
|
||||
- **시크릿 백엔드와 그 시작 가드를 composition root 에서만 와이어링한다.** `SecretSourceFactory`로
|
||||
선택된 `SecretSource`(기본 `ENVIRONMENT`)와, 그것을 통해 필수 시크릿을 검증하는
|
||||
`SecretSourceValidator`를 빈으로 노출한다. 검증기를 Spring 스테레오타입 없는 일반 클래스로 둔
|
||||
이유는 단위 테스트 가능성을 지키기 위함이고, 이 config 가 유일한 production 와이어링이다.
|
||||
- **`RuntimeSafetyConfig`와 일부러 분리했다.** 시크릿 계약은 secrets-config-source-contract 가
|
||||
소유하고, 런타임 안전 토글은 env-driven-runtime-configuration 이 소유한다. 소유 경계를 코드 구조로
|
||||
보존하기 위해 한 config 로 합치지 않았다.
|
||||
|
||||
### SecretSourceFactory
|
||||
- **유일한 확장 지점을 `switch` 하나로 모았다.** 새 백엔드는 `SecretSourceStrategy` 값 +
|
||||
`SecretSource` 구현 + 이 `switch`의 case 추가로 끝나고 소비자는 바뀌지 않는다.
|
||||
`RateLimiterFactory`와 같은 형태로, "확장 비용이 어디에 있는가"를 한곳에서 보이게 했다.
|
||||
|
||||
### EnvironmentSecretSource
|
||||
- **기본 백엔드는 Spring `Environment`에서 읽는 것이다.** `ENVIRONMENT` 전략은 시크릿이 env var /
|
||||
마운트 파일 / `application.yml`로 들어와 Spring 자체 property resolution 으로 바인딩됨을 전제한다.
|
||||
Vault·AWS·GCP 연동은 소비자를 건드리지 않고 `SecretSourceFactory` 뒤에 별도 `SecretSource`
|
||||
구현으로 추가하면 된다.
|
||||
|
||||
### SecretSourceValidator
|
||||
- **prod 에서 깨지면 부팅 자체를 거부하는 fail-fast 가드다.** 두 가지 prod 불변식을 context refresh
|
||||
완료 전에 강제한다. (1) `__LOCAL_DEV_` 접두사를 가진 dev/local 가짜 자격증명 값이 prod 프로파일에
|
||||
도달하는 것은 잘못 로드된 사고이므로 부팅을 중단시킨다. (2) 레지스트리에서 `secret` +
|
||||
`prod_default: null`로 분류된 모든 키는 prod 에서 비어 있지 않게 주입돼야 한다 — 빈 시크릿으로
|
||||
부팅하는 것은 금지다. dev/local 프로파일은 두 검사를 건너뛴다(`__LOCAL_DEV_*`가 의도된 로컬
|
||||
폴백이므로).
|
||||
- **`SmartInitializingSingleton` 타이밍을 의도적으로 받아들였다.** 이 콜백은 모든 싱글톤 생성 *후*
|
||||
context refresh 완료 *전*에 한 번 실행된다. 따라서 eager `DataSource`가 이 가드보다 먼저
|
||||
`__LOCAL_DEV_` 자격증명으로 연결을 시도할 가능성은 있다. 그러나 prod 에서 `__LOCAL_DEV_` 값에
|
||||
도달하는 것 자체가 예외적 오적재이고, 트래픽을 받기 전에 context 가 중단되므로 이 타이밍으로
|
||||
충분하다고 판단했다. 더 이른 차단이 필요해지면 `EnvironmentPostProcessor`로 승격한다.
|
||||
- **필수 시크릿 검사는 Spring 환경이 아니라 `SecretSource` 백엔드를 통해 한다.** 존재 검사를
|
||||
백엔드(`secretSource.resolve(key)`) 경유로 하기 때문에, env → Vault/AWS 백엔드 교체 시에도 검사가
|
||||
자동으로 따라간다. 검사를 Spring `Environment`에 고정했다면 백엔드를 바꿀 때 검증 로직이 함께
|
||||
끊겼을 것이다.
|
||||
- **`REQUIRED_PROD_SECRETS`는 레지스트리와 1:1로 강제된다.** 이 in-code 상수는
|
||||
`docs/registries/secrets-classification.yaml`의 secret 행과 정확히 일치해야 하며,
|
||||
`SecretsClassificationRegistryTest`가 1:1 일치를 단언한다. 그래서 레지스트리에 시크릿을 추가/제거하고
|
||||
이 상수를 갱신하지 않으면 빌드가 깨진다 — drift 를 컴파일·테스트 단계에서 잡는다.
|
||||
- **prod 판정은 대소문자 무시다.** `SPRING_PROFILES_ACTIVE=PROD` 같은 배포 오타도 prod 가드를
|
||||
발동시켜야 하므로 `equalsIgnoreCase`로 비교한다(`Environment#matchesProfiles`는 대소문자를 구분해
|
||||
"PROD"를 통과시킨다).
|
||||
|
||||
### HikariPoolConstraintValidator
|
||||
- **HikariCP 노브 사이의 상호 제약을 부팅 실패로 바꾼다.** Hikari/Tomcat 은 잘못된 값을 결국
|
||||
거부하지만 pool/connector 초기화 시점에 난해한 메시지로만 거부한다. 이 검증기는 계약을 일찍·명확
|
||||
하게 만든다: resolved Spring property 를 읽고, 위반을 모두 모아 운영자가 실제로 설정한 `APP_*`
|
||||
환경 키를 메시지에 담아 `IllegalStateException`을 던진다. 속성이 *없으면* 건너뛰고(placeholder/
|
||||
프레임워크 기본값이 소유), *있을 때만* 검사한다.
|
||||
- **강제하는 제약과 그 숫자의 근거.** `connection-timeout >= 250ms`(너무 짧으면 정상 연결도 타임
|
||||
아웃). `validation-timeout < connection-timeout`(둘 다 있을 때; validation-timeout 기본 5000ms 가
|
||||
흔한 connection-timeout 5s/5000ms 와 같아지는 충돌을 해소). `keepalive-time < max-lifetime`(둘 다
|
||||
있을 때; keepalive 가 lifetime 보다 길면 의미 없음). `leak-detection-threshold`는 0(비활성)이
|
||||
아니라면 `>= 2000ms`(너무 작으면 정상 사용을 누수로 오탐).
|
||||
- **모든 노브를 `String`으로 읽어 직접 파싱하는 방어적 처리.** `env-keys.yaml`의 `connection-timeout`
|
||||
기본값은 Duration 문자열 `5s`인데 `src/.env`는 `30000`(ms)을 준다. `Environment#getProperty(...,
|
||||
Long.class)`를 `"5s"`에 호출하면 `ConversionFailedException`이 난다. 그래서 각 값을 `String`으로
|
||||
읽어 `parseMillis`로 넘기고, `null`/blank 또는 plain-integer 가 아닌 값은 `null`(= 부재로 간주,
|
||||
조용히 skip)로 처리한다. 덕분에 검증기가 형식 drift 값에 절대 죽지 않는다.
|
||||
- **env 키가 아직 없는 노브는 "env key pending" 문구를 쓴다.** `connection-timeout` /
|
||||
`max-lifetime`만 `env-keys.yaml`에 등록돼 있고, greenfield 노브(validation-timeout, keepalive-time,
|
||||
leak-detection-threshold)는 env 키가 없다. 없는 키 이름을 지어내는 대신 pending 문구를 메시지에 넣는다.
|
||||
|
||||
### OpenInViewSafetyValidator
|
||||
- **OSIV(Open Session In View) OFF 를 hard stop 으로 강제한다.** OSIV 가 켜져 있으면 Hibernate
|
||||
세션이 view 렌더링까지 열려 있어, presentation 레이어에서 lazy 연관을 건드리면 거기서 조용히 DB
|
||||
쿼리가 나간다 — 이것이 정확히 금지된 레이어 경계 위반이다. Spring Boot 는
|
||||
`spring.jpa.open-in-view`가 암묵 기본값일 때 WARN 만 찍을 뿐 명시적으로 `true`로 설정한 배포는
|
||||
막지 못한다. WARN 은 놓치기 쉬우므로, resolved 값이 `true`면 운영자가 실제 설정한
|
||||
`APP_DATASOURCE_OPEN_IN_VIEW` 키를 메시지에 담아 부팅을 실패시킨다.
|
||||
- **읽기만 하고 re-bind 하지 않으며, 부재 값은 Spring 기본에 맡긴다.** `SmartInitializingSingleton`
|
||||
으로 한 번만 검사하고, 값이 *없으면* Spring Boot 기본(이 스켈레톤은 `application.yml`에서 OSIV off
|
||||
가 기본)에 맡기며 *있는 `true`*만 거부한다.
|
||||
|
||||
### RuntimeNumericBoundsValidator
|
||||
- **고위험 숫자 노브(pool/thread 사이징)만 일부러 좁게 검증한다.** pool/connector 사이징 키는
|
||||
Spring-native property(`spring.datasource.hikari.*`, `server.tomcat.*`)로 직결되고 `env-keys.yaml`이
|
||||
각각을 `positive_int` / `non_negative_int`로 표시하지만, 이 표시는 그동안 강제되지 않았다 —
|
||||
Hikari/Tomcat 이 결국 거부하더라도 난해한 메시지로 init 시점에야 거부했다. 이 검증기는 resolved
|
||||
Spring property 를 읽어, 운영자가 실제 설정한 `APP_*` 키를 담은 메시지로 일찍 부팅을 실패시킨다.
|
||||
잘못 설정하면 실제 런타임 장애로 이어지는 키(pool/thread 사이징)에만 범위를 한정했고, Logback
|
||||
소유 로그 사이징·Spring `Duration` 키는 (해당 라이브러리가 검증하므로) 범위 밖이다.
|
||||
- **읽기 전용으로 Spring 바인딩을 중복하지 않고, 부재 키는 skip 한다.** 값이 없으면 placeholder/
|
||||
프레임워크 기본값이 소유하므로 건너뛰고, 범위를 벗어난 *있는* 값만 거부한다("lenient default
|
||||
금지").
|
||||
|
||||
### StartupSafetyValidator
|
||||
- **`SmartInitializingSingleton` 타이밍을 고른 이유가 핵심이다.** 검사는 모든 싱글톤 생성 후 context
|
||||
refresh 완료 전에 한 번 돈다. `EnvironmentPostProcessor`는 bean 정의가 생기기 전에 실행돼 bean
|
||||
*존재 여부*(multi-instance 검사에 필요)를 검사할 수 없고, `ApplicationReadyEvent` 리스너는 트래픽
|
||||
직전에야 발동해 잘못된 부팅을 거부하기엔 너무 늦다. 그래서 이 중간 타이밍을 택했고, 위반 시
|
||||
throw 하여 context 가 시작을 거부한다.
|
||||
- **prod 안전 토글은 잘못 켜져 있으면 PROFILE_MISMATCH(exit 71)로 죽인다.** prod 프로파일에서 내부
|
||||
상세 노출/요청 본문 캡처 토글이 켜져 있으면 구조화된 startup-failure 로그와 함께 부팅을 중단한다.
|
||||
- **multi-instance 가 켜지면 조율 bean 이 모두 존재해야 한다.** `APP_MULTI_INSTANCE_ENABLED=true`일
|
||||
때 `REQUIRED_MULTI_INSTANCE_BEANS`(distributed lock / cache stampede protection / outbox leader
|
||||
election / distributed rate limiter / migration startup job)가 하나라도 빠지면
|
||||
REQUIRED_ADAPTER_DISABLED(exit 72)로 실패시킨다. 이 bean 들은 각각 다른 브랜치가 소유하고, 이
|
||||
검증기는 존재 여부만 단언한다.
|
||||
- **prod 판정은 대소문자 무시다.** `SPRING_PROFILES_ACTIVE=PROD` 오타도 prod-safety 가드를
|
||||
발동시켜야 하므로 `equalsIgnoreCase`로 비교한다.
|
||||
|
||||
---
|
||||
|
||||
## concurrency — 도메인 컨텍스트 전파 전략 조립
|
||||
|
||||
### DomainContextConfig
|
||||
- **`DomainContextPropagator`를 빈으로 노출하고, 설정에서 고른 `DomainContextStrategy`로
|
||||
`DomainContextPropagatorFactory`를 통해 조립한다.** `application-core`는 설정을 직접 읽지 않는다는
|
||||
원칙 때문에, 전략 해석과 propagator 생성은 composition root(여기)에서 끝내고 완성된 propagator 만
|
||||
주입한다. 그래서 use case 코드는 어떤 전파 전략을 쓰는지 몰라도 되고, 전략 교체는 코드가 아니라
|
||||
설정으로만 일어난다.
|
||||
|
||||
### DomainContextSettings
|
||||
- **`ca-skeleton.domain-context.strategy`가 비어 있으면 `THREAD_LOCAL`로 기본값을 채운다.** 스켈레톤은
|
||||
"설정 없이도 바로 동작하는 기본값"을 제공하는 것을 원칙으로 하므로, 전략을 강제로 지정하게 만들지
|
||||
않고 가장 안전한 `THREAD_LOCAL`을 디폴트로 둔다. 이 전략은 rate-limit 알고리즘처럼 전용 환경변수
|
||||
를 두지 않는데, 보안 비밀이나 배포별 값이 아니라 운영상의 선택지라 설정 바인딩만으로 충분하기
|
||||
때문이다.
|
||||
|
||||
---
|
||||
|
||||
## async — 비동기 executor 컨텍스트 전파와 포화 처리
|
||||
|
||||
### AsyncContextTaskDecorator
|
||||
- **executor 경계를 넘을 때 caller 스레드의 컨텍스트를 worker 스레드로 옮기는 단 하나의
|
||||
`TaskDecorator`다.** MDC 는 submit 시점에 맵 전체를 복사한다(`MDC.getCopyOfContextMap()`). 그래서
|
||||
async 전파 대상 4개 foundation 키(`request_id`/`trace_id`/`correlation_id`/`tenant_id`)와
|
||||
`span_id`(SLF4J-Micrometer tracing bridge 가 MDC 에 써넣는 값)가 worker 로그 라인에 그대로
|
||||
따라온다. 특정 상수 holder(`MdcKeys`)에 결합하지 않고 맵을 통째로 복사하는 이유는, 그래야 그
|
||||
시점에 존재하는 모든 키를 빠짐없이 옮길 수 있기 때문이다.
|
||||
- **domain 컨텍스트는 직접 복사하지 않고 shared seam(`DomainContextPropagator.wrap`)에 위임한다.** 이
|
||||
브랜치는 executor 배선만 소유하고, caller→worker 의 실제 hand-off 는 seam 이 소유한다는 책임 분리에
|
||||
따른 것이다.
|
||||
- **trace/span "문자열"만 옮기지 Micrometer `Observation` scope 는 worker 스레드에서 다시 열지
|
||||
않는다.** 그래서 로그 연속성은 유지되지만, worker 에서 새로 만든 child observation 은 caller 의
|
||||
span 아래로 nesting 되지 않는다. 완전한 scope 전파는 `io.micrometer:context-propagation` + Spring
|
||||
의 `ContextPropagatingTaskDecorator`가 필요한데, 그 라이브러리를 의도적으로 classpath 에 두지
|
||||
않았다. 따라서 "수동 4-key copy decorator" 경로를 채택하고, worker 스레드 span 이 정말 필요한
|
||||
프로젝트를 위해 라이브러리 업그레이드를 확장 지점(seam)으로 문서화해 둔다.
|
||||
- **`SecurityContext` principal 은 일부러 전파하지 않는다.** `user_principal`은 전파 대상이 아니고,
|
||||
pooled 로 재사용되는 worker 스레드에 `SecurityContext`를 복사하는 것은 stale-context 위험이기
|
||||
때문이다. principal 이 worker 에서 진짜 필요한 use case 는 자기 executor 를 Spring Security 의
|
||||
`DelegatingSecurityContextTaskExecutor`로 감싸서 명시적으로 opt-in 한다.
|
||||
- **decorator 는 대칭적이다.** 태스크 실행 후 worker 스레드의 이전 MDC 를 복원하므로, pooled
|
||||
스레드가 한 태스크의 컨텍스트를 다음 태스크로 흘리지 않는다.
|
||||
|
||||
### AsyncExecutorConfig
|
||||
- **Spring Boot 가 auto-config 하는 `applicationTaskExecutor`를 bounded pool 로 교체한다.** Boot 기본
|
||||
executor 의 큐는 unbounded(`Integer.MAX_VALUE`)라 금지된다. 빈 이름을 `applicationTaskExecutor`로
|
||||
두는 이유는 Boot 의 `@ConditionalOnMissingBean(Executor.class)` auto-config 를 back-off 시키면서,
|
||||
동시에 `@Async`가 resolve 하는 바로 그 executor 가 되게 하기 위함이다.
|
||||
- **TaskDecorator 미설정 executor 등록은 ApplicationContext 기동 실패로 만든다.**
|
||||
`AsyncContextTaskDecorator`를 executor 빈의 *필수* 생성자 의존으로 두었기 때문에, decorator 없이
|
||||
executor 를 등록하려 하면 컨텍스트가 뜨지 않는다.
|
||||
- **`awaitTerminationSeconds(19)`는 컨테이너 app-shutdown 예산 20s 에서 cleanup margin 1s 를 뺀
|
||||
값이다.** 25s 는 금지된다 — 컨테이너가 강제 종료하기 전에 graceful drain 이 끝나야 하기 때문이다.
|
||||
- **`executor.saturation` gauge 는 살아있는 큐 깊이를 읽는다.** 큐 > 용량 80% 면 p2, rejection
|
||||
발생이면 p1 알림 기준이다.
|
||||
|
||||
### AsyncExecutorSettings
|
||||
- **큐는 반드시 bounded 여야 한다.** unbounded 큐는 `maxPoolSize`를 도달 불가능하게 만든다 — JDK
|
||||
`ThreadPoolExecutor`는 큐가 가득 찼을 때만 core 이상으로 스레드를 늘리기 때문이다. 게다가 Spring
|
||||
`ThreadPoolTaskExecutor`의 큐 기본값은 `Integer.MAX_VALUE`, 즉 사실상 unbounded 다. 그래서 compact
|
||||
생성자가 큐 용량으로 `Integer.MAX_VALUE`를 거부한다 — 그만큼 큰 용량은 이름만 다른 unbounded
|
||||
sentinel 이기 때문이다.
|
||||
- **검증은 fail-fast(ApplicationContext 기동 실패)다.** 잘못 설정된 pool 은 런타임 fault 가 아니라
|
||||
deploy-time 버그이므로, 런타임까지 끌고 가지 않고 기동 시점에 즉시 터뜨린다.
|
||||
- **cross-field 불변식: `maxSize`는 `coreSize`보다 작을 수 없다.** core 보다 작은 max 는 무의미하고,
|
||||
pool 이 설정된 core 크기에조차 도달하지 못하게 만들기 때문이다.
|
||||
|
||||
### BackgroundJobMetrics
|
||||
- **이 브랜치가 소유하는 background-job/async-executor 메트릭 어휘 SSOT 다.** 메트릭 이름·태그는
|
||||
`docs/registries/metrics.yaml`과 verbatim 일치한다: `executor.saturation`(gauge, `executor_name`),
|
||||
`executor.rejected.total`(counter, `executor_name`+`policy`), `job.retry.total`(counter,
|
||||
`job_name`+`outcome`), `job.dlq.total`(counter, `job_name`).
|
||||
- **`executor.*` 두 meter 는 `AsyncExecutorConfig`가 라이브로 배선하고, `job.*` 두 recorder 는 어휘
|
||||
표면만 먼저 출시한다.** retry/DLQ 어휘는 outbox/outbound 브랜치가 나중에 retry carrier 를 고르면
|
||||
소비하라고 이 브랜치가 소유한다. carrier 자체는 아직 정해지지 않았으므로, retry 엔진이 아니라
|
||||
vocabulary surface 만 출시하는 것이다.
|
||||
- **`MeterRegistry` 빈이 없으면(=classpath 에 Actuator 없음) 모든 연산이 no-op 다.** `ObjectProvider`로
|
||||
registry 를 resolve 하며, 이는 `OutboxMetrics` 선례를 그대로 따른다.
|
||||
- **`RetryOutcome` enum 은 retry 어휘를 type-safe 하게 표기한 것이다.** `job.retry.total`의 `outcome`
|
||||
태그 값은 metrics.yaml 에서 4개(SUCCESS/RETRY/EXHAUSTED/DLQ)로 bounded 되어 있고, 미래의 retry
|
||||
carrier 가 이 값을 emit 한다.
|
||||
|
||||
### LoggingAbortPolicy
|
||||
- **기본 포화 정책은 `AbortPolicy`다.** bounded 큐가 가득 차고 pool 이 `maxPoolSize`에 도달하면
|
||||
태스크를 거부하고 그 거부를 caller 에게 전달한다. 이 wrapper 는 bare
|
||||
`ThreadPoolExecutor.AbortPolicy`가 빠뜨리는 두 가지 계약 의무를, abort 를 다시 던지기 *전에*
|
||||
추가한다: (1) `error.code=JOB_EXECUTOR_REJECTED` + category + `executor_name` + `policy`를 담은
|
||||
structured ERROR 로그, (2) `executor.rejected.total{executor_name, policy}` counter 증가.
|
||||
- **그 다음 `RejectedExecutionException`을 던져 AbortPolicy 의미를 유지한다.** fire-and-forget
|
||||
`@Async` caller 의 거부가 조용히 삼켜지지 않도록 하기 위함이다 — 이 예외가 async-exception 계약이
|
||||
흡수하는 신호다.
|
||||
- **허용되는 유일한 대안은 `CallerRunsPolicy`뿐이고, 그것도 명시적 use-case 선언이 있을 때만이다.**
|
||||
caller-runs 의 back-pressure 는 request 스레드 latency 를 갉아먹기 때문이다. unbounded 큐는 아예
|
||||
금지다.
|
||||
|
||||
---
|
||||
|
||||
## idempotency — 멱등성 런타임 조립과 TTL 상한
|
||||
|
||||
### IdempotencyConfig
|
||||
- **공유 `Clock` 빈과 `IdempotencyExecutor`를 composition root 에서 조립하고, `@EnableScheduling`으로
|
||||
만료 레코드 reaper 의 스케줄 purge 를 켠다.** `Clock`을 빈으로 한 번만 정의해 executor·persistence
|
||||
store 어댑터·reaper·rate-limit 인터셉터가 같은 시계를 쓰게 만들어 테스트에서 시간 고정이 쉽고
|
||||
분기마다 시간 해석이 어긋나지 않는다. executor 를 여기서 만드는 이유는 TTL 같은 설정을
|
||||
`application-core`가 직접 읽으면 안 되기 때문이다. `@Scheduled` purge 는 `@EnableScheduling` 없이는
|
||||
동작하지 않으므로 이 설정 클래스에서 명시적으로 활성화한다.
|
||||
|
||||
### IdempotencySettings
|
||||
- **TTL 기본값은 24h 이고 상한은 72h 이며, 이 상한 검사는 컴팩트 생성자에서 fail-fast 로 던진다.**
|
||||
`Duration`의 상한은 JSR-303(`@Valid`) 애너테이션으로 표현할 수 없는 교차 필드 불변식이라 생성자에서
|
||||
직접 검증한다. 검증을 느슨하게 두고 넘어가면 과도하게 긴 TTL 이 저장소를 조용히 부풀리고 멱등성 키
|
||||
추측 공격의 유효 시간 창을 넓히기 때문에, 부팅 시점에 즉시 실패시키는 쪽을 택했다.
|
||||
- **`MAX_TTL`(72h) / 기본 TTL(24h)을 바꿀 때는 보안 베이스라인의 JWT 키 회전 겹침(overlap) 윈도와
|
||||
반드시 함께 검토해야 한다.** 멱등성 레코드의 수명이 키 회전 겹침 윈도(현재 24h)보다 길면, 가명화
|
||||
(pseudonym) 기준이 회전된 principal 에 대해 레코드가 재생(replay)될 수 있다. 이 불변식을 강제하는
|
||||
CI 게이트는 security-operational-baseline 쪽에 위임돼 있다.
|
||||
- **`reaperInterval`은 전용 환경변수가 없고 기본 10분이다.** reaper 실행 주기는 비밀이나 배포별 값이
|
||||
아니라 순수 운영 튜닝 값이라 env key 를 따로 두지 않고, 비어 있거나 0/음수면 10분으로 채운다.
|
||||
|
||||
---
|
||||
|
||||
## lock — 분산 락 메트릭 데코레이터 배선
|
||||
|
||||
### DistributedLockConfig
|
||||
- **`distributedLockProvider` 빈은 `ca-skeleton.runtime.multi-instance-enabled=true`일 때만
|
||||
`@ConditionalOnProperty`로 등록되고 `@Primary`로 우선 적용된다.** 다중 인스턴스 환경에서만
|
||||
`adapter-persistence`의 원시 `jdbcDistributedLock`(JdbcLockRegistry, Flyway 가 프로비저닝한
|
||||
`INT_LOCK` 기반) 어댑터를 `MeteredDistributedLockPort`로 감싸 `lock.acquisition` 카운터를 기록한다.
|
||||
- **단일 인스턴스에서는 이 조건부 빈이 아예 없으므로 `adapter-persistence`가 등록한 `@Primary
|
||||
inProcessDistributedLock`(in-process `DefaultLockRegistry`)이 그대로 선택된다.** 이 경로는 의도적
|
||||
으로 계측하지 않는다 — `lock.acquisition` 카운터는 인스턴스 간 분산 조율을 관측할 때만 의미가
|
||||
있고, JVM 안의 단순 뮤텍스에는 무의미하기 때문이다.
|
||||
- **빈 이름 `"distributedLockProvider"`는 `StartupSafetyValidator`가 이름으로 조회해 검증하는
|
||||
계약이다.** 절대 이름을 바꾸지 말 것.
|
||||
|
||||
### MeteredDistributedLockPort
|
||||
- **`DistributedLockPort`를 감싸는 얇은 Micrometer 데코레이터다.** `tryAcquire` 호출마다
|
||||
`lock.acquisition` 카운터를 `outcome` 태그와 함께 1 증가시킨다. 태그 값은 세 가지: `acquired`(락
|
||||
획득 성공, 위임 핸들 반환), `timeout`(`LockAcquisitionTimeoutException` 발생), `error`(그 외 모든
|
||||
`RuntimeException` 발생). 메트릭 이름과 태그 값은 `docs/registries/metrics.yaml`의 `lock.acquisition`
|
||||
행과 일치해야 한다.
|
||||
- **리스 만료(lease-expiry) CME 흡수.** 보유자가 `close()`를 호출하기 전에 리스(저장소 TTL)가
|
||||
만료되면 그 락 행은 이미 다른 인스턴스가 회수해 갔을 수 있고, 그 시점에 내부 `JdbcLock.unlock()`은
|
||||
`ConcurrentModificationException`(CME)을 던진다. 이 예외를 호출자의 `finally { lock.close(); }`
|
||||
블록 밖으로 그대로 전파하면 보호 구간(critical section)에서 발생한 본래 예외를 가려버린다
|
||||
(masking). 그래서 CME 만 잡아 WARN 로그를 남기고 태그 없는 `lock.lease.expired` 카운터를 1
|
||||
증가시킨 뒤 `close()`는 정상 복귀시킨다. 이렇게 해야 신호(로그+메트릭)는 남기면서도 호출자의 정상
|
||||
흐름과 예외 전파를 방해하지 않는다. 오직 `ConcurrentModificationException`만 흡수하며, DB 장애 같은
|
||||
다른 예외(예: `DataAccessResourceFailureException`)는 리스 만료 신호가 아니므로 손대지 않고 그대로
|
||||
전파한다.
|
||||
- **MeterRegistry 부재 시 no-op.** 레지스트리를 `ObjectProvider.getIfAvailable()`로 해석하므로
|
||||
클래스패스에 `MeterRegistry` 빈이 없으면(Actuator 미탑재) 모든 메트릭 연산이 조용히 no-op 이
|
||||
된다. `BackgroundJobMetrics` 선례와 동일한 패턴이다.
|
||||
- **메트릭 실패가 락 경로를 절대 깨뜨리지 않음.** 카운터 등록·증가는 모두 try/catch 로 감싸 예외를
|
||||
로깅 후 삼킨다(log-and-swallow). Micrometer 쪽 실패가 락 획득·보유·실패 보고를 막는 일은 없어야
|
||||
하기 때문이다.
|
||||
|
||||
---
|
||||
|
||||
## outbox — 트랜잭셔널 아웃박스 릴레이 와이어링
|
||||
|
||||
### OutboxConfig
|
||||
- **릴레이 use case 를 `@Service`가 아니라 `app-bootstrap`에서 수동 조립한다.**
|
||||
`PublishPendingOutboxEventsUseCase`는 `batchSize`와 `inFlightTimeout` 같은 설정값을 생성자로 받아야
|
||||
하는데, `application-core`는 설정(`OutboxSettings`)을 직접 읽으면 안 된다. 그래서 설정을 볼 수 있는
|
||||
합성 루트(`OutboxConfig`)가 값을 꺼내 use case 를 손으로 만들어 넘긴다. use case 클래스의
|
||||
`@UseCaseCapability` 애너테이션은 와이어링 방식과 무관하게 유지된다(ArchUnit 이 강제).
|
||||
- **릴레이 use case 를 독립 컨텍스트 빈으로 등록하지 않는다.** 만약 빈으로 올리면 `adapter-web`의
|
||||
`MethodSecurityConfig` 메서드 보안 pointcut(`@RequiresPermission`)이 이 타입을 CGLIB 프록시로
|
||||
감싼다. 그런데 use case 가 `final` 클래스라 프록시 생성 자체가 실패하고, 설령 된다 해도 스케줄러
|
||||
스레드에는 `Authentication`이 없어 매 릴레이 틱이 fail-closed 로 거부된다. 그래서 빈으로 올리지
|
||||
않고, `outbox:relay` 권한 확인은 스케줄러 컨텍스트에서 관례로 둔다.
|
||||
- **`outboxRelayScheduler` 빈은 `ca-skeleton.outbox.relay-enabled`로 게이팅한다(기본 true,
|
||||
`matchIfMissing=true`).** 릴레이를 끄고 싶을 때 빈 자체가 만들어지지 않게 하기 위함. 키가 없으면
|
||||
켜진 것으로 본다.
|
||||
- **`outboxLeaderElection` 토큰 빈은 조건 없이(unconditional) 항상 등록한다.** `StartupSafetyValidator`
|
||||
가 `APP_MULTI_INSTANCE_ENABLED=true`일 때 이 빈을 반드시 찾을 수 있어야 하기 때문이다. SKIP
|
||||
LOCKED 는 멀티 인스턴스 여부와 무관하게 언제나 릴레이의 리더십 메커니즘이라, `@ConditionalOnProperty`
|
||||
로 끌 수 있게 만들면 단순 조정 기능이 아니라 릴레이 정확성 자체가 깨진다.
|
||||
- **`outboxMetrics` 빈은 `MeterRegistry`가 없을 때 no-op 이다.** Actuator 가 클래스패스에 없는
|
||||
환경에서도 와이어링이 깨지지 않도록 `ObjectProvider`로 레지스트리를 선택적으로 주입한다.
|
||||
|
||||
### OutboxLeaderElectionToken
|
||||
- **로직이 전혀 없는 마커(documentation artifact) 빈이다.** 이 타입은 동작을 갖지 않고, Spring
|
||||
컨텍스트에 존재한다는 사실만으로 멀티 인스턴스 조정 빈 요구사항을 충족시킨다.
|
||||
- **리더 선출을 외부 코디네이터 없이 PostgreSQL `FOR UPDATE SKIP LOCKED`로 구현한다는 것을
|
||||
표현한다.** claim 쿼리에 SKIP LOCKED 를 걸면 각 릴레이 인스턴스가 서로 겹치지 않는(disjoint) 행
|
||||
집합을 가져가므로, 별도 코디네이터 없이도 "각 인스턴스가 자기 파티션의 리더" 형태로 리더 선출
|
||||
의미가 성립한다.
|
||||
|
||||
### OutboxMetrics
|
||||
- **`MeterRegistry`가 없으면 모든 메트릭 연산이 no-op 이다.** Actuator 가 없는 환경에서도 릴레이가
|
||||
정상 동작해야 하므로, 생성자에서 `ObjectProvider`로 레지스트리를 조회해 없으면 게이지도 만들지
|
||||
않고 기록도 건너뛴다.
|
||||
- **카운터는 PUBLISHED/FAILED/DEAD 만 집계하고 IN_FLIGHT 는 제외한다.** IN_FLIGHT 는 릴레이 사이클의
|
||||
종료 결과(terminal outcome)가 아니라 처리 중을 나타내는 일시적 상태라 카운터 outcome 으로 의미가
|
||||
없다.
|
||||
- **MultiGauge 갱신 시 `overwrite=true`로 등록한다.** 매 스케줄러 틱마다 store 를 다시 조회해
|
||||
게이지를 갱신하는데, 이전 틱에 있었지만 지금은 사라진 event type 같은 오래된 time-series 태그를
|
||||
덮어써 제거하기 위함이다.
|
||||
|
||||
### OutboxRelayScheduler
|
||||
- **합성 루트가 직접 등록하고 컴포넌트 스캔하지 않는다.** 이 스케줄러가 구동하는 릴레이 use case 가
|
||||
(위 OutboxConfig 사유로) 일부러 빈이 아니기 때문에, 그 use case 를 조립하는 `OutboxConfig`가
|
||||
스케줄러 등록까지 같이 소유한다.
|
||||
- **릴레이 사이클에서 발생하는 예상치 못한 예외를 잡아 ERROR 로 로깅만 하고 삼킨다.** 스케줄러
|
||||
스레드가 죽으면 릴레이가 조용히 멈추므로, 다음 틱을 위해 스레드를 살려둔다. 단, 개별 발행 실패
|
||||
(FAILED/DEAD 전이)는 릴레이 use case 내부에서 이미 상태 전이와 ERROR 로그로 처리되어 결과에
|
||||
반영되므로 이 catch 블록까지 오지 않는다 — 여기서 삼키는 것은 어디까지나 "예상치 못한" 예외다.
|
||||
- **`@EnableScheduling`을 직접 켜지 않고 fixed-delay 를 쓴다.** 스케줄링은 이미 `IdempotencyConfig`를
|
||||
통해 활성화돼 있어 중복으로 켤 필요가 없고, fixed-delay 는 릴레이 실행 시간과 무관하게 사이클이
|
||||
겹치지 않도록(non-overlapping) 보장한다.
|
||||
|
||||
### OutboxSettings
|
||||
- **여섯 개 설정값 모두 코드 리터럴 기본값을 쓰고 env placeholder 를 두지 않는다.** 신규 env key 를
|
||||
추가하지 않는다는 결정에 따라, 값이 없으면 컴팩트 생성자에서 직접 기본값(예: `PT5S`, `20`,
|
||||
`PT5M`, `PT10M`, `P7D`)을 채운다.
|
||||
- **`reaper-interval`과 `published-retention`은 `adapter-persistence`의 `OutboxReaper`도 property
|
||||
문자열로 읽는다.** 두 모듈이 같은 키를 공유하지만, 이 properties record 가 여섯 값 전체를 문서화
|
||||
하는 단일 지점(single place) 역할을 한다.
|
||||
|
||||
---
|
||||
|
||||
## logging — 로그 시크릿 마스킹·샘플링·가명화
|
||||
|
||||
### LogMaskingPatterns
|
||||
- **마스킹 정규식 규칙을 한 곳에만 둔 단일 진실 원천(SSOT)이다.** JSON 인코더 경로
|
||||
(`SecretMaskingJsonGeneratorDecorator`, staging/prod/default 프로파일)와 사람이 읽기 쉬운 패턴 경로
|
||||
(`SecretMaskingMessageConverter`, local/dev)가 같은 규칙을 공유한다. 규칙을 한 군데로 모아둔 이유는,
|
||||
프로파일이나 로그 포맷을 바꿔도 가려지는 시크릿의 범위가 절대 달라지지 않게 하기 위해서다. 즉
|
||||
"가독성을 위해 포맷을 바꿨더니 시크릿이 다시 노출되는" 사고를 구조적으로 막는다.
|
||||
- **시크릿 값만 가리고 키/스킴은 남긴다.** `token=abc123` → `token=****`,
|
||||
`Authorization: Bearer eyJ...` → `Authorization: Bearer ****`처럼 동작한다. 키와 인증 스킴(`Bearer`
|
||||
등)을 남기는 이유는 진단할 때 "어떤 종류의 자격증명이 있었는지"는 알아야 하기 때문이다.
|
||||
- **정규식 마스킹은 보증이 아니라 심층 방어(defence-in-depth)의 보조 수단이다.** 1차 방어선은
|
||||
"로거가 애초에 본문/페이로드를 받지 않도록 설계한 것"(`FailOpenDependencyLogger`)이고, 이 정규식은
|
||||
그걸 빠져나간 누출을 잡는 그물이다. 키 접두어 없는 Base64URL 블롭 같은 난독화된 형태는 놓칠 수
|
||||
있으므로, 새로운 누출 형태가 보이면 이 규칙 목록을 운영 중에 계속 보강해야 한다.
|
||||
|
||||
### MetricsAsyncAppender
|
||||
- **백프레셔로 로그가 버려질 때 그 사실을 메트릭으로 노출한다.** Logback `AsyncAppender`는 큐 여유가
|
||||
`discardingThreshold` 아래로 떨어지면 `INFO`/`DEBUG` 같은 낮은 심각도 이벤트를 조용히 버린다
|
||||
(`WARN`/`ERROR`는 항상 보존). 이렇게 조용히 사라지는 드롭은 관측이 안 되면 장애 분석 때 "로그가
|
||||
비어 있는데 왜 비었는지 모르는" 상황을 만들기 때문에, 버릴 때마다 `log.appender.dropped.total`
|
||||
Micrometer 카운터를 올리고(`appender`·`level` 태그) 그다음 상위 클래스에 실제 드롭을 위임한다.
|
||||
- **카운터를 `Metrics.globalRegistry`로 발행한다.** Logback 은 Spring 컨텍스트보다 먼저 초기화되는데,
|
||||
Spring Boot 가 시작 시 애플리케이션 `MeterRegistry`를 이 전역 컴포지트에 합류시킨다. 그래서 초기화
|
||||
순서가 어긋나도 결국 평소 메트릭 엔드포인트에서 이 수치가 보인다.
|
||||
- **`level` 태그를 `INFO`/`DEBUG`로만 한정한다.** `metrics.yaml`의 `allowed_values` 계약 때문이다.
|
||||
루트 레벨을 TRACE 로 낮춰야만 가능한 `TRACE` 드롭은 상위 클래스가 여전히 버리지만 카운트하지는
|
||||
않아서, 태그 카디널리티가 레지스트리 계약을 벗어나지 않게 유지한다.
|
||||
|
||||
### PseudonymizationConfig
|
||||
- **`user_principal`(로그에 남는 사용자 식별자)를 전체 HMAC 방식으로 가명화한다.**
|
||||
`UserPrincipalPseudonymizerPort`(application-core)를 HMAC-SHA-256 구현(adapter-identifier)에
|
||||
바인딩하고, 솔트는 `PrivacySettings`에서 가져온다. 원본 식별자를 그대로 로그에 남기지 않으려는
|
||||
개인정보 보호 결정이다. 실제 사용처는 `adapter-web`의 `RequestLoggingFilter`로, 보안 principal 이
|
||||
MDC/로그에 닿기 전에 이 포트로 가린다.
|
||||
- **`@ConditionalOnMissingBean`으로 기본 구현을 둔다.** 이 템플릿을 포크한 프로젝트가 자기만의
|
||||
가명화기(예: 향후 솔트 회전을 지원하는 구현)를 등록하면 이 기본 빈이 비켜주도록, 즉 기본값은
|
||||
제공하되 교체를 막지 않도록 하기 위해서다.
|
||||
|
||||
### SamplingTurboFilter
|
||||
- **레벨을 의식하는 로그 샘플러다.** `WARN`/`ERROR`는 절대 샘플링하지 않고 항상 통과시킨다
|
||||
(`FilterReply.NEUTRAL`). 진단·장애 신호인 경고/오류는 100% 보장해야 하고, 샘플링 대상은 `INFO`
|
||||
이하만이라는 정책 때문이다. `INFO` 이하는 확률 `rate`로 보존하고 나머지는 `FilterReply.DENY`로
|
||||
버린다.
|
||||
- **단일 `rate` 노브로 제어한다.** `APP_LOG_SAMPLING_RATE`(logback `springProperty`)에서 주입되고,
|
||||
운영자가 프로파일별로 설정한다(prod `0.1` = INFO 10% 샘플링, staging/dev/local `1.0` = 전부 보존).
|
||||
고트래픽 vs 일반 엔드포인트의 더 세밀한 분기는 이 전역 rate 위에 마커 기반 확장으로 문서화만 되어
|
||||
있고 여기서는 구현하지 않았다.
|
||||
- **검증은 "경고 후 기본값(warn-and-default)" 방식이다.** `rate`가 `[0.0, 1.0]` 범위를 벗어나면
|
||||
Logback 상태 시스템에 경고를 남기고 `1.0`(전부 보존)으로 폴백한다. 잘못된 설정이 로그를 소리 없이
|
||||
버리는 쪽으로 가지 않고, 항상 안전한 "샘플링 안 함"으로 degrade 되게 한 선택이다.
|
||||
|
||||
### SecretMaskingJsonGeneratorDecorator
|
||||
- **시크릿 마스킹의 JSON 인코더 쪽 팔이다.** `LogMaskingPatterns` 목록을 미리 적재한
|
||||
`MaskingJsonGeneratorDecorator`로, staging/prod/default 프로파일의 `LogstashEncoder`에 연결된다.
|
||||
JSON 생성 시점에 마스킹하기 때문에 어떤 구조화 필드가 시크릿을 담았든 상관없이 message·MDC
|
||||
값·스택 트레이스 텍스트 등 모든 문자열 값을 덮는다. 이 "어디서 새든 잡는 그물(catch-net)" 성질이
|
||||
Redaction Layer 1에 요구되는 핵심이다.
|
||||
|
||||
### SecretMaskingMessageConverter
|
||||
- **시크릿 마스킹의 사람이 읽는 `PatternLayout` 쪽 팔이다.** local/dev 콘솔 패턴의 `%maskedMsg`
|
||||
변환 워드로 등록되어, 가독성 위주의 인코더도 운영 JSON 경로와 똑같은 마스킹을 적용한다. 사람이
|
||||
읽기 좋은 포맷으로 바꿨다는 이유로 시크릿이 다시 노출되지 않게 하려는 것이다. 여기서는 개발자
|
||||
콘솔의 현실적 누출 벡터인 message 본문만 가린다(JSON 경로는 추가로 MDC·스택 트레이스 값까지
|
||||
마스킹).
|
||||
|
||||
---
|
||||
|
||||
## metrics — 메트릭 계약 MeterFilter 설치
|
||||
|
||||
### MetricsCardinalityMeterFilter
|
||||
- **금지 태그가 붙은 미터를 런타임에서 막는 방어 필터다.** 미터의 `Meter.Id`에 붙은 태그 키 중
|
||||
하나라도 `ForbiddenMetricTags.FORBIDDEN`에 들어 있으면 그 미터를 `DENY`하고, 아니면 `NEUTRAL`을
|
||||
반환한다. `user_id`, `request_id`, `raw_url`, `raw_query`, `raw_header_value`, `ip_address` 같은
|
||||
고카디널리티(high-cardinality) 라벨 키는 고유 값마다 Prometheus 시계열을 하나씩 만들어 수백만
|
||||
개로 폭증할 수 있어 차단한다.
|
||||
- **이 필터는 카디널리티 계약의 런타임(runtime) 절반이다.** 정적(static) 절반은 레지스트리 계약
|
||||
테스트(`MetricsAlertingContractTest`)가 담당한다 — 둘이 짝을 이뤄 심층 방어를 구성한다.
|
||||
- **상태가 없어(stateless) 여러 레지스트리에 공유해도 안전하다.** public no-arg 생성자는
|
||||
`MetricsContractConfig.install()`이 직접 인스턴스화하기 위한 계약이다.
|
||||
|
||||
### MetricsContractConfig
|
||||
- **`@PostConstruct`에서 `MeterFilter`들을 `MeterRegistry`에 직접 설치한다.** `@Bean MeterFilter`로
|
||||
등록하지 않는 이유: 이 템플릿 클래스패스에는 `@Bean MeterFilter`를 자동 수집하는 Spring Boot
|
||||
Actuator 자동설정(`MeterRegistryCustomizer`)이 없어서 `@Bean MeterFilter`는 그냥 동작하지 않는
|
||||
(inert) 죽은 빈이 되기 때문이다. `OutboundHttpResilienceConfig`에서 확립한 선례를 따른다.
|
||||
- **설치 순서가 중요하다.** (1) `MetricsCardinalityMeterFilter`(deny-list)를 먼저 설치해 금지 태그가
|
||||
붙은 미터가 분포 필터에 닿기 전에 거부되도록 하고, (2) 그다음 `MetricsDistributionMeterFilter`
|
||||
(SLO 기반 히스토그램 설정)를 설치한다.
|
||||
- **필터는 설치 이후 등록되는 미터에만 적용된다.** `@PostConstruct`는 Spring 빈 생명주기 중 애플리
|
||||
케이션 코드가 어떤 미터든 등록하기 전에 실행되므로 모든 미터가 필터 적용 대상이 된다.
|
||||
- **`MeterRegistry` 부재 시 no-op.** `ObjectProvider.getIfAvailable()`로 해석해 레지스트리 빈이
|
||||
없으면(Actuator 미탑재) DEBUG 로그만 남기고 설치를 건너뛴다.
|
||||
- **`install(MeterRegistry)`를 public static 으로 둔 이유.** 계약 테스트가 Spring 컨텍스트 없이
|
||||
`SimpleMeterRegistry`에 대해 직접 구동할 수 있게 하기 위함이다.
|
||||
|
||||
### MetricsDistributionMeterFilter
|
||||
- **소유한(owned) 타이머 메트릭 5종에만 SLO 기반 히스토그램·백분위 설정을 적용한다.** 대상:
|
||||
`http.server.requests`, `http.server.requests.latency`, `dependency.client.requests`,
|
||||
`db.query.duration`, `jvm.gc.pause`. 이들은 `metrics.yaml`에서 이 브랜치가 소유하고
|
||||
`histogram_buckets: slo_driven`인 행이다. `resilience4j.circuitbreaker.calls`,
|
||||
`hikaricp.connections.acquire`처럼 소비만 하고 소유하지 않는(consumed-but-not-owned) 행은 다른
|
||||
브랜치 소유라 의도적으로 제외 — 덮어쓰면 안 된다.
|
||||
- **히스토그램 전략은 두 축이다.**
|
||||
- *인스턴스 간 집계 가능한 진실의 원천*: `percentilesHistogram(true)`(= `publishPercentileHistogram`)가
|
||||
`_bucket` 시계열을 생성하고, Prometheus 에서 `histogram_quantile()`로 인스턴스 간 집계할 수 있다.
|
||||
이것이 다중 인스턴스 배포의 정식 p50/p95/p99 다.
|
||||
- *클라이언트 측 편의값(집계 불가)*: `percentiles(0.5, 0.9, 0.95, 0.99)`(= `publishPercentiles`)는
|
||||
단일 인스턴스 가시성을 위한 사전 계산 분위 게이지를 제공한다. 이 값들은 인스턴스 간 평균을 내면
|
||||
안 된다(통계적으로 틀림). 인스턴스 간 집계는 반드시 `histogram_quantile()`로 한다.
|
||||
- **SLO 경계값 `100ms / 500ms / 1s / 5s`는 잠정 SLO 역산이며 외부 표준에서 유도한 값이 아니다.**
|
||||
정식 SLO 가 채택되면 재검토 대상이다. 함께 설정하는 `minimumExpectedValue=1ms`,
|
||||
`maximumExpectedValue=10s`는 히스토그램 버킷의 관측 범위를 한정한다.
|
||||
- **상태가 없어 여러 레지스트리에 공유해도 안전하고,** public no-arg 생성자는
|
||||
`MetricsContractConfig.install()`이 직접 인스턴스화하기 위한 계약이다.
|
||||
|
||||
---
|
||||
|
||||
## management/security — 액추에이터 엔드포인트 보안 체인
|
||||
|
||||
### ManagementSecurityConfig
|
||||
- **`actuatorSecurityFilterChain`은 `@Order(0)`으로 메인 앱 체인(adapter-web `SecurityConfig`)보다
|
||||
앞서 실행된다.** 그래서 액추에이터 엔드포인트에 매칭되는 요청은 앱 체인이 아니라 이 체인이
|
||||
처리한다. 보안 매처는 `EndpointRequest.toAnyEndpoint()`로, 관리 포트의 `/actuator/**` 경로만 이
|
||||
체인 범위에 든다.
|
||||
- **접근 정책.** `health`, `info`, `prometheus`는 permit-all 이다. 자격 증명 없이 Kubernetes 프로브와
|
||||
Prometheus 스크레이프가 접근할 수 있어야 하고, 이 엔드포인트들은 민감 데이터를 노출하지 않기
|
||||
때문이다(health 상세는 when-authorized). 나머지 액추에이터 엔드포인트는 모두 인증이 필요하다.
|
||||
- **`loggers` 쓰기 차단.** 런타임 로그 레벨 변경은 `POST /actuator/loggers/{name}`이고, 레벨 리셋은
|
||||
`DELETE /actuator/loggers/{name}`이다. 둘 다 변형(mutation) 작업이므로 `denyAll()`로 모두에게
|
||||
(인증된 사용자 포함) 거부한다 — 인증만으로 쓰기 권한을 주지 않는다. `loggers` 읽기는 아래
|
||||
`authenticated()`로 흘러 인증을 요구한다.
|
||||
- **인증 실패 시 기본값 403 대신 401(자격 증명 필요)을 반환한다(`HttpStatusEntryPoint(UNAUTHORIZED)`).**
|
||||
대화형 로그인이나 basic-auth realm 은 제공하지 않는다 — 관리 포트는 네트워크 ACL 뒤에 있으므로
|
||||
올바른 HTTP 의미(credentials required)만 신호하면 된다.
|
||||
- **운영에서 deny-by-default 보장은 관리 포트를 별도 포트로 분리해 네트워크 ACL 수준에서 강제한다.**
|
||||
이 in-process 체인은 그 위의 심층 방어 계층이다.
|
||||
- **SHAPE-OWNERSHIP(하드 규칙): 이 클래스는 `HealthEndpoint` / `HealthIndicator` / `HealthComponent`를
|
||||
import·구현·의존해선 안 된다** — health 엔드포인트의 형태(shape)는 runtime-health 브랜치 소유다.
|
||||
보안 매칭은 오직 엔드포인트 id 문자열로만 하고 health 내부 구조에는 절대 손대지 않는다. ArchUnit
|
||||
규칙 `management_security_does_not_depend_on_health_internals`(`CleanArchitectureTest`)가 이를 정적
|
||||
으로 강제한다.
|
||||
|
||||
---
|
||||
|
||||
## tracing — 분산 트레이싱 wiring과 샘플링 정책
|
||||
|
||||
### TracingConfig
|
||||
- **컴포지션 루트에서 트레이싱을 조립한다.** 두 가지를 한다: (1) 시작 시 `tracing.sampling.rate`
|
||||
게이지 등록, (2) `SpanErrorRecorder` 빈 등록.
|
||||
- **`SpanErrorRecorder` 빈은 `@ConditionalOnBean(Tracer.class)`가 아니라 `ObjectProvider<Tracer>`로
|
||||
런타임 존재 여부를 직접 조회한다.** 이유: `@ConditionalOnBean`은 사용자 정의 `@Configuration`에서
|
||||
autoconfiguration 이 만드는 빈(여기서는 `Tracer`)을 조건으로 쓸 때 빈 등록 순서가 보장되지 않아
|
||||
신뢰할 수 없기 때문이다. `Tracer`가 있으면 `MicrometerSpanErrorRecorder`를, 없으면
|
||||
`SpanErrorRecorder.NOOP`을 반환한다.
|
||||
- **`@ConditionalOnMissingBean(SpanErrorRecorder.class)`를 붙여, 테스트 mock 이나 fork 가 자체
|
||||
`SpanErrorRecorder` 빈을 이미 등록한 경우 이 빈은 backoff 한다.** adapter-web 의
|
||||
`GlobalExceptionHandler`는 `ObjectProvider`로 `SpanErrorRecorder`를 조회하므로, 자기 자신의 NOOP
|
||||
self-default 에서 여기서 등록한 실제 구현으로 자동 교체된다.
|
||||
- **OTel/Micrometer tracer 런타임은 이 repo 에서 seam 이 활성 상태다.** `micrometer-tracing-bridge-otel`
|
||||
+ `opentelemetry-exporter-otlp`가 `app-bootstrap/build.gradle`에 실제로 wiring 되어 있다. 단, span
|
||||
exporter 는 `OTEL_EXPORTER_OTLP_ENDPOINT`가 비어 있는 동안 꺼져 있고, 엔드포인트를 설정하면
|
||||
export 가 시작된다.
|
||||
- **게이지 등록 시 active profile 은 `Environment.getActiveProfiles()`의 첫 번째 값을 쓴다.** 멀티
|
||||
프로파일 배포에서는 맨 앞에 나열된 프로파일이 권위 있는 배포 환경이라는 규약이다. `MeterRegistry`가
|
||||
classpath 에 없으면(Actuator 미탑재) 게이지 등록은 조용히 no-op 이 된다.
|
||||
- **`TracingSamplingRateGaugeRegistrar` record 는 등록된 active profile 과 resolved rate 를 노출하는
|
||||
값 홀더다.** 정식 포트나 use case 가 아니라, 살아있는 `MeterRegistry` 없이도 테스트가 "무엇이
|
||||
등록되었는지" 검사할 수 있게 하는 bootstrap 내부 전용 장치다.
|
||||
|
||||
### TracingSettings
|
||||
- **`ca-skeleton.tracing.*` 바인딩 `@ConfigurationProperties` record.** 세 env 키가 들어온다:
|
||||
`APP_TRACING_ENABLED` → `enabled`, `APP_TRACING_SAMPLE_RATE` → `sampleRate`,
|
||||
`OTEL_EXPORTER_OTLP_ENDPOINT` → `exporter.otlpEndpoint`.
|
||||
- **`sampleRate`가 blank 이면 숫자 검증을 건너뛴다.** blank 는 "값이 잘못됐다"가 아니라 "프로파일별
|
||||
기본값에 위임하라"는 신호이기 때문이다. 실제 프로파일별 기본값(prod=0.01, staging=0.10,
|
||||
dev/local=1.0)은 `TracingSampleRateResolver`가 소유하고, resolved 된 값은
|
||||
`TracingSamplingEnvironmentPostProcessor`가 Spring Boot 네이티브
|
||||
`management.tracing.sampling.probability`로 브리지한다.
|
||||
- **검증은 시작 시 fail-fast 다.** `sampleRate`가 non-blank 인데 [0.0, 1.0] float 이 아니면 시작이
|
||||
실패하고, `otlpEndpoint`가 non-empty 인데 scheme 없는 잘못된 URL 이면 시작이 실패한다. `enabled`는
|
||||
Spring 바인딩이 `boolean` 타입을 강제하므로 추가 검증이 필요 없다. `otlpEndpoint`가 비어 있으면
|
||||
exporter off 상태(seam 기본값)이고, 이것이 정상 동작이므로 검증을 통과시킨다.
|
||||
- **`sampleRateValue()`는 호출 전 `sampleRate().isBlank()` 확인이 caller 규약이다.** blank 인 상태로
|
||||
호출하면 `IllegalStateException`을 던지는데, 이는 파싱 오류가 아니라 caller 규약 위반을 드러내기
|
||||
위한 것이다.
|
||||
|
||||
### TracingSampleRateResolver
|
||||
- **프로파일별 트레이싱 샘플 비율 SSOT 다.** prod=0.01(1%), staging=0.10(10%), dev/local=1.0(100%),
|
||||
그 외=1.0. `APP_TRACING_SAMPLE_RATE`가 non-blank 이고 [0,1] float 로 파싱되면 프로파일 기본값을
|
||||
override 한다.
|
||||
- **Spring 의존성이 전혀 없는 순수 Java 로 작성했다.** 어떤 컨텍스트도 없이 단위 테스트할 수 있고,
|
||||
`EnvironmentPostProcessor`(컨텍스트 생성 이전 단계)와 `TracingConfig`(빈 생성 단계) 양쪽에서 동일
|
||||
로직을 재사용하기 위함이다.
|
||||
|
||||
### TracingSamplingRateGauge
|
||||
- **`tracing.sampling.rate` 게이지를 등록한다.** metric 계약: type=gauge, tag=`profile`(prod/staging/
|
||||
dev/local 허용), value=resolved effective sample rate(double in [0.0, 1.0]).
|
||||
- **오직 active Spring profile 의 시리즈 하나만 방출하므로 cardinality 는 1이다.** tag 는 프로파일
|
||||
이름을 정규화하지 않고 그대로 쓴다. 따라서 비표준 프로파일(예: "qa")로 띄워도 시리즈 하나는 정상
|
||||
방출되고, 단지 4-value allowlist 를 벗어날 뿐이다 — 이는 런타임 에러가 아니라 레지스트리 문서상의
|
||||
관심사다.
|
||||
- **`MeterRegistry`를 `ObjectProvider`로 조회하며, Actuator 미탑재로 빈이 없으면 등록을 조용히
|
||||
건너뛴다.** 이는 `BackgroundJobMetrics` / `OutboxMetrics`의 선례를 그대로 따른다.
|
||||
|
||||
### TracingSamplingEnvironmentPostProcessor
|
||||
- **resolved 된 유효 샘플 비율을 Spring Boot 네이티브 키 `management.tracing.sampling.probability`로
|
||||
단일화(브리지)하는 `EnvironmentPostProcessor`다.** 이를 통해 "프로파일별 기본값 + env override"라는
|
||||
우리 규약을 Spring Boot 의 기본 샘플링 메커니즘에 그대로 연결한다.
|
||||
- **브리지 우선순위.** (1) 사용자가 `management.tracing.sampling.probability`를 이미 명시했으면
|
||||
덮어쓰지 않고 그대로 둔다, (2) `ca-skeleton.tracing.sample-rate`가 non-blank 면 그 값 사용, (3)
|
||||
blank 면 `TracingSampleRateResolver.defaultRateForProfile(...)`로 active profile 기본값 적용.
|
||||
- **`META-INF/spring.factories`에 `org.springframework.boot.EnvironmentPostProcessor` 키로 등록된다.**
|
||||
`EnvironmentPostProcessor`는 ApplicationContext 가 생성되기 전에 실행되므로 일반 빈이 아니라
|
||||
Spring Boot bootstrap factory 로 등록해야 한다.
|
||||
|
||||
### MicrometerSpanErrorRecorder
|
||||
- **NOOP 을 대체하는 Micrometer/OTel 기반 실제 `SpanErrorRecorder`다.** 현재 span 에 예외와
|
||||
`error.code` 태그를 기록한다. 계약 이행: `Span.error(Throwable)` 호출(OTel bridge 를 통해
|
||||
`recordException` + span status ERROR 매핑), `span.tag("error.code", errorCode)`(ca-tmpl 레지스트리
|
||||
속성명), null `error`는 방어적 no-op, 현재 span 이 없으면 no-op.
|
||||
- **"sampled span 에만 스택트레이스 부착" 정책은 의도적으로 구현하지 않았다.** Micrometer Tracing
|
||||
추상화 레벨에서는 sampled 여부를 직접 제어할 수 없기 때문이다(`Span.isNoop()`은 OTel NOOP span
|
||||
여부일 뿐 sampling 여부가 아니다). 다만 OTel SDK 의 unsampled span 은 이미 no-op 으로 처리되므로
|
||||
명시적 sampled-only 분기가 없어도 실제 성능 부담은 없다.
|
||||
|
||||
---
|
||||
|
||||
## settings — @ConfigurationProperties 검증 정책
|
||||
|
||||
이 모듈의 설정 record 들은 대체로 두 가지 검증 전략 중 하나를 고른다: **fail-fast**(잘못되면 기동
|
||||
중단)와 **warn-and-default**(경고만 남기고 안전한 기본값으로 진행). 어느 쪽을 쓰는지가 각 record 의
|
||||
핵심 결정이다.
|
||||
|
||||
### BootstrapSettings
|
||||
- **`appName`은 비어 있으면 시작을 실패시킨다(fail-fast).** `ca-skeleton.bootstrap.*` 바인딩이며,
|
||||
앱 이름은 운영자가 직접 제공해야 하고 합리적인 기본값이 존재하지 않으므로, 비어 있거나 blank 면
|
||||
`@NotBlank`로 막는다(Spring Boot 가 `BindValidationException`을 띄우고 컨텍스트 기동을 거부).
|
||||
다른 settings 의 "warn-and-default"와 달리 여기서 fail-fast 를 택한 이유는, 잘못된 앱 이름으로
|
||||
조용히 기동되는 것보다 즉시 멈추는 편이 안전하기 때문이다.
|
||||
|
||||
### LoggingSettings
|
||||
- **모든 항목이 "warn-and-default" 정책이다.** `ca-skeleton.logging.*` 바인딩(원천은 .env). 잘못된
|
||||
값은 경고 로그를 남기고 안전한 기본값으로 진행할 뿐 시작을 실패시키지 않는다.
|
||||
- **fail-fast 대신 warn-and-default 를 택한 이유.** Logback 은 이 record 가 바인딩되기 전에 자기
|
||||
초기화 단계에서 `<springProperty>` 바인딩으로 이미 같은 값들을 자체 기본값과 함께 소비했다. 따라서
|
||||
이 record 의 역할은 로깅을 다시 강제하는 것이 아니라, 입력이 잘못됐을 때 운영자에게 명확한 경고를
|
||||
표면화하는 것이다.
|
||||
- **size 문자열(`maxSize`, `totalSizeCap`)은 검증하지 않는다.** 이 값들의 파싱 계약은 logback 이
|
||||
소유하므로 logback 에게 맡긴다.
|
||||
|
||||
### PrivacySettings
|
||||
- **보안/감사 로그에서 `user_principal`을 가명화하는 데 쓰는 HMAC salt 를 보관한다.**
|
||||
`ca-skeleton.privacy.*` 바인딩(원천 `APP_PRIVACY_PSEUDONYMIZATION_SALT`). 알고리즘은 HMAC-SHA-256
|
||||
+ 90일 회전 salt 이고, 이 salt 는 `secret`-tier 값이라 prod 에서는 반드시 secret manager 에서
|
||||
공급되어야 한다.
|
||||
- **검증은 `LoggingSettings`와 일관되게 "warn-and-default"다.** blank salt 는 경고를 남기고, 명확히
|
||||
표시된 dev sentinel(`__LOCAL_DEV_` 접두사)로 폴백해 local/test 실행이 절대 기동에 실패하지 않게
|
||||
한다.
|
||||
- **이 sentinel 을 prod 로 승격하는 것은 별도 계약이 독립적으로
|
||||
차단한다** — prod 프로파일에서 `__LOCAL_DEV_` 값이면 시작이 실패한다. 그 게이트는 이 record 의
|
||||
책임 밖이라 여기서 중복 강제하지 않는다.
|
||||
|
||||
### RuntimeSafetySettings
|
||||
- **시작 시 `StartupSafetyValidator`가 강제하는 운영 안전 토글이다.** `ca-skeleton.runtime.*`
|
||||
바인딩(원천 `APP_ERROR_DETAIL_EXPOSURE_ENABLED`, `APP_LOG_BODY_CAPTURE_ENABLED`,
|
||||
`APP_MULTI_INSTANCE_ENABLED`, 모두 기본값 `false`).
|
||||
- **`errorDetailExposureEnabled` / `logBodyCaptureEnabled`는 prod-unsafe 토글이다.** `prod`
|
||||
프로파일에서 둘 중 하나라도 켜면 시작이 실패한다. 그래야 내부 에러 상세나 요청 본문 캡처가
|
||||
프로덕션에서 조용히 켜진 채 남는 일이 없다.
|
||||
- **`multiInstanceEnabled`가 `true`면 인스턴스 조율 capability 빈들(lock / cache-stampede / leader /
|
||||
rate-limit / migration)이 모두 존재하는지 단언하고, 하나라도 없으면 시작을 실패시킨다.** 멀티
|
||||
인스턴스 모드를 켜놓고 조율 인프라가 빠진 채 기동되는 위험한 상태를 막기 위함이다.
|
||||
|
||||
---
|
||||
|
||||
## build.gradle — 의존성 구성 근거
|
||||
|
||||
app-bootstrap 은 합성 루트라 "왜 이 의존성이, 왜 이 scope 로" 결정이 많다. 빌드 파일에는 한 줄
|
||||
요약만 두고, 비자명한 근거는 여기에 모은다. 바탕 원칙은 **api vs implementation 정책**(CLAUDE.md):
|
||||
모듈 간 의존은 기본 `implementation`이라 transitive 로 새지 않는다 — 그래서 테스트에서 그 타입이
|
||||
필요하면 여기서 **명시적으로** 다시 선언한다.
|
||||
|
||||
### 런타임(production) 의존성
|
||||
- **`flyway-core`를 직접 의존하는 이유.** PostgreSQL vendor 모듈이 `flyway-database-postgresql`와
|
||||
마이그레이션 스크립트를 소유하지만, 합성 루트는 Flyway API 자체가 필요하다. `MigrationStartupConfig`의
|
||||
`migrationStartupRunner`(FlywayMigrationStrategy)가 `migrate()`를 직접 구동하고 `FlywayException`을
|
||||
exit-70 `MigrationFailedException`으로 번역하기 때문이다.
|
||||
- **`spring-boot-starter-security`를 compile classpath 에 두는 이유.** adapter-web 이 security 를
|
||||
`implementation`(not `api`)으로 선언해서 Spring Security 타입이 app-bootstrap 컴파일 경로로 새지
|
||||
않는다. 그런데 `ManagementSecurityConfig`가 `HttpSecurity`/`SecurityFilterChain`/`EndpointRequest`를
|
||||
쓴다. cross-cutting 보안 와이어링은 합성 루트가 소유한다는 원칙(AGENTS.md)에 따라 여기서 직접
|
||||
의존한다.
|
||||
- **`micrometer-core`** — `OutboxMetrics` 카운터/게이지용. `ObjectProvider<MeterRegistry>`라 registry
|
||||
가 없으면 no-op 이다(registry 는 Actuator 가 제공).
|
||||
- **tracing(`micrometer-tracing-bridge-otel` + `opentelemetry-exporter-otlp`)** — OTel/Micrometer
|
||||
tracer 런타임. exporter 는 `OTEL_EXPORTER_OTLP_ENDPOINT`가 비면 켜진 채로 대기만 하고 export 는
|
||||
안 한다. 버전은 Spring Boot BOM 이 관리.
|
||||
- **`logstash-logback-encoder`를 `runtimeOnly`가 아니라 `implementation`으로 올린 이유.** 보통 JSON
|
||||
로그 인코더는 런타임에만 있으면 되지만, `StartupFailures`가 `StructuredArguments`를 **compile
|
||||
time**에 호출해 `startup.phase`/`error.code`/`error.category` JSON 필드를 방출하므로 compile
|
||||
classpath 에 있어야 한다.
|
||||
|
||||
### 테스트 의존성 (transitive 로 안 새서 명시 선언)
|
||||
- **`testImplementation project(':adapter-persistence-postgresql')`** — vendor 클래스
|
||||
(`PostgreSqlSqlStateErrorMapping`/`PostgreSqlOutboxClaimRepository`)를 test compile 경로에 올려,
|
||||
full-matrix contract 테스트가 실제 production composition 을 배선하게 한다(런타임 의존은 `runtimeOnly`).
|
||||
- **`testRuntimeOnly postgresql`** — PG JDBC 드라이버. vendor 모듈이 `runtimeOnly`로 선언해
|
||||
app-bootstrap 테스트 경로로 안 새므로 여기서 명시한다.
|
||||
- **`spring-boot-starter-data-jpa` + `HikariCP`** — adapter-persistence-rdbms 가 `implementation`이라
|
||||
JPA/Hikari 가 compile 경로로 오지 않는다. outbox contract 테스트가 minimal Spring context 를 직접
|
||||
만들어 이들이 컴파일 시점에 필요하다.
|
||||
- **`spring-integration-jdbc`** — `JdbcLockRegistry`/`DefaultLockRepository`도 rdbms 의
|
||||
`implementation`이라 안 샌다. `DistributedLockProviderContractTest`가 같은 Testcontainers PG
|
||||
DataSource 에 대해 독립 registry 두 개(= 두 인스턴스 시뮬레이션)를 직접 만들어 상호배제·리스 만료를
|
||||
검증한다. 테스트 전용 — production 와이어링은 전부 `DistributedLockPersistenceConfig`(rdbms) 소유.
|
||||
- **`spring-boot-starter-json`** — `JacksonAutoConfiguration`은 jackson-databind 가 test 경로에 있어야
|
||||
활성화되고, deserialization-policy 경계 테스트가 `DeserializationFeature` enum 을 직접 읽는다.
|
||||
- **Testcontainers(`postgresql`/`junit-jupiter`)** — outbox contract 테스트용. 버전은 Spring Boot
|
||||
BOM 이 관리.
|
||||
- **`spring-security-test`** — `@WithMockUser`로 actuator 보안 인가(permit-all 프로브 / authenticated
|
||||
loggers / loggers 쓰기 거부)를 검증한다.
|
||||
|
||||
### ArchUnit "violation-as-data" fixture 의존성 (test 컴파일러 전용)
|
||||
ArchUnit 규칙이 **금지**하는 타입을 fixture 가 일부러 import 해서, 규칙이 실제로 그 위반을 잡는지
|
||||
증명한다. production classpath 엔 없어야 하므로 전부 `testCompileOnly`이고, 각 금지 glob 을 독립적으로
|
||||
증명하려고 의존성을 쪼개 둔다.
|
||||
- `spring-tx` — `..architecture.violations.*`의 일반 위반 fixture.
|
||||
- `spring-webmvc` / `spring-websocket` / `jakarta.websocket-api` — streaming 위반 fixture +
|
||||
over-block 가드(streaming-response-contract). spring-web(`org.springframework.http..`)는
|
||||
spring-webmvc 를 통해 transitive 로 도착한다.
|
||||
- `kafka-clients` / `jakarta.ws.rs-api` — transport-free 도메인 이벤트 fixture
|
||||
(domain-modeling-guardrails)가 금지된 broker/wire/HTTP 패키지를 import 한다.
|
||||
- `spring-cloud-context` — `@RefreshScope` 금지(no-refresh-scope) fixture 용. Spring Cloud 는
|
||||
production 에 없고 규칙은 애너테이션을 FQN 문자열로 참조한다. BOM 이 spring-cloud 좌표를 관리하지
|
||||
않아 버전을 명시 고정한다.
|
||||
- **`testImplementation project(':sample-portfolio')`** — ArchUnit 이 템플릿 reference 구현을 분석하려고
|
||||
test 경로에만 둔다. **production 은 절대 sample-portfolio 에 의존 금지** —
|
||||
`production_code_does_not_depend_on_sample_portfolio` 규칙이 강제한다.
|
||||
|
||||
### 빌드 설정
|
||||
- **test JVM UTC 고정(`-Duser.timezone=UTC`)** — `RuntimeHealthLifecycleContractTest`가
|
||||
`TimeZone.getDefault().getID() == "UTC"`를 단언해, 호스트 로케일과 무관하게 타임스탬프 산술이
|
||||
결정적이게 한다. production UTC 는 여기서 강제하지 않으며 container-runtime-contract(Dockerfile
|
||||
`TZ=UTC`)가 소유한다.
|
||||
- **`bootRun.workingDir = rootProject.projectDir`** — `src/.env`를 읽도록 Gradle 루트(src/)에서
|
||||
실행한다.
|
||||
@@ -0,0 +1,192 @@
|
||||
// Application entry point. Wires every module together and runs Spring Boot.
|
||||
apply plugin: 'org.springframework.boot'
|
||||
|
||||
// The sample fixture (sampleFixture -> sample-portfolio -> objectstorage) pulls software.amazon.awssdk:s3
|
||||
// onto the sample-on test classpath; its version is managed by the AWS SDK v2 BOM (NOT the Spring Boot
|
||||
// BOM). Import that BOM at this module's scope so the transitive s3 dependency resolves for the ArchUnit
|
||||
// sample-on analysis. AWS-SDK version SSOT = ext.awsSdkVersion.
|
||||
dependencyManagement {
|
||||
imports {
|
||||
mavenBom "software.amazon.awssdk:bom:${awsSdkVersion}"
|
||||
}
|
||||
}
|
||||
|
||||
// feature-sample-removal-adoption-contract D7 — ordinary tests may inspect the sample fixture,
|
||||
// while sampleOffTest compiles the same core suite without the sample project on either classpath.
|
||||
configurations {
|
||||
sampleFixture {
|
||||
canBeConsumed = false
|
||||
canBeResolved = false
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
sampleOffTest {
|
||||
java.srcDirs = sourceSets.test.java.srcDirs
|
||||
resources.srcDirs = sourceSets.test.resources.srcDirs
|
||||
compileClasspath += sourceSets.main.output
|
||||
runtimeClasspath += sourceSets.main.output
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
testCompileClasspath.extendsFrom sampleFixture
|
||||
testRuntimeClasspath.extendsFrom sampleFixture
|
||||
sampleOffTestImplementation.extendsFrom testImplementation
|
||||
sampleOffTestCompileOnly.extendsFrom testCompileOnly
|
||||
sampleOffTestRuntimeOnly.extendsFrom testRuntimeOnly
|
||||
sampleOffTestAnnotationProcessor.extendsFrom testAnnotationProcessor
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':domain-core')
|
||||
implementation project(':application-core')
|
||||
implementation project(':adapter:outbound:persistence-jpa')
|
||||
implementation project(':adapter:outbound:support')
|
||||
implementation project(':adapter:outbound:messaging')
|
||||
implementation project(':adapter:outbound:cache-redis')
|
||||
implementation project(':adapter:outbound:notification')
|
||||
implementation project(':adapter:outbound:httpclient')
|
||||
implementation project(':adapter:outbound:identifier')
|
||||
implementation project(':adapter:inbound:web')
|
||||
implementation project(':shared-contract')
|
||||
implementation 'org.springframework.boot:spring-boot-starter'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
implementation 'me.paulschwarz:spring-dotenv:4.0.0'
|
||||
// Boot 4 Flyway API/autoconfiguration: the composition root drives startup migration
|
||||
// (MigrationStartupConfig). See README.
|
||||
implementation 'org.springframework.boot:spring-boot-flyway'
|
||||
// Flyway API: MigrationStartupRunner directly invokes Flyway. Kept explicit for readability.
|
||||
implementation 'org.flywaydb:flyway-core'
|
||||
|
||||
// Micrometer core for OutboxMetrics meters (no-op without a MeterRegistry). See README.
|
||||
implementation 'io.micrometer:micrometer-core'
|
||||
|
||||
// OTel/Micrometer tracer runtime (exporter stays off until an OTLP endpoint is set). See README.
|
||||
implementation 'io.micrometer:micrometer-tracing-bridge-otel'
|
||||
implementation 'io.opentelemetry:opentelemetry-exporter-otlp'
|
||||
|
||||
// Actuator + Prometheus registry (health/info/prometheus/loggers endpoints).
|
||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||
implementation 'io.micrometer:micrometer-registry-prometheus'
|
||||
// Security types for ManagementSecurityConfig (not reachable via adapter-web's implementation dep). See README.
|
||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
|
||||
// test-only: ArchUnit needs actuator types to verify the health-shape guardrail. See README.
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||
// test-only: @WithMockUser for the actuator security authorization tests. See README.
|
||||
testImplementation 'org.springframework.security:spring-security-test'
|
||||
|
||||
// test-only: Testcontainers PostgreSQL for the outbox contract tests.
|
||||
testImplementation 'org.testcontainers:testcontainers-postgresql'
|
||||
testImplementation 'org.testcontainers:testcontainers-junit-jupiter'
|
||||
// test-only: Boot 4 split JPA slice annotations into dedicated test modules.
|
||||
testImplementation 'org.springframework.boot:spring-boot-data-jpa-test'
|
||||
// test-only: PG JDBC driver (the vendor module's runtimeOnly does not leak here). See README.
|
||||
testRuntimeOnly 'org.postgresql:postgresql'
|
||||
// test-only: JPA/Hikari for the outbox tests' minimal context (not leaked from rdbms). See README.
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
testImplementation 'com.zaxxer:HikariCP'
|
||||
|
||||
// test-only: JdbcLockRegistry for the distributed-lock contract test (two simulated instances). See README.
|
||||
testImplementation 'org.springframework.integration:spring-integration-jdbc'
|
||||
|
||||
testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'
|
||||
// test-only: jackson-databind for the deserialization-policy boundary test. See README.
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-json'
|
||||
// sample-on only: ArchUnit analyses the reference impl. sampleOffTest intentionally omits it.
|
||||
sampleFixture project(':sample-portfolio')
|
||||
// test-only: ArchUnit violation fixtures intentionally import forbidden types. See README.
|
||||
testCompileOnly 'org.springframework:spring-tx'
|
||||
// test-only: streaming/websocket violation fixtures import these forbidden packages. See README.
|
||||
testCompileOnly 'org.springframework:spring-webmvc' // SseEmitter, ResponseBodyEmitter, StreamingResponseBody
|
||||
testCompileOnly 'org.springframework:spring-websocket' // org.springframework.web.socket..
|
||||
testCompileOnly 'jakarta.websocket:jakarta.websocket-api' // jakarta.websocket..
|
||||
// test-only: transport-free domain-event fixtures import these forbidden broker/wire packages. See README.
|
||||
testCompileOnly 'org.apache.kafka:kafka-clients' // org.apache.kafka..
|
||||
testCompileOnly 'jakarta.ws.rs:jakarta.ws.rs-api' // jakarta.ws.rs..
|
||||
// test-only: @RefreshScope for the no-refresh-scope violation fixture (version pinned; not in the BOM). See README.
|
||||
testCompileOnly 'org.springframework.cloud:spring-cloud-context:4.1.4' // org.springframework.cloud.context..
|
||||
|
||||
// JSON log encoder; implementation (not runtimeOnly) because StartupFailures uses StructuredArguments at compile time. See README.
|
||||
implementation 'net.logstash.logback:logstash-logback-encoder:8.0'
|
||||
|
||||
// test-only: ApprovalTests JSON snapshot verification for the contract-verification suite
|
||||
// (feature-contract-verification-test-suite D7 — envelope/error shape snapshots). See README.
|
||||
testImplementation 'com.approvaltests:approvaltests:31.0.0'
|
||||
// test-only: JUnit Platform Test Kit — proves optional-adapter tests report SKIPPED (never FAILED)
|
||||
// when their enable-flag env var is unset (feature-contract-verification-test-suite D3, Claims #7). See README.
|
||||
testImplementation 'org.junit.platform:junit-platform-testkit'
|
||||
}
|
||||
|
||||
// Pin UTC for the TEST JVM so timestamp tests are host-locale-independent (production UTC owned elsewhere). See README.
|
||||
tasks.named('test') {
|
||||
jvmArgs '-Duser.timezone=UTC'
|
||||
}
|
||||
|
||||
tasks.register('sampleOffTest', Test) {
|
||||
group = 'verification'
|
||||
description = 'Compiles and runs the core test suite without sample-portfolio on the classpath.'
|
||||
testClassesDirs = sourceSets.sampleOffTest.output.classesDirs
|
||||
classpath = sourceSets.sampleOffTest.runtimeClasspath
|
||||
useJUnitPlatform {
|
||||
excludeTags 'quarantine'
|
||||
}
|
||||
shouldRunAfter tasks.named('test')
|
||||
outputs.upToDateWhen { false }
|
||||
systemProperty 'ca.sample.mode', 'off'
|
||||
jvmArgs '-Duser.timezone=UTC'
|
||||
}
|
||||
|
||||
// The custom source set compiles the same test corpus, so it follows the repository-wide
|
||||
// warning-only policy already applied to checkstyleTest and spotbugsTest in the root build.
|
||||
tasks.named('checkstyleSampleOffTest') {
|
||||
ignoreFailures = true
|
||||
}
|
||||
|
||||
tasks.named('spotbugsSampleOffTest') {
|
||||
ignoreFailures = true
|
||||
}
|
||||
|
||||
// feature-developer-experience-contract D2/D7 delegation: the DX entrypoint verifies production
|
||||
// isolation and the sample-on build contract. sample-off is the separate sampleOffTest task.
|
||||
tasks.register('bootstrapSampleContract', Test) {
|
||||
group = 'developer experience'
|
||||
description = 'Runs the delegated sample production-isolation/build contract for bootstrap.'
|
||||
testClassesDirs = sourceSets.test.output.classesDirs
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
useJUnitPlatform()
|
||||
filter {
|
||||
includeTestsMatching 'dev.caskeleton.bootstrap.contract.SampleRemovalSmokeContractTest'
|
||||
}
|
||||
outputs.upToDateWhen { false }
|
||||
jvmArgs '-Duser.timezone=UTC'
|
||||
}
|
||||
|
||||
bootJar {
|
||||
mainClass = 'dev.caskeleton.bootstrap.CaSkeletonApplication'
|
||||
}
|
||||
|
||||
// Run from the repo's src/ root and inject src/.env into the Java process environment.
|
||||
// Boot 4 initializes profiles/logging before spring-dotenv can reliably contribute .env values.
|
||||
bootRun {
|
||||
workingDir = rootProject.projectDir
|
||||
doFirst {
|
||||
File envFile = rootProject.file('.env')
|
||||
if (!envFile.isFile()) {
|
||||
return
|
||||
}
|
||||
envFile.eachLine { raw ->
|
||||
String line = raw.trim()
|
||||
if (line.isEmpty() || line.startsWith('#') || !line.contains('=')) {
|
||||
return
|
||||
}
|
||||
int separator = line.indexOf('=')
|
||||
String key = line.substring(0, separator).trim()
|
||||
String value = line.substring(separator + 1).trim()
|
||||
if (!key.isEmpty() && System.getenv(key) == null && !environment.containsKey(key)) {
|
||||
environment key, value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
aopalliance:aopalliance:1.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-classic:1.5.34=sampleFixture
|
||||
ch.qos.logback:logback-core:1.5.21=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.34=sampleFixture
|
||||
com.approvaltests:approvaltests-util:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.approvaltests:approvaltests:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.21=sampleFixture
|
||||
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-core:2.21.4=sampleFixture
|
||||
com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-databind:2.21.4=sampleFixture
|
||||
com.fasterxml.jackson.dataformat:jackson-dataformat-toml:2.21.4=sampleFixture
|
||||
com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.21.4=sampleFixture
|
||||
com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.21.4=sampleFixture
|
||||
com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.21.4=sampleFixture
|
||||
com.fasterxml.jackson.module:jackson-module-parameter-names:2.21.4=sampleFixture
|
||||
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson:jackson-bom:2.21.4=sampleFixture
|
||||
com.fasterxml:classmate:1.7.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml:classmate:1.7.3=sampleFixture
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.github.docker-java:docker-java-api:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.github.docker-java:docker-java-transport-zerodep:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.github.docker-java:docker-java-transport:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.github.f4b6a3:uuid-creator:6.1.1=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=sampleFixture,spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,sampleOffTestCompileClasspath,spotbugs,testCompileClasspath
|
||||
com.google.code.gson:gson:2.13.2=spotbugs
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,sampleOffTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:guava:33.6.0-jre=checkstyle
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,sampleOffTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,sampleOffTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.jayway.jsonpath:json-path:2.9.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.nimbusds:nimbus-jose-jwt:10.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
com.nimbusds:nimbus-jose-jwt:9.37.4=sampleFixture
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
com.squareup.okhttp3:okhttp-jvm:5.2.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
com.squareup.okhttp3:okhttp:4.12.0=sampleFixture
|
||||
com.squareup.okhttp3:okhttp:5.2.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
com.squareup.okio:okio-jvm:3.16.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
com.squareup.okio:okio-jvm:3.6.0=sampleFixture
|
||||
com.squareup.okio:okio:3.16.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
com.squareup.okio:okio:3.6.0=sampleFixture
|
||||
com.sun.istack:istack-commons-runtime:4.1.2=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
com.tngtech.archunit:archunit-junit5-api:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
com.tngtech.archunit:archunit-junit5-engine:1.3.0=sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
com.tngtech.archunit:archunit-junit5:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.tngtech.archunit:archunit:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.zaxxer:HikariCP:6.3.3=sampleFixture
|
||||
com.zaxxer:HikariCP:7.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-codec:commons-codec:1.19.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.20.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
commons-logging:commons-logging:1.3.5=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.cdimascio:dotenv-java:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
|
||||
io.github.resilience4j:resilience4j-bulkhead:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.github.resilience4j:resilience4j-circuitbreaker:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.github.resilience4j:resilience4j-core:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.github.resilience4j:resilience4j-micrometer:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.github.resilience4j:resilience4j-ratelimiter:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.github.resilience4j:resilience4j-retry:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.github.resilience4j:resilience4j-timelimiter:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.micrometer:context-propagation:1.1.4=sampleFixture
|
||||
io.micrometer:context-propagation:1.2.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-commons:1.15.12=sampleFixture
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-core:1.15.12=sampleFixture
|
||||
io.micrometer:micrometer-core:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-jakarta9:1.15.12=sampleFixture
|
||||
io.micrometer:micrometer-jakarta9:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.15.12=sampleFixture
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-registry-prometheus:1.15.12=sampleFixture
|
||||
io.micrometer:micrometer-registry-prometheus:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-tracing-bridge-otel:1.5.12=sampleFixture
|
||||
io.micrometer:micrometer-tracing-bridge-otel:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-tracing:1.5.12=sampleFixture
|
||||
io.micrometer:micrometer-tracing:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-buffer:4.2.7.Final=testRuntimeClasspath
|
||||
io.netty:netty-codec-base:4.2.7.Final=testRuntimeClasspath
|
||||
io.netty:netty-codec-compression:4.2.7.Final=testRuntimeClasspath
|
||||
io.netty:netty-codec-http2:4.2.7.Final=testRuntimeClasspath
|
||||
io.netty:netty-codec-http:4.2.7.Final=testRuntimeClasspath
|
||||
io.netty:netty-codec-marshalling:4.2.7.Final=testRuntimeClasspath
|
||||
io.netty:netty-codec-protobuf:4.2.7.Final=testRuntimeClasspath
|
||||
io.netty:netty-codec:4.2.7.Final=testRuntimeClasspath
|
||||
io.netty:netty-common:4.2.7.Final=testRuntimeClasspath
|
||||
io.netty:netty-handler:4.2.7.Final=testRuntimeClasspath
|
||||
io.netty:netty-resolver:4.2.7.Final=testRuntimeClasspath
|
||||
io.netty:netty-transport-classes-epoll:4.2.7.Final=testRuntimeClasspath
|
||||
io.netty:netty-transport-native-unix-common:4.2.7.Final=testRuntimeClasspath
|
||||
io.netty:netty-transport:4.2.7.Final=testRuntimeClasspath
|
||||
io.opentelemetry.semconv:opentelemetry-semconv:1.32.0=sampleFixture
|
||||
io.opentelemetry.semconv:opentelemetry-semconv:1.37.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.opentelemetry:opentelemetry-api:1.49.0=sampleFixture
|
||||
io.opentelemetry:opentelemetry-api:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.opentelemetry:opentelemetry-common:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.opentelemetry:opentelemetry-context:1.49.0=sampleFixture
|
||||
io.opentelemetry:opentelemetry-context:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.opentelemetry:opentelemetry-exporter-common:1.49.0=sampleFixture
|
||||
io.opentelemetry:opentelemetry-exporter-common:1.55.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.opentelemetry:opentelemetry-exporter-otlp-common:1.49.0=sampleFixture
|
||||
io.opentelemetry:opentelemetry-exporter-otlp-common:1.55.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.opentelemetry:opentelemetry-exporter-otlp:1.49.0=sampleFixture
|
||||
io.opentelemetry:opentelemetry-exporter-otlp:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.opentelemetry:opentelemetry-exporter-sender-okhttp:1.49.0=sampleFixture
|
||||
io.opentelemetry:opentelemetry-exporter-sender-okhttp:1.55.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.opentelemetry:opentelemetry-extension-trace-propagators:1.49.0=sampleFixture
|
||||
io.opentelemetry:opentelemetry-extension-trace-propagators:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.opentelemetry:opentelemetry-sdk-common:1.49.0=sampleFixture
|
||||
io.opentelemetry:opentelemetry-sdk-common:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:1.49.0=sampleFixture
|
||||
io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:1.55.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.opentelemetry:opentelemetry-sdk-logs:1.49.0=sampleFixture
|
||||
io.opentelemetry:opentelemetry-sdk-logs:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.opentelemetry:opentelemetry-sdk-metrics:1.49.0=sampleFixture
|
||||
io.opentelemetry:opentelemetry-sdk-metrics:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.opentelemetry:opentelemetry-sdk-trace:1.49.0=sampleFixture
|
||||
io.opentelemetry:opentelemetry-sdk-trace:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.opentelemetry:opentelemetry-sdk:1.49.0=sampleFixture
|
||||
io.opentelemetry:opentelemetry-sdk:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.projectreactor:reactor-core:3.7.19=sampleFixture
|
||||
io.projectreactor:reactor-core:3.8.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.prometheus:prometheus-metrics-config:1.3.10=sampleFixture
|
||||
io.prometheus:prometheus-metrics-config:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.prometheus:prometheus-metrics-core:1.3.10=sampleFixture
|
||||
io.prometheus:prometheus-metrics-core:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.prometheus:prometheus-metrics-exposition-formats:1.3.10=sampleFixture
|
||||
io.prometheus:prometheus-metrics-exposition-formats:1.4.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.prometheus:prometheus-metrics-exposition-textformats:1.3.10=sampleFixture
|
||||
io.prometheus:prometheus-metrics-exposition-textformats:1.4.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.prometheus:prometheus-metrics-model:1.3.10=sampleFixture
|
||||
io.prometheus:prometheus-metrics-model:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.prometheus:prometheus-metrics-tracer-common:1.3.10=sampleFixture
|
||||
io.prometheus:prometheus-metrics-tracer-common:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.smallrye:jandex:3.2.0=sampleFixture
|
||||
io.swagger.core.v3:swagger-annotations-jakarta:2.2.29=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.swagger.core.v3:swagger-core-jakarta:2.2.29=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
io.swagger.core.v3:swagger-models-jakarta:2.2.29=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:2.1.1=sampleFixture
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.inject:jakarta.inject-api:2.0.1=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
jakarta.persistence:jakarta.persistence-api:3.1.0=sampleFixture
|
||||
jakarta.persistence:jakarta.persistence-api:3.2.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.transaction:jakarta.transaction-api:2.0.1=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.validation:jakarta.validation-api:3.0.2=sampleFixture
|
||||
jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.websocket:jakarta.websocket-api:2.2.0=sampleOffTestCompileClasspath,testCompileClasspath
|
||||
jakarta.ws.rs:jakarta.ws.rs-api:4.0.0=sampleOffTestCompileClasspath,testCompileClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=sampleFixture
|
||||
javax.inject:javax.inject:1=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
me.paulschwarz:spring-dotenv:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy-agent:1.17.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy:1.17.8=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
net.java.dev.jna:jna:5.18.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
net.logstash.logback:logstash-logback-encoder:8.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:accessors-smart:2.6.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:json-smart:2.6.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-compress:1.28.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle,testRuntimeClasspath
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle,testRuntimeClasspath
|
||||
org.apache.kafka:kafka-clients:4.1.1=sampleOffTestCompileClasspath,testCompileClasspath
|
||||
org.apache.logging.log4j:log4j-api:2.24.3=sampleFixture
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.24.3=sampleFixture
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.tomcat.embed:tomcat-embed-core:10.1.55=sampleFixture
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:10.1.55=sampleFixture
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:10.1.55=sampleFixture
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=sampleOffTestCompileClasspath,testCompileClasspath
|
||||
org.aspectj:aspectjweaver:1.9.25=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.aspectj:aspectjweaver:1.9.25.1=sampleFixture
|
||||
org.assertj:assertj-core:3.27.6=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.awaitility:awaitility:4.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.checkerframework:checker-qual:3.49.5=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||
org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.eclipse.angus:angus-activation:2.0.3=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.flywaydb:flyway-core:11.14.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.flywaydb:flyway-core:11.7.2=sampleFixture
|
||||
org.flywaydb:flyway-database-postgresql:11.14.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.flywaydb:flyway-database-postgresql:11.7.2=sampleFixture
|
||||
org.glassfish.jaxb:jaxb-core:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.glassfish.jaxb:jaxb-core:4.0.9=sampleFixture
|
||||
org.glassfish.jaxb:jaxb-runtime:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.glassfish.jaxb:jaxb-runtime:4.0.9=sampleFixture
|
||||
org.glassfish.jaxb:txw2:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.glassfish.jaxb:txw2:4.0.9=sampleFixture
|
||||
org.hamcrest:hamcrest:3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.hdrhistogram:HdrHistogram:2.2.2=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.hibernate.common:hibernate-commons-annotations:7.0.3.Final=sampleFixture
|
||||
org.hibernate.models:hibernate-models:1.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.hibernate.orm:hibernate-core:6.6.53.Final=sampleFixture
|
||||
org.hibernate.orm:hibernate-core:7.1.8.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.hibernate.validator:hibernate-validator:8.0.3.Final=sampleFixture
|
||||
org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.jboss.logging:jboss-logging:3.6.3.Final=sampleFixture
|
||||
org.jetbrains.kotlin:kotlin-stdlib-common:1.9.25=sampleFixture
|
||||
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.25=sampleFixture
|
||||
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.25=sampleFixture
|
||||
org.jetbrains.kotlin:kotlin-stdlib:1.9.25=sampleFixture
|
||||
org.jetbrains.kotlin:kotlin-stdlib:2.2.21=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.jetbrains:annotations:13.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture
|
||||
org.jetbrains:annotations:17.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestAnnotationProcessor,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.1=sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-testkit:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.latencyutils:LatencyUtils:2.0.3=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-core:5.20.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-junit-jupiter:5.20.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.objenesis:objenesis:3.3=sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.openapitools:jackson-databind-nullable:0.2.6=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.opentest4j:opentest4j:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.resource:1.0.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.7.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
|
||||
org.postgresql:postgresql:42.7.11=sampleFixture
|
||||
org.postgresql:postgresql:42.7.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.reactivestreams:reactive-streams:1.0.4=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.rnorth.duct-tape:duct-tape:1.0.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.skyscreamer:jsonassert:1.5.3=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.18=sampleFixture
|
||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.18=sampleFixture
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.springdoc:springdoc-openapi-starter-common:2.8.6=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.6=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-actuator-autoconfigure:3.5.16=sampleFixture
|
||||
org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-actuator:3.5.16=sampleFixture
|
||||
org.springframework.boot:spring-boot-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-autoconfigure:3.5.16=sampleFixture
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-data-jpa-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-health:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-hibernate:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jdbc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jpa-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-micrometer-observation:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-restclient:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-actuator:3.5.16=sampleFixture
|
||||
org.springframework.boot:spring-boot-starter-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-data-jpa:3.5.16=sampleFixture
|
||||
org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-flyway:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jdbc:3.5.16=sampleFixture
|
||||
org.springframework.boot:spring-boot-starter-jdbc:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-json:3.5.16=sampleFixture
|
||||
org.springframework.boot:spring-boot-starter-json:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:3.5.16=sampleFixture
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-oauth2-resource-server:3.5.16=sampleFixture
|
||||
org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-security:3.5.16=sampleFixture
|
||||
org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:3.5.16=sampleFixture
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-validation:3.5.16=sampleFixture
|
||||
org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-web:3.5.16=sampleFixture
|
||||
org.springframework.boot:spring-boot-starter-web:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:3.5.16=sampleFixture
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:3.5.16=sampleFixture
|
||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.cloud:spring-cloud-context:4.1.4=sampleOffTestCompileClasspath,testCompileClasspath
|
||||
org.springframework.data:spring-data-commons:3.5.13=sampleFixture
|
||||
org.springframework.data:spring-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.data:spring-data-jpa:3.5.13=sampleFixture
|
||||
org.springframework.data:spring-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.integration:spring-integration-core:6.5.10=sampleFixture
|
||||
org.springframework.integration:spring-integration-core:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.integration:spring-integration-jdbc:6.5.10=sampleFixture
|
||||
org.springframework.integration:spring-integration-jdbc:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.retry:spring-retry:2.0.13=sampleFixture
|
||||
org.springframework.security:spring-security-config:6.5.11=sampleFixture
|
||||
org.springframework.security:spring-security-config:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-core:6.5.11=sampleFixture
|
||||
org.springframework.security:spring-security-core:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-crypto:6.5.11=sampleFixture
|
||||
org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-oauth2-core:6.5.11=sampleFixture
|
||||
org.springframework.security:spring-security-oauth2-core:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-oauth2-jose:6.5.11=sampleFixture
|
||||
org.springframework.security:spring-security-oauth2-jose:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-oauth2-resource-server:6.5.11=sampleFixture
|
||||
org.springframework.security:spring-security-oauth2-resource-server:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-test:7.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-web:6.5.11=sampleFixture
|
||||
org.springframework.security:spring-security-web:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:6.2.19=sampleFixture
|
||||
org.springframework:spring-aop:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aspects:6.2.19=sampleFixture
|
||||
org.springframework:spring-aspects:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:6.2.19=sampleFixture
|
||||
org.springframework:spring-beans:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:6.2.19=sampleFixture
|
||||
org.springframework:spring-context:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:6.2.19=sampleFixture
|
||||
org.springframework:spring-core:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:6.2.19=sampleFixture
|
||||
org.springframework:spring-expression:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-jcl:6.2.19=sampleFixture
|
||||
org.springframework:spring-jdbc:6.2.19=sampleFixture
|
||||
org.springframework:spring-jdbc:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-messaging:6.2.19=sampleFixture
|
||||
org.springframework:spring-messaging:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-orm:6.2.19=sampleFixture
|
||||
org.springframework:spring-orm:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-tx:6.2.19=sampleFixture
|
||||
org.springframework:spring-tx:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:6.2.19=sampleFixture
|
||||
org.springframework:spring-web:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:6.2.19=sampleFixture
|
||||
org.springframework:spring-webmvc:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-websocket:7.0.1=sampleOffTestCompileClasspath,testCompileClasspath
|
||||
org.testcontainers:testcontainers-database-commons:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.testcontainers:testcontainers-jdbc:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.testcontainers:testcontainers-junit-jupiter:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.testcontainers:testcontainers-postgresql:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.testcontainers:testcontainers:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.4=sampleFixture
|
||||
org.yaml:snakeyaml:2.5=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:annotations:2.30.0=testRuntimeClasspath
|
||||
software.amazon.awssdk:apache-client:2.30.0=testRuntimeClasspath
|
||||
software.amazon.awssdk:arns:2.30.0=testRuntimeClasspath
|
||||
software.amazon.awssdk:auth:2.30.0=testRuntimeClasspath
|
||||
software.amazon.awssdk:aws-core:2.30.0=testRuntimeClasspath
|
||||
software.amazon.awssdk:aws-query-protocol:2.30.0=testRuntimeClasspath
|
||||
software.amazon.awssdk:aws-xml-protocol:2.30.0=testRuntimeClasspath
|
||||
software.amazon.awssdk:checksums-spi:2.30.0=testRuntimeClasspath
|
||||
software.amazon.awssdk:checksums:2.30.0=testRuntimeClasspath
|
||||
software.amazon.awssdk:crt-core:2.30.0=testRuntimeClasspath
|
||||
software.amazon.awssdk:endpoints-spi:2.30.0=testRuntimeClasspath
|
||||
software.amazon.awssdk:http-auth-aws-eventstream:2.30.0=testRuntimeClasspath
|
||||
software.amazon.awssdk:http-auth-aws:2.30.0=testRuntimeClasspath
|
||||
software.amazon.awssdk:http-auth-spi:2.30.0=testRuntimeClasspath
|
||||
software.amazon.awssdk:http-auth:2.30.0=testRuntimeClasspath
|
||||
software.amazon.awssdk:http-client-spi:2.30.0=testRuntimeClasspath
|
||||
software.amazon.awssdk:identity-spi:2.30.0=testRuntimeClasspath
|
||||
software.amazon.awssdk:json-utils:2.30.0=testRuntimeClasspath
|
||||
software.amazon.awssdk:metrics-spi:2.30.0=testRuntimeClasspath
|
||||
software.amazon.awssdk:netty-nio-client:2.30.0=testRuntimeClasspath
|
||||
software.amazon.awssdk:profiles:2.30.0=testRuntimeClasspath
|
||||
software.amazon.awssdk:protocol-core:2.30.0=testRuntimeClasspath
|
||||
software.amazon.awssdk:regions:2.30.0=testRuntimeClasspath
|
||||
software.amazon.awssdk:retries-spi:2.30.0=testRuntimeClasspath
|
||||
software.amazon.awssdk:retries:2.30.0=testRuntimeClasspath
|
||||
software.amazon.awssdk:s3:2.30.0=testRuntimeClasspath
|
||||
software.amazon.awssdk:sdk-core:2.30.0=testRuntimeClasspath
|
||||
software.amazon.awssdk:third-party-jackson-core:2.30.0=testRuntimeClasspath
|
||||
software.amazon.awssdk:utils:2.30.0=testRuntimeClasspath
|
||||
software.amazon.eventstream:eventstream:1.0.1=testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
empty=developmentOnly,testAndDevelopmentOnly
|
||||
@@ -0,0 +1,28 @@
|
||||
package dev.caskeleton.bootstrap;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
|
||||
@SpringBootApplication(
|
||||
scanBasePackages = {
|
||||
"dev.caskeleton.bootstrap",
|
||||
"dev.caskeleton.adapter",
|
||||
"dev.caskeleton.application",
|
||||
"dev.caskeleton.domain",
|
||||
"dev.caskeleton.shared"
|
||||
})
|
||||
@ConfigurationPropertiesScan(
|
||||
basePackages = {
|
||||
"dev.caskeleton.bootstrap",
|
||||
"dev.caskeleton.adapter",
|
||||
"dev.caskeleton.application",
|
||||
"dev.caskeleton.domain",
|
||||
"dev.caskeleton.shared"
|
||||
})
|
||||
public class CaSkeletonApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(CaSkeletonApplication.class, args);
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package dev.caskeleton.bootstrap.async;
|
||||
|
||||
import dev.caskeleton.shared.concurrency.DomainContextPropagator;
|
||||
import java.util.Map;
|
||||
import org.jspecify.annotations.NonNull;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.core.task.TaskDecorator;
|
||||
|
||||
/**
|
||||
* Carries caller-thread context (MDC + domain context) across the executor boundary onto the worker
|
||||
* thread. The {@code SecurityContext} principal is deliberately NOT propagated here. See README for
|
||||
* the design rationale.
|
||||
*/
|
||||
public final class AsyncContextTaskDecorator implements TaskDecorator {
|
||||
|
||||
private final DomainContextPropagator domainContextPropagator;
|
||||
|
||||
public AsyncContextTaskDecorator(DomainContextPropagator domainContextPropagator) {
|
||||
this.domainContextPropagator = domainContextPropagator;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Runnable decorate(@NonNull Runnable runnable) {
|
||||
// Capture the caller-thread context NOW (at submit time), not when the task runs.
|
||||
Map<String, String> callerMdc = MDC.getCopyOfContextMap();
|
||||
// Domain context capture/restore/revert is owned by the shared seam.
|
||||
Runnable domainWrapped = domainContextPropagator.wrap(runnable);
|
||||
return () -> {
|
||||
Map<String, String> previousMdc = MDC.getCopyOfContextMap();
|
||||
setOrClearMdc(callerMdc);
|
||||
try {
|
||||
domainWrapped.run();
|
||||
} finally {
|
||||
// Revert so a pooled worker thread does not carry this task's MDC into the next.
|
||||
setOrClearMdc(previousMdc);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static void setOrClearMdc(Map<String, String> context) {
|
||||
if (context != null) {
|
||||
MDC.setContextMap(context);
|
||||
} else {
|
||||
MDC.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package dev.caskeleton.bootstrap.async;
|
||||
|
||||
import dev.caskeleton.shared.concurrency.DomainContextPropagator;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.core.task.TaskDecorator;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
/**
|
||||
* Wires the async executor. Replaces Spring Boot's auto-configured {@code applicationTaskExecutor}
|
||||
* with a bounded pool; naming the bean {@value #EXECUTOR_BEAN_NAME} backs off Boot's auto-config
|
||||
* and makes this the executor {@code @Async} resolves to. See README for the design rationale.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableAsync
|
||||
@EnableConfigurationProperties(AsyncExecutorSettings.class)
|
||||
public class AsyncExecutorConfig {
|
||||
|
||||
/** Spring's default {@code @Async} executor bean name (Boot's auto-config backs off). */
|
||||
public static final String EXECUTOR_BEAN_NAME = "applicationTaskExecutor";
|
||||
|
||||
/** Shutdown await: container app-shutdown budget (20s) minus a 1s cleanup margin. */
|
||||
static final int AWAIT_TERMINATION_SECONDS = 19;
|
||||
|
||||
@Bean
|
||||
TaskDecorator asyncContextTaskDecorator(DomainContextPropagator domainContextPropagator) {
|
||||
return new AsyncContextTaskDecorator(domainContextPropagator);
|
||||
}
|
||||
|
||||
@Bean
|
||||
BackgroundJobMetrics backgroundJobMetrics(ObjectProvider<MeterRegistry> meterRegistryProvider) {
|
||||
return new BackgroundJobMetrics(meterRegistryProvider);
|
||||
}
|
||||
|
||||
@Bean(name = EXECUTOR_BEAN_NAME)
|
||||
@Primary
|
||||
ThreadPoolTaskExecutor applicationTaskExecutor(
|
||||
AsyncExecutorSettings settings,
|
||||
TaskDecorator asyncContextTaskDecorator,
|
||||
BackgroundJobMetrics metrics) {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setThreadNamePrefix("app-async-");
|
||||
executor.setCorePoolSize(settings.coreSize());
|
||||
executor.setMaxPoolSize(settings.maxSize());
|
||||
executor.setQueueCapacity(settings.queueCapacity());
|
||||
executor.setTaskDecorator(asyncContextTaskDecorator);
|
||||
executor.setRejectedExecutionHandler(new LoggingAbortPolicy(EXECUTOR_BEAN_NAME, metrics));
|
||||
executor.setWaitForTasksToCompleteOnShutdown(true);
|
||||
executor.setAwaitTerminationSeconds(AWAIT_TERMINATION_SECONDS);
|
||||
executor.initialize();
|
||||
metrics.registerSaturationGauge(
|
||||
EXECUTOR_BEAN_NAME, () -> executor.getThreadPoolExecutor().getQueue().size());
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package dev.caskeleton.bootstrap.async;
|
||||
|
||||
import dev.caskeleton.bootstrap.runtime.startup.StartupFailures;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
/**
|
||||
* Async executor pool sizing knobs, bound from {@code ca-skeleton.async.executor.*}. Rejects {@code
|
||||
* Integer.MAX_VALUE} as a queue capacity so the queue stays bounded. See README for the design
|
||||
* rationale.
|
||||
*
|
||||
* @param coreSize always-alive worker count (≥ 1)
|
||||
* @param maxSize hard ceiling on workers (≥ 1 and ≥ {@code coreSize})
|
||||
* @param queueCapacity bounded backlog depth (1 ≤ capacity < {@code Integer.MAX_VALUE})
|
||||
*/
|
||||
@Validated
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.async.executor")
|
||||
public record AsyncExecutorSettings(Integer coreSize, Integer maxSize, Integer queueCapacity) {
|
||||
|
||||
private static final int DEFAULT_CORE_SIZE = 10;
|
||||
private static final int DEFAULT_MAX_SIZE = 50;
|
||||
private static final int DEFAULT_QUEUE_CAPACITY = 200;
|
||||
|
||||
public AsyncExecutorSettings {
|
||||
if (coreSize == null) {
|
||||
coreSize = DEFAULT_CORE_SIZE;
|
||||
} else if (coreSize < 1) {
|
||||
throw StartupFailures.envValidation(
|
||||
"APP_ASYNC_EXECUTOR_CORE_SIZE (ca-skeleton.async.executor.core-size) must be >= 1 "
|
||||
+ "(positive_int), was "
|
||||
+ coreSize);
|
||||
}
|
||||
if (queueCapacity == null) {
|
||||
queueCapacity = DEFAULT_QUEUE_CAPACITY;
|
||||
} else if (queueCapacity < 1) {
|
||||
throw StartupFailures.envValidation(
|
||||
"APP_ASYNC_EXECUTOR_QUEUE_CAPACITY (ca-skeleton.async.executor.queue-capacity) must be >= 1 "
|
||||
+ "(positive_int_bounded), was "
|
||||
+ queueCapacity);
|
||||
} else if (queueCapacity == Integer.MAX_VALUE) {
|
||||
throw StartupFailures.envValidation(
|
||||
"APP_ASYNC_EXECUTOR_QUEUE_CAPACITY (ca-skeleton.async.executor.queue-capacity) must be bounded "
|
||||
+ "(< Integer.MAX_VALUE) — an unbounded queue makes max-size unreachable and is "
|
||||
+ "forbidden (D7, positive_int_bounded)");
|
||||
}
|
||||
if (maxSize == null) {
|
||||
maxSize = DEFAULT_MAX_SIZE;
|
||||
} else if (maxSize < 1) {
|
||||
throw StartupFailures.envValidation(
|
||||
"APP_ASYNC_EXECUTOR_MAX_SIZE (ca-skeleton.async.executor.max-size) must be >= 1 "
|
||||
+ "(positive_int_ge_core), was "
|
||||
+ maxSize);
|
||||
}
|
||||
// cross-field invariant: max must not be below core.
|
||||
if (maxSize < coreSize) {
|
||||
throw StartupFailures.envValidation(
|
||||
"APP_ASYNC_EXECUTOR_MAX_SIZE (ca-skeleton.async.executor.max-size="
|
||||
+ maxSize
|
||||
+ ") must be >= APP_ASYNC_EXECUTOR_CORE_SIZE (core-size="
|
||||
+ coreSize
|
||||
+ ") (positive_int_ge_core)");
|
||||
}
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package dev.caskeleton.bootstrap.async;
|
||||
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.Gauge;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import java.util.function.DoubleSupplier;
|
||||
import java.util.function.Supplier;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
|
||||
/**
|
||||
* Background-job / async-executor metric recorder. The {@code job.*} recorders are the retry/DLQ
|
||||
* vocabulary for future retry carriers to consume. With no {@code MeterRegistry} bean (no Actuator
|
||||
* on the classpath) every operation is a no-op. See README for the design rationale.
|
||||
*/
|
||||
public final class BackgroundJobMetrics {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BackgroundJobMetrics.class);
|
||||
|
||||
static final String EXECUTOR_SATURATION = "executor.saturation";
|
||||
static final String EXECUTOR_REJECTED = "executor.rejected.total";
|
||||
static final String JOB_RETRY = "job.retry.total";
|
||||
static final String JOB_DLQ = "job.dlq.total";
|
||||
|
||||
static final String TAG_EXECUTOR_NAME = "executor_name";
|
||||
static final String TAG_POLICY = "policy";
|
||||
static final String TAG_JOB_NAME = "job_name";
|
||||
static final String TAG_OUTCOME = "outcome";
|
||||
|
||||
private final MeterRegistry registry; // null when no Actuator on classpath
|
||||
|
||||
public BackgroundJobMetrics(ObjectProvider<MeterRegistry> meterRegistryProvider) {
|
||||
this.registry = meterRegistryProvider.getIfAvailable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the {@code executor.saturation} gauge for {@code executorName}, reading the current
|
||||
* queue depth from {@code queueDepth} on each scrape. No-op without a registry.
|
||||
*/
|
||||
public void registerSaturationGauge(String executorName, DoubleSupplier queueDepth) {
|
||||
if (registry == null) {
|
||||
return;
|
||||
}
|
||||
Gauge.builder(EXECUTOR_SATURATION, queueDepth, DoubleSupplier::getAsDouble)
|
||||
.tag(TAG_EXECUTOR_NAME, executorName)
|
||||
.description("Current async executor queue depth")
|
||||
.register(registry);
|
||||
}
|
||||
|
||||
public void recordRejection(String executorName, String policy) {
|
||||
increment(
|
||||
EXECUTOR_REJECTED,
|
||||
() ->
|
||||
Counter.builder(EXECUTOR_REJECTED)
|
||||
.tag(TAG_EXECUTOR_NAME, executorName)
|
||||
.tag(TAG_POLICY, policy));
|
||||
}
|
||||
|
||||
public void recordRetryOutcome(String jobName, RetryOutcome outcome) {
|
||||
increment(
|
||||
JOB_RETRY,
|
||||
() ->
|
||||
Counter.builder(JOB_RETRY).tag(TAG_JOB_NAME, jobName).tag(TAG_OUTCOME, outcome.name()));
|
||||
}
|
||||
|
||||
public void recordDeadLetter(String jobName) {
|
||||
increment(JOB_DLQ, () -> Counter.builder(JOB_DLQ).tag(TAG_JOB_NAME, jobName));
|
||||
}
|
||||
|
||||
private void increment(String meterName, Supplier<Counter.Builder> builder) {
|
||||
if (registry == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
builder.get().register(registry).increment();
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("background-job metrics: failed to record counter {}", meterName, ex);
|
||||
}
|
||||
}
|
||||
|
||||
/** Retry-cycle outcome tag values for {@code job.retry.total} (bounded to 4). */
|
||||
public enum RetryOutcome {
|
||||
SUCCESS,
|
||||
RETRY,
|
||||
EXHAUSTED,
|
||||
DLQ
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package dev.caskeleton.bootstrap.async;
|
||||
|
||||
import static net.logstash.logback.argument.StructuredArguments.kv;
|
||||
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.RejectedExecutionHandler;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The async executor's saturation policy. An {@code AbortPolicy} that logs and increments a
|
||||
* rejection counter, then re-throws {@link RejectedExecutionException} so a fire-and-forget
|
||||
* {@code @Async} caller's rejection is not silently swallowed. See README for the design rationale.
|
||||
*/
|
||||
public final class LoggingAbortPolicy implements RejectedExecutionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LoggingAbortPolicy.class);
|
||||
|
||||
/** Tag value for {@code executor.rejected.total{policy}} (metrics.yaml allowed set). */
|
||||
static final String POLICY = "AbortPolicy";
|
||||
|
||||
private final String executorName;
|
||||
private final BackgroundJobMetrics metrics;
|
||||
|
||||
public LoggingAbortPolicy(String executorName, BackgroundJobMetrics metrics) {
|
||||
this.executorName = executorName;
|
||||
this.metrics = metrics;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void rejectedExecution(Runnable task, ThreadPoolExecutor executor) {
|
||||
metrics.recordRejection(executorName, POLICY);
|
||||
log.error(
|
||||
"async executor rejected task — pool saturated (core/max/queue exhausted): {} {} {} {} {}",
|
||||
kv("error.code", OperationalError.JOB_EXECUTOR_REJECTED.code()),
|
||||
kv("error.category", OperationalError.JOB_EXECUTOR_REJECTED.category().name()),
|
||||
kv("executor_name", executorName),
|
||||
kv("policy", POLICY),
|
||||
kv("queue_size", executor.getQueue().size()));
|
||||
// Preserve AbortPolicy semantics: the caller must see the rejection.
|
||||
throw new RejectedExecutionException(
|
||||
"Task "
|
||||
+ task
|
||||
+ " rejected from async executor '"
|
||||
+ executorName
|
||||
+ "' ("
|
||||
+ OperationalError.JOB_EXECUTOR_REJECTED.code()
|
||||
+ ")");
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.caskeleton.bootstrap.concurrency;
|
||||
|
||||
import dev.caskeleton.shared.concurrency.DomainContextPropagator;
|
||||
import dev.caskeleton.shared.concurrency.DomainContextPropagatorFactory;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/** Wires the {@link DomainContextPropagator} bean. See README for the design rationale. */
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(DomainContextSettings.class)
|
||||
public class DomainContextConfig {
|
||||
|
||||
@Bean
|
||||
public DomainContextPropagator domainContextPropagator(DomainContextSettings properties) {
|
||||
return DomainContextPropagatorFactory.create(properties.strategy());
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package dev.caskeleton.bootstrap.concurrency;
|
||||
|
||||
import dev.caskeleton.shared.concurrency.DomainContextStrategy;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Domain-context propagation strategy selection. See README for the design rationale.
|
||||
*
|
||||
* @param strategy the propagation strategy; {@code null} → {@code THREAD_LOCAL}
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.domain-context")
|
||||
public record DomainContextSettings(DomainContextStrategy strategy) {
|
||||
|
||||
public DomainContextSettings {
|
||||
if (strategy == null) {
|
||||
strategy = DomainContextStrategy.THREAD_LOCAL;
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package dev.caskeleton.bootstrap.idempotency;
|
||||
|
||||
import dev.caskeleton.application.idempotency.IdempotencyExecutor;
|
||||
import dev.caskeleton.application.idempotency.IdempotencyStorePort;
|
||||
import java.time.Clock;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* Wires idempotency execution. {@code @EnableScheduling} activates the {@code IdempotencyReaper}'s
|
||||
* scheduled purge. See README for the design rationale.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableScheduling
|
||||
public class IdempotencyConfig {
|
||||
|
||||
@Bean
|
||||
public Clock systemClock() {
|
||||
return Clock.systemUTC();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public IdempotencyExecutor idempotencyExecutor(
|
||||
IdempotencyStorePort store, Clock clock, IdempotencySettings properties) {
|
||||
return new IdempotencyExecutor(store, clock, properties.ttl());
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package dev.caskeleton.bootstrap.idempotency;
|
||||
|
||||
import dev.caskeleton.bootstrap.runtime.startup.StartupFailures;
|
||||
import java.time.Duration;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
/**
|
||||
* Idempotency runtime knobs. The {@code <= 72h} TTL cap is enforced fail-fast in the compact
|
||||
* constructor. See README for the design rationale.
|
||||
*
|
||||
* @param ttl default idempotency record TTL (≤ 72h)
|
||||
* @param reaperInterval how often the expired-record reaper runs
|
||||
*/
|
||||
@Validated
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.idempotency")
|
||||
public record IdempotencySettings(Duration ttl, Duration reaperInterval) {
|
||||
|
||||
private static final Duration MAX_TTL = Duration.ofHours(72);
|
||||
|
||||
public IdempotencySettings {
|
||||
if (ttl == null) {
|
||||
ttl = Duration.ofHours(24);
|
||||
}
|
||||
if (ttl.isZero() || ttl.isNegative()) {
|
||||
throw StartupFailures.envValidation(
|
||||
"APP_IDEMPOTENCY_TTL (ca-skeleton.idempotency.ttl) must be positive, was " + ttl);
|
||||
}
|
||||
if (ttl.compareTo(MAX_TTL) > 0) {
|
||||
throw StartupFailures.envValidation(
|
||||
"APP_IDEMPOTENCY_TTL (ca-skeleton.idempotency.ttl) must be <= 72h (D6), was " + ttl);
|
||||
}
|
||||
if (reaperInterval == null || reaperInterval.isZero() || reaperInterval.isNegative()) {
|
||||
reaperInterval = Duration.ofMinutes(10);
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package dev.caskeleton.bootstrap.lock;
|
||||
|
||||
import dev.caskeleton.application.lock.DistributedLockPort;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
|
||||
/**
|
||||
* Composition-root wiring for the distributed-lock metrics decorator. See README for the design
|
||||
* rationale.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
public class DistributedLockConfig {
|
||||
|
||||
/**
|
||||
* The bean name {@code "distributedLockProvider"} is the contract with {@code
|
||||
* StartupSafetyValidator} — do not rename. See README for the design rationale.
|
||||
*/
|
||||
@Bean(name = "distributedLockProvider")
|
||||
@Primary
|
||||
@ConditionalOnProperty(
|
||||
prefix = "ca-skeleton.runtime",
|
||||
name = "multi-instance-enabled",
|
||||
havingValue = "true")
|
||||
DistributedLockPort distributedLockProvider(
|
||||
@Qualifier("jdbcDistributedLock") DistributedLockPort jdbcDistributedLock,
|
||||
ObjectProvider<MeterRegistry> meterRegistry) {
|
||||
return new MeteredDistributedLockPort(jdbcDistributedLock, meterRegistry);
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package dev.caskeleton.bootstrap.lock;
|
||||
|
||||
import dev.caskeleton.application.lock.DistributedLock;
|
||||
import dev.caskeleton.application.lock.DistributedLockPort;
|
||||
import dev.caskeleton.application.lock.LockAcquisitionTimeoutException;
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import java.time.Duration;
|
||||
import java.util.ConcurrentModificationException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
|
||||
/**
|
||||
* Metrics decorator for {@link DistributedLockPort}. Metric failures are logged-and-swallowed so
|
||||
* they never affect the lock path. See README for the design rationale.
|
||||
*/
|
||||
public final class MeteredDistributedLockPort implements DistributedLockPort {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MeteredDistributedLockPort.class);
|
||||
|
||||
static final String LOCK_ACQUISITION = "lock.acquisition";
|
||||
static final String TAG_OUTCOME = "outcome";
|
||||
static final String OUTCOME_ACQUIRED = "acquired";
|
||||
static final String OUTCOME_TIMEOUT = "timeout";
|
||||
static final String OUTCOME_ERROR = "error";
|
||||
|
||||
static final String LOCK_LEASE_EXPIRED = "lock.lease.expired";
|
||||
|
||||
private final DistributedLockPort delegate;
|
||||
private final MeterRegistry registry; // null when no MeterRegistry on classpath
|
||||
|
||||
public MeteredDistributedLockPort(
|
||||
DistributedLockPort delegate, ObjectProvider<MeterRegistry> meterRegistryProvider) {
|
||||
this.delegate = delegate;
|
||||
this.registry = meterRegistryProvider.getIfAvailable();
|
||||
}
|
||||
|
||||
@Override
|
||||
public DistributedLock tryAcquire(String key, Duration waitTime, Duration leaseTtl) {
|
||||
try {
|
||||
DistributedLock handle = delegate.tryAcquire(key, waitTime, leaseTtl);
|
||||
increment(OUTCOME_ACQUIRED);
|
||||
return () -> closeHandlingLeaseExpiry(key, handle);
|
||||
} catch (LockAcquisitionTimeoutException e) {
|
||||
increment(OUTCOME_TIMEOUT);
|
||||
throw e;
|
||||
} catch (RuntimeException e) {
|
||||
increment(OUTCOME_ERROR);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Absorbs a lease-expiry {@link ConcurrentModificationException} on close so it does not disrupt
|
||||
* the caller's {@code finally} block; any other exception propagates unchanged. See README for
|
||||
* the design rationale.
|
||||
*/
|
||||
private void closeHandlingLeaseExpiry(String key, DistributedLock handle) {
|
||||
try {
|
||||
handle.close();
|
||||
} catch (ConcurrentModificationException e) {
|
||||
// Lease expired before release: the lock row was reclaimed, so another instance may
|
||||
// have entered the critical section. Surface it (log + metric) but return normally.
|
||||
log.warn(
|
||||
"distributed lock '{}' had already been released by lease expiry before close() "
|
||||
+ "— another instance may have entered the critical section (D6 efficiency-lock boundary)",
|
||||
key,
|
||||
e);
|
||||
incrementLeaseExpired();
|
||||
}
|
||||
}
|
||||
|
||||
private void increment(String outcome) {
|
||||
if (registry == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Counter.builder(LOCK_ACQUISITION).tag(TAG_OUTCOME, outcome).register(registry).increment();
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn(
|
||||
"distributed-lock metrics: failed to record counter {}[outcome={}]",
|
||||
LOCK_ACQUISITION,
|
||||
outcome,
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void incrementLeaseExpired() {
|
||||
if (registry == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Counter.builder(LOCK_LEASE_EXPIRED).register(registry).increment();
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("distributed-lock metrics: failed to record counter {}", LOCK_LEASE_EXPIRED, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package dev.caskeleton.bootstrap.logging;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonStreamContext;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
import net.logstash.logback.mask.ValueMasker;
|
||||
|
||||
/**
|
||||
* SSOT for secret-masking regex rules (shared by the JSON-encoder and pattern-layout paths). See
|
||||
* README for the design rationale.
|
||||
*/
|
||||
public final class LogMaskingPatterns {
|
||||
|
||||
public static final String MASK = "****";
|
||||
|
||||
private record Rule(Pattern pattern, String replacement) {}
|
||||
|
||||
// Value char class shared by the key=value / authorization rules: a secret token runs until
|
||||
// the next whitespace, quote, comma, ampersand, or closing brace (JSON / query / kv delimiters).
|
||||
private static final String VALUE = "[^\\s\"',&}]+";
|
||||
private static final String SEP = "[\"']?\\s*[:=]\\s*[\"']?";
|
||||
|
||||
private static final List<Rule> RULES =
|
||||
List.of(
|
||||
// 1. key=value / "key":"value" secrets — keep the key + separator, mask the value.
|
||||
new Rule(
|
||||
Pattern.compile(
|
||||
"(?i)(password|passwd|pwd|secret|token|api[_-]?key|access[_-]?token"
|
||||
+ "|refresh[_-]?token|client[_-]?secret)("
|
||||
+ SEP
|
||||
+ ")("
|
||||
+ VALUE
|
||||
+ ")"),
|
||||
"$1$2" + MASK),
|
||||
// 2. Authorization header (optional auth scheme kept) — mask the credential.
|
||||
new Rule(
|
||||
Pattern.compile(
|
||||
"(?i)(authorization" + SEP + ")((?:bearer|basic|negotiate)\\s+)?(" + VALUE + ")"),
|
||||
"$1$2" + MASK),
|
||||
// 3. Standalone bearer token not preceded by an "authorization" key.
|
||||
new Rule(Pattern.compile("(?i)(bearer\\s+)([A-Za-z0-9._~+/=-]{8,})"), "$1" + MASK));
|
||||
|
||||
private LogMaskingPatterns() {}
|
||||
|
||||
/**
|
||||
* Returns {@code input} unchanged (same reference) when nothing matched; never returns {@code
|
||||
* null} for a non-null argument.
|
||||
*/
|
||||
public static String mask(String input) {
|
||||
if (input == null || input.isEmpty()) {
|
||||
return input;
|
||||
}
|
||||
String out = input;
|
||||
for (Rule rule : RULES) {
|
||||
out = rule.pattern().matcher(out).replaceAll(rule.replacement());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts {@link #mask(String)} to the logstash {@link ValueMasker} SPI. Returns the original
|
||||
* object reference when nothing was masked so the decorator writes the value untouched.
|
||||
*/
|
||||
public static ValueMasker valueMasker() {
|
||||
return (JsonStreamContext context, Object value) -> {
|
||||
if (value instanceof CharSequence cs) {
|
||||
String original = cs.toString();
|
||||
String masked = mask(original);
|
||||
return masked.equals(original) ? value : masked;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package dev.caskeleton.bootstrap.logging;
|
||||
|
||||
import ch.qos.logback.classic.AsyncAppender;
|
||||
import ch.qos.logback.classic.Level;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
|
||||
/**
|
||||
* {@link AsyncAppender} that counts log events dropped under back-pressure. Published through
|
||||
* {@link Metrics#globalRegistry} because Logback initializes before the Spring context. See README
|
||||
* for the design rationale.
|
||||
*/
|
||||
public class MetricsAsyncAppender extends AsyncAppender {
|
||||
|
||||
static final String DROPPED_METER = "log.appender.dropped.total";
|
||||
|
||||
@Override
|
||||
protected void append(ILoggingEvent eventObject) {
|
||||
if (isQueueBelowDiscardingThreshold() && isDiscardable(eventObject)) {
|
||||
Level level = eventObject.getLevel();
|
||||
if (level == Level.INFO || level == Level.DEBUG) {
|
||||
Metrics.counter(DROPPED_METER, "appender", appenderName(), "level", level.toString())
|
||||
.increment();
|
||||
}
|
||||
}
|
||||
super.append(eventObject);
|
||||
}
|
||||
|
||||
private String appenderName() {
|
||||
String name = getName();
|
||||
return name == null ? "unknown" : name;
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.bootstrap.logging;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.identifier.HmacUserPrincipalPseudonymizer;
|
||||
import dev.caskeleton.application.observability.UserPrincipalPseudonymizerPort;
|
||||
import dev.caskeleton.bootstrap.settings.PrivacySettings;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Composition-root wiring for {@code user_principal} pseudonymization.
|
||||
* {@code @ConditionalOnMissingBean} lets a forking project substitute its own pseudonymizer. See
|
||||
* README for the design rationale.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(PrivacySettings.class)
|
||||
public class PseudonymizationConfig {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
UserPrincipalPseudonymizerPort userPrincipalPseudonymizer(PrivacySettings privacySettings) {
|
||||
return new HmacUserPrincipalPseudonymizer(privacySettings.saltBytes());
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package dev.caskeleton.bootstrap.logging;
|
||||
|
||||
import ch.qos.logback.classic.Level;
|
||||
import ch.qos.logback.classic.Logger;
|
||||
import ch.qos.logback.classic.turbo.TurboFilter;
|
||||
import ch.qos.logback.core.spi.FilterReply;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import org.slf4j.Marker;
|
||||
|
||||
/**
|
||||
* Level-aware log sampler. {@code WARN}/{@code ERROR} are never sampled because they are
|
||||
* diagnostic/incident signals that must be guaranteed; only {@code INFO} and below are sampled. See
|
||||
* README for the design rationale.
|
||||
*/
|
||||
public class SamplingTurboFilter extends TurboFilter {
|
||||
|
||||
private static final double KEEP_ALL = 1.0d;
|
||||
|
||||
private volatile double rate = KEEP_ALL;
|
||||
|
||||
/** Logback Joran setter — {@code <rate>${LOG_SAMPLING_RATE}</rate>}. */
|
||||
public void setRate(double rate) {
|
||||
this.rate = rate;
|
||||
}
|
||||
|
||||
public double getRate() {
|
||||
return rate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
if (rate < 0.0d || rate > 1.0d || Double.isNaN(rate)) {
|
||||
addWarn(
|
||||
"APP_LOG_SAMPLING_RATE must be in [0.0, 1.0] (got " + rate + "); using 1.0 (keep all)");
|
||||
rate = KEEP_ALL;
|
||||
}
|
||||
super.start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public FilterReply decide(
|
||||
Marker marker, Logger logger, Level level, String format, Object[] params, Throwable t) {
|
||||
if (!isStarted() || level == null) {
|
||||
return FilterReply.NEUTRAL;
|
||||
}
|
||||
if (level.toInt() >= Level.WARN_INT) {
|
||||
return FilterReply.NEUTRAL;
|
||||
}
|
||||
// <= INFO: keep with probability `rate`.
|
||||
if (rate >= KEEP_ALL) {
|
||||
return FilterReply.NEUTRAL;
|
||||
}
|
||||
if (rate <= 0.0d) {
|
||||
return FilterReply.DENY;
|
||||
}
|
||||
return ThreadLocalRandom.current().nextDouble() < rate ? FilterReply.NEUTRAL : FilterReply.DENY;
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package dev.caskeleton.bootstrap.logging;
|
||||
|
||||
import net.logstash.logback.mask.MaskingJsonGeneratorDecorator;
|
||||
|
||||
/**
|
||||
* JSON-encoder arm of secret redaction ({@link LogMaskingPatterns} catalog). Masks at
|
||||
* JSON-generation time, so it covers every emitted string value (message, MDC, stack trace). See
|
||||
* README for the design rationale.
|
||||
*/
|
||||
public class SecretMaskingJsonGeneratorDecorator extends MaskingJsonGeneratorDecorator {
|
||||
|
||||
public SecretMaskingJsonGeneratorDecorator() {
|
||||
setDefaultMask(LogMaskingPatterns.MASK);
|
||||
addValueMasker(LogMaskingPatterns.valueMasker());
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package dev.caskeleton.bootstrap.logging;
|
||||
|
||||
import ch.qos.logback.classic.pattern.MessageConverter;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
|
||||
/**
|
||||
* {@code PatternLayout} arm of secret redaction ({@link LogMaskingPatterns} catalog). Registered as
|
||||
* the {@code %maskedMsg} conversion word; unlike the JSON arm, only the message body is masked
|
||||
* here. See README for the design rationale.
|
||||
*/
|
||||
public class SecretMaskingMessageConverter extends MessageConverter {
|
||||
|
||||
@Override
|
||||
public String convert(ILoggingEvent event) {
|
||||
return LogMaskingPatterns.mask(super.convert(event));
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package dev.caskeleton.bootstrap.logging;
|
||||
|
||||
import ch.qos.logback.classic.Level;
|
||||
import ch.qos.logback.classic.Logger;
|
||||
import ch.qos.logback.classic.turbo.TurboFilter;
|
||||
import ch.qos.logback.core.spi.FilterReply;
|
||||
import dev.caskeleton.bootstrap.runtime.startup.StartupFailureLogState;
|
||||
import java.util.Set;
|
||||
import org.slf4j.Marker;
|
||||
|
||||
/**
|
||||
* Drops Spring Boot's generic duplicate startup-failure logs after {@code StartupFailures} has
|
||||
* already emitted the canonical structured record.
|
||||
*/
|
||||
public class StartupFailureSpringBootLogFilter extends TurboFilter {
|
||||
|
||||
private static final String SPRING_APPLICATION_LOGGER =
|
||||
"org.springframework.boot.SpringApplication";
|
||||
private static final String FAILURE_ANALYSIS_LOGGER =
|
||||
"org.springframework.boot.diagnostics.LoggingFailureAnalysisReporter";
|
||||
private static final String SPRING_LOGGER_PREFIX = "org.springframework.";
|
||||
private static final String CONTEXT_REFRESH_CANCELLED_PREFIX =
|
||||
"Exception encountered during context initialization - cancelling refresh attempt";
|
||||
|
||||
private static final Set<String> DUPLICATE_MESSAGES =
|
||||
Set.of("Application run failed", "Unable to close ApplicationContext");
|
||||
|
||||
@Override
|
||||
public FilterReply decide(
|
||||
Marker marker, Logger logger, Level level, String format, Object[] params, Throwable t) {
|
||||
if (!isStarted() || !StartupFailureLogState.startupFailureReported()) {
|
||||
return FilterReply.NEUTRAL;
|
||||
}
|
||||
if (logger == null) {
|
||||
return FilterReply.NEUTRAL;
|
||||
}
|
||||
String loggerName = logger.getName();
|
||||
if (SPRING_APPLICATION_LOGGER.equals(loggerName)
|
||||
&& format != null
|
||||
&& DUPLICATE_MESSAGES.contains(format)) {
|
||||
return FilterReply.DENY;
|
||||
}
|
||||
if (FAILURE_ANALYSIS_LOGGER.equals(loggerName)) {
|
||||
return FilterReply.DENY;
|
||||
}
|
||||
if (loggerName.startsWith(SPRING_LOGGER_PREFIX)
|
||||
&& format != null
|
||||
&& format.startsWith(CONTEXT_REFRESH_CANCELLED_PREFIX)) {
|
||||
return FilterReply.DENY;
|
||||
}
|
||||
return FilterReply.NEUTRAL;
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package dev.caskeleton.bootstrap.management.security;
|
||||
|
||||
import org.springframework.boot.security.autoconfigure.actuate.web.servlet.EndpointRequest;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
|
||||
|
||||
/**
|
||||
* Actuator endpoint security chain, ordered ahead of the main app chain. Security matching is done
|
||||
* by endpoint id string only, never by health endpoint internals. See README for the design
|
||||
* rationale.
|
||||
*/
|
||||
@Configuration
|
||||
public class ManagementSecurityConfig {
|
||||
|
||||
@Bean
|
||||
@Order(0)
|
||||
public SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||
http.securityMatcher(EndpointRequest.toAnyEndpoint())
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
// Answer 401 (credentials required) instead of the default 403; no interactive login.
|
||||
.exceptionHandling(
|
||||
ex -> ex.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
|
||||
.authorizeHttpRequests(
|
||||
auth ->
|
||||
auth
|
||||
// permit-all for probes and Prometheus scrape.
|
||||
.requestMatchers(EndpointRequest.to("health", "info", "prometheus"))
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/actuator/loggers/**")
|
||||
.denyAll()
|
||||
.requestMatchers(HttpMethod.DELETE, "/actuator/loggers/**")
|
||||
.denyAll()
|
||||
.anyRequest()
|
||||
.authenticated());
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package dev.caskeleton.bootstrap.metrics;
|
||||
|
||||
import dev.caskeleton.shared.metrics.ForbiddenMetricTags;
|
||||
import io.micrometer.core.instrument.Meter;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import io.micrometer.core.instrument.config.MeterFilter;
|
||||
import io.micrometer.core.instrument.config.MeterFilterReply;
|
||||
|
||||
/**
|
||||
* {@link MeterFilter} that blocks high-cardinality label keys at runtime. See README for the design
|
||||
* rationale.
|
||||
*
|
||||
* @see ForbiddenMetricTags
|
||||
* @see MetricsContractConfig
|
||||
*/
|
||||
public final class MetricsCardinalityMeterFilter implements MeterFilter {
|
||||
|
||||
public MetricsCardinalityMeterFilter() {
|
||||
// public no-arg constructor — required by MetricsContractConfig.install()
|
||||
}
|
||||
|
||||
@Override
|
||||
public MeterFilterReply accept(Meter.Id id) {
|
||||
for (Tag tag : id.getTags()) {
|
||||
if (ForbiddenMetricTags.isForbidden(tag.getKey())) {
|
||||
return MeterFilterReply.DENY;
|
||||
}
|
||||
}
|
||||
return MeterFilterReply.NEUTRAL;
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package dev.caskeleton.bootstrap.metrics;
|
||||
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Installs the metrics MeterFilters onto the MeterRegistry (deny-list before histogram config). See
|
||||
* README for the design rationale.
|
||||
*
|
||||
* @see MetricsCardinalityMeterFilter
|
||||
* @see MetricsDistributionMeterFilter
|
||||
*/
|
||||
@Configuration
|
||||
public class MetricsContractConfig {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MetricsContractConfig.class);
|
||||
|
||||
private final ObjectProvider<MeterRegistry> meterRegistryProvider;
|
||||
|
||||
public MetricsContractConfig(ObjectProvider<MeterRegistry> meterRegistryProvider) {
|
||||
this.meterRegistryProvider = meterRegistryProvider;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void installFilters() {
|
||||
MeterRegistry registry = meterRegistryProvider.getIfAvailable();
|
||||
if (registry == null) {
|
||||
log.debug(
|
||||
"metrics-alerting-contract: no MeterRegistry available; "
|
||||
+ "MeterFilter install skipped (no Actuator on classpath)");
|
||||
return;
|
||||
}
|
||||
install(registry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public and static so contract tests can drive it directly against a {@link
|
||||
* io.micrometer.core.instrument.simple.SimpleMeterRegistry} without a Spring context. See README
|
||||
* for the design rationale.
|
||||
*
|
||||
* @param registry the registry to configure; must not be {@code null}
|
||||
*/
|
||||
public static void install(MeterRegistry registry) {
|
||||
registry.config().meterFilter(new MetricsCardinalityMeterFilter());
|
||||
registry.config().meterFilter(new MetricsDistributionMeterFilter());
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package dev.caskeleton.bootstrap.metrics;
|
||||
|
||||
import io.micrometer.core.instrument.Meter;
|
||||
import io.micrometer.core.instrument.config.MeterFilter;
|
||||
import io.micrometer.core.instrument.distribution.DistributionStatisticConfig;
|
||||
import java.time.Duration;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* {@link MeterFilter} that applies SLO-driven histogram and percentile configuration to a fixed set
|
||||
* of owned timer metrics. See README for the design rationale.
|
||||
*
|
||||
* @see MetricsContractConfig
|
||||
* @see MetricsCardinalityMeterFilter
|
||||
*/
|
||||
public final class MetricsDistributionMeterFilter implements MeterFilter {
|
||||
|
||||
private static final Set<String> SLO_DRIVEN_TIMERS =
|
||||
Set.of(
|
||||
"http.server.requests",
|
||||
"http.server.requests.latency",
|
||||
"dependency.client.requests",
|
||||
"db.query.duration",
|
||||
"jvm.gc.pause");
|
||||
|
||||
public MetricsDistributionMeterFilter() {
|
||||
// public no-arg constructor — required by MetricsContractConfig.install()
|
||||
}
|
||||
|
||||
@Override
|
||||
public DistributionStatisticConfig configure(Meter.Id id, DistributionStatisticConfig config) {
|
||||
if (!SLO_DRIVEN_TIMERS.contains(id.getName())) {
|
||||
return config;
|
||||
}
|
||||
return DistributionStatisticConfig.builder()
|
||||
.percentilesHistogram(true)
|
||||
.percentiles(0.5, 0.9, 0.95, 0.99)
|
||||
.serviceLevelObjectives(
|
||||
(double) Duration.ofMillis(100).toNanos(),
|
||||
(double) Duration.ofMillis(500).toNanos(),
|
||||
(double) Duration.ofSeconds(1).toNanos(),
|
||||
(double) Duration.ofSeconds(5).toNanos())
|
||||
.minimumExpectedValue((double) Duration.ofMillis(1).toNanos())
|
||||
.maximumExpectedValue((double) Duration.ofSeconds(10).toNanos())
|
||||
.build()
|
||||
.merge(config);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package dev.caskeleton.bootstrap.outbox;
|
||||
|
||||
import dev.caskeleton.application.outbox.OutboxBackoffPolicy;
|
||||
import dev.caskeleton.application.outbox.OutboxMessagePublishPort;
|
||||
import dev.caskeleton.application.outbox.OutboxStorePort;
|
||||
import dev.caskeleton.application.outbox.PublishPendingOutboxEventsUseCase;
|
||||
import dev.caskeleton.application.transaction.TransactionPort;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import java.time.Clock;
|
||||
import java.util.SplittableRandom;
|
||||
import java.util.random.RandomGenerator;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Composition-root wiring for the transactional outbox relay: assembles the relay use case manually
|
||||
* and registers the leader-election token, metrics, and scheduler. See README for the design
|
||||
* rationale.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(OutboxSettings.class)
|
||||
public class OutboxConfig {
|
||||
|
||||
/**
|
||||
* Relay use case is assembled manually here (not a bean) from {@link OutboxSettings} values. See
|
||||
* README for the design rationale.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnProperty(
|
||||
name = "ca-skeleton.outbox.relay-enabled",
|
||||
havingValue = "true",
|
||||
matchIfMissing = true)
|
||||
public OutboxRelayScheduler outboxRelayScheduler(
|
||||
OutboxStorePort store,
|
||||
OutboxMessagePublishPort publishPort,
|
||||
TransactionPort tx,
|
||||
Clock clock,
|
||||
RandomGenerator outboxRandomGenerator,
|
||||
OutboxSettings properties,
|
||||
OutboxMetrics metrics) {
|
||||
PublishPendingOutboxEventsUseCase relayUseCase =
|
||||
new PublishPendingOutboxEventsUseCase(
|
||||
store,
|
||||
publishPort,
|
||||
tx,
|
||||
new OutboxBackoffPolicy(outboxRandomGenerator),
|
||||
clock,
|
||||
properties.batchSize(),
|
||||
properties.inFlightTimeout());
|
||||
return new OutboxRelayScheduler(relayUseCase, metrics, clock);
|
||||
}
|
||||
|
||||
/** Uses a {@code java.base} RNG so the relay also starts on the slim Temurin JRE image. */
|
||||
@Bean
|
||||
public RandomGenerator outboxRandomGenerator() {
|
||||
return new SplittableRandom();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered unconditionally so the SKIP LOCKED leadership mechanism is always present. See
|
||||
* README for the design rationale.
|
||||
*/
|
||||
@Bean(name = "outboxLeaderElection")
|
||||
public OutboxLeaderElectionToken outboxLeaderElection() {
|
||||
return new OutboxLeaderElectionToken();
|
||||
}
|
||||
|
||||
/** Outbox metrics collector; no-op when {@link MeterRegistry} is absent. */
|
||||
@Bean
|
||||
public OutboxMetrics outboxMetrics(
|
||||
OutboxStorePort store, ObjectProvider<MeterRegistry> meterRegistryProvider) {
|
||||
return new OutboxMetrics(store, meterRegistryProvider);
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package dev.caskeleton.bootstrap.outbox;
|
||||
|
||||
/**
|
||||
* Marker bean: presence in the context satisfies the multi-instance coordination requirement (no
|
||||
* logic; SKIP LOCKED is the leadership mechanism). See README for the design rationale.
|
||||
*/
|
||||
public final class OutboxLeaderElectionToken {
|
||||
|
||||
private static final String STRATEGY_DESCRIPTION =
|
||||
"SKIP LOCKED claim — each relay instance claims a disjoint row partition; "
|
||||
+ "no external coordinator required (PostgreSQL FOR UPDATE SKIP LOCKED, I3/D8)";
|
||||
|
||||
public String strategyDescription() {
|
||||
return STRATEGY_DESCRIPTION;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "OutboxLeaderElectionToken{strategy=SKIP_LOCKED}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package dev.caskeleton.bootstrap.outbox;
|
||||
|
||||
import dev.caskeleton.application.outbox.OutboxEventStatus;
|
||||
import dev.caskeleton.application.outbox.OutboxRelayResult;
|
||||
import dev.caskeleton.application.outbox.OutboxStorePort;
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.MultiGauge;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
|
||||
/**
|
||||
* Outbox relay metric collector. No-op when no {@link MeterRegistry} is present (resolved via
|
||||
* {@link ObjectProvider}). See README for the design rationale.
|
||||
*/
|
||||
public final class OutboxMetrics {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(OutboxMetrics.class);
|
||||
|
||||
private static final String COUNTER_NAME = "outbox.publisher.published.total";
|
||||
private static final String SIZE_GAUGE = "outbox.pending.size";
|
||||
private static final String LAG_GAUGE = "outbox.publisher.lag";
|
||||
|
||||
private final OutboxStorePort store;
|
||||
private final MeterRegistry registry; // null when no Actuator on classpath
|
||||
private final MultiGauge pendingSizeGauge;
|
||||
private final MultiGauge publisherLagGauge;
|
||||
|
||||
public OutboxMetrics(OutboxStorePort store, ObjectProvider<MeterRegistry> meterRegistryProvider) {
|
||||
this.store = store;
|
||||
this.registry = meterRegistryProvider.getIfAvailable();
|
||||
|
||||
if (this.registry != null) {
|
||||
this.pendingSizeGauge =
|
||||
MultiGauge.builder(SIZE_GAUGE)
|
||||
.description("Number of outbox rows grouped by status")
|
||||
.register(this.registry);
|
||||
this.publisherLagGauge =
|
||||
MultiGauge.builder(LAG_GAUGE)
|
||||
.description("Age in seconds of the oldest unpublished row per event type")
|
||||
.baseUnit("seconds")
|
||||
.register(this.registry);
|
||||
} else {
|
||||
this.pendingSizeGauge = null;
|
||||
this.publisherLagGauge = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Counts each terminal outcome (PUBLISHED, FAILED, DEAD; not IN_FLIGHT). */
|
||||
public void recordRelayResult(OutboxRelayResult result) {
|
||||
if (registry == null || result == null) {
|
||||
return;
|
||||
}
|
||||
for (OutboxRelayResult.EventOutcome eo : result.outcomes()) {
|
||||
try {
|
||||
Counter.builder(COUNTER_NAME)
|
||||
.tag("event_type", eo.eventType())
|
||||
.tag("outcome", eo.outcome().name())
|
||||
.register(registry)
|
||||
.increment();
|
||||
} catch (Exception ex) {
|
||||
log.warn(
|
||||
"outbox metrics: failed to record counter for eventType={} outcome={}",
|
||||
eo.eventType(),
|
||||
eo.outcome(),
|
||||
ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses {@code overwrite=true} so stale time-series tags from removed event types are replaced
|
||||
* rather than left dangling.
|
||||
*
|
||||
* @param now current wall-clock instant (for the lag gauge age computation)
|
||||
*/
|
||||
public void refresh(Instant now) {
|
||||
if (registry == null) {
|
||||
return;
|
||||
}
|
||||
refreshPendingSize();
|
||||
refreshPublisherLag(now);
|
||||
}
|
||||
|
||||
private void refreshPendingSize() {
|
||||
try {
|
||||
Map<OutboxEventStatus, Long> counts = store.countByStatus();
|
||||
List<MultiGauge.Row<?>> rows = new ArrayList<>(OutboxEventStatus.values().length);
|
||||
for (OutboxEventStatus status : OutboxEventStatus.values()) {
|
||||
long count = counts.getOrDefault(status, 0L);
|
||||
rows.add(MultiGauge.Row.of(Tags.of("status", status.name()), count));
|
||||
}
|
||||
pendingSizeGauge.register(rows, true);
|
||||
} catch (Exception ex) {
|
||||
log.warn("outbox metrics: failed to refresh pending-size gauge", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshPublisherLag(Instant now) {
|
||||
try {
|
||||
Map<String, Long> lagByType = store.oldestUnpublishedAgeSecondsByEventType(now);
|
||||
List<MultiGauge.Row<?>> rows = new ArrayList<>(lagByType.size());
|
||||
for (Map.Entry<String, Long> entry : lagByType.entrySet()) {
|
||||
rows.add(MultiGauge.Row.of(Tags.of("event_type", entry.getKey()), entry.getValue()));
|
||||
}
|
||||
publisherLagGauge.register(rows, true);
|
||||
} catch (Exception ex) {
|
||||
log.warn("outbox metrics: failed to refresh publisher-lag gauge", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package dev.caskeleton.bootstrap.outbox;
|
||||
|
||||
import dev.caskeleton.application.outbox.OutboxRelayResult;
|
||||
import dev.caskeleton.application.outbox.PublishPendingOutboxEventsCommand;
|
||||
import dev.caskeleton.application.outbox.PublishPendingOutboxEventsUseCase;
|
||||
import java.time.Clock;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
|
||||
/**
|
||||
* Thin bootstrap scheduler that drives the outbox relay use case on a fixed-delay polling loop.
|
||||
* Registered by {@code OutboxConfig}. See README for the design rationale.
|
||||
*/
|
||||
public class OutboxRelayScheduler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(OutboxRelayScheduler.class);
|
||||
|
||||
private final PublishPendingOutboxEventsUseCase relayUseCase;
|
||||
private final OutboxMetrics metrics;
|
||||
private final Clock clock;
|
||||
|
||||
public OutboxRelayScheduler(
|
||||
PublishPendingOutboxEventsUseCase relayUseCase, OutboxMetrics metrics, Clock clock) {
|
||||
this.relayUseCase = relayUseCase;
|
||||
this.metrics = metrics;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unexpected exceptions are caught (not propagated) so the scheduler thread stays alive for the
|
||||
* next tick.
|
||||
*/
|
||||
@Scheduled(fixedDelayString = "${ca-skeleton.outbox.poll-interval:PT5S}")
|
||||
public void relay() {
|
||||
try {
|
||||
OutboxRelayResult result = relayUseCase.handle(PublishPendingOutboxEventsCommand.INSTANCE);
|
||||
|
||||
if (result.claimedCount() > 0) {
|
||||
log.debug(
|
||||
"outbox relay cycle: claimed={} outcomes={}",
|
||||
result.claimedCount(),
|
||||
result.outcomes().size());
|
||||
}
|
||||
|
||||
metrics.recordRelayResult(result);
|
||||
metrics.refresh(clock.instant());
|
||||
|
||||
} catch (Exception ex) {
|
||||
log.error(
|
||||
"outbox relay scheduler: unexpected error in relay cycle — "
|
||||
+ "relay will retry on the next tick",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package dev.caskeleton.bootstrap.outbox;
|
||||
|
||||
import dev.caskeleton.bootstrap.runtime.startup.StartupFailures;
|
||||
import java.time.Duration;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
/**
|
||||
* Outbox relay runtime knobs bound from {@code ca-skeleton.outbox.*} (literal defaults, no env
|
||||
* placeholders). See README for the design rationale.
|
||||
*
|
||||
* @param relayEnabled whether the relay scheduler is enabled; default {@code true}
|
||||
* @param pollInterval how often the relay polls the outbox table; default {@code PT5S}
|
||||
* @param batchSize maximum rows claimed per relay cycle; default {@code 20}
|
||||
* @param inFlightTimeout in-flight orphan visibility window; default {@code PT5M}
|
||||
* @param reaperInterval how often the reaper purges old PUBLISHED rows; default {@code PT10M}
|
||||
* @param publishedRetention how long PUBLISHED rows are kept before the reaper deletes them;
|
||||
* default {@code P7D}
|
||||
*/
|
||||
@Validated
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.outbox")
|
||||
public record OutboxSettings(
|
||||
Boolean relayEnabled,
|
||||
Duration pollInterval,
|
||||
Integer batchSize,
|
||||
Duration inFlightTimeout,
|
||||
Duration reaperInterval,
|
||||
Duration publishedRetention) {
|
||||
|
||||
public OutboxSettings {
|
||||
if (relayEnabled == null) {
|
||||
relayEnabled = true;
|
||||
}
|
||||
|
||||
if (pollInterval == null) {
|
||||
pollInterval = Duration.ofSeconds(5);
|
||||
} else if (pollInterval.isZero() || pollInterval.isNegative()) {
|
||||
throw StartupFailures.envValidation(
|
||||
"ca-skeleton.outbox.pollInterval must be positive, was " + pollInterval);
|
||||
}
|
||||
|
||||
if (batchSize == null) {
|
||||
batchSize = 20;
|
||||
} else if (batchSize <= 0) {
|
||||
throw StartupFailures.envValidation(
|
||||
"ca-skeleton.outbox.batchSize must be > 0, was " + batchSize);
|
||||
}
|
||||
|
||||
if (inFlightTimeout == null) {
|
||||
inFlightTimeout = Duration.ofMinutes(5);
|
||||
} else if (inFlightTimeout.isZero() || inFlightTimeout.isNegative()) {
|
||||
throw StartupFailures.envValidation(
|
||||
"ca-skeleton.outbox.inFlightTimeout must be positive, was " + inFlightTimeout);
|
||||
}
|
||||
|
||||
if (reaperInterval == null) {
|
||||
reaperInterval = Duration.ofMinutes(10);
|
||||
} else if (reaperInterval.isZero() || reaperInterval.isNegative()) {
|
||||
throw StartupFailures.envValidation(
|
||||
"ca-skeleton.outbox.reaperInterval must be positive, was " + reaperInterval);
|
||||
}
|
||||
|
||||
if (publishedRetention == null) {
|
||||
publishedRetention = Duration.ofDays(7);
|
||||
} else if (publishedRetention.isZero() || publishedRetention.isNegative()) {
|
||||
throw StartupFailures.envValidation(
|
||||
"ca-skeleton.outbox.publishedRetention must be positive, was " + publishedRetention);
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package dev.caskeleton.bootstrap.runtime;
|
||||
|
||||
import java.util.Optional;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
/**
|
||||
* Default {@link SecretSource} ({@link SecretSourceStrategy#ENVIRONMENT}): resolves secrets from
|
||||
* the Spring {@link Environment}. See README for the design rationale.
|
||||
*/
|
||||
public final class EnvironmentSecretSource implements SecretSource {
|
||||
|
||||
private final Environment environment;
|
||||
|
||||
public EnvironmentSecretSource(Environment environment) {
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> resolve(String key) {
|
||||
String value = environment.getProperty(key);
|
||||
return (value == null || value.isBlank()) ? Optional.empty() : Optional.of(value);
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package dev.caskeleton.bootstrap.runtime;
|
||||
|
||||
import dev.caskeleton.bootstrap.runtime.startup.StartupFailures;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.springframework.beans.factory.SmartInitializingSingleton;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
/**
|
||||
* Fail-fast startup guard for HikariCP inter-knob constraints. Reads (never re-binds) resolved
|
||||
* Spring properties as a {@link SmartInitializingSingleton}; an absent property is skipped. See
|
||||
* README for the design rationale.
|
||||
*/
|
||||
public class HikariPoolConstraintValidator implements SmartInitializingSingleton {
|
||||
|
||||
static final String CONNECTION_TIMEOUT_KEY = "spring.datasource.hikari.connection-timeout";
|
||||
static final String VALIDATION_TIMEOUT_KEY = "spring.datasource.hikari.validation-timeout";
|
||||
static final String KEEPALIVE_TIME_KEY = "spring.datasource.hikari.keepalive-time";
|
||||
static final String MAX_LIFETIME_KEY = "spring.datasource.hikari.max-lifetime";
|
||||
static final String LEAK_DETECTION_KEY = "spring.datasource.hikari.leak-detection-threshold";
|
||||
|
||||
// Operator-facing env keys, so a boot failure names the APP_* variable the operator set.
|
||||
// Knobs without a registered env key emit ENV_KEY_PENDING instead of a fabricated name.
|
||||
static final String CONNECTION_TIMEOUT_ENV_KEY = "APP_DATASOURCE_CONNECTION_TIMEOUT";
|
||||
static final String MAX_LIFETIME_ENV_KEY = "APP_DATASOURCE_POOL_MAX_LIFETIME";
|
||||
static final String ENV_KEY_PENDING = "env key pending feature-env-driven-runtime-configuration";
|
||||
|
||||
private final Environment environment;
|
||||
|
||||
public HikariPoolConstraintValidator(Environment environment) {
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterSingletonsInstantiated() {
|
||||
List<String> violations = new ArrayList<>();
|
||||
|
||||
Long connectionTimeout = parseMillis(environment.getProperty(CONNECTION_TIMEOUT_KEY));
|
||||
Long validationTimeout = parseMillis(environment.getProperty(VALIDATION_TIMEOUT_KEY));
|
||||
Long keepaliveTime = parseMillis(environment.getProperty(KEEPALIVE_TIME_KEY));
|
||||
Long maxLifetime = parseMillis(environment.getProperty(MAX_LIFETIME_KEY));
|
||||
Long leakDetection = parseMillis(environment.getProperty(LEAK_DETECTION_KEY));
|
||||
|
||||
if (connectionTimeout != null && connectionTimeout < 250L) {
|
||||
violations.add(
|
||||
"D2/HIKARI-CFG-C1: "
|
||||
+ CONNECTION_TIMEOUT_ENV_KEY
|
||||
+ " ("
|
||||
+ CONNECTION_TIMEOUT_KEY
|
||||
+ ") must be >= 250 ms, was "
|
||||
+ connectionTimeout);
|
||||
}
|
||||
|
||||
if (validationTimeout != null
|
||||
&& connectionTimeout != null
|
||||
&& validationTimeout >= connectionTimeout) {
|
||||
violations.add(
|
||||
"D7/HIKARI-CFG-C6: validation-timeout ("
|
||||
+ VALIDATION_TIMEOUT_KEY
|
||||
+ ", "
|
||||
+ ENV_KEY_PENDING
|
||||
+ ") must be < connection-timeout ("
|
||||
+ CONNECTION_TIMEOUT_ENV_KEY
|
||||
+ " / "
|
||||
+ CONNECTION_TIMEOUT_KEY
|
||||
+ "); was validation-timeout="
|
||||
+ validationTimeout
|
||||
+ ", connection-timeout="
|
||||
+ connectionTimeout);
|
||||
}
|
||||
|
||||
if (keepaliveTime != null && maxLifetime != null && keepaliveTime >= maxLifetime) {
|
||||
violations.add(
|
||||
"D4/HIKARI-CFG-C4: keepalive-time ("
|
||||
+ KEEPALIVE_TIME_KEY
|
||||
+ ", "
|
||||
+ ENV_KEY_PENDING
|
||||
+ ") must be < max-lifetime ("
|
||||
+ MAX_LIFETIME_ENV_KEY
|
||||
+ " / "
|
||||
+ MAX_LIFETIME_KEY
|
||||
+ "); was keepalive-time="
|
||||
+ keepaliveTime
|
||||
+ ", max-lifetime="
|
||||
+ maxLifetime);
|
||||
}
|
||||
|
||||
if (leakDetection != null && leakDetection != 0L && leakDetection < 2000L) {
|
||||
violations.add(
|
||||
"D5/HIKARI-CFG-C5: leak-detection-threshold ("
|
||||
+ LEAK_DETECTION_KEY
|
||||
+ ", "
|
||||
+ ENV_KEY_PENDING
|
||||
+ ") must be >= 2000 ms to enable leak detection"
|
||||
+ " (0 = disabled/allowed); was "
|
||||
+ leakDetection);
|
||||
}
|
||||
|
||||
if (!violations.isEmpty()) {
|
||||
throw StartupFailures.envValidation(
|
||||
"invalid HikariCP configuration (feature-database-connection-pool-contract"
|
||||
+ " D2/D4/D5/D7): "
|
||||
+ violations);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a raw property string as a plain long (milliseconds). A non-plain-integer value (e.g. a
|
||||
* Duration string such as {@code "5s"}) yields {@code null}, which the caller treats as absent.
|
||||
* See README for the design rationale.
|
||||
*
|
||||
* @return the parsed milliseconds, or {@code null} when absent/non-numeric
|
||||
*/
|
||||
private static Long parseMillis(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(raw.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return null; // non-numeric (e.g. Duration string) — treat as absent
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package dev.caskeleton.bootstrap.runtime;
|
||||
|
||||
import dev.caskeleton.bootstrap.runtime.startup.StartupFailures;
|
||||
import org.springframework.beans.factory.SmartInitializingSingleton;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
/**
|
||||
* Fail-fast startup guard that enforces OSIV (Open Session In View) being OFF. Reads (never
|
||||
* re-binds) the resolved property as a {@link SmartInitializingSingleton}; an absent value is left
|
||||
* to Spring Boot's default and only a present {@code true} is rejected. See README for the design
|
||||
* rationale.
|
||||
*/
|
||||
public class OpenInViewSafetyValidator implements SmartInitializingSingleton {
|
||||
|
||||
static final String OPEN_IN_VIEW_KEY = "spring.jpa.open-in-view";
|
||||
static final String ENV_KEY = "APP_DATASOURCE_OPEN_IN_VIEW";
|
||||
|
||||
private final Environment environment;
|
||||
|
||||
public OpenInViewSafetyValidator(Environment environment) {
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterSingletonsInstantiated() {
|
||||
Boolean openInView = environment.getProperty(OPEN_IN_VIEW_KEY, Boolean.class);
|
||||
if (Boolean.TRUE.equals(openInView)) {
|
||||
throw StartupFailures.envValidation(
|
||||
ENV_KEY
|
||||
+ " ("
|
||||
+ OPEN_IN_VIEW_KEY
|
||||
+ ") must be false "
|
||||
+ "(feature-persistence-failure-baseline D2 — OSIV off baseline): "
|
||||
+ "open-in-view keeps the Hibernate session open through view rendering, "
|
||||
+ "so a lazy association touched in the presentation layer issues a DB query "
|
||||
+ "there, violating the layer boundary. Set "
|
||||
+ ENV_KEY
|
||||
+ "=false.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package dev.caskeleton.bootstrap.runtime;
|
||||
|
||||
import dev.caskeleton.bootstrap.runtime.startup.StartupFailures;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.springframework.beans.factory.SmartInitializingSingleton;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
/**
|
||||
* Fail-fast startup guard for the high-risk numeric runtime knobs (pool/thread sizing). Reads the
|
||||
* resolved Spring property; an absent key is skipped. See README for the design rationale.
|
||||
*/
|
||||
public class RuntimeNumericBoundsValidator implements SmartInitializingSingleton {
|
||||
|
||||
/** A numeric property bound: resolved Spring key, the {@code APP_*} env key, and minimum. */
|
||||
record Bound(String springKey, String envKey, int minInclusive) {}
|
||||
|
||||
static final List<Bound> BOUNDS =
|
||||
List.of(
|
||||
// positive (>= 1)
|
||||
new Bound(
|
||||
"spring.datasource.hikari.maximum-pool-size", "APP_DATASOURCE_POOL_MAX_SIZE", 1),
|
||||
new Bound("server.tomcat.threads.max", "APP_SERVER_TOMCAT_MAX_THREADS", 1),
|
||||
new Bound("server.tomcat.max-connections", "APP_SERVER_TOMCAT_MAX_CONNECTIONS", 1),
|
||||
// non-negative (>= 0)
|
||||
new Bound("spring.datasource.hikari.minimum-idle", "APP_DATASOURCE_POOL_MIN_IDLE", 0),
|
||||
new Bound("server.tomcat.threads.min-spare", "APP_SERVER_TOMCAT_MIN_SPARE_THREADS", 0),
|
||||
new Bound("server.tomcat.accept-count", "APP_SERVER_TOMCAT_ACCEPT_COUNT", 0));
|
||||
|
||||
private final Environment environment;
|
||||
|
||||
public RuntimeNumericBoundsValidator(Environment environment) {
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterSingletonsInstantiated() {
|
||||
List<String> violations = new ArrayList<>();
|
||||
for (Bound bound : BOUNDS) {
|
||||
Integer value = environment.getProperty(bound.springKey(), Integer.class);
|
||||
if (value == null) {
|
||||
continue; // absent → skip (framework default owns it)
|
||||
}
|
||||
if (value < bound.minInclusive()) {
|
||||
violations.add(
|
||||
bound.envKey()
|
||||
+ " ("
|
||||
+ bound.springKey()
|
||||
+ ") must be >= "
|
||||
+ bound.minInclusive()
|
||||
+ ", was "
|
||||
+ value);
|
||||
}
|
||||
}
|
||||
if (!violations.isEmpty()) {
|
||||
throw StartupFailures.envValidation(
|
||||
"invalid runtime numeric configuration (feature-env-driven-runtime-configuration "
|
||||
+ "D10 — no lenient default): "
|
||||
+ violations);
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package dev.caskeleton.bootstrap.runtime;
|
||||
|
||||
import dev.caskeleton.bootstrap.settings.RuntimeSafetySettings;
|
||||
import org.springframework.beans.factory.ListableBeanFactory;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
/**
|
||||
* Wires the runtime-safety startup fail-fast validators into the running application. See README
|
||||
* for the design rationale.
|
||||
*/
|
||||
@Configuration
|
||||
public class RuntimeSafetyConfig {
|
||||
|
||||
@Bean
|
||||
StartupSafetyValidator startupSafetyValidator(
|
||||
Environment environment, RuntimeSafetySettings settings, ListableBeanFactory beanFactory) {
|
||||
return new StartupSafetyValidator(environment, settings, beanFactory);
|
||||
}
|
||||
|
||||
@Bean
|
||||
RuntimeNumericBoundsValidator runtimeNumericBoundsValidator(Environment environment) {
|
||||
return new RuntimeNumericBoundsValidator(environment);
|
||||
}
|
||||
|
||||
@Bean
|
||||
OpenInViewSafetyValidator openInViewSafetyValidator(Environment environment) {
|
||||
return new OpenInViewSafetyValidator(environment);
|
||||
}
|
||||
|
||||
@Bean
|
||||
HikariPoolConstraintValidator hikariPoolConstraintValidator(Environment environment) {
|
||||
return new HikariPoolConstraintValidator(environment);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package dev.caskeleton.bootstrap.runtime;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Backend seam for secret resolution (the concrete backend is chosen by {@link
|
||||
* SecretSourceFactory}). See README for the design rationale.
|
||||
*/
|
||||
public interface SecretSource {
|
||||
|
||||
/**
|
||||
* A blank value MUST be treated as absent so a present-but-empty secret cannot pass a presence
|
||||
* check.
|
||||
*
|
||||
* @return the resolved non-blank value, or empty when absent/blank
|
||||
*/
|
||||
Optional<String> resolve(String key);
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package dev.caskeleton.bootstrap.runtime;
|
||||
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.env.ConfigurableEnvironment;
|
||||
|
||||
/**
|
||||
* Wires the secret/config-source backend and its startup fail-fast guard. See README for the design
|
||||
* rationale.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(SecretSourceSettings.class)
|
||||
public class SecretSourceConfig {
|
||||
|
||||
@Bean
|
||||
SecretSource secretSource(SecretSourceSettings properties, ConfigurableEnvironment environment) {
|
||||
return SecretSourceFactory.create(properties.strategy(), environment);
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecretSourceValidator secretSourceValidator(
|
||||
ConfigurableEnvironment environment, SecretSource secretSource) {
|
||||
return new SecretSourceValidator(environment, secretSource);
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.caskeleton.bootstrap.runtime;
|
||||
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
/**
|
||||
* Builds the configured {@link SecretSource} backend; the {@code switch} below is the one extension
|
||||
* point for adding a backend. See README for the design rationale.
|
||||
*/
|
||||
public final class SecretSourceFactory {
|
||||
|
||||
private SecretSourceFactory() {}
|
||||
|
||||
public static SecretSource create(SecretSourceStrategy strategy, Environment environment) {
|
||||
return switch (strategy) {
|
||||
case ENVIRONMENT -> new EnvironmentSecretSource(environment);
|
||||
};
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package dev.caskeleton.bootstrap.runtime;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Secret backend selection, bound from {@code ca-skeleton.secret-source.strategy}. See README for
|
||||
* the design rationale.
|
||||
*
|
||||
* @param strategy the secret backend; {@code null} → {@code ENVIRONMENT}
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.secret-source")
|
||||
public record SecretSourceSettings(SecretSourceStrategy strategy) {
|
||||
|
||||
public SecretSourceSettings {
|
||||
if (strategy == null) {
|
||||
strategy = SecretSourceStrategy.ENVIRONMENT;
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package dev.caskeleton.bootstrap.runtime;
|
||||
|
||||
/**
|
||||
* Selectable secret backend, bound from {@code ca-skeleton.secret-source.strategy}. {@link
|
||||
* #ENVIRONMENT} is the shipped default. See README for the design rationale.
|
||||
*/
|
||||
public enum SecretSourceStrategy {
|
||||
ENVIRONMENT
|
||||
|
||||
// Future backends (each: new SecretSource impl + factory case):
|
||||
// VAULT, // HashiCorp Vault
|
||||
// AWS_SECRETS_MANAGER, // AWS Secrets Manager
|
||||
// GCP_SECRET_MANAGER // GCP Secret Manager
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package dev.caskeleton.bootstrap.runtime;
|
||||
|
||||
import dev.caskeleton.bootstrap.runtime.startup.StartupFailures;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.springframework.beans.factory.SmartInitializingSingleton;
|
||||
import org.springframework.core.env.ConfigurableEnvironment;
|
||||
import org.springframework.core.env.EnumerablePropertySource;
|
||||
import org.springframework.core.env.PropertySource;
|
||||
|
||||
/**
|
||||
* Fail-fast startup guard for the secret/config-source contract (prod-profile only; a violation
|
||||
* throws so the application refuses to start). See README for the design rationale.
|
||||
*/
|
||||
public class SecretSourceValidator implements SmartInitializingSingleton {
|
||||
|
||||
/** Prefix marking a dev/local fake credential; forbidden to reach the prod profile. */
|
||||
static final String LOCAL_DEV_SENTINEL_PREFIX = "__LOCAL_DEV_";
|
||||
|
||||
private static final String PROD_PROFILE = "prod";
|
||||
|
||||
/**
|
||||
* Secret keys that must be injected under the {@code prod} profile. {@code
|
||||
* SecretsClassificationRegistryTest} asserts this list matches the registry 1:1. See README for
|
||||
* the design rationale.
|
||||
*/
|
||||
public static final List<String> REQUIRED_PROD_SECRETS =
|
||||
List.of(
|
||||
"APP_DATASOURCE_PASSWORD",
|
||||
"APP_SECURITY_JWT_SIGNING_KEY",
|
||||
"APP_SECURITY_OAUTH_CLIENT_SECRET",
|
||||
"APP_EXTERNAL_API_KEY",
|
||||
"APP_CACHE_REDIS_PASSWORD",
|
||||
"APP_PRIVACY_PSEUDONYMIZATION_SALT");
|
||||
|
||||
private final ConfigurableEnvironment environment;
|
||||
private final SecretSource secretSource;
|
||||
|
||||
public SecretSourceValidator(ConfigurableEnvironment environment, SecretSource secretSource) {
|
||||
this.environment = environment;
|
||||
this.secretSource = secretSource;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterSingletonsInstantiated() {
|
||||
validateNoLocalDevSentinelInProd();
|
||||
validateRequiredSecretsPresent();
|
||||
}
|
||||
|
||||
private void validateNoLocalDevSentinelInProd() {
|
||||
if (!isProdActive()) {
|
||||
return;
|
||||
}
|
||||
List<String> offenders = new ArrayList<>();
|
||||
for (PropertySource<?> source : environment.getPropertySources()) {
|
||||
if (source instanceof EnumerablePropertySource<?> enumerable) {
|
||||
for (String name : enumerable.getPropertyNames()) {
|
||||
Object value = enumerable.getProperty(name);
|
||||
if (value instanceof String text
|
||||
&& text.startsWith(LOCAL_DEV_SENTINEL_PREFIX)
|
||||
&& !offenders.contains(name)) {
|
||||
offenders.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!offenders.isEmpty()) {
|
||||
throw StartupFailures.envValidation(
|
||||
"prod profile forbids '"
|
||||
+ LOCAL_DEV_SENTINEL_PREFIX
|
||||
+ "' sentinel credential "
|
||||
+ "values, but these keys carry one: "
|
||||
+ offenders
|
||||
+ " — inject real secrets from the secret manager / mounted env for prod.");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateRequiredSecretsPresent() {
|
||||
if (!isProdActive()) {
|
||||
return;
|
||||
}
|
||||
List<String> missing = new ArrayList<>();
|
||||
for (String key : REQUIRED_PROD_SECRETS) {
|
||||
if (secretSource.resolve(key).isEmpty()) {
|
||||
missing.add(key);
|
||||
}
|
||||
}
|
||||
if (!missing.isEmpty()) {
|
||||
throw StartupFailures.envValidation(
|
||||
"prod profile requires these secrets to be injected, but they are missing/blank: "
|
||||
+ missing
|
||||
+ " — booting with an empty secret is forbidden "
|
||||
+ "(feature-secrets-config-source-contract §테스트 계약).");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isProdActive() {
|
||||
// Case-insensitive: a typo such as SPRING_PROFILES_ACTIVE=PROD must still match.
|
||||
for (String profile : environment.getActiveProfiles()) {
|
||||
if (PROD_PROFILE.equalsIgnoreCase(profile)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package dev.caskeleton.bootstrap.runtime;
|
||||
|
||||
import dev.caskeleton.bootstrap.runtime.startup.StartupFailures;
|
||||
import dev.caskeleton.bootstrap.settings.RuntimeSafetySettings;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.springframework.beans.factory.ListableBeanFactory;
|
||||
import org.springframework.beans.factory.SmartInitializingSingleton;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
/**
|
||||
* Fail-fast startup guard for env-combination invariants. Implemented as a {@link
|
||||
* SmartInitializingSingleton} so the check runs once after every singleton is instantiated but
|
||||
* before the context finishes refreshing; a violation throws so the context refuses to start. See
|
||||
* README for the design rationale.
|
||||
*/
|
||||
public class StartupSafetyValidator implements SmartInitializingSingleton {
|
||||
|
||||
/**
|
||||
* Bean names every instance-coordination capability must register when {@code
|
||||
* APP_MULTI_INSTANCE_ENABLED=true}; this validator only asserts their presence.
|
||||
*/
|
||||
static final List<String> REQUIRED_MULTI_INSTANCE_BEANS =
|
||||
List.of(
|
||||
"distributedLockProvider", // distributed lock (JdbcLockRegistry)
|
||||
"cacheStampedeProtection", // cache stampede protection (Redisson RLock)
|
||||
"outboxLeaderElection", // outbox leader election (SKIP LOCKED)
|
||||
"distributedRateLimiter", // distributed rate limiter (Redis counter)
|
||||
"migrationStartupRunner" // platform migration startup job
|
||||
);
|
||||
|
||||
private static final String PROD_PROFILE = "prod";
|
||||
|
||||
private final Environment environment;
|
||||
private final RuntimeSafetySettings settings;
|
||||
private final ListableBeanFactory beanFactory;
|
||||
|
||||
public StartupSafetyValidator(
|
||||
Environment environment, RuntimeSafetySettings settings, ListableBeanFactory beanFactory) {
|
||||
this.environment = environment;
|
||||
this.settings = settings;
|
||||
this.beanFactory = beanFactory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterSingletonsInstantiated() {
|
||||
validateProdSafety();
|
||||
validateMultiInstance();
|
||||
}
|
||||
|
||||
/** Under the {@code prod} profile, internal-detail and body-capture toggles must be off. */
|
||||
private void validateProdSafety() {
|
||||
if (!isProdActive()) {
|
||||
return;
|
||||
}
|
||||
List<String> unsafe = new ArrayList<>();
|
||||
if (settings.errorDetailExposureEnabled()) {
|
||||
unsafe.add("APP_ERROR_DETAIL_EXPOSURE_ENABLED");
|
||||
}
|
||||
if (settings.logBodyCaptureEnabled()) {
|
||||
unsafe.add("APP_LOG_BODY_CAPTURE_ENABLED");
|
||||
}
|
||||
if (!unsafe.isEmpty()) {
|
||||
// prod-unsafe toggle left on under prod → PROFILE_MISMATCH (exit 71).
|
||||
throw StartupFailures.profileMismatch(
|
||||
"prod profile forbids these toggles being enabled: "
|
||||
+ unsafe
|
||||
+ " — set them to false for the prod profile.");
|
||||
}
|
||||
}
|
||||
|
||||
/** When multi-instance is on, every instance-coordination capability bean must exist. */
|
||||
private void validateMultiInstance() {
|
||||
if (!settings.multiInstanceEnabled()) {
|
||||
return;
|
||||
}
|
||||
List<String> missing = new ArrayList<>();
|
||||
for (String beanName : REQUIRED_MULTI_INSTANCE_BEANS) {
|
||||
if (!beanFactory.containsBean(beanName)) {
|
||||
missing.add(beanName);
|
||||
}
|
||||
}
|
||||
if (!missing.isEmpty()) {
|
||||
// required coordination bean absent under multi-instance → REQUIRED_ADAPTER_DISABLED (exit
|
||||
// 72).
|
||||
throw StartupFailures.requiredAdapterDisabled(
|
||||
"APP_MULTI_INSTANCE_ENABLED=true requires the instance-coordination beans "
|
||||
+ REQUIRED_MULTI_INSTANCE_BEANS
|
||||
+ ", but these are missing: "
|
||||
+ missing);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isProdActive() {
|
||||
// Case-insensitive: a typo such as SPRING_PROFILES_ACTIVE=PROD must still match.
|
||||
for (String profile : environment.getActiveProfiles()) {
|
||||
if (PROD_PROFILE.equalsIgnoreCase(profile)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package dev.caskeleton.bootstrap.runtime.startup;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.springframework.beans.factory.SmartInitializingSingleton;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
/**
|
||||
* Keeps the Flyway prod safety nets on; raises PROFILE_MISMATCH (exit 71) when an override
|
||||
* re-enables a forbidden option. See README for the design rationale.
|
||||
*/
|
||||
public class FlywayProdSafetyValidator implements SmartInitializingSingleton {
|
||||
|
||||
static final String BASELINE_ON_MIGRATE_KEY = "spring.flyway.baseline-on-migrate";
|
||||
static final String OUT_OF_ORDER_KEY = "spring.flyway.out-of-order";
|
||||
static final String CLEAN_DISABLED_KEY = "spring.flyway.clean-disabled";
|
||||
|
||||
private static final String PROD_PROFILE = "prod";
|
||||
|
||||
private final Environment environment;
|
||||
|
||||
public FlywayProdSafetyValidator(Environment environment) {
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterSingletonsInstantiated() {
|
||||
if (!isProdActive()) {
|
||||
return;
|
||||
}
|
||||
List<String> violations = new ArrayList<>();
|
||||
if (isTrue(BASELINE_ON_MIGRATE_KEY)) {
|
||||
violations.add(BASELINE_ON_MIGRATE_KEY + "=true (removes the missing-migration safety net)");
|
||||
}
|
||||
if (isTrue(OUT_OF_ORDER_KEY)) {
|
||||
violations.add(OUT_OF_ORDER_KEY + "=true (breaks migration ordering consistency)");
|
||||
}
|
||||
if (isFalse(CLEAN_DISABLED_KEY)) {
|
||||
violations.add(CLEAN_DISABLED_KEY + "=false (re-arms destructive Flyway clean)");
|
||||
}
|
||||
if (!violations.isEmpty()) {
|
||||
throw StartupFailures.profileMismatch(
|
||||
"prod profile forbids these Flyway options (feature-migration-startup-contract"
|
||||
+ " D2/D4): "
|
||||
+ violations
|
||||
+ " — partial-schema recovery goes through runbook://migration/manual-recovery,"
|
||||
+ " never an in-prod Flyway repair.");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isTrue(String key) {
|
||||
return Boolean.parseBoolean(environment.getProperty(key));
|
||||
}
|
||||
|
||||
private boolean isFalse(String key) {
|
||||
String value = environment.getProperty(key);
|
||||
return value != null && "false".equalsIgnoreCase(value.trim());
|
||||
}
|
||||
|
||||
private boolean isProdActive() {
|
||||
// Case-insensitive so a SPRING_PROFILES_ACTIVE=PROD typo still triggers the guard.
|
||||
for (String profile : environment.getActiveProfiles()) {
|
||||
if (PROD_PROFILE.equalsIgnoreCase(profile)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.caskeleton.bootstrap.runtime.startup;
|
||||
|
||||
/**
|
||||
* A Flyway forward-only migration failed during startup (MIGRATION_FAILED, exit 70 — sysexits
|
||||
* {@code EX_SOFTWARE}). Phase: migration. Wraps the underlying {@code FlywayException} as the cause
|
||||
* to preserve the root error for triage.
|
||||
*/
|
||||
public class MigrationFailedException extends StartupFailureException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public MigrationFailedException(String message, Throwable cause) {
|
||||
super(StartupErrorCode.MIGRATION_FAILED, message, cause);
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package dev.caskeleton.bootstrap.runtime.startup;
|
||||
|
||||
import dev.caskeleton.bootstrap.settings.RuntimeSafetySettings;
|
||||
import org.springframework.boot.flyway.autoconfigure.FlywayMigrationStrategy;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
/**
|
||||
* Composition-root wiring for the startup migration guards and the Flyway migration strategy. See
|
||||
* README for the design rationale.
|
||||
*/
|
||||
@Configuration
|
||||
public class MigrationStartupConfig {
|
||||
|
||||
/**
|
||||
* Bean name must stay {@code migrationStartupRunner} (Flyway autoconfiguration delegates to it).
|
||||
*/
|
||||
@Bean
|
||||
FlywayMigrationStrategy migrationStartupRunner(RuntimeSafetySettings settings) {
|
||||
return new MigrationStartupRunner(settings);
|
||||
}
|
||||
|
||||
/** Env-validation (exit 78): datasource connection env present before migration runs. */
|
||||
@Bean
|
||||
RequiredEnvironmentValidator requiredEnvironmentValidator(Environment environment) {
|
||||
return new RequiredEnvironmentValidator(environment);
|
||||
}
|
||||
|
||||
/** Profile check (exit 71): forbidden Flyway options stay off under the prod profile. */
|
||||
@Bean
|
||||
FlywayProdSafetyValidator flywayProdSafetyValidator(Environment environment) {
|
||||
return new FlywayProdSafetyValidator(environment);
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package dev.caskeleton.bootstrap.runtime.startup;
|
||||
|
||||
import static net.logstash.logback.argument.StructuredArguments.kv;
|
||||
|
||||
import dev.caskeleton.bootstrap.settings.RuntimeSafetySettings;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.flywaydb.core.api.FlywayException;
|
||||
import org.flywaydb.core.api.output.MigrateResult;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.flyway.autoconfigure.FlywayMigrationStrategy;
|
||||
|
||||
/**
|
||||
* Runs the Flyway migration during context refresh so it completes before readiness, and translates
|
||||
* a {@link FlywayException} into a {@link MigrationFailedException} (exit 70). See README for the
|
||||
* design rationale.
|
||||
*/
|
||||
public class MigrationStartupRunner implements FlywayMigrationStrategy {
|
||||
|
||||
private static final Logger log =
|
||||
LoggerFactory.getLogger("dev.caskeleton.bootstrap.runtime.startup");
|
||||
|
||||
private final RuntimeSafetySettings settings;
|
||||
|
||||
public MigrationStartupRunner(RuntimeSafetySettings settings) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void migrate(Flyway flyway) {
|
||||
if (!settings.migrationOnStartup()) {
|
||||
log.info(
|
||||
"startup phase {}: in-app Flyway migration is disabled via configuration (ca-skeleton.runtime.migration-on-startup=false)",
|
||||
kv("startup.phase", StartupPhase.MIGRATION.wireName()));
|
||||
return;
|
||||
}
|
||||
log.info(
|
||||
"startup phase {}: applying Flyway forward-only migrations",
|
||||
kv("startup.phase", StartupPhase.MIGRATION.wireName()));
|
||||
try {
|
||||
MigrateResult result = flyway.migrate();
|
||||
int executed = (result != null) ? result.migrationsExecuted : 0;
|
||||
log.info(
|
||||
"startup phase {}: migration complete, {} migration(s) applied",
|
||||
kv("startup.phase", StartupPhase.MIGRATION.wireName()),
|
||||
executed);
|
||||
} catch (FlywayException e) {
|
||||
// StartupFailures emits the structured failure log before constructing the exception.
|
||||
throw StartupFailures.migrationFailed(
|
||||
"Flyway forward-only migration failed during startup", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.caskeleton.bootstrap.runtime.startup;
|
||||
|
||||
/**
|
||||
* An active profile conflicts with an enabled prod-forbidden setting — e.g. a prod-unsafe toggle or
|
||||
* a forbidden Flyway option left on under the {@code prod} profile (PROFILE_MISMATCH, exit 71 —
|
||||
* ca-tmpl internal convention, see {@link StartupErrorCode}). Phase: profile-check.
|
||||
*/
|
||||
public class ProfileMismatchException extends StartupFailureException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public ProfileMismatchException(String message) {
|
||||
super(StartupErrorCode.PROFILE_MISMATCH, message, null);
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package dev.caskeleton.bootstrap.runtime.startup;
|
||||
|
||||
/**
|
||||
* A required capability adapter / coordination bean is disabled or absent at startup
|
||||
* (REQUIRED_ADAPTER_DISABLED, exit 72 — ca-tmpl internal convention, see {@link StartupErrorCode}).
|
||||
* Phase: adapter-enablement. Distinct from the runtime-lifecycle {@code ADAPTER_DISABLED}: this is
|
||||
* a startup validation, not a runtime invoke against a disabled optional adapter. See README for
|
||||
* the design rationale.
|
||||
*/
|
||||
public class RequiredAdapterDisabledException extends StartupFailureException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public RequiredAdapterDisabledException(String message) {
|
||||
super(StartupErrorCode.REQUIRED_ADAPTER_DISABLED, message, null);
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package dev.caskeleton.bootstrap.runtime.startup;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.beans.factory.SmartInitializingSingleton;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
/**
|
||||
* Raises STARTUP_VALIDATION_FAILED (exit 78) naming every missing operator env key before a
|
||||
* migration fails with an opaque driver error. Absent or blank counts as missing. See README for
|
||||
* the design rationale.
|
||||
*/
|
||||
public class RequiredEnvironmentValidator implements SmartInitializingSingleton {
|
||||
|
||||
/** Spring property key → operator-facing env key (named in the failure message). */
|
||||
static final Map<String, String> REQUIRED_DATASOURCE_ENV =
|
||||
Map.of(
|
||||
"spring.datasource.url", "APP_DATASOURCE_URL",
|
||||
"spring.datasource.username", "APP_DATASOURCE_USERNAME",
|
||||
"spring.datasource.driver-class-name", "APP_DATASOURCE_DRIVER");
|
||||
|
||||
private final Environment environment;
|
||||
|
||||
public RequiredEnvironmentValidator(Environment environment) {
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterSingletonsInstantiated() {
|
||||
List<String> missing = new ArrayList<>();
|
||||
// Sorted for a stable, deterministic message regardless of map iteration order.
|
||||
REQUIRED_DATASOURCE_ENV.entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByValue())
|
||||
.forEach(
|
||||
entry -> {
|
||||
String value = environment.getProperty(entry.getKey());
|
||||
if (value == null || value.isBlank()) {
|
||||
missing.add(entry.getValue() + " (" + entry.getKey() + ")");
|
||||
}
|
||||
});
|
||||
if (!missing.isEmpty()) {
|
||||
throw StartupFailures.envValidation(
|
||||
"required datasource environment is missing or blank — a Flyway migration"
|
||||
+ " cannot run without it: "
|
||||
+ missing);
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package dev.caskeleton.bootstrap.runtime.startup;
|
||||
|
||||
/**
|
||||
* The four startup-failure causes. Each exit code becomes the JVM exit status only because the
|
||||
* carrying exception implements {@link org.springframework.boot.ExitCodeGenerator} (see {@link
|
||||
* StartupFailureException}). See README for the design rationale.
|
||||
*/
|
||||
public enum StartupErrorCode {
|
||||
STARTUP_VALIDATION_FAILED(78, StartupPhase.ENV_VALIDATION),
|
||||
MIGRATION_FAILED(70, StartupPhase.MIGRATION),
|
||||
PROFILE_MISMATCH(71, StartupPhase.PROFILE_CHECK),
|
||||
REQUIRED_ADAPTER_DISABLED(72, StartupPhase.ADAPTER_ENABLEMENT);
|
||||
|
||||
/** Registry-fixed category for all four codes. */
|
||||
private static final String CATEGORY = "INTERNAL";
|
||||
|
||||
private final int exitCode;
|
||||
private final StartupPhase phase;
|
||||
|
||||
StartupErrorCode(int exitCode, StartupPhase phase) {
|
||||
this.exitCode = exitCode;
|
||||
this.phase = phase;
|
||||
}
|
||||
|
||||
public int exitCode() {
|
||||
return exitCode;
|
||||
}
|
||||
|
||||
/** The startup phase this cause belongs to ({@code startup.phase} log field). */
|
||||
public StartupPhase phase() {
|
||||
return phase;
|
||||
}
|
||||
|
||||
/** Registry category — always {@code INTERNAL} ({@code error.category} log field). */
|
||||
public String category() {
|
||||
return CATEGORY;
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package dev.caskeleton.bootstrap.runtime.startup;
|
||||
|
||||
import org.springframework.boot.ExitCodeGenerator;
|
||||
|
||||
/**
|
||||
* Base type for the four startup-failure causes. Implementing {@link ExitCodeGenerator} turns the
|
||||
* cause's {@link StartupErrorCode#exitCode()} into the JVM exit status when context refresh fails.
|
||||
* Extends {@link IllegalStateException} for source/behaviour compatibility with the prior {@code
|
||||
* IllegalStateException}-based startup validation. See README for the design rationale.
|
||||
*/
|
||||
public abstract class StartupFailureException extends IllegalStateException
|
||||
implements ExitCodeGenerator {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final transient StartupErrorCode errorCode;
|
||||
|
||||
protected StartupFailureException(StartupErrorCode errorCode, String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
public StartupErrorCode errorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getExitCode() {
|
||||
return errorCode.exitCode();
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package dev.caskeleton.bootstrap.runtime.startup;
|
||||
|
||||
import org.springframework.boot.SpringBootExceptionReporter;
|
||||
|
||||
/**
|
||||
* Marks typed startup failures as already reported by {@link StartupFailures}, suppressing Spring
|
||||
* Boot's generic "Application run failed" stacktrace for those fatal paths only.
|
||||
*/
|
||||
public class StartupFailureExceptionReporter implements SpringBootExceptionReporter {
|
||||
|
||||
@Override
|
||||
public boolean reportException(Throwable failure) {
|
||||
return containsStartupFailure(failure);
|
||||
}
|
||||
|
||||
private static boolean containsStartupFailure(Throwable failure) {
|
||||
for (Throwable current = failure; current != null; current = current.getCause()) {
|
||||
if (current instanceof StartupFailureException) {
|
||||
return true;
|
||||
}
|
||||
if (current.getCause() == current) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package dev.caskeleton.bootstrap.runtime.startup;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/** Process-local marker that the canonical startup failure log has already been emitted. */
|
||||
public final class StartupFailureLogState {
|
||||
|
||||
private static final AtomicBoolean STARTUP_FAILURE_REPORTED = new AtomicBoolean(false);
|
||||
|
||||
private StartupFailureLogState() {}
|
||||
|
||||
public static void markStartupFailureReported() {
|
||||
STARTUP_FAILURE_REPORTED.set(true);
|
||||
}
|
||||
|
||||
public static boolean startupFailureReported() {
|
||||
return STARTUP_FAILURE_REPORTED.get();
|
||||
}
|
||||
|
||||
public static void clearForTest() {
|
||||
STARTUP_FAILURE_REPORTED.set(false);
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package dev.caskeleton.bootstrap.runtime.startup;
|
||||
|
||||
import static net.logstash.logback.argument.StructuredArguments.kv;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Single source for raising a startup failure. Every factory method emits the structured failure
|
||||
* log (with the {@code startup.phase} / {@code error.code} / {@code error.category} fields) before
|
||||
* returning the exception to throw. See README for the design rationale.
|
||||
*/
|
||||
public final class StartupFailures {
|
||||
|
||||
private static final Logger log =
|
||||
LoggerFactory.getLogger("dev.caskeleton.bootstrap.runtime.startup");
|
||||
|
||||
private StartupFailures() {}
|
||||
|
||||
public static StartupValidationException envValidation(String detail) {
|
||||
emit(StartupErrorCode.STARTUP_VALIDATION_FAILED, detail, null);
|
||||
return new StartupValidationException(detail);
|
||||
}
|
||||
|
||||
public static ProfileMismatchException profileMismatch(String detail) {
|
||||
emit(StartupErrorCode.PROFILE_MISMATCH, detail, null);
|
||||
return new ProfileMismatchException(detail);
|
||||
}
|
||||
|
||||
public static RequiredAdapterDisabledException requiredAdapterDisabled(String detail) {
|
||||
emit(StartupErrorCode.REQUIRED_ADAPTER_DISABLED, detail, null);
|
||||
return new RequiredAdapterDisabledException(detail);
|
||||
}
|
||||
|
||||
public static MigrationFailedException migrationFailed(String detail, Throwable cause) {
|
||||
emit(StartupErrorCode.MIGRATION_FAILED, detail, cause);
|
||||
return new MigrationFailedException(detail, cause);
|
||||
}
|
||||
|
||||
/** Emits the structured startup-failure log. {@code cause} may be {@code null}. */
|
||||
static void emit(StartupErrorCode code, String detail, Throwable cause) {
|
||||
StartupFailureLogState.markStartupFailureReported();
|
||||
if (cause == null) {
|
||||
log.error(
|
||||
"startup failure in phase {}: {}",
|
||||
kv("startup.phase", code.phase().wireName()),
|
||||
detail,
|
||||
kv("error.code", code.name()),
|
||||
kv("error.category", code.category()));
|
||||
} else {
|
||||
Throwable rootCause = rootCause(cause);
|
||||
log.error(
|
||||
"startup failure in phase {}: {} (root cause {}: {})",
|
||||
kv("startup.phase", code.phase().wireName()),
|
||||
detail,
|
||||
kv("error.root_cause.class", rootCause.getClass().getName()),
|
||||
kv("error.root_cause.message", safeMessage(rootCause)),
|
||||
kv("error.code", code.name()),
|
||||
kv("error.category", code.category()));
|
||||
}
|
||||
}
|
||||
|
||||
private static Throwable rootCause(Throwable cause) {
|
||||
Throwable current = cause;
|
||||
while (current.getCause() != null && current.getCause() != current) {
|
||||
current = current.getCause();
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private static String safeMessage(Throwable cause) {
|
||||
String message = cause.getMessage();
|
||||
return message == null ? "" : message;
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package dev.caskeleton.bootstrap.runtime.startup;
|
||||
|
||||
/**
|
||||
* The startup lifecycle phase a fail-fast guard belongs to, written to the {@code startup.phase}
|
||||
* structured-log field so an operator can tell the four failure causes apart in the logs. See
|
||||
* README for the design rationale.
|
||||
*/
|
||||
public enum StartupPhase {
|
||||
|
||||
/** Required environment / configuration value missing or malformed (exit 78). */
|
||||
ENV_VALIDATION("env-validation"),
|
||||
/** Flyway forward-only migration failed (exit 70). */
|
||||
MIGRATION("migration"),
|
||||
/** A required capability adapter/bean was disabled or absent (exit 72). */
|
||||
ADAPTER_ENABLEMENT("adapter-enablement"),
|
||||
/** An active profile conflicts with an enabled prod-forbidden setting (exit 71). */
|
||||
PROFILE_CHECK("profile-check");
|
||||
|
||||
private final String wireName;
|
||||
|
||||
StartupPhase(String wireName) {
|
||||
this.wireName = wireName;
|
||||
}
|
||||
|
||||
/** The verbatim value written to the {@code startup.phase} structured-log field. */
|
||||
public String wireName() {
|
||||
return wireName;
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package dev.caskeleton.bootstrap.runtime.startup;
|
||||
|
||||
/**
|
||||
* A required environment / configuration value is missing or malformed (STARTUP_VALIDATION_FAILED,
|
||||
* exit 78 — sysexits {@code EX_CONFIG}). Phase: env-validation.
|
||||
*/
|
||||
public class StartupValidationException extends StartupFailureException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public StartupValidationException(String message) {
|
||||
super(StartupErrorCode.STARTUP_VALIDATION_FAILED, message, null);
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package dev.caskeleton.bootstrap.settings;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
/**
|
||||
* Bootstrap-scope settings bound from {@code ca-skeleton.bootstrap.*}. {@code appName} is fail-fast
|
||||
* (@NotBlank): no sensible default, so a blank value must stop startup rather than warn-and-default
|
||||
* like the other settings. See README for the design rationale.
|
||||
*/
|
||||
@Validated
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.bootstrap")
|
||||
public record BootstrapSettings(
|
||||
@NotBlank(
|
||||
message = "APP_NAME (ca-skeleton.bootstrap.app-name) is required and must not be blank")
|
||||
String appName) {}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package dev.caskeleton.bootstrap.settings;
|
||||
|
||||
import java.time.ZoneId;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Logging settings bound from {@code ca-skeleton.logging.*}. Every knob is "warn-and-default": a
|
||||
* bad value logs a warning and proceeds with a safe fallback rather than failing startup. See
|
||||
* README for the design rationale.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.logging")
|
||||
public record LoggingSettings(File file, Async async, Json json) {
|
||||
|
||||
public record File(
|
||||
boolean enabled, String path, String maxSize, int maxHistory, String totalSizeCap) {
|
||||
private static final Logger log = LoggerFactory.getLogger(File.class);
|
||||
|
||||
public File {
|
||||
if (enabled) {
|
||||
if (path == null || path.isBlank()) {
|
||||
log.warn(
|
||||
"APP_LOG_FILE_PATH is blank while APP_LOG_FILE_ENABLED=true; using 'logs/app.json'");
|
||||
path = "logs/app.json";
|
||||
}
|
||||
if (maxHistory <= 0) {
|
||||
log.warn("APP_LOG_FILE_MAX_HISTORY must be >= 1 (got {}); using 14", maxHistory);
|
||||
maxHistory = 14;
|
||||
}
|
||||
// Size strings (maxSize, totalSizeCap) are parsed by logback; let it own that contract.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record Async(boolean enabled, int queueSize, int discardingThreshold) {
|
||||
private static final Logger log = LoggerFactory.getLogger(Async.class);
|
||||
|
||||
public Async {
|
||||
if (enabled) {
|
||||
if (queueSize <= 0) {
|
||||
log.warn("APP_LOG_ASYNC_QUEUE_SIZE must be >= 1 (got {}); using 512", queueSize);
|
||||
queueSize = 512;
|
||||
}
|
||||
if (discardingThreshold < 0) {
|
||||
log.warn(
|
||||
"APP_LOG_ASYNC_DISCARDING_THRESHOLD must be >= 0 (got {}); using 20",
|
||||
discardingThreshold);
|
||||
discardingThreshold = 20;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record Json(
|
||||
String timezone, String timestampPattern, boolean includeCallerData, int loggerNameLength) {
|
||||
private static final Logger log = LoggerFactory.getLogger(Json.class);
|
||||
private static final String DEFAULT_TIMEZONE = "UTC";
|
||||
private static final String DEFAULT_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX";
|
||||
|
||||
public Json {
|
||||
if (timezone == null || timezone.isBlank()) {
|
||||
log.warn("APP_LOG_JSON_TIMEZONE is blank; using '{}'", DEFAULT_TIMEZONE);
|
||||
timezone = DEFAULT_TIMEZONE;
|
||||
} else if (!"default".equalsIgnoreCase(timezone)) {
|
||||
try {
|
||||
ZoneId.of(timezone);
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"APP_LOG_JSON_TIMEZONE '{}' is not a valid IANA zone; using '{}'",
|
||||
timezone,
|
||||
DEFAULT_TIMEZONE);
|
||||
timezone = DEFAULT_TIMEZONE;
|
||||
}
|
||||
}
|
||||
if (timestampPattern == null || timestampPattern.isBlank()) {
|
||||
log.warn("APP_LOG_JSON_TIMESTAMP_PATTERN is blank; using ISO 8601 default");
|
||||
timestampPattern = DEFAULT_PATTERN;
|
||||
}
|
||||
if (loggerNameLength < 0) {
|
||||
log.warn(
|
||||
"APP_LOG_JSON_LOGGER_NAME_LENGTH must be >= 0 (got {}); using 0", loggerNameLength);
|
||||
loggerNameLength = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package dev.caskeleton.bootstrap.settings;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Privacy settings bound from {@code ca-skeleton.privacy.*}. Holds the HMAC salt used to
|
||||
* pseudonymize {@code user_principal} in security/audit logs. A blank salt is "warn-and-default":
|
||||
* it logs a warning and falls back to a dev sentinel so local/test runs never fail to start. See
|
||||
* README for the design rationale.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.privacy")
|
||||
public record PrivacySettings(String pseudonymizationSalt) {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PrivacySettings.class);
|
||||
|
||||
/** Dev/local fallback salt — the {@code __LOCAL_DEV_} prefix is the prod-unsafe sentinel. */
|
||||
static final String DEV_SENTINEL_SALT = "__LOCAL_DEV_pseudonymization_salt";
|
||||
|
||||
public PrivacySettings {
|
||||
if (pseudonymizationSalt == null || pseudonymizationSalt.isBlank()) {
|
||||
log.warn(
|
||||
"APP_PRIVACY_PSEUDONYMIZATION_SALT is blank; using a dev sentinel salt. "
|
||||
+ "Set a real secret-manager value before production.");
|
||||
pseudonymizationSalt = DEV_SENTINEL_SALT;
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] saltBytes() {
|
||||
return pseudonymizationSalt.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package dev.caskeleton.bootstrap.settings;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.bind.DefaultValue;
|
||||
|
||||
/**
|
||||
* Operational safety toggles bound from {@code ca-skeleton.runtime.*} (all default {@code false}
|
||||
* except migrationOnStartup which defaults to {@code true}), enforced at startup by {@code
|
||||
* StartupSafetyValidator}. See README for the design rationale.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.runtime")
|
||||
public record RuntimeSafetySettings(
|
||||
boolean errorDetailExposureEnabled,
|
||||
boolean logBodyCaptureEnabled,
|
||||
boolean multiInstanceEnabled,
|
||||
@DefaultValue("true") boolean migrationOnStartup) {}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package dev.caskeleton.bootstrap.tracing;
|
||||
|
||||
import dev.caskeleton.shared.tracing.SpanErrorRecorder;
|
||||
import io.micrometer.tracing.Span;
|
||||
import io.micrometer.tracing.Tracer;
|
||||
|
||||
/** Micrometer/OTel-backed {@link SpanErrorRecorder}. See README for the design rationale. */
|
||||
public final class MicrometerSpanErrorRecorder implements SpanErrorRecorder {
|
||||
|
||||
private final Tracer tracer;
|
||||
|
||||
public MicrometerSpanErrorRecorder(Tracer tracer) {
|
||||
this.tracer = tracer;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void recordException(Throwable error, String errorCode) {
|
||||
if (error == null) {
|
||||
return;
|
||||
}
|
||||
Span span = tracer.currentSpan();
|
||||
if (span == null) {
|
||||
return;
|
||||
}
|
||||
span.error(error);
|
||||
if (errorCode != null) {
|
||||
span.tag("error.code", errorCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package dev.caskeleton.bootstrap.tracing;
|
||||
|
||||
import dev.caskeleton.shared.tracing.SpanErrorRecorder;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.tracing.Tracer;
|
||||
import io.micrometer.tracing.otel.bridge.OtelBaggageManager;
|
||||
import io.micrometer.tracing.otel.bridge.OtelCurrentTraceContext;
|
||||
import io.micrometer.tracing.otel.bridge.OtelTracer;
|
||||
import io.opentelemetry.sdk.OpenTelemetrySdk;
|
||||
import io.opentelemetry.sdk.trace.SdkTracerProvider;
|
||||
import io.opentelemetry.sdk.trace.samplers.Sampler;
|
||||
import java.util.List;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
/** Composition-root wiring for distributed tracing. See README for the design rationale. */
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(TracingSettings.class)
|
||||
public class TracingConfig {
|
||||
|
||||
private final TracingSettings properties;
|
||||
private final Environment environment;
|
||||
private final ObjectProvider<MeterRegistry> meterRegistryProvider;
|
||||
|
||||
// ObjectProvider<MeterRegistry> so wiring works even when no MeterRegistry bean exists.
|
||||
public TracingConfig(
|
||||
TracingSettings properties,
|
||||
Environment environment,
|
||||
ObjectProvider<MeterRegistry> meterRegistryProvider) {
|
||||
this.properties = properties;
|
||||
this.environment = environment;
|
||||
this.meterRegistryProvider = meterRegistryProvider;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public TracingSamplingRateGaugeRegistrar tracingSamplingRateGaugeRegistrar() {
|
||||
String activeProfile = resolveActiveProfile();
|
||||
double effectiveRate =
|
||||
new TracingSampleRateResolver().resolve(activeProfile, properties.sampleRate());
|
||||
TracingSamplingRateGauge.register(activeProfile, effectiveRate, meterRegistryProvider);
|
||||
return new TracingSamplingRateGaugeRegistrar(activeProfile, effectiveRate);
|
||||
}
|
||||
|
||||
private String resolveActiveProfile() {
|
||||
String[] profiles = environment.getActiveProfiles();
|
||||
return (profiles != null && profiles.length > 0) ? profiles[0] : "local";
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(Tracer.class)
|
||||
@ConditionalOnProperty(
|
||||
prefix = "ca-skeleton.tracing",
|
||||
name = "enabled",
|
||||
havingValue = "true",
|
||||
matchIfMissing = true)
|
||||
Tracer micrometerTracer() {
|
||||
double sampleRate =
|
||||
new TracingSampleRateResolver().resolve(resolveActiveProfile(), properties.sampleRate());
|
||||
SdkTracerProvider tracerProvider =
|
||||
SdkTracerProvider.builder()
|
||||
.setSampler(Sampler.parentBased(Sampler.traceIdRatioBased(sampleRate)))
|
||||
.build();
|
||||
OpenTelemetrySdk openTelemetry =
|
||||
OpenTelemetrySdk.builder().setTracerProvider(tracerProvider).build();
|
||||
OtelCurrentTraceContext currentTraceContext = new OtelCurrentTraceContext();
|
||||
return new OtelTracer(
|
||||
openTelemetry.getTracer("dev.caskeleton"),
|
||||
currentTraceContext,
|
||||
event -> {},
|
||||
new OtelBaggageManager(currentTraceContext, List.of(), List.of()));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link ObjectProvider}, not {@code @ConditionalOnBean(Tracer.class)}: a missing {@link Tracer}
|
||||
* yields {@link SpanErrorRecorder#NOOP} rather than no bean, so downstream injection points never
|
||||
* break when tracing is off.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(SpanErrorRecorder.class)
|
||||
SpanErrorRecorder micrometerSpanErrorRecorder(ObjectProvider<Tracer> tracerProvider) {
|
||||
Tracer tracer = tracerProvider.getIfAvailable();
|
||||
if (tracer == null) {
|
||||
return SpanErrorRecorder.NOOP;
|
||||
}
|
||||
return new MicrometerSpanErrorRecorder(tracer);
|
||||
}
|
||||
|
||||
/** Exists so tests can inspect what was registered without a live MeterRegistry. */
|
||||
public record TracingSamplingRateGaugeRegistrar(
|
||||
String activeProfile, double effectiveSampleRate) {}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package dev.caskeleton.bootstrap.tracing;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Resolves the effective tracing sample rate from the active profile and the configured override.
|
||||
* Plain Java, no Spring dependency, so it is reusable from both the {@code
|
||||
* EnvironmentPostProcessor} and bean phases. See README for the design rationale.
|
||||
*/
|
||||
public final class TracingSampleRateResolver {
|
||||
|
||||
/**
|
||||
* @param profile Spring active profile name; {@code null}/blank treated as "anything else"
|
||||
*/
|
||||
public static double defaultRateForProfile(String profile) {
|
||||
if (profile == null) {
|
||||
return 1.0;
|
||||
}
|
||||
return switch (profile.trim().toLowerCase(Locale.ROOT)) {
|
||||
case "prod" -> 0.01;
|
||||
case "staging" -> 0.10;
|
||||
case "dev" -> 1.0;
|
||||
case "local" -> 1.0;
|
||||
default -> 1.0;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A configured override wins over the per-profile default.
|
||||
*
|
||||
* @param configuredRate raw string from {@code APP_TRACING_SAMPLE_RATE}; may be blank
|
||||
* @return effective sample rate in [0.0, 1.0]
|
||||
*/
|
||||
public double resolve(String profile, String configuredRate) {
|
||||
if (configuredRate != null && !configuredRate.isBlank()) {
|
||||
try {
|
||||
double v = Double.parseDouble(configuredRate.trim());
|
||||
if (v >= 0.0 && v <= 1.0) {
|
||||
return v;
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
// fall through to profile default
|
||||
}
|
||||
}
|
||||
return defaultRateForProfile(profile);
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package dev.caskeleton.bootstrap.tracing;
|
||||
|
||||
import java.util.Map;
|
||||
import org.springframework.boot.EnvironmentPostProcessor;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.core.env.ConfigurableEnvironment;
|
||||
import org.springframework.core.env.MapPropertySource;
|
||||
|
||||
/**
|
||||
* Bridges the resolved tracing sample rate into Spring Boot's native {@code
|
||||
* management.tracing.sampling.probability}. Registered via {@code META-INF/spring.factories}. See
|
||||
* README for the design rationale.
|
||||
*/
|
||||
public class TracingSamplingEnvironmentPostProcessor implements EnvironmentPostProcessor {
|
||||
|
||||
private static final String NATIVE_KEY = "management.tracing.sampling.probability";
|
||||
private static final String CA_SAMPLE_RATE_KEY = "ca-skeleton.tracing.sample-rate";
|
||||
|
||||
private final TracingSampleRateResolver resolver = new TracingSampleRateResolver();
|
||||
|
||||
@Override
|
||||
public void postProcessEnvironment(
|
||||
ConfigurableEnvironment environment, SpringApplication application) {
|
||||
// A user-supplied value takes precedence.
|
||||
if (environment.containsProperty(NATIVE_KEY)) {
|
||||
return;
|
||||
}
|
||||
String[] profiles = environment.getActiveProfiles();
|
||||
String profile = (profiles != null && profiles.length > 0) ? profiles[0] : "local";
|
||||
String configured = environment.getProperty(CA_SAMPLE_RATE_KEY);
|
||||
double rate = resolver.resolve(profile, configured);
|
||||
environment
|
||||
.getPropertySources()
|
||||
.addLast(
|
||||
new MapPropertySource(
|
||||
"tracingSamplingBridge", Map.of(NATIVE_KEY, String.valueOf(rate))));
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package dev.caskeleton.bootstrap.tracing;
|
||||
|
||||
import io.micrometer.core.instrument.Gauge;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
|
||||
/**
|
||||
* Registers the {@code tracing.sampling.rate} gauge. Resolves the registry via an {@link
|
||||
* ObjectProvider} so it stays a no-op when no {@code MeterRegistry} bean is present. See README for
|
||||
* the design rationale.
|
||||
*/
|
||||
public final class TracingSamplingRateGauge {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TracingSamplingRateGauge.class);
|
||||
|
||||
public static final String METRIC_NAME = "tracing.sampling.rate";
|
||||
public static final String TAG_PROFILE = "profile";
|
||||
|
||||
private TracingSamplingRateGauge() {}
|
||||
|
||||
public static void register(
|
||||
String activeProfile,
|
||||
double effectiveSampleRate,
|
||||
ObjectProvider<MeterRegistry> meterRegistryProvider) {
|
||||
|
||||
MeterRegistry registry = meterRegistryProvider.getIfAvailable();
|
||||
if (registry == null) {
|
||||
return; // no-op: no Actuator / MeterRegistry on classpath
|
||||
}
|
||||
try {
|
||||
Gauge.builder(METRIC_NAME, () -> effectiveSampleRate)
|
||||
.tag(TAG_PROFILE, activeProfile != null ? activeProfile : "unknown")
|
||||
.description("Effective distributed-tracing sample rate for the active profile")
|
||||
.register(registry);
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("tracing metrics: failed to register gauge {}", METRIC_NAME, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package dev.caskeleton.bootstrap.tracing;
|
||||
|
||||
import dev.caskeleton.bootstrap.runtime.startup.StartupFailures;
|
||||
import java.net.URI;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
/**
|
||||
* Tracing settings bound from {@code ca-skeleton.tracing.*}. A blank {@code sampleRate} means
|
||||
* "defer to the per-profile default". See README for the design rationale.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.tracing")
|
||||
@Validated
|
||||
public record TracingSettings(boolean enabled, String sampleRate, Exporter exporter) {
|
||||
|
||||
/** Nested properties for the OTLP exporter endpoint. */
|
||||
public record Exporter(String otlpEndpoint) {
|
||||
|
||||
public Exporter {
|
||||
if (otlpEndpoint == null) {
|
||||
otlpEndpoint = "";
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isConfigured() {
|
||||
return otlpEndpoint != null && !otlpEndpoint.isBlank();
|
||||
}
|
||||
}
|
||||
|
||||
public TracingSettings {
|
||||
if (sampleRate == null) {
|
||||
sampleRate = "";
|
||||
}
|
||||
if (exporter == null) {
|
||||
exporter = new Exporter("");
|
||||
}
|
||||
// Blank sampleRate = defer to the per-profile default; skip numeric validation.
|
||||
if (!sampleRate.isBlank()) {
|
||||
validateSampleRate(sampleRate);
|
||||
}
|
||||
validateOtlpEndpoint(exporter.otlpEndpoint());
|
||||
}
|
||||
|
||||
/**
|
||||
* Precondition: {@link #sampleRate()} must be non-blank — blank means "defer to the per-profile
|
||||
* default", so callers must check {@code sampleRate().isBlank()} first (throws {@link
|
||||
* IllegalStateException} otherwise).
|
||||
*/
|
||||
public double sampleRateValue() {
|
||||
if (sampleRate.isBlank()) {
|
||||
throw new IllegalStateException(
|
||||
"sampleRateValue() called on a blank sampleRate — blank means "
|
||||
+ "\"defer to TracingSampleRateResolver per-profile default\". "
|
||||
+ "Check sampleRate().isBlank() before calling this method.");
|
||||
}
|
||||
return Double.parseDouble(sampleRate.trim());
|
||||
}
|
||||
|
||||
private static void validateSampleRate(String raw) {
|
||||
try {
|
||||
double v = Double.parseDouble(raw.trim());
|
||||
if (v < 0.0 || v > 1.0) {
|
||||
throw StartupFailures.envValidation(
|
||||
"APP_TRACING_SAMPLE_RATE must be a float in [0.0, 1.0] "
|
||||
+ "(feature-distributed-tracing-contract D6 float_between_0_and_1); "
|
||||
+ "got: "
|
||||
+ raw);
|
||||
}
|
||||
} catch (NumberFormatException ex) {
|
||||
throw StartupFailures.envValidation(
|
||||
"APP_TRACING_SAMPLE_RATE must be a float in [0.0, 1.0] "
|
||||
+ "(feature-distributed-tracing-contract D6 float_between_0_and_1); "
|
||||
+ "got: "
|
||||
+ raw);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateOtlpEndpoint(String endpoint) {
|
||||
if (endpoint == null || endpoint.isBlank()) {
|
||||
return; // empty = exporter off
|
||||
}
|
||||
try {
|
||||
URI uri = URI.create(endpoint.trim());
|
||||
String scheme = uri.getScheme();
|
||||
if (scheme == null || scheme.isBlank()) {
|
||||
throw StartupFailures.envValidation(
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT must be a valid URL with a scheme "
|
||||
+ "(feature-distributed-tracing-contract D1 url_or_empty); "
|
||||
+ "got: "
|
||||
+ endpoint);
|
||||
}
|
||||
} catch (IllegalArgumentException ex) {
|
||||
throw StartupFailures.envValidation(
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT must be a valid URL or empty "
|
||||
+ "(feature-distributed-tracing-contract D1 url_or_empty); "
|
||||
+ "got: "
|
||||
+ endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
org.springframework.boot.EnvironmentPostProcessor=\
|
||||
dev.caskeleton.bootstrap.tracing.TracingSamplingEnvironmentPostProcessor
|
||||
|
||||
org.springframework.boot.SpringBootExceptionReporter=\
|
||||
dev.caskeleton.bootstrap.runtime.startup.StartupFailureExceptionReporter
|
||||
@@ -0,0 +1,529 @@
|
||||
# =============================================================================
|
||||
# Mirrors src/.env into Spring's Environment. Comments document allowed values;
|
||||
# input validation lives in the *Settings records under each module.
|
||||
# =============================================================================
|
||||
|
||||
spring:
|
||||
application:
|
||||
# free-form string (also exposed as the JSON log "app" field)
|
||||
name: ${APP_NAME}
|
||||
mvc:
|
||||
problemdetails:
|
||||
# Architectural decision D1/D5: RFC 7807 ProblemDetail is rejected in favour
|
||||
# of the custom {success,data,error,meta} envelope. Pin OFF explicitly so a
|
||||
# future Spring Boot default flip cannot silently re-enable it.
|
||||
enabled: false
|
||||
web:
|
||||
error:
|
||||
# always | never | on_param
|
||||
include-stacktrace: ${APP_SERVER_ERROR_INCLUDE_STACKTRACE}
|
||||
# always | never | on_param
|
||||
include-message: ${APP_SERVER_ERROR_INCLUDE_MESSAGE}
|
||||
profiles:
|
||||
# common values: local | dev | stage | prod (free-form)
|
||||
active: ${SPRING_PROFILES_ACTIVE:local}
|
||||
datasource:
|
||||
# jdbc URL: jdbc:postgresql://host:5432/db
|
||||
url: ${APP_DATASOURCE_URL}
|
||||
username: ${APP_DATASOURCE_USERNAME}
|
||||
password: ${APP_DATASOURCE_PASSWORD}
|
||||
driver-class-name: ${APP_DATASOURCE_DRIVER}
|
||||
hikari:
|
||||
# D1 (feature-database-connection-pool-contract): small-pool axiom + PostgreSQL formula
|
||||
# starting point (maximumPoolSize = cores * 2 + effective_spindle_count, adjust via load
|
||||
# test). Fixed-size pool recommended (minimumIdle = maximumPoolSize). Must satisfy
|
||||
# application-port D12 REQUIRES_NEW lower bound:
|
||||
# maxPoolSize >= concurrent_threads * (1 + max_inNew_depth) + 1
|
||||
# Value owner: feature-env-driven-runtime-configuration (APP_DATASOURCE_POOL_MAX_SIZE).
|
||||
# integer >= 1
|
||||
maximum-pool-size: ${APP_DATASOURCE_POOL_MAX_SIZE}
|
||||
# D1: fixed-size pool recommended (minimumIdle = maximumPoolSize per HikariCP #HIKARI-CFG-C8).
|
||||
# Current registry value min-idle=2 is a MIN_IDLE_POLICY_DRIFT vs the fixed-size
|
||||
# recommendation; value alignment is delegated to feature-env-driven-runtime-configuration.
|
||||
# integer >= 0
|
||||
minimum-idle: ${APP_DATASOURCE_POOL_MIN_IDLE}
|
||||
# D2 (HIKARI-CFG-C1): fail-fast pin — reject pool-starved threads quickly rather than
|
||||
# holding them for 30 s (HikariCP default). Must be >= 250 ms (enforced at startup by
|
||||
# HikariPoolConstraintValidator). Typical synchronous HTTP path value: a few seconds.
|
||||
# CONNECTION_TIMEOUT_FORMAT_DRIFT: env-keys.yaml default is "5s" (Duration string) while
|
||||
# src/.env carries 30000 (ms). HikariPoolConstraintValidator reads this defensively as a
|
||||
# String to avoid ConversionFailedException on the drift value. Alignment is delegated to
|
||||
# feature-env-driven-runtime-configuration (APP_DATASOURCE_CONNECTION_TIMEOUT).
|
||||
# milliseconds (or Spring Duration string when env-keys default overrides)
|
||||
connection-timeout: ${APP_DATASOURCE_CONNECTION_TIMEOUT}
|
||||
# milliseconds
|
||||
idle-timeout: ${APP_DATASOURCE_POOL_IDLE_TIMEOUT}
|
||||
# D3 (HIKARI-CFG-C2): must be several seconds shorter than the DB/infrastructure idle
|
||||
# timeout (DB wait_timeout, PgBouncer idle_transaction_timeout, firewall NAT timeout).
|
||||
# Current default (30 min) is a placeholder until the actual DB wait_timeout is confirmed
|
||||
# (see §Claims "DB wait_timeout 미확인" — needs-confirmation). Recommended: DB_idle_limit
|
||||
# minus at least 60 s as a conservative margin. Value owner: feature-env-driven.
|
||||
# milliseconds
|
||||
max-lifetime: ${APP_DATASOURCE_POOL_MAX_LIFETIME}
|
||||
# D4 (HIKARI-CFG-C4): greenfield — ping idle connections to prevent NAT/firewall/DB
|
||||
# idle-kill from silently dropping them. Constraint: keepalive-time < max-lifetime
|
||||
# (enforced by HikariPoolConstraintValidator). Provisional literal 120 000 ms (2 min);
|
||||
# adjust once actual DB/firewall idle timeout is confirmed (§Claims).
|
||||
# UNSUPPORTED_IMPL_DECISION: literal value is a provisional policy default.
|
||||
# New env key APP_DATASOURCE_KEEPALIVE_TIME registration delegated to
|
||||
# feature-env-driven-runtime-configuration.
|
||||
keepalive-time: 120000
|
||||
# D5 (HIKARI-CFG-C5): greenfield — enable connection leak early warning. Value must be
|
||||
# >= 2000 ms to activate (enforced by HikariPoolConstraintValidator; 0 = disabled).
|
||||
# Provisional literal 30 000 ms (30 s) — chosen well above the estimated longest normal
|
||||
# transaction (~5 s) to avoid false positives on legitimate slow operations.
|
||||
# UNSUPPORTED_IMPL_DECISION: literal value is a provisional policy default.
|
||||
# New env key APP_DATASOURCE_LEAK_DETECTION_THRESHOLD delegated to
|
||||
# feature-env-driven-runtime-configuration.
|
||||
leak-detection-threshold: 30000
|
||||
# D7 (HIKARI-CFG-C6): greenfield — must be < connection-timeout (HIKARI-CFG-C6),
|
||||
# enforced by HikariPoolConstraintValidator. Resolves VALIDATION_TIMEOUT_CONFLICT:
|
||||
# HikariCP default 5000 ms equals connection-timeout 5 s / 5000 ms → constraint
|
||||
# violation. Provisional literal 3000 ms satisfies the constraint for connection-timeout
|
||||
# values >= 3001 ms.
|
||||
# UNSUPPORTED_IMPL_DECISION: literal value is a provisional policy default.
|
||||
# New env key APP_DATASOURCE_VALIDATION_TIMEOUT delegated to
|
||||
# feature-env-driven-runtime-configuration.
|
||||
validation-timeout: 3000
|
||||
# D6 (HIKARI-CFG-C7): greenfield — keep positive (default = 1 ms) to fail fast when DB
|
||||
# is unavailable at startup. Aligns with runtime-health startup validation and project-note
|
||||
# §9 "잘못된 env 값 startup fail-fast" policy. Negative value disables fail-fast (allowed
|
||||
# only in orchestration environments where DB may start after the app — coordinate with
|
||||
# runtime-health-lifecycle branch).
|
||||
# UNSUPPORTED_IMPL_DECISION: literal value is a provisional policy default.
|
||||
# New env key APP_DATASOURCE_INIT_FAIL_TIMEOUT delegated to
|
||||
# feature-env-driven-runtime-configuration.
|
||||
initialization-fail-timeout: 1
|
||||
# ---------------------------------------------------------------------------
|
||||
# D8 (feature-database-connection-pool-contract) — SLOW QUERY DETECTION
|
||||
# POLICY ONLY — no library dependency added here. Implementation is DEFERRED
|
||||
# pending local verification (TODO #3: confirm ParameterTransformer masking
|
||||
# applies to slow-query listener output).
|
||||
#
|
||||
# Baseline (app layer, param-safe):
|
||||
# datasource-proxy SlowQueryListener + ParameterTransformer ([REDACTED] masking).
|
||||
# Requires the datasource-proxy / spring-boot-data-source-decorator dependency
|
||||
# and a @Bean ParameterTransformer; deferred until masking behavior on slow-query
|
||||
# output is locally verified.
|
||||
#
|
||||
# Production augment (DBA-owned):
|
||||
# DB-side log_min_duration_statement — parameters included in extended-protocol
|
||||
# output (PostgreSQL official security warning). DBA controls and redacts.
|
||||
#
|
||||
# Dev only (PROD-FORBIDDEN):
|
||||
# Hibernate SQL_SLOW (LOG_QUERIES_SLOWER_THAN_MS) logs materialized SQL with
|
||||
# substituted parameters — violates the "SQL/param 로그 금지" hard rule from
|
||||
# feature-persistence-failure-baseline. NEVER enable in prod.
|
||||
#
|
||||
# Rejected:
|
||||
# P6Spy — no built-in masking API; effective SQL exposes parameters by default
|
||||
# with no safe override. Rejected per D8 (#C3/#C4).
|
||||
# ---------------------------------------------------------------------------
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flyway forward-only migration (feature-migration-startup-contract D1/D2/D4).
|
||||
# Flyway runs during context refresh (Spring Boot autoconfig + the
|
||||
# migrationStartupRunner FlywayMigrationStrategy), BEFORE the app reports
|
||||
# readiness — so migration is inherently readiness-gated (D5): a failed or
|
||||
# in-progress migration can never serve traffic.
|
||||
#
|
||||
# The three options below are PINNED, not env-driven: they are forbidden under
|
||||
# prod (D2/D4) and a static pin means a future Flyway/Spring Boot default flip
|
||||
# cannot silently re-enable them (same reasoning as spring.mvc.problemdetails.
|
||||
# enabled above). FlywayProdSafetyValidator fails the boot (exit 71) if any
|
||||
# per-environment override re-enables them under the prod profile.
|
||||
# ---------------------------------------------------------------------------
|
||||
flyway:
|
||||
# false: never auto-baseline an existing schema — keep the missing-migration
|
||||
# safety net (FLYWAY-C6). Enabling under prod is forbidden (D4).
|
||||
baseline-on-migrate: false
|
||||
# false: reject out-of-order migrations — preserve cross-developer ordering
|
||||
# consistency (FLYWAY-C5). Enabling under prod is forbidden (D4).
|
||||
out-of-order: false
|
||||
# true: keep Flyway `clean` (drops the whole schema) disarmed. Disabling this
|
||||
# under prod is forbidden — clean is destructive.
|
||||
clean-disabled: true
|
||||
jpa:
|
||||
hibernate:
|
||||
# none | validate | update | create | create-drop
|
||||
ddl-auto: ${APP_DATASOURCE_DDL_AUTO}
|
||||
# true | false
|
||||
show-sql: ${APP_DATASOURCE_SHOW_SQL}
|
||||
# true | false (don't enable in prod)
|
||||
open-in-view: ${APP_DATASOURCE_OPEN_IN_VIEW}
|
||||
properties:
|
||||
hibernate:
|
||||
# true | false (paired with show-sql)
|
||||
format_sql: ${APP_DATASOURCE_FORMAT_SQL}
|
||||
security:
|
||||
oauth2:
|
||||
resourceserver:
|
||||
jwt:
|
||||
# OIDC issuer (e.g., Keycloak realm URL). Spring Boot resolves JWKS from
|
||||
# /.well-known/openid-configuration at this URI.
|
||||
issuer-uri: ${APP_SECURITY_JWT_ISSUER}
|
||||
# validated against the JWT `aud` claim; blank disables the check
|
||||
audiences: ${APP_SECURITY_JWT_AUDIENCE}
|
||||
main:
|
||||
# off | console | log
|
||||
banner-mode: ${SPRING_BANNER_MODE}
|
||||
# true | false
|
||||
lazy-initialization: ${SPRING_MAIN_LAZY_INITIALIZATION}
|
||||
# true | false
|
||||
log-startup-info: ${SPRING_MAIN_LOG_STARTUP_INFO}
|
||||
threads:
|
||||
virtual:
|
||||
# true | false (Java 21 virtual threads for Tomcat request handlers)
|
||||
enabled: ${SPRING_THREADS_VIRTUAL_ENABLED}
|
||||
servlet:
|
||||
multipart:
|
||||
# feature-api-contract-baseline D8: bound request body size so an oversized
|
||||
# upload classifies as 413 PAYLOAD_TOO_LARGE inside the envelope (via
|
||||
# GlobalExceptionHandler#handleMaxUploadSizeExceededException), never a raw 500.
|
||||
# Multipart-specific upload limits (UPLOAD_SIZE_EXCEEDED) are refined by
|
||||
# feature-file-resource-handling-contract.
|
||||
max-file-size: ${SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE:10MB}
|
||||
max-request-size: ${SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE:10MB}
|
||||
# Jackson deserialization policy (feature-boundary-validation-mapping-contract B1).
|
||||
# Every request DTO crosses this boundary; the switches make malformed payloads
|
||||
# fail at the edge rather than silently coercing or dropping fields.
|
||||
jackson:
|
||||
deserialization:
|
||||
# true | false (Jackson 2.13+ default; reject unknown JSON keys)
|
||||
fail-on-unknown-properties: ${SPRING_JACKSON_DESER_FAIL_ON_UNKNOWN_PROPERTIES}
|
||||
# true | false (block JSON null → primitive 0/false coercion)
|
||||
fail-on-null-for-primitives: ${SPRING_JACKSON_DESER_FAIL_ON_NULL_FOR_PRIMITIVES}
|
||||
# true | false (surface JSON containing fields the target @JsonIgnore'd)
|
||||
fail-on-ignored-properties: ${SPRING_JACKSON_DESER_FAIL_ON_IGNORED_PROPERTIES}
|
||||
# Serialization output policy (feature-schema-serialization-contract D2/D3).
|
||||
# These mirror current defaults but are pinned so a future Spring Boot default
|
||||
# flip cannot silently break the datetime / decimal wire contract — the same
|
||||
# reasoning as spring.mvc.problemdetails.enabled above.
|
||||
datatype:
|
||||
enum:
|
||||
# true | false (false = Jackson default; unknown enum -> throw, not null)
|
||||
read-unknown-enum-values-as-null: ${SPRING_JACKSON_DESER_READ_UNKNOWN_ENUM_VALUES_AS_NULL}
|
||||
datetime:
|
||||
# true | false (false: java.time -> ISO-8601 string via JavaTimeModule, D2)
|
||||
write-dates-as-timestamps: ${SPRING_JACKSON_SER_WRITE_DATES_AS_TIMESTAMPS}
|
||||
lifecycle:
|
||||
# duration: 30s | 1m | 500ms
|
||||
timeout-per-shutdown-phase: ${APP_SERVER_SHUTDOWN_TIMEOUT}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Actuator / management — MERGED ownership at integration:
|
||||
# - feature-management-actuator-security-contract owns server.port, the exposure
|
||||
# allowlist/exclude, health show-details, shutdown-disabled, and info.
|
||||
# - feature-runtime-health-lifecycle-contract owns the health probe SHAPE
|
||||
# (probes.enabled + the liveness/readiness/startup groups + dependency taxonomy).
|
||||
# ---------------------------------------------------------------------------
|
||||
management:
|
||||
# feature-management-actuator-security-contract D1: separate management port so actuator
|
||||
# endpoints are not exposed on the same socket as the app API. Default: 9001.
|
||||
server:
|
||||
port: ${MANAGEMENT_SERVER_PORT:9001}
|
||||
endpoints:
|
||||
web:
|
||||
# D2: production allowlist — only safe, scrape-friendly endpoints are exposed.
|
||||
exposure:
|
||||
include: health,prometheus,info,loggers
|
||||
# D2/D4/D5: explicitly excluded dangerous endpoints (env leaks secrets,
|
||||
# heapdump/threaddump = memory forensics, shutdown = remote kill, configprops = secret leak).
|
||||
exclude: env,configprops,heapdump,threaddump,shutdown
|
||||
endpoint:
|
||||
health:
|
||||
# D8: never expose health details to unauthenticated callers.
|
||||
show-details: when-authorized
|
||||
# feature-runtime-health-lifecycle-contract: expose the Kubernetes-ready
|
||||
# liveness/readiness/startup probe paths.
|
||||
probes:
|
||||
enabled: true
|
||||
group:
|
||||
# Liveness: JVM can continue (OOM → liveness DOWN → pod restart).
|
||||
# Only livenessState is included; dependency health MUST NOT be here —
|
||||
# a DOWN DB must never trigger a pod restart when the JVM is healthy.
|
||||
liveness:
|
||||
include: livenessState
|
||||
# Readiness: ready to serve traffic AND all REQUIRED dependencies up.
|
||||
# Required: db (primary DB — auto-contributed by Spring Boot DataSource).
|
||||
# Optional: cache / broker / notification adapters are NOT in this group
|
||||
# (they are conditional or optional per the dependency taxonomy).
|
||||
readiness:
|
||||
include: readinessState,db
|
||||
# Startup: startup/migration validation complete.
|
||||
# readinessState acts as the startup completion gate — it flips UP only
|
||||
# after the context is fully initialized (Flyway migration included).
|
||||
startup:
|
||||
include: readinessState
|
||||
shutdown:
|
||||
# D4: shutdown endpoint disabled globally — even if somehow exposed, it cannot be invoked.
|
||||
access: none
|
||||
info:
|
||||
build:
|
||||
# Build-info only (no env leak).
|
||||
enabled: true
|
||||
|
||||
server:
|
||||
# integer 1-65535
|
||||
port: ${APP_SERVER_PORT}
|
||||
# graceful | immediate
|
||||
shutdown: ${APP_SERVER_SHUTDOWN}
|
||||
# none | native | framework (trust X-Forwarded-* behind LB/proxy)
|
||||
forward-headers-strategy: ${APP_SERVER_FORWARD_HEADERS_STRATEGY}
|
||||
tomcat:
|
||||
threads:
|
||||
# integer >= 1
|
||||
max: ${APP_SERVER_TOMCAT_MAX_THREADS}
|
||||
# integer >= 0
|
||||
min-spare: ${APP_SERVER_TOMCAT_MIN_SPARE_THREADS}
|
||||
# integer >= 0 (OS-level backlog queue depth)
|
||||
accept-count: ${APP_SERVER_TOMCAT_ACCEPT_COUNT}
|
||||
# integer >= 1 (hard cap on simultaneously open connections)
|
||||
max-connections: ${APP_SERVER_TOMCAT_MAX_CONNECTIONS}
|
||||
# duration: 20s | 1m
|
||||
connection-timeout: ${APP_SERVER_TOMCAT_CONNECTION_TIMEOUT}
|
||||
compression:
|
||||
# true | false
|
||||
enabled: ${APP_SERVER_COMPRESSION_ENABLED}
|
||||
# bytes or sized: 1024 | 1KB | 2KB
|
||||
min-response-size: ${APP_SERVER_COMPRESSION_MIN_RESPONSE_SIZE}
|
||||
logging:
|
||||
level:
|
||||
# TRACE | DEBUG | INFO | WARN | ERROR | OFF
|
||||
root: ${APP_LOG_LEVEL_ROOT}
|
||||
dev.caskeleton: ${APP_LOG_LEVEL_APP}
|
||||
org.springframework: ${APP_LOG_LEVEL_SPRING}
|
||||
org.springframework.web: ${APP_LOG_LEVEL_WEB}
|
||||
# DEBUG here prints SQL once JPA/jdbc is wired in
|
||||
org.hibernate.SQL: ${APP_LOG_LEVEL_SQL}
|
||||
|
||||
# Module-scoped knobs. Each block is bound into a *Settings @ConfigurationProperties
|
||||
# record in the corresponding module, which is where allowed-value validation lives.
|
||||
ca-skeleton:
|
||||
bootstrap:
|
||||
# required, non-blank — startup fails if blank (see BootstrapSettings)
|
||||
app-name: ${APP_NAME}
|
||||
runtime:
|
||||
# feature-env-driven-runtime-configuration D8: enforced at startup by
|
||||
# StartupSafetyValidator. The two prod-unsafe toggles fail startup if true under
|
||||
# the prod profile; multi-instance fails startup if its coordination beans are absent.
|
||||
# true | false (internal error detail in responses — forbidden under prod)
|
||||
error-detail-exposure-enabled: ${APP_ERROR_DETAIL_EXPOSURE_ENABLED:false}
|
||||
# true | false (request/response body capture in logs — forbidden under prod)
|
||||
log-body-capture-enabled: ${APP_LOG_BODY_CAPTURE_ENABLED:false}
|
||||
# true | false (requires the 5 instance-coordination beans when true)
|
||||
multi-instance-enabled: ${APP_MULTI_INSTANCE_ENABLED:false}
|
||||
# true | false (whether to run Flyway migrations automatically at startup)
|
||||
migration-on-startup: ${APP_MIGRATION_ON_STARTUP:true}
|
||||
# feature-distributed-lock-contract D5 — distributed lock acquisition contract (try-lock +
|
||||
# finite wait + lease TTL). Bound to LockSettings (adapter-persistence). Plain values, NOT
|
||||
# APP_* env keys — new env-key registration is feature-env-driven-runtime-configuration's
|
||||
# domain (out of scope for this branch); code defaults in LockSettings mirror these.
|
||||
lock:
|
||||
wait-time: 3s
|
||||
lease-ttl: 30s
|
||||
presentation:
|
||||
# feature-api-contract-baseline D2: API version prefix. Default is the URI
|
||||
# prefix "/v1" (major-version path, AIP-185); override via env, or set "" for
|
||||
# no prefix. The supplemental "X-Api-Version" header never overrides the path.
|
||||
api-base-path: ${PRESENTATION_API_BASE_PATH:/v1}
|
||||
rate-limit:
|
||||
# feature-rate-limit-idempotency-contract D1/§G. enabled is env-driven
|
||||
# (restart-only); limit/window are the fixed-window mechanism's literal tuning
|
||||
# parameters (no env key — UNSUPPORTED_IMPL per-key counter, single-node D5).
|
||||
enabled: ${APP_RATE_LIMIT_ENABLED}
|
||||
limit: 100
|
||||
window: 1s
|
||||
# RateLimiter strategy: fixed-window (default) | (extend: sliding-window | token-bucket)
|
||||
algorithm: fixed-window
|
||||
# Client IP source for unauthenticated rate-limit keys:
|
||||
# remote-addr-only (safe default) | forwarded-headers-trusted (only behind trusted ingress/LB)
|
||||
client-ip-mode: ${APP_RATE_LIMIT_CLIENT_IP_MODE:remote-addr-only}
|
||||
idempotency:
|
||||
# feature-rate-limit-idempotency-contract D6/§E. ttl is env-driven (<=72h,
|
||||
# validated in IdempotencyProperties); reaper-interval is literal operational tuning.
|
||||
ttl: ${APP_IDEMPOTENCY_TTL}
|
||||
reaper-interval: 10m
|
||||
async:
|
||||
# feature-background-job-async-contract D7 — @Async ThreadPoolTaskExecutor pool sizing.
|
||||
# Registry SSOT: docs/registries/env-keys.yaml (APP_ASYNC_EXECUTOR_* rows 1337-1377).
|
||||
# Bound into AsyncExecutorSettings @ConfigurationProperties(prefix = "ca-skeleton.async.executor").
|
||||
# Bounded queue is mandatory (D7) — an unbounded queue makes max-size unreachable.
|
||||
executor:
|
||||
# int >= 1 (positive_int). Always-alive worker count.
|
||||
core-size: ${APP_ASYNC_EXECUTOR_CORE_SIZE:10}
|
||||
# int >= core-size (positive_int_ge_core). Hard ceiling on workers.
|
||||
max-size: ${APP_ASYNC_EXECUTOR_MAX_SIZE:50}
|
||||
# int in 1..<Integer.MAX_VALUE (positive_int_bounded). Bounded backlog depth.
|
||||
queue-capacity: ${APP_ASYNC_EXECUTOR_QUEUE_CAPACITY:200}
|
||||
outbox:
|
||||
# feature-domain-event-outbox-contract I11 — all six values are literal defaults;
|
||||
# NO env placeholders (spec: 신규 env key 없음). Bound into OutboxProperties.
|
||||
# true | false — enable/disable the relay scheduler (OutboxRelayScheduler)
|
||||
relay-enabled: true
|
||||
# ISO-8601 duration — how often the relay polls for pending rows
|
||||
poll-interval: PT5S
|
||||
# integer >= 1 — maximum rows claimed per relay cycle
|
||||
batch-size: 20
|
||||
# ISO-8601 duration — IN_FLIGHT orphan visibility window (I6: next_attempt_at reuse)
|
||||
in-flight-timeout: PT5M
|
||||
# ISO-8601 duration — read also by adapter-persistence OutboxReaper @Scheduled
|
||||
reaper-interval: PT10M
|
||||
# ISO-8601 duration — PUBLISHED row retention before reaper deletes them (I3)
|
||||
# read also by adapter-persistence OutboxReaper via ${ca-skeleton.outbox.published-retention:P7D}
|
||||
published-retention: P7D
|
||||
security:
|
||||
# REQUIRED; startup fails if blank
|
||||
issuer-uri: ${APP_SECURITY_JWT_ISSUER}
|
||||
# blank to skip audience check
|
||||
audience: ${APP_SECURITY_JWT_AUDIENCE}
|
||||
# comma-separated list (Spring binds to List<String>)
|
||||
public-paths: ${SECURITY_PUBLIC_PATHS}
|
||||
authz:
|
||||
# feature-authentication-authorization-contract D2/D3/D8: app-side role→permission
|
||||
# mapping (the default source; IdP-issued permission claims are an out-of-scope
|
||||
# alternative). Keys are RAW IdP role names (no ROLE_ prefix — that prefix only
|
||||
# exists on Spring authorities, not on the principal's raw role set), looked up
|
||||
# case-insensitively. Values are explicitly enumerated `resource:action` permissions
|
||||
# (no wildcards — least-privilege, OWASP-AUTHZ-C4). The values below are the
|
||||
# sample-portfolio demonstration (§5): `user` may read/write, only `admin` may close.
|
||||
role-permissions:
|
||||
user: worklog:read,worklog:write
|
||||
admin: worklog:read,worklog:write,worklog:close
|
||||
cors:
|
||||
# true | false
|
||||
enabled: ${APP_SECURITY_CORS_ENABLED}
|
||||
# comma-separated
|
||||
allowed-origins: ${APP_SECURITY_CORS_ORIGINS}
|
||||
# comma-separated; empty -> defaults
|
||||
allowed-methods: ${APP_SECURITY_CORS_ALLOWED_METHODS}
|
||||
# comma-separated; "*" allows any
|
||||
allowed-headers: ${APP_SECURITY_CORS_ALLOWED_HEADERS}
|
||||
# true | false
|
||||
allow-credentials: ${APP_SECURITY_CORS_ALLOW_CREDENTIALS}
|
||||
# seconds
|
||||
max-age-seconds: ${APP_SECURITY_CORS_MAX_AGE}
|
||||
logging:
|
||||
file:
|
||||
# true | false (wraps console + adds rolling JSON file appender)
|
||||
enabled: ${APP_LOG_FILE_ENABLED}
|
||||
# relative (to src/) or absolute
|
||||
path: ${APP_LOG_FILE_PATH}
|
||||
# size with unit: KB | MB | GB
|
||||
max-size: ${APP_LOG_FILE_MAX_SIZE}
|
||||
# integer >= 1
|
||||
max-history: ${APP_LOG_FILE_MAX_HISTORY}
|
||||
# size with unit or 0
|
||||
total-size-cap: ${APP_LOG_FILE_TOTAL_SIZE_CAP}
|
||||
async:
|
||||
# true | false (AsyncAppender wrapper for non-blocking I/O)
|
||||
enabled: ${APP_LOG_ASYNC_ENABLED}
|
||||
# integer >= 1
|
||||
queue-size: ${APP_LOG_ASYNC_QUEUE_SIZE}
|
||||
# integer >= 0 (0 = never drop)
|
||||
discarding-threshold: ${APP_LOG_ASYNC_DISCARDING_THRESHOLD}
|
||||
json:
|
||||
# IANA timezone (UTC | Asia/Seoul | ...) or "default"
|
||||
timezone: ${APP_LOG_JSON_TIMEZONE}
|
||||
# ISO 8601 pattern
|
||||
timestamp-pattern: ${APP_LOG_JSON_TIMESTAMP_PATTERN}
|
||||
# true | false (file/method/line — expensive)
|
||||
include-caller-data: ${APP_LOG_JSON_INCLUDE_CALLER_DATA}
|
||||
# integer; 0 = full name, positive = abbreviated
|
||||
logger-name-length: ${APP_LOG_JSON_LOGGER_NAME_LENGTH}
|
||||
# float in [0.0, 1.0] — keep-probability for <=INFO logs (prod 0.1 = 10% sampling;
|
||||
# WARN/ERROR always 100%). Consumed by SamplingTurboFilter via logback <springProperty>.
|
||||
sampling-rate: ${APP_LOG_SAMPLING_RATE}
|
||||
privacy:
|
||||
# secret-tier HMAC salt for user_principal pseudonymization (DRIFT-6; algorithm SSOT
|
||||
# feature-data-retention-privacy-contract). Blank -> PrivacySettings warns + uses a dev
|
||||
# sentinel; prod MUST supply a real secret-manager value.
|
||||
pseudonymization-salt: ${APP_PRIVACY_PSEUDONYMIZATION_SALT:}
|
||||
# feature-distributed-tracing-contract D1/D4/D6 — tracing seam settings.
|
||||
# The OTel/Micrometer tracer runtime is active in this repo (Plan A — seam activated):
|
||||
# micrometer-tracing-bridge-otel + opentelemetry-exporter-otlp are wired in
|
||||
# app-bootstrap/build.gradle. The exporter remains off while OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
# is blank (url_or_empty default). A fork can supply the endpoint via env or override
|
||||
# SpanErrorRecorder with its own bean.
|
||||
# D4 disabled-fallback: even when enabled=false, RequestLoggingFilter always
|
||||
# generates a W3C traceparent so meta.traceId and log trace_id are never null.
|
||||
# D6 per-profile defaults: prod=0.01 / staging=0.10 / dev·local=1.0.
|
||||
# APP_TRACING_SAMPLE_RATE overrides the per-profile default when set.
|
||||
tracing:
|
||||
# true | false (boolean_strict). Tracing seam on/off.
|
||||
enabled: ${APP_TRACING_ENABLED:true}
|
||||
# float in [0.0, 1.0]. Per-profile override (D6 float_between_0_and_1 validation at startup).
|
||||
# blank = per-profile default via TracingSampleRateResolver (D-1 ISSUE-1 fix).
|
||||
sample-rate: ${APP_TRACING_SAMPLE_RATE:}
|
||||
exporter:
|
||||
# url_or_empty: blank = exporter off (D1 SEAM default); non-blank must be a valid URL.
|
||||
otlp-endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:}
|
||||
|
||||
# feature-integration-adapter-templates — optional integration adapter toggles.
|
||||
# The `app.<domain>.<adapter>.enabled` flags feed Spring @ConditionalOnProperty (Layer 1):
|
||||
# disabled (default): messaging binds a fail-fast Disabled* sentinel bean; cache/notification
|
||||
# contribute nothing and fail fast in their router (CacheStoreRouter / RoutingNotifier) on
|
||||
# unbound access. Enabled registers the real adapter
|
||||
# (which needs its project-supplied integration client bean). Domain namespace, NOT a
|
||||
# generic `app.adapter.*` prefix (branch-note §Audit A1). Env keys are the registry SSOT.
|
||||
app:
|
||||
cache:
|
||||
redis:
|
||||
# true | false (boolean_strict). Redis cache adapter on/off.
|
||||
enabled: ${APP_CACHE_REDIS_ENABLED}
|
||||
# Logical-cache-name → backendId routing (CacheStoreRouter). No keys by default —
|
||||
# forks add e.g. `bindings: { worklog: redis }` or env APP_CACHE_BINDINGS_WORKLOG=redis.
|
||||
# A binding to a backend that is not enabled fails startup (Layer 3 moved to router).
|
||||
messaging:
|
||||
# Active message broker id (e.g. kafka). Blank = messaging disabled (fail-fast on use).
|
||||
# Selects the single MessageBroker; adding a broker is new files only (MessagingConfig).
|
||||
broker: ${APP_MESSAGING_BROKER}
|
||||
kafka:
|
||||
# CSV of host:port; required (non-empty) only when broker=kafka. Bound +
|
||||
# validated by KafkaAdapterSettings (format) + KafkaAdapterConfig (required-when-active).
|
||||
brokers: ${APP_MESSAGING_KAFKA_BROKERS:}
|
||||
notification:
|
||||
# Active provider id per kind; blank = that kind disabled (fail-fast on use).
|
||||
# Add a provider = new files only (NotificationConfig); select it here.
|
||||
slack:
|
||||
provider: ${APP_NOTIFICATION_SLACK_PROVIDER}
|
||||
email:
|
||||
provider: ${APP_NOTIFICATION_EMAIL_PROVIDER}
|
||||
# ---------------------------------------------------------------------------
|
||||
# feature-outbound-http-client-baseline D5/D3/D7
|
||||
# Registry SSOT: docs/registries/env-keys.yaml (APP_OUTBOUND_HTTP_* rows 489–570)
|
||||
# Bound into OutboundHttpSettings @ConfigurationProperties(prefix = "app.outbound.http").
|
||||
# ---------------------------------------------------------------------------
|
||||
outbound:
|
||||
http:
|
||||
# duration (e.g. 2s). REQUIRED — non-zero (spring_duration_shorthand_non_zero).
|
||||
connect-timeout: ${APP_OUTBOUND_HTTP_CONNECT_TIMEOUT}
|
||||
# duration (e.g. 5s). REQUIRED — non-zero.
|
||||
read-timeout: ${APP_OUTBOUND_HTTP_READ_TIMEOUT}
|
||||
# duration (e.g. 10s). REQUIRED — non-zero (deadline budget for the whole call incl. retries).
|
||||
global-call-timeout: ${APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT}
|
||||
# true | false (boolean_strict). Resilience4j retry — default disabled (D3).
|
||||
retry-enabled: ${APP_OUTBOUND_HTTP_RETRY_ENABLED:false}
|
||||
# retry 튜닝 (retry-enabled=true 일 때 적용). 기본값 = 기존 하드코딩 동작 보존.
|
||||
retry:
|
||||
# int >= 1 (positive_int). 총 시도 횟수(최초 시도 포함).
|
||||
max-attempts: ${APP_OUTBOUND_HTTP_RETRY_MAX_ATTEMPTS:3}
|
||||
# duration (spring_duration_shorthand_non_zero). exponential backoff 시작 간격.
|
||||
initial-backoff: ${APP_OUTBOUND_HTTP_RETRY_INITIAL_BACKOFF:100ms}
|
||||
# double >= 1.0 (double_ge_1). exponential backoff 배수.
|
||||
backoff-multiplier: ${APP_OUTBOUND_HTTP_RETRY_BACKOFF_MULTIPLIER:2.0}
|
||||
# true | false (boolean_strict). Resilience4j circuit breaker — default disabled.
|
||||
circuit-breaker-enabled: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED:false}
|
||||
# circuit-breaker 튜닝 (circuit-breaker-enabled=true 일 때 적용). 기본값 = Resilience4j ofDefaults().
|
||||
circuit-breaker:
|
||||
# float in (0, 100] (float_in_0_exclusive_to_100). open 전환 실패율 임계치(%).
|
||||
failure-rate-threshold: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD:50}
|
||||
# int >= 1 (positive_int). COUNT_BASED sliding window 크기.
|
||||
sliding-window-size: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_SLIDING_WINDOW_SIZE:100}
|
||||
# int >= 1 (positive_int). 실패율 계산을 시작하는 최소 호출 수.
|
||||
minimum-number-of-calls: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_MINIMUM_NUMBER_OF_CALLS:100}
|
||||
# duration (spring_duration_shorthand_non_zero). open 상태 유지 시간.
|
||||
wait-duration-in-open-state: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_WAIT_DURATION_IN_OPEN_STATE:60s}
|
||||
# int >= 1 (positive_int). half-open 상태에서 허용하는 시험 호출 수.
|
||||
permitted-calls-in-half-open: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_PERMITTED_CALLS_IN_HALF_OPEN:10}
|
||||
# data size (e.g. 10MB). Streaming threshold — buffered reads above this fail (D7).
|
||||
response-size-limit: ${APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT:10MB}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
|
||||
|
||||
<!-- Early logging initializes before dotenv-backed application.yml placeholders are reliable. -->
|
||||
<springProperty scope="context" name="APP_NAME"
|
||||
source="APP_NAME" defaultValue="ca-skeleton"/>
|
||||
<springProperty scope="context" name="APP_PROFILE"
|
||||
source="SPRING_PROFILES_ACTIVE" defaultValue="local"/>
|
||||
|
||||
<springProperty scope="context" name="FILE_ENABLED"
|
||||
source="APP_LOG_FILE_ENABLED" defaultValue="false"/>
|
||||
<springProperty scope="context" name="FILE_PATH"
|
||||
source="APP_LOG_FILE_PATH" defaultValue="logs/ca-skeleton.json"/>
|
||||
<springProperty scope="context" name="FILE_MAX_SIZE"
|
||||
source="APP_LOG_FILE_MAX_SIZE" defaultValue="100MB"/>
|
||||
<springProperty scope="context" name="FILE_MAX_HISTORY"
|
||||
source="APP_LOG_FILE_MAX_HISTORY" defaultValue="14"/>
|
||||
<springProperty scope="context" name="FILE_TOTAL_SIZE_CAP"
|
||||
source="APP_LOG_FILE_TOTAL_SIZE_CAP" defaultValue="3GB"/>
|
||||
|
||||
<springProperty scope="context" name="ASYNC_ENABLED"
|
||||
source="APP_LOG_ASYNC_ENABLED" defaultValue="true"/>
|
||||
<springProperty scope="context" name="ASYNC_QUEUE_SIZE"
|
||||
source="APP_LOG_ASYNC_QUEUE_SIZE" defaultValue="512"/>
|
||||
<springProperty scope="context" name="ASYNC_DISCARDING_THRESHOLD"
|
||||
source="APP_LOG_ASYNC_DISCARDING_THRESHOLD" defaultValue="20"/>
|
||||
|
||||
<springProperty scope="context" name="JSON_TIMEZONE"
|
||||
source="APP_LOG_JSON_TIMEZONE" defaultValue="UTC"/>
|
||||
<springProperty scope="context" name="JSON_TIMESTAMP_PATTERN"
|
||||
source="APP_LOG_JSON_TIMESTAMP_PATTERN"
|
||||
defaultValue="yyyy-MM-dd'T'HH:mm:ss.SSSXXX"/>
|
||||
<springProperty scope="context" name="JSON_INCLUDE_CALLER_DATA"
|
||||
source="APP_LOG_JSON_INCLUDE_CALLER_DATA" defaultValue="false"/>
|
||||
<springProperty scope="context" name="JSON_LOGGER_NAME_LENGTH"
|
||||
source="APP_LOG_JSON_LOGGER_NAME_LENGTH" defaultValue="0"/>
|
||||
|
||||
<!-- D5/D8: ≤INFO sampling rate (prod 0.1 = 10%); WARN/ERROR always kept. -->
|
||||
<springProperty scope="context" name="LOG_SAMPLING_RATE"
|
||||
source="APP_LOG_SAMPLING_RATE" defaultValue="1.0"/>
|
||||
|
||||
<!-- Redaction Layer 1 (DRIFT-2): %maskedMsg masks secrets in the human-readable pattern,
|
||||
the SAME catalog (LogMaskingPatterns) the JSON decorator uses. -->
|
||||
<conversionRule conversionWord="maskedMsg"
|
||||
class="dev.caskeleton.bootstrap.logging.SecretMaskingMessageConverter"/>
|
||||
|
||||
<!-- Sampling Policy (final): drop a share of ≤INFO events; WARN/ERROR pass unconditionally. -->
|
||||
<turboFilter class="dev.caskeleton.bootstrap.logging.SamplingTurboFilter">
|
||||
<rate>${LOG_SAMPLING_RATE}</rate>
|
||||
</turboFilter>
|
||||
<turboFilter class="dev.caskeleton.bootstrap.logging.StartupFailureSpringBootLogFilter"/>
|
||||
|
||||
<!-- ===================== Console appender (per profile, D10) ===================== -->
|
||||
|
||||
<!-- local/dev: human-readable PatternLayout, with %maskedMsg redaction (DX exception). -->
|
||||
<springProfile name="local,dev">
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} req=%X{request_id:-} trace=%X{trace_id:-} user=%X{user_principal:-} - %maskedMsg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
</springProfile>
|
||||
|
||||
<!-- everything else (staging/prod/default/test): structured JSON + Layer 1 masking decorator. -->
|
||||
<springProfile name="!local & !dev">
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
|
||||
<includeContext>false</includeContext>
|
||||
<customFields>{"app":"${APP_NAME}","profile":"${APP_PROFILE}"}</customFields>
|
||||
<includeMdcKeyName>trace_id</includeMdcKeyName>
|
||||
<includeMdcKeyName>span_id</includeMdcKeyName>
|
||||
<includeMdcKeyName>request_id</includeMdcKeyName>
|
||||
<includeMdcKeyName>correlation_id</includeMdcKeyName>
|
||||
<includeMdcKeyName>user_principal</includeMdcKeyName>
|
||||
<timeZone>${JSON_TIMEZONE}</timeZone>
|
||||
<timestampPattern>${JSON_TIMESTAMP_PATTERN}</timestampPattern>
|
||||
<includeCallerData>${JSON_INCLUDE_CALLER_DATA}</includeCallerData>
|
||||
<shortenedLoggerNameLength>${JSON_LOGGER_NAME_LENGTH}</shortenedLoggerNameLength>
|
||||
<jsonGeneratorDecorator
|
||||
class="dev.caskeleton.bootstrap.logging.SecretMaskingJsonGeneratorDecorator"/>
|
||||
</encoder>
|
||||
</appender>
|
||||
</springProfile>
|
||||
|
||||
<!-- ===================== File appender (always JSON, gated) ====================== -->
|
||||
|
||||
<condition class="ch.qos.logback.core.boolex.PropertyEqualityCondition">
|
||||
<key>FILE_ENABLED</key><value>true</value>
|
||||
</condition>
|
||||
<if>
|
||||
<then>
|
||||
<appender name="JSON_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${FILE_PATH}</file>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
<fileNamePattern>${FILE_PATH}.%d{yyyy-MM-dd}.%i.gz</fileNamePattern>
|
||||
<maxFileSize>${FILE_MAX_SIZE}</maxFileSize>
|
||||
<maxHistory>${FILE_MAX_HISTORY}</maxHistory>
|
||||
<totalSizeCap>${FILE_TOTAL_SIZE_CAP}</totalSizeCap>
|
||||
</rollingPolicy>
|
||||
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
|
||||
<includeContext>false</includeContext>
|
||||
<customFields>{"app":"${APP_NAME}","profile":"${APP_PROFILE}"}</customFields>
|
||||
<includeMdcKeyName>trace_id</includeMdcKeyName>
|
||||
<includeMdcKeyName>span_id</includeMdcKeyName>
|
||||
<includeMdcKeyName>request_id</includeMdcKeyName>
|
||||
<includeMdcKeyName>correlation_id</includeMdcKeyName>
|
||||
<includeMdcKeyName>user_principal</includeMdcKeyName>
|
||||
<timeZone>${JSON_TIMEZONE}</timeZone>
|
||||
<timestampPattern>${JSON_TIMESTAMP_PATTERN}</timestampPattern>
|
||||
<includeCallerData>${JSON_INCLUDE_CALLER_DATA}</includeCallerData>
|
||||
<shortenedLoggerNameLength>${JSON_LOGGER_NAME_LENGTH}</shortenedLoggerNameLength>
|
||||
<jsonGeneratorDecorator
|
||||
class="dev.caskeleton.bootstrap.logging.SecretMaskingJsonGeneratorDecorator"/>
|
||||
</encoder>
|
||||
</appender>
|
||||
</then>
|
||||
</if>
|
||||
|
||||
<!-- ===================== Async wrap (drop-counting, DRIFT-5) ===================== -->
|
||||
|
||||
<condition class="ch.qos.logback.core.boolex.PropertyEqualityCondition">
|
||||
<key>ASYNC_ENABLED</key><value>true</value>
|
||||
</condition>
|
||||
<if>
|
||||
<then>
|
||||
<appender name="ASYNC_CONSOLE" class="dev.caskeleton.bootstrap.logging.MetricsAsyncAppender">
|
||||
<queueSize>${ASYNC_QUEUE_SIZE}</queueSize>
|
||||
<discardingThreshold>${ASYNC_DISCARDING_THRESHOLD}</discardingThreshold>
|
||||
<neverBlock>false</neverBlock>
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</appender>
|
||||
<condition class="ch.qos.logback.core.boolex.PropertyEqualityCondition">
|
||||
<key>FILE_ENABLED</key><value>true</value>
|
||||
</condition>
|
||||
<if>
|
||||
<then>
|
||||
<appender name="ASYNC_FILE" class="dev.caskeleton.bootstrap.logging.MetricsAsyncAppender">
|
||||
<queueSize>${ASYNC_QUEUE_SIZE}</queueSize>
|
||||
<discardingThreshold>${ASYNC_DISCARDING_THRESHOLD}</discardingThreshold>
|
||||
<neverBlock>false</neverBlock>
|
||||
<appender-ref ref="JSON_FILE"/>
|
||||
</appender>
|
||||
</then>
|
||||
</if>
|
||||
</then>
|
||||
</if>
|
||||
|
||||
<!-- ============================== Root =============================== -->
|
||||
|
||||
<!-- <if> may not be nested inside <root> (logback IfNestedWithinSecondPhaseElementSC); wrap
|
||||
each <root> in a top-level <condition>+<if>, one per ASYNC×FILE combination. -->
|
||||
<condition class="ch.qos.logback.core.boolex.PropertyEqualityCondition">
|
||||
<key>ASYNC_ENABLED</key><value>true</value>
|
||||
</condition>
|
||||
<if>
|
||||
<then>
|
||||
<condition class="ch.qos.logback.core.boolex.PropertyEqualityCondition">
|
||||
<key>FILE_ENABLED</key><value>true</value>
|
||||
</condition>
|
||||
<if>
|
||||
<then>
|
||||
<root level="INFO">
|
||||
<appender-ref ref="ASYNC_CONSOLE"/>
|
||||
<appender-ref ref="ASYNC_FILE"/>
|
||||
</root>
|
||||
</then>
|
||||
<else>
|
||||
<root level="INFO">
|
||||
<appender-ref ref="ASYNC_CONSOLE"/>
|
||||
</root>
|
||||
</else>
|
||||
</if>
|
||||
</then>
|
||||
<else>
|
||||
<condition class="ch.qos.logback.core.boolex.PropertyEqualityCondition">
|
||||
<key>FILE_ENABLED</key><value>true</value>
|
||||
</condition>
|
||||
<if>
|
||||
<then>
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
<appender-ref ref="JSON_FILE"/>
|
||||
</root>
|
||||
</then>
|
||||
<else>
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</root>
|
||||
</else>
|
||||
</if>
|
||||
</else>
|
||||
</if>
|
||||
</configuration>
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package dev.caskeleton.adapter.outbound;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.core.CacheStoreRouter;
|
||||
import dev.caskeleton.adapter.outbound.messaging.core.DisabledMessagePublisher;
|
||||
import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessage;
|
||||
import dev.caskeleton.adapter.outbound.notification.core.RoutingNotifier;
|
||||
import dev.caskeleton.application.notification.Channel;
|
||||
import dev.caskeleton.application.notification.Notification;
|
||||
import dev.caskeleton.shared.error.AdapterDisabledException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Layer 3 (D4) — a disabled optional adapter that is nonetheless invoked must fail fast with {@link
|
||||
* AdapterDisabledException}, never a silent no-op or timeout wait (required_test {@code
|
||||
* adapter-contract:adapter-disabled-runtime-call}).
|
||||
*
|
||||
* <p>Notification uses the router-fail-fast shape (no per-channel {@code Disabled*Notifier}
|
||||
* sentinel): an unbound route on a {@link RoutingNotifier} with no providers/routes throws {@link
|
||||
* AdapterDisabledException} — mirrors the cache D4 contract.
|
||||
*/
|
||||
class DisabledAdapterSentinelTest {
|
||||
|
||||
private static final Notification STUB_NOTIFICATION =
|
||||
new Notification("test@example.com", "s", "b");
|
||||
|
||||
@Test
|
||||
void disabledMessagingFailsFast() {
|
||||
assertThatThrownBy(
|
||||
() -> new DisabledMessagePublisher().publish(new OutboundMessage("t", "k", "p")))
|
||||
.isInstanceOf(AdapterDisabledException.class)
|
||||
.extracting("adapterName")
|
||||
.isEqualTo("messaging");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unwiredCacheGetFailsFast() {
|
||||
assertThatThrownBy(() -> new CacheStoreRouter(List.of(), Map.of()).get("worklog", "k"))
|
||||
.isInstanceOf(AdapterDisabledException.class)
|
||||
.extracting("adapterName")
|
||||
.isEqualTo("cache");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unwiredCachePutFailsFast() {
|
||||
assertThatThrownBy(() -> new CacheStoreRouter(List.of(), Map.of()).put("worklog", "k", "v"))
|
||||
.isInstanceOf(AdapterDisabledException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unwiredNotificationEmailFailsFast() {
|
||||
// router-fail-fast: no providers, no routes → AdapterDisabledException on first call
|
||||
assertThatThrownBy(
|
||||
() -> new RoutingNotifier(List.of(), Map.of()).notify(Channel.EMAIL, STUB_NOTIFICATION))
|
||||
.isInstanceOf(AdapterDisabledException.class)
|
||||
.extracting("adapterName")
|
||||
.isEqualTo("notification");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unwiredNotificationSlackFailsFast() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new RoutingNotifier(List.of(), Map.of())
|
||||
.notify(Channel.SLACK, "alerts", STUB_NOTIFICATION))
|
||||
.isInstanceOf(AdapterDisabledException.class)
|
||||
.extracting("adapterName")
|
||||
.isEqualTo("notification");
|
||||
}
|
||||
}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
package dev.caskeleton.adapter.outbound;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.CacheRouterConfig;
|
||||
import dev.caskeleton.adapter.outbound.cache.core.CacheBackend;
|
||||
import dev.caskeleton.adapter.outbound.cache.core.CacheStore;
|
||||
import dev.caskeleton.adapter.outbound.cache.core.CacheStoreRouter;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.RedisCacheAdapterConfig;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.RedisClient;
|
||||
import dev.caskeleton.adapter.outbound.messaging.MessagingConfig;
|
||||
import dev.caskeleton.adapter.outbound.messaging.core.DisabledMessagePublisher;
|
||||
import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker;
|
||||
import dev.caskeleton.adapter.outbound.messaging.core.MessagePublisher;
|
||||
import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessagePublisher;
|
||||
import dev.caskeleton.adapter.outbound.messaging.kafka.KafkaAdapterConfig;
|
||||
import dev.caskeleton.adapter.outbound.messaging.kafka.KafkaSender;
|
||||
import dev.caskeleton.adapter.outbound.messaging.outbox.DisabledOutboxMessagePublisher;
|
||||
import dev.caskeleton.adapter.outbound.notification.NotificationConfig;
|
||||
import dev.caskeleton.adapter.outbound.notification.core.NotificationProvider;
|
||||
import dev.caskeleton.adapter.outbound.notification.core.RoutingNotifier;
|
||||
import dev.caskeleton.adapter.outbound.notification.email.google.GoogleEmailClient;
|
||||
import dev.caskeleton.adapter.outbound.notification.email.google.GoogleEmailNotificationAdapterConfig;
|
||||
import dev.caskeleton.adapter.outbound.notification.slack.webhook.SlackClient;
|
||||
import dev.caskeleton.adapter.outbound.notification.slack.webhook.SlackNotificationAdapterConfig;
|
||||
import dev.caskeleton.adapter.outbound.support.OutboundSupportConfig;
|
||||
import dev.caskeleton.application.notification.Channel;
|
||||
import dev.caskeleton.application.notification.Notification;
|
||||
import dev.caskeleton.application.notification.NotificationPort;
|
||||
import dev.caskeleton.application.outbox.OutboxMessagePublishPort;
|
||||
import dev.caskeleton.shared.error.AdapterDisabledException;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Layer 1 (D2) — {@code @ConditionalOnProperty} bean-gating contract (required_test {@code
|
||||
* adapter-contract:{redis,kafka,slack-webhook,google-email}-disabled-default}).
|
||||
*
|
||||
* <p>Asserts that with the default (env absent → disabled) the real adapter bean count is 0 and the
|
||||
* router fails fast on unbound access; and that flipping the enable flag (with the integration
|
||||
* client supplied) registers the real adapter and routes correctly. Also validates OCP: a second
|
||||
* backend plugs in via new files only without changing existing configs.
|
||||
*
|
||||
* <p>Notification: uses the router-fail-fast shape (mirrors cache D4). No per-channel {@code
|
||||
* Disabled*Notifier} sentinel — unbound route → {@link AdapterDisabledException} from {@link
|
||||
* RoutingNotifier}.
|
||||
*/
|
||||
class OptionalAdapterBeanGatingTest {
|
||||
|
||||
private static final Notification STUB_NOTIFICATION =
|
||||
new Notification("test@example.com", "test", "body");
|
||||
|
||||
private final ApplicationContextRunner runner =
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of())
|
||||
.withUserConfiguration(
|
||||
OutboundSupportConfig.class,
|
||||
MessagingConfig.class,
|
||||
KafkaAdapterConfig.class,
|
||||
RedisCacheAdapterConfig.class,
|
||||
CacheRouterConfig.class,
|
||||
NotificationConfig.class,
|
||||
SlackNotificationAdapterConfig.class,
|
||||
GoogleEmailNotificationAdapterConfig.class,
|
||||
StubClientsConfig.class);
|
||||
|
||||
@Test
|
||||
void allOptionalAdaptersAreDisabledByDefaultAndTheAppStillStarts() {
|
||||
// L262: an optional adapter must NOT become a required startup dependency.
|
||||
runner.run(
|
||||
context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
|
||||
// real provider beans absent (Layer 1 — disabled, contributes nothing)
|
||||
assertThat(context.getBeansOfType(MessageBroker.class)).isEmpty();
|
||||
assertThat(context.getBeansOfType(CacheStore.class)).isEmpty();
|
||||
assertThat(context.getBeansOfType(NotificationProvider.class)).isEmpty();
|
||||
|
||||
// messaging: fail-fast sentinels satisfy the ports (Layer 3 fallback)
|
||||
assertThat(context.getBean(MessagePublisher.class))
|
||||
.isInstanceOf(DisabledMessagePublisher.class);
|
||||
assertThat(context.getBean(OutboxMessagePublishPort.class))
|
||||
.isInstanceOf(DisabledOutboxMessagePublisher.class);
|
||||
|
||||
// cache D4: zero backends boot fine, unwired access fails fast in the router
|
||||
CacheStoreRouter cacheRouter = context.getBean(CacheStoreRouter.class);
|
||||
assertThatThrownBy(() -> cacheRouter.get("worklog", "k"))
|
||||
.isInstanceOf(AdapterDisabledException.class);
|
||||
|
||||
// notification D4: zero providers boot fine, unbound route fails fast in RoutingNotifier
|
||||
NotificationPort notificationPort = context.getBean(NotificationPort.class);
|
||||
assertThat(notificationPort).isInstanceOf(RoutingNotifier.class);
|
||||
assertThatThrownBy(() -> notificationPort.notify(Channel.EMAIL, STUB_NOTIFICATION))
|
||||
.isInstanceOf(AdapterDisabledException.class)
|
||||
.extracting("adapterName")
|
||||
.isEqualTo("notification");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void kafkaSelectedRegistersTheBrokerAndBindsTheRealPublishers() {
|
||||
runner
|
||||
.withPropertyValues(
|
||||
"app.messaging.broker=kafka", "app.messaging.kafka.brokers=broker-1:9092")
|
||||
.run(
|
||||
context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(context.getBeansOfType(MessageBroker.class)).hasSize(1);
|
||||
assertThat(context.getBean(MessagePublisher.class))
|
||||
.isInstanceOf(OutboundMessagePublisher.class);
|
||||
assertThat(context.getBeansOfType(DisabledMessagePublisher.class)).isEmpty();
|
||||
assertThat(context.getBeansOfType(DisabledOutboxMessagePublisher.class)).isEmpty();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void redisEnabledContributesTheBackendAndRoutesBoundLogicalCaches() {
|
||||
runner
|
||||
.withPropertyValues("app.cache.redis.enabled=true", "app.cache.bindings.worklog=redis")
|
||||
.run(
|
||||
context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(context.getBeansOfType(CacheStore.class)).hasSize(1);
|
||||
CacheStoreRouter router = context.getBean(CacheStoreRouter.class);
|
||||
assertThat(router.get("worklog", "k")).isEmpty();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void slackWebhookEnabledContributesTheProviderAndRoutesBoundNotifications() {
|
||||
runner
|
||||
.withPropertyValues(
|
||||
"app.notification.slack-webhook.enabled=true",
|
||||
"app.notification.routes.slack.default=slack-webhook")
|
||||
.run(
|
||||
context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(context.getBeansOfType(NotificationProvider.class)).hasSize(1);
|
||||
NotificationPort port = context.getBean(NotificationPort.class);
|
||||
assertThat(port).isInstanceOf(RoutingNotifier.class);
|
||||
// stub SlackClient is a no-op — call succeeds without exception
|
||||
assertThatCode(() -> port.notify(Channel.SLACK, STUB_NOTIFICATION))
|
||||
.doesNotThrowAnyException();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void googleEmailEnabledContributesTheProviderAndRoutesBoundNotifications() {
|
||||
runner
|
||||
.withPropertyValues(
|
||||
"app.notification.google-email.enabled=true",
|
||||
"app.notification.routes.email.default=google-email")
|
||||
.run(
|
||||
context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(context.getBeansOfType(NotificationProvider.class)).hasSize(1);
|
||||
NotificationPort port = context.getBean(NotificationPort.class);
|
||||
assertThat(port).isInstanceOf(RoutingNotifier.class);
|
||||
assertThatCode(() -> port.notify(Channel.EMAIL, STUB_NOTIFICATION))
|
||||
.doesNotThrowAnyException();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void aSecondBackendPlugsInWithNewFilesOnlyAndBothRouteByLogicalName() {
|
||||
// OCP proof: SecondBackendConfig simulates a future backend added as a NEW config
|
||||
// only — RedisCacheAdapterConfig / CacheRouterConfig are not touched.
|
||||
runner
|
||||
.withUserConfiguration(SecondBackendConfig.class)
|
||||
.withPropertyValues(
|
||||
"app.cache.redis.enabled=true",
|
||||
"app.cache.test-second.enabled=true",
|
||||
"app.cache.bindings.worklog=redis",
|
||||
"app.cache.bindings.session=test-second")
|
||||
.run(
|
||||
context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(context.getBeansOfType(CacheStore.class)).hasSize(2);
|
||||
CacheStoreRouter router = context.getBean(CacheStoreRouter.class);
|
||||
assertThat(router.get("session", "k")).contains("from-second-backend");
|
||||
assertThat(router.get("worklog", "k")).isEmpty();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void aBindingToADisabledBackendFailsStartup() {
|
||||
runner
|
||||
.withPropertyValues("app.cache.bindings.worklog=redis")
|
||||
.run(context -> assertThat(context).hasFailed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRouteBindingToADisabledProviderFailsStartup() {
|
||||
// configuration contradiction: route references a provider that contributed no bean
|
||||
runner
|
||||
.withPropertyValues("app.notification.routes.email.default=google-email")
|
||||
.run(context -> assertThat(context).hasFailed());
|
||||
}
|
||||
|
||||
/** Simulates a forking project's additional cache backend — new files only. */
|
||||
@Configuration
|
||||
static class SecondBackendConfig {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(name = "app.cache.test-second.enabled", havingValue = "true")
|
||||
CacheBackend secondBackend() {
|
||||
return new CacheBackend() {
|
||||
@Override
|
||||
public String backendId() {
|
||||
return "test-second";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> get(String key) {
|
||||
return Optional.of("from-second-backend");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void put(String key, String value) {}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Supplies the integration-seam client beans an enabled adapter requires. */
|
||||
@Configuration
|
||||
static class StubClientsConfig {
|
||||
|
||||
@Bean
|
||||
KafkaSender kafkaSender() {
|
||||
return message -> {};
|
||||
}
|
||||
|
||||
@Bean
|
||||
RedisClient redisClient() {
|
||||
return new RedisClient() {
|
||||
@Override
|
||||
public Optional<String> read(String key) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(String key, String value) {}
|
||||
};
|
||||
}
|
||||
|
||||
@Bean
|
||||
SlackClient slackClient() {
|
||||
return notification -> {};
|
||||
}
|
||||
|
||||
@Bean
|
||||
GoogleEmailClient googleEmailClient() {
|
||||
return notification -> {};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
+705
@@ -0,0 +1,705 @@
|
||||
package dev.caskeleton.bootstrap.architecture;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.tngtech.archunit.core.domain.JavaClasses;
|
||||
import com.tngtech.archunit.core.importer.ClassFileImporter;
|
||||
import com.tngtech.archunit.lang.EvaluationResult;
|
||||
import dev.caskeleton.bootstrap.architecture.allowed.application.CleanProjectionQueryPort;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.application.BulkWriteWithoutWriteAccessUseCase;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.application.FixtureRepository;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.application.GenericLeakQueryPort;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.application.JakartaValidationApplicationFixture;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.application.MissingTransactionBoundaryUseCase;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.application.OutboundWithoutPermissionUseCase;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.application.RawLeakQueryPort;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.application.ReadOnlyRepositoryWriteUseCase;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.domain.AnnotatedPublicNoArgValueObjectFixture;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.domain.JakartaValidationDomainFixture;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.domain.entity.FakeDomainEntity;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.domain.vo.PackagePublicNoArgValueObjectFixture;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.streaming.JakartaWebSocketEndpointFixture;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.streaming.SpringWebSocketHandlerFixture;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Negative ("violations-as-data") tests proving each architecture rule actually catches the
|
||||
* violation it claims to. Each test loads the intentional fixture classes under {@code
|
||||
* dev.caskeleton.bootstrap.architecture.violations.*} and asserts that the corresponding rule from
|
||||
* {@link CleanArchitectureTest} reports at least one violation.
|
||||
*
|
||||
* <p>Without this layer, a rule that silently no-matches in production (e.g. a predicate
|
||||
* referencing a package nothing in the codebase happens to populate) would pass vacuously — exactly
|
||||
* the regression caught in {@code
|
||||
* raw/errors/archunit-test-scope-sample-ticket-inclusion-2026-05-28}.
|
||||
*
|
||||
* <p>Pattern reference: Spring Modulith's {@code modules.detectViolations().getMessages()} on an
|
||||
* {@code example/ninvalid} fixture package ({@code
|
||||
* raw/company-tech-blogs/spring-modulith-archunit-generated-exemption-and-violations-as-data}
|
||||
* SPRING-MOD-AU-C2).
|
||||
*/
|
||||
class ArchitectureViolationFixtureTest {
|
||||
|
||||
private static final JavaClasses VIOLATION_CLASSES =
|
||||
new ClassFileImporter().importPackages("dev.caskeleton.bootstrap.architecture.violations");
|
||||
|
||||
/** Loaded in isolation to verify D3 rules do NOT over-block StreamingResponseBody. */
|
||||
private static final JavaClasses ALLOWED_STREAMING_CLASSES =
|
||||
new ClassFileImporter()
|
||||
.importPackages("dev.caskeleton.bootstrap.architecture.allowed.streaming");
|
||||
|
||||
// Each jakarta.validation fixture is imported in ISOLATION so the two package globs in
|
||||
// VALIDATION_CONSTRAINTS_STAY_AT_WEB_BOUNDARY ("..domain.." vs "..application..") are
|
||||
// verified independently — evaluating both against the shared VIOLATION_CLASSES pool would
|
||||
// let either fixture satisfy hasViolation(), so a silently broken glob would pass vacuously.
|
||||
private static final JavaClasses VALIDATION_IN_DOMAIN_FIXTURE_ONLY =
|
||||
new ClassFileImporter().importClasses(JakartaValidationDomainFixture.class);
|
||||
private static final JavaClasses VALIDATION_IN_APPLICATION_FIXTURE_ONLY =
|
||||
new ClassFileImporter().importClasses(JakartaValidationApplicationFixture.class);
|
||||
|
||||
// Each WebSocket fixture is imported in ISOLATION so the two package globs in
|
||||
// NO_WEBSOCKET_HANDLER ("org.springframework.web.socket.." vs "jakarta.websocket..")
|
||||
// are verified independently. Evaluating both against the shared VIOLATION_CLASSES pool
|
||||
// would let either fixture satisfy hasViolation() — so a silently broken jakarta (or
|
||||
// spring) glob would still pass vacuously, the exact failure mode this test layer exists
|
||||
// to prevent. (importClasses is safe here: both fixtures are annotation-only, so the JVM
|
||||
// does not resolve the testCompileOnly types at link time.)
|
||||
private static final JavaClasses SPRING_WEBSOCKET_FIXTURE_ONLY =
|
||||
new ClassFileImporter().importClasses(SpringWebSocketHandlerFixture.class);
|
||||
private static final JavaClasses JAKARTA_WEBSOCKET_FIXTURE_ONLY =
|
||||
new ClassFileImporter().importClasses(JakartaWebSocketEndpointFixture.class);
|
||||
|
||||
// feature-application-query-bypass-contract D1: each *QueryPort leak fixture is imported
|
||||
// in ISOLATION (with FakeDomainEntity so the forbidden type resolves) so the raw-leak and
|
||||
// generic-leak cases are proven independently. The generic-leak corpus is what proves the
|
||||
// rule inspects generic type arguments — a raw-return-type check would pass it vacuously.
|
||||
private static final JavaClasses RAW_LEAK_QUERY_PORT_ONLY =
|
||||
new ClassFileImporter().importClasses(RawLeakQueryPort.class, FakeDomainEntity.class);
|
||||
private static final JavaClasses GENERIC_LEAK_QUERY_PORT_ONLY =
|
||||
new ClassFileImporter().importClasses(GenericLeakQueryPort.class, FakeDomainEntity.class);
|
||||
|
||||
/** Over-block guard corpus: a legitimate projection port the D1 rule must NOT flag. */
|
||||
private static final JavaClasses CLEAN_PROJECTION_QUERY_PORT_ONLY =
|
||||
new ClassFileImporter().importClasses(CleanProjectionQueryPort.class);
|
||||
|
||||
// feature-domain-modeling-guardrails: the @ValueObject rule is an OR of an annotation
|
||||
// branch and a "..domain.vo.." package branch — each is imported in ISOLATION so a
|
||||
// silently broken branch cannot pass vacuously via the other one in the shared pool.
|
||||
private static final JavaClasses VO_ANNOTATION_FIXTURE_ONLY =
|
||||
new ClassFileImporter().importClasses(AnnotatedPublicNoArgValueObjectFixture.class);
|
||||
private static final JavaClasses VO_PACKAGE_FIXTURE_ONLY =
|
||||
new ClassFileImporter().importClasses(PackagePublicNoArgValueObjectFixture.class);
|
||||
|
||||
// feature-domain-modeling-guardrails D4/D8: each transport glob in
|
||||
// DOMAIN_EVENTS_ARE_TRANSPORT_FREE (kafka / spring-http / jax-rs) is proven on its own
|
||||
// isolated corpus — mirrors the NO_WEBSOCKET_HANDLER isolation. Each fixture lives in its
|
||||
// own subpackage and is loaded with importPackages, which reads .class bytes directly
|
||||
// (no JVM classloading), so the testCompileOnly transport types need not resolve at runtime
|
||||
// — the same mechanism the streaming websocket fixtures rely on.
|
||||
private static final JavaClasses KAFKA_DOMAIN_EVENT_FIXTURE_ONLY =
|
||||
new ClassFileImporter()
|
||||
.importPackages("dev.caskeleton.bootstrap.architecture.violations.domain.event.kafka");
|
||||
private static final JavaClasses SPRING_HTTP_DOMAIN_EVENT_FIXTURE_ONLY =
|
||||
new ClassFileImporter()
|
||||
.importPackages(
|
||||
"dev.caskeleton.bootstrap.architecture.violations.domain.event.springhttp");
|
||||
private static final JavaClasses JAXRS_DOMAIN_EVENT_FIXTURE_ONLY =
|
||||
new ClassFileImporter()
|
||||
.importPackages("dev.caskeleton.bootstrap.architecture.violations.domain.event.jaxrs");
|
||||
private static final JavaClasses NON_RECORD_DOMAIN_EVENT_FIXTURE_ONLY =
|
||||
new ClassFileImporter()
|
||||
.importPackages(
|
||||
"dev.caskeleton.bootstrap.architecture.violations.domain.event.nonrecord");
|
||||
|
||||
// feature-repository-access-permission-contract D12 / D6: each coherence fixture is imported
|
||||
// in ISOLATION so the two distinct rules are proven independently (a write-call violation must
|
||||
// not be allowed to satisfy the bulk-access assertion in a shared pool, and vice versa). The
|
||||
// D12 corpus includes FixtureRepository so the write-method call target resolves.
|
||||
private static final JavaClasses READ_ONLY_REPOSITORY_WRITE_FIXTURE_ONLY =
|
||||
new ClassFileImporter()
|
||||
.importClasses(ReadOnlyRepositoryWriteUseCase.class, FixtureRepository.class);
|
||||
private static final JavaClasses BULK_WRITE_WITHOUT_WRITE_ACCESS_FIXTURE_ONLY =
|
||||
new ClassFileImporter().importClasses(BulkWriteWithoutWriteAccessUseCase.class);
|
||||
private static final JavaClasses MISSING_TRANSACTION_BOUNDARY_FIXTURE_ONLY =
|
||||
new ClassFileImporter().importClasses(MissingTransactionBoundaryUseCase.class);
|
||||
// D7: externalOutboundAllowed=false use case that calls OutboxMessagePublishPort
|
||||
// (bound to OutboxMessagePublishAdapter under ..adapter.outbound..). Imported in isolation; the
|
||||
// outbound-port set the rule checks against is precomputed from production classes.
|
||||
private static final JavaClasses OUTBOUND_WITHOUT_PERMISSION_FIXTURE_ONLY =
|
||||
new ClassFileImporter().importClasses(OutboundWithoutPermissionUseCase.class);
|
||||
|
||||
// feature-secrets-config-source-contract D3/D10: the @RefreshScope fixture is loaded via
|
||||
// importPackages (NOT importClasses) so the JVM never link-resolves the testCompileOnly
|
||||
// spring-cloud-context annotation type — mirrors the streaming WebSocket isolation rationale.
|
||||
private static final JavaClasses REFRESH_SCOPE_FIXTURE_ONLY =
|
||||
new ClassFileImporter()
|
||||
.importPackages("dev.caskeleton.bootstrap.architecture.violations.secrets");
|
||||
|
||||
@Test
|
||||
void noRefreshScopeAnywhereCatchesRefreshScopeAnnotation() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.NO_REFRESH_SCOPE_ANYWHERE.evaluate(REFRESH_SCOPE_FIXTURE_ONLY);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as("NO_REFRESH_SCOPE_ANYWHERE must catch RefreshScopeUsingFixture (D3/D10)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void domainIsPureCatchesSpringDependencyInDomainPackage() {
|
||||
EvaluationResult result = CleanArchitectureTest.DOMAIN_IS_PURE.evaluate(VIOLATION_CLASSES);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as("DOMAIN_IS_PURE must catch SpringDependentDomainFixture")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void applicationDoesNotUseSpringTransactionalAnnotationCatchesViolation() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.APPLICATION_DOES_NOT_USE_SPRING_TRANSACTIONAL_ANNOTATION.evaluate(
|
||||
VIOLATION_CLASSES);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"APPLICATION_DOES_NOT_USE_SPRING_TRANSACTIONAL_ANNOTATION must catch "
|
||||
+ "TransactionalAnnotatedFixture")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void applicationDoesNotDependOnApplicationContextCatchesViolation() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.APPLICATION_DOES_NOT_DEPEND_ON_APPLICATION_CONTEXT.evaluate(
|
||||
VIOLATION_CLASSES);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"APPLICATION_DOES_NOT_DEPEND_ON_APPLICATION_CONTEXT must catch "
|
||||
+ "ApplicationContextDependentFixture")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void inboundPortImplementationsEndWithUseCaseCatchesBadlyNamedImpl() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.INBOUND_PORT_IMPLEMENTATIONS_END_WITH_USE_CASE.evaluate(
|
||||
VIOLATION_CLASSES);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as("INBOUND_PORT_IMPLEMENTATIONS_END_WITH_USE_CASE must catch " + "BadlyNamedHandler")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void inboundPortImplementationsDeclareCapabilityCatchesMissingAnnotation() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.INBOUND_PORT_IMPLEMENTATIONS_DECLARE_CAPABILITY.evaluate(
|
||||
VIOLATION_CLASSES);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"INBOUND_PORT_IMPLEMENTATIONS_DECLARE_CAPABILITY must catch "
|
||||
+ "MissingCapabilityUseCase")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
// The KEYED-idempotency freeze rule + its KeyedIdempotencyUseCase fixture were
|
||||
// removed when feature-rate-limit-idempotency-contract merged (Idempotency.KEYED is
|
||||
// now a supported capability), so the corresponding fixture test is gone too.
|
||||
|
||||
@Test
|
||||
void readOnlyUseCasesDoNotCallRepositoryWriteMethodsCatchesReadToWriteUpgrade() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.READ_ONLY_USE_CASES_DO_NOT_CALL_REPOSITORY_WRITE_METHODS.evaluate(
|
||||
READ_ONLY_REPOSITORY_WRITE_FIXTURE_ONLY);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"READ_ONLY_USE_CASES_DO_NOT_CALL_REPOSITORY_WRITE_METHODS (D12) must catch "
|
||||
+ "ReadOnlyRepositoryWriteUseCase calling FixtureRepository.save")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkWriteCapabilityRequiresWriteRepositoryAccessCatchesReadAccessBulk() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.BULK_WRITE_CAPABILITY_REQUIRES_WRITE_REPOSITORY_ACCESS.evaluate(
|
||||
BULK_WRITE_WITHOUT_WRITE_ACCESS_FIXTURE_ONLY);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"BULK_WRITE_CAPABILITY_REQUIRES_WRITE_REPOSITORY_ACCESS (D6) must catch "
|
||||
+ "BulkWriteWithoutWriteAccessUseCase declaring bulkWrite=true with "
|
||||
+ "repositoryAccess=READ_REPOSITORY")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void useCaseCapabilityMatchesTransactionPortBoundaryCatchesMissingWriteBoundary() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY.evaluate(
|
||||
MISSING_TRANSACTION_BOUNDARY_FIXTURE_ONLY);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY must catch "
|
||||
+ "MissingTransactionBoundaryUseCase declaring WRITE_REPOSITORY without "
|
||||
+ "TransactionPort.inWrite")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sharedContractScopeRuleCatchesDomainSpecificSharedPackage() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.SHARED_CONTRACT_CONTAINS_ONLY_OPERATIONAL_CONTRACT_PACKAGES.evaluate(
|
||||
VIOLATION_CLASSES);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"SHARED_CONTRACT_CONTAINS_ONLY_OPERATIONAL_CONTRACT_PACKAGES must catch "
|
||||
+ "a domain-specific package under ..shared.worklog..")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void externalOutboundCallsRequireExternalOutboundAllowedCapabilityCatchesUnpermittedCall() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.EXTERNAL_OUTBOUND_CALLS_REQUIRE_EXTERNAL_OUTBOUND_ALLOWED_CAPABILITY
|
||||
.evaluate(OUTBOUND_WITHOUT_PERMISSION_FIXTURE_ONLY);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"EXTERNAL_OUTBOUND_CALLS_REQUIRE_EXTERNAL_OUTBOUND_ALLOWED_CAPABILITY (D7) must "
|
||||
+ "catch OutboundWithoutPermissionUseCase calling OutboxMessagePublishPort without "
|
||||
+ "externalOutboundAllowed=true")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestDtosDoNotSilenceUnknownFieldsCatchesClassLevelIgnoreUnknown() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.REQUEST_DTOS_DO_NOT_SILENCE_UNKNOWN_FIELDS.evaluate(
|
||||
VIOLATION_CLASSES);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"REQUEST_DTOS_DO_NOT_SILENCE_UNKNOWN_FIELDS must catch "
|
||||
+ "JsonIgnoreUnknownRequestFixture (B1)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void noJacksonLaissezFaireSubtypeValidatorCatchesUnsafeDefaultTyping() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.NO_JACKSON_LAISSEZ_FAIRE_SUBTYPE_VALIDATOR.evaluate(
|
||||
VIOLATION_CLASSES);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as("NO_JACKSON_LAISSEZ_FAIRE_SUBTYPE_VALIDATOR must catch " + "DefaultTypingFixture (B5)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void noJacksonEnableDefaultTypingCallCatchesActivateCall() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.NO_JACKSON_ENABLE_DEFAULT_TYPING_CALL.evaluate(VIOLATION_CLASSES);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"NO_JACKSON_ENABLE_DEFAULT_TYPING_CALL must catch "
|
||||
+ "DefaultTypingFixture.unsafe() (B5)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void noInheritableThreadLocalCatchesInheritableThreadLocalField() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.NO_INHERITABLE_THREAD_LOCAL.evaluate(VIOLATION_CLASSES);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as("NO_INHERITABLE_THREAD_LOCAL must catch " + "InheritableThreadLocalFixture (B6)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void controllersDoNotReturnDomainOrEntityTypesCatchesDomainReturningFixture() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.CONTROLLERS_DO_NOT_RETURN_DOMAIN_OR_ENTITY_TYPES.evaluate(
|
||||
VIOLATION_CLASSES);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"CONTROLLERS_DO_NOT_RETURN_DOMAIN_OR_ENTITY_TYPES must catch "
|
||||
+ "DomainReturningControllerFixture (판정 기준 Forbidden)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void applicationMethodsDoNotAcceptWebDtosCatchesWebDtoAcceptingFixture() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.APPLICATION_METHODS_DO_NOT_ACCEPT_WEB_DTOS.evaluate(
|
||||
VIOLATION_CLASSES);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"APPLICATION_METHODS_DO_NOT_ACCEPT_WEB_DTOS must catch "
|
||||
+ "WebDtoAcceptingApplicationFixture (판정 기준 Forbidden + 테스트 계약)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void noProblemDetailUsageCatchesProblemDetailFixture() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.NO_PROBLEM_DETAIL_USAGE.evaluate(VIOLATION_CLASSES);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as("NO_PROBLEM_DETAIL_USAGE must catch " + "ProblemDetailUsingFixture (D5)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void noMergePatchJsonMediaTypeCatchesMergePatchFixture() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.NO_MERGE_PATCH_JSON_MEDIA_TYPE_STRING.evaluate(VIOLATION_CLASSES);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as("NO_MERGE_PATCH_JSON_MEDIA_TYPE_STRING must catch " + "MergePatchJsonFixture (B2)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboundAdapterMethodReturnsOnlyDomainCatchesRawLeakingFixture() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.OUTBOUND_ADAPTER_METHOD_RETURNS_ONLY_DOMAIN_OR_PRIMITIVES.evaluate(
|
||||
VIOLATION_CLASSES);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"OUTBOUND_ADAPTER_METHOD_RETURNS_ONLY_DOMAIN_OR_PRIMITIVES must catch "
|
||||
+ "RawTypeLeakingAdapterFixture (B7)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void controllerRequestMappingsFollowAip122CatchesKebabPathFixture() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.CONTROLLER_REQUEST_MAPPINGS_FOLLOW_AIP122.evaluate(VIOLATION_CLASSES);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"CONTROLLER_REQUEST_MAPPINGS_FOLLOW_AIP122 must catch "
|
||||
+ "KebabPathControllerFixture (D19)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validCascadeDepthCatchesDeepCascadeFixture() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.VALID_CASCADE_DEPTH_AT_MOST_THREE.evaluate(VIOLATION_CLASSES);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as("VALID_CASCADE_DEPTH_AT_MOST_THREE must catch " + "DeepCascadeRequestFixture (B4)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
// ---- feature-business-rule-validation-contract C1/D1 violation fixtures ----
|
||||
|
||||
@Test
|
||||
void validationConstraintsStayAtWebBoundaryCatchesJakartaValidationInDomain() {
|
||||
// Isolated corpus: proves the "..domain.." glob fires on its own.
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.VALIDATION_CONSTRAINTS_STAY_AT_WEB_BOUNDARY.evaluate(
|
||||
VALIDATION_IN_DOMAIN_FIXTURE_ONLY);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"VALIDATION_CONSTRAINTS_STAY_AT_WEB_BOUNDARY must catch "
|
||||
+ "JakartaValidationDomainFixture (C1/D1)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validationConstraintsStayAtWebBoundaryCatchesJakartaValidationInApplication() {
|
||||
// Isolated corpus: proves the "..application.." glob fires on its own — not vacuously
|
||||
// via the domain fixture also present in the shared violations tree.
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.VALIDATION_CONSTRAINTS_STAY_AT_WEB_BOUNDARY.evaluate(
|
||||
VALIDATION_IN_APPLICATION_FIXTURE_ONLY);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"VALIDATION_CONSTRAINTS_STAY_AT_WEB_BOUNDARY must catch "
|
||||
+ "JakartaValidationApplicationFixture (C1/D1)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
// ---- feature-streaming-response-contract D3 violation fixtures ----
|
||||
|
||||
@Test
|
||||
void noSseEmitterCatchesSseEmitterFixture() {
|
||||
EvaluationResult result = CleanArchitectureTest.NO_SSE_EMITTER.evaluate(VIOLATION_CLASSES);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as("NO_SSE_EMITTER must catch SseEmitterUsingFixture (D3)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void noResponseBodyEmitterCatchesResponseBodyEmitterFixture() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.NO_RESPONSE_BODY_EMITTER.evaluate(VIOLATION_CLASSES);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as("NO_RESPONSE_BODY_EMITTER must catch ResponseBodyEmitterUsingFixture (D3)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void noWebsocketHandlerCatchesSpringWebsocketFixture() {
|
||||
// Isolated corpus: proves the "org.springframework.web.socket.." glob fires on its own.
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.NO_WEBSOCKET_HANDLER.evaluate(SPRING_WEBSOCKET_FIXTURE_ONLY);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as("NO_WEBSOCKET_HANDLER must catch SpringWebSocketHandlerFixture (D3)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void noWebsocketHandlerCatchesJakartaWebsocketFixture() {
|
||||
// Isolated corpus: proves the "jakarta.websocket.." glob fires on its own — not
|
||||
// vacuously via the spring fixture also present in the shared violations tree.
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.NO_WEBSOCKET_HANDLER.evaluate(JAKARTA_WEBSOCKET_FIXTURE_ONLY);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as("NO_WEBSOCKET_HANDLER must catch JakartaWebSocketEndpointFixture (D3)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
// ---- feature-streaming-response-contract D3 over-block guard (spec Claim #3) ----
|
||||
|
||||
@Test
|
||||
void noSseEmitterDoesNotCatchStreamingResponseBody() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.NO_SSE_EMITTER.evaluate(ALLOWED_STREAMING_CLASSES);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"NO_SSE_EMITTER must NOT catch StreamingResponseBodyAllowedFixture — "
|
||||
+ "StreamingResponseBody is request-response download, not server-push "
|
||||
+ "(feature-streaming-response-contract D3 R3 OUT_OF_BRANCH_SCOPE)")
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void noResponseBodyEmitterDoesNotCatchStreamingResponseBody() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.NO_RESPONSE_BODY_EMITTER.evaluate(ALLOWED_STREAMING_CLASSES);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"NO_RESPONSE_BODY_EMITTER must NOT catch StreamingResponseBodyAllowedFixture — "
|
||||
+ "StreamingResponseBody FQN is not ResponseBodyEmitter "
|
||||
+ "(feature-streaming-response-contract D3 R3 OUT_OF_BRANCH_SCOPE)")
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void noWebsocketHandlerDoesNotCatchStreamingResponseBody() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.NO_WEBSOCKET_HANDLER.evaluate(ALLOWED_STREAMING_CLASSES);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"NO_WEBSOCKET_HANDLER must NOT catch StreamingResponseBodyAllowedFixture — "
|
||||
+ "StreamingResponseBody is not in org.springframework.web.socket.. "
|
||||
+ "or jakarta.websocket.. packages "
|
||||
+ "(feature-streaming-response-contract D3 R3 OUT_OF_BRANCH_SCOPE)")
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
// ---- feature-schema-serialization-contract D3 (BigDecimal precision trap ban) ----
|
||||
|
||||
@Test
|
||||
void noBigdecimalDoubleConstructorCatchesDoubleAndFloatConstructors() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.NO_BIGDECIMAL_DOUBLE_CONSTRUCTOR.evaluate(VIOLATION_CLASSES);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"NO_BIGDECIMAL_DOUBLE_CONSTRUCTOR must catch "
|
||||
+ "BigDecimalDoubleConstructorFixture (D3 / SBMS-C3)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
// ---- feature-application-query-bypass-contract D1 (read/query port purity) ----
|
||||
|
||||
@Test
|
||||
void queryPortsPurityRuleCatchesRawDomainReturn() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES.evaluate(
|
||||
RAW_LEAK_QUERY_PORT_ONLY);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES must catch "
|
||||
+ "RawLeakQueryPort returning a domain type directly (D1)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryPortsPurityRuleCatchesGenericDomainReturn() {
|
||||
// Proves the rule inspects generic type arguments (List<FakeDomainEntity>) — a
|
||||
// raw-return-type check would pass this vacuously.
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES.evaluate(
|
||||
GENERIC_LEAK_QUERY_PORT_ONLY);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES must catch "
|
||||
+ "GenericLeakQueryPort leaking a domain type via List<DomainType> (D1)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryPortsPurityRuleDoesNotFlagCleanProjectionPort() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES.evaluate(
|
||||
CLEAN_PROJECTION_QUERY_PORT_ONLY);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES must NOT flag "
|
||||
+ "CleanProjectionQueryPort returning List<String> — no over-block (D1)")
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
// ---- feature-domain-modeling-guardrails violation fixtures ----
|
||||
|
||||
@Test
|
||||
void domainHasNoLoggerCatchesLoggerInDomainPackage() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.DOMAIN_HAS_NO_LOGGER.evaluate(VIOLATION_CLASSES);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as("DOMAIN_HAS_NO_LOGGER must catch LoggerUsingDomainFixture (D3)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void valueObjectRuleCatchesAnnotatedPublicNoArgConstructor() {
|
||||
// Isolated corpus: proves the @ValueObject annotation branch fires on its own.
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.VALUE_OBJECTS_HAVE_NO_PUBLIC_NO_ARG_CONSTRUCTOR.evaluate(
|
||||
VO_ANNOTATION_FIXTURE_ONLY);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"VALUE_OBJECTS_HAVE_NO_PUBLIC_NO_ARG_CONSTRUCTOR must catch "
|
||||
+ "AnnotatedPublicNoArgValueObjectFixture (D5/D6)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void valueObjectRuleCatchesPackageConventionPublicNoArgConstructor() {
|
||||
// Isolated corpus: proves the ..domain.vo.. package branch fires on its own — not
|
||||
// vacuously via the annotation fixture also present in the shared violations tree.
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.VALUE_OBJECTS_HAVE_NO_PUBLIC_NO_ARG_CONSTRUCTOR.evaluate(
|
||||
VO_PACKAGE_FIXTURE_ONLY);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"VALUE_OBJECTS_HAVE_NO_PUBLIC_NO_ARG_CONSTRUCTOR must catch "
|
||||
+ "PackagePublicNoArgValueObjectFixture via the ..domain.vo.. branch (D5/D6)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aggregateRootSettersAreNotPublicCatchesPublicSetter() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.AGGREGATE_ROOT_SETTERS_ARE_NOT_PUBLIC.evaluate(VIOLATION_CLASSES);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"AGGREGATE_ROOT_SETTERS_ARE_NOT_PUBLIC must catch "
|
||||
+ "PublicSetterAggregateFixture (D7)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void domainEventsAreRecordsCatchesNonRecordEvent() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.DOMAIN_EVENTS_ARE_RECORDS.evaluate(
|
||||
NON_RECORD_DOMAIN_EVENT_FIXTURE_ONLY);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as("DOMAIN_EVENTS_ARE_RECORDS must catch NonRecordDomainEventFixture (D4/D8)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void domainEventsAreRecordsDoesNotFlagARecordEvent() {
|
||||
// Over-block guard: a @DomainEvent that IS a record must NOT be flagged.
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.DOMAIN_EVENTS_ARE_RECORDS.evaluate(KAFKA_DOMAIN_EVENT_FIXTURE_ONLY);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as("DOMAIN_EVENTS_ARE_RECORDS must NOT flag KafkaDomainEventFixture — it is a record")
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void domainEventsAreTransportFreeCatchesKafkaType() {
|
||||
// Isolated corpus: proves the org.apache.kafka.. glob fires on its own.
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.DOMAIN_EVENTS_ARE_TRANSPORT_FREE.evaluate(
|
||||
KAFKA_DOMAIN_EVENT_FIXTURE_ONLY);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as("DOMAIN_EVENTS_ARE_TRANSPORT_FREE must catch KafkaDomainEventFixture (D4/D8)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void domainEventsAreTransportFreeCatchesSpringHttpType() {
|
||||
// Isolated corpus: proves the org.springframework.http.. glob fires on its own.
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.DOMAIN_EVENTS_ARE_TRANSPORT_FREE.evaluate(
|
||||
SPRING_HTTP_DOMAIN_EVENT_FIXTURE_ONLY);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as("DOMAIN_EVENTS_ARE_TRANSPORT_FREE must catch SpringHttpDomainEventFixture (D4/D8)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void domainEventsAreTransportFreeCatchesJaxRsType() {
|
||||
// Isolated corpus: proves the jakarta.ws.rs.. glob fires on its own — not vacuously
|
||||
// via the kafka or spring fixtures in the shared tree.
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.DOMAIN_EVENTS_ARE_TRANSPORT_FREE.evaluate(
|
||||
JAXRS_DOMAIN_EVENT_FIXTURE_ONLY);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as("DOMAIN_EVENTS_ARE_TRANSPORT_FREE must catch JaxRsDomainEventFixture (D4/D8)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void domainEventsAreTransportFreeDoesNotFlagACleanEvent() {
|
||||
// Over-block guard: a transport-free @DomainEvent must NOT be flagged.
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.DOMAIN_EVENTS_ARE_TRANSPORT_FREE.evaluate(
|
||||
NON_RECORD_DOMAIN_EVENT_FIXTURE_ONLY);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"DOMAIN_EVENTS_ARE_TRANSPORT_FREE must NOT flag NonRecordDomainEventFixture — "
|
||||
+ "it references no broker/HTTP type")
|
||||
.isFalse();
|
||||
}
|
||||
}
|
||||
+1901
File diff suppressed because it is too large
Load Diff
+157
@@ -0,0 +1,157 @@
|
||||
package dev.caskeleton.bootstrap.architecture;
|
||||
|
||||
import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage;
|
||||
import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage;
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.tngtech.archunit.core.domain.JavaClasses;
|
||||
import com.tngtech.archunit.core.importer.ClassFileImporter;
|
||||
import com.tngtech.archunit.lang.ArchRule;
|
||||
import com.tngtech.archunit.lang.EvaluationResult;
|
||||
import dev.caskeleton.bootstrap.architecture.allowed.contractisolation.contract.SampleFeatureUsingContractFixture;
|
||||
import dev.caskeleton.bootstrap.architecture.allowed.contractisolation.features.sample.SampleFeatureFixture;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.contractisolation.contract.NonSampleFeatureCoupledContractFixture;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.contractisolation.features.billing.BillingFeatureFixture;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Contract-test domain-isolation ArchUnit rule (feature-contract-verification-test-suite 테스트 계약
|
||||
* §1).
|
||||
*
|
||||
* <p>Enforces that contract test classes (residing in a {@code ..contract..} package) must NOT
|
||||
* depend on a non-sample business-domain <em>feature</em> package. The {@code ..features.sample..}
|
||||
* fixture package is the single allowed exemption.
|
||||
*
|
||||
* <p>Uses the <em>manual-importer</em> pattern (plain {@code @Test} + {@code ClassFileImporter})
|
||||
* rather than {@code @AnalyzeClasses}, because this rule is <em>about</em> test classes. The
|
||||
* {@code @AnalyzeClasses} suites in this package all use {@code ImportOption.DoNotIncludeTests} and
|
||||
* therefore cannot see test classes; the manual importer is the only way to load test bytecode.
|
||||
*
|
||||
* <h2>PACKAGE_DRIFT adaptation note</h2>
|
||||
*
|
||||
* <p>The branch spec's literal glob uses {@code com.example.caskeleton.features.{도메인}}. This
|
||||
* project's base package is {@code dev.caskeleton}, not {@code com.example.caskeleton}. ArchUnit's
|
||||
* {@code ..} wildcards make this mismatch irrelevant: the predicates {@code
|
||||
* resideInAPackage("..features..")} and {@code resideOutsideOfPackage("..features.sample..")} are
|
||||
* base-package-agnostic and correctly match any package whose qualified name contains {@code
|
||||
* features} (or excludes {@code features.sample}) regardless of root prefix.
|
||||
*
|
||||
* <h2>Predicate composition note</h2>
|
||||
*
|
||||
* <p>ArchUnit does not support regex negation like {@code (?!sample)}. The "a {@code ..features..}
|
||||
* package that is NOT {@code ..features.sample..}" predicate is expressed by composing two
|
||||
* predicates with {@code .and(...)}: {@code
|
||||
* resideInAPackage("..features..").and(resideOutsideOfPackage("..features.sample.."))}.
|
||||
*/
|
||||
class ContractSuiteIsolationArchTest {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The isolation rule
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Contract-test domain-isolation rule: a class in a {@code ..contract..} package must not depend
|
||||
* on a class in a {@code ..features..} package that is NOT {@code ..features.sample..}.
|
||||
*
|
||||
* <p>Package-visible field (no {@code @ArchTest}) because this class uses the manual-importer
|
||||
* pattern; {@code @ArchTest} wiring only fires under {@code @AnalyzeClasses}.
|
||||
*/
|
||||
static final ArchRule CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN =
|
||||
noClasses()
|
||||
.that()
|
||||
.resideInAPackage("..contract..")
|
||||
.should()
|
||||
.dependOnClassesThat(
|
||||
resideInAPackage("..features..").and(resideOutsideOfPackage("..features.sample..")))
|
||||
.as(
|
||||
"feature-contract-verification-test-suite 테스트 계약 §1: a contract test (..contract..) "
|
||||
+ "must stay domain-agnostic — it must not depend on a non-sample business-domain "
|
||||
+ "feature package (..features.. except ..features.sample.. fixtures). "
|
||||
+ "PACKAGE_DRIFT: base package is dev.caskeleton, not com.example.caskeleton; "
|
||||
+ ".. wildcards make this base-agnostic.")
|
||||
.allowEmptyShould(true);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Positive-control / over-block / clean-check corpora
|
||||
// Deterministic class-literal imports — no package enumeration for controls.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Violation corpus: a contract class depending on a non-sample feature type. */
|
||||
private static final JavaClasses NON_SAMPLE_FEATURE_VIOLATION_CORPUS =
|
||||
new ClassFileImporter()
|
||||
.importClasses(NonSampleFeatureCoupledContractFixture.class, BillingFeatureFixture.class);
|
||||
|
||||
/** Allowed corpus: a contract class depending only on the sample feature fixture. */
|
||||
private static final JavaClasses SAMPLE_FEATURE_ALLOWED_CORPUS =
|
||||
new ClassFileImporter()
|
||||
.importClasses(SampleFeatureUsingContractFixture.class, SampleFeatureFixture.class);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 1 — clean-check: real contract tests pass the isolation rule
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void realContractTestsAreDomainAgnostic() {
|
||||
// Package-scan corpus: non-vacuity guard ensures the scan is never silently empty.
|
||||
JavaClasses corpus =
|
||||
new ClassFileImporter().importPackages("dev.caskeleton.bootstrap.contract");
|
||||
|
||||
assertThat(corpus.size())
|
||||
.as(
|
||||
"contract corpus (dev.caskeleton.bootstrap.contract) must be non-empty — an empty "
|
||||
+ "scan would make this clean-check pass vacuously "
|
||||
+ "(feature-contract-verification-test-suite 테스트 계약 §1 non-vacuity guard)")
|
||||
.isGreaterThan(0);
|
||||
|
||||
EvaluationResult result =
|
||||
CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN.evaluate(corpus);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN must NOT fire on "
|
||||
+ "dev.caskeleton.bootstrap.contract — real contract tests must remain "
|
||||
+ "domain-agnostic (feature-contract-verification-test-suite 테스트 계약 §1)")
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 2 — positive control: rule fires on non-sample feature coupling
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void isolationRuleFiresOnNonSampleFeatureCoupling() {
|
||||
// Deterministic importClasses — proves the rule is non-vacuous.
|
||||
EvaluationResult result =
|
||||
CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN.evaluate(
|
||||
NON_SAMPLE_FEATURE_VIOLATION_CORPUS);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN MUST fire on "
|
||||
+ "NonSampleFeatureCoupledContractFixture (..contract.. → BillingFeatureFixture in "
|
||||
+ "..features.billing..) — proves the rule is non-vacuous "
|
||||
+ "(feature-contract-verification-test-suite 테스트 계약 §1 positive control)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test 3 — over-block guard: rule does NOT fire on sample feature usage
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void isolationRuleAllowsSampleFeatureFixture() {
|
||||
// Deterministic importClasses — proves the rule does not over-block sample fixtures.
|
||||
EvaluationResult result =
|
||||
CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN.evaluate(
|
||||
SAMPLE_FEATURE_ALLOWED_CORPUS);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN must NOT fire on "
|
||||
+ "SampleFeatureUsingContractFixture (..contract.. → SampleFeatureFixture in "
|
||||
+ "..features.sample..) — sample fixture usage is the allowed exemption "
|
||||
+ "(feature-contract-verification-test-suite 테스트 계약 §1 over-block guard)")
|
||||
.isFalse();
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package dev.caskeleton.bootstrap.architecture;
|
||||
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods;
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
|
||||
|
||||
import com.tngtech.archunit.junit.AnalyzeClasses;
|
||||
import com.tngtech.archunit.junit.ArchTest;
|
||||
import com.tngtech.archunit.lang.ArchRule;
|
||||
|
||||
/**
|
||||
* feature-integration-adapter-templates Layer 2 (D3 / §구현 가이드 §3) — static isolation + gating guard
|
||||
* for the optional integration adapters (Kafka / Redis / Slack / Google Email). Owner: this branch.
|
||||
*
|
||||
* <p>What this layer statically guarantees, and its documented limit (branch-note L111 / D3 Open
|
||||
* Risk): ArchUnit can prove (1) the application layer never imports an optional adapter package,
|
||||
* and (2) every optional-adapter {@code @Bean} is gated by {@code @ConditionalOnProperty} — i.e.
|
||||
* "the adapter candidate class HAS the {@code @ConditionalOnProperty} annotation". Whether the
|
||||
* adapter is actually <em>active</em> at runtime is a config evaluation ArchUnit cannot reach; that
|
||||
* runtime guarantee is delegated to Layer 3 ({@code AdapterDisabledException}).
|
||||
*
|
||||
* <p>Spring annotation types are referenced by fully-qualified NAME so this test needs no compile
|
||||
* dependency on spring-context / spring-boot-autoconfigure (they arrive only via adapter-outbound's
|
||||
* {@code implementation} scope, not transitively here).
|
||||
*/
|
||||
@AnalyzeClasses(packages = "dev.caskeleton", importOptions = ProductionClassImportOption.class)
|
||||
class DisabledAdapterArchitectureTest {
|
||||
|
||||
private static final String[] OPTIONAL_ADAPTER_PACKAGES = {
|
||||
"..adapter.outbound.messaging.kafka..",
|
||||
"..adapter.outbound.cache.redis..",
|
||||
"..adapter.outbound.notification.slack..",
|
||||
"..adapter.outbound.notification.email.."
|
||||
};
|
||||
|
||||
private static final String BEAN = "org.springframework.context.annotation.Bean";
|
||||
private static final String CONDITIONAL_ON_PROPERTY =
|
||||
"org.springframework.boot.autoconfigure.condition.ConditionalOnProperty";
|
||||
|
||||
/**
|
||||
* Layer 2 isolation — the application layer must never import an optional adapter package, so a
|
||||
* disabled adapter's classes can never appear on a use-case path (the static half of the
|
||||
* disabled-adapter detection; D3).
|
||||
*/
|
||||
@ArchTest
|
||||
static final ArchRule APPLICATION_DOES_NOT_DEPEND_ON_OPTIONAL_ADAPTERS =
|
||||
noClasses()
|
||||
.that()
|
||||
.resideInAPackage("..application..")
|
||||
.should()
|
||||
.dependOnClassesThat()
|
||||
.resideInAnyPackage(OPTIONAL_ADAPTER_PACKAGES)
|
||||
.as(
|
||||
"D3 APPLICATION_DOES_NOT_DEPEND_ON_OPTIONAL_ADAPTERS: the application layer must "
|
||||
+ "not import an optional adapter package (Kafka/Redis/Slack/Google Email) — "
|
||||
+ "the static half of the disabled-adapter detection. Runtime activity is "
|
||||
+ "delegated to Layer 3 (feature-integration-adapter-templates D3)")
|
||||
.allowEmptyShould(true);
|
||||
|
||||
/**
|
||||
* Layer 2 gating — every {@code @Bean} factory method that lives in an optional adapter package
|
||||
* must be gated by {@code @ConditionalOnProperty}, so no optional-adapter bean can ship ungated
|
||||
* (disabled-default could otherwise be bypassed). This is exactly the "adapter candidate
|
||||
* HAS @ConditionalOnProperty" guarantee from D3 (L111).
|
||||
*/
|
||||
@ArchTest
|
||||
static final ArchRule OPTIONAL_ADAPTER_BEANS_ARE_GATED_BY_CONDITIONAL_ON_PROPERTY =
|
||||
methods()
|
||||
.that()
|
||||
.areAnnotatedWith(BEAN)
|
||||
.and()
|
||||
.areDeclaredInClassesThat()
|
||||
.resideInAnyPackage(OPTIONAL_ADAPTER_PACKAGES)
|
||||
.should()
|
||||
.beAnnotatedWith(CONDITIONAL_ON_PROPERTY)
|
||||
.as(
|
||||
"D3 OPTIONAL_ADAPTER_BEANS_ARE_GATED_BY_CONDITIONAL_ON_PROPERTY: every @Bean in an "
|
||||
+ "optional adapter package (Kafka/Redis/Slack/Google Email) must declare "
|
||||
+ "@ConditionalOnProperty(app.<domain>.<adapter>.enabled) — Layer 1 disabled-default "
|
||||
+ "must not be bypassable by an ungated bean. ArchUnit reaches the annotation "
|
||||
+ "presence only; runtime activation is Layer 3's job "
|
||||
+ "(feature-integration-adapter-templates D3, L111)")
|
||||
.allowEmptyShould(true);
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package dev.caskeleton.bootstrap.architecture;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.tngtech.archunit.core.domain.JavaClasses;
|
||||
import com.tngtech.archunit.core.importer.ClassFileImporter;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Contract dry-run for adding a real domain feature without importing sample-portfolio. */
|
||||
class DomainFeatureOnboardingContractTest {
|
||||
|
||||
private static final String ROOT = "dev.caskeleton.onboarding";
|
||||
|
||||
private static final JavaClasses ONBOARDING_CLASSES =
|
||||
new ClassFileImporter().importPackages(ROOT);
|
||||
|
||||
@Test
|
||||
void readOnlyOnboardingSliceHasMinimumContractAndNoWriteArtifacts() {
|
||||
assertThat(classNames())
|
||||
.contains(
|
||||
ROOT + ".application.query.ListFeatureAggregatesQuery",
|
||||
ROOT + ".application.query.FeatureAggregateSummary",
|
||||
ROOT + ".application.port.FeatureAggregateSummaryQueryPort",
|
||||
ROOT + ".application.usecase.ListFeatureAggregatesUseCase",
|
||||
ROOT + ".adapter.inbound.web.dto.FeatureAggregateSummaryResponse",
|
||||
ROOT + ".adapter.inbound.web.mapper.FeatureAggregateResponseMapper",
|
||||
ROOT + ".adapter.inbound.web.controller.FeatureAggregateController")
|
||||
.doesNotContain(
|
||||
ROOT + ".application.command.ListFeatureAggregatesCommand",
|
||||
ROOT + ".application.port.ListFeatureAggregatesWritePort");
|
||||
|
||||
assertThat(
|
||||
CleanArchitectureTest.INBOUND_PORT_IMPLEMENTATIONS_END_WITH_USE_CASE
|
||||
.evaluate(ONBOARDING_CLASSES)
|
||||
.hasViolation())
|
||||
.isFalse();
|
||||
assertThat(
|
||||
CleanArchitectureTest.INBOUND_PORT_IMPLEMENTATIONS_DECLARE_CAPABILITY
|
||||
.evaluate(ONBOARDING_CLASSES)
|
||||
.hasViolation())
|
||||
.isFalse();
|
||||
assertThat(
|
||||
CleanArchitectureTest.READ_ONLY_USE_CASES_DO_NOT_CALL_REPOSITORY_WRITE_METHODS
|
||||
.evaluate(ONBOARDING_CLASSES)
|
||||
.hasViolation())
|
||||
.isFalse();
|
||||
assertThat(
|
||||
CleanArchitectureTest.USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY
|
||||
.evaluate(ONBOARDING_CLASSES)
|
||||
.hasViolation())
|
||||
.isFalse();
|
||||
assertThat(
|
||||
CleanArchitectureTest.CONTROLLERS_DO_NOT_RETURN_DOMAIN_OR_ENTITY_TYPES
|
||||
.evaluate(ONBOARDING_CLASSES)
|
||||
.hasViolation())
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeOnboardingSliceHasMinimumContractAndStaticRulesPass() {
|
||||
assertThat(classNames())
|
||||
.contains(
|
||||
ROOT + ".domain.feature.FeatureAggregate",
|
||||
ROOT + ".domain.feature.FeatureAggregateCreated",
|
||||
ROOT + ".domain.feature.FeatureAggregateId",
|
||||
ROOT + ".domain.feature.FeatureAggregateIdFactory",
|
||||
ROOT + ".application.command.CreateFeatureAggregateCommand",
|
||||
ROOT + ".application.port.FeatureAggregateWritePort",
|
||||
ROOT + ".application.usecase.CreateFeatureAggregateUseCase",
|
||||
ROOT + ".adapter.outbound.persistence.entity.FeatureAggregateEntity",
|
||||
ROOT + ".adapter.outbound.persistence.mapper.FeatureAggregateEntityMapper",
|
||||
ROOT + ".adapter.outbound.persistence.repository.FeatureAggregateRepositoryAdapter",
|
||||
ROOT + ".adapter.inbound.web.dto.CreateFeatureAggregateRequest",
|
||||
ROOT + ".adapter.inbound.web.dto.FeatureAggregateResponse",
|
||||
ROOT + ".adapter.inbound.web.controller.FeatureAggregateController");
|
||||
|
||||
assertThat(CleanArchitectureTest.DOMAIN_IS_PURE.evaluate(ONBOARDING_CLASSES).hasViolation())
|
||||
.isFalse();
|
||||
assertThat(
|
||||
CleanArchitectureTest.APPLICATION_DOES_NOT_DEPEND_ON_ADAPTERS_OR_TRANSPORT
|
||||
.evaluate(ONBOARDING_CLASSES)
|
||||
.hasViolation())
|
||||
.isFalse();
|
||||
assertThat(
|
||||
CleanArchitectureTest.MUTATING_USE_CASES_DECLARE_REQUIRED_PERMISSION
|
||||
.evaluate(ONBOARDING_CLASSES)
|
||||
.hasViolation())
|
||||
.isFalse();
|
||||
assertThat(
|
||||
CleanArchitectureTest.USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY
|
||||
.evaluate(ONBOARDING_CLASSES)
|
||||
.hasViolation())
|
||||
.isFalse();
|
||||
assertThat(
|
||||
CleanArchitectureTest.PERSISTENCE_ADAPTER_DOES_NOT_DEPEND_ON_WEB_OR_OUTBOUND_ADAPTERS
|
||||
.evaluate(ONBOARDING_CLASSES)
|
||||
.hasViolation())
|
||||
.isFalse();
|
||||
assertThat(
|
||||
CleanArchitectureTest.PRODUCTION_CODE_DOES_NOT_DEPEND_ON_SAMPLE_PORTFOLIO
|
||||
.evaluate(ONBOARDING_CLASSES)
|
||||
.hasViolation())
|
||||
.isFalse();
|
||||
assertThat(getClass().getResource("/db/onboarding-migration/V999__feature_aggregate.sql"))
|
||||
.as("write onboarding dry-run must include a persistence migration artifact")
|
||||
.isNotNull();
|
||||
}
|
||||
|
||||
private static List<String> classNames() {
|
||||
return ONBOARDING_CLASSES.stream().map(javaClass -> javaClass.getName()).sorted().toList();
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package dev.caskeleton.bootstrap.architecture;
|
||||
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
|
||||
|
||||
import com.tngtech.archunit.junit.AnalyzeClasses;
|
||||
import com.tngtech.archunit.junit.ArchTest;
|
||||
import com.tngtech.archunit.lang.ArchRule;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Naming convention enforcement — the machine-checkable ({@code [객관]}) subset of {@code
|
||||
* .agents/plugins/ca-superpowers/rules/code-conventions.md}.
|
||||
*
|
||||
* <p>The naming/idiom SSOT is {@code code-conventions.md}. This test enforces only the rules that
|
||||
* ArchUnit can decide reliably (N6, I5). {@code [판단]} naming rules (N2/N7/N8/...) and rules with
|
||||
* intentional project exceptions (N3/N4 — e.g. the {@code IdFactory} domain port, the {@code
|
||||
* SpringTransactionPort} canonical impl) stay in {@code ca-quality-reviewer} (doc+review), per the
|
||||
* code-conventions enforcement matrix.
|
||||
*/
|
||||
@AnalyzeClasses(packages = "dev.caskeleton", importOptions = ProductionClassImportOption.class)
|
||||
class NamingConventionTest {
|
||||
|
||||
// N6 — types we own that bind configuration end with *Settings (operational config)
|
||||
// or *Policy (authz / domain-policy binding). Spring framework *Properties types
|
||||
// (JacksonProperties, WebMvcProperties, ...) are excluded by the dev.caskeleton scope.
|
||||
@ArchTest
|
||||
static final ArchRule CONFIGURATION_PROPERTIES_END_WITH_SETTINGS_OR_POLICY =
|
||||
classes()
|
||||
.that()
|
||||
.areAnnotatedWith(ConfigurationProperties.class)
|
||||
.and()
|
||||
.resideInAPackage("dev.caskeleton..")
|
||||
.should()
|
||||
.haveSimpleNameEndingWith("Settings")
|
||||
.orShould()
|
||||
.haveSimpleNameEndingWith("Policy")
|
||||
.as(
|
||||
"code-conventions N6: dev.caskeleton @ConfigurationProperties types end with "
|
||||
+ "'Settings' (operational config) or 'Policy' (authz/domain policy binding)")
|
||||
.allowEmptyShould(true);
|
||||
|
||||
// I5 — every throwable we own is named *Exception (failure category = one meaningful type).
|
||||
@ArchTest
|
||||
static final ArchRule THROWABLES_ARE_NAMED_EXCEPTION =
|
||||
classes()
|
||||
.that()
|
||||
.areAssignableTo(Throwable.class)
|
||||
.and()
|
||||
.resideInAPackage("dev.caskeleton..")
|
||||
.should()
|
||||
.haveSimpleNameEndingWith("Exception")
|
||||
.as("code-conventions I5: dev.caskeleton throwable types end with 'Exception'")
|
||||
.allowEmptyShould(true);
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.caskeleton.bootstrap.architecture;
|
||||
|
||||
import com.tngtech.archunit.core.importer.ImportOption;
|
||||
import com.tngtech.archunit.core.importer.Location;
|
||||
|
||||
/** Excludes both Gradle's default test output and the sample-off custom test output. */
|
||||
public final class ProductionClassImportOption implements ImportOption {
|
||||
|
||||
private final ImportOption defaultTestExclusion = new ImportOption.DoNotIncludeTests();
|
||||
|
||||
@Override
|
||||
public boolean includes(Location location) {
|
||||
return defaultTestExclusion.includes(location) && !location.contains("/sampleOffTest/");
|
||||
}
|
||||
}
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
package dev.caskeleton.bootstrap.architecture;
|
||||
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.tngtech.archunit.core.domain.JavaClasses;
|
||||
import com.tngtech.archunit.core.importer.ClassFileImporter;
|
||||
import com.tngtech.archunit.lang.ArchRule;
|
||||
import com.tngtech.archunit.lang.EvaluationResult;
|
||||
import dev.caskeleton.bootstrap.architecture.allowed.slice.SingleSliceWebMvcFixture;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.fixtureleak.LeakyProductionConsumerFixture;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.fixtureleak.fixtures.LeakedTestFixture;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.slice.MixedSliceAnnotationsFixture;
|
||||
import dev.caskeleton.bootstrap.taxonomyfixtures.TestcontainersUsingFixture;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Test taxonomy and fixture-contract ArchUnit rules (feature-test-taxonomy-fixture-contract §테스트 계약
|
||||
* #4 / D3 / D6 / D7).
|
||||
*
|
||||
* <p>Uses the <em>manual-importer</em> pattern (plain {@code @Test} + {@code ClassFileImporter})
|
||||
* rather than {@code @AnalyzeClasses}, because the rules here are <em>about</em> test classes. The
|
||||
* {@code @AnalyzeClasses} suites in this package all use {@code ImportOption.DoNotIncludeTests} and
|
||||
* therefore cannot see test classes; the manual importer is the only way to load test bytecode.
|
||||
*
|
||||
* <h2>Import strategy — determinism over auto-vacuity</h2>
|
||||
*
|
||||
* <p>Positive controls and over-block guards import their target by an explicit {@code
|
||||
* importClasses(SomeFixture.class)} <em>class literal</em>. This is deterministic: it reads a
|
||||
* specific class file and never enumerates a package against the thread-context classloader. {@code
|
||||
* importPackages(String)} (used only for the two clean-checks below) was observed to return an
|
||||
* empty corpus under an inconsistent incremental build — which would make a positive control flake
|
||||
* and, worse, make a clean-check pass <em>vacuously</em>. To defend against the latter, every
|
||||
* package-scan clean-check first asserts its corpus is non-empty, so a guard can never silently
|
||||
* stop enforcing.
|
||||
*/
|
||||
class TestTaxonomyArchitectureTest {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Clean-check corpora — real contract / architecture test trees (package scan).
|
||||
// Guarded for non-vacuity in each test so an empty scan fails loudly, never silently.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static final JavaClasses CONTRACT_TESTS =
|
||||
new ClassFileImporter().importPackages("dev.caskeleton.bootstrap.contract");
|
||||
|
||||
private static final JavaClasses ARCHITECTURE_TESTS =
|
||||
new ClassFileImporter().importPackages("dev.caskeleton.bootstrap.architecture");
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Positive-control / over-block corpora — deterministic class-literal imports.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Holds an org.testcontainers field type — proves the TC-ban rule is non-vacuous. */
|
||||
private static final JavaClasses TESTCONTAINERS_USING_FIXTURE =
|
||||
new ClassFileImporter().importClasses(TestcontainersUsingFixture.class);
|
||||
|
||||
/**
|
||||
* @WebMvcTest + @DataJpaTest on one class — proves the slice-mixing rule fires.
|
||||
*/
|
||||
private static final JavaClasses SLICE_VIOLATION_FIXTURE =
|
||||
new ClassFileImporter().importClasses(MixedSliceAnnotationsFixture.class);
|
||||
|
||||
/**
|
||||
* @WebMvcTest only — over-block guard for the slice-mixing rule.
|
||||
*/
|
||||
private static final JavaClasses SLICE_ALLOWED_FIXTURE =
|
||||
new ClassFileImporter().importClasses(SingleSliceWebMvcFixture.class);
|
||||
|
||||
/** A non-fixture class depending on a {@code ..fixtures..} class — proves the leak rule fires. */
|
||||
private static final JavaClasses FIXTURE_LEAK_VIOLATION_CORPUS =
|
||||
new ClassFileImporter()
|
||||
.importClasses(LeakyProductionConsumerFixture.class, LeakedTestFixture.class);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Task 2 — §테스트 계약 #4 / D3: Testcontainers ban for contract + architecture level
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* ArchUnit rule: contract-level and architecture-level test classes must not depend on
|
||||
* Testcontainers. Real-service tests belong to the integration level ({@code
|
||||
* ..bootstrap.integration..}).
|
||||
*
|
||||
* <p>Package-visible field (no {@code @ArchTest}) because this class uses the manual-importer
|
||||
* pattern; {@code @ArchTest} wiring only fires under {@code @AnalyzeClasses}.
|
||||
*/
|
||||
static final ArchRule CONTRACT_AND_ARCHITECTURE_TESTS_DO_NOT_DEPEND_ON_TESTCONTAINERS =
|
||||
noClasses()
|
||||
.should()
|
||||
.dependOnClassesThat()
|
||||
.resideInAPackage("org.testcontainers..")
|
||||
.as(
|
||||
"feature-test-taxonomy-fixture-contract §테스트 계약 #4 / D3: unit·contract·"
|
||||
+ "architecture level tests must not depend on Testcontainers — real-service "
|
||||
+ "tests belong to the integration level (..bootstrap.integration..).")
|
||||
.allowEmptyShould(true);
|
||||
|
||||
@Test
|
||||
void contractLevelTestsHaveNoTestcontainersDependency() {
|
||||
// Non-vacuity guard: the contract tree must actually be scanned (never silently empty).
|
||||
assertThat(CONTRACT_TESTS.size())
|
||||
.as(
|
||||
"contract corpus (dev.caskeleton.bootstrap.contract) must be non-empty — an empty "
|
||||
+ "scan would make this clean-check pass vacuously (§테스트 계약 #4 / D3)")
|
||||
.isGreaterThan(0);
|
||||
|
||||
EvaluationResult result =
|
||||
CONTRACT_AND_ARCHITECTURE_TESTS_DO_NOT_DEPEND_ON_TESTCONTAINERS.evaluate(CONTRACT_TESTS);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"CONTRACT_AND_ARCHITECTURE_TESTS_DO_NOT_DEPEND_ON_TESTCONTAINERS must NOT fire "
|
||||
+ "on dev.caskeleton.bootstrap.contract — no Testcontainers usage after "
|
||||
+ "Task 1 reclassification (§테스트 계약 #4 / D3)")
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void architectureLevelTestsHaveNoTestcontainersDependency() {
|
||||
assertThat(ARCHITECTURE_TESTS.size())
|
||||
.as(
|
||||
"architecture corpus (dev.caskeleton.bootstrap.architecture) must be non-empty — an "
|
||||
+ "empty scan would make this clean-check pass vacuously (§테스트 계약 #4 / D3)")
|
||||
.isGreaterThan(0);
|
||||
|
||||
EvaluationResult result =
|
||||
CONTRACT_AND_ARCHITECTURE_TESTS_DO_NOT_DEPEND_ON_TESTCONTAINERS.evaluate(
|
||||
ARCHITECTURE_TESTS);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"CONTRACT_AND_ARCHITECTURE_TESTS_DO_NOT_DEPEND_ON_TESTCONTAINERS must NOT fire "
|
||||
+ "on dev.caskeleton.bootstrap.architecture — no Testcontainers usage "
|
||||
+ "(§테스트 계약 #4 / D3)")
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void banRuleFiresOnTestcontainersUsage() {
|
||||
// Positive control / non-vacuity proof: a class with an org.testcontainers field type must
|
||||
// be flagged. Deterministic importClasses — no package enumeration.
|
||||
EvaluationResult result =
|
||||
CONTRACT_AND_ARCHITECTURE_TESTS_DO_NOT_DEPEND_ON_TESTCONTAINERS.evaluate(
|
||||
TESTCONTAINERS_USING_FIXTURE);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"CONTRACT_AND_ARCHITECTURE_TESTS_DO_NOT_DEPEND_ON_TESTCONTAINERS MUST fire on "
|
||||
+ "TestcontainersUsingFixture (declares a PostgreSQLContainer field) — proves the "
|
||||
+ "rule is non-vacuous (§테스트 계약 #4 / D3 non-vacuity guard)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Task 3 — D7 / SB-SLICE-C2: Spring slice annotation mixing ban
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* ArchUnit rule: a test class must not carry two Spring slice annotations ({@code @WebMvcTest} +
|
||||
* {@code @DataJpaTest}). Spring documents mixing slice annotations as not supported
|
||||
* (SB-SLICE-C2).
|
||||
*
|
||||
* <p>Annotations referenced by FQN string — consistent with {@link
|
||||
* DisabledAdapterArchitectureTest}.
|
||||
*/
|
||||
static final ArchRule SLICE_TESTS_DO_NOT_MIX_TWO_SPRING_SLICE_ANNOTATIONS =
|
||||
noClasses()
|
||||
.that()
|
||||
.areAnnotatedWith("org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest")
|
||||
.should()
|
||||
.beAnnotatedWith("org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest")
|
||||
.as(
|
||||
"feature-test-taxonomy-fixture-contract D7 / SB-SLICE-C2: a test class must not "
|
||||
+ "combine two Spring slice annotations (@WebMvcTest + @DataJpaTest) — Spring "
|
||||
+ "documents mixing slice annotations as not supported.")
|
||||
.allowEmptyShould(true);
|
||||
|
||||
@Test
|
||||
void sliceMixingRuleFiresOnDualAnnotatedClass() {
|
||||
EvaluationResult result =
|
||||
SLICE_TESTS_DO_NOT_MIX_TWO_SPRING_SLICE_ANNOTATIONS.evaluate(SLICE_VIOLATION_FIXTURE);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"SLICE_TESTS_DO_NOT_MIX_TWO_SPRING_SLICE_ANNOTATIONS MUST fire on "
|
||||
+ "MixedSliceAnnotationsFixture (@WebMvcTest + @DataJpaTest) "
|
||||
+ "(D7 / SB-SLICE-C2 positive control)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sliceMixingRuleDoesNotFlagSingleSliceClass() {
|
||||
EvaluationResult result =
|
||||
SLICE_TESTS_DO_NOT_MIX_TWO_SPRING_SLICE_ANNOTATIONS.evaluate(SLICE_ALLOWED_FIXTURE);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"SLICE_TESTS_DO_NOT_MIX_TWO_SPRING_SLICE_ANNOTATIONS must NOT fire on "
|
||||
+ "SingleSliceWebMvcFixture (@WebMvcTest only) "
|
||||
+ "(D7 / SB-SLICE-C2 over-block guard)")
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Task 4 — D6: fixture main-classpath leakage guard (meta-test)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void fixtureLeakRuleFiresOnProductionDependingOnFixture() {
|
||||
// Evaluates the @ArchTest rule from CleanArchitectureTest against a deterministic
|
||||
// corpus: LeakyProductionConsumerFixture (outside ..fixtures..) depends on
|
||||
// LeakedTestFixture (in ..fixtures..). Proves the production-side guard fires.
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.PRODUCTION_CODE_DOES_NOT_DEPEND_ON_TEST_FIXTURES.evaluate(
|
||||
FIXTURE_LEAK_VIOLATION_CORPUS);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"PRODUCTION_CODE_DOES_NOT_DEPEND_ON_TEST_FIXTURES MUST fire on "
|
||||
+ "LeakyProductionConsumerFixture → LeakedTestFixture "
|
||||
+ "(D6 fixture-leak positive control)")
|
||||
.isTrue();
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.caskeleton.bootstrap.architecture.allowed.application;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Over-block guard fixture for {@code QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES}
|
||||
* (feature-application-query-bypass-contract D1).
|
||||
*
|
||||
* <p>A clean {@code *QueryPort} in an {@code ..application..} package that returns only an
|
||||
* application/JDK projection shape ({@code List<String>}). The D1 purity rule must NOT flag it —
|
||||
* proving the rule guards purity without forbidding legitimate projection reads (no over-block).
|
||||
* Lives under {@code ..allowed.application..} so it is loaded only by the isolated over-block
|
||||
* corpus, never the production scan.
|
||||
*/
|
||||
public interface CleanProjectionQueryPort {
|
||||
|
||||
List<String> findTitles();
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* "Allowed-as-data" over-block guard fixtures for the feature-application-query-bypass-contract D1
|
||||
* purity rule. Classes here are LEGITIMATE read/query ports that the {@code
|
||||
* QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES} rule must NOT flag — loaded in isolation by
|
||||
* {@link dev.caskeleton.bootstrap.architecture.ArchitectureViolationFixtureTest} to prove the rule
|
||||
* does not over-block clean projection reads.
|
||||
*/
|
||||
package dev.caskeleton.bootstrap.architecture.allowed.application;
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.caskeleton.bootstrap.architecture.allowed.contractisolation.contract;
|
||||
|
||||
import dev.caskeleton.bootstrap.architecture.allowed.contractisolation.features.sample.SampleFeatureFixture;
|
||||
|
||||
/**
|
||||
* Over-block guard fixture: a class in a {@code ..contract..} package that depends only on the
|
||||
* allowed {@code ..features.sample..} type.
|
||||
*
|
||||
* <p>This proves the contract isolation rule does not over-block legitimate sample-fixture usage. A
|
||||
* contract test depending on {@link SampleFeatureFixture} (in {@code ..features.sample..}) must NOT
|
||||
* be flagged by {@code CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN}.
|
||||
*
|
||||
* <p>No {@code @Test} methods; never executed by JUnit. Loaded in bytecode-only form by {@link
|
||||
* dev.caskeleton.bootstrap.architecture.ContractSuiteIsolationArchTest} via {@link
|
||||
* com.tngtech.archunit.core.importer.ClassFileImporter#importClasses}.
|
||||
*
|
||||
* <p>(feature-contract-verification-test-suite 테스트 계약 §1)
|
||||
*/
|
||||
public class SampleFeatureUsingContractFixture {
|
||||
|
||||
// The field reference creates the bytecode dependency edge that ArchUnit detects.
|
||||
@SuppressWarnings("unused")
|
||||
private final SampleFeatureFixture sampleFeature = new SampleFeatureFixture();
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* "Allowed-as-data" over-block guard fixture for the contract isolation rule ({@code
|
||||
* CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN}).
|
||||
*
|
||||
* <p>This package simulates a contract test that depends only on the allowed {@code
|
||||
* ..features.sample..} fixture type. Classes here must NOT be flagged by the isolation rule,
|
||||
* proving the rule correctly exempts sample-fixture dependencies.
|
||||
*
|
||||
* <p>These fixtures have NO {@code @Test} methods and are never executed by JUnit. They are loaded
|
||||
* in bytecode-only form by {@link
|
||||
* dev.caskeleton.bootstrap.architecture.ContractSuiteIsolationArchTest} via {@link
|
||||
* com.tngtech.archunit.core.importer.ClassFileImporter#importClasses} and are excluded from the
|
||||
* production {@code @AnalyzeClasses} suite via {@code ImportOption.DoNotIncludeTests} (test-only
|
||||
* source tree).
|
||||
*
|
||||
* <p>(feature-contract-verification-test-suite 테스트 계약 §1)
|
||||
*/
|
||||
package dev.caskeleton.bootstrap.architecture.allowed.contractisolation.contract;
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.caskeleton.bootstrap.architecture.allowed.contractisolation.features.sample;
|
||||
|
||||
/**
|
||||
* Stand-in for the single allowed exemption in the contract isolation rule: a {@code
|
||||
* ..features.sample..} fixture type that contract tests ARE permitted to depend on.
|
||||
*
|
||||
* <p>This class lives under a {@code ..features.sample..} package, making it the over-block guard
|
||||
* target for the rule ({@code CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN}). A
|
||||
* contract test depending on this class must NOT be flagged by the rule.
|
||||
*
|
||||
* <p>No behavior is needed — only its package location matters for the bytecode-based ArchUnit
|
||||
* analysis. No {@code @Test} methods; never executed.
|
||||
*
|
||||
* <p>(feature-contract-verification-test-suite 테스트 계약 §1)
|
||||
*/
|
||||
public class SampleFeatureFixture {
|
||||
// Empty — only its package location matters for ArchUnit dependency analysis.
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* "Allowed-as-data" over-block guard fixture for the contract isolation rule ({@code
|
||||
* CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN}).
|
||||
*
|
||||
* <p>This package simulates the single allowed exemption: a {@code ..features.sample..} fixture
|
||||
* package. Contract tests depending on classes here must NOT be flagged by the isolation rule,
|
||||
* proving the rule does not over-block legitimate sample-fixture usage.
|
||||
*
|
||||
* <p>These fixtures have NO {@code @Test} methods and are never executed by JUnit. They are loaded
|
||||
* in bytecode-only form by {@link
|
||||
* dev.caskeleton.bootstrap.architecture.ContractSuiteIsolationArchTest} via {@link
|
||||
* com.tngtech.archunit.core.importer.ClassFileImporter#importClasses} and are excluded from the
|
||||
* production {@code @AnalyzeClasses} suite via {@code ImportOption.DoNotIncludeTests} (test-only
|
||||
* source tree).
|
||||
*
|
||||
* <p>(feature-contract-verification-test-suite 테스트 계약 §1)
|
||||
*/
|
||||
package dev.caskeleton.bootstrap.architecture.allowed.contractisolation.features.sample;
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package dev.caskeleton.bootstrap.architecture.allowed.slice;
|
||||
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
|
||||
/**
|
||||
* Allowed fixture: a class annotated with only {@code @WebMvcTest} (single slice).
|
||||
*
|
||||
* <p>This fixture is the over-block guard for the ArchUnit rule {@code
|
||||
* SLICE_TESTS_DO_NOT_MIX_TWO_SPRING_SLICE_ANNOTATIONS}: a class carrying exactly one Spring slice
|
||||
* annotation must NOT be flagged. It has NO {@code @Test} methods and is never executed by JUnit —
|
||||
* only its bytecode is read by {@link com.tngtech.archunit.core.importer.ClassFileImporter}.
|
||||
*
|
||||
* <p>(feature-test-taxonomy-fixture-contract D7 / SB-SLICE-C2 over-block guard)
|
||||
*
|
||||
* <p>Public so the meta-test can reference it via {@code importClasses(...)} from the sibling
|
||||
* {@code ..architecture} package — a deterministic import that does not depend on
|
||||
* classpath/classloader package enumeration.
|
||||
*/
|
||||
@WebMvcTest
|
||||
public class SingleSliceWebMvcFixture {
|
||||
// Legitimate single-slice test class. No test methods — loaded as bytecode only.
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* "Allowed-as-data" over-block guard fixtures for the Spring slice annotation mixing ban ({@code
|
||||
* SLICE_TESTS_DO_NOT_MIX_TWO_SPRING_SLICE_ANNOTATIONS}).
|
||||
*
|
||||
* <p>Classes in this package carry only a single Spring slice annotation and must NOT be flagged by
|
||||
* the rule. They exist to prove the rule does not over-block legitimate single-slice test classes.
|
||||
*
|
||||
* <p>These fixtures have NO {@code @Test} methods and are never executed by JUnit. They are loaded
|
||||
* in bytecode-only form by {@link
|
||||
* dev.caskeleton.bootstrap.architecture.TestTaxonomyArchitectureTest} via {@link
|
||||
* com.tngtech.archunit.core.importer.ClassFileImporter#importPackages} and are excluded from the
|
||||
* production {@code @AnalyzeClasses} suite via {@code ImportOption.DoNotIncludeTests}.
|
||||
*/
|
||||
package dev.caskeleton.bootstrap.architecture.allowed.slice;
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package dev.caskeleton.bootstrap.architecture.allowed.streaming;
|
||||
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
/**
|
||||
* Over-block guard fixture for the D3 streaming ban rules (feature-streaming-response-contract D3,
|
||||
* spec Claim #3).
|
||||
*
|
||||
* <p>{@link StreamingResponseBody} is explicitly <em>not</em> blocked by D3. It is the large-file
|
||||
* download / chunked-body mechanism (request-response model, SPRING-ASYNC-C2 "for example, for a
|
||||
* file download") owned by feature-file-resource-handling-contract D8. Blocking it would break file
|
||||
* downloads — an OUT_OF_BRANCH_SCOPE regression.
|
||||
*
|
||||
* <p>This fixture is loaded in isolation against the D3 ArchUnit rules to assert that {@code
|
||||
* NO_SSE_EMITTER}, {@code NO_RESPONSE_BODY_EMITTER}, and {@code NO_WEBSOCKET_HANDLER} all return
|
||||
* {@code hasViolation() == false} when only {@code StreamingResponseBody} is referenced.
|
||||
*/
|
||||
public class StreamingResponseBodyAllowedFixture {
|
||||
|
||||
public StreamingResponseBody allowed() {
|
||||
return outputStream -> outputStream.write("data".getBytes());
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* "Allowed-as-data" guard fixtures used by {@link
|
||||
* dev.caskeleton.bootstrap.architecture.ArchitectureViolationFixtureTest}.
|
||||
*
|
||||
* <p>Each class in this package references a type that the D3 streaming ban rules must <em>not</em>
|
||||
* catch — proving that the rules do not over-block. The primary guard is {@link
|
||||
* dev.caskeleton.bootstrap.architecture.allowed.streaming.StreamingResponseBodyAllowedFixture},
|
||||
* which references {@code StreamingResponseBody} (large-file download, request-response model,
|
||||
* owned by feature-file-resource-handling-contract D8 — OUT_OF_BRANCH_SCOPE for
|
||||
* feature-streaming-response-contract D3).
|
||||
*
|
||||
* <p>These fixtures are loaded in isolation by {@link
|
||||
* com.tngtech.archunit.core.importer.ClassFileImporter#importPackages} and asserted to produce
|
||||
* {@code hasViolation() == false} against the D3 rules. They live under {@code src/test/java/...}
|
||||
* and are excluded from the production {@code @AnalyzeClasses} suite via {@code DoNotIncludeTests}.
|
||||
*/
|
||||
package dev.caskeleton.bootstrap.architecture.allowed.streaming;
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.caskeleton.bootstrap.architecture.violations.adapter.inbound.web.controller;
|
||||
|
||||
import dev.caskeleton.bootstrap.architecture.violations.domain.entity.FakeDomainEntity;
|
||||
|
||||
/**
|
||||
* Negative fixture for {@code CONTROLLERS_DO_NOT_RETURN_DOMAIN_OR_ENTITY_TYPES}. The class is in
|
||||
* {@code ..adapter.inbound.web..controller..} (matched by the rule's "declared in" clause) and
|
||||
* returns a class in {@code ..domain.entity..} — exactly the leak the contract forbids.
|
||||
*/
|
||||
public class DomainReturningControllerFixture {
|
||||
|
||||
public FakeDomainEntity readSomething() {
|
||||
return new FakeDomainEntity();
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package dev.caskeleton.bootstrap.architecture.violations.adapter.inbound.web.controller;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
/**
|
||||
* Negative fixture for {@code CONTROLLER_REQUEST_MAPPINGS_FOLLOW_AIP122} (D19). The class is in
|
||||
* {@code ..adapter.inbound.web..controller..} and maps kebab-case path segments ({@code
|
||||
* /work-logs}, {@code /repo-stats}) — exactly the naming the AIP-122 contract forbids
|
||||
* (feature-api-contract-baseline D19).
|
||||
*/
|
||||
@RequestMapping("/work-logs")
|
||||
public class KebabPathControllerFixture {
|
||||
|
||||
@GetMapping("/repo-stats")
|
||||
public String kebabSegment() {
|
||||
return "violation";
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package dev.caskeleton.bootstrap.architecture.violations.adapter.inbound.web.dto;
|
||||
|
||||
/**
|
||||
* Lives in {@code ..adapter.inbound.web..dto..} so the {@code
|
||||
* APPLICATION_METHODS_DO_NOT_ACCEPT_WEB_DTOS} ArchUnit rule treats any application method that
|
||||
* takes it as a parameter as a violation.
|
||||
*/
|
||||
public record FakeRequestDto(String value) {}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package dev.caskeleton.bootstrap.architecture.violations.adapter.inbound.web.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
/**
|
||||
* Negative fixture for {@code REQUEST_DTOS_DO_NOT_SILENCE_UNKNOWN_FIELDS} (B1).
|
||||
*
|
||||
* <p>Annotated with {@code @JsonIgnoreProperties(ignoreUnknown = true)} at the class level —
|
||||
* exactly the pattern the contract forbids because it cancels the {@code
|
||||
* FAIL_ON_UNKNOWN_PROPERTIES=true} boundary policy for this DTO alone, masking client-side contract
|
||||
* drift.
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record JsonIgnoreUnknownRequestFixture(String value) {}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package dev.caskeleton.bootstrap.architecture.violations.adapter.inbound.web.dto;
|
||||
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
|
||||
/**
|
||||
* Negative fixture for {@code NO_MERGE_PATCH_JSON_MEDIA_TYPE_STRING}. Uses the RFC 7396 {@code
|
||||
* application/merge-patch+json} content type that B2 rejects.
|
||||
*/
|
||||
public class MergePatchJsonFixture {
|
||||
|
||||
@PostMapping(consumes = "application/merge-patch+json")
|
||||
public void forbidden() {}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package dev.caskeleton.bootstrap.architecture.violations.adapter.inbound.web.dto.cascade;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
/**
|
||||
* Negative fixture for {@code VALID_CASCADE_DEPTH_AT_MOST_THREE} — chained {@code @Valid} fields go
|
||||
* four levels deep, one over the B4 ceiling.
|
||||
*/
|
||||
public class DeepCascadeRequestFixture {
|
||||
|
||||
@Valid public Level1 level1;
|
||||
|
||||
public static class Level1 {
|
||||
@Valid public Level2 level2;
|
||||
}
|
||||
|
||||
public static class Level2 {
|
||||
@Valid public Level3 level3;
|
||||
}
|
||||
|
||||
public static class Level3 {
|
||||
@Valid public Level4 level4;
|
||||
}
|
||||
|
||||
public static class Level4 {
|
||||
public String leaf;
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package dev.caskeleton.bootstrap.architecture.violations.adapter.outbound;
|
||||
|
||||
/**
|
||||
* Stands in for a raw external response wire shape. Lives in {@code ..adapter.outbound..} — exactly
|
||||
* where the ACL contract says raw types must not escape from.
|
||||
*/
|
||||
public class RawExternalResponseFixture {}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package dev.caskeleton.bootstrap.architecture.violations.adapter.outbound;
|
||||
|
||||
/**
|
||||
* Negative fixture for {@code OUTBOUND_ADAPTER_METHOD_RETURNS_ONLY_DOMAIN_OR_PRIMITIVES}. Returns a
|
||||
* class still inside the outbound adapter package — the ACL bypass the contract forbids (B7).
|
||||
*/
|
||||
public class RawTypeLeakingAdapterFixture {
|
||||
|
||||
public RawExternalResponseFixture leakRaw() {
|
||||
return new RawExternalResponseFixture();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user