~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
127 lines · 3.8 KB Raw History
/**
 * 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;
  };
}