diff --git a/www/integrations/nixos/deploy.md b/www/integrations/nixos/deploy.md index 46a136d..dcf16f3 100644 --- a/www/integrations/nixos/deploy.md +++ b/www/integrations/nixos/deploy.md @@ -7,11 +7,14 @@ It does not run `nixos-rebuild`, reload nginx, change DNS, or write directly to ## Required local tools and private inputs - Node.js 22+, Git, tar, SSH and rsync. -- A normal SSH identity accepted by the existing root receiver: - `AIA_DEPLOY_SSH_IDENTITY=/absolute/path/to/identity`. -- `AIA_DEPLOY_HOST=administrator@host` (or the approved equivalent). -- For a real public release, an owner-controlled, regular encrypted payload: +- Either an OpenSSH host alias already configured locally, or a normal SSH + identity accepted by the existing root receiver. +- For environment-only invocations: `AIA_DEPLOY_HOST=administrator@host`, + `AIA_DEPLOY_SSH_IDENTITY=/absolute/path/to/identity`, and `AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE=/absolute/private/employee-portal/approved-production-ciphertext.json`. +- For positional invocations, use either `SSH_HOST CIPHERTEXT` (OpenSSH resolves + the host alias and identity) or `SSH_HOST SSH_IDENTITY CIPHERTEXT` (the CLI + supplies `-i` and `IdentitiesOnly=yes`). The payload remains outside Git and the source snapshot. It is browser ciphertext only; neither access code nor decrypted portal HTML enters an Astro environment @@ -23,6 +26,15 @@ From `www/`: ```sh npm run deploy -- --dry-run + +# Use a configured OpenSSH host alias and a private ciphertext path. +npm run deploy -- --dry-run aiar \ + "$PWD/private/employee-portal/approved-production-ciphertext.json" + +# Supply an explicit SSH identity; paths may contain spaces. +npm run deploy -- --dry-run 'root@66.94.121.180' \ + "$HOME/.ssh/AIAMarketing" \ + "$PWD/private/employee-portal/approved-production-ciphertext.json" ``` Dry-run performs the local production build, tests, artifact verification, @@ -37,8 +49,27 @@ AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE="$PWD/private/employee-portal/approved-pro npm run deploy -- --confirm ``` +The equivalent confirmed positional commands are: + +```sh +npm run deploy -- --confirm aiar \ + "$PWD/private/employee-portal/approved-production-ciphertext.json" + +npm run deploy -- --confirm 'root@66.94.121.180' \ + "$HOME/.ssh/AIAMarketing" \ + "$PWD/private/employee-portal/approved-production-ciphertext.json" +``` + +When positional values are present, they take precedence over all three +deployment environment variables. Two positional values deliberately omit +`-i`, allowing the local OpenSSH configuration to select the identity. Dry-run +never opens SSH or rsync, even when host and identity arguments are provided. +Invalid modes, mixed confirmation flags, incomplete positional sets, unsafe +hosts, relative positional paths, and missing ciphertext files fail before a +release can be promoted. + The confirmed command builds with `PUBLIC_ALLOW_INDEXING=true`, Form A live and -counters off, then uploads only fresh commit-specific `staging/source-` and +the approved live counters configuration, then uploads only fresh commit-specific `staging/source-` and `staging/dist-` directories. It runs, in order: ```text diff --git a/www/tools/deploy.mjs b/www/tools/deploy.mjs index 5c49ae0..1ba84fd 100644 --- a/www/tools/deploy.mjs +++ b/www/tools/deploy.mjs @@ -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 }); } } diff --git a/www/tools/deploy.test.mjs b/www/tools/deploy.test.mjs index 21bd76e..0505b5f 100644 --- a/www/tools/deploy.test.mjs +++ b/www/tools/deploy.test.mjs @@ -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 }); } +});