conductor

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

commit 644564b89914a5854c86480ff28a206d8d99be24
parent e60515fff046bad256797d6b04d16ee15c244205
Author: finwo <finwo@pm.me>
Date:   Sat, 19 Sep 2026 14:52:22 +0200

Container images, compose manifests and deployment docs

Diffstat:
A.dockerignore | 13+++++++++++++
MREADME.md | 16++++++++++++----
Adeploy/Dockerfile | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Adeploy/Dockerfile.worker | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adeploy/docker-compose.yml | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adeploy/smoke.sh | 180+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adeploy/worker/docker-compose.yml | 46++++++++++++++++++++++++++++++++++++++++++++++
Adocs/api.md | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/deployment.md | 184+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/worker/agent.js | 21+++++++++++++++++++--
Msrc/worker/config.js | 13+++++++++++--
Mtest/ascii.test.js | 20+++++++++++++-------
Mtest/worker.test.js | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
13 files changed, 941 insertions(+), 15 deletions(-)

diff --git a/.dockerignore b/.dockerignore @@ -0,0 +1,13 @@ +# Build context: only what the images actually need. +.git/ +node_modules/ +data/ +test/ +docs/ +examples/ +deploy/ +*.md +conductor.yaml +.editorconfig +.gitignore +package-lock.json diff --git a/README.md b/README.md @@ -33,13 +33,18 @@ Under construction. Working today: - the worker: containerised jobs, services, features, caches, cleanup - accounts, OIDC, project ownership, visibility and project variables - the web interface, server rendered with htmx - -Not yet built: container images and deployment manifests. + - container images, compose manifests and a deployment smoke test Installation ------------ -Requires node 24 or newer. No native modules. +With docker: + +```sh +docker compose -f deploy/docker-compose.yml up -d conductor +``` + +From source, requiring node 24 or newer and no native modules: ```sh npm install @@ -178,7 +183,10 @@ architecture of its own, waits for all of them. 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. +See [docs/pipeline.md](docs/pipeline.md) for the full reference, +[docs/worker.md](docs/worker.md) for running a worker, +[docs/deployment.md](docs/deployment.md) for deploying, and +[docs/api.md](docs/api.md) for the HTTP API. Architecture ------------ diff --git a/deploy/Dockerfile b/deploy/Dockerfile @@ -0,0 +1,51 @@ +# deploy/Dockerfile - the conductor +# +# Build from the repository root, since the context is the whole project: +# docker build -f deploy/Dockerfile -t conductor . +# +# State lives in /data: the sqlite database, the git mirrors, the log spool +# and, unless object storage is configured, artifacts. Mount a volume there +# or none of it survives a restart. + +FROM node:24-bookworm-slim + +# git is needed for the mirrors the conductor reads pipelines and source +# archives out of. Everything else it does is in node. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates git \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Dependencies first, so editing source does not reinstall them. mysql2 and +# pg are optional and only pulled when the database calls for them; both are +# installed here so one image covers every backend. +COPY package.json ./ +RUN npm install --omit=dev --no-audit --no-fund \ + && npm cache clean --force + +COPY src/ ./src/ +COPY migrations/ ./migrations/ +COPY assets/ ./assets/ + +# Paths point into the volume rather than at the defaults, which are +# relative to the working directory. +ENV NODE_ENV=production \ + CONDUCTOR_HOST=0.0.0.0 \ + CONDUCTOR_PORT=8080 \ + CONDUCTOR_DATABASE_PATH=/data/conductor.db \ + CONDUCTOR_STORAGE_PATH=/data/storage \ + CONDUCTOR_MIRROR_PATH=/data/mirrors \ + CONDUCTOR_LOG_PATH=/data/logs + +RUN mkdir -p /data && chown -R node:node /data + +USER node +VOLUME ["/data"] +EXPOSE 8080 + +# No curl in the image, so the check is made with node itself. +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:'+(process.env.CONDUCTOR_PORT||8080)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +CMD ["node", "src/conductor/index.js"] diff --git a/deploy/Dockerfile.worker b/deploy/Dockerfile.worker @@ -0,0 +1,60 @@ +# deploy/Dockerfile.worker - the worker +# +# Build from the repository root: +# docker build -f deploy/Dockerfile.worker -t conductor-worker . +# +# The worker runs each job in its own container, so it needs a docker +# socket. It does not run a daemon of its own: the containers it starts are +# siblings on the host, not children. +# +# That has one consequence worth understanding. Paths in the -v flags the +# worker passes are resolved by the host daemon, not inside this container, +# so the workspace directory has to exist at the same path in both. The +# compose file in deploy/worker does that; if you run this by hand, mount +# the workspace at the path you configure rather than somewhere convenient. + +FROM node:24-bookworm-slim + +# Static docker CLI, rather than docker.io, which would drag in a daemon +# this image has no use for. +ARG DOCKER_CLI_VERSION=27.3.1 + +# git is only needed for projects configured to clone rather than download +# a source archive; tar always is. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl git tar \ + && arch="$(uname -m)" \ + && case "${arch}" in \ + x86_64) docker_arch=x86_64 ;; \ + aarch64) docker_arch=aarch64 ;; \ + *) echo "unsupported architecture: ${arch}" >&2; exit 1 ;; \ + esac \ + && curl -fsSL "https://download.docker.com/linux/static/stable/${docker_arch}/docker-${DOCKER_CLI_VERSION}.tgz" \ + | tar -xz -C /usr/local/bin --strip-components=1 docker/docker \ + && docker --version \ + && apt-get purge -y --auto-remove curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# The source is ES modules, and node reparses every file with a warning +# unless the package says so. +RUN printf '{\n "name": "conductor-worker",\n "private": true,\n "type": "module"\n}\n' > package.json + +# The worker source needs no npm packages at all. yaml is installed only so +# the configuration file may be YAML as well as JSON; nothing breaks +# without it. +RUN npm install --omit=dev --no-audit --no-fund yaml \ + && npm cache clean --force + +COPY src/worker/ ./src/worker/ + +# Deliberately no CONDUCTOR_WORKER_CONFIG: the worker is configurable by +# environment alone, and naming a file that is not mounted would make it +# refuse to start. A config mounted at /etc/conductor/worker.json is picked +# up on its own. +ENV NODE_ENV=production + +# Runs as root because the docker socket is usually root owned. The jobs +# themselves are isolated by being containers, not by this user. +ENTRYPOINT ["node", "src/worker/agent.js"] diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml @@ -0,0 +1,104 @@ +# deploy/docker-compose.yml - a conductor and one worker +# +# docker compose -f deploy/docker-compose.yml up -d --build +# +# Out of the box this is sqlite on a volume with artifacts on disk, which +# is a perfectly reasonable way to run it. Postgres and MinIO are behind +# profiles; turn them on when you want them: +# +# docker compose --profile postgres --profile s3 up -d +# +# The administrator password is printed once in the conductor log on first +# start. Set CONDUCTOR_ADMIN_PASSWORD to choose it instead. + +name: conductor + +services: + conductor: + build: + context: .. + dockerfile: deploy/Dockerfile + image: conductor:latest + restart: unless-stopped + ports: + - "${CONDUCTOR_PORT:-8080}:8080" + environment: + # Must match how the outside world reaches this, because workers are + # handed absolute callback URLs built from it. + CONDUCTOR_PUBLIC_URL: "${CONDUCTOR_PUBLIC_URL:-http://127.0.0.1:8080}" + # Without these two, sessions do not survive a restart and stored + # secrets are kept in the clear. Generate with: openssl rand -hex 32 + CONDUCTOR_SESSION_SECRET: "${CONDUCTOR_SESSION_SECRET:-}" + CONDUCTOR_SECRET_KEY: "${CONDUCTOR_SECRET_KEY:-}" + CONDUCTOR_ADMIN_PASSWORD: "${CONDUCTOR_ADMIN_PASSWORD:-}" + CONDUCTOR_DATABASE_URL: "${CONDUCTOR_DATABASE_URL:-}" + CONDUCTOR_S3_ENDPOINT: "${CONDUCTOR_S3_ENDPOINT:-}" + CONDUCTOR_S3_BUCKET: "${CONDUCTOR_S3_BUCKET:-}" + CONDUCTOR_S3_ACCESS_KEY_ID: "${CONDUCTOR_S3_ACCESS_KEY_ID:-}" + CONDUCTOR_S3_SECRET_ACCESS_KEY: "${CONDUCTOR_S3_SECRET_ACCESS_KEY:-}" + volumes: + - conductor-data:/data + + worker: + build: + context: .. + dockerfile: deploy/Dockerfile.worker + image: conductor-worker:latest + restart: unless-stopped + depends_on: + - conductor + environment: + CONDUCTOR_URL: http://conductor:8080 + CONDUCTOR_WORKER_NAME: "${CONDUCTOR_WORKER_NAME:-compose-worker}" + # Mint one with: docker compose exec conductor node src/admin-cli.js token:add compose-worker + CONDUCTOR_WORKER_TOKEN: "${CONDUCTOR_WORKER_TOKEN:?set CONDUCTOR_WORKER_TOKEN, see deploy/README}" + CONDUCTOR_WORKER_ARCHES: "${CONDUCTOR_WORKER_ARCHES:-}" + CONDUCTOR_WORKER_CONCURRENCY: "${CONDUCTOR_WORKER_CONCURRENCY:-2}" + # The path has to be identical inside and outside this container. + CONDUCTOR_WORKER_WORKSPACE: /var/lib/conductor/work + CONDUCTOR_WORKER_CACHE: /var/lib/conductor/cache + volumes: + # Jobs run as sibling containers on the host daemon, so the bind + # mounts the worker asks for are resolved by the host. The workspace + # therefore has to be a host path mounted at the same location here, + # not a named volume. + - /var/run/docker.sock:/var/run/docker.sock + - /var/lib/conductor/work:/var/lib/conductor/work + - /var/lib/conductor/cache:/var/lib/conductor/cache + + postgres: + profiles: ["postgres"] + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_USER: conductor + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-conductor}" + POSTGRES_DB: conductor + volumes: + - conductor-postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U conductor"] + interval: 10s + timeout: 5s + retries: 5 + + # Set CONDUCTOR_S3_ENDPOINT=http://minio:9000 and the rest of the S3 + # variables to have the conductor use this. + minio: + profiles: ["s3"] + image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: "${MINIO_ROOT_USER:-conductor}" + MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-conductor-secret}" + ports: + - "${MINIO_PORT:-9000}:9000" + - "${MINIO_CONSOLE_PORT:-9001}:9001" + volumes: + - conductor-minio:/data + +volumes: + conductor-data: + conductor-postgres: + conductor-minio: diff --git a/deploy/smoke.sh b/deploy/smoke.sh @@ -0,0 +1,180 @@ +#!/bin/sh +# deploy/smoke.sh - build the images and run a pipeline through them +# +# Checks the thing people actually deploy, rather than the code the tests +# import: both images build, the conductor starts on an empty volume, +# a worker registers, and a real pipeline runs to completion with its log +# and artifact readable afterwards. +# +# Usage: deploy/smoke.sh [--keep] +# +# --keep leave the stack running afterwards for poking at +# +# Needs docker and a few hundred megabytes of disk. Takes about a minute. + +set -eu + +KEEP=0 +[ "${1:-}" = "--keep" ] && KEEP=1 + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +WORK=$(mktemp -d) +PORT="${SMOKE_PORT:-18500}" +PROJECT=conductor-smoke +SECRET=smoke-trigger-secret + +# Paths must match inside and outside the worker, since jobs run as +# siblings on the host daemon. +STATE=${SMOKE_STATE:-/tmp/conductor-smoke-state} + +log() { printf '\n== %s\n' "$*"; } +fail() { printf '\nFAILED: %s\n' "$*" >&2; exit 1; } + +cleanup() { + status=$? + if [ "${KEEP}" -eq 1 ] && [ "${status}" -eq 0 ]; then + printf '\nleaving the stack up; tear it down with:\n' + printf ' docker compose -p %s -f %s down -v\n' "${PROJECT}" "${WORK}/compose.yml" + return + fi + printf '\n== cleaning up\n' + SMOKE_TOKEN=unused docker compose -p "${PROJECT}" -f "${WORK}/compose.yml" down -v >/dev/null 2>&1 || true + rm -rf "${WORK}" "${STATE}" +} +trap cleanup EXIT + +log "building images" +docker build -q -f "${ROOT}/deploy/Dockerfile" -t conductor:smoke "${ROOT}" >/dev/null +docker build -q -f "${ROOT}/deploy/Dockerfile.worker" -t conductor-worker:smoke "${ROOT}" >/dev/null + +log "preparing a repository" +mkdir -p "${WORK}/repo" "${STATE}/work" "${STATE}/cache" +cat > "${WORK}/repo/.conductor.yml" <<'PIPELINE' +version: 1 +visibility: public +defaults: + image: alpine:3 +jobs: + build: + script: + - echo "run $CONDUCTOR_RUN_NUMBER of $CONDUCTOR_PROJECT" + - mkdir -p out && echo "packaged" > out/result.txt + artifacts: + paths: [out/**] + publish: + needs: [build] + script: ['echo published'] +PIPELINE +echo 'smoke test repository' > "${WORK}/repo/README.md" +git -C "${WORK}/repo" init -q -b main +git -C "${WORK}/repo" config user.email smoke@example.invalid +git -C "${WORK}/repo" config user.name Smoke +git -C "${WORK}/repo" add -A +git -C "${WORK}/repo" commit -q -m 'smoke test' +SHA=$(git -C "${WORK}/repo" rev-parse HEAD) + +cat > "${WORK}/compose.yml" <<COMPOSE +name: ${PROJECT} +services: + conductor: + image: conductor:smoke + ports: ["${PORT}:8080"] + environment: + CONDUCTOR_PUBLIC_URL: http://conductor:8080 + CONDUCTOR_SESSION_SECRET: smoke-session-secret + CONDUCTOR_ADMIN_PASSWORD: smoke-admin-password + volumes: + - smoke-data:/data + - ${WORK}/repo:/repo:ro + worker: + image: conductor-worker:smoke + depends_on: [conductor] + environment: + CONDUCTOR_URL: http://conductor:8080 + CONDUCTOR_WORKER_NAME: smoke-worker + CONDUCTOR_WORKER_TOKEN: "\${SMOKE_TOKEN}" + CONDUCTOR_WORKER_CONCURRENCY: "2" + CONDUCTOR_WORKER_WORKSPACE: ${STATE}/work + CONDUCTOR_WORKER_CACHE: ${STATE}/cache + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ${STATE}/work:${STATE}/work + - ${STATE}/cache:${STATE}/cache +volumes: + smoke-data: +COMPOSE + +compose() { SMOKE_TOKEN="${SMOKE_TOKEN:-unused}" docker compose -p "${PROJECT}" -f "${WORK}/compose.yml" "$@"; } + +log "starting the conductor" +compose up -d conductor >/dev/null 2>&1 + +i=0 +while [ "${i}" -lt 60 ]; do + curl -fsS -m 2 "http://127.0.0.1:${PORT}/health" >/dev/null 2>&1 && break + i=$((i + 1)) + sleep 1 +done +[ "${i}" -lt 60 ] || fail "the conductor did not become healthy" +curl -fsS "http://127.0.0.1:${PORT}/health" +printf '\n' + +log "registering the project and a worker" +compose exec -T conductor node src/admin-cli.js project:add demo /repo --secret "${SECRET}" >/dev/null +SMOKE_TOKEN=$(compose exec -T conductor node src/admin-cli.js token:add smoke-worker | awk '/token:/{print $2}') +[ -n "${SMOKE_TOKEN}" ] || fail "no worker token was issued" +export SMOKE_TOKEN + +log "starting the worker" +compose up -d worker >/dev/null 2>&1 + +log "triggering a run" +BODY=$(printf '{"sha":"%s","ref":"refs/heads/main"}' "${SHA}") +SIG=$(printf '%s' "${BODY}" | openssl dgst -sha256 -hmac "${SECRET}" | sed 's/^.*[= ]//') +RESPONSE=$(curl -fsS -X POST "http://127.0.0.1:${PORT}/api/trigger/demo" \ + -H 'Content-Type: application/json' \ + -H "X-Hub-Signature-256: sha256=${SIG}" \ + -d "${BODY}") +printf '%s\n' "${RESPONSE}" + +RUN=$(printf '%s' "${RESPONSE}" | sed 's/.*"run_id":"\([^"]*\)".*/\1/') +[ -n "${RUN}" ] || fail "no run was created" + +log "waiting for the run to finish" +STATE_NOW= +i=0 +while [ "${i}" -lt 90 ]; do + STATE_NOW=$(curl -fsS "http://127.0.0.1:${PORT}/api/runs/${RUN}" | sed 's/.*"state":"\([^"]*\)".*/\1/') + case "${STATE_NOW}" in + success|failed|cancelled) break ;; + esac + i=$((i + 1)) + sleep 1 +done + +printf 'run %s finished as %s after %ss\n' "${RUN}" "${STATE_NOW}" "${i}" +[ "${STATE_NOW}" = success ] || { + compose logs worker | tail -30 + fail "the run ended as ${STATE_NOW}" +} + +log "checking the log and the artifact" +JOB="${RUN}:build" +ENCODED=$(printf '%s' "${JOB}" | sed 's/:/%3A/g') + +LOG=$(curl -fsS "http://127.0.0.1:${PORT}/api/jobs/${ENCODED}/log") +printf '%s\n' "${LOG}" +printf '%s' "${LOG}" | grep -q 'run 1 of demo' || fail "the job environment did not reach the script" + +ARTIFACT=$(curl -fsS "http://127.0.0.1:${PORT}/api/jobs/${ENCODED}" \ + | sed 's/.*"artifacts":\[{"id":"\([^"]*\)".*/\1/') +[ -n "${ARTIFACT}" ] || fail "no artifact was recorded" + +CONTENT=$(curl -fsSL "http://127.0.0.1:${PORT}/api/artifacts/${ARTIFACT}") +printf 'artifact contents: %s\n' "${CONTENT}" +[ "${CONTENT}" = packaged ] || fail "the artifact did not round trip" + +log "checking the interface" +curl -fsS "http://127.0.0.1:${PORT}/" | grep -q 'conductor' || fail "the interface did not render" + +printf '\nPASSED\n' diff --git a/deploy/worker/docker-compose.yml b/deploy/worker/docker-compose.yml @@ -0,0 +1,46 @@ +# deploy/worker/docker-compose.yml - a worker on your own machine +# +# For contributing build capacity to a conductor run by someone else. The +# worker needs no inbound connectivity, so this works from behind NAT. +# +# Setup: +# 1. Ask for a worker token, or mint one yourself in the interface under +# Workers. It is shown once. +# 2. Put it in .env next to this file: +# CONDUCTOR_URL=https://ci.example.com +# CONDUCTOR_WORKER_TOKEN=... +# 3. docker compose up -d && docker compose logs -f +# +# A worker you register is only ever offered jobs from your own projects. +# +# Jobs run as sibling containers on this host's docker daemon. They are +# isolated from each other, but a job is code from a repository running on +# your machine: only point this at a conductor whose projects you are +# willing to execute. + +name: conductor-worker + +services: + worker: + image: "${CONDUCTOR_WORKER_IMAGE:-conductor-worker:latest}" + restart: unless-stopped + environment: + CONDUCTOR_URL: "${CONDUCTOR_URL:?set CONDUCTOR_URL in .env}" + CONDUCTOR_WORKER_TOKEN: "${CONDUCTOR_WORKER_TOKEN:?set CONDUCTOR_WORKER_TOKEN in .env}" + CONDUCTOR_WORKER_NAME: "${CONDUCTOR_WORKER_NAME:-}" + # Architectures this machine can build for. Leave unset to take only + # jobs that declare none. + CONDUCTOR_WORKER_ARCHES: "${CONDUCTOR_WORKER_ARCHES:-}" + CONDUCTOR_WORKER_CONCURRENCY: "${CONDUCTOR_WORKER_CONCURRENCY:-1}" + # Identical inside and out, because the docker daemon resolves these + # paths on the host. + CONDUCTOR_WORKER_WORKSPACE: /var/lib/conductor/work + CONDUCTOR_WORKER_CACHE: /var/lib/conductor/cache + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /var/lib/conductor/work:/var/lib/conductor/work + - /var/lib/conductor/cache:/var/lib/conductor/cache + # Features are declared in a config file when a job needs something + # from this machine, such as a signing key. See docs/worker.md. + # - ./worker.json:/etc/conductor/worker.json:ro + # - /srv/keys/build.rsa:/srv/keys/build.rsa:ro diff --git a/docs/api.md b/docs/api.md @@ -0,0 +1,182 @@ +HTTP API +======== + +Four surfaces, distinguished by who may reach them: + +| Prefix | Authentication | +| --------------- | ----------------------- | +| `/api/trigger` | per project HMAC | +| `/api/workers` | worker token | +| `/api/admin` | session, admin role | +| everything else | session, or anonymous | + +Errors are `{"error": "..."}` with a meaningful status. A resource the +caller may not see returns 404 rather than 403, so absence and denial are +indistinguishable. + +Authentication +-------------- + +Sign in for a token, or send the session cookie the same call sets: + +```sh +curl -X POST https://ci.example.com/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":"..."}' +``` + +```json +{ "token": "...", "expires_in": 43200, "user": { "id": "...", "role": "admin" } } +``` + +Then `Authorization: Bearer <token>`. With OIDC configured the provider +issues the token instead and `/api/auth/login` refuses; `GET /api/auth/mode` +says which is in force. + +| Method | Path | Purpose | +| ------ | ------------------ | ------------------------------ | +| GET | `/api/auth/mode` | how this conductor authenticates | +| POST | `/api/auth/login` | sign in, built-in accounts only | +| POST | `/api/auth/logout` | clear the cookie | +| GET | `/api/auth/me` | the current user | + +Triggers +-------- + +``` +POST /api/trigger/:project +``` + +Accepts the payload from `hooks/post-receive`, and the push events of +GitHub, Gitea and GitLab. Signed with the project's trigger secret: +`X-Hub-Signature-256: sha256=<hmac>` over the exact request body, or +`X-Gitlab-Token`. + +```json +{ "sha": "<40 hex>", "base": "<40 hex or empty>", "ref": "refs/heads/main", "actor": "alice" } +``` + +Nothing in the payload is trusted beyond which commit to read. The pipeline +comes from the repository at that commit. + +| Status | Meaning | +| ------ | --------------------------------------------- | +| 200 | run created, or ignored for a branch deletion | +| 401 | signature missing or wrong | +| 404 | no such project | +| 409 | project disabled | +| 422 | the pipeline at that commit is invalid | + +Reading runs +------------ + +Visible without signing in when the project is public; otherwise to the +owner and to administrators. + +| Method | Path | Purpose | +| ------ | ------------------------ | ------------------------------- | +| GET | `/api/runs` | recent runs, `?project=`, `?limit=` | +| GET | `/api/runs/:id` | one run with its jobs and edges | +| GET | `/api/jobs/:id` | one job with its artifacts | +| GET | `/api/jobs/:id/log` | log bytes, `?offset=`, `?limit=` | +| GET | `/api/artifacts/:id` | download, may redirect | + +The log endpoint is built for following along: + +| Header | Meaning | +| ---------------- | ---------------------------------------- | +| `X-Log-Offset` | where the returned bytes start | +| `X-Log-Size` | total bytes available now | +| `X-Log-Complete` | `true` once the job has finished | + +Poll with `?offset=` set to the previous size until `X-Log-Complete` is +`true`. A job environment is never returned by any of these. + +Projects and workers +-------------------- + +Any signed in user manages what they own; administrators manage everything. + +| Method | Path | Purpose | +| ------ | ----------------------------------------- | -------------------------- | +| GET | `/api/projects` | projects you may manage | +| POST | `/api/projects` | register one | +| GET | `/api/projects/:id` | one project | +| PATCH | `/api/projects/:id` | `enabled`, `visibility`, `owner_id` | +| DELETE | `/api/projects/:id` | remove it and its runs | +| POST | `/api/projects/:id/trigger-secret` | rotate, returns it once | +| GET | `/api/projects/:id/variables` | names only, never values | +| PUT | `/api/projects/:id/variables/:name` | set one | +| DELETE | `/api/projects/:id/variables/:name` | remove one | +| GET | `/api/worker-tokens` | tokens you may manage | +| POST | `/api/worker-tokens` | issue one, returned once | +| PATCH | `/api/worker-tokens/:id` | enable or disable | +| DELETE | `/api/worker-tokens/:id` | revoke | +| POST | `/api/runs/:id/cancel` | cancel a running run | +| POST | `/api/runs/:id/retry` | run the same commit again | + +Creating a project returns the trigger secret, and creating a worker token +returns the token. Neither can be read back afterwards; rotate or reissue. + +`owner_id` may only be changed by an administrator, and only an +administrator can create a shared worker (`{"shared": true}`), which is one +that accepts jobs from any project rather than from one owner. + +Administration +-------------- + +Administrator role required. + +| Method | Path | Purpose | +| ------ | ------------------------------ | ------------------------------ | +| GET | `/api/admin/users` | all accounts | +| POST | `/api/admin/users` | create one | +| PATCH | `/api/admin/users/:id` | `password`, `role`, `disabled` | +| GET | `/api/admin/users/:id/impact` | what deleting it would destroy | +| DELETE | `/api/admin/users/:id` | delete it and everything it owns | + +Deleting an account deletes the projects and worker tokens it owned, and +the run history of those projects. Ask `/impact` first. The last +administrator cannot be deleted, demoted or disabled. + +The worker API +-------------- + +Documented because a worker is a normal client of it, and anyone may write +another. Every call needs `Authorization: Bearer <worker token>`, and a +worker may only touch a job it currently holds. + +| Method | Path | Purpose | +| ------ | ---------------------------------------- | --------------------------- | +| GET | `/api/workers/poll` | claim work, 204 when idle | +| GET | `/api/workers/jobs/:id/source.tar.gz` | the tree at the commit | +| POST | `/api/workers/jobs/:id/log` | append output | +| POST | `/api/workers/jobs/:id/artifact` | upload one file | +| POST | `/api/workers/jobs/:id/heartbeat` | stay alive, learn of cancellation | +| POST | `/api/workers/jobs/:id/done` | report the outcome | + +Poll with `?arches=x86_64,aarch64&features=dind,sign-key&name=my-worker`. +Only jobs whose architecture the worker offers and whose `requires` it +satisfies are handed out. A worker owned by a user is only offered that +user's projects. + +A claimed job carries everything needed to run it: image, script, +environment, services, artifact patterns, timeout, where to fetch the +source, and absolute URLs for the calls above. + +Log appends send `Content-Type: application/octet-stream` with +`X-Log-Offset` set to where the worker believes it is writing. An +overlapping chunk is trimmed and a gap is refused with 409 and +`expected_offset`, which makes a retry after a dropped connection safe. + +Artifact uploads send the bytes with `X-Artifact-Path` and a +`Content-Length`. The path is sanitised, so traversal is stripped rather +than honoured. Bodies are streamed and may be arbitrarily large; a body +that does not match its declared length is rejected. + +```json +{ "success": true, "exit_code": 0, "error": null } +``` + +`done` reports the outcome. A failure with attempts remaining requeues the +job; otherwise everything downstream of it is skipped. diff --git a/docs/deployment.md b/docs/deployment.md @@ -0,0 +1,184 @@ +Deployment +========== + +One service, one image. The conductor accepts triggers, schedules runs, +serves the worker API and renders the interface. Workers are separate and +may live anywhere, including on machines you do not administer. + +Quick start +----------- + +```sh +docker compose -f deploy/docker-compose.yml up -d --build +``` + +That brings up a conductor on port 8080 with sqlite on a volume, and one +worker. The compose file needs a worker token, so the first run is +two steps: + +```sh +# Start the conductor on its own. +docker compose -f deploy/docker-compose.yml up -d conductor + +# Mint a token, then bring the worker up with it. +docker compose -f deploy/docker-compose.yml exec conductor \ + node src/admin-cli.js token:add compose-worker + +CONDUCTOR_WORKER_TOKEN=... docker compose -f deploy/docker-compose.yml up -d worker +``` + +The administrator password is written to the conductor log once on first +start. Set `CONDUCTOR_ADMIN_PASSWORD` to choose it instead. + +To check the whole thing before trusting it with anything: + +```sh +deploy/smoke.sh +``` + +That builds both images, starts them, runs a real pipeline and verifies the +log and artifact came back. + +Images +------ + +| Image | Contents | +| -------------------- | ----------------------------------------------- | +| `deploy/Dockerfile` | the conductor: node, git, the application | +| `deploy/Dockerfile.worker` | the worker: node, git, tar, the docker CLI | + +Both build from the repository root: + +```sh +docker build -f deploy/Dockerfile -t conductor . +docker build -f deploy/Dockerfile.worker -t conductor-worker . +``` + +The worker image installs `yaml` so its configuration file may be YAML; the +worker source itself has no npm dependencies at all. + +State +----- + +Everything the conductor keeps lives in `/data`: + +``` +/data/conductor.db sqlite, unless database.url points elsewhere +/data/mirrors one bare mirror per project +/data/logs the live log spool +/data/storage artifacts and archived logs, unless S3 is configured +``` + +Mount a volume there. Losing it loses run history, and the mirrors and +spool will be rebuilt. + +Configuration +------------- + +Every setting has a default and can come from the environment, so the image +needs no config file. See `conductor.example.yaml` for the annotated list. + +Worth setting in production: + +| Variable | Why | +| -------------------------- | ------------------------------------------------ | +| `CONDUCTOR_PUBLIC_URL` | Workers get absolute callback URLs built from it. | +| `CONDUCTOR_SESSION_SECRET` | Otherwise sessions end at every restart. | +| `CONDUCTOR_SECRET_KEY` | Otherwise stored secrets are kept in the clear. | +| `CONDUCTOR_ADMIN_PASSWORD` | Otherwise one is generated and logged once. | + +Generate the two secrets with `openssl rand -hex 32`. + +### Postgres or MySQL + +```sh +CONDUCTOR_DATABASE_URL=postgres://conductor:secret@postgres:5432/conductor \ + docker compose --profile postgres -f deploy/docker-compose.yml up -d +``` + +The schema is created on start. `npm run migrate` applies it separately +when a deployment wants that as its own reviewable step. + +### Object storage + +```sh +CONDUCTOR_S3_ENDPOINT=http://minio:9000 \ +CONDUCTOR_S3_BUCKET=conductor \ +CONDUCTOR_S3_ACCESS_KEY_ID=... \ +CONDUCTOR_S3_SECRET_ACCESS_KEY=... \ + docker compose --profile s3 -f deploy/docker-compose.yml up -d +``` + +Artifacts and finished logs then go to the bucket instead of the volume, +and downloads are handed to the store with a presigned redirect rather than +being proxied. The bucket must already exist. + +Workers +------- + +A worker needs the docker socket, because it runs each job in a container. +Those containers are siblings on the host daemon, not children of the +worker, which has one consequence that catches people out: + +**The workspace path must be identical inside and outside the worker +container.** The `-v` flags the worker passes are resolved by the host +daemon, so a path that only exists inside the worker produces an empty +workspace and a job that cannot find its own source. The compose files +mount `/var/lib/conductor/work` at the same path on both sides for exactly +this reason. A named volume will not do. + +To contribute capacity to somebody else's conductor, use +`deploy/worker/docker-compose.yml`; see [worker.md](worker.md). + +Behind a reverse proxy +---------------------- + +``` +server { + server_name ci.example.com; + location / { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + # Artifacts can be large, and a build can be quiet for a long time. + client_max_body_size 0; + proxy_read_timeout 3600s; + proxy_request_buffering off; + } +} +``` + +`proxy_request_buffering off` matters: with it on, nginx spools an entire +artifact to its own disk before the conductor sees a byte, which for +multi gigabyte build output is both slow and surprising. + +Set `CONDUCTOR_PUBLIC_URL=https://ci.example.com` to match, or workers will +be handed callback URLs that do not work. + +Backups +------- + +With sqlite and local storage, the `/data` volume is the whole system. With +postgres and S3, back up the database and the bucket; the mirrors and the +log spool are caches and rebuild themselves. + +Secrets are encrypted with `CONDUCTOR_SECRET_KEY`. A backup restored +without that key leaves trigger secrets and project variables unreadable, +so keep it somewhere other than next to the backup. + +Upgrading +--------- + +Migrations run on start and are forward only. They are recorded with a +checksum, so an edited migration is refused rather than applied twice. +Take a backup first: there is no automatic downgrade. + +```sh +docker compose -f deploy/docker-compose.yml pull +docker compose -f deploy/docker-compose.yml up -d +``` + +Workers can be upgraded independently and in any order. The worker API is +the boundary between them, and a worker that goes away mid-job has that job +requeued after `scheduler.heartbeat_timeout`. diff --git a/src/worker/agent.js b/src/worker/agent.js @@ -70,6 +70,24 @@ export async function main(argv = process.argv.slice(2)) { let stopping = false; const running = new Set(); + // An idle worker is waiting on a timer and nothing else. The timer has + // to hold the event loop open, or node finds it has no work left and + // exits cleanly the first time there is nothing to build. Waking the + // sleepers on a signal keeps shutdown immediate despite that. + const sleepers = new Set(); + + function sleep(ms) { + return new Promise((resolve) => { + const wake = () => { + clearTimeout(timer); + sleepers.delete(wake); + resolve(); + }; + const timer = setTimeout(wake, ms); + sleepers.add(wake); + }); + } + const shutdown = (signal) => { if (stopping) { logger.warn('second signal, exiting now'); @@ -77,11 +95,10 @@ export async function main(argv = process.argv.slice(2)) { } stopping = true; logger.info(`${signal} received, finishing ${running.size} running job(s)`); + for (const wake of [...sleepers]) wake(); }; for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => shutdown(signal)); - const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms).unref?.(); }); - // One loop per concurrency slot. Each polls independently, so a slow job // in one slot does not stall the others. async function slot(index) { diff --git a/src/worker/config.js b/src/worker/config.js @@ -121,15 +121,24 @@ function normalizeFeature(name, raw, problems) { }; } +// Looked at when no file was named. Present means use it, absent means +// carry on with the environment alone, which is how the container image +// runs with nothing mounted. +const DEFAULT_PATHS = ['/etc/conductor/worker.json', '/etc/conductor/worker.yaml', './worker.json', './worker.yaml']; + export async function loadWorkerConfig(explicitPath) { - const file = explicitPath || process.env.CONDUCTOR_WORKER_CONFIG || null; + const named = explicitPath || process.env.CONDUCTOR_WORKER_CONFIG || null; let cfg = { ...DEFAULTS }; + // A file asked for by name must exist; a default one need not. + if (named && !fs.existsSync(named)) throw new Error(`worker config not found: ${named}`); + const file = named ?? DEFAULT_PATHS.find((candidate) => fs.existsSync(candidate)) ?? null; + if (file) { - if (!fs.existsSync(file)) throw new Error(`worker config not found: ${file}`); const parsed = await readConfigFile(file); if (!isPlainObject(parsed)) throw new Error(`${file} must contain a mapping at the top level`); cfg = { ...cfg, ...parsed }; + cfg.source = file; } for (const [env, key, parse] of ENV_MAP) { diff --git a/test/ascii.test.js b/test/ascii.test.js @@ -21,7 +21,17 @@ const CHECK_EXT = new Set([ '.js', '.mjs', '.cjs', '.json', '.md', '.sql', '.yml', '.yaml', '.html', '.css', '.sh', '.txt', ]); -const CHECK_NAMES = new Set(['.editorconfig', '.gitignore', 'Dockerfile', 'post-receive']); +const CHECK_NAMES = new Set(['.editorconfig', '.gitignore', '.dockerignore', 'post-receive']); + +// Dockerfile, Dockerfile.worker, and anything else in that family. +const CHECK_PREFIXES = ['Dockerfile']; + +function shouldCheck(file) { + const base = path.basename(file); + if (CHECK_EXT.has(path.extname(file))) return true; + if (CHECK_NAMES.has(base)) return true; + return CHECK_PREFIXES.some((prefix) => base.startsWith(prefix)); +} async function* walk(dir) { for (const entry of await fs.readdir(dir, { withFileTypes: true })) { @@ -43,9 +53,7 @@ test('every tracked text file is pure ASCII', async () => { const offences = []; for await (const file of walk(ROOT)) { - const ext = path.extname(file); - const base = path.basename(file); - if (!CHECK_EXT.has(ext) && !CHECK_NAMES.has(base)) continue; + if (!shouldCheck(file)) continue; const text = await fs.readFile(file, 'utf8'); const lines = text.split('\n'); @@ -67,9 +75,7 @@ test('no file uses CRLF line endings or trailing whitespace', async () => { const offences = []; for await (const file of walk(ROOT)) { - const ext = path.extname(file); - const base = path.basename(file); - if (!CHECK_EXT.has(ext) && !CHECK_NAMES.has(base)) continue; + if (!shouldCheck(file)) continue; const text = await fs.readFile(file, 'utf8'); const rel = path.relative(ROOT, file); diff --git a/test/worker.test.js b/test/worker.test.js @@ -7,6 +7,8 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; import { loadWorkerConfig, featureNames } from '../src/worker/config.js'; import { buildScript, shellQuote } from '../src/worker/script.js'; import { containerName, createRuntime } from '../src/worker/docker.js'; @@ -229,6 +231,70 @@ test('artifact when controls collection', () => { assert.equal(shouldCollect('always', false), true); }); +test('an idle worker keeps running instead of exiting', async () => { + // A worker with nothing to do is waiting on a timer and nothing else. + // If that timer does not hold the event loop open, node decides it has + // finished and exits 0, which looks like a clean shutdown and is not. + const dir = await tempDir(); + const config = path.join(dir, 'worker.json'); + + // Points at a port nothing is listening on, so every poll fails and the + // agent stays in its retry sleep. + await fs.writeFile(config, JSON.stringify({ + conductor_url: 'http://127.0.0.1:1', + token: 'irrelevant', + poll_interval: 1, + docker: 'true', + })); + + const agent = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src/worker/agent.js'); + const child = spawn(process.execPath, [agent, '--config', config], { stdio: 'ignore' }); + + const outcome = await new Promise((resolve) => { + const timer = setTimeout(() => resolve('still running'), 4000); + child.on('exit', (code) => { + clearTimeout(timer); + resolve(`exited with ${code}`); + }); + }); + + child.kill('SIGKILL'); + assert.equal(outcome, 'still running'); + + await fs.rm(dir, { recursive: true, force: true }); +}); + +test('a worker stops promptly on a signal rather than after its poll interval', async () => { + const dir = await tempDir(); + const config = path.join(dir, 'worker.json'); + await fs.writeFile(config, JSON.stringify({ + conductor_url: 'http://127.0.0.1:1', + token: 'irrelevant', + // Long enough that waiting it out would fail this test. + poll_interval: 60, + docker: 'true', + })); + + const agent = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src/worker/agent.js'); + const child = spawn(process.execPath, [agent, '--config', config], { stdio: 'ignore' }); + + // Let it reach the sleep, then ask it to stop. + await new Promise((r) => { setTimeout(r, 1500); }); + const started = Date.now(); + child.kill('SIGTERM'); + + const code = await new Promise((resolve) => { + const timer = setTimeout(() => resolve('timed out'), 10000); + child.on('exit', (c) => { clearTimeout(timer); resolve(c); }); + }); + + child.kill('SIGKILL'); + assert.equal(code, 0, 'should exit cleanly on SIGTERM'); + assert.ok(Date.now() - started < 8000, 'should not wait out the poll interval'); + + await fs.rm(dir, { recursive: true, force: true }); +}); + // A client stub that records what the log stream sends. function recordingClient() { const calls = [];