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>
176 lines
15 KiB
XML
176 lines
15 KiB
XML
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1844" viewBox="0 0 1200 1844" role="img">
|
|
<title>terminal evidence</title>
|
|
<desc>Terminal-style rendering generated from retained command output. Sensitive-looking values are redacted in the visual asset.</desc>
|
|
<rect x="1" y="1" width="1198" height="1842" rx="14" fill="#0d1117" stroke="#30363d"/>
|
|
<rect x="1" y="1" width="1198" height="44" rx="14" fill="#161b22"/>
|
|
<rect x="1" y="30" width="1198" height="14" fill="#161b22"/>
|
|
<circle cx="24" cy="22" r="6" fill="#ff5f57"/>
|
|
<circle cx="44" cy="22" r="6" fill="#febc2e"/>
|
|
<circle cx="64" cy="22" r="6" fill="#28c840"/>
|
|
<text x="92" y="27" fill="#8b949e" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">terminal evidence</text>
|
|
<text x="24" y="68" fill="#c9d1d9" font-size="15" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">$ set -e
|
|
set -o pipefail
|
|
D=$(mktemp -d); trap 'rm -rf "$D"' EXIT
|
|
C=/shared/codebase/clean-architecture-backend-template
|
|
</tspan><tspan x="24" dy="22">M=$C/src/sample-portfolio
|
|
CACHE=/root/.gradle/caches/modules-2/files-2.1
|
|
java -version 2>&1 | grep version
|
|
cp=""
|
|
while I</tspan><tspan x="24" dy="22">FS= read -r line; do
|
|
coord=${line%%=*}; confs=${line#*=}
|
|
case ",$confs," in *",testRuntimeClasspath,"*) ;; *) contin</tspan><tspan x="24" dy="22">ue ;; esac
|
|
g=${coord%%:*}; rest=${coord#*:}; n=${rest%%:*}; v=${rest##*:}
|
|
# 잠금 파일의 좌표 하나가 캐시에 없다. 없</tspan><tspan x="24" dy="22">는 것은 건너뛴다.
|
|
jar=$(find "$CACHE/$g/$n/$v" -name '*.jar' ! -name '*sources*' ! -name '*javadoc*' 2>/dev/null | hea</tspan><tspan x="24" dy="22">d -1) || true
|
|
[ -n "$jar" ] && cp="$cp:$jar"
|
|
done < <(grep -E '^[a-zA-Z0-9._-]+:[^=]+=' "$M/gradle.lockfile")
|
|
# 형제 </tspan><tspan x="24" dy="22">모듈은 고정 리비전 산출물만 붙인다.
|
|
# sample-portfolio 는 고정 리비전에 -plain 만 있으므로 둘 다 받는다.
|
|
SIB=$(find "$C/</tspan><tspan x="24" dy="22">src" -path '*/build/libs/*+21234e38cdb9*.jar' ! -name '*-testkit.jar' | sort | tr '\n' ':')
|
|
cat > "$D/CacheConditionalPr</tspan><tspan x="24" dy="22">obe.java" <<'JAVA'
|
|
import dev.caskeleton.adapter.inbound.web.filter.CacheControlFilter;
|
|
import dev.caskeleton.applicatio</tspan><tspan x="24" dy="22">n.transaction.TransactionPort;
|
|
import dev.caskeleton.sample.portfolio.adapter.inbound.web.controller.WorkLogController;
|
|
</tspan><tspan x="24" dy="22">import dev.caskeleton.sample.portfolio.application.worklog.GetWorkLogUseCase;
|
|
import dev.caskeleton.sample.portfolio.dom</tspan><tspan x="24" dy="22">ain.worklog.Period;
|
|
import dev.caskeleton.sample.portfolio.domain.worklog.WorkCategory;
|
|
import dev.caskeleton.sample.por</tspan><tspan x="24" dy="22">tfolio.domain.worklog.WorkLog;
|
|
import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogId;
|
|
import dev.caskeleton.sa</tspan><tspan x="24" dy="22">mple.portfolio.domain.worklog.WorkLogPage;
|
|
import dev.caskeleton.sample.portfolio.domain.worklog.WorkLogRepository;
|
|
impo</tspan><tspan x="24" dy="22">rt dev.caskeleton.sample.portfolio.domain.worklog.WorkLogSortField;
|
|
import java.time.LocalDate;
|
|
import java.util.List;
|
|
i</tspan><tspan x="24" dy="22">mport java.util.Optional;
|
|
import java.util.function.Supplier;
|
|
import org.springframework.http.MediaType;
|
|
import org.spri</tspan><tspan x="24" dy="22">ngframework.http.ResponseEntity;
|
|
import org.springframework.security.web.header.HeaderWriterFilter;
|
|
import org.springfra</tspan><tspan x="24" dy="22">mework.security.web.header.writers.CacheControlHeadersWriter;
|
|
import org.springframework.test.web.servlet.MockMvc;
|
|
impor</tspan><tspan x="24" dy="22">t org.springframework.test.web.servlet.MvcResult;
|
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuil</tspan><tspan x="24" dy="22">ders;
|
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
|
import org.springframework.web.bind.annotation.</tspan><tspan x="24" dy="22">GetMapping;
|
|
import org.springframework.web.bind.annotation.RestController;
|
|
|
|
/** 캐시 필터와 조건부 읽기 경로를 한 사슬에</tspan><tspan x="24" dy="22"> 올려 응답 헤더를 읽는다. */
|
|
public final class CacheConditionalProbe {
|
|
|
|
private static final String ID = "0190bd6e-7c</tspan><tspan x="24" dy="22">3e-7abc-8def-0123456789ab";
|
|
|
|
/** 저장된 표현 하나. version 3 이므로 ETag 는 W/"3" 이다. */
|
|
private static WorkLog sa</tspan><tspan x="24" dy="22">mple() {
|
|
return WorkLog.rehydrate(
|
|
WorkLogId.of(ID), "샘플", WorkCategory.DATABASE, "s", "c",
|
|
List.o</tspan><tspan x="24" dy="22">f("postgres"), List.of("https://example.com"),
|
|
new Period(LocalDate.of(2025, 1, 1), null), 3L);
|
|
}
|
|
|
|
/** 조회</tspan><tspan x="24" dy="22">만 하는 저장소. 나머지 연산은 이 프로브가 부르지 않는다. */
|
|
private static final class OneRow implements WorkLogReposi</tspan><tspan x="24" dy="22">tory {
|
|
@Override public WorkLog save(WorkLog workLog) { throw new UnsupportedOperationException(); }
|
|
@Override p</tspan><tspan x="24" dy="22">ublic Optional<WorkLog> findById(WorkLogId id) { return Optional.of(sample()); }
|
|
@Override public WorkLogPage findPa</tspan><tspan x="24" dy="22">ge(
|
|
int page, int size, WorkLogSortField sortField, boolean ascending, WorkCategory category) {
|
|
throw new </tspan><tspan x="24" dy="22">UnsupportedOperationException();
|
|
}
|
|
@Override public boolean existsById(WorkLogId id) { return true; }
|
|
@Overr</tspan><tspan x="24" dy="22">ide public void deleteById(WorkLogId id) { throw new UnsupportedOperationException(); }
|
|
}
|
|
|
|
/** 경계만 흉내 내고 그대</tspan><tspan x="24" dy="22">로 실행하는 트랜잭션 포트. */
|
|
private static final class Direct implements TransactionPort {
|
|
@Override public <T> </tspan><tspan x="24" dy="22">T inWrite(Supplier<T> action) { return action.get(); }
|
|
@Override public <T> T inRootWrite(Supplier<T> action) { retu</tspan><tspan x="24" dy="22">rn action.get(); }
|
|
@Override public <T> T inRead(Supplier<T> action) { return action.get(); }
|
|
@Override public <</tspan><tspan x="24" dy="22">T> T inNew(Supplier<T> action) { return action.get(); }
|
|
}
|
|
|
|
/** 스스로 캐시 지시자를 붙이는 대조용 엔드포인트. */
|
|
@</tspan><tspan x="24" dy="22">RestController
|
|
public static class OptsIn {
|
|
@GetMapping("/opts-in")
|
|
public ResponseEntity<String> get() {
|
|
</tspan><tspan x="24" dy="22"> return ResponseEntity.ok().header("Cache-Control", "max-age=60").body("ok");
|
|
}
|
|
}
|
|
|
|
private static void row(Stri</tspan><tspan x="24" dy="22">ng label, MvcResult result) {
|
|
System.out.printf(" %-24s %-6d %-46s %s%n", label,
|
|
result.getResponse().getSt</tspan><tspan x="24" dy="22">atus(),
|
|
String.valueOf(result.getResponse().getHeader("Cache-Control")),
|
|
String.valueOf(result.getRespon</tspan><tspan x="24" dy="22">se().getHeader("ETag")));
|
|
}
|
|
|
|
public static void main(String[] args) throws Exception {
|
|
GetWorkLogUseCase get = n</tspan><tspan x="24" dy="22">ew GetWorkLogUseCase(new OneRow(), new Direct());
|
|
WorkLogController controller =
|
|
new WorkLogController(null,</tspan><tspan x="24" dy="22"> null, null, null, get, null, null);
|
|
MockMvc mvc = MockMvcBuilders.standaloneSetup(controller, new OptsIn())
|
|
</tspan><tspan x="24" dy="22"> .addFilters(new CacheControlFilter())
|
|
.build();
|
|
|
|
System.out.println();
|
|
System.out.println("[조건부 읽기</tspan><tspan x="24" dy="22"> 경로에 세 번 요청한다]");
|
|
System.out.printf(" %-24s %-6s %-46s %s%n", "요청", "상태", "Cache-Control", "ETag");
|
|
</tspan><tspan x="24" dy="22"> row("첫 요청", mvc.perform(
|
|
MockMvcRequestBuilders.get("/worklogs/" + ID).accept(MediaType.APPLICATION_JSON))
|
|
</tspan><tspan x="24" dy="22"> .andReturn());
|
|
row("받은 태그를 되돌려", mvc.perform(
|
|
MockMvcRequestBuilders.get("/worklogs/" + ID)
|
|
</tspan><tspan x="24" dy="22"> .header("If-None-Match", "W/\"3\"").accept(MediaType.APPLICATION_JSON))
|
|
.andReturn());
|
|
row("맞지</tspan><tspan x="24" dy="22"> 않는 태그", mvc.perform(
|
|
MockMvcRequestBuilders.get("/worklogs/" + ID)
|
|
.header("If-None-Match", "W/</tspan><tspan x="24" dy="22">\"2\"").accept(MediaType.APPLICATION_JSON))
|
|
.andReturn());
|
|
|
|
System.out.println();
|
|
System.out.println("[</tspan><tspan x="24" dy="22">스스로 지시자를 붙이는 엔드포인트]");
|
|
row("직접 붙인 값", mvc.perform(MockMvcRequestBuilders.get("/opts-in")).andRet</tspan><tspan x="24" dy="22">urn());
|
|
|
|
// 이 컨트롤러가 사는 모듈은 SecurityConfig 를 스캔에서 빼므로 프레임워크 기록기가 사슬에 남는다.
|
|
MockM</tspan><tspan x="24" dy="22">vc withWriter = MockMvcBuilders.standaloneSetup(controller, new OptsIn())
|
|
.addFilters(new CacheControlFilter(),
|
|
</tspan><tspan x="24" dy="22"> new HeaderWriterFilter(java.util.List.of(new CacheControlHeadersWriter())))
|
|
.build();
|
|
MockMvc wr</tspan><tspan x="24" dy="22">iterOnly = MockMvcBuilders.standaloneSetup(controller, new OptsIn())
|
|
.addFilters(new HeaderWriterFilter(java.uti</tspan><tspan x="24" dy="22">l.List.of(new CacheControlHeadersWriter())))
|
|
.build();
|
|
|
|
System.out.println();
|
|
System.out.println("[프레</tspan><tspan x="24" dy="22">임워크 캐시 기록기가 사슬에 함께 있을 때]");
|
|
row("둘 다 있을 때", withWriter.perform(
|
|
MockMvcRequestBuilders</tspan><tspan x="24" dy="22">.get("/worklogs/" + ID).accept(MediaType.APPLICATION_JSON))
|
|
.andReturn());
|
|
row("기록기만 있을 때", writerOnl</tspan><tspan x="24" dy="22">y.perform(
|
|
MockMvcRequestBuilders.get("/worklogs/" + ID).accept(MediaType.APPLICATION_JSON))
|
|
.andReturn(</tspan><tspan x="24" dy="22">));
|
|
}
|
|
}
|
|
JAVA
|
|
javac -encoding UTF-8 -cp "$SIB$cp" -d "$D/out" "$D/CacheConditionalProbe.java" 2>&1 | grep -v '^$' || tr</tspan><tspan x="24" dy="22">ue
|
|
# 부팅 로그는 시각을 달고 나오므로 끈다. 이 프로브가 읽는 것은 응답 헤더뿐이다.
|
|
printf '%s\n' '<configuration><root l</tspan><tspan x="24" dy="22">evel="OFF"/></configuration>' > "$D/out/logback.xml"
|
|
java -Dstdout.encoding=UTF-8 -cp "$D/out:$SIB$cp" CacheConditionalP</tspan><tspan x="24" dy="22">robe
|
|
</tspan></text>
|
|
<text x="24" y="1432" fill="#8b949e" font-size="13" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">cwd: /shared/codebase/clean-architecture-backend-template/src</tspan></text>
|
|
<text x="24" y="1454" fill="#8b949e" font-size="13" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">time: 2026-09-03T00:50:44+00:00 · exit 0</tspan></text>
|
|
<line x1="24" y1="1468" x2="1176" y2="1468" stroke="#30363d"/>
|
|
<text x="24" y="1508" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">openjdk version "21.0.12" 2026-07-21</tspan></text>
|
|
<text x="24" y="1530" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"></tspan></text>
|
|
<text x="24" y="1552" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">[조건부 읽기 경로에 세 번 요청한다]</tspan></text>
|
|
<text x="24" y="1574" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"> 요청 상태 Cache-Control ETag</tspan></text>
|
|
<text x="24" y="1596" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"> 첫 요청 200 no-store W/"3"</tspan></text>
|
|
<text x="24" y="1618" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"> 받은 태그를 되돌려 304 no-store W/"3"</tspan></text>
|
|
<text x="24" y="1640" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"> 맞지 않는 태그 200 no-store W/"3"</tspan></text>
|
|
<text x="24" y="1662" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"></tspan></text>
|
|
<text x="24" y="1684" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">[스스로 지시자를 붙이는 엔드포인트]</tspan></text>
|
|
<text x="24" y="1706" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"> 직접 붙인 값 200 max-age=60 null</tspan></text>
|
|
<text x="24" y="1728" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"></tspan></text>
|
|
<text x="24" y="1750" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">[프레임워크 캐시 기록기가 사슬에 함께 있을 때]</tspan></text>
|
|
<text x="24" y="1772" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"> 둘 다 있을 때 200 no-store W/"3"</tspan></text>
|
|
<text x="24" y="1794" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"> 기록기만 있을 때 200 no-cache, no-store, max-age=0, must-revalidate W/"3"</tspan></text>
|
|
</svg>
|