[verified] feat: add Webmaker visual editor v0.2.0
This commit is contained in:
+81
-6
@@ -3,6 +3,7 @@
|
||||
* app.mjs(라우트)와 jobs.mjs(작업 종류)가 함께 쓴다. 모든 함수는 deps(store·vault·config·log·jobs) 를 받는다.
|
||||
*/
|
||||
import { hostname } from 'node:os';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { newId, randomToken, sha256, hashPassword } from '../lib/crypto.mjs';
|
||||
import { nowIso, toJson, parseJson } from '../lib/db.mjs';
|
||||
import { audit } from '../lib/audit.mjs';
|
||||
@@ -10,8 +11,8 @@ import { notify } from '../lib/notify.mjs';
|
||||
import { redact, safeError } from '../lib/redact.mjs';
|
||||
import { serviceFetch } from '../lib/serviceauth.mjs';
|
||||
import { JobError } from '../lib/jobs.mjs';
|
||||
import { existsSync, statSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync, statSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
|
||||
import { join, basename } from 'node:path';
|
||||
import { syncFromHeartbeat } from './mailsvc.mjs';
|
||||
|
||||
export const EXTERNAL_KINDS = new Set(['site.deploy', 'site.remove', 'site.check', 'site.rollback']); // 외부 Agent 가 받을 수 있는 작업(§10.2)
|
||||
@@ -260,15 +261,89 @@ export async function awaitAgentJob(d, ctx, agentJobId, { timeoutMs = d.config.a
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 릴리스(§32.1)
|
||||
/** 키 순서에 무관한 비교용 JSON(릴리스 재등록의 "같은 내용" 판정) */
|
||||
function canonJson(v) { if (Array.isArray(v)) return '[' + v.map(canonJson).join(',') + ']'; if (v && typeof v === 'object') return '{' + Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canonJson(v[k])).join(',') + '}'; return JSON.stringify(v === undefined ? null : v); }
|
||||
/**
|
||||
* 릴리스 등록(upsert). 0.2.0 검토 수정 3차 — **승인된 버전은 불변**: 같은 내용(main 커밋·빌드 시각·artifact·checks, 메모는 준 경우에만)의 재등록은 멱등하게 그대로 돌려주고(approve 요청이어도 이미 승인),
|
||||
* 어느 하나라도 다른 재등록은 approve 여부와 무관하게 `release_immutable` 로 거절하며 아무것도 바꾸지 않는다(새 내용은 새 버전 번호로). 승인되지 않은 버전의 내용 변경 재등록은 내용을 갱신하되
|
||||
* 상태를 registered 로 두고(승인자·시각 비움) 승인을 물려받지 않는다 — approve 가 참이면 releaseApprovalProblem 규칙을 지나야만 승인된다. CLI(hal/index.mjs register-release·scripts/release.mjs register)도 이 함수를 쓴다.
|
||||
*/
|
||||
/** 승인된 릴리스를 다른 내용으로 바꾸려는 등록인지(같은 내용이면 null). registerRelease·installArtifacts 가 같은 규칙을 쓴다 */
|
||||
export function releaseImmutableProblem(ex, { mainCommit, builtAt, artifacts, checks = null, notes = null }) {
|
||||
if (!ex || ex.status !== 'approved') return null;
|
||||
const same = ex.main_commit === mainCommit && ex.built_at === builtAt && canonJson(ex.artifacts) === canonJson(artifacts || {}) && canonJson(ex.checks) === canonJson(checks) && (notes == null || notes === ex.notes);
|
||||
return same ? null : `승인된 릴리스 ${ex.version} 은 바꿀 수 없습니다 — artifact·검사 기록·main 커밋·빌드 시각·메모가 다른 빌드는 새 버전 번호로 등록하세요`;
|
||||
}
|
||||
/**
|
||||
* artifact 설치(0.2.0 검토 수정 4차 — 불변·fail-closed). 등록(registerRelease) **전** 에 부른다.
|
||||
* 1) 원본 파일마다 바이트의 sha256 과 크기를 manifest 의 선언값과 대조한다 — 다르면 `artifact_hash_mismatch` 로 거절(아무것도 쓰지 않는다).
|
||||
* 2) 같은 버전이 이미 승인되어 있으면 메타데이터(releaseImmutableProblem)와 설치된 파일의 해시까지 같을 때만 멱등(복사하지 않음); 하나라도 다르면 `release_immutable` 로 거절하고 기존 파일·DB 를 건드리지 않는다.
|
||||
* 3) 새 버전·미승인 버전은 대상 폴더의 임시 파일(.tmp-…)에 쓰고 해시를 다시 확인한 뒤 rename 으로 승격한다(원자적) — 승인된 파일은 어떤 경로로도 잘리거나 덮이지 않는다.
|
||||
* @param {object} d HAL deps
|
||||
* @param {{version:string, mainCommit:string, builtAt:string, checks?:object, notes?:string, artifacts:Record<string,{file:string, sha256:string, size?:number, src:string}>}} m src = 원본 파일 경로
|
||||
* @returns {Record<string,{file:string, sha256:string, size:number}>} 등록에 넘길 artifact 목록(파일 이름만)
|
||||
*/
|
||||
export function installArtifacts(d, m) {
|
||||
const entries = Object.entries(m.artifacts || {});
|
||||
const declared = {}; const verified = {};
|
||||
for (const [mod, a] of entries) {
|
||||
if (!a || !a.file || !a.src) throw new JobError('artifact_missing', `${mod}: artifact 파일 정보가 없습니다`);
|
||||
if (!existsSync(a.src)) throw new JobError('artifact_missing', `${mod}: artifact 파일이 없습니다 (${basename(a.src)})`);
|
||||
const bytes = readFileSync(a.src); const sha = createHash('sha256').update(bytes).digest('hex');
|
||||
if (String(a.sha256 || '').toLowerCase() !== sha) throw new JobError('artifact_hash_mismatch', `${mod}: artifact 해시 불일치 — manifest ${String(a.sha256 || '').slice(0, 12)}… / 파일 ${sha.slice(0, 12)}… (설치·등록하지 않습니다)`);
|
||||
if (a.size !== undefined && a.size !== null && Number(a.size) !== bytes.length) throw new JobError('artifact_hash_mismatch', `${mod}: artifact 크기 불일치 — manifest ${a.size} / 파일 ${bytes.length}`);
|
||||
declared[mod] = { file: basename(a.file), sha256: sha, size: bytes.length }; verified[mod] = { bytes, ...declared[mod], src: a.src };
|
||||
}
|
||||
const ex = getRelease(d, m.version);
|
||||
const dir = join(artifactDir(d), m.version);
|
||||
if (ex && ex.status === 'approved') {
|
||||
const why = releaseImmutableProblem(ex, { mainCommit: m.mainCommit, builtAt: m.builtAt, artifacts: declared, checks: m.checks ?? null, notes: m.notes ?? null });
|
||||
if (why) throw new JobError('release_immutable', why);
|
||||
for (const [mod, v] of Object.entries(verified)) { // 설치된 바이트도 같아야 멱등 — 아니면 승인된 파일을 건드리지 않고 거절
|
||||
const p = join(dir, v.file); if (!existsSync(p)) throw new JobError('release_immutable', `승인된 릴리스 ${m.version} 의 ${mod} artifact 파일이 없습니다 — 설치 상태를 운영자가 확인하세요`);
|
||||
const cur = createHash('sha256').update(readFileSync(p)).digest('hex'); if (cur !== v.sha256) throw new JobError('release_immutable', `승인된 릴리스 ${m.version} 의 ${mod} artifact 는 바꿀 수 없습니다(설치된 파일과 바이트가 다름)`);
|
||||
}
|
||||
return declared; // 멱등 — 아무것도 쓰지 않는다
|
||||
}
|
||||
mkdirSync(dir, { recursive: true });
|
||||
for (const [mod, v] of Object.entries(verified)) {
|
||||
const target = join(dir, v.file);
|
||||
if (existsSync(target) && createHash('sha256').update(readFileSync(target)).digest('hex') === v.sha256) continue; // 이미 같은 바이트
|
||||
const tmp = join(dir, `.${v.file}.tmp-${process.pid}-${Math.random().toString(16).slice(2)}`);
|
||||
try { writeFileSync(tmp, v.bytes, { mode: 0o644 }); const again = createHash('sha256').update(readFileSync(tmp)).digest('hex'); if (again !== v.sha256) throw new JobError('artifact_hash_mismatch', `${mod}: 임시 파일 해시 불일치`); renameSync(tmp, target); }
|
||||
catch (e) { try { unlinkSync(tmp); } catch (_) {} throw e; }
|
||||
}
|
||||
return declared;
|
||||
}
|
||||
export function registerRelease(d, { version, mainCommit, builtAt, artifacts, checks = null, notes = null, approve = false, by = null }) {
|
||||
const now = nowIso(); const ex = d.store.get('SELECT version FROM releases WHERE version=?', version);
|
||||
if (ex) d.store.run('UPDATE releases SET main_commit=?, built_at=?, artifacts_json=?, checks_json=?, notes=COALESCE(?, notes) WHERE version=?', mainCommit, builtAt, toJson(artifacts), toJson(checks), notes, version);
|
||||
else d.store.run('INSERT INTO releases(version, main_commit, built_at, artifacts_json, checks_json, status, notes, registered_at) VALUES(?,?,?,?,?,\'registered\',?,?)', version, mainCommit, builtAt, toJson(artifacts), toJson(checks), notes, now);
|
||||
const now = nowIso(); const ex = getRelease(d, version);
|
||||
if (ex) {
|
||||
const same = ex.main_commit === mainCommit && ex.built_at === builtAt && canonJson(ex.artifacts) === canonJson(artifacts || {}) && canonJson(ex.checks) === canonJson(checks) && (notes == null || notes === ex.notes);
|
||||
if (ex.status === 'approved') {
|
||||
const why = releaseImmutableProblem(ex, { mainCommit, builtAt, artifacts, checks, notes }); if (why) throw new JobError('release_immutable', why);
|
||||
audit(d.store, { actorType: by ? 'operator' : 'system', actorId: by, action: 'release.register', subjectType: 'release', subjectId: version, detail: { mainCommit, artifacts: Object.keys(artifacts || {}), idempotent: true, approved: true } });
|
||||
return ex;
|
||||
}
|
||||
if (!same) d.store.run('UPDATE releases SET main_commit=?, built_at=?, artifacts_json=?, checks_json=?, notes=COALESCE(?, notes), status=\'registered\', approved_by=NULL, approved_at=NULL WHERE version=?', mainCommit, builtAt, toJson(artifacts), toJson(checks), notes, version);
|
||||
} else d.store.run('INSERT INTO releases(version, main_commit, built_at, artifacts_json, checks_json, status, notes, registered_at) VALUES(?,?,?,?,?,\'registered\',?,?)', version, mainCommit, builtAt, toJson(artifacts), toJson(checks), notes, now);
|
||||
if (approve) approveRelease(d, version, by);
|
||||
audit(d.store, { actorType: by ? 'operator' : 'system', actorId: by, action: 'release.register', subjectType: 'release', subjectId: version, detail: { mainCommit, artifacts: Object.keys(artifacts || {}) } });
|
||||
return getRelease(d, version);
|
||||
}
|
||||
export function approveRelease(d, version, by) { const r = getRelease(d, version); if (!r) throw new JobError('release_not_found', '릴리스가 없습니다'); const checks = r.checks || {}; if (checks.testsPassed === false) throw new JobError('release_checks_failed', '검사에 실패한 릴리스는 승인할 수 없습니다'); d.store.run('UPDATE releases SET status=\'approved\', approved_by=?, approved_at=? WHERE version=?', by, nowIso(), version); audit(d.store, { actorType: 'operator', actorId: by, action: 'release.approve', subjectType: 'release', subjectId: version, immutable: true }); return getRelease(d, version); }
|
||||
/**
|
||||
* 릴리스 검사 기록이 승인 가능한지(0.2.0 검토 수정 2차 — fail-closed). 세 값이 **정확히** 있어야 한다: testsPassed === true(--skip-tests 빌드의 null·없음은 불가), dirty === false(작업 트리 깨끗함이 기록됨),
|
||||
* provenanceClean === true(도입 소스 출처가 깨끗하다고 확인됨). 기록이 없거나(옛 빌드) 명시적 false 가 아닌 "모름" 도 승인하지 않는다. 승인 경로(approveRelease·registerRelease approve:true·운영자 API·release.mjs register --approve)가 모두 이 한 규칙을 쓴다.
|
||||
* @returns {string|null} 승인할 수 없는 이유(없으면 null)
|
||||
*/
|
||||
export function releaseApprovalProblem(checks) {
|
||||
const c = checks && typeof checks === 'object' ? checks : null;
|
||||
if (!c) return '검사 기록(checks)이 없는 릴리스는 승인할 수 없습니다 — 테스트·작업 트리·도입 소스 출처가 확인된 빌드로 다시 등록하세요';
|
||||
if (c.testsPassed !== true) return `테스트 통과가 기록되지 않은 릴리스는 승인할 수 없습니다 (checks.testsPassed=${JSON.stringify(c.testsPassed ?? null)} — --skip-tests 빌드는 등록만 되고 승인은 불가)`;
|
||||
if (c.dirty !== false) return `작업 트리가 깨끗하다고 기록되지 않은 릴리스는 승인할 수 없습니다 (checks.dirty=${JSON.stringify(c.dirty ?? null)} — main 에 병합된 커밋에서 빌드하세요)`;
|
||||
if (c.provenanceClean !== true) return `도입 소스 출처(provenance)가 깨끗하다고 확인되지 않은 릴리스는 승인할 수 없습니다 (checks.provenanceClean=${JSON.stringify(c.provenanceClean ?? null)}${(c.provenanceProblems || []).length ? ': ' + c.provenanceProblems.join(' / ') : ''})`;
|
||||
return null;
|
||||
}
|
||||
export function approveRelease(d, version, by) { const r = getRelease(d, version); if (!r) throw new JobError('release_not_found', '릴리스가 없습니다'); const why = releaseApprovalProblem(r.checks); if (why) throw new JobError('release_checks_failed', why); d.store.run('UPDATE releases SET status=\'approved\', approved_by=?, approved_at=? WHERE version=?', by, nowIso(), version); audit(d.store, { actorType: 'operator', actorId: by, action: 'release.approve', subjectType: 'release', subjectId: version, immutable: true }); return getRelease(d, version); }
|
||||
export function getRelease(d, version) { const r = d.store.get('SELECT * FROM releases WHERE version=?', version); return r ? { ...r, artifacts: parseJson(r.artifacts_json, {}), checks: parseJson(r.checks_json, null) } : null; }
|
||||
export function listReleases(d) { return d.store.all('SELECT * FROM releases ORDER BY registered_at DESC').map((r) => ({ ...r, artifacts: parseJson(r.artifacts_json, {}), checks: parseJson(r.checks_json, null) })); }
|
||||
export const artifactDir = (d) => join(d.config.dataDir, 'artifacts');
|
||||
|
||||
Reference in New Issue
Block a user