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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
135 lines · 4.8 KB Raw History
#!/usr/bin/env node
/**
 * The daemon's command line. Resolves the repositories to watch, signs the
 * runner in, and hands the watch loop a shell executor.
 */

import { readFile } from 'node:fs/promises';
import { resolveRepoLocation, XrpcClient } from '@pdsjs/git';
import { watch } from './daemon.js';
import { createShellExecutor } from './executor.js';
import { createPublisher, createUnfinishedRefsReader } from './publish.js';
import { readRepoRecord } from './request.js';
import { createRunner } from './runner.js';
import { grantedSecrets, parseSecretsFile } from './secrets.js';
import { parseSpec, repoKey } from './spec.js';
import { createStateStore, statePath } from './state.js';

const USAGE = `pdsjs-git-ci watch <handle-or-did>/<repo> [...]

Runs .pdsjs/ci.json for every push to the named repositories and publishes
the result as a dev.pdsjs.git.check record.

Environment:
  PDSJS_CI_IDENTIFIER    Runner account handle or DID (required)
  PDSJS_CI_PASSWORD      App password for that account (required)
  PDSJS_CI_REPOS         Repositories to watch, when none are given as
                         arguments. Separated by spaces or commas.
  PDSJS_CI_SECRETS       Per-repository secrets file path
  PDSJS_CI_SERVICE       Runner's PDS base URL, skips identity resolution
  PDSJS_CI_STATE         State file path
  PDSJS_CI_STEP_TIMEOUT  Per-step timeout in milliseconds
`;

/**
 * Load the secrets file and key its grants by the record key of the
 * repository each names. The file is written by a person, so it names a
 * repository the way a watch spec does, by handle and name; a run names one
 * by its record key, which no rename moves.
 *
 * A grant naming a repository the account does not hold is reported here.
 * The alternative is a build that quietly runs without its secrets.
 * @param {string} path
 * @param {NodeJS.ProcessEnv} env
 * @param {(message: string) => void} onNotice
 * @returns {Promise<import('./secrets.js').SecretsResolver>}
 */
async function loadSecrets(path, env, onNotice) {
  const grants = parseSecretsFile(await readFile(path, 'utf8'));
  /** @type {Map<string, Record<string, string>>} */
  const byKey = new Map();
  for (const [spec, values] of Object.entries(grants)) {
    const { authority, repoName } = parseSpec(spec);
    const { did, service } = await resolveRepoLocation(authority, env);
    try {
      const record = await readRepoRecord({ service, did, repoName });
      byKey.set(repoKey(did, record.rkey), values);
    } catch {
      onNotice(`secrets: ${spec} names no repository this account holds`);
    }
  }
  return (did, repoRkey, names) =>
    grantedSecrets(byKey.get(repoKey(did, repoRkey)), names);
}

async function main() {
  const env = process.env;
  const [command, ...args] = process.argv.slice(2);
  // A container image names no repositories in its command, so the
  // environment carries them instead.
  const specs =
    args.length > 0
      ? args
      : (env.PDSJS_CI_REPOS ?? '').split(/[\s,]+/).filter(Boolean);
  if (command !== 'watch' || specs.length === 0) {
    process.stderr.write(USAGE);
    process.exit(command === '--help' ? 0 : 129);
  }

  const identifier = env.PDSJS_CI_IDENTIFIER;
  const password = env.PDSJS_CI_PASSWORD;
  if (!identifier || !password) {
    throw new Error(
      'PDSJS_CI_IDENTIFIER and PDSJS_CI_PASSWORD must both be set',
    );
  }

  const repos = await Promise.all(
    specs.map(async (spec) => {
      const { authority, repoName } = parseSpec(spec);
      const { did, service } = await resolveRepoLocation(authority, env);
      return { did, repoName, service };
    }),
  );

  const runnerService =
    env.PDSJS_CI_SERVICE ?? (await resolveRepoLocation(identifier, {})).service;
  const client = new XrpcClient(runnerService);
  const { did } = await client.createSession(identifier, password);

  const onNotice = (/** @type {string} */ message) => {
    process.stdout.write(`${new Date().toISOString()} ${message}\n`);
  };
  const timeout = env.PDSJS_CI_STEP_TIMEOUT;
  const runCheck = createRunner({
    executor: createShellExecutor(
      timeout ? { timeoutMs: Number(timeout) } : {},
    ),
    publisher: createPublisher({ client, did }),
    secrets: env.PDSJS_CI_SECRETS
      ? await loadSecrets(env.PDSJS_CI_SECRETS, env, onNotice)
      : undefined,
    onNotice,
  });

  const controller = new AbortController();
  process.on('SIGINT', () => controller.abort());
  process.on('SIGTERM', () => controller.abort());

  onNotice(`publishing checks as ${identifier} (${did})`);
  await watch({
    repos,
    runCheck,
    unfinishedRefs: createUnfinishedRefsReader({ client, did }),
    state: createStateStore(statePath(env), onNotice),
    signal: controller.signal,
    onNotice,
  });
}

main().catch((err) => {
  process.stderr.write(
    `fatal: ${err instanceof Error ? err.message : String(err)}\n`,
  );
  process.exit(1);
});