1 parent
0d9b1fd703
commit
7c09c89da5
9 files changed
+202
-31
No files matched your search
@@ -17,7 +17,9 @@ try {
|
||||
const options = process.argv.slice(2);
|
||||
if (options.some((option) => !['--dry-run', '--validate-only'].includes(option))) throw new Error('Unknown production deployment option');
|
||||
const settings = productionSettings(process.env);
|
||||
console.log(JSON.stringify({ worker: settings.worker, bucket: settings.bucket, origin: settings.origin, assets: settings.vars.QA_ASSET_PREFIX }));
|
||||
console.log(JSON.stringify({ worker: settings.worker, bucket: settings.bucket, origin: settings.origin,
|
||||
api: settings.vars.ABC_API_BASE_URL, authorize: settings.vars.AUTH_AUTHORIZE_URL,
|
||||
assets: settings.vars.QA_ASSET_PREFIX }));
|
||||
if (!options.includes('--validate-only')) {
|
||||
const args = ['deploy', '--config', settings.config, '--name', settings.worker];
|
||||
for (const [name, value] of Object.entries(settings.vars)) args.push('--var', name + ':' + value);
|
||||
|
||||
@@ -5,7 +5,12 @@ export const productionTarget = Object.freeze({
|
||||
worker: 'baron-qa-gateway-prod',
|
||||
config: 'wrangler.production.toml',
|
||||
bucket: 'baron-qa-write',
|
||||
origin: 'https://qa.baroncs.co.kr'
|
||||
origin: 'https://qa.baroncs.co.kr',
|
||||
apiOrigin: 'https://qa.brsw.kr',
|
||||
projectId: '01a1c543-ab4f-744f-8eff-ab85f2644271',
|
||||
channelId: '01a1c544-35ac-7674-9bf0-2469bfbe3e52',
|
||||
authIssuer: 'https://app.brsw.kr/oidc',
|
||||
orgContextApiUrl: 'https://app.brsw.kr/api/v1/integrations/org-context'
|
||||
});
|
||||
|
||||
function required(env, name) {
|
||||
@@ -23,6 +28,15 @@ function httpsBase(value, name) {
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
function httpsIssuer(value) {
|
||||
let url;
|
||||
try { url = new URL(value); } catch { throw new Error('AUTH_ISSUER must be an HTTPS issuer URL'); }
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) {
|
||||
throw new Error('AUTH_ISSUER must be an HTTPS issuer URL');
|
||||
}
|
||||
return url.origin + url.pathname.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function productionSettings(env) {
|
||||
// These names are mapped exclusively from PROD_* settings in the production workflow.
|
||||
required(env, 'CLOUDFLARE_API_TOKEN');
|
||||
@@ -34,20 +48,34 @@ export function productionSettings(env) {
|
||||
};
|
||||
if (secrets.SESSION_SECRET.length < 32) throw new Error('SESSION_SECRET must contain at least 32 characters');
|
||||
if (required(env, 'ORG_CONTEXT_ENABLED') !== 'true') throw new Error('ORG_CONTEXT_ENABLED must be true for production');
|
||||
const organization = deploymentOrgSettings(env);
|
||||
const organization = deploymentOrgSettings({
|
||||
...env, ORG_CONTEXT_API_URL: env.ORG_CONTEXT_API_URL || productionTarget.orgContextApiUrl
|
||||
});
|
||||
orgContextConfig({ ...organization.vars, ...secrets });
|
||||
|
||||
const issuer = httpsIssuer(env.AUTH_ISSUER || productionTarget.authIssuer);
|
||||
const vars = {
|
||||
...organization.vars,
|
||||
AUTH_CLIENT_ID: required(env, 'AUTH_CLIENT_ID'),
|
||||
ABC_API_BASE_URL: httpsBase(required(env, 'ABC_API_BASE_URL'), 'ABC_API_BASE_URL'),
|
||||
ABC_PROJECT_ID: required(env, 'ABC_PROJECT_ID'),
|
||||
ABC_CHANNEL_ID: required(env, 'ABC_CHANNEL_ID')
|
||||
AUTH_AUTHORIZE_URL: issuer + '/oauth2/auth',
|
||||
AUTH_TOKEN_URL: issuer + '/oauth2/token',
|
||||
AUTH_USERINFO_URL: issuer + '/userinfo',
|
||||
ABC_API_BASE_URL: httpsBase(env.ABC_API_BASE_URL || productionTarget.apiOrigin, 'ABC_API_BASE_URL'),
|
||||
ABC_PROJECT_ID: String(env.ABC_PROJECT_ID || productionTarget.projectId).trim(),
|
||||
ABC_CHANNEL_ID: String(env.ABC_CHANNEL_ID || productionTarget.channelId).trim()
|
||||
};
|
||||
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(name + ' must be a UUID');
|
||||
}
|
||||
// Fail before uploads if a PROD_* variable points at staging or another channel.
|
||||
for (const [name, expected] of Object.entries({
|
||||
ABC_API_BASE_URL: productionTarget.apiOrigin,
|
||||
ABC_PROJECT_ID: productionTarget.projectId,
|
||||
ABC_CHANNEL_ID: productionTarget.channelId
|
||||
})) {
|
||||
if (vars[name] !== expected) throw new Error(name + ' must match the production EGBIM Q&A target: ' + expected);
|
||||
}
|
||||
const revision = required(env, 'DEPLOY_COMMIT_SHA');
|
||||
if (!/^[0-9a-f]{40}$/.test(revision)) throw new Error('DEPLOY_COMMIT_SHA must be a full lowercase Git commit SHA');
|
||||
vars.QA_ASSET_PREFIX = 'releases/' + revision;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { productionSettings } from './production-settings.mjs';
|
||||
|
||||
export async function verifyAbc(env = process.env, fetchImpl = fetch) {
|
||||
const { vars, secrets } = productionSettings(env);
|
||||
const endpoint = vars.ABC_API_BASE_URL + '/api/projects/' + encodeURIComponent(vars.ABC_PROJECT_ID)
|
||||
+ '/channels/' + encodeURIComponent(vars.ABC_CHANNEL_ID) + '/fields';
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl(endpoint, {
|
||||
method: 'GET', redirect: 'error', signal: AbortSignal.timeout(15000),
|
||||
headers: { accept: 'application/json', 'x-api-key': secrets.ABC_API_KEY }
|
||||
});
|
||||
} catch {
|
||||
throw new Error('Production ABC API request failed; check PROD_ABC_API_BASE_URL, HTTPS and connectivity');
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error('Production ABC API returned HTTP ' + response.status
|
||||
+ '; check PROD_ABC_API_KEY, PROD_ABC_PROJECT_ID and PROD_ABC_CHANNEL_ID');
|
||||
}
|
||||
let body;
|
||||
try { body = await response.json(); } catch { throw new Error('Production ABC fields response is not JSON'); }
|
||||
const fields = Array.isArray(body) ? body : body?.items || body?.data?.items || body?.data;
|
||||
if (!Array.isArray(fields)) throw new Error('Production ABC fields response is not a field list');
|
||||
const keys = new Set(fields.map((field) => field.key));
|
||||
const missing = ['title', 'contents', 'Category'].filter((key) => !keys.has(key));
|
||||
if (missing.length) throw new Error('Production Q&A channel is missing required fields: ' + missing.join(', '));
|
||||
const categories = new Set((fields.find((field) => field.key === 'Category').options || []).map((option) => option.key));
|
||||
const missingCategories = ['ERROR_QNA', 'IMPROVEMENT_QNA', 'GENERAL_QNA'].filter((key) => !categories.has(key));
|
||||
if (missingCategories.length) throw new Error('Production Q&A channel is missing category options: ' + missingCategories.join(', '));
|
||||
console.log('Production ABC API verified: ' + vars.ABC_API_BASE_URL + ', project ' + vars.ABC_PROJECT_ID
|
||||
+ ', channel ' + vars.ABC_CHANNEL_ID + '. No feedback data was changed.');
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
try { await verifyAbc(); } catch (error) { console.error(error.message); process.exitCode = 1; }
|
||||
}
|
||||
Reference in new issue
Block a user