The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
225 lines
12 KiB
Markdown
225 lines
12 KiB
Markdown
/shared/codebase/clean-architecture-backend-template/src/grpc-advanced/grpc-advanced-compat/src/main/resources/envoy/envoy.yaml
|
|
---
|
|
# grpc-advanced-compat 완전 해부
|
|
|
|
> 상태: COMPLETE
|
|
> 재오픈 게이트: cycle 2 — `src/main` production 17파일 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
|
|
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
|
> 분석 범위: `src/grpc-advanced/grpc-advanced-compat`
|
|
> SSOT owner: `grpc-advanced-compat`
|
|
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
|
|
|
|
---
|
|
|
|
## 0. SSOT identity / 커버리지
|
|
|
|
- `runtime_memberships`: **`[]`** — build-only
|
|
- 다섯 다리: gRPC-Web · Servlet · Spring Integration · Reactor · Kotlin
|
|
|
|
| 패키지 | 파일 | LOC |
|
|
|---|---:|---:|
|
|
| `web` | 4 | 220 |
|
|
| `kotlin` | 3 | 164 |
|
|
| `servlet` | 3 | 152 |
|
|
| `reactor` | 4 | 233 |
|
|
| `integration` | 3 | 193 |
|
|
|
|
### Coverage ledger
|
|
|
|
| scope | count | disposition | reason |
|
|
|---|---:|---|---|
|
|
| `main/java/**` | 17 | `FULL_READ` | 962줄 전 본문 |
|
|
| `main/resources/envoy/envoy.yaml` | 1 | `FULL_READ` | 70줄 전문. 참조 프록시 설정 — 이전 판의 ledger 에 아예 없었다 |
|
|
| `test/java/**` | 5 | `FULL_READ` | 523줄 |
|
|
| `build.gradle` | 1 | `FULL_READ` | 전문 |
|
|
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
|
|
|
`UNCLASSIFIED` 0.
|
|
|
|
---
|
|
|
|
## 1. 모듈의 정체와 코틀린 레인의 처리
|
|
|
|
```groovy
|
|
// build.gradle:6-10
|
|
// No Kotlin source set (adaptation D7): this repository has no Kotlin toolchain, so the Kotlin lane
|
|
// is expressed as a Java-side boundary contract whose compatibility gate fails closed until a real
|
|
// toolchain lane exists. Everything the gate would otherwise assert — one schema source, coroutine
|
|
// cancellation propagation, Flow backpressure inside the Stable buffer limits, evidence type
|
|
// preservation — is a checkable contract without it.
|
|
```
|
|
|
|
그리고 게이트가 그 판단을 코드로 반복한다.
|
|
|
|
> "Fails closed in this repository, and says so rather than reporting a pass it cannot justify…
|
|
> a gate that reported success anyway would put an unverified claim in the support matrix."
|
|
|
|
`blockers` 는 다섯 항목을 낸다. 넷은 프로파일에서 확인 가능하고, 다섯째가 툴체인 레인 부재다. `supportableHere()` 는 상수 거짓이다.
|
|
|
|
이 처리가 이 저장소의 다른 곳(`grpc-advanced-diagnostics` 의 인프라 테스트킷 계약)과 같은 원칙이다 — 인프라 없이 도는 묶음은 통과하고 아무것도 세우지 않는다.
|
|
|
|
## 2. 다리마다 무엇을 거절하는가
|
|
|
|
| 다리 | 거절 |
|
|
|---|---|
|
|
| gRPC-Web | 브라우저에 노출된 메서드 중 gRPC-Web 이 나를 수 없는 RPC 종류 |
|
|
| Servlet | 컨테이너가 제공하지 않는 전송 설정 요구 |
|
|
| Kotlin | 다섯 블로커(툴체인 레인 포함) |
|
|
| Spring Integration | 변환기 없는 다리, 허용 목록 밖 헤더 |
|
|
| Reactor | (§17.2) |
|
|
|
|
gRPC-Web 의 근거:
|
|
|
|
> "Two schemas — one for browsers, one for services — is how a field ends up meaning something
|
|
> different depending on which client asked, and the divergence is only visible to whoever reads
|
|
> both files."
|
|
|
|
Servlet 의 근거:
|
|
|
|
> "Refuses rather than warns, because the setting would otherwise be accepted and ignored. A
|
|
> keepalive configured on a Servlet deployment does nothing, the connections behave as the container
|
|
> decides, and the investigation starts from the assumption that the setting is in force."
|
|
|
|
그리고 Servlet 실행이 Netty 인증을 대신할 수 없다는 것을 상수로 못박는다.
|
|
|
|
## 3. Spring Integration 다리가 무엇을 약속하지 않는가
|
|
|
|
> "A Spring Integration `Message` accumulates headers as it moves through a flow — routing keys,
|
|
> correlation ids, errors channels, whatever a transformer added — and copying them onto gRPC
|
|
> metadata sends a service's internal plumbing across the network."
|
|
|
|
> "The bridge does not add durability. Spring Integration channels can look like a broker, and a
|
|
> bridge that implied acknowledgement or redelivery semantics would be promising something gRPC does
|
|
> not do."
|
|
|
|
변환기가 없는 다리는 생성자가 거부한다 — 반사에 맡기는 것이 예상 밖 타입이 유선에 닿는 경로다.
|
|
|
|
## 12. negative-space probes
|
|
|
|
**12.1 도달성.** Advanced 가족이므로 배선 경로가 없다. 그 위에 이 리프에는 테스트조차 없는 타입이 둘 있다(§17.2).
|
|
|
|
**12.2 대조군 — 메타데이터 경로 둘.** `grpc-client` 의 `GrpcClientMetadataPolicy.materialize` 는 허용 목록으로 거른 뒤 `budget.check(accepted)` 를 부른다. 이 리프의 `GrpcIntegrationBridgePolicy.metadataFrom` 은 허용 목록으로 거르고 예산을 부르지 않는다(§17.1).
|
|
|
|
**12.3 리프 전체의 외부 참조가 0 이다.** 재통독에서 다섯 패키지를 각각 확인했다.
|
|
|
|
```
|
|
dev.caskeleton.grpc.advanced.{web, servlet, integration, reactor, kotlin} → 리프 밖 참조 0
|
|
```
|
|
|
|
같은 Advanced 가족의 `grpc-advanced-bootstrap` 조차 이 리프의 타입을 하나도 부르지 않는다. Advanced 는 기능 플래그로 도달한다는 것이 이 가족의 규약인데, 그 플래그가 가리킬 대상이 배선되어 있지 않다.
|
|
|
|
**12.4 드리프트.** build.gradle 이 서술한 다섯 다리와 코틀린 레인의 처리 방식이 코드와 일치한다. 다만 ledger 가 `main/resources` 를 세지 않고 있었다(§17.3 의 재료가 거기 있다).
|
|
|
|
## 16. 확인하지 못한 것
|
|
|
|
- 실제 브라우저·프록시·서블릿 컨테이너로 어떤 다리도 돌리지 않았다. 그 인프라가 필요하다는 것이 이 가족의 기록이다.
|
|
- 코틀린 툴체인이 없으므로 코틀린 계약 넷을 실행으로 확인할 수 없다.
|
|
|
|
## 17. 손볼 것
|
|
|
|
### 17.1 P3 — 통합 다리의 메타데이터 조립이 메타데이터 예산을 검사하지 않는다
|
|
|
|
```java
|
|
public Map<GrpcMetadataKey, String> metadataFrom(Map<String, Object> messageHeaders) {
|
|
Map<GrpcMetadataKey, String> metadata = new LinkedHashMap<>();
|
|
headerAllowlist.forEach(key -> {
|
|
Object value = messageHeaders.get(key.name());
|
|
if (value != null) { metadata.put(key, String.valueOf(value)); }
|
|
});
|
|
return Map.copyOf(metadata);
|
|
}
|
|
```
|
|
|
|
허용 목록으로 키를 거르지만 값의 크기도, 합계도 보지 않는다.
|
|
|
|
클래스 javadoc 자신이 예산을 이 정책의 이유 중 하나로 든다 — 흐름의 내부 배관을 네트워크로 보내면 "it counts against the metadata budget."
|
|
|
|
그리고 같은 저장소의 다른 메타데이터 경로는 예산을 검사한다.
|
|
|
|
```java
|
|
// GrpcClientMetadataPolicy.materialize
|
|
proposed.forEach((key, value) -> { if (allowed.contains(key) && value != null) accepted.put(key, value); });
|
|
budget.check(accepted); // ← 이 줄이 이 다리에는 없다
|
|
```
|
|
|
|
`String.valueOf(value)` 이므로 헤더 값이 임의의 객체일 때 그 문자열 표현이 그대로 실린다. Spring Integration 헤더에는 컬렉션이나 도메인 객체가 흔히 들어가므로 값 하나가 클 수 있다.
|
|
|
|
수정은 이 record 에 `GrpcMetadataBudget` 를 성분으로 추가하고 `metadataFrom` 끝에서 검사하는 것이다. 형태가 이미 옆 리프에 있다.
|
|
|
|
### 17.2 P3 — 반응형 표면 두 타입은 테스트조차 없다
|
|
|
|
```
|
|
ReactiveGrpcClient 저장소 전체에서 등장하는 파일 1개 (자기 자신)
|
|
ReactiveGrpcServerAdapter 저장소 전체에서 등장하는 파일 1개 (자기 자신)
|
|
```
|
|
|
|
이 가족의 다른 미참조 Advanced 타입은 전부 테스트가 하나씩 있다 — 같은 패키지의 `GrpcReactorCancellationBridge` 는 2개 파일, `GrpcReactorContextBridge` 는 4개 파일에 등장한다.
|
|
|
|
두 타입은 채택자가 부를 표면이므로 production 참조 0 이 설계와 모순되지는 않는다. 어긋나는 것은 검증이다. 채택자용 표면이면 그 계약이 무엇인지를 테스트가 붙들어야 하고, 이 가족은 다른 곳에서 정확히 그렇게 한다.
|
|
|
|
`ReactiveGrpcClient` 의 javadoc 이 "Exposes a unary call as a `Mono` and a server stream as a `Flux`" 라고 적는데, 그 사상이 취소와 배압에서 어떻게 동작하는지는 어디에서도 확인되지 않는다. 같은 리프의 `GrpcReactorCancellationBridge` 가 취소 전파를 다루므로 둘을 함께 검증할 자리가 이미 있다.
|
|
|
|
### 17.3 P3 — 저장소가 참조 프록시 설정을 갖고 있는데, 그것을 판정할 코드에 넣지 않는다
|
|
|
|
이 리프에는 두 가지가 함께 있다.
|
|
|
|
- `GrpcWebProxyContract.violations(profile, exposedHeaders, allowedOrigins)` — 프록시 설정이 브라우저 클라이언트에게 통할지 판정하는 코드.
|
|
- `src/main/resources/envoy/envoy.yaml` — 그 설정의 참조 구현.
|
|
|
|
그리고 설정 파일 자신이 그 관계를 주장한다.
|
|
|
|
```yaml
|
|
# Shipped as a resource rather than as documentation prose because GrpcWebProxyContract asserts
|
|
# against it: the CORS allowlist, the exposed trailer headers and the TLS termination are the three
|
|
# things a browser client silently fails without, and a contract nobody checks is a contract that
|
|
# drifts from whatever is actually deployed.
|
|
```
|
|
|
|
`GrpcWebProxyContract` 는 이 파일에 대해 아무것도 단언하지 않는다. 판정기는 시험에서 리터럴 집합을 받고, 참조 설정은 시험에서 문자열 포함으로만 확인된다.
|
|
|
|
```java
|
|
// GrpcWebCompatibilityGateTest
|
|
assertThat(GrpcWebProxyContract.violations(profile, requiredExposedHeaders(), Set.of("*"))) // ← 리터럴
|
|
.anySatisfy(v -> assertThat(v).contains("defeats the profile's allowlist"));
|
|
…
|
|
String envoy = resource("envoy/envoy.yaml");
|
|
assertThat(envoy)
|
|
.contains("expose_headers: \"grpc-status,grpc-message") // ← 부분 문자열
|
|
.contains("exact: \"https://app.example.com\"");
|
|
```
|
|
|
|
그래서 참조 설정이 `grpc-status` 를 노출하는지는 문자열이 확인하고, 그 노출이 **충분한지** 는 `requiredExposedHeaders()` 가 정의하는데, 둘을 잇는 코드가 없다. 필수 트레일러 목록이 늘어나면 판정기는 새 항목을 요구하고 참조 설정은 옛 문자열로 계속 통과한다.
|
|
|
|
이 리프의 다른 판정기들과 다른 점은 재료가 이미 저장소에 있다는 것이다 — grpc-testkit §17.5·grpc-server §17.1 은 스캔할 대상 자체를 만들어야 하지만, 여기서는 파일 하나를 파싱하면 된다.
|
|
|
|
수정은 시험이 `envoy.yaml` 의 `expose_headers` 와 `allow_origin`(`exact:`)을 뽑아 `GrpcWebProxyContract.violations` 에 넣고 비어 있음을 단언하는 것이다. 그러면 참조 설정과 계약이 한 곳에서 함께 움직인다.
|
|
|
|
### 확인된 설계(문제 아님)
|
|
|
|
- **코틀린 게이트가 닫힌 실패를 하고 그 이유를 말하는 것** — 정당화할 수 없는 통과를 보고하지 않는다.
|
|
- **코틀린 계약 넷을 툴체인 없이도 확인 가능하게 만든 것** — 나중에 필요한 것은 레인 추가이지 계약 작성이 아니다.
|
|
- **브라우저와 기본 클라이언트가 한 스키마를 쓰게 한 것.**
|
|
- **Servlet 이 제공하지 않는 설정을 경고가 아니라 거절로 다룬 것.**
|
|
- **Servlet 실행이 Netty 인증을 대신하지 못한다고 못박은 것.**
|
|
- **통합 다리가 브로커 의미론을 약속하지 않는다고 상수로 밝힌 것.**
|
|
- **변환기 없는 다리를 생성자가 거부한 것.**
|
|
- **다리가 생성된 스텁·서비스 API 를 대체하지 않는다고 밝힌 것.**
|
|
|
|
---
|
|
|
|
## Source anchors
|
|
|
|
```
|
|
src/grpc-advanced/grpc-advanced-compat/build.gradle:1-14
|
|
main/java/…/kotlin/GrpcKotlinCompatibilityGate.java:1-69
|
|
main/java/…/web/GrpcWebCompatibilityGate.java:1-56
|
|
main/java/…/servlet/GrpcServletStartupValidator.java:1-51
|
|
main/java/…/integration/GrpcIntegrationBridgePolicy.java:1-78
|
|
main/java/…/reactor/(ReactiveGrpcClient · ReactiveGrpcServerAdapter · GrpcReactorCancellationBridge · GrpcReactorContextBridge)
|
|
main/java/…/web/(GrpcWebProfile · GrpcWebProxyContract · GrpcWebRpcSupport)
|
|
main/java/…/servlet/(GrpcServletCompatibilityProfile · GrpcServletCapabilityMatrix)
|
|
main/java/…/kotlin/(GrpcCoroutineContextBridge · GrpcKotlinProfile)
|
|
main/java/…/integration/(GrpcIntegrationInboundGateway · GrpcIntegrationOutboundGateway)
|
|
src/grpc/grpc-client/…/GrpcClientMetadataPolicy.java (예산 검사 대비)
|
|
```
|