diff --git a/apps/attendance/README.md b/apps/attendance/README.md index 72417283..0e6d9c25 100644 --- a/apps/attendance/README.md +++ b/apps/attendance/README.md @@ -29,12 +29,13 @@ KV에는 트랜잭션이 없고 다른 지역에서는 쓰기가 최대 약 60 ## API -| 경로 | 인증 | 용도 | -| ------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| `POST /api/taps` | `Authorization: Bearer ` | `{ "uid": "04A1B2C3", "at": 1760000000000 }` (`at` 생략 시 서버 시각. 미래 60초·과거 7일을 벗어나면 400) | -| `GET /` | Basic (비밀번호 = `DASHBOARD_PASSWORD`, 사용자 이름 무시) | 대시보드 | -| `GET /api/state` | Basic | 현재 인원, 회원별 오늘·이번 주 시간, 최근 기록 | -| `POST /api/members` | Basic, 같은 출처 JSON | `{ "uid", "name" }` 회원 등록·이름 수정 | +| 경로 | 인증 | 용도 | +| --------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| `POST /api/taps` | `Authorization: Bearer ` | `{ "uid": "04A1B2C3", "at": 1760000000000 }` (`at` 생략 시 서버 시각. 미래 60초·과거 7일을 벗어나면 400) | +| `GET /` | Basic (비밀번호 = `DASHBOARD_PASSWORD`, 사용자 이름 무시) | 대시보드 | +| `GET /api/state` | Basic | 현재 인원, 회원별 오늘·이번 주 시간, 최근 기록 | +| `POST /api/members` | Basic, 같은 출처 JSON | `{ "uid", "name" }` 회원 등록·이름 수정 | +| `GET /api/automation` | Basic | 독립적인 오류 수신 요약과 Rocky 읽기 전용 헬스. RFID Bearer로 접근 불가 | ## 대시보드 @@ -47,6 +48,18 @@ KV에는 트랜잭션이 없고 다른 지역에서는 쓰기가 최대 약 60 - 회원 이름과 카드 정보는 `textContent`로만 출력합니다. 상태 조회와 등록은 같은 출처의 기존 인증을 사용하며 등록은 JSON 요청으로만 전송합니다. - 배포는 BCSDLab 조직 Gateway를 요구하는 접근 정책을 전제로 합니다. 일반 소비자 WARP 접속만 허용하는 정책은 충분하지 않습니다. Worker의 모든 도메인·미리보기 우회 경로를 보호하기 전에는 공개 경로를 활성화하지 않습니다. +## 오류 수신·Rocky 읽기 전용 모니터 + +- 서버의 `ERROR_AUTOMATION` Worker service binding이 고정된 `bcsd-error-automation`의 `GET /healthz`와 `GET /healthz/rocky`를 병렬로 읽습니다. 브라우저·Pi에는 관리/모델/진단/호스트 모니터 토큰을 주지 않습니다. +- 두 Worker에 별도의 동일한 `ERROR_MONITOR_TOKEN`을 secret으로 설정해야 합니다(32자 이상 무작위 값). `DEVICE_TOKEN`, `DASHBOARD_PASSWORD`, 실행기·진단·모델 토큰을 재사용하지 않습니다. secret 설정은 배포 작업이므로 정상 운영 승인 절차를 따릅니다. +- 수신 요약은 Sentry/Cloudflare의 최근 24시간 **고유 오류 등록 건수**, 최근 신규 등록 시각과 최대 10개의 처리 상태입니다. 건수와 최근 시각은 목록 제한과 독립적으로 전체 기간에서 계산합니다. 중복 웹훅 전달 횟수나 마지막 재전송 시각이 아니며 GitHub PR 리뷰와 미사용 `koin-api` 기록은 제외합니다. +- Rocky는 기존 `OMP_GATEWAY` VPC와 `koin-omp-gateway-rocky` 터널을 통해 게이트웨이의 고정 `GET /healthz`만 조회합니다. `OMP_GATEWAY_MONITOR_TOKEN`은 error-automation과 호스트만 공유하는 별도 secret이며 모델·실행기·수신 요약 토큰과 같으면 거부합니다. 호스트 설정은 [게이트웨이 운영 계약](../../scripts/autofix/README.md#rocky-읽기-전용-헬스-활성화)을 따릅니다. +- CPU는 Linux `/proc/stat` 두 표본의 busy 비율, RAM 사용량은 `MemTotal − MemAvailable`입니다. 실행기·브로커는 고정 systemd 서비스의 설치/활성/프로세스 상태를 구분합니다. 서비스 활성이나 프로세스 실행은 브로커 HTTP 응답·모델 인증·작업 완료의 증거가 아니며 모델 상태는 미확인으로 표시합니다. +- 오류 전문·프롬프트·소스·로그·원본 provider ID·자격 증명을 반환하지 않습니다. 조회가 작업 claim, 모델 생성, GitHub 작업을 시작하지 않습니다. +- 출근부 조회와 별개로 60초마다 갱신합니다. 조회별 5초 본문 수신 기한·16 KiB JSON 한도·리다이렉트 거절을 적용하고 120초보다 오래된 스냅샷은 unknown으로 처리합니다. +- `/api/automation`의 최상위 `state`/`data`는 수신 요약이고 `rocky.state`/`rocky.data`는 호스트 관측입니다. 한쪽 실패가 다른 쪽의 관측을 지우지 않습니다. 미설정·연결 실패·응답 불일치는 해당 결과의 `state: unknown`, `data: null`이며 측정 실패 CPU/RAM도 null입니다. 실제 조회한 빈 저장소만 0건으로 표시하고 조용한 수신량을 장애로 판단하지 않습니다. +- 연결 실패·오프라인·숨긴 화면·확인 기한 초과 시 마지막 관측은 과거 기록임을 표시합니다. 조직 Gateway 보호와 기존 비공개 노출 경계는 유지하며 새 터널·공개 SSH·WARP/OpenBao 권한은 추가하지 않습니다. + ## 배포 ```sh diff --git a/apps/attendance/package.json b/apps/attendance/package.json index 2707b15f..03da9822 100644 --- a/apps/attendance/package.json +++ b/apps/attendance/package.json @@ -5,7 +5,10 @@ "scripts": { "dev": "wrangler dev", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "node --experimental-strip-types --test test/logic.test.mjs", + "test": "node --experimental-strip-types --test test/*.test.mjs", "deploy": "wrangler deploy" + }, + "dependencies": { + "@koin/telemetry": "workspace:*" } } diff --git a/apps/attendance/src/automation.ts b/apps/attendance/src/automation.ts new file mode 100644 index 00000000..701fc508 --- /dev/null +++ b/apps/attendance/src/automation.ts @@ -0,0 +1,98 @@ +import { + parseErrorMonitorSnapshot, + parseRockyMonitorSnapshot, + readMonitorJson, + type ErrorMonitorSnapshot, + type RockyMonitorSnapshot, +} from '@koin/telemetry/monitor'; + +export type AutomationEnv = Partial> & { + ERROR_MONITOR_TOKEN?: string; +}; + +interface MonitorResult { + state: 'ok' | 'unknown'; + checkedAt: number | null; + data: TSnapshot | null; + reason?: 'not_configured' | 'unavailable' | 'stale'; +} + +export type AutomationResult = MonitorResult; +export interface AutomationDashboardResult extends AutomationResult { + rocky: MonitorResult; +} + +const DEADLINE_MS = 5_000; +const FRESH_MS = 120_000; +const HEALTH_URL = 'https://error-automation.internal/healthz'; +const ROCKY_HEALTH_URL = 'https://error-automation.internal/healthz/rocky'; +const UNKNOWN_HEADERS = { 'cache-control': 'no-store' }; + +function unknown(reason: NonNullable): MonitorResult { + return { state: 'unknown', checkedAt: null, data: null, reason }; +} + +/** The deadline covers both the binding fetch and consumption of the complete response. */ +async function getMonitorResult( + env: AutomationEnv, + url: string, + parse: (value: unknown, now: number) => TSnapshot | null, + now: () => number, +): Promise> { + const binding = env.ERROR_AUTOMATION; + const token = env.ERROR_MONITOR_TOKEN; + if (!binding || !token || token.length < 32) return unknown('not_configured'); + + const controller = new AbortController(); + const { promise: deadline, reject } = Promise.withResolvers(); + const timer = setTimeout(() => { + controller.abort(); + reject(new Error('monitor deadline exceeded')); + }, DEADLINE_MS); + try { + const task = async (): Promise> => { + const response = await binding.fetch(url, { + method: 'GET', + headers: { authorization: `Bearer ${token}` }, + redirect: 'manual', + signal: controller.signal, + }); + if (response.status !== 200 || response.redirected) { + void response.body?.cancel().catch(() => {}); + return unknown('unavailable'); + } + const payload = await readMonitorJson(response, controller.signal); + const checkedAt = + payload !== null && typeof payload === 'object' && !Array.isArray(payload) && 'checkedAt' in payload + ? payload.checkedAt + : null; + const currentTime = now(); + const snapshot = parse(payload, currentTime); + if (!snapshot) + return unknown( + typeof checkedAt === 'number' && Number.isFinite(checkedAt) && currentTime - checkedAt > FRESH_MS + ? 'stale' + : 'unavailable', + ); + return { state: 'ok', checkedAt: snapshot.checkedAt, data: snapshot }; + }; + return await Promise.race([task(), deadline]); + } catch { + return unknown('unavailable'); + } finally { + clearTimeout(timer); + } +} + +export function getAutomationResult(env: AutomationEnv, now: () => number = Date.now): Promise { + return getMonitorResult(env, HEALTH_URL, parseErrorMonitorSnapshot, now); +} + +export async function handleAutomation(env: AutomationEnv): Promise { + const [ingress, rocky] = await Promise.all([ + getAutomationResult(env), + getMonitorResult(env, ROCKY_HEALTH_URL, parseRockyMonitorSnapshot, Date.now), + ]); + const result: AutomationDashboardResult = { ...ingress, rocky }; + return Response.json(result, { headers: UNKNOWN_HEADERS }); +} diff --git a/apps/attendance/src/dashboard.ts b/apps/attendance/src/dashboard.ts index 519ce51c..7a23d4d8 100644 --- a/apps/attendance/src/dashboard.ts +++ b/apps/attendance/src/dashboard.ts @@ -144,6 +144,22 @@ export const DASHBOARD_HTML = /* html */ ` #sync-status[data-state="error"] { color:var(--error); } .sync-note { margin:0; color:var(--muted); font-size:12px; } footer { display:flex; justify-content:space-between; flex-wrap:wrap; gap:8px; padding:24px 4px 0; color:var(--muted); font-size:11px; } + .monitor-strip { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 16px; border:1px solid var(--line); border-radius:18px; background:var(--surface); box-shadow:var(--shadow); } + .monitor-panels { flex:1; min-width:0; display:grid; gap:6px; } + .monitor-summary { display:flex; align-items:center; flex-wrap:wrap; gap:6px 18px; min-width:0; font-size:12px; } + .monitor-summary strong { font-size:13px; } + .monitor-summary small { color:var(--muted); } + #monitor-state[data-state="ok"], #rocky-state[data-state="ok"] { background:var(--green-container); color:var(--on-green-container); } + #monitor-state[data-state="unknown"], #rocky-state[data-state="unknown"] { background:var(--amber-container); color:var(--amber); } + .monitor-dialog { width:min(650px,calc(100% - 32px)); max-height:min(80vh,700px); overflow:auto; border:1px solid var(--outline); border-radius:24px; padding:22px; background:var(--surface); color:var(--text); box-shadow:0 20px 60px #10131b44; } + .monitor-dialog::backdrop { background:#10131b88; } + .monitor-dialog header { display:flex; justify-content:space-between; align-items:center; gap:12px; } + .monitor-dialog ul { list-style:none; margin:12px 0; padding:0; } + .monitor-dialog li { padding:8px 0; border-bottom:1px solid var(--line); font-size:13px; overflow-wrap:anywhere; } + @media (max-width:700px) { + .monitor-strip { align-items:flex-start; } + .monitor-summary { gap:4px 10px; } + } footer p { margin:0; } @media (min-width:1600px) { .app-shell { max-width:1600px; } @@ -213,7 +229,12 @@ export const DASHBOARD_HTML = /* html */ ` #date { font-size:10px; } .header-actions { gap:7px; } .header-actions button { min-height:36px; padding:8px 12px; font-size:11px; } - main { flex:1; min-height:0; display:grid; grid-template-columns:minmax(0,1.8fr) minmax(290px,1fr); grid-template-rows:auto minmax(0,.9fr) minmax(0,1.2fr) minmax(0,1fr) 18px; grid-template-areas:"error error" "hero summary" "members unknown" "recent unknown" "sync sync"; gap:10px 12px; } + main { flex:1; min-height:0; display:grid; grid-template-columns:minmax(0,1.8fr) minmax(290px,1fr); grid-template-rows:auto auto minmax(0,.9fr) minmax(0,1.2fr) minmax(0,1fr) 18px; grid-template-areas:"monitor monitor" "error error" "hero summary" "members unknown" "recent unknown" "sync sync"; gap:8px 12px; } + .monitor-strip { grid-area:monitor; padding:4px 12px; min-height:38px; border-radius:12px; overflow:hidden; } + .monitor-summary { gap:2px 12px; font-size:clamp(9px,1.1vh,14px); line-height:1.3; } + .monitor-summary strong { font-size:inherit; } + .monitor-summary small { font-size:inherit; } + .monitor-strip button { min-height:30px; padding:4px 10px; font-size:10px; } #error { grid-area:error; padding:8px 12px; border-radius:12px; } #error p { font-size:11px; } .hero { grid-area:hero; grid-template-columns:clamp(180px,13vw,340px) minmax(0,1fr); grid-template-rows:minmax(0,1fr); } @@ -293,6 +314,28 @@ export const DASHBOARD_HTML = /* html */ `
+
+
+
+ 오류 수신 · 24시간 + 확인 중 + Sentry 고유 등록 — · 최근 신규 — + Cloudflare 고유 등록 — · 최근 신규 — + 수신 확인 — +
+
+ Rocky + 확인 중 + CPU — + RAM — + 실행기 — + 브로커 — + 호스트 확인 — + 브로커 HTTP·모델 실행 미확인 +
+
+ +
+ +

오류 수신 · Rocky 관측

+

+
    +

    Rocky 읽기 전용 헬스

    +

    아직 조회된 호스트 정보가 없습니다.

    +
    diff --git a/apps/attendance/src/index.ts b/apps/attendance/src/index.ts index 359b5ffc..f34eaa94 100644 --- a/apps/attendance/src/index.ts +++ b/apps/attendance/src/index.ts @@ -1,7 +1,8 @@ -import { DAY_MS, decideTap, isUnrecorded, kstDateKey, kstWeekStart, summarize, type SessionRow } from './logic'; -import { DASHBOARD_HTML } from './dashboard'; +import { DAY_MS, decideTap, isUnrecorded, kstDateKey, kstWeekStart, summarize, type SessionRow } from './logic.ts'; +import { DASHBOARD_HTML } from './dashboard.ts'; +import { handleAutomation } from './automation.ts'; -interface Env { +interface Env extends Cloudflare.Env { /** * 키 구성 (값은 비어 있고 데이터는 모두 메타데이터에 두어 list 한 번으로 읽습니다): * - `member:` 메타 {name} @@ -9,11 +10,12 @@ interface Env { * - `s::<입실 시각 13자리>:` 메타 {checkOut, autoClosed} — 세션 * - `latest:` 값 JSON {checkIn, checkOut, autoClosed} — 회원의 최근 세션 포인터 */ - ATTENDANCE: KVNamespace; /** 라즈베리파이가 태그 이벤트를 보낼 때 쓰는 Bearer 토큰. */ DEVICE_TOKEN: string; /** 대시보드·회원 등록용 Basic 인증 비밀번호(사용자 이름은 무시). */ DASHBOARD_PASSWORD: string; + /** 서버 측에서만 사용하는 오류 자동화 모니터 토큰. */ + ERROR_MONITOR_TOKEN?: string; } interface MemberMeta { @@ -250,7 +252,11 @@ export default { : json({ error: 'method_not_allowed' }, 405, { allow: 'POST' }); } - const isDashboardRoute = url.pathname === '/' || url.pathname === '/api/state' || url.pathname === '/api/members'; + const isDashboardRoute = + url.pathname === '/' || + url.pathname === '/api/state' || + url.pathname === '/api/members' || + url.pathname === '/api/automation'; if (!isDashboardRoute) return json({ error: 'not_found' }, 404); if (!(await dashboardAuthorized(request, env))) return new Response('Unauthorized', UNAUTHORIZED); @@ -265,6 +271,7 @@ export default { }); } if (url.pathname === '/api/state' && request.method === 'GET') return handleState(env); + if (url.pathname === '/api/automation' && request.method === 'GET') return handleAutomation(env); if (url.pathname === '/api/members' && request.method === 'POST') return handleMember(request, url, env); return json({ error: 'method_not_allowed' }, 405); }, diff --git a/apps/attendance/test/automation.test.mjs b/apps/attendance/test/automation.test.mjs new file mode 100644 index 00000000..732f2f45 --- /dev/null +++ b/apps/attendance/test/automation.test.mjs @@ -0,0 +1,288 @@ +import assert from 'node:assert/strict'; +import { timingSafeEqual } from 'node:crypto'; +import test from 'node:test'; +import worker from '../src/index.ts'; +import { getAutomationResult, handleAutomation } from '../src/automation.ts'; + +// Workers expose crypto.subtle.timingSafeEqual; Node's Web Crypto does not. +if (!globalThis.crypto.subtle.timingSafeEqual) { + Object.defineProperty(globalThis.crypto.subtle, 'timingSafeEqual', { + value: (left, right) => timingSafeEqual(Buffer.from(left), Buffer.from(right)), + configurable: true, + }); +} + +const NOW = Date.UTC(2026, 10, 13, 12); +const TOKEN = 'm'.repeat(32); +const AUTH = 'Basic ' + btoa('dashboard:secret'); +const JOB_ID = 'autofix-' + 'a'.repeat(64); +const snapshot = (overrides = {}) => ({ + version: 1, + scope: 'ingress-only', + checkedAt: NOW, + windowStart: NOW - 24 * 60 * 60 * 1000, + worker: 'responding', + sources: { + sentry: { count: 1, latestAt: NOW - 1000 }, + cloudflare: { count: 0, latestAt: null }, + }, + recent: [{ id: JOB_ID, source: 'sentry', app: 'web', registeredAt: NOW - 1000, status: 'queued' }], + ...overrides, +}); +const json = (value) => Response.json(value); +const binding = (fetch) => ({ fetch }); +const env = (fetch) => ({ + ERROR_AUTOMATION: binding(fetch), + ERROR_MONITOR_TOKEN: TOKEN, + DASHBOARD_PASSWORD: 'secret', + DEVICE_TOKEN: 'device-secret', + ATTENDANCE: new Proxy( + {}, + { + get: () => { + throw new Error('monitor must never touch attendance KV'); + }, + }, + ), +}); +const request = (headers = {}, path = '/api/automation') => + new Request('https://attendance.example' + path, { headers }); + +// The route, not a test-only wrapper, must enforce dashboard Basic before any binding call. +test('automation route rejects unauthenticated and RFID Bearer callers before upstream access', async (t) => { + t.mock.method(Date, 'now', () => NOW); + let calls = 0; + const workerEnv = env(async (url) => { + calls++; + assert.ok( + ['https://error-automation.internal/healthz', 'https://error-automation.internal/healthz/rocky'].includes(url), + ); + return json(snapshot()); + }); + for (const headers of [{}, { authorization: 'Bearer device-secret' }, { authorization: 'Basic !!!' }]) { + const response = await worker.fetch(request(headers), workerEnv); + assert.equal(response.status, 401); + assert.match(response.headers.get('www-authenticate'), /Basic/); + } + assert.equal(calls, 0); + const response = await worker.fetch( + request({ authorization: AUTH }, '/api/automation?url=https%3A%2F%2Fattacker.example%2Fhealthz'), + workerEnv, + ); + assert.equal(response.status, 200); + assert.equal(calls, 2); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal((await response.json()).state, 'ok'); +}); + +test('fixed service binding, HTTPS path, private token, and no redirect follow', async () => { + let calls = 0; + const result = await getAutomationResult( + env(async (url, options) => { + calls++; + assert.equal(url, 'https://error-automation.internal/healthz'); + assert.equal(options.method, 'GET'); + assert.equal(options.headers.authorization, 'Bearer ' + TOKEN); + assert.equal(options.redirect, 'manual'); + assert.ok(options.signal); + return Response.redirect('https://attacker.example/collect', 302); + }), + () => NOW, + ); + assert.equal(calls, 1); + assert.deepEqual(result, { state: 'unknown', checkedAt: null, data: null, reason: 'unavailable' }); + assert.ok(!JSON.stringify(result).includes(TOKEN)); +}); + +test('missing or short server-side secret and missing binding never fabricate empty traffic', async () => { + let calls = 0; + const base = env(async () => { + calls++; + return json(snapshot()); + }); + for (const settings of [ + { ERROR_AUTOMATION: undefined }, + { ERROR_MONITOR_TOKEN: undefined }, + { ERROR_MONITOR_TOKEN: 'device-secret' }, + ]) { + const result = await getAutomationResult({ ...base, ...settings }, () => NOW); + assert.deepEqual(result, { state: 'unknown', checkedAt: null, data: null, reason: 'not_configured' }); + } + assert.equal(calls, 0); +}); + +test('quiet observed zero is healthy; stale, malformed, and sensitive extra fields are unknown', async () => { + const empty = snapshot({ + sources: { sentry: { count: 0, latestAt: null }, cloudflare: { count: 0, latestAt: null } }, + recent: [], + }); + const good = await getAutomationResult( + env(async () => json(empty)), + () => NOW, + ); + assert.equal(good.state, 'ok'); + assert.equal(good.data.sources.sentry.count, 0); + for (const payload of [ + snapshot({ + checkedAt: NOW - 121_000, + windowStart: NOW - 121_000 - 24 * 60 * 60 * 1000, + sources: empty.sources, + recent: [], + }), + snapshot({ sources: { sentry: { count: -1, latestAt: NOW }, cloudflare: { count: 0, latestAt: null } } }), + snapshot({ recent: [{ ...snapshot().recent[0], diagnostic: 'private stack trace' }] }), + snapshot({ pr_url: 'https://github.example/private' }), + ]) { + const result = await getAutomationResult( + env(async () => json(payload)), + () => NOW, + ); + assert.equal(result.state, 'unknown'); + assert.equal(result.data, null); + assert.equal(result.checkedAt, null); + assert.ok(!JSON.stringify(result).includes('private stack trace')); + } +}); + +test('stale but structurally valid snapshot has explicit unknown/stale state, not zero', async () => { + const checkedAt = NOW - 121_000; + const payload = snapshot({ + checkedAt, + windowStart: checkedAt - 24 * 60 * 60 * 1000, + sources: { sentry: { count: 0, latestAt: null }, cloudflare: { count: 0, latestAt: null } }, + recent: [], + }); + assert.deepEqual( + await getAutomationResult( + env(async () => json(payload)), + () => NOW, + ), + { + state: 'unknown', + checkedAt: null, + data: null, + reason: 'stale', + }, + ); +}); + +test('body size bounds apply even to streaming responses without Content-Length', async () => { + const oversized = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(10_000)); + controller.enqueue(new Uint8Array(7_000)); + controller.close(); + }, + }); + for (const response of [ + () => new Response('x', { headers: { 'content-type': 'application/json', 'content-length': '16385' } }), + () => new Response(oversized, { headers: { 'content-type': 'application/json' } }), + () => new Response(Uint8Array.of(0xff), { headers: { 'content-type': 'application/json' } }), + () => new Response(JSON.stringify(snapshot()), { headers: { 'content-type': 'text/html' } }), + ]) { + const result = await getAutomationResult( + env(async () => response()), + () => NOW, + ); + assert.deepEqual(result, { state: 'unknown', checkedAt: null, data: null, reason: 'unavailable' }); + } +}); + +test('five-second deadline covers a stalled response body, not merely binding fetch', { timeout: 8_000 }, async () => { + let aborted = false; + const start = performance.now(); + const result = await getAutomationResult( + env(async (_url, options) => { + options.signal.addEventListener('abort', () => { + aborted = true; + }); + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.of(123)); + }, + }), + { + headers: { 'content-type': 'application/json' }, + }, + ); + }), + () => NOW, + ); + assert.deepEqual(result, { state: 'unknown', checkedAt: null, data: null, reason: 'unavailable' }); + assert.ok(aborted); + assert.ok(performance.now() - start >= 4_900); +}); + +test('API response does not expose monitor token, upstream diagnostic fields, or cacheable data', async () => { + const response = await handleAutomation(env(async () => json(snapshot()))); + const body = await response.text(); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.ok(!body.includes(TOKEN)); + assert.ok(!body.includes('diagnostic')); + assert.ok(!body.includes('sourceId')); + assert.ok(!body.includes('pr_url')); +}); + +function rockySnapshot() { + const unknownUnit = { configured: null, state: 'unknown', process: 'unknown', checkedAt: null }; + return { + version: 1, + scope: 'rocky-read-only', + checkedAt: NOW, + gateway: 'responding', + cpu: { usagePercent: 12.5, sampleStart: NOW - 200, sampleEnd: NOW }, + memory: { totalBytes: 8192, availableBytes: 6144, usedBytes: 2048, checkedAt: NOW }, + executor: { configured: true, state: 'active', process: 'running', checkedAt: NOW }, + broker: unknownUnit, + brokerResponse: 'not_probed', + model: 'not_probed', + }; +} + +test('incoming issues and Rocky health fail independently without fabricating counts or measurements', async (t) => { + t.mock.method(Date, 'now', () => NOW); + for (const failed of ['ingress', 'rocky']) { + const response = await handleAutomation( + env(async (url) => { + const host = url.endsWith('/rocky'); + if ((failed === 'rocky') === host) throw new Error('private-upstream-detail'); + return json(host ? rockySnapshot() : snapshot()); + }), + ); + const result = await response.json(); + assert.equal(response.status, 200); + assert.equal(result.state, failed === 'ingress' ? 'unknown' : 'ok'); + assert.equal(result.rocky.state, failed === 'rocky' ? 'unknown' : 'ok'); + if (failed === 'ingress') { + assert.equal(result.data, null); + assert.equal(result.rocky.data.cpu.usagePercent, 12.5); + assert.equal(result.rocky.data.broker.process, 'unknown'); + assert.equal(result.rocky.data.brokerResponse, 'not_probed'); + assert.equal(result.rocky.data.model, 'not_probed'); + } else { + assert.equal(result.data.sources.sentry.count, 1); + assert.equal(result.rocky.data, null); + assert.equal(result.rocky.checkedAt, null); + } + assert.doesNotMatch(JSON.stringify(result), /private-upstream|Bearer/); + } +}); + +test('invalid or stale Rocky measurements cannot overwrite a valid ingress summary', async (t) => { + t.mock.method(Date, 'now', () => NOW); + for (const payload of [ + { ...rockySnapshot(), checkedAt: NOW - 121_000 }, + { ...rockySnapshot(), memory: { ...rockySnapshot().memory, usedBytes: 0 } }, + { ...rockySnapshot(), broker: { ...rockySnapshot().broker, credentials: 'private-broker-token' } }, + { ...rockySnapshot(), model: 'healthy' }, + ]) { + const response = await handleAutomation(env(async (url) => json(url.endsWith('/rocky') ? payload : snapshot()))); + const result = await response.json(); + assert.equal(result.state, 'ok'); + assert.equal(result.data.sources.sentry.count, 1); + assert.equal(result.rocky.state, 'unknown'); + assert.equal(result.rocky.data, null); + assert.doesNotMatch(JSON.stringify(result), /private-broker/); + } +}); diff --git a/apps/attendance/tsconfig.json b/apps/attendance/tsconfig.json index 817dd5d8..783e3f63 100644 --- a/apps/attendance/tsconfig.json +++ b/apps/attendance/tsconfig.json @@ -11,5 +11,5 @@ "erasableSyntaxOnly": true, "allowImportingTsExtensions": true }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "worker-configuration.d.ts"] } diff --git a/apps/attendance/worker-configuration.d.ts b/apps/attendance/worker-configuration.d.ts new file mode 100644 index 00000000..2e8c08ec --- /dev/null +++ b/apps/attendance/worker-configuration.d.ts @@ -0,0 +1,37 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types --include-runtime=false` (hash: f10478503a80b9bd2db903e0b46e7468) +interface __BaseEnv_Env { + ATTENDANCE: KVNamespace; + ERROR_AUTOMATION: Fetcher /* bcsd-error-automation */; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./src/index"); + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} +declare module "*.txt" { + const value: string; + export default value; +} +declare module "*.html" { + const value: string; + export default value; +} +declare module "*.sql" { + const value: string; + export default value; +} +declare module "*.bin" { + const value: ArrayBuffer; + export default value; +} +declare module "*.wasm" { + const value: WebAssembly.Module; + export default value; +} +declare module "*.wasm?module" { + const value: WebAssembly.Module; + export default value; +} \ No newline at end of file diff --git a/apps/attendance/wrangler.jsonc b/apps/attendance/wrangler.jsonc index cbc04d7b..1fe3d5a8 100644 --- a/apps/attendance/wrangler.jsonc +++ b/apps/attendance/wrangler.jsonc @@ -8,4 +8,5 @@ "observability": { "enabled": true }, // 기존 운영 네임스페이스를 재사용합니다. 새 네임스페이스로 치환하지 않습니다. "kv_namespaces": [{ "binding": "ATTENDANCE", "id": "2cc47a111f334c4ea99deb2435263e00" }], + "services": [{ "binding": "ERROR_AUTOMATION", "service": "bcsd-error-automation" }], } diff --git a/apps/error-automation/README.md b/apps/error-automation/README.md index 10bc942c..deb68d21 100644 --- a/apps/error-automation/README.md +++ b/apps/error-automation/README.md @@ -43,12 +43,17 @@ pnpm --dir apps/error-automation exec wrangler secret put GITHUB_WEBHOOK_SECRET pnpm --dir apps/error-automation exec wrangler secret put AUTOFIX_EXECUTOR_TOKEN # 모델 upstream: Rocky omp 게이트웨이. VPC 바인딩 `OMP_GATEWAY`(wrangler.jsonc)와 이 bearer가 함께 있어야 추론이 가능하다. pnpm --dir apps/error-automation exec wrangler secret put OMP_GATEWAY_TOKEN +# 읽기 전용 모니터: ERROR_MONITOR_TOKEN은 attendance와만, OMP_GATEWAY_MONITOR_TOKEN은 Rocky와만 공유한다. +pnpm --dir apps/error-automation exec wrangler secret put ERROR_MONITOR_TOKEN +pnpm --dir apps/error-automation exec wrangler secret put OMP_GATEWAY_MONITOR_TOKEN ``` `SENTRY_CLIENT_SECRET`는 Sentry 웹훅 서명, `SENTRY_READ_TOKEN`은 Sentry canonical issue/event 조회에만 사용한다. `CLOUDFLARE_WEBHOOK_SECRET`는 Issues Generic Webhook의 `cf-webhook-auth`와 같은 32자 이상 전용 무작위 값이다. `CLOUDFLARE_DIAGNOSTICS_TOKEN`은 BCSD 한 계정의 Workers Observability Write(공식 조회 API도 이 권한 요구)와 **koin-web/koin-api 두 Worker만** Individual Workers Content Read-Only를 가진 별도 토큰이다. 배포/쓰기 명령에는 사용하지 않고, 고정 Cloudflare API 호스트의 승인된 두 서비스 증거 조회에만 사용한다. `DIAGNOSTICS_READ_TOKEN`은 허용 scope의 진단 GET 전용 32자 이상 값이다. `AUTOFIX_EXECUTOR_TOKEN`은 별도 32자 이상 값으로 **Rocky 전용 실행기와 Worker만** 공유한다. 다른 서비스의 광범위한 제어 토큰을 재사용하지 않고, 해당 호스트에 외부 인바운드 포트를 열지 않는다. Worker는 provider API 키를 갖지 않으며 `ANTHROPIC_API_KEY` 경로는 제거되었다. `OMP_GATEWAY`/`OMP_GATEWAY_TOKEN`이 **유일한** 모델 upstream이다. `OMP_GATEWAY`는 Workers VPC 서비스 `koin-omp-gateway`(`01a1bdd6-d594-76d1-9898-d116f61b6be7`) 바인딩이며, Cloudflare 터널 `koin-omp-gateway-rocky`를 거쳐 Rocky의 `127.0.0.1:43129` 게이트웨이(`scripts/autofix/local-omp-gateway.mjs`, 인증은 Rocky omp auth-broker)로만 연결된다. 공개 hostname은 없다. 둘 다 없으면 접수·진단·큐는 그대로 동작하고 모델 호출만 `omp gateway not configured`로 실패한다. 둘 중 하나라도 있으면 둘 다 유효해야 한다: 바인딩은 `fetch`를 가진 서비스이고, 토큰은 헤더에 안전한 32~512자다. 잘못되거나 반쪽만 있는 설정은 `configuration()`에서 503으로 닫힌다. Worker는 고정 model/system/tools/max_tokens 본문을 바인딩의 `POST /v1/messages`에 `Authorization: Bearer `으로만 보내고(`x-api-key`·`anthropic-version` 없음), 리디렉션·응답 모델 불일치(`anthropic/claude-…` 같은 provider 접두 포함)·비정상 상태는 그대로 실패다. 게이트웨이 실패 뒤 다른 upstream·계정·키로 재시도하지 않는다. 토큰은 다른 secret과 함께 진단·이슈 본문에서 마스킹된다. 이 bearer는 provider 키가 아니라 게이트웨이 접근 토큰이며 실행기·후보 소스·Docker에 전달하지 않는다. Workers Builds 토큰에는 VPC 바인딩을 위한 `Connectivity Directory Bind`가 있어야 한다. +읽기 전용 모니터는 동일 바인딩의 고정 `GET /healthz`를 별도 `OMP_GATEWAY_MONITOR_TOKEN`으로 조회한다. Worker의 `GET /healthz`는 수신 저장소 요약, `GET /healthz/rocky`는 호스트 관측이며 두 경로 모두 `ERROR_MONITOR_TOKEN`으로 인증한다. 수신 요약은 VPC 없이도 동작하고 호스트 조회는 모델·실행기 자격 증명을 필요로 하지 않는다. CPU/RAM·systemd 프로세스 관측과 모델 정상 여부를 혼동하지 않으며 상세 설정·비공개 연결·기한·미확인 상태는 [읽기 전용 모니터 운영 계약](../../scripts/autofix/README.md#읽기-전용-수신-상태rocky-헬스)을 따른다. + 기존 `GITHUB_DISPATCH_APP_*`은 단일 고정 App의 설정명이다. 일반 read 토큰(contents/issues/pull requests read)과 발행 write 토큰(contents/issues/pull requests write)은 기존 범위를 유지한다. **자동 merge 판정 전용** `merge-evidence` 토큰만 contents/pull requests/checks/statuses read를 요청한다. 조직 소유자가 아직 Checks/Commit statuses read 권한 변경을 승인하지 않았다면 이 토큰 발급 거절은 리뷰 발행을 막지 않고 `App 권한(checks/statuses) 승인 대기` 사유로 merge만 건너뛴다. App에는 Pull requests write, Contents write, Checks read, Commit statuses read와 Pull request webhook 이벤트가 필요하다. 키와 토큰은 모델·실행기·DO 저장소에 전달하지 않는다. `GITHUB_WEBHOOK_SECRET`은 App webhook과 일치하는 32자 이상 전용 secret이다. Worker의 외부 HTTP 호출은 `redirect: "manual"`과 비정상 상태 거부를 함께 사용합니다. workerd는 `"error"` 모드를 지원하지 않으며, 자동 리디렉션으로 GitHub·모델 자격 증명을 다른 호스트에 보내지 않습니다. 접수 실패 로그에는 단계·고정 오류 분류·코드 위치만, 모델 거부에는 상태·오류 종류·고정 분류만 남기고 원문 요청/응답과 키는 기록하지 않습니다. diff --git a/apps/error-automation/package.json b/apps/error-automation/package.json index daba39cd..d028f66f 100644 --- a/apps/error-automation/package.json +++ b/apps/error-automation/package.json @@ -5,11 +5,12 @@ "scripts": { "dev": "wrangler dev", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "node --experimental-strip-types --test test/webhook.test.mjs test/workflow.test.mjs test/queue.test.mjs test/model.test.mjs test/pull-request.test.mjs", + "test": "node --experimental-strip-types --test test/webhook.test.mjs test/workflow.test.mjs test/queue.test.mjs test/model.test.mjs test/pull-request.test.mjs test/monitor.test.mjs", "smoke:local": "node test/smoke-local.mjs", "deploy": "wrangler deploy" }, "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31" + "@jridgewell/trace-mapping": "^0.3.31", + "@koin/telemetry": "workspace:*" } } diff --git a/apps/error-automation/src/index.ts b/apps/error-automation/src/index.ts index e956d107..fbbfe9bd 100644 --- a/apps/error-automation/src/index.ts +++ b/apps/error-automation/src/index.ts @@ -1,4 +1,5 @@ import { DurableObject } from 'cloudflare:workers'; +import { parseErrorMonitorSnapshot, parseRockyMonitorSnapshot, readMonitorJson } from '@koin/telemetry/monitor'; import { DiagnosticProviderError, trustedDiagnostics } from './diagnostics'; import { SourceMapEvidenceError } from './source-maps'; import { gatewayTransport, modelRateLimit, parseModelRequest } from './model'; @@ -444,6 +445,8 @@ export function redact(value: unknown, env: Env): string { env.SENTRY_READ_TOKEN, env.DIAGNOSTICS_READ_TOKEN, env.AUTOFIX_EXECUTOR_TOKEN, + env.ERROR_MONITOR_TOKEN, + env.OMP_GATEWAY_MONITOR_TOKEN, ]) { if (secret) text = text.replaceAll(secret, '[REDACTED]'); } @@ -594,15 +597,94 @@ function cloudflarePayload(raw: unknown, config: Configuration, env: Env): Diagn ); } -async function verifySecret(value: string | null, secret: string): Promise { - if (!value || value.length > 4096 || !secret) throw new RequestError(401, 'Missing or invalid authentication.'); +async function sameSecret(value: string, secret: string): Promise { const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, [ 'sign', 'verify', ]); const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(secret)); - if (!(await crypto.subtle.verify('HMAC', key, signature, encoder.encode(value)))) { + return crypto.subtle.verify('HMAC', key, signature, encoder.encode(value)); +} + +async function verifySecret(value: string | null, secret: string): Promise { + if (!value || value.length > 4096 || !secret || !(await sameSecret(value, secret))) throw new RequestError(401, 'Missing or invalid authentication.'); +} + +/** 인증·고정 경로 검증은 저장소와 VPC 접속보다 먼저 끝냅니다. */ +async function authorizeMonitoring(request: Request, env: Env, url: URL): Promise { + if (!env.ERROR_MONITOR_TOKEN || env.ERROR_MONITOR_TOKEN.length < 32) + throw new RequestError(503, 'Read-only monitoring is not configured.'); + const authorization = request.headers.get('Authorization'); + await verifySecret(authorization?.startsWith('Bearer ') ? authorization.slice(7) : null, env.ERROR_MONITOR_TOKEN); + if (request.method !== 'GET') + return new Response('Use GET.', { status: 405, headers: { Allow: 'GET', 'Cache-Control': 'no-store' } }); + if (url.search) throw new RequestError(400, 'Monitoring query parameters are not supported.'); + return null; +} + +/** 수신 요약은 Rocky 연결 실패와 독립적으로 저장된 이슈만 읽습니다. */ +async function monitoringHealth(request: Request, env: Env, url: URL): Promise { + const denied = await authorizeMonitoring(request, env, url); + if (denied) return denied; + const { promise: deadline, reject } = Promise.withResolvers(); + const timer = setTimeout(() => reject(new RequestError(503, 'Monitoring storage timed out.')), 3000); + try { + const result = await Promise.race([ + env.AUTOFIX_QUEUE.getByName('bcsdlab/bcsd_united').monitorSnapshot(Date.now()), + deadline, + ]); + const snapshot = parseErrorMonitorSnapshot(result, Date.now()); + if (!snapshot) throw new RequestError(503, 'Monitoring snapshot is unavailable or stale.'); + return reply(200, { ...snapshot }, null); + } catch (error) { + if (error instanceof RequestError) throw error; + throw new RequestError(503, 'Monitoring snapshot is unavailable.'); + } finally { + clearTimeout(timer); + } +} + +/** 기존 VPC의 고정 healthz만 읽으며 모델용 Bearer나 공개 URL을 사용하지 않습니다. */ +async function rockyMonitoringHealth(request: Request, env: Env, url: URL): Promise { + const denied = await authorizeMonitoring(request, env, url); + if (denied) return denied; + const gateway = env.OMP_GATEWAY; + const token = env.OMP_GATEWAY_MONITOR_TOKEN; + if (!gateway || !token || !/^[A-Za-z0-9_-]{32,512}$/.test(token)) + throw new RequestError(503, 'Rocky read-only monitoring is not configured.'); + for (const privileged of [env.OMP_GATEWAY_TOKEN, env.AUTOFIX_EXECUTOR_TOKEN, env.ERROR_MONITOR_TOKEN]) { + if (privileged && (await sameSecret(token, privileged))) + throw new RequestError(503, 'Rocky read-only monitoring requires a separate credential.'); + } + const controller = new AbortController(); + const { promise: deadline, reject } = Promise.withResolvers(); + const timer = setTimeout(() => { + controller.abort(); + reject(new RequestError(503, 'Rocky monitoring timed out.')); + }, 3000); + try { + const task = async (): Promise => { + const response = await gateway.fetch('http://omp-gateway/healthz', { + method: 'GET', + redirect: 'manual', + headers: { Authorization: `Bearer ${token}` }, + signal: controller.signal, + }); + if (response.status !== 200 || response.redirected) { + void response.body?.cancel().catch(() => {}); + throw new RequestError(503, 'Rocky monitoring is unavailable.'); + } + const snapshot = parseRockyMonitorSnapshot(await readMonitorJson(response, controller.signal), Date.now()); + if (!snapshot) throw new RequestError(503, 'Rocky monitoring is unavailable or stale.'); + return reply(200, { ...snapshot }, null); + }; + return await Promise.race([task(), deadline]); + } catch (error) { + if (error instanceof RequestError) throw error; + throw new RequestError(503, 'Rocky monitoring is unavailable.'); + } finally { + clearTimeout(timer); } } @@ -932,6 +1014,8 @@ export default { if (url.protocol !== 'https:') throw new RequestError(400, 'HTTPS is required.'); if (env.ENVIRONMENT !== 'production') throw new RequestError(503, 'Only the fixed production installation is enabled.'); + if (url.pathname === '/healthz') return await monitoringHealth(request, env, url); + if (url.pathname === '/healthz/rocky') return await rockyMonitoringHealth(request, env, url); if (url.pathname.startsWith('/executor/')) return await executorRequest(request, env, url); const retrieval = url.pathname.startsWith('/diagnostics/'); if (!retrieval && !['/webhooks/sentry', '/webhooks/cloudflare', '/webhooks/github'].includes(url.pathname)) diff --git a/apps/error-automation/src/queue.ts b/apps/error-automation/src/queue.ts index 061b175d..718eaf21 100644 --- a/apps/error-automation/src/queue.ts +++ b/apps/error-automation/src/queue.ts @@ -1,4 +1,5 @@ import { DurableObject } from 'cloudflare:workers'; +import { parseErrorMonitorSnapshot, type ErrorMonitorSnapshot } from '@koin/telemetry/monitor'; import { autoMergePullRequest, type AutoMergeOutcome } from './auto-merge'; import { prepare } from './prepare'; import { redact } from './index'; @@ -71,7 +72,7 @@ const INPUT_OVERHEAD_TOKENS = 1024; const MAX_PENDING = 100; const encoder = new TextEncoder(); -type Status = 'queued' | 'preparing' | 'leased' | 'verifying' | 'reporting' | 'publishing' | 'completed' | 'failed'; +type Status = ErrorMonitorSnapshot['recent'][number]['status']; interface Row extends Record { run_id: string; identity_key: string; @@ -91,6 +92,19 @@ interface Row extends Record { model_input_tokens: number; model_output_tokens: number; } + +interface MonitorRow extends Record { + run_id: string | null; + source: 'sentry' | 'cloudflare' | null; + app: 'web' | 'api' | null; + status: Status | null; + enqueued_at: number | null; + sentry_count: number; + sentry_latest_at: number | null; + cloudflare_count: number; + cloudflare_latest_at: number | null; + invalid_count: number; +} export interface Claim { version: 1; runId: string; @@ -248,6 +262,88 @@ export class AutofixQueue extends DurableObject { if (columns.has('issue_number')) ctx.storage.sql.exec('ALTER TABLE jobs DROP COLUMN issue_number'); } + /** 처음 등록된 오류 identity만 집계한다. 조회는 만료 처리·알람·작업 상태를 건드리지 않는다. */ + monitorSnapshot(now: number): ErrorMonitorSnapshot { + if (!Number.isSafeInteger(now) || now < 24 * 60 * 60_000) throw new Error('Invalid monitor time'); + const windowStart = now - 24 * 60 * 60_000; + const rows = this.ctx.storage.sql + .exec( + `WITH registered AS ( + SELECT run_id, status, enqueued_at, + json_extract(identity, '$.source') AS source, + json_extract(identity, '$.app') AS app, + json_extract(identity, '$.scope.workerName') AS worker_name, + json_type(identity, '$.scope.workerName') AS worker_type + FROM jobs + WHERE enqueued_at BETWEEN ? AND ? + AND (identity_key LIKE 'sentry/%' OR identity_key LIKE 'cloudflare/%') + ), classified AS ( + SELECT *, + CASE WHEN source = 'sentry' AND app = 'web' THEN 0 + WHEN source = 'cloudflare' AND app IN ('web', 'api') AND worker_type = 'text' AND worker_name != '' THEN 0 + ELSE 1 END AS invalid + FROM registered + ), eligible AS ( + SELECT run_id, source, app, status, enqueued_at, invalid + FROM classified + WHERE invalid = 1 OR source = 'sentry' OR worker_name != 'koin-api' + ), totals AS ( + SELECT count(CASE WHEN source = 'sentry' THEN 1 END) AS sentry_count, + max(CASE WHEN source = 'sentry' THEN enqueued_at END) AS sentry_latest_at, + count(CASE WHEN source = 'cloudflare' THEN 1 END) AS cloudflare_count, + max(CASE WHEN source = 'cloudflare' THEN enqueued_at END) AS cloudflare_latest_at, + coalesce(sum(invalid), 0) AS invalid_count + FROM eligible + ), recent AS ( + SELECT run_id, source, app, status, enqueued_at + FROM eligible + ORDER BY enqueued_at DESC, run_id DESC + LIMIT 10 + ) + SELECT recent.*, totals.* + FROM totals LEFT JOIN recent ON 1 = 1 + ORDER BY recent.enqueued_at DESC, recent.run_id DESC`, + windowStart, + now, + ) + .toArray(); + const snapshot: ErrorMonitorSnapshot = { + version: 1, + scope: 'ingress-only', + checkedAt: now, + windowStart, + worker: 'responding', + sources: { + sentry: { count: 0, latestAt: null }, + cloudflare: { count: 0, latestAt: null }, + }, + recent: [], + }; + const totals = rows[0]; + if (totals.invalid_count !== 0) throw new Error('Invalid monitor identity'); + snapshot.sources.sentry = { count: totals.sentry_count, latestAt: totals.sentry_latest_at }; + snapshot.sources.cloudflare = { count: totals.cloudflare_count, latestAt: totals.cloudflare_latest_at }; + for (const row of rows) { + if (row.run_id === null) continue; + if ( + (row.source !== 'sentry' && row.source !== 'cloudflare') || + row.app === null || + row.status === null || + row.enqueued_at === null + ) + throw new Error('Invalid monitor identity'); + snapshot.recent.push({ + id: row.run_id, + source: row.source, + app: row.app, + registeredAt: row.enqueued_at, + status: row.status, + }); + } + if (!parseErrorMonitorSnapshot(snapshot, now)) throw new Error('Invalid monitor snapshot'); + return snapshot; + } + private async admit( identity: QueueIdentity, key: string, diff --git a/apps/error-automation/src/types.ts b/apps/error-automation/src/types.ts index 54939096..b815d04d 100644 --- a/apps/error-automation/src/types.ts +++ b/apps/error-automation/src/types.ts @@ -145,11 +145,15 @@ export interface Env { CLOUDFLARE_SOURCES: string; CLOUDFLARE_DIAGNOSTICS_TOKEN?: string; DIAGNOSTICS_READ_TOKEN: string; + /** 수신 이슈 요약 전용 Bearer. 모델·실행기·진단·태그 토큰과 재사용하지 않습니다. */ + ERROR_MONITOR_TOKEN?: string; ERROR_PROJECT_DOMAINS: string; ERROR_SOURCE_BRANCHES: string; /** The only model upstream: Workers VPC service binding to the Rocky omp gateway, paired with its bearer. */ OMP_GATEWAY?: Fetcher; OMP_GATEWAY_TOKEN?: string; + /** Rocky 읽기 전용 healthz 전용 Bearer. 모델·실행기·Worker 수신 요약 토큰과 분리합니다. */ + OMP_GATEWAY_MONITOR_TOKEN?: string; GITHUB_OWNER: string; GITHUB_REPOSITORY: string; GITHUB_DISPATCH_APP_ID: string; diff --git a/apps/error-automation/test/monitor.test.mjs b/apps/error-automation/test/monitor.test.mjs new file mode 100644 index 00000000..f5fa3d88 --- /dev/null +++ b/apps/error-automation/test/monitor.test.mjs @@ -0,0 +1,267 @@ +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; +import { test } from 'node:test'; + +const platform = + 'data:text/javascript,' + + encodeURIComponent('export class DurableObject { constructor(ctx,env) { this.ctx=ctx; this.env=env; } }'); +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === 'cloudflare:workers') return { url: platform, shortCircuit: true }; + if ( + specifier.startsWith('./') && + context.parentURL?.includes('/apps/error-automation/src/') && + !specifier.endsWith('.ts') + ) + return nextResolve(specifier + '.ts', context); + return nextResolve(specifier, context); + }, +}); +const { default: worker } = await import('../src/index.ts'); +const { parseRockyMonitorSnapshot } = await import('@koin/telemetry/monitor'); +const monitorToken = 'read-only-monitor-' + 'm'.repeat(40); +const executorToken = 'executor-only-' + 'e'.repeat(40); +function harness(read) { + let reads = 0; + let forbidden = 0; + const never = () => { + forbidden++; + throw new Error('Privileged operation must not run'); + }; + const env = { + ENVIRONMENT: 'production', + ERROR_MONITOR_TOKEN: monitorToken, + AUTOFIX_EXECUTOR_ID: 'koin-rocky-umpc', + AUTOFIX_EXECUTOR_TOKEN: executorToken, + OMP_GATEWAY: { fetch: never }, + ERROR_ISSUES: { get: never, idFromName: never }, + AUTOFIX_QUEUE: { + getByName() { + return { + async monitorSnapshot(now) { + reads++; + if (read) return read(now); + return { + version: 1, + scope: 'ingress-only', + worker: 'responding', + checkedAt: now, + windowStart: now - 86400000, + sources: { sentry: { count: 0, latestAt: null }, cloudflare: { count: 0, latestAt: null } }, + recent: [], + }; + }, + claim: never, + enqueue: never, + errorStatus: never, + }; + }, + }, + }; + function request(path = '/healthz', token = monitorToken, method = 'GET') { + return worker.fetch( + new Request('https://automation.example' + path, { + method, + headers: { + ...(token ? { Authorization: 'Bearer ' + token } : {}), + 'X-Executor-Id': 'koin-rocky-umpc', + 'Content-Type': 'application/json', + }, + }), + env, + ); + } + return { env, request, reads: () => reads, forbidden: () => forbidden }; +} + +test('monitor authentication is separate from executor access and runs without model configuration', async () => { + const h = harness(); + assert.equal((await h.request('/healthz', null)).status, 401); + assert.equal((await h.request('/healthz', executorToken)).status, 401); + assert.equal(h.reads(), 0); + const response = await h.request(); + assert.equal(response.status, 200); + assert.equal(response.headers.get('cache-control'), 'no-store'); + const body = await response.json(); + assert.equal(body.scope, 'ingress-only'); + assert.ok(!('executor' in body) && !('cpu' in body) && !('ram' in body)); + assert.equal((await h.request('/executor/claim', monitorToken, 'POST')).status, 401); + assert.equal(h.forbidden(), 0); +}); + +test('monitor credentials and exact GET path are required before reading storage', async () => { + const h = harness(); + assert.equal((await h.request('/healthz', monitorToken, 'POST')).status, 405); + assert.equal((await h.request('/healthz?url=https://untrusted.example')).status, 400); + assert.equal(h.reads(), 0); + h.env.ERROR_MONITOR_TOKEN = undefined; + assert.equal((await h.request()).status, 503); + assert.equal(h.reads(), 0); +}); + +test('unavailable or unexpectedly detailed monitoring data cannot be reported as healthy', async () => { + const secret = 'private-provider-detail'; + const h = harness(() => { + throw new Error(secret); + }); + const response = await h.request(); + assert.equal(response.status, 503); + assert.ok(!(await response.text()).includes(secret)); + const stale = harness((now) => ({ + version: 1, + scope: 'ingress-only', + worker: 'responding', + checkedAt: now - 121000, + windowStart: now - 121000 - 86400000, + sources: { sentry: { count: 0, latestAt: null }, cloudflare: { count: 0, latestAt: null } }, + recent: [], + })); + assert.equal((await stale.request()).status, 503); + assert.equal(h.forbidden(), 0); +}); + +test('monitoring storage has a bounded deadline without invoking privileged work', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const started = Promise.withResolvers(); + const stopped = Promise.withResolvers(); + const h = harness(() => { + started.resolve(); + return stopped.promise; + }); + const pending = h.request(); + await started.promise; + t.mock.timers.tick(3000); + assert.equal((await pending).status, 503); + assert.equal(h.forbidden(), 0); +}); + +const rockyToken = 'rocky-monitor-' + 'r'.repeat(40); +function rockySnapshot(now = Date.now()) { + const unit = { configured: true, state: 'active', process: 'running', checkedAt: now }; + return { + version: 1, + scope: 'rocky-read-only', + checkedAt: now, + gateway: 'responding', + cpu: { usagePercent: 0, sampleStart: now - 200, sampleEnd: now }, + memory: { totalBytes: 8192, availableBytes: 6144, usedBytes: 2048, checkedAt: now }, + executor: { ...unit }, + broker: { ...unit }, + brokerResponse: 'not_probed', + model: 'not_probed', + }; +} + +test('Rocky monitoring rejects unauthorized and parameterized requests before private host access', async () => { + const h = harness(); + h.env.OMP_GATEWAY_MONITOR_TOKEN = rockyToken; + for (const token of [null, executorToken, rockyToken]) + assert.equal((await h.request('/healthz/rocky', token)).status, 401); + assert.equal((await h.request('/healthz/rocky', monitorToken, 'POST')).status, 405); + assert.equal((await h.request('/healthz/rocky?url=https://attacker.example')).status, 400); + assert.equal(h.forbidden(), 0); + assert.equal(h.reads(), 0); +}); + +test('Rocky health is reachable only through the pinned VPC path with a role-separated credential', async () => { + const h = harness(); + h.env.OMP_GATEWAY_TOKEN = 'model-' + 'p'.repeat(40); + h.env.OMP_GATEWAY_MONITOR_TOKEN = rockyToken; + let requests = 0; + h.env.OMP_GATEWAY.fetch = async (url, options) => { + requests++; + assert.equal(url, 'http://omp-gateway/healthz'); + assert.equal(options.method, 'GET'); + assert.equal(options.redirect, 'manual'); + assert.equal(options.headers.Authorization, 'Bearer ' + rockyToken); + assert.notEqual(options.headers.Authorization, 'Bearer ' + h.env.OMP_GATEWAY_TOKEN); + return Response.json(rockySnapshot()); + }; + const response = await h.request('/healthz/rocky'); + assert.equal(response.status, 200); + const snapshot = await response.json(); + assert.equal(snapshot.gateway, 'responding'); + assert.equal(snapshot.brokerResponse, 'not_probed'); + assert.equal(snapshot.model, 'not_probed'); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal(requests, 1); + assert.equal(h.reads(), 0); + assert.equal(h.forbidden(), 0); + for (const token of [undefined, monitorToken, executorToken, h.env.OMP_GATEWAY_TOKEN]) { + h.env.OMP_GATEWAY_MONITOR_TOKEN = token; + assert.equal((await h.request('/healthz/rocky')).status, 503); + } + assert.equal(requests, 1); + assert.equal((await h.request()).status, 200); +}); + +test('Rocky failures, redirects, stale samples and sensitive extra fields never become healthy host data', async () => { + const h = harness(); + h.env.OMP_GATEWAY_MONITOR_TOKEN = rockyToken; + for (const response of [ + () => Response.redirect('https://attacker.example', 302), + () => new Response('private-broker-error', { status: 500 }), + () => Response.json(rockySnapshot(Date.now() - 121_000)), + () => Response.json({ ...rockySnapshot(), credentials: 'private-broker-token' }), + () => new Response('x', { headers: { 'content-type': 'application/json', 'content-length': '16385' } }), + ]) { + h.env.OMP_GATEWAY.fetch = async () => response(); + const reply = await h.request('/healthz/rocky'); + assert.equal(reply.status, 503); + assert.doesNotMatch(await reply.text(), /private-broker/); + assert.equal((await h.request()).status, 200); + } + assert.equal(h.forbidden(), 0); +}); + +test('Rocky health deadline bounds a stalled response body and aborts the private request', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const started = Promise.withResolvers(); + const h = harness(); + h.env.OMP_GATEWAY_MONITOR_TOKEN = rockyToken; + let aborted = false; + h.env.OMP_GATEWAY.fetch = async (_url, options) => { + options.signal.addEventListener('abort', () => { + aborted = true; + }); + started.resolve(); + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.of(123)); + }, + }), + { + headers: { 'content-type': 'application/json' }, + }, + ); + }; + const pending = h.request('/healthz/rocky'); + await started.promise; + t.mock.timers.tick(3000); + assert.equal((await pending).status, 503); + assert.equal(aborted, true); + assert.equal(h.forbidden(), 0); +}); + +test('Rocky parser distinguishes measured idle, missing measurements and unprobed broker/model health', () => { + const now = Date.now(); + const snapshot = rockySnapshot(now); + assert.deepEqual(parseRockyMonitorSnapshot(snapshot, now), snapshot); + const unknownUnit = { configured: null, state: 'unknown', process: 'unknown', checkedAt: null }; + const unknown = { ...snapshot, cpu: null, memory: null, executor: unknownUnit, broker: unknownUnit }; + assert.deepEqual(parseRockyMonitorSnapshot(unknown, now), unknown); + for (const invalid of [ + { ...snapshot, brokerResponse: 'responding' }, + { ...snapshot, model: 'healthy' }, + { ...snapshot, cpu: { ...snapshot.cpu, sampleEnd: now - 200 } }, + { ...snapshot, cpu: { ...snapshot.cpu, usagePercent: 101 } }, + { ...snapshot, memory: { ...snapshot.memory, usedBytes: 0 } }, + { ...snapshot, memory: { ...snapshot.memory, availableBytes: 8193 } }, + { ...snapshot, executor: { ...snapshot.executor, configured: false } }, + { ...snapshot, broker: { ...snapshot.broker, checkedAt: now - 121_000 } }, + { ...snapshot, broker: { ...snapshot.broker, checkedAt: now + 1 } }, + { ...snapshot, broker: { ...snapshot.broker, raw: 'private state' } }, + ]) + assert.equal(parseRockyMonitorSnapshot(invalid, now), null); +}); diff --git a/apps/error-automation/test/queue.test.mjs b/apps/error-automation/test/queue.test.mjs index c231bf7a..5ebf1eda 100644 --- a/apps/error-automation/test/queue.test.mjs +++ b/apps/error-automation/test/queue.test.mjs @@ -20,6 +20,7 @@ registerHooks({ }, }); const { AutofixQueue } = await import('../src/queue.ts'); +const { parseErrorMonitorSnapshot } = await import('../../../packages/telemetry/src/monitor.ts'); const identity = { source: 'sentry', sourceId: '123', @@ -309,3 +310,224 @@ test('issue-free schema migration preserves prior identity tombstones and PR rec assert.equal(receipt.prUrl, 'https://github.com/BCSDLab/BCSD_united/pull/99'); assert.equal(receipt.reason, null); }); + +test('monitor snapshot counts registered identities in 24h without exposing job payloads or changing state', async (t) => { + const h = harness(t); + const now = Date.now(); + const start = now - 24 * 60 * 60_000; + const secret = 'private-source-id-and-diagnostic'; + const insert = h.db.prepare( + 'INSERT INTO jobs (run_id, identity_key, identity, status, enqueued_at, reason, prepared) VALUES (?, ?, ?, ?, ?, ?, ?)', + ); + const add = (digit, source, app, workerName, registeredAt, status = 'queued') => { + const rowIdentity = { + source, + sourceId: `${secret}-${digit}`, + project: workerName ?? 'koin-united-prod', + app, + scope: + source === 'cloudflare' ? { accountId: 'private', policyId: 'private', workerName } : { organization: 'bcsd' }, + }; + insert.run( + `autofix-${digit.toString(16).padStart(64, '0')}`, + `${source}/${digit}`, + JSON.stringify(rowIdentity), + status, + registeredAt, + `private-reason-${digit}`, + `private-prepared-${digit}`, + ); + }; + assert.equal(await h.queue.enqueue(identity, key), 'queued'); + assert.equal(await h.queue.enqueue(identity, key), 'duplicate'); + h.db.prepare('UPDATE jobs SET enqueued_at = ? WHERE identity_key = ?').run(start, key); + for (let i = 1; i <= 12; i++) add(i, 'sentry', 'web', null, now - i * 1000, i === 1 ? 'failed' : 'completed'); + add(13, 'cloudflare', 'api', 'koin-web-api', now - 500, 'verifying'); + add(14, 'cloudflare', 'web', 'koin-web', now - 700, 'leased'); + add(15, 'cloudflare', 'api', 'koin-api', now - 300); + add(16, 'cloudflare', 'web', 'koin-api', now - 200); + add(17, 'sentry', 'web', null, start - 1); + add(18, 'cloudflare', 'web', 'koin-web', now + 1); + insert.run( + `autofix-${'f'.repeat(64)}`, + 'github-pr/42', + JSON.stringify({ source: 'github-pr', pullNumber: 42 }), + 'queued', + now, + 'private-pr-reason', + 'private-pr-payload', + ); + const before = h.db.prepare('SELECT * FROM jobs ORDER BY run_id').all(); + const changes = h.db.prepare('SELECT total_changes() AS count').get().count; + const snapshot = h.queue.monitorSnapshot(now); + assert.deepEqual(snapshot.sources, { + sentry: { count: 13, latestAt: now - 1000 }, + cloudflare: { count: 2, latestAt: now - 500 }, + }); + assert.equal(snapshot.checkedAt, now); + assert.equal(snapshot.windowStart, start); + assert.equal(snapshot.scope, 'ingress-only'); + assert.equal(snapshot.worker, 'responding'); + assert.equal(snapshot.recent.length, 10); + assert.deepEqual( + snapshot.recent.slice(0, 3).map(({ source, registeredAt, status }) => [source, registeredAt, status]), + [ + ['cloudflare', now - 500, 'verifying'], + ['cloudflare', now - 700, 'leased'], + ['sentry', now - 1000, 'failed'], + ], + ); + assert.equal( + snapshot.recent.every( + (item, index) => + item.registeredAt >= start && (!index || item.registeredAt <= snapshot.recent[index - 1].registeredAt), + ), + true, + ); + assert.equal( + snapshot.recent.every(({ id }) => /^autofix-[a-f0-9]{64}$/.test(id)), + true, + ); + assert.doesNotMatch( + JSON.stringify(snapshot), + /private|sourceId|identity_key|prepared|reason|pr_url|koin-api|github-pr/, + ); + assert.deepEqual(h.db.prepare('SELECT * FROM jobs ORDER BY run_id').all(), before); + assert.equal(h.db.prepare('SELECT total_changes() AS count').get().count, changes); + assert.deepEqual(h.alarms, []); + assert.deepEqual(h.writes, []); + assert.deepEqual(new AutofixQueue(h.queue.ctx, h.queue.env).monitorSnapshot(now), snapshot); +}); + +test('monitor snapshot aggregates a source absent from recent ten and rejects invalid older rows', (t) => { + const h = harness(t); + const now = Date.now(); + const insert = h.db.prepare( + 'INSERT INTO jobs (run_id, identity_key, identity, status, enqueued_at) VALUES (?, ?, ?, ?, ?)', + ); + for (let i = 1; i <= 12; i++) { + insert.run( + `autofix-${i.toString(16).padStart(64, '0')}`, + `sentry/${i}`, + JSON.stringify({ ...identity, sourceId: String(i) }), + 'queued', + now - i * 1000, + ); + } + const cloudflareIdentity = { + source: 'cloudflare', + sourceId: 'older', + project: 'koin-web', + app: 'web', + scope: { accountId: 'account', policyId: 'policy', workerName: 'koin-web' }, + }; + insert.run( + `autofix-${'d'.repeat(64)}`, + 'cloudflare/older', + JSON.stringify(cloudflareIdentity), + 'failed', + now - 20_000, + ); + const snapshot = h.queue.monitorSnapshot(now); + assert.deepEqual(snapshot.sources, { + sentry: { count: 12, latestAt: now - 1000 }, + cloudflare: { count: 1, latestAt: now - 20_000 }, + }); + assert.equal(snapshot.recent.length, 10); + assert.equal( + snapshot.recent.every((entry) => entry.source === 'sentry'), + true, + ); + + insert.run( + `autofix-${'e'.repeat(64)}`, + 'cloudflare/invalid', + JSON.stringify({ ...cloudflareIdentity, scope: { accountId: 'account', policyId: 'policy' } }), + 'queued', + now - 30_000, + ); + assert.throws(() => h.queue.monitorSnapshot(now), /Invalid monitor identity/); + h.db.prepare('UPDATE jobs SET identity = ? WHERE identity_key = ?').run('{not json', 'cloudflare/invalid'); + assert.throws(() => h.queue.monitorSnapshot(now)); +}); + +test('monitor snapshot reports observed empty windows, not stale jobs or malformed identities', (t) => { + const h = harness(t); + const now = Date.now(); + assert.deepEqual(h.queue.monitorSnapshot(now).sources, { + sentry: { count: 0, latestAt: null }, + cloudflare: { count: 0, latestAt: null }, + }); + h.db + .prepare('INSERT INTO jobs (run_id, identity_key, identity, status, enqueued_at) VALUES (?, ?, ?, ?, ?)') + .run(`autofix-${'a'.repeat(64)}`, 'sentry/bad', '{not json', 'queued', now); + assert.throws(() => h.queue.monitorSnapshot(now)); + h.db.prepare('UPDATE jobs SET identity = ?').run( + JSON.stringify({ + source: 'cloudflare', + sourceId: 'id', + project: 'koin-web', + app: 'web', + scope: { accountId: 'account', policyId: 'policy' }, + }), + ); + assert.throws(() => h.queue.monitorSnapshot(now), /Invalid monitor identity/); + assert.throws(() => h.queue.monitorSnapshot(NaN), /Invalid monitor time/); +}); + +test('shared monitor parser accepts fresh ingress snapshots and rejects stale, unsafe, or inconsistent payloads', () => { + const now = Date.now(); + const snapshot = { + version: 1, + scope: 'ingress-only', + checkedAt: now, + windowStart: now - 24 * 60 * 60_000, + worker: 'responding', + sources: { + sentry: { count: 1, latestAt: now - 10 }, + cloudflare: { count: 0, latestAt: null }, + }, + recent: [ + { id: `autofix-${'a'.repeat(64)}`, source: 'sentry', app: 'web', registeredAt: now - 10, status: 'queued' }, + ], + }; + assert.deepEqual(parseErrorMonitorSnapshot(snapshot, now), snapshot); + const shift = (offset) => ({ + ...snapshot, + checkedAt: now + offset, + windowStart: now + offset - 24 * 60 * 60_000, + sources: { ...snapshot.sources, sentry: { count: 1, latestAt: now + offset - 10 } }, + recent: [{ ...snapshot.recent[0], registeredAt: now + offset - 10 }], + }); + assert.deepEqual(parseErrorMonitorSnapshot(shift(-120_000), now), shift(-120_000)); + assert.deepEqual(parseErrorMonitorSnapshot(shift(60_000), now), shift(60_000)); + assert.equal(parseErrorMonitorSnapshot(shift(-120_001), now), null); + assert.equal(parseErrorMonitorSnapshot(shift(60_001), now), null); + assert.equal(parseErrorMonitorSnapshot(snapshot, NaN), null); + const invalid = [ + { ...snapshot, version: 2 }, + { ...snapshot, scope: 'executor-healthy' }, + { ...snapshot, worker: 'healthy' }, + { ...snapshot, checkedAt: Infinity }, + { ...snapshot, windowStart: snapshot.windowStart - 1 }, + { ...snapshot, diagnostic: 'secret' }, + { ...snapshot, sources: { ...snapshot.sources, sentry: { count: -1, latestAt: now - 10 } } }, + { ...snapshot, sources: { ...snapshot.sources, sentry: { count: 1.5, latestAt: now - 10 } } }, + { ...snapshot, sources: { ...snapshot.sources, sentry: { count: 1, latestAt: null } } }, + { ...snapshot, sources: { ...snapshot.sources, sentry: { count: 1, latestAt: now + 1 } } }, + { ...snapshot, sources: { ...snapshot.sources, cloudflare: { count: 0, latestAt: null, token: 'secret' } } }, + { ...snapshot, sources: { ...snapshot.sources, cloudflare: { count: 1, latestAt: null } } }, + { ...snapshot, recent: [{ ...snapshot.recent[0], status: 'unknown' }] }, + { ...snapshot, recent: [{ ...snapshot.recent[0], sourceId: 'secret' }] }, + { ...snapshot, recent: [{ ...snapshot.recent[0], id: 'source-id' }] }, + { ...snapshot, recent: [{ ...snapshot.recent[0], registeredAt: snapshot.windowStart - 1 }] }, + { ...snapshot, recent: [{ ...snapshot.recent[0], registeredAt: now + 1 }] }, + { ...snapshot, recent: [{ ...snapshot.recent[0], app: 'api' }] }, + { ...snapshot, recent: Array(11).fill(snapshot.recent[0]) }, + { + ...snapshot, + recent: [snapshot.recent[0], { ...snapshot.recent[0], id: `autofix-${'b'.repeat(64)}`, registeredAt: now - 1 }], + }, + ]; + for (const candidate of invalid) assert.equal(parseErrorMonitorSnapshot(candidate, now), null); +}); diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index 1e00570d..4b8cc090 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -4,6 +4,7 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./monitor": "./src/monitor.ts", "./runtime": { "workerd": "./src/worker.ts", "browser": "./src/browser.ts", diff --git a/packages/telemetry/src/monitor.ts b/packages/telemetry/src/monitor.ts new file mode 100644 index 00000000..92cb2679 --- /dev/null +++ b/packages/telemetry/src/monitor.ts @@ -0,0 +1,272 @@ +export interface ErrorMonitorSnapshot { + version: 1; + scope: 'ingress-only'; + checkedAt: number; + windowStart: number; + worker: 'responding'; + sources: { + sentry: { count: number; latestAt: number | null }; + cloudflare: { count: number; latestAt: number | null }; + }; + recent: { + id: string; + source: 'sentry' | 'cloudflare'; + app: 'web' | 'api'; + registeredAt: number; + status: 'queued' | 'preparing' | 'leased' | 'verifying' | 'reporting' | 'publishing' | 'completed' | 'failed'; + }[]; +} + +const WINDOW_MS = 24 * 60 * 60_000; + +function record(value: unknown, keys: readonly string[]): value is Record { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + Object.keys(value).length === keys.length && + keys.every((key) => Object.hasOwn(value, key)) + ); +} + +function timestamp(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +function status(value: unknown): value is ErrorMonitorSnapshot['recent'][number]['status'] { + switch (value) { + case 'queued': + case 'preparing': + case 'leased': + case 'verifying': + case 'reporting': + case 'publishing': + case 'completed': + case 'failed': + return true; + default: + return false; + } +} + +/** Reject stale, malformed, or unexpectedly detailed snapshots rather than displaying invented health. */ +function isErrorMonitorSnapshot(value: unknown, now: number): value is ErrorMonitorSnapshot { + if ( + !timestamp(now) || + !record(value, ['version', 'scope', 'checkedAt', 'windowStart', 'worker', 'sources', 'recent']) + ) + return false; + const { checkedAt, windowStart, sources, recent } = value; + if ( + value.version !== 1 || + value.scope !== 'ingress-only' || + value.worker !== 'responding' || + !timestamp(checkedAt) || + !timestamp(windowStart) || + checkedAt - windowStart !== WINDOW_MS || + checkedAt < now - 120_000 || + checkedAt > now + 60_000 || + !record(sources, ['sentry', 'cloudflare']) || + !Array.isArray(recent) || + recent.length > 10 + ) + return false; + + let sentryCount = 0; + let cloudflareCount = 0; + for (const name of ['sentry', 'cloudflare'] as const) { + const source = sources[name]; + if (!record(source, ['count', 'latestAt'])) return false; + if ( + typeof source.count !== 'number' || + !Number.isSafeInteger(source.count) || + source.count < 0 || + (source.latestAt !== null && + (!timestamp(source.latestAt) || source.latestAt < windowStart || source.latestAt > checkedAt)) || + (source.count === 0) !== (source.latestAt === null) + ) + return false; + if (name === 'sentry') sentryCount = source.count; + else cloudflareCount = source.count; + } + + const ids = new Set(); + let previous = checkedAt; + let sentryRecent = 0; + let cloudflareRecent = 0; + for (const entry of recent) { + if ( + !record(entry, ['id', 'source', 'app', 'registeredAt', 'status']) || + typeof entry.id !== 'string' || + !/^autofix-[a-f0-9]{64}$/.test(entry.id) || + ids.has(entry.id) || + (entry.source !== 'sentry' && entry.source !== 'cloudflare') || + (entry.app !== 'web' && entry.app !== 'api') || + (entry.source === 'sentry' && entry.app !== 'web') || + !timestamp(entry.registeredAt) || + entry.registeredAt < windowStart || + entry.registeredAt > previous || + !status(entry.status) + ) + return false; + ids.add(entry.id); + previous = entry.registeredAt; + if (entry.source === 'sentry') sentryRecent++; + else cloudflareRecent++; + } + if (sentryRecent > sentryCount || cloudflareRecent > cloudflareCount) return false; + return true; +} + +export function parseErrorMonitorSnapshot(value: unknown, now: number): ErrorMonitorSnapshot | null { + return isErrorMonitorSnapshot(value, now) ? value : null; +} + +export interface RockyUnitSnapshot { + configured: boolean | null; + state: 'active' | 'inactive' | 'failed' | 'activating' | 'deactivating' | 'unknown'; + process: 'running' | 'stopped' | 'unknown'; + checkedAt: number | null; +} + +export interface RockyMonitorSnapshot { + version: 1; + scope: 'rocky-read-only'; + checkedAt: number; + gateway: 'responding'; + cpu: { usagePercent: number; sampleStart: number; sampleEnd: number } | null; + memory: { totalBytes: number; availableBytes: number; usedBytes: number; checkedAt: number } | null; + executor: RockyUnitSnapshot; + broker: RockyUnitSnapshot; + brokerResponse: 'not_probed'; + model: 'not_probed'; +} + +function freshTimestamp(value: unknown, now: number): value is number { + return timestamp(value) && value >= now - 120_000 && value <= now + 60_000; +} + +function unitSnapshot(value: unknown, now: number): value is RockyUnitSnapshot { + if (!record(value, ['configured', 'state', 'process', 'checkedAt'])) return false; + if ( + (value.configured !== null && typeof value.configured !== 'boolean') || + typeof value.state !== 'string' || + !['active', 'inactive', 'failed', 'activating', 'deactivating', 'unknown'].includes(value.state) || + typeof value.process !== 'string' || + !['running', 'stopped', 'unknown'].includes(value.process) + ) + return false; + if (value.configured === false && (value.state === 'active' || value.process === 'running')) return false; + if (value.checkedAt === null) + return value.configured === null && value.state === 'unknown' && value.process === 'unknown'; + return freshTimestamp(value.checkedAt, now); +} + +function nonnegativeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +function isRockyMonitorSnapshot(value: unknown, now: number): value is RockyMonitorSnapshot { + if ( + !timestamp(now) || + !record(value, [ + 'version', + 'scope', + 'checkedAt', + 'gateway', + 'cpu', + 'memory', + 'executor', + 'broker', + 'brokerResponse', + 'model', + ]) || + value.version !== 1 || + value.scope !== 'rocky-read-only' || + value.gateway !== 'responding' || + value.brokerResponse !== 'not_probed' || + value.model !== 'not_probed' || + !freshTimestamp(value.checkedAt, now) || + !unitSnapshot(value.executor, now) || + !unitSnapshot(value.broker, now) + ) + return false; + if ( + (value.executor.checkedAt !== null && value.executor.checkedAt > value.checkedAt) || + (value.broker.checkedAt !== null && value.broker.checkedAt > value.checkedAt) + ) + return false; + const { cpu, memory, checkedAt } = value; + if ( + cpu !== null && + (!record(cpu, ['usagePercent', 'sampleStart', 'sampleEnd']) || + typeof cpu.usagePercent !== 'number' || + !Number.isFinite(cpu.usagePercent) || + cpu.usagePercent < 0 || + cpu.usagePercent > 100 || + !freshTimestamp(cpu.sampleStart, now) || + !freshTimestamp(cpu.sampleEnd, now) || + cpu.sampleEnd <= cpu.sampleStart || + cpu.sampleEnd - cpu.sampleStart > 10_000 || + cpu.sampleEnd > checkedAt) + ) + return false; + if ( + memory !== null && + (!record(memory, ['totalBytes', 'availableBytes', 'usedBytes', 'checkedAt']) || + !nonnegativeInteger(memory.totalBytes) || + memory.totalBytes === 0 || + !nonnegativeInteger(memory.availableBytes) || + !nonnegativeInteger(memory.usedBytes) || + memory.availableBytes > memory.totalBytes || + memory.usedBytes !== memory.totalBytes - memory.availableBytes || + !freshTimestamp(memory.checkedAt, now) || + memory.checkedAt > checkedAt) + ) + return false; + return true; +} + +/** 서비스 활성 상태를 브로커 응답이나 모델 실행 가능 상태로 승격하지 않습니다. */ +export function parseRockyMonitorSnapshot(value: unknown, now: number): RockyMonitorSnapshot | null { + return isRockyMonitorSnapshot(value, now) ? value : null; +} + +/** 바인딩 응답도 헤더·스트림·UTF-8·전체 본문 크기를 검증합니다. */ +export async function readMonitorJson(response: Response, signal: AbortSignal): Promise { + const maxBytes = 16 * 1024; + const length = response.headers.get('content-length'); + if ( + (length !== null && (!/^\d+$/.test(length) || Number(length) > maxBytes)) || + !/^application\/json(?:\s*;|\s*$)/i.test(response.headers.get('content-type') ?? '') + ) { + void response.body?.cancel().catch(() => {}); + throw new Error('Invalid monitoring response'); + } + if (!response.body) throw new Error('Missing monitoring response'); + const reader = response.body.getReader(); + const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }); + let bytes = 0; + let text = ''; + let complete = false; + const cancel = () => { + void reader.cancel().catch(() => {}); + }; + signal.addEventListener('abort', cancel, { once: true }); + try { + if (signal.aborted) throw new Error('Monitoring deadline exceeded'); + for (;;) { + const chunk = await reader.read(); + if (chunk.done) break; + bytes += chunk.value.byteLength; + if (bytes > maxBytes) throw new Error('Oversized monitoring response'); + text += decoder.decode(chunk.value, { stream: true }); + } + complete = true; + return JSON.parse(text + decoder.decode()); + } finally { + signal.removeEventListener('abort', cancel); + if (!complete) cancel(); + reader.releaseLock(); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c57b6c4b..8f904db4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -269,6 +269,12 @@ importers: specifier: 3.24.6 version: 3.24.6(@types/node@24.19.2) + apps/attendance: + dependencies: + '@koin/telemetry': + specifier: workspace:* + version: link:../../packages/telemetry + apps/b-bot: dependencies: '@koin/b-bot-ai': @@ -332,6 +338,9 @@ importers: '@jridgewell/trace-mapping': specifier: ^0.3.31 version: 0.3.31 + '@koin/telemetry': + specifier: workspace:* + version: link:../../packages/telemetry apps/web: dependencies: diff --git a/scripts/autofix/README.md b/scripts/autofix/README.md index ca1a6fc6..46f0ff22 100644 --- a/scripts/autofix/README.md +++ b/scripts/autofix/README.md @@ -8,6 +8,20 @@ - **Cloudflare Issues:** Worker가 공식 Notifications envelope를 인증하고 계정·정책·Worker·앱을 재검증한다. 불투명한 `data`를 예외/경로/소스 프레임으로 임의 해석하지 않고, 일치하는 Issues occurrence·Tail/Telemetry·고정 소스 버전의 안전한 GET 경로와 매핑된 앱 소스 프레임을 별도 확인한 경우에만 수정 후보를 만든다. 근거가 부족하면 `CLOUDFLARE_EVIDENCE_UNAVAILABLE`로 실패 기록을 남기며 원본 Cloudflare Issue는 변경하지 않는다. 기존 Workers 지표 알림을 Issues 대신 쓰거나 존재하지 않는 비공개 Issues 수정 API를 호출하지 않는다. - 외부 API, 인증된 사용자 상태, dependency/config/generated code 수정, Node 회귀로 재현할 수 없는 오류는 안전 범위 밖이다. 실패를 우회하거나 빈 PR을 만들지 않는다. +## 읽기 전용 수신 상태·Rocky 헬스 + +정확한 HTTPS `GET /healthz`와 `GET /healthz/rocky`는 별도의 `ERROR_MONITOR_TOKEN` Bearer만 인증합니다. 쿼리 파라미터는 받지 않으며 잘못된/없는 토큰은 401, 미설정은 503, 다른 메서드는 405입니다. 이 토큰은 실행기·진단·모델·기기 토큰과 분리합니다. + +`GET /healthz` 응답은 `version: 1`, `scope: ingress-only`, 관측 시각, 24시간 창의 Sentry/Cloudflare 고유 등록 건수·최근 신규 등록 시각·최대 10개 처리 상태뿐입니다. 건수와 최근 시각은 제한 없는 전체 기간 집계이며 최근 목록만 10건으로 제한합니다. `enqueued_at`은 처음 등록된 시각이므로 중복 웹훅 수신량/재전송 시각으로 해석하지 않습니다. GitHub PR 작업과 Cloudflare `koin-api` 기록은 제외합니다. 원본 provider ID, 오류 전문, 진단, 소스, 로그, 토큰, lease, PR URL은 노출하지 않습니다. + +수신 조회는 고정 저장소 큐의 SELECT만 수행하고 알람·claim·모델·GitHub 작업을 실행하지 않습니다. VPC나 모델 자격 증명 없이 동작합니다. 저장소 오류·오래되거나 잘못된 응답은 503이며 0건/정상으로 대체하지 않습니다. 응답은 no-store, 저장소 응답 기한은 3초입니다. + +`GET /healthz/rocky`는 기존 `OMP_GATEWAY` VPC 바인딩으로 고정 `http://omp-gateway/healthz`만 조회합니다. 호스트 전용 `OMP_GATEWAY_MONITOR_TOKEN`을 사용하며 모델용 `OMP_GATEWAY_TOKEN`, 실행기 토큰, 수신용 `ERROR_MONITOR_TOKEN`과 같으면 503으로 닫힙니다. VPC 응답 본문까지 3초·16 KiB로 제한하고 리다이렉트·오래된/알 수 없는 필드를 거부합니다. 실패 원문을 전달하지 않습니다. + +Rocky 응답은 `version: 1`, `scope: rocky-read-only`, 실제 관측 시각, 게이트웨이 응답 여부, CPU 두 표본의 차이, `MemTotal − MemAvailable` RAM 사용량과 고정 실행기·브로커 서비스 상태입니다. 설치/활성/프로세스 실행과 브로커 HTTP 응답·모델 준비 상태는 다릅니다. 미측정 값은 null/unknown이며 `brokerResponse`와 `model`은 `not_probed`입니다. 모델 생성·작업 claim·GitHub 조회는 수행하지 않습니다. + +출근부는 Worker service binding과 서버 secret으로 두 경로를 독립적으로 읽습니다. 정상 main 승격, 두 Worker 배포, Rocky 코드·전용 파일·secret 설정이 활성화 전제입니다. 수신 Worker 응답만으로 Rocky 정상을 주장하지 않으며 호스트 연결 실패도 실제 수신 건수를 0으로 바꾸지 않습니다. 공개 hostname, 새 터널, 사설 SSH 라우트, WARP/OpenBao 권한을 추가하지 않습니다. + ## 고정 제공자 식별자 웹훅은 아래 식별자만 저장하고 고정 저장소 큐에 등록한다. 추가 프롬프트·URL·저장소·호스트·브랜치·명령·파일 경로로 실행 대상을 바꿀 수 없다. @@ -111,10 +125,10 @@ cat /home/user/.home-butler/koin-omp-gateway/omp-gateway.token | pnpm --dir apps 게이트웨이 계약: - `OMP_GATEWAY_TOKEN_FILE`은 현재 uid 소유의 **0600 일반 파일**(symlink 불가)이어야 하며 32–512자 URL-safe 토큰 한 줄을 담는다. 조건이 하나라도 어긋나면 시작하지 않는다. `OMP_GATEWAY_PORT` 기본값은 `43129`다. -- 정확히 `POST /v1/messages`만 받는다(query 포함 다른 경로 404, 다른 메서드 405). bearer는 시간 일정 비교로 먼저 검사하며(실패 401), 인증 전에는 omp를 실행하지 않는다. broker/usage/models 등 다른 게이트웨이 경로는 노출하지 않는다. +- 모델 경로는 정확히 `POST /v1/messages`이며 읽기 전용 관측 경로는 별도 Bearer의 `GET /healthz`다(query 포함 다른 경로 404, 다른 메서드 405). 모델 토큰으로 헬스를 읽거나 모니터 토큰으로 모델을 실행할 수 없다. bearer는 시간 일정 비교로 검사하며(실패 401), 인증 전에는 omp나 호스트 probe를 실행하지 않는다. broker/usage/models 등 다른 게이트웨이 경로는 노출하지 않는다. - 요청 본문은 1 MiB까지(초과 시 즉시 413 후 연결 종료, 나머지 바이트는 버퍼링하지 않음) JSON 객체여야 하고 `model`은 정확히 `claude-sonnet-5-5` 또는 `claude-opus-5-5`, `max_tokens`는 1–8,192만 허용한다. 퍼지 이름·role(`@smol`)·provider 접두사·`stream`·provider 실행 도구(`type`이 `custom`이 아닌 tool)·알 수 없는 필드는 400이다. 브리지는 도구를 실행하지 않으며 본문을 해석·수정하지 않고 `model`만 `anthropic/<같은 id>`로 바꿔 전달한다. - 응답은 요청한 모델이 실제로 답한 `message` envelope(`anthropic/<요청 id>` 또는 bare id)일 때만 200이며 `model`을 요청한 bare id로 정규화한다. 다른 모델·fallback chain이 답하면 502다. omp 상위 429는 `rate_limit_error`로 429를 돌려주되 `Retry-After`는 붙이지 않고(stdio는 헤더를 주지 않음), 그 밖의 상위 실패는 상위 오류 문구 없이 502 `upstream_error`다. omp의 오류 문구와 자식 stderr는 로컬 경로·계정 정보를 담을 수 있어 응답·로그 어디에도 쓰지 않는다. -- 동시 요청은 하나만 처리한다(진행 중이면 503 + `Retry-After: 5`, Worker는 이를 종료 실패로 취급). 요청당 180초 기한은 본문 수신부터 적용되어 본문이 멈추거나 추론이 늦으면 504이며, 기한 초과·클라이언트 연결 종료·종료 신호 때 브리지가 띄운 omp 자식을 SIGKILL하고 슬롯을 즉시 비운다. 자식 stdout은 4 MiB까지만 읽고 초과 시 자식을 종료한다. shell을 거치지 않고 `omp auth-gateway stdio`를 직접 실행한다. +- 모델 요청은 하나만 처리한다(진행 중이면 503 + `Retry-After: 5`, Worker는 이를 종료 실패로 취급). 요청당 180초 기한은 본문 수신부터 적용되어 본문이 멈추거나 추론이 늦으면 504이며, 기한 초과·클라이언트 연결 종료·종료 신호 때 브리지가 띄운 omp 자식을 SIGKILL하고 슬롯을 즉시 비운다. 자식 stdout은 4 MiB까지만 읽고 초과 시 자식을 종료한다. shell을 거치지 않고 `omp auth-gateway stdio`를 직접 실행한다. 헬스 수집·응답 생성은 모델 슬롯과 독립적이며 2초로 제한한다. - 로그는 경로 분류·상태·소요 시간만 남긴다. - **자식 환경과 재시도 차단:** omp 자식은 허용 목록 환경만 받는다(`PATH`/`HOME`/`USER`/`TMPDIR`/`LANG`/`LC_*`/`XDG_*`/`PI_CONFIG_DIR`/`PI_PROXY`/proxy 변수/`NODE_EXTRA_CA_CERTS`/`OMP_AUTH_BROKER_*`). `ANTHROPIC_API_KEY`·`ANTHROPIC_OAUTH_TOKEN`·`ANTHROPIC_BASE_URL`·`CLAUDE_CODE_*`·다른 `*_API_KEY`와 운영자의 `PI_CONFIG_FILES`는 전달되지 않으므로 브리지 환경으로 요청을 다른 endpoint나 key로 돌릴 수 없다. 브리지는 시작 시 0600 임시 overlay(`PI_CONFIG_FILES`)로 `retry.enabled=false`, `maxRetries=0`, `modelFallback=false`, `usageAwareFallback=false`, `waitForUsageReset=false`, `rateLimitWalksChain=false`, 두 모델의 `fallbackChains=[]`를 자식 프로세스에만 적용한다(사용자 전역 `config.yml`은 수정하지 않음). 따라서 Worker가 예산에 계산하는 게이트웨이 호출 한 번은 provider 시도 한 번이며, 같은 모델 재시도·다른 모델 fallback·usage reset 대기는 일어나지 않는다. - **시작 게이트:** 시작 시 `GET /v1/models`(quota 소비 없음)로 `anthropic/claude-sonnet-5-5`와 `anthropic/claude-opus-5-5`가 omp의 자격증명 선택에 따라 실제 서비스되는지 확인하고, 하나라도 없으면 listen하지 않고 종료한다. 로그인 만료 등은 요청 시점에 502로 드러나며 브리지가 로그인을 대신 수행하지 않는다. @@ -122,6 +136,24 @@ cat /home/user/.home-butler/koin-omp-gateway/omp-gateway.token | pnpm --dir apps 제한과 해제: 게이트웨이는 공개 주소가 없고 VPC 바인딩을 가진 Worker만 도달하며, bearer가 추가로 접근을 막는다. 게이트웨이 bearer는 연결 인증용 비밀이며 provider 자격증명이 아니다. 해제할 때는 두 유닛을 내리고 토큰 파일을 삭제한 뒤 Worker의 `OMP_GATEWAY_TOKEN`과 VPC 바인딩을 제거한다. 그 시점부터 새 인증 게이트웨이를 설정할 때까지 **모델 처리는 불가능**하며(모델 호출이 설정 오류로 실패), API key를 다시 등록하는 복구 경로는 없다. 게이트웨이는 Rocky와 omp auth-broker 상태에 의존하고, 모델 호출 외의 어떤 Worker 동작(발행·검증·merge)도 바꾸지 않는다. +### Rocky 읽기 전용 헬스 활성화 + +게이트웨이는 `OMP_GATEWAY_MONITOR_TOKEN_FILE`을 설정하지 않아도 기존 모델 경로로 실행할 수 있으며 헬스만 503이다. 헬스를 활성화할 때는 다음 운영 절차를 따른다. + +1. 실제 `koin-omp-gateway.service`의 `User`/`Group`을 확인하고 작업·모델 요청이 진행 중이지 않은 시점에 호스트 코드를 갱신한다. 저장소 유닛의 실행 계정은 `homebutler`다. +2. 그 uid 소유의 0700 일반 디렉터리에 모델용과 다른 32–512자 URL-safe 무작위 모니터 토큰을 0600 일반 파일로 신규 생성한다. 디렉터리와 파일은 symlink를 허용하지 않으며 기존 모델/브로커 토큰을 복사하거나 덮어쓰지 않는다. +3. 유닛 drop-in에 아래 경로를 설정하고 승인된 작업 창에서 daemon-reload와 게이트웨이 재시작을 수행한다. 터널·브로커·실행기 유닛이나 네트워크 접근 정책은 변경하지 않는다. + + ```ini + [Service] + Environment=OMP_GATEWAY_MONITOR_TOKEN_FILE=/home/user/.home-butler/koin-omp-gateway/monitor.token + ``` + +4. 같은 파일의 값을 안전한 대화형 입력으로 error-automation의 `OMP_GATEWAY_MONITOR_TOKEN` secret에 등록한다. `ERROR_MONITOR_TOKEN`은 attendance와 error-automation 사이의 별도 비밀이다. secret 등록은 즉시 배포를 일으키므로 main 승격과 운영 승인 절차를 따른다. +5. 기존 비공개 경로에서 실제 `/healthz/rocky` 응답과 관측 시각을 확인한다. Linux `/proc/stat`은 약 200ms 차이로 읽으며 guest tick을 중복 합산하지 않고 idle+iowait을 제외한다. 메모리나 고정 `systemctl show` 읽기가 실패하면 해당 값은 미확인이다. 서비스 활성은 실행 중 프로세스·브로커 응답·모델 호출 성공과 동일하지 않다. + +모니터만 중지할 때는 drop-in의 모니터 파일 환경 설정과 Worker의 모니터 전용 secret만 운영 절차로 해제한다. 모델 토큰·VPC·터널을 제거하지 않는다. + ## 기존 PR 검토·수정 모드 (`mode: 'pull-request'`) 같은 Worker 큐와 실행기 lease를 쓰는 별도 `mode:'pull-request'` 작업이다. 수동 `/executor/pull-requests/enqueue`는 `PR_REVIEW_ENABLED==='true'`와 명시적 허용 PR 번호가 필요하다. 자동 경로는 `PR_REVIEW_ENABLED==='true'` + `PR_REVIEW_AUTO==='true'` + 서명된 `POST /webhooks/github`의 opened/reopened/synchronize/ready_for_review 이벤트다. Worker는 canonical PR을 조회해 fork/draft/봇/보호 ref와 자동화 자신이 생성한 수정 head SHA를 제외하고 실제 develop tip과 head를 pin한다. develop보다 뒤처진 head는 write 토큰 확보 후 SHA당 최대 3번 update-branch를 시도한다(일시적 실패만 재시도, 409/422 충돌은 한 marker 댓글로 종결). 성공 후 synchronize를 기다린다. diff --git a/scripts/autofix/koin-omp-gateway.service b/scripts/autofix/koin-omp-gateway.service index b6b8642a..cadc7b4c 100644 --- a/scripts/autofix/koin-omp-gateway.service +++ b/scripts/autofix/koin-omp-gateway.service @@ -14,6 +14,8 @@ Environment=HOME=/home/user/.home-butler/koin-omp-gateway Environment=OMP_AUTH_BROKER_URL=http://127.0.0.1:8766 Environment=PATH=/home/user/.local/bin:/usr/local/bin:/usr/bin:/bin Environment=OMP_GATEWAY_TOKEN_FILE=/home/user/.home-butler/koin-omp-gateway/omp-gateway.token +# 선택 사항: 먼저 homebutler 소유의 0700 디렉터리에 모델 토큰과 다른 0600 모니터 토큰을 마련한다. +# Environment=OMP_GATEWAY_MONITOR_TOKEN_FILE=/home/user/.home-butler/koin-omp-gateway/monitor.token Environment=OMP_GATEWAY_PORT=43129 ExecStart=/usr/bin/node /usr/local/lib/koin-autofix/local-omp-gateway.mjs NoNewPrivileges=yes diff --git a/scripts/autofix/local-omp-gateway.mjs b/scripts/autofix/local-omp-gateway.mjs index fa484399..39802222 100755 --- a/scripts/autofix/local-omp-gateway.mjs +++ b/scripts/autofix/local-omp-gateway.mjs @@ -1,23 +1,20 @@ #!/usr/bin/env node /** - * Loopback bridge that lets the error-automation Worker reach this machine's - * omp credentials for the fixed model request only. It accepts exactly - * `POST /v1/messages` with the dedicated bearer, forwards the body as one - * `omp auth-gateway stdio` request (official credential path, nothing exported) - * and returns the Anthropic Messages envelope back. The bridge never executes - * tools, never logs bodies, upstream errors or the child's stderr, serves one - * request at a time, and kills its own omp child when the client disconnects or - * the deadline passes. + * Rocky의 고정 모델 요청을 로컬 omp 자격 증명으로 전달하는 루프백 브리지. + * `POST /v1/messages`는 모델 토큰으로, 선택적 `GET /healthz`는 별도 모니터 토큰으로 인증한다. + * 상태 조회는 로컬 커널과 systemd 정보만 읽으며 omp, 브로커, 모델을 호출하지 않는다. * - * OMP_GATEWAY_TOKEN_FILE regular 0600 file owned by this uid holding the bearer - * OMP_GATEWAY_PORT loopback port (default 43129) + * OMP_GATEWAY_TOKEN_FILE 현재 사용자 소유의 0600 모델 토큰 파일 + * OMP_GATEWAY_MONITOR_TOKEN_FILE 선택 사항: 소유자 전용 0700 디렉터리 내의 별도 0600 토큰 파일 + * OMP_GATEWAY_PORT 루프백 포트(기본값 43129) */ import { spawn } from 'node:child_process'; import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; -import { constants, mkdtemp, open, rm, writeFile } from 'node:fs/promises'; +import { constants, lstat, mkdtemp, open, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { createServer } from 'node:http'; +import { setTimeout as sleep } from 'node:timers/promises'; import { pathToFileURL } from 'node:url'; export const MODELS = ['claude-sonnet-5-5', 'claude-opus-5-5']; @@ -27,6 +24,11 @@ export const MAX_INPUT_BYTES = 1024 * 1024; export const MAX_OUTPUT_BYTES = 4 * 1024 * 1024; export const REQUEST_TIMEOUT_MS = 180_000; const PATH = '/v1/messages'; +const HEALTH_PATH = '/healthz'; +const HEALTH_TIMEOUT_MS = 2_000; +const CPU_SAMPLE_MS = 200; +const SYSTEMCTL_OUTPUT_BYTES = 4096; +const UNITS = ['koin-autofix-executor.service', 'homebutler-auth-broker.service']; const TOKEN = /^[A-Za-z0-9_-]{32,512}$/; const ERROR_TYPE = /^[a-z_]{1,64}$/; const MAX_TOKENS = 8192; @@ -78,6 +80,24 @@ export function authorized(header, token) { return timingSafeEqual(a, b); } +/** 모니터 토큰은 파일뿐 아니라 부모 디렉터리도 현재 사용자 소유의 비심볼릭 링크 0700이어야 한다. */ +export async function loadMonitorToken(path) { + if (typeof path !== 'string' || !path) throw new Error('OMP_GATEWAY_MONITOR_TOKEN_FILE is required'); + const parent = await lstat(dirname(path)); + if (!parent.isDirectory() || parent.isSymbolicLink()) throw new Error('Monitor token parent must be a directory'); + if ((parent.mode & 0o777) !== 0o700) throw new Error('Monitor token parent mode must be 0700'); + if (typeof process.getuid === 'function' && parent.uid !== process.getuid()) + throw new Error('Monitor token parent must be owned by the current user'); + return loadToken(path); +} + +/** 길이나 접두사에 따른 조기 반환 없이 두 토큰을 비교한다. */ +export function distinctTokens(modelToken, monitorToken) { + const modelHash = createHash('sha256').update(modelToken).digest(); + const monitorHash = createHash('sha256').update(monitorToken).digest(); + return !timingSafeEqual(modelHash, monitorHash); +} + function isRecord(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -364,6 +384,166 @@ function readBody(req, max, signal) { }); } +function cpuCounters(text) { + const line = text.match(/^cpu\s+([^\n]+)/m)?.[1]; + if (!line) return null; + const fields = line.trim().split(/\s+/); + if (fields.length < 8 || fields.slice(0, 8).some((field) => !/^\d+$/.test(field))) return null; + const counters = fields.slice(0, 8).map(Number); + if (counters.some((value) => !Number.isSafeInteger(value))) return null; + return { counters, total: counters.reduce((sum, value) => sum + value, 0), idle: counters[3] + counters[4] }; +} + +/** Linux /proc/stat 집계값에서 guest 시간은 이미 user/nice에 포함되므로 중복 합산하지 않는다. */ +export function cpuDelta(first, second, sampleStart, sampleEnd) { + if ( + !Number.isSafeInteger(sampleStart) || + sampleStart < 0 || + !Number.isSafeInteger(sampleEnd) || + sampleEnd <= sampleStart || + sampleEnd - sampleStart > 10_000 + ) + return null; + const start = cpuCounters(first); + const end = cpuCounters(second); + if ( + !start || + !end || + !Number.isSafeInteger(start.total) || + !Number.isSafeInteger(end.total) || + end.counters.some((value, index) => value < start.counters[index]) + ) + return null; + const total = end.total - start.total; + const idle = end.idle - start.idle; + if (total <= 0 || idle < 0 || idle > total) return null; + return { usagePercent: ((total - idle) / total) * 100, sampleStart, sampleEnd }; +} + +/** 회수 가능한 캐시를 반영하는 MemAvailable을 사용하며, 읽지 못한 메모리는 null로 둔다. */ +export function memoryReading(text, checkedAt) { + const values = new Map(); + for (const line of text.split('\n')) { + const match = /^(MemTotal|MemAvailable):\s+(\d+)\s+kB\s*$/.exec(line); + if (match) values.set(match[1], Number(match[2]) * 1024); + } + const totalBytes = values.get('MemTotal'); + const availableBytes = values.get('MemAvailable'); + if ( + !Number.isSafeInteger(totalBytes) || + totalBytes <= 0 || + !Number.isSafeInteger(availableBytes) || + availableBytes < 0 || + availableBytes > totalBytes + ) + return null; + return { totalBytes, availableBytes, usedBytes: totalBytes - availableBytes, checkedAt }; +} + +function unknownUnit() { + return { configured: null, state: 'unknown', process: 'unknown', checkedAt: null }; +} + +/** systemctl show의 메타데이터만 읽는다. ActiveState만으로 프로세스 실행을 단정하지 않는다. */ +export function unitReading(text, checkedAt) { + const values = new Map(); + for (const line of text.split('\n')) { + const match = /^(LoadState|ActiveState|SubState|MainPID)=(.*)$/.exec(line); + if (match && !values.has(match[1])) values.set(match[1], match[2]); + } + if (['LoadState', 'ActiveState', 'SubState', 'MainPID'].some((key) => !values.has(key))) return unknownUnit(); + const configured = + values.get('LoadState') === 'loaded' ? true : values.get('LoadState') === 'not-found' ? false : null; + const rawState = values.get('ActiveState'); + const state = ['active', 'inactive', 'failed', 'activating', 'deactivating'].includes(rawState) + ? rawState + : 'unknown'; + const pid = values.get('MainPID'); + const substate = values.get('SubState'); + let processState = 'unknown'; + if (/^\d+$/.test(pid) && Number.isSafeInteger(Number(pid))) { + if (Number(pid) > 0 && substate === 'running') processState = 'running'; + else if (Number(pid) === 0 && ['dead', 'exited', 'failed'].includes(substate)) processState = 'stopped'; + else if (Number(pid) === 0 && state === 'inactive') processState = 'stopped'; + } + return { configured, state, process: processState, checkedAt }; +} + +/** 고정 인수와 출력 상한을 사용하고, 중단 시 프로브를 종료한 뒤 close를 기다린다. */ +function showUnit(unit, { signal }) { + return new Promise((resolve, reject) => { + signal.throwIfAborted(); + const child = spawn( + 'systemctl', + ['show', '--no-pager', '--property=LoadState,ActiveState,SubState,MainPID', unit], + { + shell: false, + stdio: ['ignore', 'pipe', 'ignore'], + signal, + }, + ); + const onAbort = () => child.kill('SIGKILL'); + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) onAbort(); + let output = ''; + let length = 0; + let failed = false; + child.stdout.on('data', (chunk) => { + length += chunk.length; + if (length > SYSTEMCTL_OUTPUT_BYTES) { + failed = true; + child.kill('SIGKILL'); + } else if (!failed) output += chunk.toString('utf8'); + }); + child.once('error', () => { + failed = true; + }); + child.once('close', (code) => { + signal.removeEventListener('abort', onAbort); + if (failed || code !== 0) reject(new Error('Unit probe unavailable')); + else resolve(output); + }); + }); +} + +/** 각 프로브는 읽기 전용이며 중단 가능하다. 실패한 항목만 독립적으로 미확인 처리한다. */ +export async function collectRockyHealth({ + signal, + now = Date.now, + readProc = readFile, + runSystemctl = showUnit, + sampleMs = CPU_SAMPLE_MS, + platform = process.platform, +} = {}) { + if (!signal) throw new Error('Health collection needs an abort signal'); + signal.throwIfAborted(); + const cpu = async () => { + const sampleStart = now(); + const first = await readProc('/proc/stat', { encoding: 'utf8', signal }); + await sleep(sampleMs, undefined, { signal }); + const second = await readProc('/proc/stat', { encoding: 'utf8', signal }); + return cpuDelta(first, second, sampleStart, now()); + }; + const memory = async () => memoryReading(await readProc('/proc/meminfo', { encoding: 'utf8', signal }), now()); + const unit = async (name) => unitReading(await runSystemctl(name, { signal }), now()); + const results = + platform === 'linux' ? await Promise.allSettled([cpu(), memory(), unit(UNITS[0]), unit(UNITS[1])]) : []; + signal.throwIfAborted(); + const value = (index, fallback) => (results[index]?.status === 'fulfilled' ? results[index].value : fallback); + return { + version: 1, + scope: 'rocky-read-only', + checkedAt: now(), + gateway: 'responding', + cpu: value(0, null), + memory: value(1, null), + executor: value(2, unknownUnit()), + broker: value(3, unknownUnit()), + brokerResponse: 'not_probed', + model: 'not_probed', + }; +} + function send(res, { status, headers, body }) { if (res.destroyed || res.headersSent) return; const bytes = Buffer.from(JSON.stringify(body)); @@ -378,22 +558,31 @@ function send(res, { status, headers, body }) { } /** - * Builds the loopback server. `runRequest` is the stdio runner and `timeoutMs` - * the per-request deadline; tests inject a fake child and a short deadline. - * Logging is limited to path class, status and timing. + * 모델 요청은 기존 단일 자식 프로세스 슬롯을 사용한다. 인증된 상태 조회는 + * 별도의 짧은 제한 시간 내에 실행하며 모델 슬롯을 점유하지 않는다. */ export function createGateway({ token, + monitorToken = null, runRequest = runStdioRequest, + collectHealth = collectRockyHealth, timeoutMs = REQUEST_TIMEOUT_MS, + healthTimeoutMs = HEALTH_TIMEOUT_MS, log = console.error, }) { + if (monitorToken !== null && !distinctTokens(token, monitorToken)) + throw new Error('Monitor and model bearer tokens must differ'); let busy = false; const server = createServer(async (req, res) => { const startedAt = Date.now(); - const route = req.url === PATH; + const modelRoute = req.url === PATH; + const healthRoute = req.url === HEALTH_PATH; const report = (status) => - log('[local-omp-gateway]', { route: route ? PATH : 'other', status, ms: Date.now() - startedAt }); + log('[local-omp-gateway]', { + route: modelRoute ? PATH : healthRoute ? HEALTH_PATH : 'other', + status, + ms: Date.now() - startedAt, + }); // A request whose body was not consumed closes once the answer is flushed, so // the remainder of an oversized or stalled body is dropped rather than drained. const done = (reply) => { @@ -408,7 +597,42 @@ export function createGateway({ req.destroy(); report('disconnected'); }; - if (!route) return done(error(404, 'not_found_error', 'Not found')); + if (!modelRoute && !healthRoute) return done(error(404, 'not_found_error', 'Not found')); + if (healthRoute) { + if (req.method !== 'GET') return done(error(405, 'not_found_error', 'Method not allowed', { Allow: 'GET' })); + if (!monitorToken) return done(error(503, 'api_error', 'Health monitoring unavailable')); + if (!authorized(req.headers.authorization, monitorToken)) + return done(error(401, 'authentication_error', 'Invalid bearer token', { 'WWW-Authenticate': 'Bearer' })); + const controller = new AbortController(); + const deadline = Promise.withResolvers(); + const abort = (reason) => { + controller.abort(reason); + deadline.reject(reason); + }; + const timer = setTimeout(() => abort(new Error('timeout')), healthTimeoutMs); + const onClose = () => { + if (!res.writableFinished) abort(new Error('client disconnected')); + }; + res.on('close', onClose); + try { + // 수집기는 중단 신호를 따르지만, 따르지 않는 구현에도 제한 시간을 강제한다. + const body = await Promise.race([collectHealth({ signal: controller.signal }), deadline.promise]); + if (controller.signal.aborted) { + if (controller.signal.reason?.message === 'timeout') + return done(error(504, 'api_error', 'Health probe timed out')); + return dropped(); + } + return done({ status: 200, body }); + } catch { + if (controller.signal.reason?.message === 'timeout') + return done(error(504, 'api_error', 'Health probe timed out')); + if (controller.signal.aborted) return dropped(); + return done(error(503, 'api_error', 'Health probe unavailable')); + } finally { + clearTimeout(timer); + res.off('close', onClose); + } + } if (req.method !== 'POST') return done(error(405, 'not_found_error', 'Method not allowed', { Allow: 'POST' })); if (!authorized(req.headers.authorization, token)) return done(error(401, 'authentication_error', 'Invalid bearer token', { 'WWW-Authenticate': 'Bearer' })); @@ -463,11 +687,16 @@ export function createGateway({ async function main() { const token = await loadToken(process.env.OMP_GATEWAY_TOKEN_FILE); + const monitorToken = process.env.OMP_GATEWAY_MONITOR_TOKEN_FILE + ? await loadMonitorToken(process.env.OMP_GATEWAY_MONITOR_TOKEN_FILE) + : null; + if (monitorToken !== null && !distinctTokens(token, monitorToken)) + throw new Error('Monitor and model bearer tokens must differ'); const port = process.env.OMP_GATEWAY_PORT === undefined ? DEFAULT_PORT : Number(process.env.OMP_GATEWAY_PORT); if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('OMP_GATEWAY_PORT must be a TCP port'); await installOverlay(); await checkReadiness(); - const server = createGateway({ token }); + const server = createGateway({ token, monitorToken }); // Closing live connections aborts the in-flight request, which kills its omp child. const stop = () => { server.close(() => removeOverlay().finally(() => process.exit(0))); diff --git a/scripts/autofix/local-omp-gateway.test.mjs b/scripts/autofix/local-omp-gateway.test.mjs index 836d3bd8..03cc95ab 100644 --- a/scripts/autofix/local-omp-gateway.test.mjs +++ b/scripts/autofix/local-omp-gateway.test.mjs @@ -12,15 +12,22 @@ import { canonicalizeResponse, checkReadiness, childEnv, + collectRockyHealth, + cpuDelta, createGateway, + distinctTokens, installOverlay, + loadMonitorToken, loadToken, + memoryReading, removeOverlay, runStdioRequest, + unitReading, validateRequest, } from './local-omp-gateway.mjs'; const TOKEN = 'a'.repeat(64); +const MONITOR_TOKEN = 'b'.repeat(64); /** A stand-in for `omp auth-gateway stdio` driven by a small inline script. */ function fakeChild(script) { @@ -54,6 +61,26 @@ test('token file must be a 0600 regular non-symlink file with a well-formed bear await assert.rejects(loadToken(''), /required/); }); +test('monitor bearer requires a private owned parent and must differ from the model bearer', async (t) => { + const dir = await mkdtemp(join(tmpdir(), 'omp-monitor-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = join(dir, 'monitor.token'); + await writeFile(file, MONITOR_TOKEN, { mode: 0o600 }); + assert.equal(await loadMonitorToken(file), MONITOR_TOKEN); + assert.equal(distinctTokens(TOKEN, MONITOR_TOKEN), true); + assert.equal(distinctTokens(TOKEN, TOKEN), false); + assert.throws(() => createGateway({ token: TOKEN, monitorToken: TOKEN, log: () => {} })); + await chmod(dir, 0o755); + await assert.rejects(loadMonitorToken(file)); + await chmod(dir, 0o700); + const link = `${dir}-link`; + await symlink(dir, link); + t.after(() => rm(link, { force: true })); + await assert.rejects(loadMonitorToken(join(link, 'monitor.token'))); + await chmod(file, 0o644); + await assert.rejects(loadMonitorToken(file)); +}); + test('the omp child gets a scrubbed environment and only the bridge retry overlay', async (t) => { assert.throws(() => childEnv({ PATH: '/bin' }, null), /overlay/); const file = await installOverlay(); @@ -73,6 +100,7 @@ test('the omp child gets a scrubbed environment and only the bridge retry overla CLAUDE_CODE_USE_FOUNDRY: '1', OPENAI_API_KEY: 'sk-other', OMP_GATEWAY_TOKEN_FILE: '/private/token', + OMP_GATEWAY_MONITOR_TOKEN_FILE: '/private/monitor', }); assert.deepEqual(env, { PI_CONFIG_FILES: file, @@ -336,3 +364,251 @@ test('a stalled or dropped request body never pins the single slot, and oversize assert.match(await complete(), /^HTTP\/1\.1 200 /); assert.equal(spawns, 3); }); + +test('Linux counters use a real two-sample busy delta and MemAvailable arithmetic', async () => { + const first = 'cpu 100 10 20 70 10 5 5 0 9 3\n'; + const second = 'cpu 120 10 30 90 10 5 5 0 99 3\n'; + assert.deepEqual(cpuDelta(first, second, 1000, 1200), { + usagePercent: 60, + sampleStart: 1000, + sampleEnd: 1200, + }); + assert.deepEqual(memoryReading('MemTotal: 1000 kB\nMemAvailable: 400 kB\n', 1200), { + totalBytes: 1024000, + availableBytes: 409600, + usedBytes: 614400, + checkedAt: 1200, + }); + const calls = []; + let samples = 0; + let clock = 1700000000000; + const result = await collectRockyHealth({ + signal: new AbortController().signal, + now: () => clock, + platform: 'linux', + sampleMs: 0, + readProc: async (path) => { + calls.push(path); + if (path === '/proc/meminfo') return 'MemTotal: 1000 kB\nMemAvailable: 400 kB\n'; + samples += 1; + if (samples === 2) clock += 200; + return samples === 1 ? first : second; + }, + runSystemctl: async (name) => { + calls.push(name); + return name === 'koin-autofix-executor.service' + ? 'LoadState=loaded\nActiveState=active\nSubState=running\nMainPID=123\n' + : 'LoadState=not-found\nActiveState=inactive\nSubState=dead\nMainPID=0\n'; + }, + }); + assert.deepEqual( + calls.sort(), + [ + '/proc/meminfo', + '/proc/stat', + '/proc/stat', + 'homebutler-auth-broker.service', + 'koin-autofix-executor.service', + ].sort(), + ); + assert.deepEqual(result, { + version: 1, + scope: 'rocky-read-only', + checkedAt: 1700000000200, + gateway: 'responding', + cpu: { usagePercent: 60, sampleStart: 1700000000000, sampleEnd: 1700000000200 }, + memory: { totalBytes: 1024000, availableBytes: 409600, usedBytes: 614400, checkedAt: 1700000000000 }, + executor: { configured: true, state: 'active', process: 'running', checkedAt: 1700000000000 }, + broker: { configured: false, state: 'inactive', process: 'stopped', checkedAt: 1700000000000 }, + brokerResponse: 'not_probed', + model: 'not_probed', + }); +}); + +test('counter resets, malformed memory and failed systemd reads stay unknown, not zero', async () => { + assert.equal(cpuDelta('cpu 10 0 0 10 0 0 0 0\n', 'cpu 9 0 0 20 0 0 0 0\n', 1, 2), null); + assert.equal(cpuDelta('cpu 10 0 0 10 0 0 0 0\n', 'cpu 10 0 0 10 0 0 0 0\n', 1, 2), null); + const before = 'cpu 10 0 0 10 0 0 0 0\n'; + const after = 'cpu 20 0 0 20 0 0 0 0\n'; + assert.equal(cpuDelta(before, after, 1000, 1000), null); + assert.equal(cpuDelta(before, after, 1001, 1000), null); + assert.equal(cpuDelta(before, after, 1000, 11001), null); + assert.equal(cpuDelta(before, after, Number.NaN, 1100), null); + assert.equal(memoryReading('MemTotal: 100 kB\nMemAvailable: 101 kB\n', 2), null); + assert.deepEqual(unitReading('LoadState=loaded\nActiveState=active\nSubState=exited\nMainPID=0\n', 2), { + configured: true, + state: 'active', + process: 'stopped', + checkedAt: 2, + }); + assert.deepEqual(unitReading('LoadState=loaded\nActiveState=active\nSubState=running\nMainPID=0\n', 2), { + configured: true, + state: 'active', + process: 'unknown', + checkedAt: 2, + }); + const result = await collectRockyHealth({ + signal: new AbortController().signal, + platform: 'linux', + sampleMs: 0, + now: () => 1234, + readProc: async () => { + throw new Error('/private/raw-path'); + }, + runSystemctl: async () => { + throw new Error('sensitive systemctl output'); + }, + }); + assert.equal(result.cpu, null); + assert.equal(result.memory, null); + assert.deepEqual(result.executor, { configured: null, state: 'unknown', process: 'unknown', checkedAt: null }); + assert.deepEqual(result.broker, result.executor); + assert.doesNotMatch(JSON.stringify(result), /private|sensitive|MainPID/); +}); + +test( + 'health route is exact, role-isolated, and remains responsive while the model slot is busy', + { timeout: 5000 }, + async (t) => { + let modelCalls = 0; + let healthCalls = 0; + const modelStarted = Promise.withResolvers(); + const modelRelease = Promise.withResolvers(); + const health = { + version: 1, + scope: 'rocky-read-only', + checkedAt: 1234, + gateway: 'responding', + cpu: null, + memory: null, + executor: { configured: null, state: 'unknown', process: 'unknown', checkedAt: null }, + broker: { configured: null, state: 'unknown', process: 'unknown', checkedAt: null }, + brokerResponse: 'not_probed', + model: 'not_probed', + }; + const server = createGateway({ + token: TOKEN, + monitorToken: MONITOR_TOKEN, + log: () => {}, + collectHealth: async () => { + healthCalls += 1; + return health; + }, + runRequest: ({ body }) => { + modelCalls += 1; + modelStarted.resolve(); + return modelRelease.promise.then(() => ({ + status: 200, + body: { type: 'message', model: body.model, content: [], stop_reason: 'end_turn' }, + })); + }, + }); + await new Promise((resolve) => server.listen({ host: '127.0.0.1', port: 0 }, resolve)); + t.after(() => { + modelRelease.resolve(); + server.closeAllConnections(); + server.close(); + }); + const origin = `http://127.0.0.1:${server.address().port}`; + const modelHeaders = { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' }; + const monitorHeaders = { Authorization: `Bearer ${MONITOR_TOKEN}` }; + const modelBody = JSON.stringify({ + model: 'claude-sonnet-5-5', + max_tokens: 10, + messages: [{ role: 'user', content: 'hi' }], + }); + const modelPost = (headers) => fetch(`${origin}/v1/messages`, { method: 'POST', headers, body: modelBody }); + assert.equal((await fetch(`${origin}/healthz`)).status, 401); + assert.equal((await fetch(`${origin}/healthz`, { headers: modelHeaders })).status, 401); + assert.equal((await modelPost(monitorHeaders)).status, 401); + for (const path of ['/healthz?x=1', '/healthz/', '/HEALTHZ', '/unknown']) { + assert.equal((await fetch(`${origin}${path}`, { headers: monitorHeaders })).status, 404); + } + const method = await fetch(`${origin}/healthz`, { method: 'POST', headers: monitorHeaders }); + assert.equal(method.status, 405); + assert.equal(method.headers.get('allow'), 'GET'); + assert.equal(healthCalls, 0); + assert.equal(modelCalls, 0); + const pending = modelPost(modelHeaders); + await modelStarted.promise; + const reply = await fetch(`${origin}/healthz`, { headers: monitorHeaders }); + assert.equal(reply.status, 200); + assert.deepEqual(await reply.json(), health); + assert.equal(healthCalls, 1); + assert.equal(modelCalls, 1); + modelRelease.resolve(); + assert.equal((await pending).status, 200); + }, +); + +test('unconfigured health returns 503 without changing model credential behavior', async (t) => { + const server = createGateway({ token: TOKEN, log: () => {} }); + await new Promise((resolve) => server.listen({ host: '127.0.0.1', port: 0 }, resolve)); + t.after(() => server.close()); + const origin = `http://127.0.0.1:${server.address().port}`; + assert.equal((await fetch(`${origin}/healthz`, { headers: { Authorization: `Bearer ${TOKEN}` } })).status, 503); + assert.equal((await fetch(`${origin}/healthz`, { method: 'POST' })).headers.get('allow'), 'GET'); +}); + +test('health collector deadline aborts its work and frees the response', async (t) => { + let aborted = false; + const server = createGateway({ + token: TOKEN, + monitorToken: MONITOR_TOKEN, + healthTimeoutMs: 30, + log: () => {}, + collectHealth: ({ signal }) => + new Promise((_, reject) => { + signal.addEventListener( + 'abort', + () => { + aborted = true; + reject(signal.reason); + }, + { once: true }, + ); + }), + }); + await new Promise((resolve) => server.listen({ host: '127.0.0.1', port: 0 }, resolve)); + t.after(() => server.close()); + const started = Date.now(); + const response = await fetch(`http://127.0.0.1:${server.address().port}/healthz`, { + headers: { Authorization: `Bearer ${MONITOR_TOKEN}` }, + }); + assert.equal(response.status, 504); + assert.ok(Date.now() - started < 1000); + assert.equal(aborted, true); +}); + +test('health deadline returns 504 even if an injected collector ignores abort', { timeout: 5000 }, async (t) => { + let aborted = false; + const server = createGateway({ + token: TOKEN, + monitorToken: MONITOR_TOKEN, + healthTimeoutMs: 30, + log: () => {}, + collectHealth: ({ signal }) => + new Promise(() => { + signal.addEventListener( + 'abort', + () => { + aborted = true; + }, + { once: true }, + ); + }), + }); + await new Promise((resolve) => server.listen({ host: '127.0.0.1', port: 0 }, resolve)); + t.after(() => server.close()); + const started = Date.now(); + const response = await fetch(`http://127.0.0.1:${server.address().port}/healthz`, { + headers: { Authorization: `Bearer ${MONITOR_TOKEN}` }, + }); + assert.equal(response.status, 504); + assert.ok(Date.now() - started < 1000); + assert.equal(aborted, true); + assert.deepEqual(await response.json(), { + type: 'error', + error: { type: 'api_error', message: 'Health probe timed out' }, + }); +});