E2E 결함 수정: Agent 절대 번들 URL, HAL 작업 재사용(실패 숨김 방지), 도메인 탐침 재개 키, 작업 엔진 동시 실행(부모/자식 교착 해소), 외부 Agent 자리표시자, JobError 4xx, 포털 HAL 이벤트·health 수신, link-portal 공유 비밀
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
7662d06009
commit
98f4182bbb
+3
-1
@@ -21,7 +21,9 @@ export function createClient({ halUrl, agentId, secret, fetchImpl = fetch }) {
|
||||
}
|
||||
/** 파일 내려받기 + sha256 검증(expected 가 있으면) */
|
||||
async function download(path, dest, expected = null) {
|
||||
const r = await fetchImpl(base + path, { headers: signHeaders(keyId, secret, 'GET', path, undefined), signal: AbortSignal.timeout(3600000) }); skew(r);
|
||||
// [E2E 결함 수정] 포털이 주는 번들 URL 은 절대 주소(토큰으로 보호)다. HAL 상대 경로일 때만 HAL 서명 헤더를 붙인다.
|
||||
const absolute = /^https?:\/\//i.test(path);
|
||||
const r = await fetchImpl(absolute ? path : base + path, { headers: absolute ? {} : signHeaders(keyId, secret, 'GET', path, undefined), signal: AbortSignal.timeout(3600000) }); if (!absolute) skew(r);
|
||||
if (!r.ok) { const e = new Error(`HAL 내려받기 실패 ${path} → ${r.status}`); e.code = `http_${r.status}`; throw e; }
|
||||
const hash = createHash('sha256'); const src = Readable.fromWeb(r.body); src.on('data', (c) => hash.update(c)); await pipeline(src, createWriteStream(dest, { mode: 0o600 }));
|
||||
const sum = hash.digest('hex'); const want = String(expected || r.headers.get('x-sha256') || '').replace(/^sha256:/i, '').toLowerCase(); if (want && want !== sum) throw Object.assign(new Error(`내려받은 파일 해시가 다릅니다(기대 ${want.slice(0, 12)}… 실제 ${sum.slice(0, 12)}…)`), { code: 'checksum_mismatch' });
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -37,8 +37,9 @@ if (cmd === 'setup-token') {
|
||||
const r = core.registerRelease(d, { version: mf.version, mainCommit: mf.mainCommit, builtAt: mf.builtAt, artifacts: Object.fromEntries(Object.entries(mf.artifacts).map(([m, a]) => [m, { file: basename(a.file), sha256: a.sha256, size: a.size }])), checks: mf.checks || null, approve: !!opt('approve'), by: opt('by', 'cli') });
|
||||
console.log(`릴리스 ${r.version} 등록(${r.status}) — artifact ${Object.keys(r.artifacts).join(', ')}`);
|
||||
} else if (cmd === 'link-portal') {
|
||||
const p = randomToken(32), h = randomToken(32); d.vault.set('service:portal', p, { kind: 'service-key' }); d.vault.set('service:hal', h, { kind: 'service-key' });
|
||||
console.log(`포털 보관소에 넣을 값(1회 표시):\n service:hal = ${p} (포털이 HAL 을 부를 때 쓰는 키 — 포털 vault 이름 'service:hal')\n service:portal = ${h} (HAL 이 포털을 부를 때 쓰는 키 — 포털 vault 이름 'service:portal')`);
|
||||
// [E2E 결함 수정] 포털은 양방향 모두 보관소 이름 'service:hal' 하나를 쓴다(포털→HAL 서명, HAL→포털 검증). 그래서 공유 비밀 하나를 HAL 의 두 이름(service:portal·service:hal)에 같이 둔다.
|
||||
const shared = randomToken(32); d.vault.set('service:portal', shared, { kind: 'service-key' }); d.vault.set('service:hal', shared, { kind: 'service-key' });
|
||||
console.log(`포털 보관소에 넣을 값(1회 표시, 공유 금지):\n service:hal = ${shared}\n (포털 서버에서: ARIWEB_PORTAL_DATA=<포털 데이터> node -e "import('./lib/vault.mjs').then(m=>m.openVault(process.env.ARIWEB_PORTAL_DATA).set('service:hal', process.argv[1]))" <값>)`);
|
||||
} else if (cmd === 'bootstrap-release') {
|
||||
core.setBootstrap(d, { release_version: opt('version'), main_commit: opt('commit'), artifact_sha256: opt('sha256') }); console.log('부트스트랩 릴리스 정보를 기록했습니다');
|
||||
} else if (cmd === 'serve') {
|
||||
|
||||
+14
-12
@@ -35,7 +35,7 @@ CREATE INDEX IF NOT EXISTS jobs_parent ON jobs(parent_id);` }];
|
||||
|
||||
/** run() 안에서 흐름을 바꾸는 신호(예외로 전달) */
|
||||
class Signal extends Error { constructor(type, payload) { super(type); this.signal = type; this.payload = payload || {}; } }
|
||||
export class JobError extends Error { constructor(code, message, { retryable = false, detail } = {}) { super(message || code); this.code = code; this.retryable = retryable; this.detail = detail; } }
|
||||
export class JobError extends Error { constructor(code, message, { retryable = false, detail, status = 400 } = {}) { super(message || code); this.code = code; this.retryable = retryable; this.detail = detail; this.status = status; /* API 로 새면 400 계열 — 500 으로 숨기지 않는다 */ } }
|
||||
|
||||
const BACKOFF_BASE_MS = 30 * 1000; const BACKOFF_MAX_MS = 60 * 60 * 1000; const STALE_LOCK_MS = 10 * 60 * 1000;
|
||||
|
||||
@@ -58,6 +58,15 @@ export function createJobEngine(store, opts = {}) {
|
||||
store.run(`UPDATE jobs SET ${sets.join(', ')} WHERE id=?`, ...vals, id);
|
||||
const j = engine.get(id); emit(j); return j;
|
||||
}
|
||||
/** 실행 가능한 작업 하나를 잠근다(큐 → 실행 중). 없으면 null */
|
||||
function claim() {
|
||||
if (running >= concurrency) return null;
|
||||
const now = nowIso();
|
||||
const cand = store.get('SELECT id FROM jobs WHERE status=? AND (next_run_at IS NULL OR next_run_at<=?) ORDER BY next_run_at ASC, created_at ASC LIMIT 1', S.QUEUED, now);
|
||||
if (!cand) return null;
|
||||
const got = store.run('UPDATE jobs SET status=?, locked_by=?, locked_at=?, started_at=COALESCE(started_at, ?), attempt=attempt+1, updated_at=? WHERE id=? AND status=?', S.RUNNING, workerId, now, now, now, cand.id, S.QUEUED).changes;
|
||||
return got ? cand.id : null;
|
||||
}
|
||||
const engine = {
|
||||
workerId,
|
||||
register(kind, def) { kinds.set(kind, { maxAttempts: 1, steps: [], ...def }); return engine; },
|
||||
@@ -122,19 +131,12 @@ export function createJobEngine(store, opts = {}) {
|
||||
return stale.length;
|
||||
},
|
||||
/** 실행 가능한 작업 하나를 집어 실행(테스트에서도 직접 부름). 돌아온 값: 실행한 작업 또는 null */
|
||||
async tick() {
|
||||
if (running >= concurrency) return null;
|
||||
const now = nowIso();
|
||||
const cand = store.get('SELECT id FROM jobs WHERE status=? AND (next_run_at IS NULL OR next_run_at<=?) ORDER BY next_run_at ASC, created_at ASC LIMIT 1', S.QUEUED, now);
|
||||
if (!cand) return null;
|
||||
const got = store.run('UPDATE jobs SET status=?, locked_by=?, locked_at=?, started_at=COALESCE(started_at, ?), attempt=attempt+1, updated_at=? WHERE id=? AND status=?', S.RUNNING, workerId, now, now, now, cand.id, S.QUEUED).changes;
|
||||
if (!got) return null;
|
||||
running++;
|
||||
try { return await execute(cand.id); } finally { running--; }
|
||||
},
|
||||
async tick() { const id = claim(); if (!id) return null; running++; try { return await execute(id); } finally { running--; } },
|
||||
/** 루프용: 작업을 집어 **기다리지 않고** 실행을 시작한다(느린 작업이 다른 작업을 막지 않게, 부모/자식 작업 교착 방지). 집은 작업 id 또는 null */
|
||||
dispatch() { const id = claim(); if (!id) return null; running++; execute(id).catch((e) => log('[jobs] execute error', safeError(e))).finally(() => { running--; }); return id; },
|
||||
/** 모든 큐 작업을 비울 때까지 tick(테스트용) */
|
||||
async drain(max = 100) { let n = 0; while (n < max) { const r = await engine.tick(); if (!r) break; n++; } return n; },
|
||||
start() { stopped = false; engine.recover(); const loop = async () => { if (stopped) return; try { let n = 0; while (n < concurrency && (await engine.tick())) n++; } catch (e) { log('[jobs] tick error', safeError(e)); } timer = setTimeout(loop, opts.pollMs || 1000); }; loop(); return engine; },
|
||||
start() { stopped = false; engine.recover(); const loop = () => { if (stopped) return; try { let n = 0; while (running < concurrency && n < concurrency && engine.dispatch()) n++; } catch (e) { log('[jobs] dispatch error', safeError(e)); } timer = setTimeout(loop, opts.pollMs || 1000); }; loop(); return engine; },
|
||||
stop() { stopped = true; if (timer) clearTimeout(timer); },
|
||||
};
|
||||
function appendLog(job, line) { const l = (job.log || []).slice(-200); l.push({ at: nowIso(), line: String(line).slice(0, 500) }); return l; }
|
||||
|
||||
@@ -83,18 +83,22 @@ export function createDomainsService(deps) {
|
||||
async function probeTarget(d) { if (d.site_id) { const s = store.get('SELECT server_id, deploy_mode, external_agent_id FROM sites WHERE id=?', d.site_id); if (s && s.deploy_mode === 'managed' && s.server_id) return { target: s.server_id, external: false, agent: null }; const a = s && s.deploy_mode === 'external' ? store.get('SELECT hal_agent_id FROM external_agents WHERE site_id=? AND revoked_at IS NULL AND status=\'connected\'', d.site_id) : null; const srv = (await hal().listServers({ role: 'web' }))[0]; return { target: srv ? srv.id : null, external: s && s.deploy_mode === 'external', agent: a ? a.hal_agent_id : null }; } const srv = (await hal().listServers({ role: 'web' }))[0]; return { target: srv ? srv.id : null, external: false, agent: null }; }
|
||||
/** 단계에 묶인 HAL 작업 보장(portal/sites/service.mjs 의 halJobFor 와 같은 규칙): 재진입 때 같은 작업, 실패 뒤 재시도 때만 새 작업 */
|
||||
async function halJobFor(ctx, job, stepId, factory) {
|
||||
const st = ctx.steps().find((x) => x.id === stepId); const prev = st && st.result && st.result.halJobId ? st.result.halJobId : null;
|
||||
let st = ctx.steps().find((x) => x.id === stepId); if (!st) { st = { id: stepId, title: stepId, state: 'pending' }; ctx.steps().push(st); } // 단계 밖 탐침도 단계 결과에 HAL 작업 id 를 보관
|
||||
const prev = st && st.result && st.result.halJobId ? st.result.halJobId : null;
|
||||
const fresh = (job.steps || []).some((x) => x.id === stepId && x.state === 'failed'); // 이 단계가 실패한 뒤의 재시도면 새 HAL 작업
|
||||
if (prev && !fresh) { let hj; try { hj = await hal().getJob(prev); } catch (e) { if (e.code === 'service_unreachable') ctx.retryLater(15000, 'HAL 연결 대기(정보 확인 지연)'); throw e; } if (!['failed', 'cancelled'].includes(hj.status)) return prev; }
|
||||
const r = await factory(`portal:${job.id}:${stepId}${prev ? ':r' + job.attempt : ''}`); if (st) { st.result = { ...(st.result || {}), halJobId: r.halJobId }; ctx.flush(); } return r.halJobId;
|
||||
if (prev && !fresh) { let hj; try { hj = await hal().getJob(prev); } catch (e) { if (e.code === 'service_unreachable') ctx.retryLater(15000, 'HAL 연결 대기(정보 확인 지연)'); throw e; } return prev; } // [E2E 결함 수정] 실패·취소된 HAL 작업도 돌려줘 awaitHal 이 실패를 드러낸다(새 작업은 단계 실패 뒤 재시도 때만)
|
||||
const seq = (st && st.result && st.result.seq) || 0;
|
||||
const r = await factory(`portal:${job.id}:${stepId}${seq ? ':s' + seq : ''}${prev ? ':r' + job.attempt : ''}`); if (st) { st.result = { ...(st.result || {}), halJobId: r.halJobId }; ctx.flush(); } return r.halJobId;
|
||||
}
|
||||
/** [E2E 결함 수정] 외부 대기(사용자 DNS 변경) 직전에 단계에 적어 둔 탐침 작업 id 를 지워, 재개 때 새 탐침을 만들게 한다(옛 결과 재사용 금지). 단계가 없으면 만들어 둔다 */
|
||||
function probeReset(ctx, stepId) { const st = ctx.steps().find((x) => x.id === stepId); if (st && st.result) { st.result = { ...st.result, halJobId: null, seq: (st.result.seq || 0) + 1 }; ctx.flush(); } } // seq 로 멱등 키를 바꿔 HAL 이 옛 탐침을 돌려주지 않게 한다
|
||||
async function awaitHal(ctx, halJobId) { let hj; try { hj = await hal().getJob(halJobId); } catch (e) { if (e.code === 'service_unreachable') ctx.retryLater(15000, 'HAL 연결 대기(정보 확인 지연)'); throw e; } if (hj.status === 'done') return hj; if (['failed', 'cancelled'].includes(hj.status)) throw new JobError(hj.errorCode || 'hal_job_failed', hj.error || '서버 작업 실패'); ctx.retryLater(5000, '서버 작업 진행 중'); }
|
||||
jobs.register('domain.connect', { title: '도메인 연결 확인', steps: CONNECT_STEPS, maxAttempts: 3, retryable: (e) => e && e.code === 'service_unreachable',
|
||||
async run(job, ctx) {
|
||||
const d0 = get(job.subject.domainId); if (!d0) throw new JobError('domain_missing', '도메인이 없습니다'); const pt = await probeTarget(d0); if (!pt.target) throw new JobError('no_server', '확인에 쓸 서버가 없습니다', { retryable: true });
|
||||
// 1 소유 확인 — TXT 값이 보일 때까지 외부 작업 대기(사용자가 DNS 를 넣는다)
|
||||
const d1 = get(d0.id);
|
||||
if (d1.ownership_state !== 'verified') { const r = await hal().createJob({ kind: 'domain.probe', target: pt.target, idempotencyKey: `portal:${job.id}:own:${job.attempt}:${Date.now() / 30000 | 0}`, payload: { name: `_ariweb-verify.${d1.name}`, expectTxt: { name: `_ariweb-verify.${d1.name}`, value: `ariweb-verify=${d1.ownership_token}` } } }); const hj = await awaitHal(ctx, r.halJobId); const ok = !!(hj.result && hj.result.txtOk); record(d1.id, 'ownership', ok, hj.result); if (!ok) { save(d1.id, { last_checked_at: nowIso(), check_json: { stage: 'ownership', detail: hj.result && hj.result.txt ? `TXT 값이 다릅니다(지금 값: ${String(hj.result.txt).slice(0, 60)})` : 'TXT 레코드가 아직 보이지 않습니다' } }); ctx.waitExternal(hj.result && hj.result.txt ? `소유 확인 TXT 값이 다릅니다 — 안내된 값으로 수정한 뒤 "다시 확인"` : `소유 확인 TXT 레코드가 아직 반영되지 않았습니다 — DNS 에 넣은 뒤 "다시 확인"`); } save(d1.id, { ownership_state: 'verified', last_checked_at: nowIso() }); }
|
||||
if (d1.ownership_state !== 'verified') { const hid = await halJobFor(ctx, job, 'ownership', (key) => hal().createJob({ kind: 'domain.probe', target: pt.target, idempotencyKey: key, payload: { name: `_ariweb-verify.${d1.name}`, expectTxt: { name: `_ariweb-verify.${d1.name}`, value: `ariweb-verify=${d1.ownership_token}` } } })); const hj = await awaitHal(ctx, hid); const ok = !!(hj.result && hj.result.txtOk); record(d1.id, 'ownership', ok, hj.result); if (!ok) { save(d1.id, { last_checked_at: nowIso(), check_json: { stage: 'ownership', detail: hj.result && hj.result.txt ? `TXT 값이 다릅니다(지금 값: ${String(hj.result.txt).slice(0, 60)})` : 'TXT 레코드가 아직 보이지 않습니다' } }); probeReset(ctx, 'ownership'); ctx.waitExternal(hj.result && hj.result.txt ? `소유 확인 TXT 값이 다릅니다 — 안내된 값으로 수정한 뒤 "다시 확인"` : `소유 확인 TXT 레코드가 아직 반영되지 않았습니다 — DNS 에 넣은 뒤 "다시 확인"`); } save(d1.id, { ownership_state: 'verified', last_checked_at: nowIso() }); }
|
||||
await ctx.step('ownership', () => ({ verified: true }));
|
||||
// 2 DNS 반영(웹 용도만). 미반영(레코드 없음) 과 잘못된 값(다른 IP) 을 구분
|
||||
const d2 = get(d0.id);
|
||||
@@ -102,7 +106,7 @@ export function createDomainsService(deps) {
|
||||
const recs = await requiredRecords(d2); const a = recs.find((x) => x.type === 'A');
|
||||
if (pt.external) { save(d2.id, { dns_state: 'customer', dns_detail_json: toJson({ note: '외부 배포: DNS 값은 고객이 관리하며 포털이 검증하지 않습니다' }) }); }
|
||||
else if (!a.value) { save(d2.id, { dns_state: 'pending', check_json: { stage: 'dns', detail: '관리형 배포를 시작해 서버가 정해진 뒤 A 레코드 값을 안내합니다' } }); ctx.waitExternal('사이트를 먼저 관리형으로 배포하면 A 레코드 값을 안내합니다'); }
|
||||
else { const r = await hal().createJob({ kind: 'domain.probe', target: pt.target, idempotencyKey: `portal:${job.id}:dns:${job.attempt}:${Date.now() / 30000 | 0}`, payload: { name: d2.name, expectA: [a.value] } }); const hj = await awaitHal(ctx, r.halJobId); const got = (hj.result && hj.result.a) || []; const state = !got.length ? 'pending' : got.includes(a.value) ? 'ok' : 'wrong'; record(d2.id, 'dns', state === 'ok', { expected: a.value, got }); save(d2.id, { dns_state: state, dns_detail_json: toJson({ expected: a.value, got, resolvedAt: hj.result && hj.result.resolvedAt }), last_checked_at: nowIso() }); if (state !== 'ok') ctx.waitExternal(state === 'wrong' ? `A 레코드 값이 다릅니다 (지금 ${got.join(', ')} / 필요 ${a.value}) — 수정 뒤 "다시 확인"` : `A 레코드가 아직 반영되지 않았습니다 (필요 ${a.value}) — 반영 뒤 "다시 확인"`); }
|
||||
else { const hid = await halJobFor(ctx, job, 'dns', (key) => hal().createJob({ kind: 'domain.probe', target: pt.target, idempotencyKey: key, payload: { name: d2.name, expectA: [a.value] } })); const hj = await awaitHal(ctx, hid); const got = (hj.result && hj.result.a) || []; const state = !got.length ? 'pending' : got.includes(a.value) ? 'ok' : 'wrong'; record(d2.id, 'dns', state === 'ok', { expected: a.value, got }); save(d2.id, { dns_state: state, dns_detail_json: toJson({ expected: a.value, got, resolvedAt: hj.result && hj.result.resolvedAt }), last_checked_at: nowIso() }); if (state !== 'ok') { probeReset(ctx, 'dns'); ctx.waitExternal(state === 'wrong' ? `A 레코드 값이 다릅니다 (지금 ${got.join(', ')} / 필요 ${a.value}) — 수정 뒤 "다시 확인"` : `A 레코드가 아직 반영되지 않았습니다 (필요 ${a.value}) — 반영 뒤 "다시 확인"`); } }
|
||||
} else save(d2.id, { dns_state: 'n/a' });
|
||||
await ctx.step('dns', () => ({ state: get(d0.id).dns_state }));
|
||||
// 3 인증서(관리형 웹 도메인만). 외부 배포는 고객 관리
|
||||
|
||||
@@ -252,7 +252,7 @@ export function createSitesService(deps) {
|
||||
async function halJobFor(ctx, job, stepId, factory, { fresh = false } = {}) {
|
||||
const st = ctx.steps().find((x) => x.id === stepId); const prev = st && st.result && st.result.halJobId ? st.result.halJobId : null;
|
||||
// fresh: 사용자 재시도(이전 실행이 실패로 끝남) — 끝난 HAL 작업의 결과를 다시 쓰지 않고 새 작업을 만든다. 아니면 진행 중·완료된 이전 작업을 그대로 본다
|
||||
if (prev && !fresh) { let hj; try { hj = await hal().getJob(prev); } catch (e) { if (e.code === 'service_unreachable') ctx.retryLater(15000, 'HAL(중앙 관리) 연결 대기 — 정보 확인 지연'); throw e; } if (!['failed', 'cancelled'].includes(hj.status)) return prev; }
|
||||
if (prev && !fresh) { let hj; try { hj = await hal().getJob(prev); } catch (e) { if (e.code === 'service_unreachable') ctx.retryLater(15000, 'HAL(중앙 관리) 연결 대기 — 정보 확인 지연'); throw e; } return prev; } // [E2E 결함 수정] 실패·취소된 HAL 작업도 그대로 돌려줘 awaitHal 이 실패를 드러낸다(새 작업은 fresh 일 때만)
|
||||
const r = await factory(`portal:${job.id}:${stepId}${prev ? ':r' + job.attempt : ''}`);
|
||||
if (st) { st.result = { ...(st.result || {}), halJobId: r.halJobId }; ctx.flush(); }
|
||||
return r.halJobId;
|
||||
@@ -364,13 +364,13 @@ export function createSitesService(deps) {
|
||||
const r = await hal().issueExternalAgentToken({ groupId: site.group_id, siteId: site.id, label });
|
||||
if (prev) store.run('UPDATE external_agents SET revoked_at=?, status=\'revoked\' WHERE id=?', nowIso(), prev.id);
|
||||
const id = newId('xa_'); const now = nowIso();
|
||||
store.run('INSERT INTO external_agents(id, site_id, group_id, hal_agent_id, label, status, token_expires_at, install_command, created_at, updated_at) VALUES(?,?,?,?,?,\'pending\',?,?,?,?)', id, site.id, site.group_id, r.agentId, label || null, r.expiresAt, r.installCommand, now, now);
|
||||
store.run('INSERT INTO external_agents(id, site_id, group_id, hal_agent_id, label, status, token_expires_at, install_command, created_at, updated_at) VALUES(?,?,?,?,?,\'pending\',?,?,?,?)', id, site.id, site.group_id, r.agentId || `pending:${id}` /* [E2E 결함 수정] HAL 은 Agent 가 실제 등록될 때 id 를 만든다 — 그 전에는 자리표시자 */, label || null, r.expiresAt, r.installCommand, now, now);
|
||||
audit(store, { actorType: 'user', actorId: user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'external_agent.issue', detail: { agentId: r.agentId, label } });
|
||||
// 토큰 원문은 이 응답에만 — 저장하지 않는다(§28.2 등록 정보 재표시 금지와 같은 원칙)
|
||||
return { agent: externalAgentFor(site.id), token: r.token, expiresAt: r.expiresAt, installCommand: r.installCommand.replace('<토큰>', r.token) };
|
||||
}
|
||||
async function refreshExternalAgent(site) { const a = externalAgentFor(site.id); if (!a) return null; try { const s = await hal().getExternalAgent(a.hal_agent_id); store.run('UPDATE external_agents SET status=?, last_seen_at=?, agent_version=?, last_deploy_json=COALESCE(?, last_deploy_json), updated_at=? WHERE id=?', s.status, s.lastSeenAt || a.last_seen_at, s.version || a.agent_version, s.lastDeploy ? toJson(s.lastDeploy) : null, nowIso(), a.id); return { ...externalAgentFor(site.id), stale: false }; } catch (e) { return { ...a, stale: true, staleReason: 'HAL 에 연결할 수 없습니다(정보 확인 지연)' }; } }
|
||||
async function revokeExternalAgent(site, user) { const a = externalAgentFor(site.id); if (!a) throw new HttpError(404, 'not_found', '외부 서버 Agent 가 없습니다'); try { await hal().revokeExternalAgent(a.hal_agent_id); } catch (e) { if (e.code !== 'service_unreachable') throw e; } store.run('UPDATE external_agents SET status=\'revoked\', revoked_at=?, updated_at=? WHERE id=?', nowIso(), nowIso(), a.id); audit(store, { actorType: 'user', actorId: user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'external_agent.revoke', detail: { agentId: a.hal_agent_id } }); return true; }
|
||||
async function refreshExternalAgent(site) { const a = externalAgentFor(site.id); if (!a) return null; try { let s; if (String(a.hal_agent_id).startsWith('pending:')) { s = hal().getExternalAgentBySite ? await hal().getExternalAgentBySite(site.id) : null; if (!s) return { ...a, stale: false }; store.run('UPDATE external_agents SET hal_agent_id=? WHERE id=?', s.id, a.id); a.hal_agent_id = s.id; } else s = await hal().getExternalAgent(a.hal_agent_id); store.run('UPDATE external_agents SET status=?, last_seen_at=?, agent_version=?, last_deploy_json=COALESCE(?, last_deploy_json), updated_at=? WHERE id=?', s.status, s.lastSeenAt || a.last_seen_at, s.version || a.agent_version, s.lastDeploy ? toJson(s.lastDeploy) : null, nowIso(), a.id); return { ...externalAgentFor(site.id), stale: false }; } catch (e) { return { ...a, stale: true, staleReason: 'HAL 에 연결할 수 없습니다(정보 확인 지연)' }; } }
|
||||
async function revokeExternalAgent(site, user) { const a = externalAgentFor(site.id); if (!a) throw new HttpError(404, 'not_found', '외부 서버 Agent 가 없습니다'); try { if (!String(a.hal_agent_id).startsWith('pending:')) await hal().revokeExternalAgent(a.hal_agent_id); } catch (e) { if (e.code !== 'service_unreachable') throw e; } store.run('UPDATE external_agents SET status=\'revoked\', revoked_at=?, updated_at=? WHERE id=?', nowIso(), nowIso(), a.id); audit(store, { actorType: 'user', actorId: user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, action: 'external_agent.revoke', detail: { agentId: a.hal_agent_id } }); return true; }
|
||||
/** 외부 서버의 배포 제거(실패 잔여 파일 정리 포함) */
|
||||
function removeExternalDeploy(site, user) { const a = externalAgentFor(site.id); if (!a) throw new HttpError(409, 'agent_missing', '외부 서버 Agent 가 없습니다'); const { job } = jobs.enqueue({ kind: 'site.remove_external', subject: { siteId: site.id, agentId: a.hal_agent_id }, idempotencyKey: `site.remove_external:${site.id}:${Date.now() / 60000 | 0}`, createdBy: user.id, groupId: site.group_id, subjectType: 'site', subjectId: site.id, label: '외부 서버 배포 제거' }); return describeJob(job); }
|
||||
jobs.register('site.remove_external', { title: '외부 서버 배포 제거', steps: [{ id: 'remove', title: '외부 서버에서 파일·실행 상태 제거' }], maxAttempts: 2, retryable: (e) => e && e.code === 'service_unreachable', async run(job, ctx) { const { siteId, agentId } = job.subject; const r = await ctx.step('remove', async () => { const id = await halJobFor(ctx, job, 'remove', (key) => hal().createJob({ kind: 'site.remove', target: agentId, idempotencyKey: key, payload: { siteId, keepData: false } }), { fresh: (job.steps || []).some((x) => x.id === 'remove' && x.state === 'failed') }); return (await awaitHal(ctx, id)).result; }); const a = externalAgentFor(siteId); if (a) store.run('UPDATE external_agents SET last_deploy_json=?, updated_at=? WHERE id=?', toJson({ removedAt: nowIso(), leftovers: (r && r.leftovers) || [] }), nowIso(), a.id); save(siteId, { current_version: null, deploy_verify: null, lifecycle: 'building', pending_cleanup_json: null }); return r; } });
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* portal/webhooks/api.mjs — HAL → 포털 이벤트 수신과 포털 건강 확인.
|
||||
*
|
||||
* POST /internal/hal-events HAL 이 작업 상태 변화를 밀어 넣는다(lib/serviceauth, 키 id 'hal'). 포털 쪽 작업은 5초 주기로 HAL 을 다시 묻는 구조이므로
|
||||
* 여기서는 "그 HAL 작업을 기다리는 포털 작업" 의 next_run_at 을 지금으로 당겨 바로 깨운다(둘 중 빠른 쪽, docs/HAL-JOBS.md §6).
|
||||
* GET /api/health HAL 모듈 설명자가 부르는 건강 확인(버전·DB·작업 엔진).
|
||||
*/
|
||||
import { verifyCtx } from '../../lib/serviceauth.mjs';
|
||||
import { HttpError } from '../../lib/http.mjs';
|
||||
import { nowIso } from '../../lib/db.mjs';
|
||||
|
||||
export async function register(app, deps) {
|
||||
const { store } = deps;
|
||||
app.get('/api/health', (ctx) => { let db = false; try { db = !!store.get('SELECT 1 AS ok').ok; } catch (_) {} return { ok: db, version: deps.version, db, jobs: deps.jobs?.kinds().length || 0, time: nowIso() }; });
|
||||
app.post('/internal/hal-events', async (ctx) => {
|
||||
const r = await verifyCtx(ctx, (id) => (id === 'hal' ? deps.vault.get('service:hal') : null));
|
||||
if (!r.ok) throw new HttpError(401, 'service_auth_failed', `서비스 인증 실패(${r.reason})`);
|
||||
const ev = await ctx.body(); const halJobId = String(ev.halJobId || ev.id || ''); if (!halJobId) return { ok: true, woken: 0 };
|
||||
// 기다리는 포털 작업: 단계 결과에 halJobId 를 적어 둔 작업(halJobFor) 또는 deployments.hal_job_id 로 연결된 작업
|
||||
const now = nowIso(); let woken = 0;
|
||||
woken += store.run('UPDATE jobs SET next_run_at=?, updated_at=? WHERE status=\'queued\' AND next_run_at>? AND steps_json LIKE ?', now, now, now, `%${halJobId}%`).changes;
|
||||
const dep = store.get('SELECT job_id FROM deployments WHERE hal_job_id=?', halJobId);
|
||||
if (dep && dep.job_id) woken += store.run('UPDATE jobs SET next_run_at=?, updated_at=? WHERE id=? AND status=\'queued\' AND next_run_at>?', now, now, dep.job_id, now).changes;
|
||||
return { ok: true, woken };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user