fix: retry transient organization verification failures in CI
Deploy EG-BIM QA Gateway / deploy (push) Successful in 41s
Deploy EG-BIM QA Gateway / deploy (push) Successful in 41s
This commit is contained in:
1 parent
03f2cd3b65
commit
250221aecf
5 files changed
+124
-17
No files matched your search
@@ -111,7 +111,7 @@ Gitea의 스테이징 설정을 `.gitea/workflows/deploy.yml`에서 Worker 런
|
|||||||
| `STAGING_ORG_CONTEXT_TENANT_IDS` | Variables | `ORG_CONTEXT_TENANT_IDS` |
|
| `STAGING_ORG_CONTEXT_TENANT_IDS` | Variables | `ORG_CONTEXT_TENANT_IDS` |
|
||||||
| `STAGING_ORG_CONTEXT_CACHE_TTL_SECONDS` | Variables, 선택 | `ORG_CONTEXT_CACHE_TTL_SECONDS` (기본 300) |
|
| `STAGING_ORG_CONTEXT_CACHE_TTL_SECONDS` | Variables, 선택 | `ORG_CONTEXT_CACHE_TTL_SECONDS` (기본 300) |
|
||||||
|
|
||||||
조회는 `SCOPED`, `identifiers`를 사용합니다. 기준 테넌트는 UUID JSON 배열로 등록하며 UUID v4를 허용합니다. 각 테넌트를 최대 3개씩 동시 조회하고, 완전한 응답만 캐시합니다. 조회 타임아웃은 요청당 5초이며 일부 테넌트 실패·범위 불일치·구성원 누락은 검증 실패로 처리합니다.
|
조회는 `SCOPED`, `identifiers`를 사용합니다. 기준 테넌트는 UUID JSON 배열로 등록하며 UUID v4를 허용합니다. 각 테넌트를 최대 3개씩 동시 조회하고, 완전한 응답만 캐시합니다. 페이지 조회 타임아웃은 요청당 5초입니다. 배포 전 `npm run org:verify`는 요청당 15초를 허용하고, 타임아웃·통신 실패·HTTP 429/502/503/504에 한해 2초 간격으로 최대 3회 시도합니다. 인증 실패·범위 불일치·구성원 누락은 재시도 없이 검증 실패로 처리합니다.
|
||||||
|
|
||||||
기본 `ORG_CONTEXT_ENABLED=false`에서는 조직 API를 호출하지 않습니다. `true`로 배포하면 `npm run org:verify`가 실제 키로 조직 API를 먼저 검증하며, 실패 시 새 Worker 배포를 진행하지 않습니다. 성공 시에는 조직·사용자·소속 건수만 로그에 출력합니다. 전체 응답·전화번호·키 원문은 출력하지 않습니다.
|
기본 `ORG_CONTEXT_ENABLED=false`에서는 조직 API를 호출하지 않습니다. `true`로 배포하면 `npm run org:verify`가 실제 키로 조직 API를 먼저 검증하며, 실패 시 새 Worker 배포를 진행하지 않습니다. 성공 시에는 조직·사용자·소속 건수만 로그에 출력합니다. 전체 응답·전화번호·키 원문은 출력하지 않습니다.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
"deploy": "wrangler deploy",
|
"deploy": "wrangler deploy",
|
||||||
"deploy:production": "node scripts/deploy-production.mjs",
|
"deploy:production": "node scripts/deploy-production.mjs",
|
||||||
"r2:upload": "bash scripts/upload-r2.sh",
|
"r2:upload": "bash scripts/upload-r2.sh",
|
||||||
"check": "node --check src/index.js && node --check src/org-context.js && node --check egbim/app.js && node --check scripts/production-settings.mjs && node --check scripts/deploy-production.mjs && node --check scripts/verify-production.mjs && bash -n scripts/upload-r2.sh",
|
"check": "node --check src/index.js && node --check src/org-context.js && node --check egbim/app.js && node --check scripts/verify-org-context.mjs && node --check scripts/production-settings.mjs && node --check scripts/deploy-production.mjs && node --check scripts/verify-production.mjs && bash -n scripts/upload-r2.sh",
|
||||||
"test": "node --test --test-isolation=none tests/*.test.js",
|
"test": "node --test --test-isolation=none tests/*.test.js",
|
||||||
"org:verify": "node scripts/verify-org-context.mjs",
|
"org:verify": "node scripts/verify-org-context.mjs",
|
||||||
"production:verify": "node scripts/verify-production.mjs"
|
"production:verify": "node scripts/verify-production.mjs"
|
||||||
|
|||||||
@@ -1,19 +1,39 @@
|
|||||||
import { getOrgDirectory } from '../src/org-context.js';
|
import { getOrgDirectory } from '../src/org-context.js';
|
||||||
import { deploymentOrgSettings } from './org-context-settings.mjs';
|
import { deploymentOrgSettings } from './org-context-settings.mjs';
|
||||||
|
import { setTimeout as wait } from 'node:timers/promises';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
try {
|
export async function verifyOrganization(env = process.env, { fetchImpl = fetch, pause = wait, log = console.log } = {}) {
|
||||||
const { vars, secrets } = deploymentOrgSettings(process.env);
|
const { vars, secrets } = deploymentOrgSettings(env);
|
||||||
if (vars.ORG_CONTEXT_ENABLED !== 'true') {
|
if (vars.ORG_CONTEXT_ENABLED !== 'true') {
|
||||||
console.log('Organization integration disabled; existing behavior retained.');
|
log('Organization integration disabled; existing behavior retained.');
|
||||||
} else {
|
return;
|
||||||
const directory = await getOrgDirectory({ ...vars, ...secrets });
|
}
|
||||||
console.log(JSON.stringify({
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
status: 'verified', rootCount: JSON.parse(vars.ORG_CONTEXT_TENANT_IDS).length,
|
try {
|
||||||
tenantCount: directory.tenantCount, userCount: directory.byUser.size,
|
// CI can wait for a slow upstream without extending the page request timeout.
|
||||||
membershipCount: directory.membershipCount
|
const directory = await getOrgDirectory({ ...vars, ...secrets }, { fetchImpl, timeoutMs: 15000 });
|
||||||
}));
|
const summary = {
|
||||||
|
status: 'verified', rootCount: JSON.parse(vars.ORG_CONTEXT_TENANT_IDS).length,
|
||||||
|
tenantCount: directory.tenantCount, userCount: directory.byUser.size,
|
||||||
|
membershipCount: directory.membershipCount
|
||||||
|
};
|
||||||
|
log(JSON.stringify(summary));
|
||||||
|
return summary;
|
||||||
|
} catch (error) {
|
||||||
|
const retryable = /^org_context_(timeout|request_failed|http_(429|502|503|504))$/.test(error?.message || '');
|
||||||
|
if (!retryable || attempt === 3) throw error;
|
||||||
|
log(`Organization verification attempt ${attempt}/3 failed (${error.message}); retrying.`);
|
||||||
|
await pause(2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
|
try {
|
||||||
|
await verifyOrganization();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(/^org_context_[a-z0-9_]+$/.test(error?.message || '') ? error.message : 'Organization integration configuration or verification failed.');
|
||||||
|
process.exitCode = 1;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error(/^org_context_[a-z0-9_]+$/.test(error?.message || '') ? error.message : 'Organization integration configuration or verification failed.');
|
|
||||||
process.exitCode = 1;
|
|
||||||
}
|
}
|
||||||
+3
-2
@@ -120,9 +120,10 @@ export function resolveUserOrganization(directory, user) {
|
|||||||
return selected ? { status: 'resolved', ...selected } : { status: 'ambiguous_membership' };
|
return selected ? { status: 'resolved', ...selected } : { status: 'ambiguous_membership' };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOrgDirectory(env, { fetchImpl = fetch, now = Date.now } = {}) {
|
export async function getOrgDirectory(env, { fetchImpl = fetch, now = Date.now, timeoutMs = 5000 } = {}) {
|
||||||
const config = orgContextConfig(env);
|
const config = orgContextConfig(env);
|
||||||
if (!config) return null;
|
if (!config) return null;
|
||||||
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60000) throw new Error('org_context_invalid_timeout');
|
||||||
// Include credential rotation without storing the secret in cache keys or diagnostic output.
|
// Include credential rotation without storing the secret in cache keys or diagnostic output.
|
||||||
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(config.keySecret)));
|
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(config.keySecret)));
|
||||||
const fingerprint = Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
const fingerprint = Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
@@ -140,7 +141,7 @@ export async function getOrgDirectory(env, { fetchImpl = fetch, now = Date.now }
|
|||||||
url.searchParams.set('tenantId', config.roots[index]);
|
url.searchParams.set('tenantId', config.roots[index]);
|
||||||
url.searchParams.set('exposure', 'identifiers');
|
url.searchParams.set('exposure', 'identifiers');
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
try {
|
try {
|
||||||
const response = await fetchImpl(url.toString(), {
|
const response = await fetchImpl(url.toString(), {
|
||||||
headers: { accept: 'application/json', 'X-Baron-Key-ID': config.keyId, 'X-Baron-Key-Secret': config.keySecret },
|
headers: { accept: 'application/json', 'X-Baron-Key-ID': config.keyId, 'X-Baron-Key-Secret': config.keySecret },
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { verifyOrganization } from '../scripts/verify-org-context.mjs';
|
||||||
|
|
||||||
|
const ROOT = '038326b6-954a-48a7-a85f-efd83f62b82a';
|
||||||
|
const USER = '01900000-0000-7000-8000-000000000001';
|
||||||
|
let sequence = 0;
|
||||||
|
const settings = () => ({
|
||||||
|
ORG_CONTEXT_ENABLED: 'true', ORG_CONTEXT_TENANT_IDS: JSON.stringify([ROOT]),
|
||||||
|
ORG_CONTEXT_KEY_ID: 'fixture-verification', ORG_CONTEXT_KEY_SECRET: 'fixture-secret-' + ++sequence
|
||||||
|
});
|
||||||
|
const directory = () => ({ scope: { tenantId: ROOT }, tree: { id: ROOT, name: '기준 조직', members: [{ id: USER }] } });
|
||||||
|
const response = (body, status = 200) => Response.json(body, { status });
|
||||||
|
|
||||||
|
for (const status of [429, 502, 503, 504]) {
|
||||||
|
test(`deployment verification recovers from HTTP ${status} and reports only verified directory counts`, async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const logs = [], pauses = [];
|
||||||
|
const result = await verifyOrganization(settings(), {
|
||||||
|
fetchImpl: async () => ++calls === 1 ? response({}, status) : response(directory()),
|
||||||
|
pause: async (ms) => pauses.push(ms), log: (line) => logs.push(line)
|
||||||
|
});
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
assert.deepEqual(pauses, [2000]);
|
||||||
|
assert.equal(result.status, 'verified');
|
||||||
|
assert.equal(result.userCount, 1);
|
||||||
|
assert.ok(!JSON.stringify(logs).includes('fixture-secret'));
|
||||||
|
assert.ok(!JSON.stringify(logs).includes(USER));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('deployment verification allows 15 seconds per request and retries an actual abort', async (t) => {
|
||||||
|
const originalTimer = globalThis.setTimeout;
|
||||||
|
const timeouts = [];
|
||||||
|
t.mock.method(globalThis, 'setTimeout', (callback, delay) => {
|
||||||
|
timeouts.push(delay);
|
||||||
|
return originalTimer(callback, delay === 15000 ? 5 : delay);
|
||||||
|
});
|
||||||
|
let calls = 0;
|
||||||
|
const result = await verifyOrganization(settings(), {
|
||||||
|
fetchImpl: async (_url, { signal }) => {
|
||||||
|
if (++calls === 1) return new Promise((_resolve, reject) => {
|
||||||
|
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true });
|
||||||
|
});
|
||||||
|
return response(directory());
|
||||||
|
},
|
||||||
|
pause: async () => {}, log() {}
|
||||||
|
});
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
assert.deepEqual(timeouts, [15000, 15000]);
|
||||||
|
assert.equal(result.status, 'verified');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persistent network failure still blocks deployment after three attempts without exposing upstream errors', async () => {
|
||||||
|
let calls = 0, pauses = 0;
|
||||||
|
const logs = [];
|
||||||
|
await assert.rejects(verifyOrganization(settings(), {
|
||||||
|
fetchImpl: async () => { calls++; throw new Error('private-network-detail'); },
|
||||||
|
pause: async () => { pauses++; }, log: (line) => logs.push(line)
|
||||||
|
}), /org_context_request_failed/);
|
||||||
|
assert.equal(calls, 3);
|
||||||
|
assert.equal(pauses, 2);
|
||||||
|
assert.ok(!JSON.stringify(logs).includes('private-network-detail'));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [name, body, status, error] of [
|
||||||
|
['unauthorized', {}, 401, 'http_401'],
|
||||||
|
['forbidden', {}, 403, 'http_403'],
|
||||||
|
['wrong scope', { scope: { tenantId: USER } }, 200, 'scope_mismatch'],
|
||||||
|
['missing members', { scope: { tenantId: ROOT }, tree: { id: ROOT, name: 'Empty' } }, 200, 'members_missing']
|
||||||
|
]) {
|
||||||
|
test(`deployment verification rejects ${name} immediately`, async () => {
|
||||||
|
let calls = 0;
|
||||||
|
await assert.rejects(verifyOrganization(settings(), {
|
||||||
|
fetchImpl: async () => { calls++; return response(body, status); },
|
||||||
|
pause: async () => assert.fail('must not retry a configuration or data error'), log() {}
|
||||||
|
}), new RegExp('org_context_' + error));
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('disabled verification makes no upstream requests', async () => {
|
||||||
|
await verifyOrganization({ ORG_CONTEXT_ENABLED: 'false' }, {
|
||||||
|
fetchImpl: async () => assert.fail('disabled lookup'), log() {}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user