feat: add manual production deployment pipeline
Deploy EG-BIM QA Gateway / deploy (push) Failing after 7s
Deploy EG-BIM QA Gateway / deploy (push) Failing after 7s
This commit is contained in:
1 parent
1808f14cc6
commit
0755155368
13 files changed
+1391
-5
No files matched your search
@@ -0,0 +1,40 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { productionSettings } from './production-settings.mjs';
|
||||
|
||||
function execute(args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('npx', ['--no-install', 'wrangler', ...args], { stdio: 'inherit' });
|
||||
child.once('error', () => reject(new Error('Production Worker command could not start')));
|
||||
child.once('exit', (code) => code === 0 ? resolve() : reject(new Error('Production Worker deployment failed')));
|
||||
});
|
||||
}
|
||||
|
||||
let secretDirectory;
|
||||
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 }));
|
||||
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);
|
||||
if (options.includes('--dry-run')) {
|
||||
args.push('--dry-run');
|
||||
} else {
|
||||
// Deploy code and secrets together; keep secret values out of arguments and logs.
|
||||
secretDirectory = await mkdtemp(join(tmpdir(), 'qa-production-secrets-'));
|
||||
const secretPath = join(secretDirectory, 'secrets.json');
|
||||
await writeFile(secretPath, JSON.stringify(settings.secrets), { mode: 0o600 });
|
||||
args.push('--secrets-file', secretPath);
|
||||
}
|
||||
await execute(args);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (secretDirectory) await rm(secretDirectory, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { orgContextConfig } from '../src/org-context.js';
|
||||
import { deploymentOrgSettings } from './org-context-settings.mjs';
|
||||
|
||||
export const productionTarget = Object.freeze({
|
||||
worker: 'baron-qa-gateway-prod',
|
||||
config: 'wrangler.production.toml',
|
||||
bucket: 'baron-qa-write',
|
||||
origin: 'https://qa.baroncs.co.kr'
|
||||
});
|
||||
|
||||
function required(env, name) {
|
||||
const value = String(env[name] || '').trim();
|
||||
if (!value) throw new Error(name + ' is required for production');
|
||||
return value;
|
||||
}
|
||||
|
||||
function httpsBase(value, name) {
|
||||
let url;
|
||||
try { url = new URL(value); } catch { throw new Error(name + ' must be an HTTPS origin'); }
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || url.pathname !== '/') {
|
||||
throw new Error(name + ' must be an HTTPS origin');
|
||||
}
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
export function productionSettings(env) {
|
||||
// These names are mapped exclusively from PROD_* settings in the production workflow.
|
||||
required(env, 'CLOUDFLARE_API_TOKEN');
|
||||
const secrets = {
|
||||
SESSION_SECRET: required(env, 'SESSION_SECRET'),
|
||||
ABC_API_KEY: required(env, 'ABC_API_KEY'),
|
||||
ORG_CONTEXT_KEY_ID: required(env, 'ORG_CONTEXT_KEY_ID'),
|
||||
ORG_CONTEXT_KEY_SECRET: required(env, 'ORG_CONTEXT_KEY_SECRET')
|
||||
};
|
||||
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);
|
||||
orgContextConfig({ ...organization.vars, ...secrets });
|
||||
|
||||
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')
|
||||
};
|
||||
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');
|
||||
}
|
||||
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;
|
||||
return { ...productionTarget, vars, secrets };
|
||||
}
|
||||
+11
-2
@@ -2,12 +2,21 @@
|
||||
set -euo pipefail
|
||||
|
||||
bucket="${1:-baron-qa-pages}"
|
||||
config="${2:-wrangler.toml}"
|
||||
prefix="${3:-}"
|
||||
|
||||
if [ "$bucket" = "baron-qa-write" ]; then
|
||||
if [ "$config" != "wrangler.production.toml" ] || [[ ! "$prefix" =~ ^releases/[0-9a-f]{40}$ ]]; then
|
||||
echo "Production uploads require the production config and a full commit release prefix" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
upload() {
|
||||
local file="$1"
|
||||
local key="$2"
|
||||
local key="${prefix:+$prefix/}$2"
|
||||
local content_type="$3"
|
||||
npx wrangler r2 object put "$bucket/$key" --file "$file" --content-type "$content_type" --remote
|
||||
npx --no-install wrangler r2 object put "$bucket/$key" --config "$config" --file "$file" --content-type "$content_type" --remote
|
||||
}
|
||||
|
||||
upload egbim/index.html egbim/index.html text/html
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { productionTarget } from './production-settings.mjs';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { setTimeout as wait } from 'node:timers/promises';
|
||||
|
||||
export async function verifyProduction(fetchImpl = fetch, revision = process.env.DEPLOY_COMMIT_SHA) {
|
||||
const origin = productionTarget.origin;
|
||||
const request = (path) => fetchImpl(origin + path, { redirect: 'manual', signal: AbortSignal.timeout(15000), headers: { 'cache-control': 'no-cache' } });
|
||||
const session = await request('/auth/session');
|
||||
if (!session.ok) throw new Error('Production session endpoint failed');
|
||||
const body = await session.json();
|
||||
if (body.authenticated !== false || body.user !== null) throw new Error('Production session endpoint returned an unexpected anonymous session');
|
||||
const asset = await request('/assets/styles.css');
|
||||
if (!asset.ok || !String(asset.headers.get('content-type')).includes('text/css') || !(await asset.text()).trim()) {
|
||||
throw new Error('Production stylesheet is unavailable');
|
||||
}
|
||||
if (revision && asset.headers.get('x-qa-release') !== 'releases/' + revision) {
|
||||
throw new Error('Production stylesheet does not match the deployed commit');
|
||||
}
|
||||
const protectedPage = await request('/egbim/write.html');
|
||||
const login = new URL(protectedPage.headers.get('location') || '/', origin);
|
||||
if (protectedPage.status !== 302 || login.origin !== origin || login.pathname !== '/auth/login') {
|
||||
throw new Error('Production write page must redirect anonymous visitors to login');
|
||||
}
|
||||
console.log('Production HTTP checks passed: session, stylesheet, protected write page.');
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
for (let attempt = 1; attempt <= 5; attempt++) {
|
||||
try { await verifyProduction(); break; } catch (error) {
|
||||
if (attempt === 5) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log('Waiting for production deployment propagation (' + attempt + '/5).');
|
||||
await wait(5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user