feat(deploy): support positional release inputs
This commit is contained in:
3 files changed
+150
-25
No files matched your search
+68
-19
@@ -83,9 +83,24 @@ export async function releaseManifest(dist, sourceCommit) {
|
||||
export function rsyncArguments(source, destination, { remove = false } = {}) {
|
||||
return ['-rlt', ...(remove ? ['--delete'] : []), '--links', '--chown=root:root', '--chmod=Du=rwx,Dgo=,Fu=rw,Fgo=', source.endsWith('/') ? source : `${source}/`, destination];
|
||||
}
|
||||
export function sshTarget(host, identity) {
|
||||
if (!/^[A-Za-z0-9_.:@\-\[\]]+$/.test(host ?? '')) fail('AIA_DEPLOY_HOST is invalid');
|
||||
if (!identity || !path.isAbsolute(identity)) fail('AIA_DEPLOY_SSH_IDENTITY must be an absolute path');
|
||||
function validHost(host) {
|
||||
return typeof host === 'string' && /^[A-Za-z0-9_.:@\-\[\]]+$/.test(host);
|
||||
}
|
||||
function usage() {
|
||||
return [
|
||||
'Usage:',
|
||||
' npm run deploy -- --dry-run [SSH_HOST CIPHERTEXT | SSH_HOST SSH_IDENTITY CIPHERTEXT]',
|
||||
' npm run deploy -- --confirm [SSH_HOST CIPHERTEXT | SSH_HOST SSH_IDENTITY CIPHERTEXT]',
|
||||
'With no positional arguments, use AIA_DEPLOY_HOST, AIA_DEPLOY_SSH_IDENTITY and AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE.',
|
||||
].join('\n');
|
||||
}
|
||||
export function sshTarget(host, identity, { allowSshConfig = false } = {}) {
|
||||
if (!validHost(host)) fail('Deployment SSH host is invalid');
|
||||
if (identity === undefined) {
|
||||
if (!allowSshConfig) fail('AIA_DEPLOY_SSH_IDENTITY must be an absolute path');
|
||||
return ['ssh'];
|
||||
}
|
||||
if (!identity || !path.isAbsolute(identity)) fail('Deployment SSH identity must be an absolute path');
|
||||
return ['ssh', '-i', identity, '-o', 'IdentitiesOnly=yes'];
|
||||
}
|
||||
function git(args, options) { return run('git', ['-C', repositoryRoot, ...args], options); }
|
||||
@@ -111,6 +126,12 @@ async function syntheticPayload(directory) {
|
||||
await fs.writeFile(file, JSON.stringify(payload), { mode: 0o600 });
|
||||
return { file, payload, synthetic: true };
|
||||
}
|
||||
export async function portalInput(ciphertext, directory, env = process.env) {
|
||||
if (!ciphertext) return syntheticPayload(directory);
|
||||
const privateRoot = path.resolve(env.AIA_PORTAL_PRIVATE_ROOT || path.dirname(ciphertext));
|
||||
const readiness = await assertProductionReadiness(privateRoot, ciphertext);
|
||||
return { file: ciphertext, payload: readiness.payload, synthetic: false };
|
||||
}
|
||||
async function createSourceSnapshot(directory, sourceCommit) {
|
||||
const tracked = git(['ls-tree', '-r', '--name-only', sourceCommit], { quiet: true }).split('\n').filter(Boolean);
|
||||
const included = [], excluded = [];
|
||||
@@ -149,19 +170,51 @@ async function executeBuild(buildEnv) {
|
||||
run('npm', ['run', 'audit:blog'], { env: buildEnv });
|
||||
run('npm', ['run', 'build'], { env: buildEnv });
|
||||
}
|
||||
function parseArgs(argv) {
|
||||
export function parseArgs(argv) {
|
||||
const allowed = new Set(['--dry-run', '--confirm']);
|
||||
if (argv.some((arg) => !allowed.has(arg))) fail('Usage: npm run deploy -- --dry-run | npm run deploy -- --confirm');
|
||||
if (argv.includes('--dry-run') && argv.includes('--confirm')) fail('Choose --dry-run or --confirm, not both');
|
||||
return { dryRun: argv.includes('--dry-run'), confirm: argv.includes('--confirm') };
|
||||
const flags = argv.filter((arg) => arg.startsWith('--'));
|
||||
const positional = argv.filter((arg) => !arg.startsWith('--'));
|
||||
if (flags.some((arg) => !allowed.has(arg)) || new Set(flags).size !== flags.length) fail(usage());
|
||||
if ((flags.includes('--dry-run') && flags.includes('--confirm')) || (!flags.includes('--dry-run') && !flags.includes('--confirm'))) fail(`${usage()}\nChoose exactly one of --dry-run or --confirm.`);
|
||||
if (![0, 2, 3].includes(positional.length) || positional.some((arg) => !arg)) fail(`${usage()}\nProvide either zero, two, or three positional arguments.`);
|
||||
return { dryRun: flags.includes('--dry-run'), confirm: flags.includes('--confirm'), positional };
|
||||
}
|
||||
async function remoteDeploy({ host, identity, sha, source, dist, manifest }) {
|
||||
const [ssh, ...sshOptions] = sshTarget(host, identity);
|
||||
export function deploymentInputs(options, env = process.env) {
|
||||
const { positional } = options;
|
||||
if (!positional.length) {
|
||||
return {
|
||||
host: env.AIA_DEPLOY_HOST,
|
||||
identity: env.AIA_DEPLOY_SSH_IDENTITY || undefined,
|
||||
ciphertext: env.AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE,
|
||||
allowSshConfig: false,
|
||||
source: 'environment',
|
||||
};
|
||||
}
|
||||
const [host, maybeIdentity, maybeCiphertext] = positional;
|
||||
const identity = positional.length === 3 ? maybeIdentity : undefined;
|
||||
const ciphertext = positional.length === 3 ? maybeCiphertext : maybeIdentity;
|
||||
if (!validHost(host)) fail('Positional SSH host is invalid');
|
||||
if (identity !== undefined && !path.isAbsolute(identity)) fail('Positional SSH identity must be an absolute path');
|
||||
if (!path.isAbsolute(ciphertext)) fail('Positional portal ciphertext path must be absolute');
|
||||
return { host, identity, ciphertext, allowSshConfig: positional.length === 2, source: 'positional' };
|
||||
}
|
||||
function assertRemoteInputs(inputs) {
|
||||
if (!validHost(inputs.host)) fail(inputs.source === 'environment' ? 'AIA_DEPLOY_HOST is required and must be valid for --confirm' : 'Positional SSH host is invalid');
|
||||
if (!inputs.allowSshConfig && (!inputs.identity || !path.isAbsolute(inputs.identity))) fail('AIA_DEPLOY_SSH_IDENTITY is required and must be an absolute path for environment-based deployment');
|
||||
}
|
||||
function shellQuote(value) {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
export function rsyncTransport(sshArguments) {
|
||||
return sshArguments.map(shellQuote).join(' ');
|
||||
}
|
||||
async function remoteDeploy({ host, identity, allowSshConfig, sha, source, dist, manifest }) {
|
||||
const [ssh, ...sshOptions] = sshTarget(host, identity, { allowSshConfig });
|
||||
const sourceStage = `${projectRoot}/staging/source-${sha}`;
|
||||
const distStage = `${projectRoot}/staging/dist-${sha}`;
|
||||
const remoteManifest = `/root/aia-site-${sha}.manifest.json`;
|
||||
run(ssh, [...sshOptions, host, `install -d -m 0700 ${sourceStage} ${distStage}`]);
|
||||
const transport = `${ssh} ${sshOptions.map((item) => `'${item.replaceAll("'", "'\\''")}'`).join(' ')}`;
|
||||
const transport = rsyncTransport([ssh, ...sshOptions]);
|
||||
const transfer = (local, remote, remove) => {
|
||||
const args = rsyncArguments(local, remote, { remove });
|
||||
args.splice(-2, 0, '-e', transport);
|
||||
@@ -175,18 +228,14 @@ async function remoteDeploy({ host, identity, sha, source, dist, manifest }) {
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const inputs = deploymentInputs(options);
|
||||
const sha = assertPreflight({ requirePrimary: !options.dryRun });
|
||||
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-deploy-'));
|
||||
try {
|
||||
const configured = process.env.AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE;
|
||||
let portal;
|
||||
if (configured) {
|
||||
const privateRoot = path.resolve(process.env.AIA_PORTAL_PRIVATE_ROOT || path.dirname(configured));
|
||||
const readiness = await assertProductionReadiness(privateRoot, configured);
|
||||
portal = { file: configured, payload: readiness.payload, synthetic: false };
|
||||
} else portal = await syntheticPayload(temporary);
|
||||
if (!options.dryRun && portal.synthetic) fail('A real deployment requires AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE');
|
||||
const portal = await portalInput(inputs.ciphertext, temporary);
|
||||
if (!options.dryRun && portal.synthetic) fail('A real deployment requires AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE or a positional ciphertext path');
|
||||
if (!options.dryRun && !options.confirm) fail('Refusing to upload without --confirm; use --dry-run for local verification');
|
||||
if (!options.dryRun) assertRemoteInputs(inputs);
|
||||
const env = { ...process.env, PUBLIC_ALLOW_INDEXING: 'true', PUBLIC_AIA_FORMS_MODE: 'live', PUBLIC_COUNTERS_MODE: 'live', PUBLIC_AIA_API_BASE: 'https://api.azinstitute4autism.com', PUBLIC_COUNTERS_LIVE_APPROVED: 'true', AIA_PORTAL_PRODUCTION_APPROVED: 'true', AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE: portal.file, AIA_PORTAL_LOCAL_PREVIEW_APPROVED: 'false' };
|
||||
await executeBuild(env);
|
||||
const portalSha256 = sha256(Buffer.from(JSON.stringify(portal.payload)));
|
||||
@@ -198,7 +247,7 @@ async function main() {
|
||||
const snapshot = await createSourceSnapshot(temporary, sha);
|
||||
const summary = { sourceCommit: sha, treeSha256: manifest.treeSha256, files: Object.keys(manifest.files).length, directories: manifest.directories.length, sourceFiles: snapshot.included, intentionallyExcludedSourceFiles: snapshot.excluded, portal: portal.synthetic ? 'synthetic-dry-run-only' : 'approved-production-ciphertext', destination: projectRoot };
|
||||
if (options.dryRun) { console.log(JSON.stringify({ dryRun: true, ...summary }, null, 2)); return; }
|
||||
await remoteDeploy({ host: process.env.AIA_DEPLOY_HOST, identity: process.env.AIA_DEPLOY_SSH_IDENTITY, sha, source: snapshot.snapshot, dist: path.join(toolRoot, 'dist'), manifest: manifestFile });
|
||||
await remoteDeploy({ ...inputs, sha, source: snapshot.snapshot, dist: path.join(toolRoot, 'dist'), manifest: manifestFile });
|
||||
console.log(JSON.stringify({ promoted: true, ...summary }, null, 2));
|
||||
} finally { await fs.rm(temporary, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import test from 'node:test';
|
||||
import { releaseManifest, rsyncArguments, sshTarget } from './deploy.mjs';
|
||||
import { deploymentInputs, parseArgs, portalInput, releaseManifest, rsyncArguments, rsyncTransport, sshTarget } from './deploy.mjs';
|
||||
|
||||
const fixturePath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures', 'contabo-release-manifest-v1.json');
|
||||
const fixture = JSON.parse(await fs.readFile(fixturePath, 'utf8'));
|
||||
@@ -42,3 +42,48 @@ test('deployment transfer uses the Contabo ownership and mode interface', () =>
|
||||
assert.deepEqual(sshTarget('root@host.example', '/absolute/key').slice(0, 3), ['ssh','-i','/absolute/key']);
|
||||
assert.throws(() => sshTarget('host;rm -rf /', '/absolute/key'), /invalid/);
|
||||
});
|
||||
|
||||
test('deployment accepts a positional OpenSSH alias and ciphertext ahead of environment settings', () => {
|
||||
const options = parseArgs(['--dry-run', 'aiar', '/private files/portal ciphertext.json']);
|
||||
const inputs = deploymentInputs(options, {
|
||||
AIA_DEPLOY_HOST: 'wrong.example',
|
||||
AIA_DEPLOY_SSH_IDENTITY: '/wrong/key',
|
||||
AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE: '/wrong/payload.json',
|
||||
});
|
||||
assert.deepEqual(inputs, {
|
||||
host: 'aiar', identity: undefined, ciphertext: '/private files/portal ciphertext.json', allowSshConfig: true, source: 'positional',
|
||||
});
|
||||
assert.deepEqual(sshTarget(inputs.host, inputs.identity, { allowSshConfig: inputs.allowSshConfig }), ['ssh']);
|
||||
assert.equal(rsyncTransport(['ssh']), "'ssh'");
|
||||
});
|
||||
|
||||
test('deployment accepts positional explicit identities without shell-splitting paths', () => {
|
||||
const options = parseArgs(['--confirm', 'root@66.94.121.180', '/keys/AIA Marketing', '/private files/portal ciphertext.json']);
|
||||
const inputs = deploymentInputs(options, {});
|
||||
assert.equal(inputs.allowSshConfig, false);
|
||||
assert.equal(inputs.identity, '/keys/AIA Marketing');
|
||||
assert.deepEqual(sshTarget(inputs.host, inputs.identity), ['ssh', '-i', '/keys/AIA Marketing', '-o', 'IdentitiesOnly=yes']);
|
||||
assert.equal(rsyncTransport(sshTarget(inputs.host, inputs.identity)), "'ssh' '-i' '/keys/AIA Marketing' '-o' 'IdentitiesOnly=yes'");
|
||||
});
|
||||
|
||||
test('deployment retains environment-only compatibility and rejects malformed positional input', () => {
|
||||
const env = {
|
||||
AIA_DEPLOY_HOST: 'root@host.example',
|
||||
AIA_DEPLOY_SSH_IDENTITY: '/absolute/key',
|
||||
AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE: '/private/payload.json',
|
||||
};
|
||||
assert.deepEqual(deploymentInputs(parseArgs(['--confirm']), env), {
|
||||
host: env.AIA_DEPLOY_HOST, identity: env.AIA_DEPLOY_SSH_IDENTITY, ciphertext: env.AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE, allowSshConfig: false, source: 'environment',
|
||||
});
|
||||
assert.throws(() => parseArgs(['--dry-run', 'aiar']), /zero, two, or three positional/);
|
||||
assert.throws(() => parseArgs(['--dry-run', '--confirm']), /Choose exactly one/);
|
||||
assert.throws(() => deploymentInputs(parseArgs(['--dry-run', 'aiar', 'relative.json']), {}), /must be absolute/);
|
||||
assert.throws(() => deploymentInputs(parseArgs(['--dry-run', 'aiar', 'relative-key', '/private/payload.json']), {}), /must be an absolute path/);
|
||||
});
|
||||
|
||||
test('a supplied ciphertext path must exist and be safe before a deployment build starts', async () => {
|
||||
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-deploy-missing-ciphertext-'));
|
||||
try {
|
||||
await assert.rejects(portalInput(path.join(temporary, 'missing.json'), temporary), /Portal ciphertext is unavailable or unsafe/);
|
||||
} finally { await fs.rm(temporary, { recursive: true, force: true }); }
|
||||
});
|
||||
Reference in new issue
Block a user