fix(setup): 실험대를 새로 세워 setup 35편을 밟고 어긋난 명령과 결과를 고친다

기반 가이드 7단계로 실험대를 철거하고 다시 세운 뒤 virtualization setup 9편과
keycloak-session-store 26편을 순서대로 밟았다. 24편은 끝까지, 11편은 되는 데까지
밟았고 밟은 범위를 편마다 적었다.

명령이 못 도는 것을 고쳤다.

- kubectl 을 `kc-lab-1` 에서 치라고 적었는데 그 기계에 kubeconfig 가 없다.
  라벨 639개와 각 편의 「어디서 치는가」를 `[lab host]` 로 옮겼다
- `-o custom-columns=…[0]…` 이 zsh 에서 글로브로 읽혀 안 돈다. 28곳에 따옴표
- busybox `sed` 가 끝 개행을 안 붙여 A-3 의 측정이 언제나 0 이었다
- `--token-file ~/node-token` 뒤에 그 파일을 지우면 k3s agent 가 재부팅을
  못 견딘다. `/etc/rancher/node-token` 으로 옮기는 처방을 재서 넣었다
- 게스트에 없는 도구를 전제로 한 명령 넷 — `conntrack`·`dig`·`strings`·`nginx -v`
- `echo` 와 JWT 헤더가 `"이름" : [ 값 ]` 으로 찍는데 문서는 공백 없이 옮겨 적어
  그 실측으로 만든 grep·sed 가 한 줄도 못 잡는다
- B-0 이 `directAccessGrantsEnabled` 와 계정 완성을 빠뜨려 B-3 이 못 돈다
- D-4·D-4a 가 `test-server` 와 `certbot-renew.*` 를 가리키는데 실제로는
  `kc-lab-edge` 의 `certbot.service` 다
- `virsh setmaxmem --config` 를 `dominfo` 로 판정하면 틀린다. `--inactive` 로
- `LIBVIRT_DEFAULT_URI` 를 rc 에만 넣으면 `ssh host '명령'` 에서 안 먹는다

결과가 조건부인 것을 갈랐다.

- readiness 는 즉시 안 뒤집힌다. A-1·A-2 의 60초 창을 적었다
- 03 의 층 ②③ `301` 은 04 이후의 값이고 그 단계에서는 `404` 다
- A-0 의 로그 필터를 요청 직후에 치면 정반대 결론이 나온다
- A-5 의 한 방향 차단은 잠깐 `1` 이었다 `2` 로 돌아온다

증거는 두 프로젝트의 `evidence/raw/` 에 99벌을 README 와 함께 남겼다. 비밀은
길이만 적었고 화면에 찍힌 토큰은 가렸다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-17 15:59:42 +09:00
co-authored by Claude Opus 5
parent ab59130196
commit 024362d096
197 changed files with 8336 additions and 960 deletions
+124
View File
@@ -0,0 +1,124 @@
// 저장 계획을 브라우저 문맥 안에서 보낸다.
//
// **왜 브라우저 안인가.** 세션 쿠키가 httpOnly 라 밖으로 못 읽고, 읽어서 옮기는 것은
// 「비밀은 길이·존재 여부만」에 걸린다. `page.request` 는 그 브라우저 문맥의 쿠키를
// 그대로 쓰므로 값이 이 프로세스 밖으로 나가지 않는다.
//
// **왜 계획을 그대로 안 보내나.** `studio-save.py` 의 계획은 `projectId`·`topicId` 를
// `null` 로, `relations` 를 빈 배열로 낸다. 서버 저장은 부분 갱신이 아니라서 그대로
// 보내면 **그 세 칸이 지워진다.** 그래서 저장 직전에 GET 으로 읽어 그 셋과
// `variantIds` 는 서버 값을 쓰고, 기록이 갖는 칸만 계획 값으로 덮는다.
//
// **게시하지 않는다.** publish/unpublish 경로가 섞이면 그 편을 건너뛴다.
async (page) => {
// 이 문맥은 샌드박스라 require 도 동적 import 도 안 된다. 그래서 계획은
// 127.0.0.1 의 정적 서버에서 받아 온다 — page.request 는 CORS 를 안 탄다.
const SUB = 'plans';
const LOCAL = 'http://127.0.0.1:8731/' + SUB + '/';
const getLocal = async (n) => {
const r = await page.request.fetch(LOCAL + n);
if (!r.ok()) throw new Error('계획을 못 받았다: ' + n + ' ' + r.status());
return await r.json();
};
const files = await getLocal('_index.json');
// 기록이 소유하는 칸. 이것만 계획 값으로 덮는다.
const OWNED = new Set([
'kind', 'title', 'slug', 'summary',
'problem', 'conclusion', 'environment', 'reproduction', 'bodyMarkdown', 'lastVerifiedOn',
'basisVersion', 'pinnedVersions',
'purpose', 'rules', 'applyWhen', 'exceptions', 'examples', 'verifiedOn',
'facts', 'assumptions', 'unknowns', 'constraints', 'options', 'nextValidation',
'resolution', 'questionStatus',
]);
// 서버가 정본인 칸. 계획이 뭐라 하든 서버 값을 쓴다.
const SERVER_OWNED = ['projectId', 'topicId', 'relations', 'variantIds'];
const csrf = await page.evaluate(
() => document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/)?.[1] || null);
if (!csrf) return { fatal: 'XSRF-TOKEN 쿠키가 없다 — 로그인 상태가 아니다' };
const api = async (method, url, body, headers) => {
const r = await page.request.fetch('https://hyeonworks.com' + url, {
method,
headers: Object.assign({ accept: 'application/json' },
body ? { 'content-type': 'application/json' } : {},
headers || {}),
data: body ? JSON.stringify(body) : undefined,
});
let j = null; try { j = await r.json(); } catch (e) { /* 204 는 본문이 없다 */ }
return { status: r.status(), body: j };
};
const results = [];
for (const f of files) {
const plan = await getLocal(f);
const name = f.replace(/\.json$/, '');
const put = plan.requests.find(r => r.method === 'PUT');
if (!put) { results.push({ name, skipped: 'PUT 요청이 없다' }); continue; }
if (plan.requests.some(r => /\/(publish|unpublish)(\/|$)/.test(r.path))) {
results.push({ name, skipped: '게시 경로가 섞여 있다' }); continue;
}
const id = put.path.split('/').pop();
// ① 지금 상태를 읽는다
const cur = await api('GET', put.path);
if (cur.status !== 200) { results.push({ name, id, step: 'GET', status: cur.status }); continue; }
const server = cur.body.data.document;
const pub = cur.body.data.currentPublication;
if (pub && pub.status === 'PUBLISHED') {
results.push({ name, id, skipped: '게시된 문서다' }); continue;
}
// ② 합친다 — 서버 값을 살리고 기록이 갖는 칸만 덮는다
const planned = put.body.document;
const doc = {};
for (const [k, v] of Object.entries(planned)) if (OWNED.has(k)) doc[k] = v;
for (const k of SERVER_OWNED) doc[k] = server[k];
// ③ 보낸다
const res = await api('PUT', put.path,
{ expectedVersion: server.version, document: doc },
{ 'x-csrf-token': csrf,
'idempotency-key': (put.headers && put.headers['Idempotency-Key']) || ('send-' + id + '-' + Date.now()) });
if (res.status !== 200) {
results.push({ name, id, step: 'PUT', status: res.status,
code: res.body && res.body.error && res.body.error.code });
continue;
}
// ④ 되읽어 대조한다
const back = await api('GET', put.path);
const b = back.body.data.document;
results.push({
name, id, kind: server.kind,
status: 200,
versionBefore: server.version, versionAfter: b.version,
bodyMatches: (b.bodyMarkdown || '') === (doc.bodyMarkdown || ''),
titleMatches: b.title === doc.title,
summaryMatches: b.summary === doc.summary,
keptProject: b.projectId === server.projectId,
keptTopic: b.topicId === server.topicId,
keptRelations: (b.relations || []).length === (server.relations || []).length,
stillUnpublished: !back.body.data.currentPublication,
});
}
const ok = results.filter(r => r.status === 200);
return {
total: files.length,
saved: ok.length,
skipped: results.filter(r => r.skipped).length,
failed: results.filter(r => r.status && r.status !== 200).length,
bodyMismatch: ok.filter(r => !r.bodyMatches).length,
lostProject: ok.filter(r => !r.keptProject).length,
lostTopic: ok.filter(r => !r.keptTopic).length,
lostRelations: ok.filter(r => !r.keptRelations).length,
becamePublished: ok.filter(r => !r.stillUnpublished).length,
problems: results.filter(r => r.skipped || (r.status && r.status !== 200)
|| (r.status === 200 && (!r.bodyMatches || !r.keptProject
|| !r.keptTopic || !r.keptRelations || !r.stillUnpublished))),
};
}