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:
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 = [];