conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

commit bd658275e2b0dd996745b9b63483127d5cbbac12
parent 5d11ffb12e6835d1516157fbe2e0c39446c4717d
Author: finwo <finwo@pm.me>
Date:   Sat, 19 Sep 2026 00:59:25 +0200

Conductor service: triggers, scheduling and worker API

Diffstat:
AREADME.md | 180+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/pipeline.md | 292+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexamples/distro.conductor.yml | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexamples/node-app.conductor.yml | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahooks/post-receive | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/admin-cli.js | 213+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/conductor/app.js | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/conductor/index.js | 39+++++++++++++++++++++++++++++++++++++++
Asrc/conductor/routes/runs.js | 164+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/conductor/routes/trigger.js | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/conductor/routes/workers.js | 251+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/conductor/scheduler.js | 418+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib/config.js | 12+++++++-----
Asrc/lib/db/migrate-cli.js | 25+++++++++++++++++++++++++
Msrc/lib/db/query.js | 20++++++++++++++++++++
Asrc/lib/git.js | 212+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/log.js | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib/pipeline/schema.js | 12++++++++----
Asrc/lib/projects.js | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/stream.js | 27+++++++++++++++++++++++++++
Asrc/lib/workers.js | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/conductor.test.js | 652+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/examples.test.js | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/helpers/harness.js | 164+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/pipeline.test.js | 22++++++++++++++++++++++
25 files changed, 3551 insertions(+), 9 deletions(-)

diff --git a/README.md b/README.md @@ -0,0 +1,180 @@ +conductor +========= + +A stand-alone CI system. Pipelines live in the repository, jobs run in +containers, and workers pull work rather than being pushed to. + +Summary +------- + +The conductor accepts a push notification, reads `.conductor.yml` at the +pushed commit, and turns it into a graph of jobs. Workers poll for work, +advertising the architectures and features they can offer. Any job whose +dependencies have succeeded is eligible, so a wide graph runs wide. + +Workers need no repository credentials: the conductor serves the source tree +for the exact commit a job was handed. A worker on someone else's hardware +can therefore build for you without being able to read anything else. + +Nothing has to be set up before it will run. With no configuration it uses +sqlite on disk, stores artifacts on the local filesystem, and manages its own +user accounts. Point it at a database url, an S3 bucket or an OIDC issuer and +it uses those instead. + +Status +------ + +Under construction. Working today: + + - configuration, with sqlite, mysql/tidb and postgres backends + - local and S3 compatible object storage + - pipeline parsing, matrix and architecture expansion, dependency graphs + - triggers, run scheduling, the worker API, live logs, artifacts, reaping + +Not yet built: the worker agent itself, user accounts and the admin API, the +read-api service, and the dashboard. Until the admin API exists, projects and +worker tokens are managed with `src/admin-cli.js`. + +Installation +------------ + +Requires node 24 or newer. No native modules. + +```sh +npm install +cp conductor.example.yaml conductor.yaml +npm start +``` + +`mysql2` and `pg` are optional and only needed when `database.url` points at +one of those servers. + +Configuration +------------- + +Every setting has a default, so an empty config file is valid. Values are +read from defaults, then `conductor.yaml`, then the environment. See +`conductor.example.yaml` for the annotated list. + +Three capabilities switch on when configured and fall back when not: + +| Setting | Configured | Not configured | +| --------------------- | -------------------- | --------------------- | +| `database.url` | mysql/tidb, postgres | sqlite at a file path | +| `storage.s3.bucket` | S3 compatible store | local filesystem | +| `auth.oidc.issuer` | OIDC | built-in accounts | + +Usage +----- + +Register a project and mint a worker token: + +```sh +node src/admin-cli.js project:add demo https://git.example.com/demo.git +node src/admin-cli.js token:add builder-1 +``` + +Both print a secret once. Put the trigger secret in the git hook and the +worker token in the worker configuration. + +Install `hooks/post-receive` into the bare repository: + +```sh +cp hooks/post-receive /srv/git/demo.git/hooks/ +chmod +x /srv/git/demo.git/hooks/post-receive +``` + +and set `CONDUCTOR_URL`, `CONDUCTOR_PROJECT` and `CONDUCTOR_SECRET` for it. +GitHub, Gitea and GitLab webhooks are accepted at the same endpoint, so a +forge can drive it instead. + +Pipelines +--------- + +```yaml +version: 1 + +defaults: + image: debian:bookworm-slim + timeout: 30m + +jobs: + test: + script: + - make test + + build: + arch: [x86_64, aarch64] + script: + - ./build.sh $ARCH + artifacts: + paths: [dist/**] + + package: + needs: [build] + arch: [x86_64, aarch64] + matrix: + pkg: [musl, busybox] + requires: [sign-key] + script: + - ./package.sh $MATRIX_PKG $ARCH + + publish: + needs: [package, test] + script: + - ./publish.sh +``` + +`arch` and `matrix` fan a job out into one job per combination. A dependency +that shares a dimension is matched on it, so the aarch64 package waits for +the aarch64 build rather than for every build, while `publish`, which has no +architecture of its own, waits for all of them. + +`requires` names features a worker must offer. A worker holding a signing key +advertises `sign-key`, and only jobs asking for it are sent there. The key +stays on the worker and is never known to the conductor. + +See `docs/pipeline.md` for the full reference. + +Architecture +------------ + +``` +git push -> post-receive -> conductor -> job graph in the database + | + worker polls + | + source tarball, container, log stream, artifacts +``` + + - `src/conductor` the write side: triggers, scheduling, the worker API + - `src/read-api` the public read side, deployable separately + - `src/worker` the agent that runs jobs + - `src/lib` configuration, database, storage, pipelines, git + +Queries are written once against all three databases using named `{key}` +markers, which each driver compiles to its own placeholder syntax. +Timestamps are epoch milliseconds everywhere. + +Testing +------- + +```sh +npm test +``` + +S3 coverage is skipped unless a server is available: + +```sh +docker run -d -p 9000:9000 -e MINIO_ROOT_USER=testkey \ + -e MINIO_ROOT_PASSWORD=testsecret123 quay.io/minio/minio server /data + +CONDUCTOR_TEST_S3_ENDPOINT=http://127.0.0.1:9000 \ +CONDUCTOR_TEST_S3_KEY=testkey \ +CONDUCTOR_TEST_S3_SECRET=testsecret123 npm test +``` + +LICENSE +------- + +See [LICENSE.md](LICENSE.md). diff --git a/docs/pipeline.md b/docs/pipeline.md @@ -0,0 +1,292 @@ +.conductor.yml reference +======================== + +The pipeline file is read from the repository at the commit being built, so +it is versioned with the code it builds. The conductor never executes +anything from the repository while parsing it; the file is data. + +Every unknown key is an error rather than a warning, and all problems in a +file are reported together. + +Top level +--------- + +| Key | Required | Description | +| ---------- | -------- | ------------------------------------ | +| `version` | yes | Must be `1`. | +| `defaults` | no | Values inherited by every job. | +| `jobs` | yes | Mapping of job name to job. | + +Job names may contain letters, digits, underscore, dot and hyphen, and must +start with a letter or digit. + +```yaml +version: 1 +defaults: + image: debian:bookworm-slim +jobs: + test: + script: [make test] +``` + +Jobs +---- + +| Key | Type | Default | Inheritable | +| --------------- | ----------------- | -------------- | ----------- | +| `image` | string | none, required | yes | +| `script` | list of strings | none, required | no | +| `needs` | list | `[]` | no | +| `arch` | string or list | none | yes | +| `matrix` | mapping | none | no | +| `requires` | list of strings | `[]` | yes | +| `services` | list | `[]` | yes | +| `env` | mapping | `{}` | merged | +| `artifacts` | list or mapping | none | no | +| `cache` | list or mapping | none | yes | +| `allow_failure` | boolean | `false` | yes | +| `timeout` | duration | server default | yes | +| `max_attempts` | integer, 1 to 10 | `1` | yes | + +`image` and `script` are the only required fields, and `image` may come from +`defaults`. + +### script + +A list of shell commands, run in order in the job's container. The job fails +on the first command that exits non-zero. + +Take care with YAML scalars. Unquoted `true`, `false`, `yes`, `no` and `on` +parse as booleans, not strings, and are rejected: + +```yaml +script: ['true'] # correct +script: [true] # rejected, this is a boolean +``` + +### timeout + +Accepts whole seconds, or a duration with units `s`, `m`, `h`, `d` and `w`, +optionally combined: `90`, `90s`, `30m`, `1h`, `1h30m`. A job timeout may not +exceed seven days; `artifacts.expire` may not exceed a year. + +Fanning out +----------- + +### arch + +Expands the job into one job per architecture, and restricts each to a worker +that offers it. The value is available to the script as `$ARCH`. + +```yaml +build: + arch: [x86_64, aarch64] + script: ['./build.sh $ARCH'] +``` + +produces `build:arch=x86_64` and `build:arch=aarch64`. + +### matrix + +Expands over the cartesian product of its dimensions. Each value is available +as `$MATRIX_<NAME>` in upper case. Dimension order follows the file, so +generated names are predictable. + +```yaml +package: + matrix: + pkg: [musl, busybox] + mode: [debug, release] +``` + +produces four jobs, named `package:pkg=musl,mode=debug` and so on. + +Matrix values become part of the job name, so they are limited to letters, +digits, underscore, dot and hyphen. Use `arch` rather than a matrix dimension +called `arch`. + +Dependencies +------------ + +`needs` lists jobs that must succeed first. A cycle is rejected, naming the +cycle. + +When a dependency shares a dimension with the dependent, it is matched on +that dimension rather than fanned out across it: + +```yaml +build: + arch: [x86_64, aarch64] + script: ['./build.sh $ARCH'] + +package: + needs: [build] + arch: [x86_64, aarch64] + script: ['./package.sh $ARCH'] + +publish: + needs: [package] + script: ['./publish.sh'] +``` + +`package:arch=x86_64` waits only for `build:arch=x86_64`. `publish` declares +no architecture, so it waits for every `package`. + +Three forms are accepted: + +```yaml +needs: [build] # match shared dimensions +needs: ['build:arch=x86_64'] # one specific job +needs: + - job: build + match: all # every instance, ignore dimensions +``` + +If a shared dimension has no counterpart, that is an error rather than a +silently dropped dependency. + +A dependency that fails normally causes its dependents, and everything +reachable from them, to be skipped. A dependency with `allow_failure: true` +satisfies its dependents whether it passes or not. + +Worker features +--------------- + +`requires` names capabilities the worker must advertise. The conductor only +offers a job to a worker that advertises all of them. + +```yaml +package: + requires: [sign-key] + script: ['./sign.sh'] +``` + +What a feature provides is defined on the worker, not here: a mount, some +environment, a privileged container. This is how a signing key reaches a +build without the conductor ever holding it, and how a job asks for docker in +docker. + +Services +-------- + +Sidecar containers started alongside the job on the same network, reachable +by their alias. + +```yaml +test: + image: node:22 + services: + - postgres:16 + - image: docker:dind + alias: docker + env: + DOCKER_TLS_CERTDIR: '' + script: [npm test] +``` + +The alias defaults to the image name without registry, path or tag, so +`postgres:16` is reachable as `postgres`. Aliases must be unique within a job. + +Artifacts +--------- + +```yaml +artifacts: + paths: + - dist/** + - build/*.tar.gz + when: on_success + expire: 30d +``` + +A bare list is shorthand for `paths`. Paths are relative to the workspace; +absolute paths are rejected. `when` is `on_success`, `on_failure` or +`always`. + +Cache +----- + +```yaml +cache: + key: deps-$MATRIX_PKG + paths: [.npm, vendor] +``` + +A directory preserved between jobs on the same worker, keyed by `key`. A +cache is an optimisation, never a correctness guarantee: a job must work with +an empty one. + +Environment +----------- + +`env` values must be scalars, and names must be valid environment variable +names. Job `env` is merged over `defaults.env`. + +Injected automatically: + +| Variable | Present when | +| ----------------- | -------------------------------- | +| `ARCH` | the job declares `arch` | +| `MATRIX_<NAME>` | for each matrix dimension | + +Interpolation +------------- + +`${{ ... }}` is substituted before the job is stored, in `image`, `script`, +`env` values, `requires`, `services` and `cache`. Two expressions are +available: `arch` and `matrix.<name>`. + +```yaml +build: + arch: [x86_64] + matrix: + pkg: [musl] + image: 'builder:${{ arch }}' + script: ['./build.sh ${{ matrix.pkg }}'] +``` + +Referring to a dimension the job does not declare is an error. Inside +`script` the `$ARCH` and `$MATRIX_*` variables usually read better, since the +shell expands them; interpolation exists for the fields the shell never sees, +such as `image`. + +Full example +------------ + +```yaml +version: 1 + +defaults: + image: debian:bookworm-slim + timeout: 30m + +jobs: + lint: + script: [make lint] + + build: + arch: [x86_64, aarch64] + script: ['./mk/build.sh $ARCH'] + artifacts: + paths: [build/out/**] + expire: 7d + + package: + needs: [build] + arch: [x86_64, aarch64] + matrix: + pkg: [musl, busybox] + requires: [sign-key] + script: ['./mk/package.sh $MATRIX_PKG $ARCH'] + artifacts: + paths: ['build/repo/$ARCH/*.apk'] + + publish: + needs: [package, lint] + requires: [publish-key] + script: ['./mk/publish.sh'] +``` + +This yields nine jobs: one `lint`, two `build`, four `package` and one +`publish`. The two `build` jobs run at the same time as `lint`; each +`package` starts as soon as its own architecture's `build` finishes. diff --git a/examples/distro.conductor.yml b/examples/distro.conductor.yml @@ -0,0 +1,64 @@ +# A source distribution building signed packages for two architectures. +# +# This is the shape the conductor was extracted from. Two details matter: +# +# Each package job waits only for the build of its own architecture, +# because the dependency shares the arch dimension. The aarch64 packages +# do not sit behind the x86_64 build. +# +# Signing happens on the worker. The worker holds the key and advertises +# the sign-key feature; the job asks for it by name. The conductor never +# sees the key, which is what makes it safe to accept build capacity from +# someone else's hardware. +# +# The package list is written out rather than discovered, since the pipeline +# is data and the conductor does not run repository code to build the graph. + +version: 1 + +defaults: + image: debian:bookworm-slim + timeout: 2h + +jobs: + check: + timeout: 10m + script: + - ./mk/deps.sh check + + toolchain: + arch: [x86_64, aarch64] + script: + - ./mk/bootstrap-host.sh --check + - ./mk/sysroot.sh --arch $ARCH + artifacts: + paths: ['build/sysroot-$ARCH.tar.zst'] + expire: 7d + + package: + needs: [toolchain] + arch: [x86_64, aarch64] + matrix: + pkg: [musl, busybox, glibc, grub, tinyssh, base-files] + requires: [sign-key] + script: + - ./mk/build.sh $MATRIX_PKG $ARCH + artifacts: + paths: ['build/repo/$ARCH/$MATRIX_PKG-*.apk'] + + image: + needs: [package] + arch: [x86_64, aarch64] + script: + - ./mk/rootfs.sh $ARCH + - ./mk/make-img.sh $ARCH + artifacts: + paths: ['build/images/*-$ARCH.img'] + + publish: + # No arch of its own, so this waits for every image and every package. + needs: [image, check] + requires: [publish-key] + script: + - ./mk/repo-index.sh + - ./mk/publish.sh diff --git a/examples/node-app.conductor.yml b/examples/node-app.conductor.yml @@ -0,0 +1,55 @@ +# A typical node service: install, check, build, publish an image. +# +# The publish job asks for the docker feature, so it only runs on a worker +# configured to provide a docker socket or a docker in docker service. + +version: 1 + +defaults: + image: node:22-bookworm-slim + timeout: 20m + +jobs: + install: + script: + - npm ci + artifacts: + paths: [node_modules/**] + expire: 1h + cache: + key: npm + paths: [.npm] + + lint: + needs: [install] + script: + - npm run lint + + test: + needs: [install] + matrix: + suite: [unit, integration] + services: + - image: postgres:16 + env: + POSTGRES_PASSWORD: test + env: + DATABASE_URL: postgres://postgres:test@postgres:5432/postgres + script: + - npm run test:$MATRIX_SUITE + artifacts: + paths: [coverage/**] + when: always + + image: + needs: [lint, test] + image: docker:27-cli + requires: [docker] + services: + - image: docker:27-dind + alias: docker + env: + DOCKER_HOST: tcp://docker:2375 + script: + - docker build -t example/app:$CONDUCTOR_SHA . + - docker push example/app:$CONDUCTOR_SHA diff --git a/hooks/post-receive b/hooks/post-receive @@ -0,0 +1,118 @@ +#!/bin/sh +# hooks/post-receive - notify a conductor of pushed commits +# +# Install into a bare repository as hooks/post-receive, chmod +x. +# Reads "<old> <new> <ref>" per pushed ref on stdin. +# +# Required: +# CONDUCTOR_URL base url, for example https://ci.example.com +# CONDUCTOR_PROJECT project id registered with the conductor +# +# Optional: +# CONDUCTOR_SECRET trigger secret, or a path to a file holding it. +# Required whenever the project has one set. +# CONDUCTOR_REFS space separated refs to build. Default: every +# branch. Example: "refs/heads/main refs/heads/dev" +# CONDUCTOR_TIMEOUT seconds to wait per request, default 10 +# +# The hook decides nothing about the build. It reports which commit was +# pushed and the conductor reads the pipeline from the repository itself. +# A failure here never blocks the push. + +set -eu + +CONDUCTOR_URL="${CONDUCTOR_URL:-}" +CONDUCTOR_PROJECT="${CONDUCTOR_PROJECT:-}" +CONDUCTOR_SECRET="${CONDUCTOR_SECRET:-}" +CONDUCTOR_REFS="${CONDUCTOR_REFS:-}" +CONDUCTOR_TIMEOUT="${CONDUCTOR_TIMEOUT:-10}" + +ZERO="0000000000000000000000000000000000000000" + +log() { printf '[conductor] %s\n' "$*" >&2; } + +if [ -z "${CONDUCTOR_URL}" ] || [ -z "${CONDUCTOR_PROJECT}" ]; then + log "CONDUCTOR_URL and CONDUCTOR_PROJECT must be set; not triggering" + exit 0 +fi + +# The secret may be given inline or as a file, so it can stay out of the +# process environment on shared hosts. +if [ -n "${CONDUCTOR_SECRET}" ] && [ -f "${CONDUCTOR_SECRET}" ]; then + CONDUCTOR_SECRET=$(cat "${CONDUCTOR_SECRET}") +fi + +if command -v curl >/dev/null 2>&1; then + http_client=curl +elif command -v wget >/dev/null 2>&1; then + http_client=wget +else + log "neither curl nor wget is available; not triggering" + exit 0 +fi + +want_ref() { + [ -z "${CONDUCTOR_REFS}" ] && case "$1" in + refs/heads/*) return 0 ;; + *) return 1 ;; + esac + for candidate in ${CONDUCTOR_REFS}; do + [ "${candidate}" = "$1" ] && return 0 + done + return 1 +} + +while read -r old new ref; do + if [ "${new}" = "${ZERO}" ]; then + log "skipping ${ref}, branch deleted" + continue + fi + + if ! want_ref "${ref}"; then + log "skipping ${ref}" + continue + fi + + # A new branch reports an all zero parent, which is not a commit. + if [ "${old}" = "${ZERO}" ]; then + base="" + else + base="${old}" + fi + + payload=$(printf '{"sha":"%s","base":"%s","ref":"%s","actor":"%s"}' \ + "${new}" "${base}" "${ref}" "${USER:-git}") + + if [ -n "${CONDUCTOR_SECRET}" ]; then + digest=$(printf '%s' "${payload}" | openssl dgst -sha256 -hmac "${CONDUCTOR_SECRET}" | sed 's/^.*[= ]//') + signature="sha256=${digest}" + else + signature="" + log "warning: no CONDUCTOR_SECRET set, sending unsigned" + fi + + url="${CONDUCTOR_URL%/}/api/trigger/${CONDUCTOR_PROJECT}" + log "triggering ${ref} at $(echo "${new}" | cut -c1-12)" + + if [ "${http_client}" = curl ]; then + response=$(curl -fsS -m "${CONDUCTOR_TIMEOUT}" -X POST "${url}" \ + -H 'Content-Type: application/json' \ + -H "X-Hub-Signature-256: ${signature}" \ + -d "${payload}" 2>&1) || { + log "trigger failed for ${ref}: ${response}" + continue + } + else + response=$(wget -qO- --timeout="${CONDUCTOR_TIMEOUT}" \ + --header='Content-Type: application/json' \ + --header="X-Hub-Signature-256: ${signature}" \ + --post-data="${payload}" "${url}" 2>&1) || { + log "trigger failed for ${ref}: ${response}" + continue + } + fi + + log "${response}" +done + +exit 0 diff --git a/src/admin-cli.js b/src/admin-cli.js @@ -0,0 +1,213 @@ +// src/admin-cli.js - local administration +// +// Usage: +// node src/admin-cli.js project:add <id> <repo-url> [--name n] [--branch b] +// [--config p] [--source archive|clone] +// [--secret s] +// node src/admin-cli.js project:list +// node src/admin-cli.js project:secret <id> [secret] +// node src/admin-cli.js project:remove <id> +// node src/admin-cli.js token:add <name> +// node src/admin-cli.js token:list +// node src/admin-cli.js token:remove <id> +// node src/admin-cli.js run:trigger <project> <sha> [--ref r] [--base b] +// node src/admin-cli.js run:cancel <run-id> +// +// The HTTP admin API arrives with the auth work. Until then this is the +// supported way to register projects and mint worker tokens. + +import crypto from 'node:crypto'; +import { loadConfig } from './lib/config.js'; +import { createServices } from './conductor/app.js'; + +const [command, ...rest] = process.argv.slice(2); + +// Splits --flag value pairs out of the positional arguments. +function parseArgs(argv) { + const flags = {}; + const positional = []; + for (let i = 0; i < argv.length; i += 1) { + if (argv[i].startsWith('--')) { + const name = argv[i].slice(2); + const next = argv[i + 1]; + if (next === undefined || next.startsWith('--')) { + flags[name] = true; + } else { + flags[name] = next; + i += 1; + } + } else { + positional.push(argv[i]); + } + } + return { flags, positional }; +} + +const { flags, positional } = parseArgs(rest); + +function usage(message) { + if (message) console.error(`error: ${message}\n`); + console.error( + [ + 'usage:', + ' project:add <id> <repo-url> [--name n] [--branch b] [--config p] [--source archive|clone] [--secret s]', + ' project:list', + ' project:secret <id> [secret]', + ' project:remove <id>', + ' token:add <name>', + ' token:list', + ' token:remove <id>', + ' run:trigger <project> <sha> [--ref r] [--base b]', + ' run:cancel <run-id>', + ].join('\n') + ); + process.exit(message ? 1 : 0); +} + +if (!command || command === 'help' || command === '--help') usage(); + +const cfg = loadConfig(); +const services = await createServices(cfg, { migrationLogger: () => {} }); +const { projects, workerTokens, scheduler, db } = services; + +function table(rows, columns) { + if (rows.length === 0) { + console.log('(none)'); + return; + } + const widths = columns.map((c) => Math.max(c.length, ...rows.map((r) => String(r[c] ?? '').length))); + console.log(columns.map((c, i) => c.padEnd(widths[i])).join(' ')); + for (const row of rows) { + console.log(columns.map((c, i) => String(row[c] ?? '').padEnd(widths[i])).join(' ')); + } +} + +const when = (ms) => (ms ? new Date(ms).toISOString().replace('T', ' ').slice(0, 19) : ''); + +try { + switch (command) { + case 'project:add': { + const [id, repoUrl] = positional; + if (!id || !repoUrl) usage('project:add needs an id and a repository url'); + + // Generated when not supplied, since an unsigned trigger endpoint is + // rarely what anyone actually wants. + const secret = flags.secret === true || flags.secret === undefined + ? crypto.randomBytes(24).toString('hex') + : String(flags.secret); + + const project = await projects.create({ + id, + name: flags.name ? String(flags.name) : id, + repo_url: repoUrl, + default_branch: flags.branch ? String(flags.branch) : 'main', + config_path: flags.config ? String(flags.config) : '.conductor.yml', + source_mode: flags.source ? String(flags.source) : 'archive', + trigger_secret: secret, + }); + + console.log(`created project ${project.id}`); + console.log(` repository: ${project.repo_url}`); + console.log(` pipeline: ${project.config_path}`); + console.log(` source mode: ${project.source_mode}`); + console.log(` trigger url: ${cfg.server.public_url.replace(/\/+$/, '')}/api/trigger/${project.id}`); + console.log(` secret: ${secret}`); + break; + } + + case 'project:list': { + const rows = (await projects.list()).map((p) => ({ + id: p.id, + repo_url: p.repo_url, + branch: p.default_branch, + config: p.config_path, + source: p.source_mode, + enabled: p.enabled ? 'yes' : 'no', + runs: p.run_counter, + })); + table(rows, ['id', 'repo_url', 'branch', 'config', 'source', 'enabled', 'runs']); + break; + } + + case 'project:secret': { + const [id, value] = positional; + if (!id) usage('project:secret needs a project id'); + if (!(await projects.get(id))) usage(`unknown project ${id}`); + const secret = value ?? crypto.randomBytes(24).toString('hex'); + await projects.setTriggerSecret(id, secret); + console.log(`trigger secret for ${id}: ${secret}`); + break; + } + + case 'project:remove': { + const [id] = positional; + if (!id) usage('project:remove needs a project id'); + await projects.remove(id); + console.log(`removed project ${id}`); + break; + } + + case 'token:add': { + const [name] = positional; + if (!name) usage('token:add needs a name'); + const created = await workerTokens.create(name); + console.log(`created worker token ${created.id} for ${created.name}`); + console.log(` token: ${created.token}`); + console.log(' This is shown once. Store it in the worker configuration now.'); + break; + } + + case 'token:list': { + const rows = (await workerTokens.list()).map((t) => ({ + id: t.id, + name: t.name, + enabled: t.enabled ? 'yes' : 'no', + created: when(t.created_at), + last_seen: when(t.last_seen_at), + last_ip: t.last_ip ?? '', + })); + table(rows, ['id', 'name', 'enabled', 'created', 'last_seen', 'last_ip']); + break; + } + + case 'token:remove': { + const [id] = positional; + if (!id) usage('token:remove needs a token id'); + console.log(await workerTokens.remove(id) ? `removed token ${id}` : `no such token ${id}`); + break; + } + + case 'run:trigger': { + const [projectId, sha] = positional; + if (!projectId || !sha) usage('run:trigger needs a project and a commit'); + const project = await projects.get(projectId); + if (!project) usage(`unknown project ${projectId}`); + + const result = await scheduler.createRun(project, { + headSha: sha, + ref: flags.ref ? String(flags.ref) : null, + baseSha: flags.base ? String(flags.base) : null, + trigger: 'manual', + actor: 'admin-cli', + }); + console.log(`created run ${result.runId} with ${result.jobCount} job(s)`); + break; + } + + case 'run:cancel': { + const [runId] = positional; + if (!runId) usage('run:cancel needs a run id'); + const result = await scheduler.cancelRun(runId); + console.log(result.ok ? `cancelled ${runId}` : `could not cancel: ${result.reason}`); + break; + } + + default: + usage(`unknown command ${command}`); + } +} catch (e) { + console.error(`error: ${e.message}`); + process.exitCode = 1; +} finally { + await db.close(); +} diff --git a/src/conductor/app.js b/src/conductor/app.js @@ -0,0 +1,91 @@ +// src/conductor/app.js - assembles the conductor +// +// Separated from index.js so tests can build a server on a temporary +// database without starting a listener or installing signal handlers. + +import Fastify from 'fastify'; +import cors from '@fastify/cors'; + +import { ensureStateDirs, parseKey } from '../lib/config.js'; +import { openDatabase, runMigrations } from '../lib/db/index.js'; +import { createStorage } from '../lib/storage/index.js'; +import { createSecretBox } from '../lib/secretbox.js'; +import { createLogStore } from '../lib/log.js'; +import { createGit } from '../lib/git.js'; +import { createProjects } from '../lib/projects.js'; +import { createWorkerTokens } from '../lib/workers.js'; +import { createScheduler } from './scheduler.js'; + +import workerRoutes from './routes/workers.js'; +import triggerRoutes from './routes/trigger.js'; +import runRoutes from './routes/runs.js'; + +export async function createServices(cfg, options = {}) { + ensureStateDirs(cfg); + + const db = await openDatabase(cfg); + if (options.migrate !== false) { + await runMigrations(db, { logger: options.migrationLogger }); + } + + const secrets = createSecretBox(parseKey(cfg.secrets.encryption_key, 'secrets.encryption_key')); + const storage = createStorage(cfg); + const logs = createLogStore(cfg); + const git = createGit(cfg); + const projects = createProjects({ db, secrets }); + const workerTokens = createWorkerTokens({ db }); + + const logger = options.logger ?? console; + const scheduler = createScheduler({ cfg, db, git, logs, storage, projects, logger }); + + return { cfg, db, secrets, storage, logs, git, projects, workerTokens, scheduler, logger }; +} + +export async function buildServer(services, options = {}) { + const { cfg } = services; + const fastify = Fastify({ + logger: options.logger ?? { level: process.env.LOG_LEVEL || 'info' }, + // Workers upload artifacts as raw streams; this only bounds parsed + // bodies such as the trigger payload. + bodyLimit: 1024 * 1024, + trustProxy: options.trustProxy ?? true, + }); + + await fastify.register(cors, { origin: true }); + + fastify.get('/health', async () => ({ + ok: true, + service: 'conductor', + database: services.db.dialect, + storage: services.storage.driver, + auth: cfg.auth.mode, + })); + + await fastify.register(triggerRoutes, { ...services, prefix: '/api/trigger' }); + await fastify.register(workerRoutes, { ...services, prefix: '/api/workers' }); + await fastify.register(runRoutes, { ...services, prefix: '/api' }); + + return fastify; +} + +// Requeues or fails jobs whose worker went away. Returns a stop function. +export function startReaper(services) { + const { cfg, scheduler, logger } = services; + let running = false; + + const timer = setInterval(async () => { + // Skip a tick rather than overlapping if a sweep runs long. + if (running) return; + running = true; + try { + await scheduler.reap(); + } catch (e) { + logger.error?.(`reaper failed: ${e.message}`); + } finally { + running = false; + } + }, cfg.scheduler.reap_interval * 1000); + + timer.unref(); + return () => clearInterval(timer); +} diff --git a/src/conductor/index.js b/src/conductor/index.js @@ -0,0 +1,39 @@ +// src/conductor/index.js - conductor entry point +// +// The write side: accepts triggers, schedules runs, and serves the worker +// API. Run the read-api separately when the public read surface should not +// sit on the same port as the write surface. + +import { loadConfig } from '../lib/config.js'; +import { createServices, buildServer, startReaper } from './app.js'; + +const cfg = loadConfig(); +const services = await createServices(cfg); +const fastify = await buildServer(services); + +if (cfg.auth.session_secret_ephemeral) { + fastify.log.warn('auth.session_secret is unset; a random one was generated and sessions will not survive a restart'); +} +if (!cfg.secrets.encryption_key) { + fastify.log.warn('secrets.encryption_key is unset; stored secrets are kept in the clear'); +} + +const stopReaper = startReaper({ ...services, logger: fastify.log }); + +async function shutdown(signal) { + fastify.log.info(`${signal} received, shutting down`); + stopReaper(); + try { + await fastify.close(); + await services.db.close(); + } catch (e) { + fastify.log.error({ err: e }, 'shutdown failed'); + process.exitCode = 1; + } +} + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.once(signal, () => { shutdown(signal); }); +} + +await fastify.listen({ port: cfg.server.port, host: cfg.server.host }); diff --git a/src/conductor/routes/runs.js b/src/conductor/routes/runs.js @@ -0,0 +1,164 @@ +// src/conductor/routes/runs.js - read access to runs, jobs and logs +// +// These routes are read only and carry no secrets, so the read-api service +// will mount the same handlers in phase 6. Anything that mutates state lives +// under the admin routes instead. + +import { StorageNotFound } from '../../lib/storage/index.js'; + +const MAX_TAIL = 1024 * 1024; + +export default async function runRoutes(fastify, { db, logs, storage }) { + fastify.get('/runs', async (req, reply) => { + const limit = clamp(req.query.limit, 1, 200, 50); + const project = typeof req.query.project === 'string' ? req.query.project : null; + + const rows = project + ? await db.all( + `SELECT id, project_id, number, ref, base_sha, head_sha, trigger_type, actor, + title, state, created_at, started_at, finished_at + FROM runs WHERE project_id = {project} + ORDER BY created_at DESC LIMIT {limit}`, + { project, limit } + ) + : await db.all( + `SELECT id, project_id, number, ref, base_sha, head_sha, trigger_type, actor, + title, state, created_at, started_at, finished_at + FROM runs ORDER BY created_at DESC LIMIT {limit}`, + { limit } + ); + + return reply.send({ runs: rows }); + }); + + fastify.get('/runs/:id', async (req, reply) => { + const run = await db.get( + `SELECT id, project_id, number, ref, base_sha, head_sha, trigger_type, actor, + title, state, error, created_at, started_at, finished_at + FROM runs WHERE id = {id}`, + { id: req.params.id } + ); + if (!run) return reply.code(404).send({ error: 'unknown run' }); + + const jobs = await db.all( + `SELECT id, name, base_name, arch, image, state, allow_failure, attempt, max_attempts, + exit_code, error, log_size, worker_name, timeout, + created_at, started_at, finished_at + FROM jobs WHERE run_id = {run} ORDER BY name`, + { run: run.id } + ); + + const deps = await db.all( + `SELECT d.job_id, d.depends_on_id + FROM job_deps d JOIN jobs j ON j.id = d.job_id + WHERE j.run_id = {run}`, + { run: run.id } + ); + const needs = new Map(jobs.map((j) => [j.id, []])); + for (const d of deps) needs.get(d.job_id)?.push(d.depends_on_id); + + return reply.send({ + run, + jobs: jobs.map((j) => ({ ...j, needs: needs.get(j.id) ?? [] })), + }); + }); + + fastify.get('/jobs/:id', async (req, reply) => { + const job = await db.get( + `SELECT id, run_id, name, base_name, arch, image, requires, spec, state, allow_failure, + attempt, max_attempts, exit_code, error, log_size, worker_name, timeout, + created_at, started_at, finished_at + FROM jobs WHERE id = {id}`, + { id: req.params.id } + ); + if (!job) return reply.code(404).send({ error: 'unknown job' }); + + const artifacts = await db.all( + 'SELECT id, path, size, sha256, created_at FROM artifacts WHERE job_id = {job} ORDER BY path', + { job: job.id } + ); + + return reply.send({ + job: { ...job, requires: safeJson(job.requires, []), spec: safeJson(job.spec, {}) }, + artifacts, + }); + }); + + // Incremental log tail. While a job runs the bytes come from the local + // spool; once it has finished they come from object storage. + fastify.get('/jobs/:id/log', async (req, reply) => { + const job = await db.get( + 'SELECT id, run_id, state, log_key, log_size FROM jobs WHERE id = {id}', + { id: req.params.id } + ); + if (!job) return reply.code(404).send({ error: 'unknown job' }); + + const offset = clamp(req.query.offset, 0, Number.MAX_SAFE_INTEGER, 0); + const limit = clamp(req.query.limit, 1, MAX_TAIL, 64 * 1024); + + if (!job.log_key) { + const chunk = await logs.read(job.run_id, job.id, { offset, limit }); + reply.header('content-type', 'text/plain; charset=utf-8'); + reply.header('x-log-offset', String(chunk.offset)); + reply.header('x-log-size', String(chunk.size)); + // Tells a follower whether to keep polling. + reply.header('x-log-complete', 'false'); + return reply.send(chunk.data); + } + + try { + const end = offset + limit - 1; + const object = await storage.get(job.log_key, { range: { start: offset, end } }); + reply.header('content-type', 'text/plain; charset=utf-8'); + reply.header('x-log-offset', String(offset)); + reply.header('x-log-size', String(job.log_size)); + reply.header('x-log-complete', 'true'); + return reply.send(object.stream); + } catch (e) { + if (e instanceof StorageNotFound) return reply.code(404).send({ error: 'log is no longer stored' }); + throw e; + } + }); + + fastify.get('/artifacts/:id', async (req, reply) => { + const artifact = await db.get( + 'SELECT id, job_id, run_id, path, storage_key, size, sha256 FROM artifacts WHERE id = {id}', + { id: req.params.id } + ); + if (!artifact) return reply.code(404).send({ error: 'unknown artifact' }); + + // Hand the client straight to the object store when that is possible, + // rather than proxying the bytes through the conductor. + const url = await storage.presign(artifact.storage_key, { expires: 300 }); + if (url) return reply.redirect(url, 302); + + try { + const object = await storage.get(artifact.storage_key); + reply.header('content-type', 'application/octet-stream'); + reply.header('content-length', String(artifact.size)); + reply.header('content-disposition', `attachment; filename="${encodeURIComponent(basename(artifact.path))}"`); + return reply.send(object.stream); + } catch (e) { + if (e instanceof StorageNotFound) return reply.code(404).send({ error: 'artifact is no longer stored' }); + throw e; + } + }); +} + +function clamp(raw, min, max, fallback) { + const n = Number(raw); + if (!Number.isFinite(n)) return fallback; + return Math.min(max, Math.max(min, Math.trunc(n))); +} + +function basename(p) { + return p.split('/').pop() || 'artifact'; +} + +function safeJson(text, fallback) { + try { + return JSON.parse(text); + } catch { + return fallback; + } +} diff --git a/src/conductor/routes/trigger.js b/src/conductor/routes/trigger.js @@ -0,0 +1,147 @@ +// src/conductor/routes/trigger.js - push notifications +// +// Accepts the generic payload produced by hooks/post-receive, and the push +// payloads of GitHub, Gitea and GitLab, since they all carry the same three +// facts. Nothing in the payload is trusted beyond which commit to look at: +// the pipeline is always read from the repository at that commit. +// +// A project with a trigger secret requires a valid signature. A project +// without one accepts unsigned triggers, which is convenient on a private +// network and a bad idea anywhere else, so it is logged. + +import crypto from 'node:crypto'; +import { SHA_PATTERN } from '../../lib/git.js'; +import { PipelineError } from '../../lib/pipeline/index.js'; + +const ZERO_SHA = '0'.repeat(40); + +export default async function triggerRoutes(fastify, { projects, scheduler, logger }) { + // HMAC is computed over the exact bytes received, so the raw body has to + // survive JSON parsing. + fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => { + req.rawBody = body; + if (body.length === 0) return done(null, {}); + try { + done(null, JSON.parse(body.toString('utf8'))); + } catch (e) { + e.statusCode = 400; + done(e); + } + }); + + fastify.post('/:project', async (req, reply) => { + const project = await projects.get(req.params.project); + if (!project) return reply.code(404).send({ error: 'unknown project' }); + if (project.enabled !== 1) return reply.code(409).send({ error: 'project is disabled' }); + + const secret = projects.triggerSecret(project); + if (secret) { + if (!verifySignature(req, secret)) { + return reply.code(401).send({ error: 'invalid or missing signature' }); + } + } else { + logger.warn?.(`project ${project.id} accepted an unsigned trigger; set a trigger secret`); + } + + const push = normalizePush(req.body); + if (!push) { + return reply.code(400).send({ + error: 'could not read a commit from the payload; expected sha, or a GitHub, Gitea or GitLab push event', + }); + } + if (push.sha === ZERO_SHA) { + return reply.send({ status: 'ignored', reason: 'branch deletion' }); + } + if (!SHA_PATTERN.test(push.sha)) { + return reply.code(400).send({ error: `not a full commit id: ${JSON.stringify(push.sha)}` }); + } + + try { + const { runId, jobCount } = await scheduler.createRun(project, { + ref: push.ref, + baseSha: push.base, + headSha: push.sha, + trigger: 'push', + actor: push.actor, + }); + return reply.send({ status: 'created', run_id: runId, jobs: jobCount }); + } catch (e) { + if (e instanceof PipelineError) { + // A broken pipeline is the pusher's problem, not a server fault. + return reply.code(422).send({ error: 'invalid pipeline', detail: e.message, problems: e.errors }); + } + req.log.error({ err: e }, `trigger failed for ${project.id}`); + return reply.code(500).send({ error: 'could not create run', detail: String(e.message ?? e) }); + } + }); +} + +function verifySignature(req, secret) { + const raw = req.rawBody ?? Buffer.alloc(0); + + // GitHub and Gitea: sha256=<hex> over the body. + const hubSignature = req.headers['x-hub-signature-256']; + if (typeof hubSignature === 'string' && hubSignature.length > 0) { + const expected = `sha256=${crypto.createHmac('sha256', secret).update(raw).digest('hex')}`; + return timingSafeEqual(expected, hubSignature); + } + + // GitLab: the secret itself, compared rather than signed. + const gitlabToken = req.headers['x-gitlab-token']; + if (typeof gitlabToken === 'string' && gitlabToken.length > 0) { + return timingSafeEqual(secret, gitlabToken); + } + + return false; +} + +function timingSafeEqual(a, b) { + const left = Buffer.from(String(a), 'utf8'); + const right = Buffer.from(String(b), 'utf8'); + if (left.length !== right.length) return false; + return crypto.timingSafeEqual(left, right); +} + +// Reduces the supported payload shapes to { sha, base, ref, actor }. +function normalizePush(body) { + if (!body || typeof body !== 'object') return null; + + // hooks/post-receive, and anything else driving the API directly. + if (typeof body.sha === 'string') { + return { + sha: body.sha, + base: usableSha(body.base), + ref: typeof body.ref === 'string' ? body.ref : null, + actor: typeof body.actor === 'string' ? body.actor : null, + }; + } + + // GitLab sends both checkout_sha and after; checkout_sha is the one that + // refers to the tip of the pushed branch. + if (typeof body.checkout_sha === 'string') { + return { + sha: body.checkout_sha, + base: usableSha(body.before), + ref: typeof body.ref === 'string' ? body.ref : null, + actor: body.user_username ?? body.user_name ?? null, + }; + } + + // GitHub and Gitea. + if (typeof body.after === 'string') { + return { + sha: body.after, + base: usableSha(body.before), + ref: typeof body.ref === 'string' ? body.ref : null, + actor: body.pusher?.name ?? body.pusher?.login ?? body.sender?.login ?? null, + }; + } + + return null; +} + +// A new branch reports an all zero parent, which is not a commit. +function usableSha(value) { + if (typeof value !== 'string' || value === ZERO_SHA || !SHA_PATTERN.test(value)) return null; + return value; +} diff --git a/src/conductor/routes/workers.js b/src/conductor/routes/workers.js @@ -0,0 +1,251 @@ +// src/conductor/routes/workers.js - the API workers poll and report to +// +// Every route here requires a worker token. A worker may only touch a job it +// currently holds, which is checked against worker_token_id rather than +// trusting the job id it sends. + +import { pipeline as streamPipeline } from 'node:stream/promises'; +import { LogOffsetError } from '../../lib/log.js'; +import { hashingTransform } from '../../lib/stream.js'; +import { sanitizeRelativePath, keys as storageKeys } from '../../lib/storage/index.js'; +import { newArtifactId } from '../../lib/ids.js'; + +// Log chunks are small and frequent; artifacts are streamed, so this only +// bounds a single log append. +const LOG_CHUNK_LIMIT = 1024 * 1024; + +export default async function workerRoutes(fastify, { cfg, db, git, logs, storage, projects, scheduler, workerTokens }) { + // Raw bodies: log chunks and artifacts arrive as octet streams and must + // not be parsed. + fastify.addContentTypeParser('application/octet-stream', (req, payload, done) => done(null, payload)); + + async function requireWorker(req, reply) { + const header = req.headers.authorization || ''; + const presented = header.startsWith('Bearer ') ? header.slice(7).trim() : ''; + const token = await workerTokens.verify(presented, { ip: req.ip }); + if (!token) { + reply.code(401).send({ error: 'invalid or missing worker token' }); + return; + } + req.worker = token; + } + + // Confirms the job exists and is held by the calling worker. + async function heldJob(req, reply) { + const job = await db.get( + `SELECT j.id, j.run_id, j.name, j.state, j.worker_token_id, j.log_size, r.project_id, r.head_sha + FROM jobs j JOIN runs r ON r.id = j.run_id + WHERE j.id = {id}`, + { id: req.params.id } + ); + if (!job) { + reply.code(404).send({ error: 'unknown job' }); + return null; + } + if (job.worker_token_id !== req.worker.id) { + reply.code(403).send({ error: 'job is not held by this worker' }); + return null; + } + return job; + } + + fastify.addHook('preHandler', requireWorker); + + // Ask for work. The worker describes what it can run; the scheduler + // decides. Returns 204 when there is nothing to do. + fastify.get('/poll', async (req, reply) => { + const arches = splitList(req.query.arches); + const features = splitList(req.query.features); + const name = typeof req.query.name === 'string' ? req.query.name.slice(0, 255) : req.worker.name; + + const job = await scheduler.claim({ + arches, + features, + tokenId: req.worker.id, + workerName: name, + }); + if (!job) return reply.code(204).send(); + + const project = await projects.get(job.project_id); + const base = cfg.server.public_url.replace(/\/+$/, ''); + + return reply.send({ + job: { + id: job.id, + run_id: job.run_id, + name: job.name, + arch: job.arch, + image: job.image, + script: job.script, + env: job.env, + services: job.services, + artifacts: job.artifacts, + cache: job.cache, + timeout: job.timeout, + attempt: job.attempt, + sha: job.sha, + // A worker fetches the tree from the conductor by default, so it + // never needs repository credentials. Projects set to clone mode + // get the URL instead. + source: project.source_mode === 'clone' + ? { mode: 'clone', repo_url: project.repo_url, sha: job.sha } + : { mode: 'archive', url: `${base}/api/workers/jobs/${encodeURIComponent(job.id)}/source.tar.gz` }, + endpoints: { + log: `${base}/api/workers/jobs/${encodeURIComponent(job.id)}/log`, + artifact: `${base}/api/workers/jobs/${encodeURIComponent(job.id)}/artifact`, + heartbeat: `${base}/api/workers/jobs/${encodeURIComponent(job.id)}/heartbeat`, + done: `${base}/api/workers/jobs/${encodeURIComponent(job.id)}/done`, + }, + }, + }); + }); + + // The tree at the job's commit, as a gzipped tar. + fastify.get('/jobs/:id/source.tar.gz', async (req, reply) => { + const job = await heldJob(req, reply); + if (!job) return reply; + + const project = await projects.get(job.project_id); + if (!project) return reply.code(404).send({ error: 'unknown project' }); + + reply.header('content-type', 'application/gzip'); + reply.header('content-disposition', `attachment; filename="${job.head_sha.slice(0, 12)}.tar.gz"`); + return reply.send(git.archiveStream(project, job.head_sha)); + }); + + // Append to the live log. X-Log-Offset makes a retry after a dropped + // connection safe. + fastify.post('/jobs/:id/log', { bodyLimit: LOG_CHUNK_LIMIT }, async (req, reply) => { + const job = await heldJob(req, reply); + if (!job) return reply; + + const chunks = []; + let total = 0; + for await (const chunk of req.body) { + total += chunk.length; + if (total > LOG_CHUNK_LIMIT) return reply.code(413).send({ error: 'log chunk too large' }); + chunks.push(chunk); + } + + const rawOffset = req.headers['x-log-offset']; + const offset = rawOffset === undefined ? undefined : Number(rawOffset); + if (offset !== undefined && !Number.isInteger(offset)) { + return reply.code(400).send({ error: 'x-log-offset must be an integer' }); + } + + try { + const result = await logs.append(job.run_id, job.id, Buffer.concat(chunks), offset); + await db.run('UPDATE jobs SET log_size = {size}, heartbeat_at = {now} WHERE id = {id}', + { id: job.id, size: result.size, now: Date.now() }); + return reply.send(result); + } catch (e) { + if (e instanceof LogOffsetError) { + // Tell the worker where to resume from. + return reply.code(409).send({ error: e.message, expected_offset: e.expected }); + } + throw e; + } + }); + + // Upload one artifact. The path is worker supplied, so it is sanitized + // before it can influence a storage key. + fastify.post('/jobs/:id/artifact', async (req, reply) => { + const job = await heldJob(req, reply); + if (!job) return reply; + + const relPath = sanitizeRelativePath(req.headers['x-artifact-path']); + if (!relPath) return reply.code(400).send({ error: 'x-artifact-path is missing or unusable' }); + + const declared = Number(req.headers['content-length']); + if (!Number.isInteger(declared) || declared < 0) { + return reply.code(411).send({ error: 'content-length is required for artifact uploads' }); + } + + // Hash on the way past, so the digest costs nothing extra even when the + // backend cannot report one. + const hasher = hashingTransform(); + const key = storageKeys.artifact(job.run_id, job.id, relPath); + + let stored; + try { + [, stored] = await Promise.all([ + streamPipeline(req.body, hasher), + storage.put(key, hasher, { size: declared }), + ]); + } catch (e) { + // A body shorter or longer than content-length is the worker's + // mistake, not a server fault. + if (/size mismatch/.test(e.message)) { + return reply.code(400).send({ error: e.message }); + } + throw e; + } + + const digest = hasher.digest(); + await db.run( + `INSERT INTO artifacts (id, job_id, run_id, path, storage_key, size, sha256, created_at) + VALUES ({id}, {job}, {run}, {path}, {key}, {size}, {sha}, {now})`, + { + id: newArtifactId(), + job: job.id, + run: job.run_id, + path: relPath, + key, + size: stored.size ?? declared, + sha: digest, + now: Date.now(), + } + ); + + return reply.send({ path: relPath, size: stored.size ?? declared, sha256: digest }); + }); + + // Keeps the job alive, and is how a worker learns it should stop. + fastify.post('/jobs/:id/heartbeat', async (req, reply) => { + const result = await scheduler.heartbeat(req.params.id, req.worker.id); + if (!result.known) return reply.code(404).send({ error: 'unknown job for this worker' }); + return reply.send({ cancelled: result.cancelled, state: result.state }); + }); + + // Report the outcome. The log is copied to storage here, whatever the + // result, so a failed job keeps its output. + fastify.post('/jobs/:id/done', async (req, reply) => { + const job = await heldJob(req, reply); + if (!job) return reply; + + const body = req.body ?? {}; + const success = body.success === true; + const exitCode = Number.isInteger(body.exit_code) ? body.exit_code : null; + const error = typeof body.error === 'string' ? body.error.slice(0, 4096) : null; + + const outcome = await scheduler.complete(job.id, { + success, + exitCode, + error, + tokenId: req.worker.id, + }); + if (!outcome.ok) return reply.code(409).send({ error: outcome.reason }); + + // A retry keeps writing to the same log, so only archive it once the + // job has actually stopped. + if (outcome.state !== 'queued') { + try { + await scheduler.finalizeLog(job.run_id, job.id); + } catch (e) { + req.log.error({ err: e }, `failed to archive log for ${job.id}`); + } + } + + return reply.send({ + state: outcome.state, + retry: outcome.retry === true, + skipped: outcome.skipped ?? [], + run_state: outcome.runState ?? null, + }); + }); +} + +function splitList(value) { + if (typeof value !== 'string') return []; + return value.split(',').map((s) => s.trim()).filter(Boolean).slice(0, 64); +} diff --git a/src/conductor/scheduler.js b/src/conductor/scheduler.js @@ -0,0 +1,418 @@ +// src/conductor/scheduler.js - run creation, dispatch and completion +// +// Scheduling reads the dependency edges in job_deps directly. Two defects in +// the prototype came from not doing that: +// +// Dispatch was serialized. A single integer level per job was compared +// against the lowest queued level, so only one job could run at a time +// even when the graph allowed the whole width of it. Here any job whose +// dependencies are all satisfied is eligible, and several workers can +// claim different jobs concurrently. +// +// Failure skipped only direct dependents. Transitive dependents stayed +// queued and were later dispatched with their inputs missing. Here the +// whole reachable set is skipped. +// +// Claiming is a conditional UPDATE guarded on the previous state, so two +// workers polling at the same instant cannot both win the same job. + +import { compilePipeline, PipelineError, transitiveDependents } from '../lib/pipeline/index.js'; +import { inClause } from '../lib/db/query.js'; +import { newRunId, jobId as makeJobId } from '../lib/ids.js'; +import { keys as storageKeys } from '../lib/storage/index.js'; + +export const RUN_STATES = ['pending', 'running', 'success', 'failed', 'cancelled']; +export const JOB_STATES = ['queued', 'running', 'success', 'failed', 'skipped', 'cancelled']; + +// States that let a dependent proceed. +const TERMINAL = ['success', 'failed', 'skipped', 'cancelled']; + +export function createScheduler({ cfg, db, git, logs, storage, projects, logger = console }) { + // A job is eligible when it is queued, its run is live, and no dependency + // is outstanding. A failed dependency marked allow_failure still counts as + // satisfied, which is the whole point of the flag. + const ELIGIBLE = ` + SELECT j.id, j.run_id, j.name, j.arch, j.image, j.requires, j.spec, + j.timeout, j.attempt, j.max_attempts, r.head_sha, r.project_id + FROM jobs j + JOIN runs r ON r.id = j.run_id + WHERE j.state = 'queued' + AND r.state = 'running' + AND NOT EXISTS ( + SELECT 1 + FROM job_deps d + JOIN jobs dj ON dj.id = d.depends_on_id + WHERE d.job_id = j.id + AND dj.state <> 'success' + AND NOT (dj.state = 'failed' AND dj.allow_failure = 1) + ) + `; + + async function loadGraph(tx, runId) { + const jobs = await tx.all( + 'SELECT id, name, state, allow_failure FROM jobs WHERE run_id = {run}', + { run: runId } + ); + const deps = await tx.all( + `SELECT d.job_id, d.depends_on_id + FROM job_deps d + JOIN jobs j ON j.id = d.job_id + WHERE j.run_id = {run}`, + { run: runId } + ); + const needs = new Map(jobs.map((j) => [j.id, []])); + for (const d of deps) needs.get(d.job_id)?.push(d.depends_on_id); + return jobs.map((j) => ({ ...j, needs: needs.get(j.id) ?? [] })); + } + + // Marks the run finished once nothing is left to do. + async function settleRun(tx, runId) { + const outstanding = await tx.get( + "SELECT COUNT(*) AS c FROM jobs WHERE run_id = {run} AND state IN ('queued', 'running')", + { run: runId } + ); + if (outstanding.c > 0) return null; + + const bad = await tx.get( + `SELECT COUNT(*) AS c FROM jobs + WHERE run_id = {run} + AND (state IN ('skipped', 'cancelled') + OR (state = 'failed' AND allow_failure = 0))`, + { run: runId } + ); + const state = bad.c > 0 ? 'failed' : 'success'; + await tx.run( + "UPDATE runs SET state = {state}, finished_at = {now} WHERE id = {run} AND state = 'running'", + { state, run: runId, now: Date.now() } + ); + return state; + } + + return { + // Reads the pipeline at the pushed commit and records the run. The + // repository is the source of truth, so nothing the trigger claims about + // the contents is trusted. + async createRun(project, { ref, baseSha, headSha, trigger = 'push', actor = null }) { + await git.sync(project, { force: true }); + + if (!(await git.hasCommit(project, headSha))) { + throw new Error(`commit ${headSha} is not present in the mirror of ${project.id}`); + } + + const text = await git.readFile(project, headSha, project.config_path); + if (text === null) { + throw new PipelineError( + [{ path: '', message: `${project.config_path} not found at ${headSha.slice(0, 12)}` }], + project.config_path + ); + } + + const pipeline = compilePipeline(text, { + source: project.config_path, + defaultTimeout: cfg.scheduler.default_job_timeout, + defaultAttempts: 1, + }); + + const info = await git.commitInfo(project, headSha).catch(() => ({ subject: null })); + const runId = newRunId(); + const now = Date.now(); + + await db.transaction(async (tx) => { + const number = await projects.nextRunNumber(tx, project.id); + const empty = pipeline.jobs.length === 0; + + await tx.run( + `INSERT INTO runs + (id, project_id, number, ref, base_sha, head_sha, trigger_type, actor, + title, state, pipeline, created_at, started_at, finished_at) + VALUES + ({id}, {project}, {number}, {ref}, {base}, {head}, {trigger}, {actor}, + {title}, {state}, {pipeline}, {now}, {now}, {finished})`, + { + id: runId, + project: project.id, + number, + ref: ref ?? null, + base: baseSha ?? null, + head: headSha, + trigger, + actor, + title: info.subject ?? null, + state: empty ? 'success' : 'running', + pipeline: JSON.stringify({ version: pipeline.version, jobs: pipeline.jobs }), + now, + finished: empty ? now : null, + } + ); + + const idByName = new Map(pipeline.jobs.map((j) => [j.name, makeJobId(runId, j.name)])); + + for (const job of pipeline.jobs) { + await tx.run( + `INSERT INTO jobs + (id, run_id, name, base_name, arch, image, requires, spec, state, + allow_failure, attempt, max_attempts, timeout, log_size, created_at) + VALUES + ({id}, {run}, {name}, {base}, {arch}, {image}, {requires}, {spec}, 'queued', + {allow}, 0, {attempts}, {timeout}, 0, {now})`, + { + id: idByName.get(job.name), + run: runId, + name: job.name, + base: job.baseName, + arch: job.arch, + image: job.image, + requires: JSON.stringify(job.requires), + spec: JSON.stringify({ + script: job.script, + env: job.env, + services: job.services, + artifacts: job.artifacts, + cache: job.cache, + matrix: job.matrix, + needs: job.needs, + depth: job.depth, + }), + allow: job.allow_failure ? 1 : 0, + attempts: job.max_attempts, + timeout: job.timeout, + now, + } + ); + } + + for (const job of pipeline.jobs) { + for (const need of job.needs) { + await tx.run( + 'INSERT INTO job_deps (job_id, depends_on_id) VALUES ({job}, {dep})', + { job: idByName.get(job.name), dep: idByName.get(need) } + ); + } + } + }); + + logger.info?.(`run ${runId} created for ${project.id} with ${pipeline.jobs.length} job(s)`); + return { runId, jobCount: pipeline.jobs.length }; + }, + + // Hands one job to a worker. The worker advertises what it can run; + // jobs asking for anything it lacks are passed over. + async claim({ arches = [], features = [], tokenId, workerName }) { + const archFilter = inClause('arch', arches); + const featureSet = new Set(features); + + // Oldest run first, so a queue drains in order rather than starving + // whichever run happens to sort last. + const candidates = await db.all( + `${ELIGIBLE} + AND (j.arch IS NULL${archFilter.empty ? '' : ` OR j.arch IN (${archFilter.sql})`}) + ORDER BY r.created_at ASC, j.created_at ASC + LIMIT 100`, + { ...archFilter.params } + ); + + for (const candidate of candidates) { + let requires; + try { + requires = JSON.parse(candidate.requires); + } catch { + requires = []; + } + if (!requires.every((f) => featureSet.has(f))) continue; + + const now = Date.now(); + const claimed = await db.run( + `UPDATE jobs + SET state = 'running', attempt = attempt + 1, worker_token_id = {token}, + worker_name = {worker}, claimed_at = {now}, heartbeat_at = {now}, + started_at = {now} + WHERE id = {id} AND state = 'queued'`, + { id: candidate.id, token: tokenId, worker: workerName ?? null, now } + ); + // Lost the race to another worker; try the next candidate. + if (claimed.changes !== 1) continue; + + const spec = JSON.parse(candidate.spec); + return { + id: candidate.id, + run_id: candidate.run_id, + project_id: candidate.project_id, + name: candidate.name, + arch: candidate.arch, + image: candidate.image, + sha: candidate.head_sha, + requires, + timeout: candidate.timeout, + attempt: candidate.attempt + 1, + max_attempts: candidate.max_attempts, + script: spec.script, + env: spec.env, + services: spec.services, + artifacts: spec.artifacts, + cache: spec.cache, + }; + } + + return null; + }, + + async heartbeat(jobId, tokenId) { + const job = await db.get( + 'SELECT id, state, worker_token_id FROM jobs WHERE id = {id}', + { id: jobId } + ); + if (!job) return { known: false }; + if (job.worker_token_id !== tokenId) return { known: false }; + + if (job.state === 'running') { + await db.run('UPDATE jobs SET heartbeat_at = {now} WHERE id = {id}', { id: jobId, now: Date.now() }); + return { known: true, cancelled: false }; + } + // The job was cancelled or reaped out from under the worker, which is + // how a worker learns to stop. + return { known: true, cancelled: true, state: job.state }; + }, + + // Records the outcome, retries if attempts remain, and otherwise skips + // everything downstream. + async complete(jobId, { success, exitCode = null, error = null, tokenId = null }) { + return db.transaction(async (tx) => { + const job = await tx.get( + `SELECT id, run_id, name, state, attempt, max_attempts, allow_failure, worker_token_id + FROM jobs WHERE id = {id}`, + { id: jobId } + ); + if (!job) return { ok: false, reason: 'unknown job' }; + if (tokenId !== null && job.worker_token_id !== tokenId) { + return { ok: false, reason: 'job belongs to another worker' }; + } + if (job.state !== 'running') { + return { ok: false, reason: `job is ${job.state}, not running` }; + } + + const now = Date.now(); + + if (success) { + await tx.run( + "UPDATE jobs SET state = 'success', exit_code = {code}, finished_at = {now} WHERE id = {id}", + { id: jobId, code: exitCode, now } + ); + const runState = await settleRun(tx, job.run_id); + return { ok: true, state: 'success', runState }; + } + + // Transient failure with attempts left: back to the queue rather + // than failing the run. + if (job.attempt < job.max_attempts) { + await tx.run( + `UPDATE jobs + SET state = 'queued', exit_code = {code}, error = {error}, + worker_token_id = NULL, worker_name = NULL, + claimed_at = NULL, heartbeat_at = NULL, started_at = NULL + WHERE id = {id}`, + { id: jobId, code: exitCode, error } + ); + return { ok: true, state: 'queued', retry: true, attempt: job.attempt }; + } + + await tx.run( + "UPDATE jobs SET state = 'failed', exit_code = {code}, error = {error}, finished_at = {now} WHERE id = {id}", + { id: jobId, code: exitCode, error, now } + ); + + let skipped = []; + if (!job.allow_failure) { + skipped = await skipDependents(tx, job.run_id, [jobId], now); + } + + const runState = await settleRun(tx, job.run_id); + return { ok: true, state: 'failed', skipped, runState }; + }); + }, + + // Every job reachable from the failed ones, not just their immediate + // dependents. + async skipDependents(runId, fromJobIds) { + return db.transaction((tx) => skipDependents(tx, runId, fromJobIds, Date.now())); + }, + + // Requeues or fails jobs whose worker stopped reporting. + async reap() { + const cutoff = Date.now() - cfg.scheduler.heartbeat_timeout * 1000; + const stale = await db.all( + `SELECT id, run_id, name, attempt, max_attempts, worker_name + FROM jobs + WHERE state = 'running' AND heartbeat_at IS NOT NULL AND heartbeat_at < {cutoff}`, + { cutoff } + ); + + const results = []; + for (const job of stale) { + const outcome = await this.complete(job.id, { + success: false, + error: `worker ${job.worker_name ?? 'unknown'} stopped reporting for more than ` + + `${cfg.scheduler.heartbeat_timeout}s`, + }); + if (outcome.ok) { + logger.warn?.(`reaped ${job.name} (${job.id}): ${outcome.retry ? 'requeued' : 'failed'}`); + results.push({ id: job.id, ...outcome }); + } + } + return results; + }, + + async cancelRun(runId, reason = 'cancelled by request') { + return db.transaction(async (tx) => { + const run = await tx.get('SELECT id, state FROM runs WHERE id = {id}', { id: runId }); + if (!run) return { ok: false, reason: 'unknown run' }; + if (run.state !== 'running') return { ok: false, reason: `run is ${run.state}` }; + + const now = Date.now(); + await tx.run( + `UPDATE jobs SET state = 'cancelled', error = {reason}, finished_at = {now} + WHERE run_id = {run} AND state IN ('queued', 'running')`, + { run: runId, reason, now } + ); + await tx.run( + "UPDATE runs SET state = 'cancelled', finished_at = {now} WHERE id = {run}", + { run: runId, now } + ); + return { ok: true }; + }); + }, + + // Called once a job finishes so the live log stops being the spool copy. + async finalizeLog(runId, jobId) { + const key = storageKeys.log(runId, jobId); + const { size } = await logs.finalize(runId, jobId, storage, key); + if (size > 0) { + await db.run('UPDATE jobs SET log_key = {key}, log_size = {size} WHERE id = {id}', + { id: jobId, key, size }); + } + return { key, size }; + }, + + TERMINAL, + }; + + async function skipDependents(tx, runId, fromJobIds, now) { + const graph = await loadGraph(tx, runId); + const reachable = transitiveDependents( + graph.map((j) => ({ name: j.id, needs: j.needs })), + fromJobIds + ); + if (reachable.size === 0) return []; + + const queued = new Set(graph.filter((j) => j.state === 'queued').map((j) => j.id)); + const toSkip = [...reachable].filter((id) => queued.has(id)); + + for (const id of toSkip) { + await tx.run( + `UPDATE jobs SET state = 'skipped', error = {error}, finished_at = {now} + WHERE id = {id} AND state = 'queued'`, + { id, error: 'skipped because a dependency did not succeed', now } + ); + } + return toSkip; + } +} diff --git a/src/lib/config.js b/src/lib/config.js @@ -185,11 +185,13 @@ export function parseKey(value, label) { function validate(cfg) { const errors = []; - if (!Number.isInteger(cfg.server.port) || cfg.server.port < 1 || cfg.server.port > 65535) { - errors.push(`server.port must be between 1 and 65535, got ${cfg.server.port}`); - } - if (!Number.isInteger(cfg.read_api.port) || cfg.read_api.port < 1 || cfg.read_api.port > 65535) { - errors.push(`read_api.port must be between 1 and 65535, got ${cfg.read_api.port}`); + // Port 0 asks the operating system for a free port, which is useful in + // tests and for ephemeral instances. + for (const key of ['server.port', 'read_api.port']) { + const port = getPath(cfg, key); + if (!Number.isInteger(port) || port < 0 || port > 65535) { + errors.push(`${key} must be between 0 and 65535, got ${port}`); + } } if (cfg.database.url && dialectFromUrl(cfg.database.url) === null) { errors.push( diff --git a/src/lib/db/migrate-cli.js b/src/lib/db/migrate-cli.js @@ -0,0 +1,25 @@ +// src/lib/db/migrate-cli.js - apply pending migrations +// +// Usage: npm run migrate +// +// The conductor also migrates on startup. This exists for deployments that +// want the schema change to be a separate, reviewable step. + +import { loadConfig, ensureStateDirs } from '../config.js'; +import { openDatabase } from './index.js'; +import { runMigrations } from './migrate.js'; + +const cfg = loadConfig(); +ensureStateDirs(cfg); + +const db = await openDatabase(cfg); +try { + console.log(`[migrate] dialect ${db.dialect}`); + const result = await runMigrations(db); + if (result.applied.length === 0) console.log('[migrate] nothing to do'); +} catch (e) { + console.error(`[migrate] ${e.message}`); + process.exitCode = 1; +} finally { + await db.close(); +} diff --git a/src/lib/db/query.js b/src/lib/db/query.js @@ -156,6 +156,26 @@ export function compileCached(sql, dialect) { return entry; } +// Builds the body of an IN (...) clause with generated marker names, since +// the number of values is only known at runtime. +// +// const arch = inClause('arch', ['x86_64', 'aarch64']); +// db.all(`... WHERE arch IN (${arch.sql})`, { ...arch.params }); +// +// Returns a clause that matches nothing when the list is empty, which is +// the safe reading of "none of these". +export function inClause(prefix, values) { + if (!Array.isArray(values) || values.length === 0) { + return { sql: 'NULL', params: {}, empty: true }; + } + const params = {}; + const markers = values.map((value, i) => { + params[`${prefix}${i}`] = value; + return `{${prefix}${i}}`; + }); + return { sql: markers.join(', '), params, empty: false }; +} + function resolve(params, key) { // An exact property wins, so a pre-flattened object works unchanged. if (params != null && Object.hasOwn(params, key)) { diff --git a/src/lib/git.js b/src/lib/git.js @@ -0,0 +1,212 @@ +// src/lib/git.js - repository mirrors +// +// The conductor keeps one bare mirror per project. Everything it needs from +// a push is read out of that mirror: the pipeline file at the pushed commit, +// the commit subject, and the source tarball handed to workers. +// +// Serving the source from here rather than having workers clone means an +// untrusted worker needs no repository credentials and cannot reach any ref +// other than the commit it was given work for. It also means the worker host +// does not need git installed. +// +// Every value interpolated into a git invocation is validated first. Refs +// and object ids arrive from webhooks, and a value beginning with a hyphen +// would otherwise be read as an option. + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { execFile, spawn } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +export const SHA_PATTERN = /^[0-9a-f]{40}$/; +export const SHORT_SHA_PATTERN = /^[0-9a-f]{7,40}$/; + +// Refuses credential prompts, so a misconfigured private repository fails +// promptly instead of hanging until the request times out. +const GIT_ENV = { + ...process.env, + GIT_TERMINAL_PROMPT: '0', + GIT_ASKPASS: '', + GCM_INTERACTIVE: 'never', + LC_ALL: 'C', +}; + +export class GitError extends Error { + constructor(message, { stderr, code } = {}) { + super(stderr ? `${message}: ${String(stderr).trim().split('\n').slice(0, 3).join('; ')}` : message); + this.name = 'GitError'; + this.stderr = stderr; + this.exitCode = code; + } +} + +export function assertSha(sha, label = 'commit') { + if (typeof sha !== 'string' || !SHA_PATTERN.test(sha)) { + throw new GitError(`${label} must be a full 40 character hex object id, got ${JSON.stringify(sha)}`); + } + return sha; +} + +// Refs come straight from a push hook. Reject anything git itself would +// consider malformed, along with leading hyphens. +export function assertRef(ref) { + if (typeof ref !== 'string' || ref.length === 0 || ref.length > 255) { + throw new GitError(`invalid ref: ${JSON.stringify(ref)}`); + } + if (ref.startsWith('-') || ref.includes('..') || /[\s~^:?*[\\]/.test(ref) || ref.endsWith('.lock')) { + throw new GitError(`invalid ref: ${JSON.stringify(ref)}`); + } + return ref; +} + +// A path inside the repository, used for the pipeline file. +export function assertRepoPath(p) { + if (typeof p !== 'string' || p.length === 0 || p.length > 512) { + throw new GitError(`invalid repository path: ${JSON.stringify(p)}`); + } + if (p.startsWith('/') || p.startsWith('-') || p.split('/').some((s) => s === '' || s === '.' || s === '..')) { + throw new GitError(`invalid repository path: ${JSON.stringify(p)}`); + } + return p; +} + +// Serializes work per mirror, so two pushes to one project cannot run +// concurrent fetches into the same directory. +const locks = new Map(); + +async function withLock(key, fn) { + const previous = locks.get(key) ?? Promise.resolve(); + let release; + const current = new Promise((resolve) => { release = resolve; }); + locks.set(key, previous.then(() => current)); + await previous; + try { + return await fn(); + } finally { + release(); + if (locks.get(key) === current) locks.delete(key); + } +} + +export function createGit(cfg) { + const timeout = cfg.git.timeout * 1000; + const fetchInterval = cfg.git.fetch_interval * 1000; + const lastFetch = new Map(); + + function mirrorPath(projectId) { + return path.join(cfg.git.mirror_path, `${projectId}.git`); + } + + async function run(args, options = {}) { + try { + const { stdout, stderr } = await execFileAsync('git', args, { + env: GIT_ENV, + timeout, + maxBuffer: options.maxBuffer ?? 16 * 1024 * 1024, + encoding: options.encoding ?? 'utf8', + }); + return { stdout, stderr }; + } catch (e) { + throw new GitError(`git ${args[0]} failed`, { stderr: e.stderr || e.message, code: e.code }); + } + } + + async function exists(dir) { + try { + await fs.stat(path.join(dir, 'HEAD')); + return true; + } catch { + return false; + } + } + + return { + mirrorPath, + + // Clones on first use, then fetches at most once per fetch_interval + // unless force is set. A trigger always forces, since it needs the + // commit that was just pushed. + async sync(project, { force = false } = {}) { + const dir = mirrorPath(project.id); + return withLock(dir, async () => { + if (!(await exists(dir))) { + await fs.mkdir(path.dirname(dir), { recursive: true }); + await run(['clone', '--mirror', '--quiet', '--', project.repo_url, dir]); + lastFetch.set(dir, Date.now()); + return dir; + } + + const since = Date.now() - (lastFetch.get(dir) ?? 0); + if (!force && since < fetchInterval) return dir; + + await run(['--git-dir', dir, 'fetch', '--prune', '--quiet', 'origin']); + lastFetch.set(dir, Date.now()); + return dir; + }); + }, + + async hasCommit(project, sha) { + assertSha(sha); + try { + const { stdout } = await run(['--git-dir', mirrorPath(project.id), 'cat-file', '-t', sha]); + return stdout.trim() === 'commit'; + } catch { + return false; + } + }, + + async resolve(project, rev) { + assertRef(rev); + const { stdout } = await run(['--git-dir', mirrorPath(project.id), 'rev-parse', '--verify', `${rev}^{commit}`]); + return stdout.trim(); + }, + + // Reads one file at a commit. Returns null when the path is absent, + // which is how a repository without a pipeline is detected. + async readFile(project, sha, filePath) { + assertSha(sha); + assertRepoPath(filePath); + try { + const { stdout } = await run( + ['--git-dir', mirrorPath(project.id), 'cat-file', 'blob', `${sha}:${filePath}`], + { maxBuffer: 4 * 1024 * 1024 } + ); + return stdout; + } catch (e) { + if (/does not exist|not a valid object name|Not a valid object/i.test(e.stderr ?? '')) return null; + throw e; + } + }, + + async commitInfo(project, sha) { + assertSha(sha); + // A unit separator keeps the fields unambiguous when a subject + // contains anything at all. + const { stdout } = await run([ + '--git-dir', mirrorPath(project.id), + 'show', '--no-patch', '--format=%s%x1f%an%x1f%ae%x1f%aI', sha, + ]); + const [subject = '', authorName = '', authorEmail = '', authoredAt = ''] = stdout.trim().split('\x1f'); + return { subject, authorName, authorEmail, authoredAt }; + }, + + // A gzipped tar of the tree at a commit, streamed rather than buffered. + // The caller pipes this straight to the worker. + archiveStream(project, sha, { prefix = '' } = {}) { + assertSha(sha); + const args = ['--git-dir', mirrorPath(project.id), 'archive', '--format=tar.gz']; + if (prefix) args.push(`--prefix=${prefix}`); + args.push(sha); + + const child = spawn('git', args, { env: GIT_ENV, stdio: ['ignore', 'pipe', 'pipe'] }); + let stderr = ''; + child.stderr.on('data', (chunk) => { stderr += chunk.toString().slice(0, 4096); }); + child.on('close', (code) => { + if (code !== 0) child.stdout.destroy(new GitError('git archive failed', { stderr, code })); + }); + return child.stdout; + }, + }; +} diff --git a/src/lib/log.js b/src/lib/log.js @@ -0,0 +1,163 @@ +// src/lib/log.js - job log spool +// +// Logs are written to local disk while a job runs, then copied to object +// storage when it finishes. The spool is always local even when S3 is +// configured, because a running job produces many small appends and a live +// tail wants cheap random reads; neither suits an object store. +// +// Appends carry the offset the worker believes it is writing at, which makes +// a retry after a dropped connection safe: an overlapping chunk is trimmed +// and a gap is refused so the worker can resend from the right place. + +import fs from 'node:fs/promises'; +import path from 'node:path'; + +export class LogOffsetError extends Error { + constructor(message, expected) { + super(message); + this.name = 'LogOffsetError'; + this.expected = expected; + } +} + +const TRUNCATION_NOTICE = '\n[conductor] log truncated: size limit reached\n'; + +export function createLogStore(cfg) { + const root = cfg.log.spool_path; + const maxSize = cfg.log.max_size; + + // One writer per job, so a retried append cannot interleave with the + // original. + const locks = new Map(); + + async function withLock(key, fn) { + const previous = locks.get(key) ?? Promise.resolve(); + let release; + const current = new Promise((resolve) => { release = resolve; }); + locks.set(key, previous.then(() => current)); + await previous; + try { + return await fn(); + } finally { + release(); + if (locks.get(key) === current) locks.delete(key); + } + } + + // Job ids contain a colon, which is fine on every filesystem we target, + // but the run id still gives a directory per run to keep listings sane. + function spoolPath(runId, jobId) { + return path.join(root, encodeURIComponent(runId), `${encodeURIComponent(jobId)}.log`); + } + + async function currentSize(file) { + try { + return (await fs.stat(file)).size; + } catch (e) { + if (e.code === 'ENOENT') return 0; + throw e; + } + } + + return { + spoolPath, + + async size(runId, jobId) { + return currentSize(spoolPath(runId, jobId)); + }, + + // Returns the new total size. When offset is omitted the chunk is simply + // appended, which is what a worker that never retries will do. + async append(runId, jobId, chunk, offset) { + const file = spoolPath(runId, jobId); + const key = `${runId}/${jobId}`; + const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8'); + + return withLock(key, async () => { + const size = await currentSize(file); + + let payload = data; + if (offset !== undefined && offset !== null) { + if (offset > size) { + throw new LogOffsetError( + `log append starts past the end of the log: got offset ${offset}, have ${size}`, + size + ); + } + if (offset < size) { + // The worker is resending something already stored. Keep only + // the part beyond what we have. + const overlap = size - offset; + if (overlap >= payload.length) return { size, written: 0 }; + payload = payload.subarray(overlap); + } + } + + if (size >= maxSize) return { size, written: 0, truncated: true }; + + let truncated = false; + if (size + payload.length > maxSize) { + payload = Buffer.concat([ + payload.subarray(0, Math.max(0, maxSize - size)), + Buffer.from(TRUNCATION_NOTICE, 'utf8'), + ]); + truncated = true; + } + + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.appendFile(file, payload); + return { size: size + payload.length, written: payload.length, truncated }; + }); + }, + + // Reads a window for the live tail. Returns the total size too, so the + // caller knows whether more is already available. + async read(runId, jobId, { offset = 0, limit = 256 * 1024 } = {}) { + const file = spoolPath(runId, jobId); + const size = await currentSize(file); + if (size === 0 || offset >= size) { + return { data: Buffer.alloc(0), offset: Math.min(offset, size), size }; + } + + const start = Math.max(0, offset); + const length = Math.min(limit, size - start); + const handle = await fs.open(file, 'r'); + try { + const buffer = Buffer.alloc(length); + const { bytesRead } = await handle.read(buffer, 0, length, start); + return { data: buffer.subarray(0, bytesRead), offset: start, size }; + } finally { + await handle.close(); + } + }, + + // Copies the finished log into object storage and drops the spool copy. + // Storage is the durable home; the spool only exists to serve a tail. + async finalize(runId, jobId, storage, storageKey) { + const file = spoolPath(runId, jobId); + const size = await currentSize(file); + if (size === 0) return { key: null, size: 0 }; + + const handle = await fs.open(file, 'r'); + try { + await storage.put(storageKey, handle.createReadStream(), { + size, + contentType: 'text/plain; charset=utf-8', + }); + } finally { + await handle.close(); + } + + await fs.rm(file, { force: true }); + return { key: storageKey, size }; + }, + + async remove(runId, jobId) { + await fs.rm(spoolPath(runId, jobId), { force: true }); + }, + + async removeRun(runId) { + await fs.rm(path.join(root, encodeURIComponent(runId)), { recursive: true, force: true }); + }, + }; +} diff --git a/src/lib/pipeline/schema.js b/src/lib/pipeline/schema.js @@ -139,7 +139,8 @@ export function asEnvMap(problems, path, value) { export const MAX_DURATION = 7 * 24 * 60 * 60; -// Accepts 90, '90s', '30m', '1h', or a composite such as '1h30m'. +// Accepts 90, '90s', '30m', '1h', '30d', '2w', or a composite such as +// '1h30m'. A bare number is seconds. export function asDuration(problems, path, value, { max = MAX_DURATION } = {}) { let seconds; @@ -151,12 +152,15 @@ export function asDuration(problems, path, value, { max = MAX_DURATION } = {}) { if (/^\d+$/.test(text)) { seconds = parseInt(text, 10); } else { - const matches = [...text.matchAll(/(\d+)([smh])/g)]; + const matches = [...text.matchAll(/(\d+)([smhdw])/g)]; const consumed = matches.reduce((n, m) => n + m[0].length, 0); if (matches.length === 0 || consumed !== text.length) { - return problems.add(path, `expected a duration such as 90s, 30m or 1h30m, got ${JSON.stringify(value)}`); + return problems.add( + path, + `expected a duration such as 90s, 30m, 1h30m or 30d, got ${JSON.stringify(value)}` + ); } - const unit = { s: 1, m: 60, h: 3600 }; + const unit = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 }; seconds = matches.reduce((n, m) => n + parseInt(m[1], 10) * unit[m[2]], 0); } } else { diff --git a/src/lib/projects.js b/src/lib/projects.js @@ -0,0 +1,100 @@ +// src/lib/projects.js - project records +// +// A project pairs a repository with the settings needed to turn a push into +// a run. The trigger secret is the only sensitive field, and it has to be +// recoverable rather than hashed because HMAC verification needs the +// original value, so it is sealed with the secret box. + +import { newProjectId, slugify } from './ids.js'; + +export const SOURCE_MODES = ['archive', 'clone']; + +const COLUMNS = ` + id, name, repo_url, default_branch, config_path, source_mode, + trigger_secret, enabled, run_counter, created_at, updated_at +`; + +export function createProjects({ db, secrets }) { + function aad(projectId) { + return `project:${projectId}/trigger_secret`; + } + + return { + async get(id) { + return db.get(`SELECT ${COLUMNS} FROM projects WHERE id = {id}`, { id }); + }, + + async list() { + return db.all(`SELECT ${COLUMNS} FROM projects ORDER BY name`); + }, + + async create(input) { + const id = input.id ? slugify(input.id) : (slugify(input.name) || newProjectId()); + const now = Date.now(); + + if (input.source_mode && !SOURCE_MODES.includes(input.source_mode)) { + throw new Error(`source_mode must be one of ${SOURCE_MODES.join(', ')}`); + } + if (!input.repo_url) throw new Error('repo_url is required'); + + const existing = await this.get(id); + if (existing) throw new Error(`project ${id} already exists`); + + await db.run( + `INSERT INTO projects + (id, name, repo_url, default_branch, config_path, source_mode, + trigger_secret, enabled, run_counter, created_at, updated_at) + VALUES + ({id}, {name}, {repo_url}, {branch}, {config_path}, {source_mode}, + {secret}, {enabled}, 0, {now}, {now})`, + { + id, + name: input.name || id, + repo_url: input.repo_url, + branch: input.default_branch || 'main', + config_path: input.config_path || '.conductor.yml', + source_mode: input.source_mode || 'archive', + secret: input.trigger_secret ? secrets.seal(input.trigger_secret, aad(id)) : null, + enabled: input.enabled === undefined ? 1 : (input.enabled ? 1 : 0), + now, + } + ); + + return this.get(id); + }, + + // Returns the plaintext trigger secret, or null when the project does + // not require signed triggers. + triggerSecret(project) { + if (!project.trigger_secret) return null; + return secrets.open(project.trigger_secret, aad(project.id)); + }, + + async setTriggerSecret(id, value) { + await db.run( + 'UPDATE projects SET trigger_secret = {secret}, updated_at = {now} WHERE id = {id}', + { id, secret: value === null ? null : secrets.seal(value, aad(id)), now: Date.now() } + ); + }, + + async setEnabled(id, enabled) { + await db.run( + 'UPDATE projects SET enabled = {enabled}, updated_at = {now} WHERE id = {id}', + { id, enabled: enabled ? 1 : 0, now: Date.now() } + ); + }, + + async remove(id) { + await db.run('DELETE FROM projects WHERE id = {id}', { id }); + }, + + // Allocates the next run number. Must run inside the same transaction + // as the run insert, or two concurrent pushes can collide on the + // unique (project_id, number) index. + async nextRunNumber(tx, projectId) { + await tx.run('UPDATE projects SET run_counter = run_counter + 1 WHERE id = {id}', { id: projectId }); + const row = await tx.get('SELECT run_counter FROM projects WHERE id = {id}', { id: projectId }); + return row.run_counter; + }, + }; +} diff --git a/src/lib/stream.js b/src/lib/stream.js @@ -0,0 +1,27 @@ +// src/lib/stream.js - stream helpers +// +// Note for anyone tempted to hash with a 'data' listener on a PassThrough: +// attaching one switches the stream into flowing mode immediately, so the +// bytes are gone before the real consumer attaches and the upload silently +// stores nothing. A Transform stays paused until something reads it, and +// keeps backpressure intact. + +import crypto from 'node:crypto'; +import { Transform } from 'node:stream'; + +export function hashingTransform(algorithm = 'sha256') { + const hash = crypto.createHash(algorithm); + let bytes = 0; + + const transform = new Transform({ + transform(chunk, encoding, callback) { + hash.update(chunk); + bytes += chunk.length; + callback(null, chunk); + }, + }); + + transform.digest = () => hash.digest('hex'); + transform.bytes = () => bytes; + return transform; +} diff --git a/src/lib/workers.js b/src/lib/workers.js @@ -0,0 +1,62 @@ +// src/lib/workers.js - worker registration tokens +// +// A worker authenticates with a bearer token. Only the SHA-256 of the token +// is stored, so the database never holds a usable credential; the plaintext +// is shown once at creation and cannot be recovered afterwards. + +import { newToken, hashToken, newWorkerTokenId } from './ids.js'; + +export function createWorkerTokens({ db }) { + return { + async create(name) { + if (!name || typeof name !== 'string') throw new Error('worker token needs a name'); + const token = newToken(); + const id = newWorkerTokenId(); + await db.run( + `INSERT INTO worker_tokens (id, name, token_hash, enabled, created_at) + VALUES ({id}, {name}, {hash}, 1, {now})`, + { id, name, hash: hashToken(token), now: Date.now() } + ); + // The only time the plaintext exists outside the worker. + return { id, name, token }; + }, + + // Returns the token record, or null. Lookup is by hash, so a timing + // difference cannot reveal anything beyond whether a hash exists. + async verify(presented, { ip = null } = {}) { + if (typeof presented !== 'string' || presented.length === 0) return null; + const hash = hashToken(presented); + const row = await db.get( + 'SELECT id, name, enabled FROM worker_tokens WHERE token_hash = {hash}', + { hash } + ); + if (!row || row.enabled !== 1) return null; + + await db.run( + 'UPDATE worker_tokens SET last_seen_at = {now}, last_ip = {ip} WHERE id = {id}', + { id: row.id, now: Date.now(), ip } + ); + return { id: row.id, name: row.name }; + }, + + async list() { + return db.all( + `SELECT id, name, enabled, created_at, last_seen_at, last_ip + FROM worker_tokens ORDER BY created_at DESC` + ); + }, + + async setEnabled(id, enabled) { + const res = await db.run( + 'UPDATE worker_tokens SET enabled = {enabled} WHERE id = {id}', + { id, enabled: enabled ? 1 : 0 } + ); + return res.changes > 0; + }, + + async remove(id) { + const res = await db.run('DELETE FROM worker_tokens WHERE id = {id}', { id }); + return res.changes > 0; + }, + }; +} diff --git a/test/conductor.test.js b/test/conductor.test.js @@ -0,0 +1,652 @@ +// test/conductor.test.js - end to end behaviour of the conductor service +// +// Drives a real server against a real git repository: trigger, dispatch, +// logs, artifacts, completion and failure handling. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { startHarness } from './helpers/harness.js'; + +// Each test gets its own harness so that ordering never matters. +async function withHarness(options, fn) { + const h = await startHarness(options); + try { + return await fn(h); + } finally { + await h.stop(); + } +} + +function jobsByName(payload) { + return Object.fromEntries(payload.jobs.map((j) => [j.name, j])); +} + +async function runState(h, runId) { + const res = await h.app.inject({ method: 'GET', url: `/api/runs/${runId}` }); + return res.json(); +} + +// Claims everything currently eligible for a worker with the given +// capabilities, keyed by job name. Tests assert on which jobs appear rather +// than on the order they arrive in, which is not part of the contract. +async function drain(h, query = {}) { + const claimed = {}; + for (let i = 0; i < 50; i += 1) { + const res = await h.poll(query); + if (res.statusCode === 204) break; + const job = res.json().job; + claimed[job.name] = job; + } + return claimed; +} + +async function claimNamed(h, name, query) { + return (await drain(h, query))[name] ?? null; +} + +async function finish(h, jobId, { success = true, exitCode = 0, error = null } = {}) { + const res = await h.app.inject({ + method: 'POST', + url: `/api/workers/jobs/${encodeURIComponent(jobId)}/done`, + headers: { ...h.auth, 'content-type': 'application/json' }, + payload: JSON.stringify({ success, exit_code: exitCode, error }), + }); + return res.json(); +} + +test('health reports the selected drivers', async () => { + await withHarness({}, async (h) => { + const res = await h.app.inject({ method: 'GET', url: '/health' }); + assert.equal(res.statusCode, 200); + assert.deepEqual(res.json(), { + ok: true, service: 'conductor', database: 'sqlite', storage: 'local', auth: 'local', + }); + }); +}); + +test('a signed trigger creates a run and expands the pipeline', async () => { + await withHarness({}, async (h) => { + const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main', actor: 'alice' }); + assert.equal(res.statusCode, 200); + + const body = res.json(); + assert.equal(body.status, 'created'); + // lint + 2 build + 2 package + publish + assert.equal(body.jobs, 6); + + const detail = await runState(h, body.run_id); + assert.equal(detail.run.state, 'running'); + assert.equal(detail.run.actor, 'alice'); + assert.equal(detail.run.title, 'initial commit'); + assert.equal(detail.run.number, 1); + + const jobs = jobsByName(detail); + assert.deepEqual(jobs['package:arch=x86_64,pkg=musl'].needs, [`${body.run_id}:build:arch=x86_64`]); + assert.equal(jobs.publish.needs.length, 3); + }); +}); + +test('an unsigned or wrongly signed trigger is refused', async () => { + await withHarness({}, async (h) => { + const unsigned = await h.app.inject({ + method: 'POST', + url: '/api/trigger/demo', + headers: { 'content-type': 'application/json' }, + payload: JSON.stringify({ sha: h.sha }), + }); + assert.equal(unsigned.statusCode, 401); + + const wrong = await h.trigger({ sha: h.sha }, { secret: 'not-the-secret' }); + assert.equal(wrong.statusCode, 401); + }); +}); + +test('github and gitlab push payloads are understood', async () => { + await withHarness({}, async (h) => { + const github = await h.trigger({ + after: h.sha, + before: '0'.repeat(40), + ref: 'refs/heads/main', + pusher: { name: 'octocat' }, + }); + assert.equal(github.statusCode, 200); + assert.equal(github.json().status, 'created'); + + const gitlab = await h.trigger({ + checkout_sha: h.sha, + ref: 'refs/heads/main', + user_username: 'gl-user', + }); + assert.equal(gitlab.statusCode, 200); + }); +}); + +test('a branch deletion is ignored rather than failing', async () => { + await withHarness({}, async (h) => { + const res = await h.trigger({ sha: '0'.repeat(40), ref: 'refs/heads/gone' }); + assert.equal(res.statusCode, 200); + assert.equal(res.json().status, 'ignored'); + }); +}); + +test('a broken pipeline is reported as a client error with detail', async () => { + await withHarness({ pipeline: 'version: 1\njobs:\n a:\n script: [x]\n' }, async (h) => { + const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + assert.equal(res.statusCode, 422); + const body = res.json(); + assert.equal(body.error, 'invalid pipeline'); + assert.ok(body.problems.some((p) => p.path === 'jobs.a.image')); + }); +}); + +test('a missing pipeline file is reported clearly', async () => { + await withHarness({ pipeline: null }, async (h) => { + const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + assert.equal(res.statusCode, 422); + assert.match(res.json().detail, /\.conductor\.yml not found/); + }); +}); + +test('the worker api rejects missing and invalid tokens', async () => { + await withHarness({}, async (h) => { + const none = await h.app.inject({ method: 'GET', url: '/api/workers/poll' }); + assert.equal(none.statusCode, 401); + + const bad = await h.app.inject({ + method: 'GET', url: '/api/workers/poll', headers: { authorization: 'Bearer nope' }, + }); + assert.equal(bad.statusCode, 401); + }); +}); + +test('independent jobs dispatch concurrently', async () => { + await withHarness({}, async (h) => { + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + + // The prototype could only ever hand out one job at a time. + const [a, b, c] = await Promise.all([ + h.poll({ arches: 'x86_64,aarch64' }), + h.poll({ arches: 'x86_64,aarch64' }), + h.poll({ arches: 'x86_64,aarch64' }), + ]); + + const names = [a, b, c].map((r) => r.json().job.name); + assert.equal(new Set(names).size, 3, `expected three distinct jobs, got ${names.join(', ')}`); + assert.deepEqual(names.slice().sort(), ['build:arch=aarch64', 'build:arch=x86_64', 'lint']); + }); +}); + +test('a job is only offered to a worker that satisfies its requires', async () => { + await withHarness({}, async (h) => { + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + + // Clear the three root jobs so only package and publish remain. + const roots = await drain(h, { arches: 'x86_64,aarch64' }); + assert.deepEqual(Object.keys(roots).sort(), ['build:arch=aarch64', 'build:arch=x86_64', 'lint']); + for (const job of Object.values(roots)) await finish(h, job.id, { success: true }); + + // package requires sign-key, which this worker does not advertise. + const without = await h.poll({ arches: 'x86_64,aarch64' }); + assert.equal(without.statusCode, 204); + + const with_ = await h.poll({ arches: 'x86_64,aarch64', features: 'sign-key' }); + assert.equal(with_.statusCode, 200); + assert.match(with_.json().job.name, /^package:/); + }); +}); + +test('a worker is not offered work for an architecture it cannot build', async () => { + await withHarness({}, async (h) => { + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + + const claimed = []; + for (let i = 0; i < 3; i += 1) { + const res = await h.poll({ arches: 'aarch64' }); + if (res.statusCode === 204) break; + claimed.push(res.json().job); + } + + // lint has no arch so it is eligible; the x86_64 build is not. + assert.ok(!claimed.some((j) => j.arch === 'x86_64')); + assert.ok(claimed.some((j) => j.name === 'build:arch=aarch64')); + }); +}); + +test('a claimed job carries everything the worker needs', async () => { + await withHarness({}, async (h) => { + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + const job = await claimNamed(h, 'build:arch=x86_64', { arches: 'x86_64' }); + + assert.equal(job.image, 'debian:bookworm-slim'); + assert.deepEqual(job.script, ['./build.sh $ARCH']); + assert.equal(job.env.ARCH, 'x86_64'); + assert.equal(job.sha, h.sha); + assert.equal(job.source.mode, 'archive'); + assert.match(job.source.url, /^http:\/\/conductor\.test\/api\/workers\/jobs\//); + assert.ok(job.endpoints.log.endsWith('/log')); + }); +}); + +test('the source tarball is served from the mirror', async () => { + await withHarness({}, async (h) => { + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + const job = await claimNamed(h, 'lint', {}); + + const res = await h.app.inject({ + method: 'GET', + url: `/api/workers/jobs/${encodeURIComponent(job.id)}/source.tar.gz`, + headers: h.auth, + }); + assert.equal(res.statusCode, 200); + assert.equal(res.headers['content-type'], 'application/gzip'); + // gzip magic number + assert.equal(res.rawPayload[0], 0x1f); + assert.equal(res.rawPayload[1], 0x8b); + }); +}); + +test('a worker cannot touch a job it does not hold', async () => { + await withHarness({}, async (h) => { + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + const job = await claimNamed(h, 'lint', {}); + + const other = await h.services.workerTokens.create('intruder'); + const res = await h.app.inject({ + method: 'POST', + url: `/api/workers/jobs/${encodeURIComponent(job.id)}/log`, + headers: { authorization: `Bearer ${other.token}`, 'content-type': 'application/octet-stream' }, + payload: Buffer.from('malicious'), + }); + assert.equal(res.statusCode, 403); + }); +}); + +test('log appends are resumable and deduplicated by offset', async () => { + await withHarness({}, async (h) => { + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + const job = await claimNamed(h, 'lint', {}); + const url = `/api/workers/jobs/${encodeURIComponent(job.id)}/log`; + const headers = { ...h.auth, 'content-type': 'application/octet-stream' }; + + const first = await h.app.inject({ + method: 'POST', url, headers: { ...headers, 'x-log-offset': '0' }, payload: Buffer.from('hello\n'), + }); + assert.deepEqual(first.json(), { size: 6, written: 6, truncated: false }); + + // A retry resends what was already stored plus new data. + const retry = await h.app.inject({ + method: 'POST', url, headers: { ...headers, 'x-log-offset': '0' }, payload: Buffer.from('hello\nworld\n'), + }); + assert.deepEqual(retry.json(), { size: 12, written: 6, truncated: false }); + + // A gap is refused, and says where to resume. + const gap = await h.app.inject({ + method: 'POST', url, headers: { ...headers, 'x-log-offset': '9999' }, payload: Buffer.from('x'), + }); + assert.equal(gap.statusCode, 409); + assert.equal(gap.json().expected_offset, 12); + + const tail = await h.app.inject({ method: 'GET', url: `/api/jobs/${encodeURIComponent(job.id)}/log` }); + assert.equal(tail.body, 'hello\nworld\n'); + assert.equal(tail.headers['x-log-size'], '12'); + assert.equal(tail.headers['x-log-complete'], 'false'); + + const partial = await h.app.inject({ + method: 'GET', url: `/api/jobs/${encodeURIComponent(job.id)}/log?offset=6`, + }); + assert.equal(partial.body, 'world\n'); + }); +}); + +test('a finished log moves to storage and is still readable', async () => { + await withHarness({}, async (h) => { + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + const job = await claimNamed(h, 'lint', {}); + + await h.app.inject({ + method: 'POST', + url: `/api/workers/jobs/${encodeURIComponent(job.id)}/log`, + headers: { ...h.auth, 'content-type': 'application/octet-stream' }, + payload: Buffer.from('compiling\ndone\n'), + }); + await finish(h, job.id, { success: true }); + + const tail = await h.app.inject({ method: 'GET', url: `/api/jobs/${encodeURIComponent(job.id)}/log` }); + assert.equal(tail.statusCode, 200); + assert.equal(tail.body, 'compiling\ndone\n'); + assert.equal(tail.headers['x-log-complete'], 'true'); + }); +}); + +test('artifacts are stored, hashed, and stripped of traversal', async () => { + await withHarness({}, async (h) => { + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + const job = await claimNamed(h, 'build:arch=x86_64', { arches: 'x86_64' }); + + const upload = await h.app.inject({ + method: 'POST', + url: `/api/workers/jobs/${encodeURIComponent(job.id)}/artifact`, + headers: { + ...h.auth, + 'content-type': 'application/octet-stream', + 'x-artifact-path': '../../../etc/passwd', + }, + payload: Buffer.from('artifact bytes'), + }); + assert.equal(upload.statusCode, 200); + + const body = upload.json(); + assert.equal(body.path, 'etc/passwd', 'traversal should be stripped, not honoured'); + assert.equal(body.size, 14); + assert.match(body.sha256, /^[0-9a-f]{64}$/); + + const detail = await h.app.inject({ method: 'GET', url: `/api/jobs/${encodeURIComponent(job.id)}` }); + const artifacts = detail.json().artifacts; + assert.equal(artifacts.length, 1); + + const download = await h.app.inject({ method: 'GET', url: `/api/artifacts/${artifacts[0].id}` }); + assert.equal(download.statusCode, 200); + assert.equal(download.body, 'artifact bytes'); + }); +}); + +test('an artifact shorter than its content-length is rejected', async () => { + await withHarness({}, async (h) => { + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + const job = await claimNamed(h, 'lint', {}); + + const res = await h.app.inject({ + method: 'POST', + url: `/api/workers/jobs/${encodeURIComponent(job.id)}/artifact`, + headers: { + ...h.auth, + 'content-type': 'application/octet-stream', + 'content-length': '500', + 'x-artifact-path': 'short.bin', + }, + payload: Buffer.from('tiny'), + }); + assert.equal(res.statusCode, 400); + }); +}); + +test('success releases dependents, matched on shared dimensions', async () => { + await withHarness({}, async (h) => { + const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; + + const roots = await drain(h, { arches: 'x86_64,aarch64', features: 'sign-key' }); + await finish(h, roots['build:arch=x86_64'].id, { success: true }); + + // Only the x86_64 package becomes eligible; the aarch64 one still waits + // on its own architecture's build. + const next = await drain(h, { arches: 'x86_64,aarch64', features: 'sign-key' }); + assert.ok(next['package:arch=x86_64,pkg=musl'], 'x86_64 package should be released'); + assert.ok(!next['package:arch=aarch64,pkg=musl'], 'aarch64 package should still be waiting'); + + const state = jobsByName(await runState(h, run)); + assert.equal(state['package:arch=aarch64,pkg=musl'].state, 'queued'); + }); +}); + +test('failure skips transitive dependents, not just direct ones', async () => { + await withHarness({}, async (h) => { + const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; + + const build = await claimNamed(h, 'build:arch=x86_64', { arches: 'x86_64' }); + const outcome = await finish(h, build.id, { success: false, exitCode: 2, error: 'compile failed' }); + assert.equal(outcome.state, 'failed'); + + const jobs = jobsByName(await runState(h, run)); + // Direct dependent. + assert.equal(jobs['package:arch=x86_64,pkg=musl'].state, 'skipped'); + // Transitive dependent: the prototype left this queued and dispatchable. + assert.equal(jobs.publish.state, 'skipped'); + // Unrelated branches are untouched. + assert.equal(jobs['build:arch=aarch64'].state, 'queued'); + assert.equal(jobs['package:arch=aarch64,pkg=musl'].state, 'queued'); + }); +}); + +test('a run settles as failed once every job is terminal', async () => { + await withHarness({}, async (h) => { + const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; + + const roots = await drain(h, { arches: 'x86_64,aarch64', features: 'sign-key' }); + + // Failing the x86_64 build skips its package and, transitively, publish. + await finish(h, roots['build:arch=x86_64'].id, { success: false, exitCode: 1 }); + await finish(h, roots['build:arch=aarch64'].id, { success: true }); + await finish(h, roots.lint.id, { success: true }); + + const released = await drain(h, { arches: 'aarch64', features: 'sign-key' }); + const last = await finish(h, released['package:arch=aarch64,pkg=musl'].id, { success: true }); + + assert.equal(last.run_state, 'failed'); + assert.equal((await runState(h, run)).run.state, 'failed'); + }); +}); + +test('a run settles as success when everything passes', async () => { + // Quoted, because an unquoted true in YAML is a boolean, not a command. + const pipeline = ` +version: 1 +jobs: + a: + image: alpine + script: ['true'] + b: + image: alpine + script: ['true'] + needs: [a] +`; + await withHarness({ pipeline }, async (h) => { + const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; + + const a = await claimNamed(h, 'a', {}); + await finish(h, a.id, { success: true }); + const b = await claimNamed(h, 'b', {}); + const last = await finish(h, b.id, { success: true }); + + assert.equal(last.run_state, 'success'); + assert.equal((await runState(h, run)).run.state, 'success'); + }); +}); + +test('an allowed failure does not fail the run or block dependents', async () => { + const pipeline = ` +version: 1 +jobs: + flaky: + image: alpine + script: ['false'] + allow_failure: true + after: + image: alpine + script: ['true'] + needs: [flaky] +`; + await withHarness({ pipeline }, async (h) => { + const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; + + const flaky = await claimNamed(h, 'flaky', {}); + const outcome = await finish(h, flaky.id, { success: false, exitCode: 1 }); + assert.deepEqual(outcome.skipped, []); + + const after = await claimNamed(h, 'after', {}); + assert.ok(after, 'dependent should still run after an allowed failure'); + const last = await finish(h, after.id, { success: true }); + assert.equal(last.run_state, 'success'); + assert.equal((await runState(h, run)).run.state, 'success'); + }); +}); + +test('a job with retries left is requeued instead of failing the run', async () => { + const pipeline = ` +version: 1 +jobs: + retried: + image: alpine + script: [maybe] + max_attempts: 2 +`; + await withHarness({ pipeline }, async (h) => { + const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; + + const first = await claimNamed(h, 'retried', {}); + const outcome = await finish(h, first.id, { success: false, exitCode: 1 }); + assert.equal(outcome.state, 'queued'); + assert.equal(outcome.retry, true); + + const second = await claimNamed(h, 'retried', {}); + assert.equal(second.attempt, 2); + const last = await finish(h, second.id, { success: false, exitCode: 1 }); + assert.equal(last.state, 'failed'); + assert.equal((await runState(h, run)).run.state, 'failed'); + }); +}); + +test('heartbeats keep a job alive and report cancellation', async () => { + await withHarness({}, async (h) => { + const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; + const job = await claimNamed(h, 'lint', {}); + + const beat = await h.app.inject({ + method: 'POST', + url: `/api/workers/jobs/${encodeURIComponent(job.id)}/heartbeat`, + headers: { ...h.auth, 'content-type': 'application/json' }, + payload: '{}', + }); + assert.deepEqual(beat.json(), { cancelled: false }); + + await h.services.scheduler.cancelRun(run); + + const after = await h.app.inject({ + method: 'POST', + url: `/api/workers/jobs/${encodeURIComponent(job.id)}/heartbeat`, + headers: { ...h.auth, 'content-type': 'application/json' }, + payload: '{}', + }); + assert.equal(after.json().cancelled, true); + assert.equal((await runState(h, run)).run.state, 'cancelled'); + }); +}); + +test('the reaper fails a job whose worker stopped reporting', async () => { + await withHarness({}, async (h) => { + const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; + const job = await claimNamed(h, 'lint', {}); + assert.equal(jobsByName(await runState(h, run)).lint.state, 'running'); + + // Backdate the heartbeat rather than waiting out the real timeout. + await h.services.db.run( + 'UPDATE jobs SET heartbeat_at = {then} WHERE id = {id}', + { then: Date.now() - 10 * 60 * 1000, id: job.id } + ); + + const reaped = await h.services.scheduler.reap(); + assert.ok(reaped.length > 0); + + const after = jobsByName(await runState(h, run)).lint; + assert.equal(after.state, 'failed'); + assert.match(after.error, /stopped reporting/); + + // The worker that lost the job can no longer complete it. + const late = await finish(h, job.id, { success: true }); + assert.match(String(late.error ?? ''), /not running|another worker/); + }); +}); + +test('a second push produces an independent, numbered run', async () => { + await withHarness({}, async (h) => { + const first = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json(); + const sha2 = await h.commit({ 'README.md': 'changed\n' }, 'second commit'); + const second = (await h.trigger({ sha: sha2, ref: 'refs/heads/main' })).json(); + + assert.notEqual(first.run_id, second.run_id); + const detail = await runState(h, second.run_id); + assert.equal(detail.run.number, 2); + assert.equal(detail.run.title, 'second commit'); + assert.equal(detail.run.head_sha, sha2); + }); +}); + +test('runs can be listed and filtered by project', async () => { + await withHarness({}, async (h) => { + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + + const all = await h.app.inject({ method: 'GET', url: '/api/runs' }); + assert.equal(all.json().runs.length, 1); + + const mine = await h.app.inject({ method: 'GET', url: '/api/runs?project=demo' }); + assert.equal(mine.json().runs.length, 1); + + const other = await h.app.inject({ method: 'GET', url: '/api/runs?project=absent' }); + assert.equal(other.json().runs.length, 0); + }); +}); + +test('unknown runs, jobs and artifacts return 404', async () => { + await withHarness({}, async (h) => { + for (const url of ['/api/runs/nope', '/api/jobs/nope', '/api/jobs/nope/log', '/api/artifacts/nope']) { + const res = await h.app.inject({ method: 'GET', url }); + assert.equal(res.statusCode, 404, `${url} should be 404`); + } + }); +}); + +test('a disabled project refuses triggers', async () => { + await withHarness({}, async (h) => { + await h.services.projects.setEnabled('demo', false); + const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + assert.equal(res.statusCode, 409); + }); +}); + +test('a trigger for an unknown project is a 404', async () => { + await withHarness({}, async (h) => { + const res = await h.app.inject({ + method: 'POST', + url: '/api/trigger/absent', + headers: { 'content-type': 'application/json' }, + payload: JSON.stringify({ sha: h.sha }), + }); + assert.equal(res.statusCode, 404); + }); +}); + +test('a commit that is not in the repository is refused', async () => { + await withHarness({}, async (h) => { + const res = await h.trigger({ sha: 'b'.repeat(40), ref: 'refs/heads/main' }); + assert.equal(res.statusCode, 500); + assert.match(res.json().detail, /not present in the mirror/); + }); +}); + +test('clone mode hands the repository url to the worker instead', async () => { + await withHarness({ sourceMode: 'clone' }, async (h) => { + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + const job = await claimNamed(h, 'lint', {}); + assert.equal(job.source.mode, 'clone'); + assert.equal(job.source.repo_url, h.repoDir); + }); +}); + +test('the trigger secret is not stored in the clear', async () => { + await withHarness({}, async (h) => { + const row = await h.services.db.get('SELECT trigger_secret FROM projects WHERE id = {id}', { id: 'demo' }); + assert.ok(row.trigger_secret.startsWith('v1.'), 'expected an encrypted value'); + assert.ok(!row.trigger_secret.includes('test-secret')); + assert.equal(h.services.projects.triggerSecret(await h.services.projects.get('demo')), 'test-secret'); + }); +}); + +test('worker tokens are stored only as hashes', async () => { + await withHarness({}, async (h) => { + const rows = await h.services.db.all('SELECT token_hash FROM worker_tokens'); + assert.ok(rows.length > 0); + for (const row of rows) { + assert.match(row.token_hash, /^[0-9a-f]{64}$/); + assert.notEqual(row.token_hash, h.worker.token); + } + }); +}); diff --git a/test/examples.test.js b/test/examples.test.js @@ -0,0 +1,57 @@ +// test/examples.test.js - the shipped examples must stay valid +// +// Documentation that no longer parses is worse than no documentation, so +// every example is compiled as part of the suite. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { compilePipeline } from '../src/lib/pipeline/index.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const EXAMPLES = path.join(ROOT, 'examples'); + +test('every example pipeline compiles', async () => { + const files = (await fs.readdir(EXAMPLES)).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml')); + assert.ok(files.length > 0, 'expected at least one example'); + + for (const file of files) { + const text = await fs.readFile(path.join(EXAMPLES, file), 'utf8'); + const pipeline = compilePipeline(text, { source: file }); + assert.ok(pipeline.jobs.length > 0, `${file} produced no jobs`); + } +}); + +test('the distribution example fans in per architecture', async () => { + const text = await fs.readFile(path.join(EXAMPLES, 'distro.conductor.yml'), 'utf8'); + const pipeline = compilePipeline(text, { source: 'distro.conductor.yml' }); + const jobs = Object.fromEntries(pipeline.jobs.map((j) => [j.name, j])); + + // check, two toolchains, twelve packages, two images, one publish. + assert.equal(pipeline.jobs.length, 18); + + // A package waits only on its own architecture's toolchain. + assert.deepEqual(jobs['package:arch=aarch64,pkg=musl'].needs, ['toolchain:arch=aarch64']); + assert.deepEqual(jobs['package:arch=x86_64,pkg=grub'].needs, ['toolchain:arch=x86_64']); + + // An image waits on every package for its architecture, and no others. + const imageNeeds = jobs['image:arch=x86_64'].needs; + assert.equal(imageNeeds.length, 6); + assert.ok(imageNeeds.every((n) => n.includes('arch=x86_64'))); + + // publish has no architecture, so it waits for both, plus check. + assert.equal(jobs.publish.needs.length, 3); + assert.equal(jobs.publish.depth, 3); +}); + +test('the node example wires services and features', async () => { + const text = await fs.readFile(path.join(EXAMPLES, 'node-app.conductor.yml'), 'utf8'); + const pipeline = compilePipeline(text, { source: 'node-app.conductor.yml' }); + const jobs = Object.fromEntries(pipeline.jobs.map((j) => [j.name, j])); + + assert.deepEqual(jobs['test:suite=unit'].services.map((s) => s.alias), ['postgres']); + assert.deepEqual(jobs.image.requires, ['docker']); + assert.equal(jobs['test:suite=integration'].artifacts.when, 'always'); +}); diff --git a/test/helpers/harness.js b/test/helpers/harness.js @@ -0,0 +1,164 @@ +// test/helpers/harness.js - a conductor on a temporary database +// +// Builds a real server against a real git repository and a real sqlite file, +// then drives it through fastify inject. No ports are bound and no +// background processes are started, so the tests stay deterministic. + +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { loadConfig } from '../../src/lib/config.js'; +import { createServices, buildServer } from '../../src/conductor/app.js'; + +const execFileAsync = promisify(execFile); + +export const DEFAULT_PIPELINE = ` +version: 1 +defaults: + image: debian:bookworm-slim +jobs: + lint: + script: [make lint] + build: + arch: [x86_64, aarch64] + script: ['./build.sh $ARCH'] + artifacts: + paths: [dist/**] + package: + needs: [build] + arch: [x86_64, aarch64] + matrix: + pkg: [musl] + requires: [sign-key] + script: ['./pkg.sh $MATRIX_PKG $ARCH'] + publish: + needs: [package, lint] + script: [./publish.sh] +`; + +export async function createRepo(dir, { pipeline = DEFAULT_PIPELINE, configPath = '.conductor.yml' } = {}) { + await fs.mkdir(dir, { recursive: true }); + const git = (...args) => execFileAsync('git', ['-C', dir, ...args]); + + await execFileAsync('git', ['init', '-q', '-b', 'main', dir]); + await git('config', 'user.email', 'test@example.invalid'); + await git('config', 'user.name', 'Test'); + + await fs.mkdir(path.dirname(path.join(dir, configPath)), { recursive: true }); + if (pipeline !== null) await fs.writeFile(path.join(dir, configPath), pipeline); + await fs.writeFile(path.join(dir, 'README.md'), 'test repository\n'); + + await git('add', '-A'); + await git('commit', '-q', '-m', 'initial commit'); + const { stdout } = await git('rev-parse', 'HEAD'); + return stdout.trim(); +} + +export async function startHarness(options = {}) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'conductor-it-')); + const repoDir = path.join(root, 'repo'); + const sha = await createRepo(repoDir, options); + + const configFile = path.join(root, 'conductor.yaml'); + await fs.writeFile(configFile, [ + 'server:', + ' port: 0', + ' public_url: http://conductor.test', + 'database:', + ' path: ./state/conductor.db', + 'storage:', + ' path: ./state/storage', + 'git:', + ' mirror_path: ./state/mirrors', + ' fetch_interval: 0', + 'log:', + ' spool_path: ./state/logs', + 'secrets:', + ` encryption_key: ${crypto.randomBytes(32).toString('hex')}`, + 'scheduler:', + ' heartbeat_timeout: 120', + ' reap_interval: 30', + '', + ].join('\n')); + + const savedConfig = process.env.CONDUCTOR_CONFIG; + process.env.CONDUCTOR_CONFIG = configFile; + const cfg = loadConfig(); + if (savedConfig === undefined) delete process.env.CONDUCTOR_CONFIG; + else process.env.CONDUCTOR_CONFIG = savedConfig; + + const services = await createServices(cfg, { migrationLogger: () => {}, logger: silentLogger() }); + const app = await buildServer(services, { logger: false }); + + const project = await services.projects.create({ + id: 'demo', + name: 'Demo', + repo_url: repoDir, + trigger_secret: 'test-secret', + config_path: options.configPath ?? '.conductor.yml', + source_mode: options.sourceMode ?? 'archive', + }); + + const worker = await services.workerTokens.create('test-worker'); + + return { + root, + repoDir, + sha, + cfg, + app, + services, + project, + worker, + auth: { authorization: `Bearer ${worker.token}` }, + + // Commits a change and returns the new commit id. + async commit(files, message = 'update') { + for (const [name, content] of Object.entries(files)) { + await fs.mkdir(path.dirname(path.join(repoDir, name)), { recursive: true }); + await fs.writeFile(path.join(repoDir, name), content); + } + await execFileAsync('git', ['-C', repoDir, 'add', '-A']); + await execFileAsync('git', ['-C', repoDir, 'commit', '-q', '-m', message]); + const { stdout } = await execFileAsync('git', ['-C', repoDir, 'rev-parse', 'HEAD']); + return stdout.trim(); + }, + + sign(body) { + return `sha256=${crypto.createHmac('sha256', 'test-secret').update(body).digest('hex')}`; + }, + + async trigger(payload, { secret = 'test-secret' } = {}) { + const body = JSON.stringify(payload); + const signature = `sha256=${crypto.createHmac('sha256', secret).update(body).digest('hex')}`; + return app.inject({ + method: 'POST', + url: '/api/trigger/demo', + headers: { 'content-type': 'application/json', 'x-hub-signature-256': signature }, + payload: body, + }); + }, + + async poll(query = {}) { + const search = new URLSearchParams(query).toString(); + return app.inject({ + method: 'GET', + url: `/api/workers/poll${search ? `?${search}` : ''}`, + headers: this.auth, + }); + }, + + async stop() { + await app.close(); + await services.db.close(); + await fs.rm(root, { recursive: true, force: true }); + }, + }; +} + +function silentLogger() { + return { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }; +} diff --git a/test/pipeline.test.js b/test/pipeline.test.js @@ -91,6 +91,28 @@ jobs: assert.equal(jobs.c.timeout, 5400); }); +test('durations accept day and week units', () => { + const p = compilePipeline(` +version: 1 +jobs: + a: + image: x + script: [y] + artifacts: + paths: [d] + expire: 30d + b: + image: x + script: [y] + artifacts: + paths: [d] + expire: 2w +`); + const jobs = byName(p); + assert.equal(jobs.a.artifacts.expire, 30 * 86400); + assert.equal(jobs.b.artifacts.expire, 14 * 86400); +}); + test('a malformed duration is rejected', () => { assert.deepEqual( errorPaths(() => compilePipeline('version: 1\njobs:\n a: { image: x, script: [y], timeout: soon }\n')),