Files
aia-website/www/tools/deploy-portal-resources.mjs
T

118 lines
8.8 KiB
JavaScript

/** Prepare and optionally transfer already-approved employee resources. */
import { createHash } from 'node:crypto';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { rsyncArguments, sshTarget } from './deploy.mjs';
import { mappingsFor, approvedMapping } from './portal-production.mjs';
const toolRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const repositoryRoot = path.resolve(toolRoot, '..');
const root = '/srv/www/azinstitute4autism.com';
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
const fullSha = /^[0-9a-f]{40}$/;
function fail(message) { throw new Error(message); }
function run(command, args, { quiet = false } = {}) {
const result = spawnSync(command, args, { cwd: toolRoot, encoding: 'utf8' });
if (result.error || result.status !== 0) fail(`${command} failed${result.stderr ? `: ${result.stderr.trim()}` : ''}`);
if (!quiet && result.stdout) process.stdout.write(result.stdout);
return result.stdout.trim();
}
function runBytes(command, args) {
const result = spawnSync(command, args, { cwd: toolRoot });
if (result.error || result.status !== 0) fail(`${command} failed${result.stderr?.length ? `: ${result.stderr.toString().trim()}` : ''}`);
return result.stdout;
}
function safeName(value) { return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,240}$/.test(value) && value !== '.' && value !== '..'; }
function roleAndName(record, mapping) {
const role = mapping?.role;
if (!['staff', 'management'].includes(role) || !approvedMapping(record, role)) fail('Resource mapping is not approved for installation');
const prefix = `/${role}-resources/`;
const name = mapping.deliveryPath.slice(prefix.length);
if (!safeName(name) || mapping.deliveryPath !== `${prefix}${name}`) fail('Resource destination path is unsafe');
return { role, name };
}
export async function prepareResourcePlan(privateRoot, staging, sourceCommit) {
if (!fullSha.test(sourceCommit)) fail('Full source Git SHA required');
const manifestPath = path.join(privateRoot, 'manifest.json');
const manifestStat = await fs.lstat(manifestPath).catch(() => null);
if (!manifestStat?.isFile() || manifestStat.isSymbolicLink() || manifestStat.size > 4_000_000) fail('Private employee resource manifest is unavailable or unsafe');
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
if (!Array.isArray(manifest.resources)) fail('Private resource manifest has no resource list');
const seen = new Set(), files = [];
for (const record of manifest.resources) {
for (const mapping of mappingsFor(record)) {
if (!['staff', 'management'].includes(mapping.role)) continue;
const { role, name } = roleAndName(record, mapping);
if (!safeName(record.localFile) || !/^[0-9a-f]{64}$/.test(record.sha256 ?? '') || !Number.isInteger(record.size) || record.size <= 0 || record.size > 50 * 1024 * 1024) fail('Approved resource lacks bounded verified local metadata');
if (seen.has(`${role}/${name}`)) fail('Two resources map to the same protected destination');
seen.add(`${role}/${name}`);
const source = path.join(privateRoot, 'hubfs', record.localFile);
const stat = await fs.lstat(source).catch(() => null);
if (!stat?.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) fail(`Approved resource is missing or unsafe: ${role}/${name}`);
const bytes = await fs.readFile(source);
if (bytes.length !== record.size || hash(bytes) !== record.sha256) fail(`Approved resource hash mismatch: ${role}/${name}`);
const destination = path.join(staging, role);
await fs.mkdir(destination, { recursive: true, mode: 0o700 });
await fs.copyFile(source, path.join(destination, record.localFile), fs.constants.COPYFILE_EXCL);
files.push({ role, name, stageName: record.localFile, sha256: record.sha256, size: record.size });
}
}
if (!files.length) fail('No deployment-approved internal resources are present; reconcile the private manifest first');
return { schema: 1, sourceCommit, files: files.sort((a, b) => `${a.role}/${a.name}`.localeCompare(`${b.role}/${b.name}`)) };
}
export function serializeResourcePlan(plan) {
if (plan?.schema !== 1 || !fullSha.test(plan.sourceCommit ?? '') || !Array.isArray(plan.files)) fail('Invalid resource installation plan');
const records = plan.files.map((entry) => {
if (!['staff', 'management'].includes(entry.role) || !safeName(entry.name) || !safeName(entry.stageName) || !/^[0-9a-f]{64}$/.test(entry.sha256 ?? '') || !Number.isInteger(entry.size) || entry.size <= 0) fail('Invalid resource installation plan entry');
return ['file', entry.role, entry.name, entry.stageName, entry.sha256, String(entry.size)].join('\t');
});
return `AIA_PORTAL_RESOURCE_PLAN_V1\nsourceCommit=${plan.sourceCommit}\n${records.join('\n')}\n`;
}
function parse(argv) {
const valid = new Set(['--dry-run','--confirm']);
if (!argv.length || argv.some((arg) => !valid.has(arg)) || (argv.includes('--dry-run') && argv.includes('--confirm'))) fail('Usage: npm run deploy:portal-resources -- --dry-run | npm run deploy:portal-resources -- --confirm');
return { dryRun: argv.includes('--dry-run'), confirm: argv.includes('--confirm') };
}
async function main() {
const options = parse(process.argv.slice(2));
const sha = run('git', ['-C', repositoryRoot, 'rev-parse', 'HEAD'], { quiet: true });
if (!fullSha.test(sha) || run('git', ['-C', repositoryRoot, 'status', '--porcelain=v1', '--untracked-files=all'], { quiet: true })) fail('Resource installation requires a clean committed worktree');
const privateRoot = process.env.AIA_PORTAL_PRIVATE_ROOT || path.join(toolRoot, 'private', 'employee-portal');
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-portal-resources-'));
try {
const staging = path.join(temporary, `portal-resources-${sha}`);
await Promise.all(['staff', 'management'].map((role) => fs.mkdir(path.join(staging, role), { recursive: true, mode: 0o700 })));
const plan = await prepareResourcePlan(privateRoot, staging, sha);
const planFile = path.join(temporary, `aia-portal-resources-${sha}.tsv`);
await fs.writeFile(planFile, serializeResourcePlan(plan), { mode: 0o600 });
if (options.dryRun) { console.log(JSON.stringify({ dryRun: true, sourceCommit: sha, resources: plan.files.map(({ role, name, sha256, size }) => ({ role, name, sha256, size })) }, null, 2)); return; }
if (!options.confirm) fail('Refusing employee-file transfer without --confirm');
const host = process.env.AIA_DEPLOY_HOST, identity = process.env.AIA_DEPLOY_SSH_IDENTITY;
const [ssh, ...sshOptions] = sshTarget(host, identity);
const remoteStage = `${root}/staging/portal-resources-${sha}`, remotePlan = `/root/aia-portal-resources-${sha}.tsv`;
const reviewedInstaller = runBytes('git', ['-C', repositoryRoot, 'show', `${sha}:www/tools/portal-resource-install.sh`]);
const localInstaller = await fs.readFile(path.join(toolRoot, 'tools', 'portal-resource-install.sh'));
if (!reviewedInstaller.equals(localInstaller)) fail('Local resource installer does not match the reviewed source commit');
const installerHash = hash(reviewedInstaller);
const installerFile = path.join(temporary, 'portal-resource-install.sh');
await fs.writeFile(installerFile, reviewedInstaller, { mode: 0o600 });
const remoteInstaller = `${remoteStage}/portal-resource-install.sh`;
const transport = `${ssh} ${sshOptions.map((item) => `'${item.replaceAll("'", "'\\''")}'`).join(' ')}`;
run(ssh, [...sshOptions, host, `install -d -m 0700 ${remoteStage}/staff ${remoteStage}/management`]);
for (const role of ['staff','management']) {
const args = rsyncArguments(path.join(staging, role), `${host}:${remoteStage}/${role}`, { remove: false });
args.splice(-2, 0, '-e', transport);
run('rsync', args);
}
run('rsync', ['-rlt', '--chown=root:root', '--chmod=Fu=rw,Fgo=', '-e', transport, planFile, `${host}:${remotePlan}`]);
run('rsync', ['-rlt', '--chown=root:root', '--chmod=Fu=rw,Fgo=', '-e', transport, installerFile, `${host}:${remoteInstaller}`]);
run(ssh, [...sshOptions, host, `test "$(sha256sum ${remoteInstaller} | awk '{print $1}')" = ${installerHash}`]);
run(ssh, [...sshOptions, host, `env -i PATH=/run/current-system/sw/bin:/usr/bin:/bin HOME=/root /bin/sh ${remoteInstaller} ${remoteStage} ${remotePlan}`]);
console.log(JSON.stringify({ installed: true, sourceCommit: sha, resources: plan.files.length }, null, 2));
} finally { await fs.rm(temporary, { recursive: true, force: true }); }
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch((error) => { console.error(`Employee resource installation stopped: ${error.message}`); process.exitCode = 1; });