chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
/**
* How a run's steps get executed. The daemon holds an `Executor` and knows
* nothing else about it, so an isolate or container backend replaces the
* shell one without touching the trigger or the publishing path.
*/
import { spawn } from 'node:child_process';
/** A step this long is assumed hung. */
const DEFAULT_STEP_TIMEOUT_MS = 30 * 60 * 1000;
/**
* The runner's own credentials. A step that reads the app password can
* publish checks as the runner, for every repository it watches. These
* names never reach a step's environment. The secrets file path goes with
* them: a step receives its granted secrets by value, never the file.
*/
const RUNNER_ONLY_VARS = [
'PDSJS_CI_IDENTIFIER',
'PDSJS_CI_PASSWORD',
'PDSJS_CI_SECRETS',
];
/**
* The environment one step runs with: the daemon's, minus the runner's
* credentials, plus what the request adds.
* @param {Record<string, string>} extra
* @returns {Record<string, string|undefined>}
*/
function stepEnv(extra) {
const base = { ...process.env };
for (const name of RUNNER_ONLY_VARS) delete base[name];
return { ...base, ...extra };
}
/**
* @typedef {Object} ExecutionRequest
* @property {string} dir - working tree the steps run against
* @property {import('./workflow.js').WorkflowStep[]} steps
* @property {Record<string, string>} env
* @property {(chunk: string) => void} onLog
* @property {AbortSignal} [signal]
*/
/**
* @typedef {Object} StepResult
* @property {string} name
* @property {number} exitCode - negative when a signal ended the step
* @property {number} durationMs
*/
/**
* Runs the steps in order and stops at the first failure. The results cover
* the steps that ran, so a short array means an earlier step failed.
* @typedef {(request: ExecutionRequest) => Promise<StepResult[]>} Executor
*/
/**
* @param {import('./workflow.js').WorkflowStep} step
* @param {ExecutionRequest} request
* @param {number} timeoutMs
* @returns {Promise<number>} exit code
*/
function runStep(step, request, timeoutMs) {
return new Promise((resolve, reject) => {
const child = spawn(step.run, {
cwd: request.dir,
env: stepEnv(request.env),
shell: true,
stdio: ['ignore', 'pipe', 'pipe'],
});
const timer = setTimeout(() => {
request.onLog(`\n[timed out after ${timeoutMs}ms]\n`);
child.kill('SIGKILL');
}, timeoutMs);
const abort = () => child.kill('SIGKILL');
request.signal?.addEventListener('abort', abort, { once: true });
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', request.onLog);
child.stderr.on('data', request.onLog);
const settle = () => {
clearTimeout(timer);
request.signal?.removeEventListener('abort', abort);
};
child.on('error', (err) => {
settle();
reject(err);
});
// A killed step reports its signal, not a code; report it as non-zero so
// a timeout reads as a failure rather than a pass.
child.on('close', (code, signal) => {
settle();
resolve(code ?? (signal ? -1 : 1));
});
});
}
/**
* @param {{timeoutMs?: number}} [options]
* @returns {Executor}
*/
export function createShellExecutor(options = {}) {
const timeoutMs = options.timeoutMs ?? DEFAULT_STEP_TIMEOUT_MS;
return async function shellExecutor(request) {
/** @type {StepResult[]} */
const results = [];
for (const step of request.steps) {
request.onLog(`\n=== ${step.name} ===\n$ ${step.run}\n`);
const startedAt = Date.now();
const exitCode = await runStep(step, request, timeoutMs);
results.push({
name: step.name,
exitCode,
durationMs: Date.now() - startedAt,
});
if (exitCode !== 0) {
request.onLog(`\n[${step.name} exited ${exitCode}]\n`);
break;
}
}
return results;
};
}