기반 가이드 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>
125 lines
5.8 KiB
JavaScript
125 lines
5.8 KiB
JavaScript
// 저장 계획을 브라우저 문맥 안에서 보낸다.
|
|
//
|
|
// **왜 브라우저 안인가.** 세션 쿠키가 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))),
|
|
};
|
|
}
|