diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 7b517ca..be702d6 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -10,17 +10,22 @@ jobs: deploy: runs-on: ubuntu-latest env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - SESSION_SECRET: ${{ secrets.SESSION_SECRET }} - ABC_API_KEY: ${{ secrets.ABC_API_KEY }} - ORG_CONTEXT_ENABLED: ${{ vars.STAGING_ORG_CONTEXT_ENABLED }} - ORG_CONTEXT_API_URL: ${{ vars.STAGING_ORG_CONTEXT_API_URL }} - ORG_CONTEXT_SCOPE_MODE: ${{ vars.STAGING_ORG_CONTEXT_SCOPE_MODE }} - ORG_CONTEXT_EXPOSURE: ${{ vars.STAGING_ORG_CONTEXT_EXPOSURE }} - ORG_CONTEXT_TENANT_IDS: ${{ vars.STAGING_ORG_CONTEXT_TENANT_IDS }} - ORG_CONTEXT_CACHE_TTL_SECONDS: ${{ vars.STAGING_ORG_CONTEXT_CACHE_TTL_SECONDS }} - ORG_CONTEXT_KEY_ID: ${{ secrets.STAGING_ORG_CONTEXT_KEY_ID }} - ORG_CONTEXT_KEY_SECRET: ${{ secrets.STAGING_ORG_CONTEXT_KEY_SECRET }} + CLOUDFLARE_API_TOKEN: ${{ secrets.STG_CLOUDFLARE_API_TOKEN }} + SESSION_SECRET: ${{ secrets.STG_SESSION_SECRET }} + ABC_API_KEY: ${{ secrets.STG_ABC_API_KEY }} + AUTH_CLIENT_ID: ${{ vars.STG_AUTH_CLIENT_ID }} + AUTH_ISSUER: ${{ vars.STG_AUTH_ISSUER }} + ABC_API_BASE_URL: ${{ vars.STG_ABC_API_BASE_URL }} + ABC_PROJECT_ID: ${{ vars.STG_ABC_PROJECT_ID }} + ABC_CHANNEL_ID: ${{ vars.STG_ABC_CHANNEL_ID }} + ORG_CONTEXT_ENABLED: ${{ vars.STG_ORG_CONTEXT_ENABLED }} + ORG_CONTEXT_API_URL: ${{ vars.STG_ORG_CONTEXT_API_URL }} + ORG_CONTEXT_SCOPE_MODE: ${{ vars.STG_ORG_CONTEXT_SCOPE_MODE }} + ORG_CONTEXT_EXPOSURE: ${{ vars.STG_ORG_CONTEXT_EXPOSURE }} + ORG_CONTEXT_TENANT_IDS: ${{ vars.STG_ORG_CONTEXT_TENANT_IDS }} + ORG_CONTEXT_CACHE_TTL_SECONDS: ${{ vars.STG_ORG_CONTEXT_CACHE_TTL_SECONDS }} + ORG_CONTEXT_KEY_ID: ${{ secrets.STG_ORG_CONTEXT_KEY_ID }} + ORG_CONTEXT_KEY_SECRET: ${{ secrets.STG_ORG_CONTEXT_KEY_SECRET }} steps: - name: Checkout @@ -35,9 +40,10 @@ jobs: shell: bash run: | set -euo pipefail - test -n "$CLOUDFLARE_API_TOKEN" || { echo "CLOUDFLARE_API_TOKEN is missing"; exit 1; } - test -n "$SESSION_SECRET" || { echo "SESSION_SECRET is missing"; exit 1; } - test -n "$ABC_API_KEY" || { echo "ABC_API_KEY is missing"; exit 1; } + test -n "$CLOUDFLARE_API_TOKEN" || { echo "STG_CLOUDFLARE_API_TOKEN is missing"; exit 1; } + test -n "$SESSION_SECRET" || { echo "STG_SESSION_SECRET is missing"; exit 1; } + test -n "$ABC_API_KEY" || { echo "STG_ABC_API_KEY is missing"; exit 1; } + node scripts/deploy-worker.mjs --validate-only - name: Install dependencies run: npm ci --no-fund --no-audit diff --git a/README.md b/README.md index 2dcdc8a..b07b61c 100644 --- a/README.md +++ b/README.md @@ -82,15 +82,37 @@ Worker는 OAuth Authorization Code + PKCE를 사용하고, callback에서 검증 ## Gitea Actions 등록값 -운영 `qa.baroncs.co.kr` 배포는 [production 파이프라인 안내](docs/qa-production-pipeline.md)를 따른다. 운영 workflow는 `Deploy EG-BIM QA Production`이며 `main`에서 수동으로 실행한다. 아래 기존 등록값은 스테이징 배포용이다. +운영 `qa.baroncs.co.kr` 배포는 [production 파이프라인 안내](docs/qa-production-pipeline.md)를 따른다. 운영 workflow는 `Deploy EG-BIM QA Production`이며 `main`에서 수동으로 실행한다. 스테이징 Actions 설정은 `STG_*`, 운영은 `PROD_*` 이름을 사용한다. -저장소 Settings → Actions → Secrets에 아래 기본 Secret 3개를 등록합니다. +저장소 Settings → Actions → Secrets에 아래 스테이징 Secret을 등록합니다. 기존 접두사 없는 이름과 `STAGING_*` 이름은 새 workflow에서 읽지 않습니다. | 이름 | 종류 | 값 | |---|---|---| -| `CLOUDFLARE_API_TOKEN` | Secret | Workers Scripts Edit + Workers R2 Storage Edit 권한의 Cloudflare API Token | -| `SESSION_SECRET` | Secret | `openssl rand -hex 32`로 생성한 세션 서명키 | -| `ABC_API_KEY` | Secret | 프로젝트 전용 ABC UserFeedback API Key | +| `STG_CLOUDFLARE_API_TOKEN` | Secret | 스테이징 Worker·R2 배포용 Cloudflare API Token | +| `STG_SESSION_SECRET` | Secret | 스테이징 세션 서명키. 기존 서명키를 유지하면 기존 로그인 세션도 유지 | +| `STG_ABC_API_KEY` | Secret | 스테이징 ABC project/channel API Key | +| `STG_ORG_CONTEXT_KEY_ID` | Secret | 스테이징 조직 API Key ID | +| `STG_ORG_CONTEXT_KEY_SECRET` | Secret | 스테이징 조직 API Key Secret | + +스테이징 일반 변수는 기존 설정에서 확인한 값으로 아래와 같이 분리한다. 운영 프로젝트·채널 ID를 스테이징에 복사하지 않는다. + +| Variable | 스테이징 값 | +|---|---| +| `STG_AUTH_CLIENT_ID` | `e16898a7-acd2-420a-a224-cdcd89ff66cd` | +| `STG_AUTH_ISSUER` | `https://sso.hmac.kr/oidc` | +| `STG_ABC_API_BASE_URL` | `https://feedback.hmac.kr` | +| `STG_ABC_PROJECT_ID` | `01a0ae3f-fcf6-74b5-bdc4-70d942d6ad72` | +| `STG_ABC_CHANNEL_ID` | `01a0ae40-51c2-7647-a93c-0249b3759777` | +| `STG_ORG_CONTEXT_ENABLED` | `true` | +| `STG_ORG_CONTEXT_API_URL` | `https://sadmin.hmac.kr/api/v1/integrations/org-context` | +| `STG_ORG_CONTEXT_SCOPE_MODE` | `SCOPED` | +| `STG_ORG_CONTEXT_EXPOSURE` | `identifiers` | +| `STG_ORG_CONTEXT_TENANT_IDS` | 기존 스테이징 기준 tenant 7개 JSON 배열 | +| `STG_ORG_CONTEXT_CACHE_TTL_SECONDS` | `300` | + +workflow가 `STG_`를 제거한 Worker/CLI 환경 이름으로 매핑한다. `scripts/deploy-worker.mjs`는 필수 API·SSO 값이 비었을 때 다른 환경으로 대체하지 않고 중단한다. `AUTH_ISSUER`로 authorization·token·userinfo 주소를 구성한다. `--validate-only`와 `--dry-run`은 Worker·Secret을 변경하지 않는다. + +2026-11-11 확인: 일반 변수 11개를 이 저장소의 Gitea Actions Variables에 등록하고 값 일치를 확인했다. `STG_*` Secret 5개는 사용자가 등록한다. 현재 원격 workflow가 사용하는 기존 이름은 새 Secret 등록 및 workflow 반영 이후 정리한다. 운영 등록 목록은 [production 안내](docs/qa-production-pipeline.md)를 따른다. `CLOUDFLARE_ACCOUNT_ID`는 secret으로 등록할 필요가 없습니다. `wrangler.toml`에 `81fa2d48964d31dd0da9558f9ce601d1`로 설정되어 있습니다. @@ -112,14 +134,14 @@ Gitea의 스테이징 설정을 `.gitea/workflows/deploy.yml`에서 Worker 런 | Gitea | 위치 | Worker 런타임 | |---|---|---| -| `STAGING_ORG_CONTEXT_KEY_ID` | Secrets | `ORG_CONTEXT_KEY_ID` (Secret) | -| `STAGING_ORG_CONTEXT_KEY_SECRET` | Secrets | `ORG_CONTEXT_KEY_SECRET` (Secret) | -| `STAGING_ORG_CONTEXT_ENABLED` | Variables | `ORG_CONTEXT_ENABLED` | -| `STAGING_ORG_CONTEXT_API_URL` | Variables | `ORG_CONTEXT_API_URL` | -| `STAGING_ORG_CONTEXT_SCOPE_MODE` | Variables | `ORG_CONTEXT_SCOPE_MODE` | -| `STAGING_ORG_CONTEXT_EXPOSURE` | Variables | `ORG_CONTEXT_EXPOSURE` | -| `STAGING_ORG_CONTEXT_TENANT_IDS` | Variables | `ORG_CONTEXT_TENANT_IDS` | -| `STAGING_ORG_CONTEXT_CACHE_TTL_SECONDS` | Variables, 선택 | `ORG_CONTEXT_CACHE_TTL_SECONDS` (기본 300) | +| `STG_ORG_CONTEXT_KEY_ID` | Secrets | `ORG_CONTEXT_KEY_ID` (Secret) | +| `STG_ORG_CONTEXT_KEY_SECRET` | Secrets | `ORG_CONTEXT_KEY_SECRET` (Secret) | +| `STG_ORG_CONTEXT_ENABLED` | Variables | `ORG_CONTEXT_ENABLED` | +| `STG_ORG_CONTEXT_API_URL` | Variables | `ORG_CONTEXT_API_URL` | +| `STG_ORG_CONTEXT_SCOPE_MODE` | Variables | `ORG_CONTEXT_SCOPE_MODE` | +| `STG_ORG_CONTEXT_EXPOSURE` | Variables | `ORG_CONTEXT_EXPOSURE` | +| `STG_ORG_CONTEXT_TENANT_IDS` | Variables | `ORG_CONTEXT_TENANT_IDS` | +| `STG_ORG_CONTEXT_CACHE_TTL_SECONDS` | Variables, 선택 | `ORG_CONTEXT_CACHE_TTL_SECONDS` (기본 300) | 조회는 `SCOPED`, `identifiers`를 사용합니다. 기준 테넌트는 UUID JSON 배열로 등록하며 UUID v4를 허용합니다. 각 테넌트를 최대 3개씩 동시 조회하고, 완전한 응답만 캐시합니다. 페이지 조회 타임아웃은 요청당 5초입니다. 배포 전 `npm run org:verify`는 요청당 15초를 허용하고, 타임아웃·통신 실패·HTTP 429/502/503/504에 한해 2초 간격으로 최대 3회 시도합니다. 인증 실패·범위 불일치·구성원 누락은 재시도 없이 검증 실패로 처리합니다. diff --git a/scripts/deploy-worker.mjs b/scripts/deploy-worker.mjs index 66f57c8..0585326 100644 --- a/scripts/deploy-worker.mjs +++ b/scripts/deploy-worker.mjs @@ -1,8 +1,9 @@ import { spawn } from 'node:child_process'; -import { deploymentOrgSettings } from './org-context-settings.mjs'; +import { stagingSettings } from './staging-settings.mjs'; const worker = 'baron-qa-gateway-test'; const dryRun = process.argv.includes('--dry-run'); +const validateOnly = process.argv.includes('--validate-only'); function execute(args, input) { return new Promise((resolve, reject) => { const child = spawn('npx', ['wrangler', ...args], { @@ -26,13 +27,16 @@ function execute(args, input) { }); } try { - const { vars, secrets } = deploymentOrgSettings(process.env); - if (!dryRun && Object.keys(secrets).length) await execute(['secret', 'bulk', '--name', worker], JSON.stringify(secrets)); - const args = ['deploy', '--name', worker]; - if (dryRun) args.push('--dry-run'); - for (const [name, value] of Object.entries(vars)) args.push('--var', name + ':' + value); - await execute(args); -} catch { - console.error('Worker deployment failed. Check configuration and preceding Wrangler diagnostics.'); + if (process.argv.slice(2).some((option) => !['--dry-run', '--validate-only'].includes(option))) throw new Error('Unknown staging deployment option'); + const { vars, secrets } = stagingSettings(process.env); + if (!validateOnly) { + if (!dryRun && Object.keys(secrets).length) await execute(['secret', 'bulk', '--name', worker], JSON.stringify(secrets)); + const args = ['deploy', '--config', 'wrangler.toml', '--name', worker]; + if (dryRun) args.push('--dry-run'); + for (const [name, value] of Object.entries(vars)) args.push('--var', name + ':' + value); + await execute(args); + } +} catch (error) { + console.error(error.message || 'Worker deployment failed. Check configuration and preceding Wrangler diagnostics.'); process.exitCode = 1; } diff --git a/scripts/staging-settings.mjs b/scripts/staging-settings.mjs new file mode 100644 index 0000000..d5324db --- /dev/null +++ b/scripts/staging-settings.mjs @@ -0,0 +1,36 @@ +import { deploymentOrgSettings } from './org-context-settings.mjs'; + +function required(env, name) { + const value = String(env[name] || '').trim(); + if (!value) throw new Error('STG_' + name + ' is required for staging'); + return value; +} + +function httpsUrl(value, name, originOnly = false) { + let url; + try { url = new URL(value); } catch { throw new Error('STG_' + name + ' must be an HTTPS URL'); } + if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || (originOnly && url.pathname !== '/')) { + throw new Error('STG_' + name + ' must be an HTTPS ' + (originOnly ? 'origin' : 'issuer URL')); + } + return originOnly ? url.origin : url.origin + url.pathname.replace(/\/+$/, ''); +} + +export function stagingSettings(env) { + const organization = deploymentOrgSettings({ ...env, ORG_CONTEXT_ENABLED: required(env, 'ORG_CONTEXT_ENABLED') }); + const issuer = httpsUrl(required(env, 'AUTH_ISSUER'), 'AUTH_ISSUER'); + const vars = { + ...organization.vars, + AUTH_CLIENT_ID: required(env, 'AUTH_CLIENT_ID'), + AUTH_AUTHORIZE_URL: issuer + '/oauth2/auth', + AUTH_TOKEN_URL: issuer + '/oauth2/token', + AUTH_USERINFO_URL: issuer + '/userinfo', + ABC_API_BASE_URL: httpsUrl(required(env, 'ABC_API_BASE_URL'), 'ABC_API_BASE_URL', true), + ABC_PROJECT_ID: required(env, 'ABC_PROJECT_ID'), + ABC_CHANNEL_ID: required(env, 'ABC_CHANNEL_ID') + }; + const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + for (const name of ['ABC_PROJECT_ID', 'ABC_CHANNEL_ID']) { + if (!uuid.test(vars[name])) throw new Error('STG_' + name + ' must be a UUID'); + } + return { vars, secrets: organization.secrets }; +} diff --git a/tests/staging-deployment.test.js b/tests/staging-deployment.test.js new file mode 100644 index 0000000..6be589d --- /dev/null +++ b/tests/staging-deployment.test.js @@ -0,0 +1,72 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { stagingSettings } from '../scripts/staging-settings.mjs'; + +const fixture = () => ({ + AUTH_CLIENT_ID: 'staging-client', AUTH_ISSUER: 'https://identity.staging.example/oidc/', + ABC_API_BASE_URL: 'https://feedback.staging.example/', + ABC_PROJECT_ID: '01900000-0000-7000-8000-000000000001', + ABC_CHANNEL_ID: '01900000-0000-7000-8000-000000000002', + ORG_CONTEXT_ENABLED: 'false', + PROD_AUTH_CLIENT_ID: 'production-client', PROD_ABC_API_BASE_URL: 'https://production.example' +}); + +test('staging uses its mapped API and SSO settings independently of production values', () => { + const settings = stagingSettings(fixture()); + assert.equal(settings.vars.AUTH_CLIENT_ID, 'staging-client'); + assert.equal(settings.vars.AUTH_AUTHORIZE_URL, 'https://identity.staging.example/oidc/oauth2/auth'); + assert.equal(settings.vars.AUTH_TOKEN_URL, 'https://identity.staging.example/oidc/oauth2/token'); + assert.equal(settings.vars.AUTH_USERINFO_URL, 'https://identity.staging.example/oidc/userinfo'); + assert.equal(settings.vars.ABC_API_BASE_URL, 'https://feedback.staging.example'); + assert.equal(settings.vars.PROD_AUTH_CLIENT_ID, undefined); +}); + +test('missing staging settings cannot silently fall back to a different environment', () => { + for (const name of ['AUTH_CLIENT_ID', 'AUTH_ISSUER', 'ABC_API_BASE_URL', 'ABC_PROJECT_ID', 'ABC_CHANNEL_ID', 'ORG_CONTEXT_ENABLED']) { + const env = fixture(); + delete env[name]; + assert.throws(() => stagingSettings(env), new RegExp('STG_' + name)); + } + assert.throws(() => stagingSettings({ ...fixture(), ABC_CHANNEL_ID: 'not-a-uuid' }), /STG_ABC_CHANNEL_ID/); + assert.throws(() => stagingSettings({ ...fixture(), AUTH_ISSUER: 'http://identity.example' }), /STG_AUTH_ISSUER/); + assert.throws(() => stagingSettings({ ...fixture(), ABC_API_BASE_URL: 'https://api.example/api' }), /STG_ABC_API_BASE_URL/); +}); + +test('staging organization credentials remain separate from public Worker variables', () => { + const settings = stagingSettings({ ...fixture(), ORG_CONTEXT_ENABLED: 'true', + ORG_CONTEXT_TENANT_IDS: '["038326b6-954a-48a7-a85f-efd83f62b82a"]', + ORG_CONTEXT_KEY_ID: 'fixture-key', ORG_CONTEXT_KEY_SECRET: 'fixture-secret' + }); + assert.equal(settings.secrets.ORG_CONTEXT_KEY_SECRET, 'fixture-secret'); + assert.equal(settings.vars.ORG_CONTEXT_KEY_SECRET, undefined); +}); + +test('staging validation never deploys and dry-run pins the staging Worker and config', () => { + const directory = mkdtempSync(join(tmpdir(), 'qa-staging-test-')); + const capture = join(directory, 'commands.jsonl'); + writeFileSync(join(directory, 'npx'), `#!/usr/bin/env node +require('node:fs').appendFileSync(process.env.QA_TEST_CAPTURE, JSON.stringify(process.argv.slice(2)) + '\\n'); +`, { mode: 0o700 }); + const script = fileURLToPath(new URL('../scripts/deploy-worker.mjs', import.meta.url)); + const env = { ...process.env, ...fixture(), PATH: directory + ':' + process.env.PATH, QA_TEST_CAPTURE: capture }; + try { + const validation = spawnSync(process.execPath, [script, '--validate-only'], { env, encoding: 'utf8' }); + assert.equal(validation.status, 0, validation.stderr); + assert.equal(existsSync(capture), false); + const dryRun = spawnSync(process.execPath, [script, '--dry-run'], { env, encoding: 'utf8' }); + assert.equal(dryRun.status, 0, dryRun.stderr); + const commands = readFileSync(capture, 'utf8').trim().split('\n').map(JSON.parse); + assert.equal(commands.length, 1); + const args = commands[0]; + assert.ok(args.includes('--dry-run')); + assert.ok(args.includes('baron-qa-gateway-test')); + assert.ok(args.includes('wrangler.toml')); + assert.ok(args.includes('ABC_API_BASE_URL:https://feedback.staging.example')); + assert.ok(!args.some(value => value.includes('production.example') || value === 'secret')); + } finally { rmSync(directory, { recursive: true, force: true }); } +});