conductor

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

commit e44064ed125147d0268e4d98d5f78966b607dcc7
parent bd658275e2b0dd996745b9b63483127d5cbbac12
Author: finwo <finwo@pm.me>
Date:   Sat, 19 Sep 2026 01:50:57 +0200

Worker agent: containerised jobs, features and artifacts

Diffstat:
MREADME.md | 15++++++++++++---
Adocs/worker.md | 168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexamples/worker.json | 20++++++++++++++++++++
Msrc/conductor/routes/workers.js | 11+++++++++++
Msrc/conductor/scheduler.js | 22+++++++++++++++++++---
Asrc/worker/agent.js | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/worker/artifacts.js | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/worker/client.js | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/worker/config.js | 206+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/worker/docker.js | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/worker/job.js | 251+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/worker/logstream.js | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/worker/script.js | 29+++++++++++++++++++++++++++++
Asrc/worker/source.js | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/conductor.test.js | 34++++++++++++++++++++++++++++++++++
Mtest/helpers/harness.js | 25++++++++++++++++++++++---
Atest/worker-docker.test.js | 340+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/worker.test.js | 319+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
18 files changed, 2138 insertions(+), 9 deletions(-)

diff --git a/README.md b/README.md @@ -30,10 +30,11 @@ Under construction. Working today: - local and S3 compatible object storage - pipeline parsing, matrix and architecture expansion, dependency graphs - triggers, run scheduling, the worker API, live logs, artifacts, reaping + - the worker: containerised jobs, services, features, caches, cleanup -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`. +Not yet built: 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 ------------ @@ -88,6 +89,14 @@ 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. +Then run a worker, on this machine or any other: + +```sh +node src/worker/agent.js --config worker.json +``` + +See [docs/worker.md](docs/worker.md), and `examples/worker.json`. + Pipelines --------- diff --git a/docs/worker.md b/docs/worker.md @@ -0,0 +1,168 @@ +Running a worker +================ + +A worker polls a conductor for jobs and runs them in containers. It needs no +inbound connectivity, so it can sit behind NAT, and it needs no repository +credentials, because the conductor serves the source for the exact commit a +job was handed. + +The worker has no npm dependencies. Copying `src/worker` onto a host with +node, tar and a container runtime is enough. + +Requirements +------------ + + - node 24 or newer + - a docker compatible CLI: docker, podman or nerdctl + - `tar`, to unpack job sources + - `git`, only for projects configured to clone rather than download + +Getting a token +--------------- + +Ask whoever runs the conductor for a worker token. They create one with: + +```sh +node src/admin-cli.js token:add friends-pi +``` + +The token is shown once and stored only as a hash, so it cannot be recovered +later. If it is lost, issue a new one and remove the old. + +Configuration +------------- + +JSON is the native format. YAML also works when the `yaml` package is +installed, which it is in the published image. + +```json +{ + "conductor_url": "https://ci.example.com", + "name": "friends-pi", + "token_file": "/run/secrets/worker_token", + "arches": ["aarch64"], + "concurrency": 2, + "features": { + "dind": { "privileged": true }, + "sign-key": { + "mounts": ["/srv/keys/build.rsa:/keys/build.rsa:ro"], + "env": { "UNOS_SIGN_KEY": "/keys/build.rsa" } + } + } +} +``` + +```sh +node src/worker/agent.js --config worker.json +``` + +| Key | Default | Description | +| ---------------- | -------------------- | ----------------------------------------------- | +| `conductor_url` | `http://127.0.0.1:8080` | Base URL of the conductor. | +| `name` | hostname | Shown against jobs this worker ran. | +| `token` | none | The worker token, inline. | +| `token_file` | none | Path to a file holding the token. Preferred. | +| `arches` | `[]` | Architectures offered. Empty means no `arch` jobs, only jobs that declare none. | +| `features` | `{}` | Capabilities offered, see below. | +| `concurrency` | `1` | Jobs run at once. | +| `poll_interval` | `5` | Seconds between polls when idle. | +| `docker` | `docker` | Runtime CLI. Set to `podman` or `nerdctl`. | +| `shell` | `sh` | Shell used to run a job script in its image. | +| `workspace_root` | a temp directory | Where job workspaces are created. | +| `cache_root` | a temp directory | Where job caches are kept between runs. | + +Every key can also be set from the environment: `CONDUCTOR_URL`, +`CONDUCTOR_WORKER_TOKEN`, `CONDUCTOR_WORKER_TOKEN_FILE`, +`CONDUCTOR_WORKER_ARCHES`, `CONDUCTOR_WORKER_CONCURRENCY`, +`CONDUCTOR_WORKER_DOCKER`, and so on. The environment wins over the file. + +Features +-------- + +A feature is a name this worker advertises, together with whatever local +resources a job asking for it should receive. The conductor only ever learns +the name. It will not send a job to a worker that does not advertise every +feature the job's `requires` lists. + +```json +"features": { + "sign-key": { + "mounts": ["/srv/keys/build.rsa:/keys/build.rsa:ro"], + "env": { "SIGN_KEY": "/keys/build.rsa" }, + "devices": [], + "privileged": false + } +} +``` + +A pipeline then asks for it by name: + +```yaml +package: + requires: [sign-key] + script: ['./sign.sh'] +``` + +This is how a signing key reaches a build without the conductor ever holding +it. The key stays on the machine that owns it, and only jobs that explicitly +ask for it are ever scheduled there. + +`privileged: true` is what a docker in docker service needs. Grant it only +to features you intend to be privileged, since it removes the isolation +between the job and the host. + +What a job gets +--------------- + +Each job runs in its own container, on its own network, in its own +workspace: + + - the repository tree at the job's commit, unpacked into `/workspace` + - `/workspace` as the working directory + - the job's `env`, plus `ARCH` and `MATRIX_*` for a fanned out job + - `CONDUCTOR_PROJECT`, `CONDUCTOR_RUN_ID`, `CONDUCTOR_RUN_NUMBER`, + `CONDUCTOR_JOB`, `CONDUCTOR_JOB_ID`, `CONDUCTOR_SHA`, `CONDUCTOR_REF` + and `CONDUCTOR_ATTEMPT` + - any `services`, reachable by their alias on the job network + - mounts and environment from the features it requires + +Output is streamed to the conductor as it happens. Artifacts are uploaded +when the job finishes, and the workspace is then removed. + +Caches are bind mounted from `cache_root` straight onto their path in the +workspace. A cache is an optimisation and nothing more: a job must still work +with an empty one, and a cache is never shared between workers. + +Operational notes +----------------- + +**Cleanup.** Containers, networks and workspaces are removed whatever the +outcome. Most images run as root, so files a job creates are owned by root +while the worker usually is not. When removing the workspace fails for that +reason, the worker empties it from inside a container first. Nothing is left +behind either way. + +**Shutdown.** On SIGINT or SIGTERM the worker stops polling and lets running +jobs finish. A second signal exits immediately, and the conductor will +eventually reap whatever was left running. + +**Failures.** A worker that stops reporting has its jobs requeued or failed +by the conductor after `scheduler.heartbeat_timeout`. Losing a worker never +strands a run. + +**Log loss.** If the conductor cannot be reached while a job runs, log output +for that window is dropped rather than failing the job. The job's outcome is +still reported. + +Trust +----- + +A worker is trusted with the source of the commits it builds and with +whatever its features grant it. It is not trusted with anything else: it +holds no repository credentials, it cannot read other refs, it cannot reach +another project's jobs, and it cannot write to storage except by uploading +artifacts for a job it currently holds. + +Running a job means running code from the repository. Only accept work from +a conductor whose projects you are willing to execute, and keep features +narrow. diff --git a/examples/worker.json b/examples/worker.json @@ -0,0 +1,20 @@ +{ + "conductor_url": "https://ci.example.com", + "name": "friends-pi", + "token_file": "/run/secrets/worker_token", + "arches": ["aarch64"], + "concurrency": 2, + "poll_interval": 5, + "docker": "docker", + "workspace_root": "/var/lib/conductor/work", + "cache_root": "/var/lib/conductor/cache", + "features": { + "dind": { + "privileged": true + }, + "sign-key": { + "mounts": ["/srv/keys/build.rsa:/keys/build.rsa:ro"], + "env": { "SIGN_KEY": "/keys/build.rsa" } + } + } +} diff --git a/src/conductor/routes/workers.js b/src/conductor/routes/workers.js @@ -73,9 +73,13 @@ export default async function workerRoutes(fastify, { cfg, db, git, logs, storag job: { id: job.id, run_id: job.run_id, + project_id: job.project_id, name: job.name, arch: job.arch, image: job.image, + // The worker maps these onto its own local mounts, environment and + // privileges. Without them no feature is applied at all. + requires: job.requires, script: job.script, env: job.env, services: job.services, @@ -84,6 +88,13 @@ export default async function workerRoutes(fastify, { cfg, db, git, logs, storag timeout: job.timeout, attempt: job.attempt, sha: job.sha, + ref: job.ref, + // Values the worker must redact from the log stream. Populated + // once project variables exist; masking also happens on ingest. + masked: job.masked ?? [], + // Derived from the server's own timeout so a worker never has to + // guess how often it is expected to check in. + heartbeat_interval: Math.max(5, Math.floor(cfg.scheduler.heartbeat_timeout / 4)), // 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. diff --git a/src/conductor/scheduler.js b/src/conductor/scheduler.js @@ -33,7 +33,8 @@ export function createScheduler({ cfg, db, git, logs, storage, projects, logger // 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 + j.timeout, j.attempt, j.max_attempts, + r.head_sha, r.project_id, r.ref, r.number FROM jobs j JOIN runs r ON r.id = j.run_id WHERE j.state = 'queued' @@ -233,6 +234,7 @@ export function createScheduler({ cfg, db, git, logs, storage, projects, logger if (claimed.changes !== 1) continue; const spec = JSON.parse(candidate.spec); + const attempt = candidate.attempt + 1; return { id: candidate.id, run_id: candidate.run_id, @@ -241,12 +243,26 @@ export function createScheduler({ cfg, db, git, logs, storage, projects, logger arch: candidate.arch, image: candidate.image, sha: candidate.head_sha, + ref: candidate.ref, requires, timeout: candidate.timeout, - attempt: candidate.attempt + 1, + attempt, max_attempts: candidate.max_attempts, script: spec.script, - env: spec.env, + // Pipeline env, plus the facts about this run that a script + // should not have to be told twice. These are set last so a + // pipeline cannot quietly redefine them. + env: { + ...spec.env, + CONDUCTOR_PROJECT: candidate.project_id, + CONDUCTOR_RUN_ID: candidate.run_id, + CONDUCTOR_RUN_NUMBER: String(candidate.number), + CONDUCTOR_JOB: candidate.name, + CONDUCTOR_JOB_ID: candidate.id, + CONDUCTOR_SHA: candidate.head_sha, + CONDUCTOR_REF: candidate.ref ?? '', + CONDUCTOR_ATTEMPT: String(attempt), + }, services: spec.services, artifacts: spec.artifacts, cache: spec.cache, diff --git a/src/worker/agent.js b/src/worker/agent.js @@ -0,0 +1,137 @@ +#!/usr/bin/env node +// src/worker/agent.js - the worker +// +// Polls a conductor for work and runs it in containers. Pull based, so the +// worker needs no inbound connectivity and can sit behind any amount of NAT. +// +// Deliberately free of npm dependencies: copy src/worker onto a host with +// node, tar and a container runtime and it runs. That matters because the +// point of this design is accepting build capacity from machines you do not +// administer. +// +// Usage: +// node src/worker/agent.js [--config worker.json] [--once] + +import { loadWorkerConfig, featureNames } from './config.js'; +import { createClient } from './client.js'; +import { createRuntime } from './docker.js'; +import { hasTar, hasGit } from './source.js'; +import { runJob } from './job.js'; + +const stamp = () => new Date().toISOString().replace('T', ' ').slice(0, 19); + +const logger = { + info: (m) => console.log(`${stamp()} ${m}`), + warn: (m) => console.warn(`${stamp()} warning: ${m}`), + error: (m) => console.error(`${stamp()} error: ${m}`), + debug: () => {}, +}; + +function parseArgv(argv) { + const out = { config: null, once: false }; + for (let i = 0; i < argv.length; i += 1) { + if (argv[i] === '--config' || argv[i] === '-c') { + out.config = argv[i + 1]; + i += 1; + } else if (argv[i] === '--once') { + out.once = true; + } else if (argv[i] === '--help' || argv[i] === '-h') { + console.log('usage: agent.js [--config <file>] [--once]'); + process.exit(0); + } + } + return out; +} + +export async function main(argv = process.argv.slice(2)) { + const args = parseArgv(argv); + const cfg = await loadWorkerConfig(args.config); + const client = createClient(cfg); + const runtime = createRuntime(cfg, { logger }); + + // Fail at startup rather than on the first job, when a missing tool would + // otherwise look like a broken pipeline. + if (!(await runtime.available())) { + throw new Error(`container runtime ${JSON.stringify(cfg.docker)} is not usable; is the daemon running?`); + } + if (!(await hasTar())) { + throw new Error('tar is required to unpack job sources but was not found on PATH'); + } + if (!(await hasGit())) { + logger.warn('git was not found; projects configured for clone mode will fail'); + } + + const features = featureNames(cfg); + logger.info(`worker ${cfg.name} polling ${cfg.conductor_url}`); + logger.info(` arches: ${cfg.arches.length > 0 ? cfg.arches.join(', ') : '(any)'}`); + logger.info(` features: ${features.length > 0 ? features.join(', ') : '(none)'}`); + logger.info(` concurrency: ${cfg.concurrency}`); + + let stopping = false; + const running = new Set(); + + const shutdown = (signal) => { + if (stopping) { + logger.warn('second signal, exiting now'); + process.exit(1); + } + stopping = true; + logger.info(`${signal} received, finishing ${running.size} running job(s)`); + }; + 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) { + let backoff = cfg.poll_interval; + + while (!stopping) { + let job = null; + try { + job = await client.poll({ arches: cfg.arches, features, name: cfg.name }); + backoff = cfg.poll_interval; + } catch (e) { + // A conductor that is down or restarting should not become a busy + // loop, so back off up to a minute. + logger.warn(`poll failed: ${e.message}`); + await sleep(backoff * 1000); + backoff = Math.min(backoff * 2, 60); + continue; + } + + if (!job) { + if (args.once) return; + await sleep(cfg.poll_interval * 1000); + continue; + } + + running.add(job.id); + logger.info(`[${index}] ${job.name} (${job.image})`); + const started = Date.now(); + try { + const result = await runJob({ cfg, client, runtime, job, logger }); + const seconds = ((Date.now() - started) / 1000).toFixed(1); + logger.info(`[${index}] ${job.name} ${result.success ? 'succeeded' : 'failed'} in ${seconds}s`); + } catch (e) { + logger.error(`[${index}] ${job.name} crashed the worker loop: ${e.stack ?? e.message}`); + } finally { + running.delete(job.id); + } + + if (args.once) return; + } + } + + await Promise.all(Array.from({ length: cfg.concurrency }, (_, i) => slot(i + 1))); + logger.info('stopped'); +} + +// Only run when executed directly, so the module stays importable by tests. +if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) { + main().catch((e) => { + logger.error(e.message); + process.exit(1); + }); +} diff --git a/src/worker/artifacts.js b/src/worker/artifacts.js @@ -0,0 +1,84 @@ +// src/worker/artifacts.js - collecting and uploading job output +// +// Patterns are matched with the glob support built into node, so there is +// nothing to install. Matches are confined to the workspace: a job can write +// a symlink pointing anywhere it likes, and following one would upload a +// file the job was never given. + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import { createReadStream } from 'node:fs'; +import { SCRIPT_DIR } from './script.js'; + +const MAX_FILES = 2000; + +export async function collectArtifacts(workspace, patterns, { logger = console } = {}) { + const root = await fs.realpath(workspace); + const seen = new Map(); + + for (const pattern of patterns) { + let matches; + try { + matches = await Array.fromAsync(fs.glob(pattern, { cwd: root })); + } catch (e) { + logger.warn?.(`artifact pattern ${pattern} failed: ${e.message}`); + continue; + } + + for (const relative of matches) { + if (seen.size >= MAX_FILES) { + logger.warn?.(`artifact limit of ${MAX_FILES} files reached, ignoring the rest`); + return [...seen.values()]; + } + + const normalized = relative.split(path.sep).join('/'); + // The generated script lives in the workspace and is not output. + if (normalized === SCRIPT_DIR || normalized.startsWith(`${SCRIPT_DIR}/`)) continue; + if (seen.has(normalized)) continue; + + const absolute = path.join(root, relative); + let resolved; + let stat; + try { + resolved = await fs.realpath(absolute); + stat = await fs.stat(resolved); + } catch { + // Vanished between globbing and stat, or a broken symlink. + continue; + } + + if (!stat.isFile()) continue; + if (resolved !== root && !resolved.startsWith(root + path.sep)) { + logger.warn?.(`refusing artifact ${normalized}: resolves outside the workspace`); + continue; + } + + seen.set(normalized, { path: normalized, absolute: resolved, size: stat.size }); + } + } + + return [...seen.values()]; +} + +export function shouldCollect(when, success) { + if (when === 'always') return true; + if (when === 'on_failure') return !success; + return success; +} + +export async function uploadArtifacts(client, url, files, { logger = console } = {}) { + const uploaded = []; + for (const file of files) { + try { + const body = Readable.toWeb(createReadStream(file.absolute)); + const result = await client.uploadArtifact(url, file.path, body, file.size); + uploaded.push(result); + } catch (e) { + // An artifact that cannot be stored is worth reporting, but the job + // itself already succeeded or failed on its own merits. + logger.warn?.(`could not upload ${file.path}: ${e.message}`); + } + } + return uploaded; +} diff --git a/src/worker/client.js b/src/worker/client.js @@ -0,0 +1,114 @@ +// src/worker/client.js - talking to the conductor +// +// Built on fetch, so there is nothing to install. Every call carries the +// worker token. Network errors are surfaced rather than retried here; the +// agent decides what a failure means, since a failed poll and a failed log +// append want very different handling. + +export class ConductorError extends Error { + constructor(message, { status = 0, body = null } = {}) { + super(message); + this.name = 'ConductorError'; + this.status = status; + this.body = body; + } +} + +export function createClient(cfg) { + const base = cfg.conductor_url; + const auth = { authorization: `Bearer ${cfg.token}` }; + + async function readError(res, action) { + let body = null; + let detail = ''; + try { + const text = await res.text(); + detail = text.slice(0, 512); + body = JSON.parse(text); + } catch { + // A non JSON body is still worth reporting as text. + } + return new ConductorError(`${action} failed: ${res.status} ${res.statusText} ${detail}`.trim(), { + status: res.status, + body, + }); + } + + return { + // Returns a job, or null when there is nothing to do. + async poll({ arches, features, name }) { + const query = new URLSearchParams(); + if (arches.length > 0) query.set('arches', arches.join(',')); + if (features.length > 0) query.set('features', features.join(',')); + if (name) query.set('name', name); + + const res = await fetch(`${base}/api/workers/poll?${query}`, { headers: auth }); + if (res.status === 204) return null; + if (!res.ok) throw await readError(res, 'poll'); + return (await res.json()).job; + }, + + // The tree at the job's commit. Returned as a web stream so it can be + // piped straight into tar without buffering. + async source(url) { + const res = await fetch(url, { headers: auth }); + if (!res.ok) throw await readError(res, 'source download'); + return res.body; + }, + + // Returns the new log size. A 409 means the conductor and the worker + // disagree about the offset, and carries the offset to resume from. + async appendLog(url, chunk, offset) { + const res = await fetch(url, { + method: 'POST', + headers: { ...auth, 'content-type': 'application/octet-stream', 'x-log-offset': String(offset) }, + body: chunk, + }); + + if (res.status === 409) { + const body = await res.json().catch(() => ({})); + return { conflict: true, expected: body.expected_offset ?? 0 }; + } + if (!res.ok) throw await readError(res, 'log append'); + return await res.json(); + }, + + async uploadArtifact(url, relPath, body, size) { + const res = await fetch(url, { + method: 'POST', + headers: { + ...auth, + 'content-type': 'application/octet-stream', + 'content-length': String(size), + 'x-artifact-path': relPath, + }, + body, + duplex: 'half', + }); + if (!res.ok) throw await readError(res, `artifact upload (${relPath})`); + return await res.json(); + }, + + // Reports that the job is alive, and learns whether it should stop. + async heartbeat(url) { + const res = await fetch(url, { + method: 'POST', + headers: { ...auth, 'content-type': 'application/json' }, + body: '{}', + }); + if (res.status === 404) return { cancelled: true, gone: true }; + if (!res.ok) throw await readError(res, 'heartbeat'); + return await res.json(); + }, + + async done(url, { success, exitCode, error }) { + const res = await fetch(url, { + method: 'POST', + headers: { ...auth, 'content-type': 'application/json' }, + body: JSON.stringify({ success, exit_code: exitCode ?? null, error: error ?? null }), + }); + if (!res.ok) throw await readError(res, 'done'); + return await res.json(); + }, + }; +} diff --git a/src/worker/config.js b/src/worker/config.js @@ -0,0 +1,206 @@ +// src/worker/config.js - worker configuration +// +// The worker deliberately has no npm dependencies, so that a third party can +// copy src/worker into a host and run it with nothing but node and a +// container runtime. JSON is therefore the native config format. YAML is +// accepted too, but only when the yaml package happens to be resolvable, +// which it is in the official image. +// +// Features are the important part. A feature is a name the worker +// advertises, plus whatever local resources a job asking for it should get: +// mounts, environment, or a privileged container. The conductor only ever +// sees the name, so a signing key can be handed to a build without the +// server holding it. + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +const DEFAULTS = { + conductor_url: 'http://127.0.0.1:8080', + name: os.hostname(), + token: null, + token_file: null, + arches: [], + features: {}, + concurrency: 1, + poll_interval: 5, + // Container runtime CLI. Podman and nerdctl are argument compatible. + docker: 'docker', + // Shell used to run a job script inside its image. + shell: 'sh', + workspace_root: null, + cache_root: null, + // Applied when a job does not ask for something longer. + default_timeout: 3600, +}; + +const ENV_MAP = [ + ['CONDUCTOR_URL', 'conductor_url', String], + ['CONDUCTOR_WORKER_NAME', 'name', String], + ['CONDUCTOR_WORKER_TOKEN', 'token', String], + ['CONDUCTOR_WORKER_TOKEN_FILE', 'token_file', String], + ['CONDUCTOR_WORKER_ARCHES', 'arches', (v) => splitList(v)], + ['CONDUCTOR_WORKER_CONCURRENCY', 'concurrency', toInt], + ['CONDUCTOR_WORKER_POLL_INTERVAL', 'poll_interval', toInt], + ['CONDUCTOR_WORKER_DOCKER', 'docker', String], + ['CONDUCTOR_WORKER_SHELL', 'shell', String], + ['CONDUCTOR_WORKER_WORKSPACE', 'workspace_root', String], + ['CONDUCTOR_WORKER_CACHE', 'cache_root', String], +]; + +function splitList(value) { + return String(value).split(',').map((s) => s.trim()).filter(Boolean); +} + +function toInt(v) { + const n = parseInt(v, 10); + if (!Number.isFinite(n)) throw new Error(`expected an integer, got ${JSON.stringify(v)}`); + return n; +} + +function isPlainObject(v) { + return v !== null && typeof v === 'object' && !Array.isArray(v); +} + +async function readConfigFile(file) { + const text = fs.readFileSync(file, 'utf8'); + if (file.endsWith('.json')) return JSON.parse(text); + + try { + const YAML = (await import('yaml')).default; + return YAML.parse(text); + } catch (e) { + if (e instanceof SyntaxError || e.name === 'YAMLParseError') throw e; + throw new Error( + `cannot read ${file}: the yaml package is not installed. ` + + 'Install it, or use a .json config file.', + { cause: e } + ); + } +} + +// A feature maps a name onto local resources. Everything is optional, so +// `{ "dind": {} }` is a valid way to advertise a capability that needs no +// setup of its own. +function normalizeFeature(name, raw, problems) { + if (raw === null || raw === undefined) return { mounts: [], env: {}, privileged: false, devices: [] }; + if (!isPlainObject(raw)) { + problems.push(`features.${name}: expected a mapping`); + return null; + } + + const allowed = ['mounts', 'env', 'privileged', 'devices']; + for (const key of Object.keys(raw)) { + if (!allowed.includes(key)) problems.push(`features.${name}.${key}: unknown key`); + } + + const mounts = []; + for (const [i, mount] of (raw.mounts ?? []).entries()) { + if (typeof mount !== 'string' || !mount.includes(':')) { + problems.push(`features.${name}.mounts[${i}]: expected "host:container" or "host:container:ro"`); + continue; + } + mounts.push(mount); + } + + const env = {}; + for (const [key, value] of Object.entries(raw.env ?? {})) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { + problems.push(`features.${name}.env.${key}: not a valid environment variable name`); + continue; + } + env[key] = String(value); + } + + return { + mounts, + env, + privileged: raw.privileged === true, + devices: (raw.devices ?? []).map(String), + }; +} + +export async function loadWorkerConfig(explicitPath) { + const file = explicitPath || process.env.CONDUCTOR_WORKER_CONFIG || null; + let cfg = { ...DEFAULTS }; + + 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 }; + } + + for (const [env, key, parse] of ENV_MAP) { + const raw = process.env[env]; + if (raw === undefined || raw === '') continue; + try { + cfg[key] = parse(raw); + } catch (e) { + throw new Error(`invalid value for ${env}: ${e.message}`); + } + } + + const problems = []; + + if (typeof cfg.conductor_url !== 'string') problems.push('conductor_url must be a string'); + else { + try { + new URL(cfg.conductor_url); + } catch { + problems.push(`conductor_url must be an absolute URL, got ${JSON.stringify(cfg.conductor_url)}`); + } + } + + if (!Array.isArray(cfg.arches)) { + problems.push('arches must be a list'); + cfg.arches = []; + } + if (!Number.isInteger(cfg.concurrency) || cfg.concurrency < 1 || cfg.concurrency > 64) { + problems.push(`concurrency must be between 1 and 64, got ${cfg.concurrency}`); + } + if (!Number.isInteger(cfg.poll_interval) || cfg.poll_interval < 1) { + problems.push(`poll_interval must be at least 1 second, got ${cfg.poll_interval}`); + } + + const features = {}; + if (!isPlainObject(cfg.features)) { + problems.push('features must be a mapping of feature name to definition'); + } else { + for (const [name, raw] of Object.entries(cfg.features)) { + if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name)) { + problems.push(`features.${name}: invalid feature name`); + continue; + } + const feature = normalizeFeature(name, raw, problems); + if (feature) features[name] = feature; + } + } + cfg.features = features; + + // The token may be inline, in a file, or in the environment. A file is + // preferred so it never appears in the process list or in `docker inspect`. + if (!cfg.token && cfg.token_file) { + try { + cfg.token = fs.readFileSync(cfg.token_file, 'utf8').trim(); + } catch (e) { + problems.push(`cannot read token_file ${cfg.token_file}: ${e.message}`); + } + } + if (!cfg.token) problems.push('no worker token: set token, token_file, or CONDUCTOR_WORKER_TOKEN'); + + cfg.workspace_root = path.resolve(cfg.workspace_root || path.join(os.tmpdir(), 'conductor-work')); + cfg.cache_root = path.resolve(cfg.cache_root || path.join(os.tmpdir(), 'conductor-cache')); + cfg.conductor_url = String(cfg.conductor_url).replace(/\/+$/, ''); + + if (problems.length > 0) { + throw new Error(`invalid worker configuration:\n - ${problems.join('\n - ')}`); + } + + return cfg; +} + +export function featureNames(cfg) { + return Object.keys(cfg.features).sort(); +} diff --git a/src/worker/docker.js b/src/worker/docker.js @@ -0,0 +1,133 @@ +// src/worker/docker.js - container runtime +// +// Shells out to a docker compatible CLI. Podman and nerdctl accept the same +// arguments, so the binary is configurable and nothing here is Docker +// specific beyond the command names. +// +// Arguments are always passed as an array and never through a shell, since +// image names, environment values and mount paths all originate from a +// repository. + +import crypto from 'node:crypto'; +import { execFile, spawn } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +// Docker requires [a-zA-Z0-9][a-zA-Z0-9_.-]*, while job names contain +// colons, commas and equals signs. A hash suffix keeps names unique after +// the unsafe characters are folded away. +export function containerName(prefix, id, suffix = '') { + const digest = crypto.createHash('sha256').update(id).digest('hex').slice(0, 8); + const safe = id.replace(/[^a-zA-Z0-9_.-]/g, '-').replace(/^[^a-zA-Z0-9]+/, '').slice(0, 40); + return [prefix, safe, digest, suffix].filter(Boolean).join('-'); +} + +export function createRuntime(cfg, { logger = console } = {}) { + const bin = cfg.docker; + + async function run(args, { timeout = 120000 } = {}) { + try { + const { stdout } = await execFileAsync(bin, args, { timeout, maxBuffer: 8 * 1024 * 1024 }); + return stdout.trim(); + } catch (e) { + const detail = (e.stderr || e.message || '').toString().trim().split('\n').slice(0, 3).join('; '); + const err = new Error(`${bin} ${args[0]} failed: ${detail}`); + err.cause = e; + throw err; + } + } + + // Turns a job and the worker's feature definitions into run arguments. + function buildRunArgs({ name, image, network, workspace, env = {}, mounts = [], privileged = false, devices = [], command = [], entrypoint = null, detach = false, networkAlias = null }) { + const args = ['run', '--rm', '--name', name]; + if (detach) args.push('--detach'); + else args.push('--attach', 'stdout', '--attach', 'stderr'); + + if (network) args.push('--network', network); + if (networkAlias) args.push('--network-alias', networkAlias); + if (privileged) args.push('--privileged'); + + for (const device of devices) args.push('--device', device); + for (const mount of mounts) args.push('--volume', mount); + + if (workspace) { + args.push('--volume', `${workspace}:/workspace`); + args.push('--workdir', '/workspace'); + } + + for (const [key, value] of Object.entries(env)) { + args.push('--env', `${key}=${value}`); + } + + if (entrypoint !== null) args.push('--entrypoint', entrypoint); + args.push(image); + args.push(...command); + return args; + } + + return { + buildRunArgs, + + async available() { + try { + await run(['version', '--format', '{{.Server.Version}}'], { timeout: 15000 }); + return true; + } catch { + return false; + } + }, + + async createNetwork(name) { + await run(['network', 'create', name]); + return name; + }, + + async removeNetwork(name) { + try { + await run(['network', 'rm', name]); + } catch (e) { + logger.warn?.(`could not remove network ${name}: ${e.message}`); + } + }, + + // Services run detached; their output is not part of the job log. + async startService(options) { + return run(buildRunArgs({ ...options, detach: true })); + }, + + // Starts the job container and hands back the process. The caller reads + // stdout and stderr, and awaits the exit code. + start(options) { + const args = buildRunArgs({ ...options, detach: false }); + const child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + return { child, args }; + }, + + // Runs a container to completion and returns its output. Used for + // short housekeeping tasks rather than for jobs. + async runOnce(options, { timeout = 120000 } = {}) { + return run(buildRunArgs({ ...options, detach: false }), { timeout }); + }, + + async kill(name, signal = 'KILL') { + try { + await run(['kill', '--signal', signal, name], { timeout: 30000 }); + } catch { + // Already gone, which is the desired state. + } + }, + + async remove(name) { + try { + await run(['rm', '--force', name], { timeout: 30000 }); + } catch { + // Already gone. + } + }, + + async pull(image) { + return run(['pull', image], { timeout: 30 * 60 * 1000 }); + }, + }; +} diff --git a/src/worker/job.js b/src/worker/job.js @@ -0,0 +1,251 @@ +// src/worker/job.js - running one job +// +// Sequence: prepare a workspace, fetch the tree, start any services on a +// private network, run the job container, ship its output, upload +// artifacts, report the outcome, and clean up whatever was created. +// +// Cleanup runs whatever happened. A worker that leaks containers or +// networks stops being usable after a few dozen jobs. + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { containerName } from './docker.js'; +import { createLogStream } from './logstream.js'; +import { fetchSource } from './source.js'; +import { buildScript, SCRIPT_DIR, SCRIPT_NAME, SCRIPT_PATH } from './script.js'; +import { collectArtifacts, uploadArtifacts, shouldCollect } from './artifacts.js'; + +// Resolves the features a job asked for into local resources. +export function resolveFeatures(job, available) { + const mounts = []; + const env = {}; + const devices = []; + let privileged = false; + const missing = []; + + for (const name of job.requires ?? []) { + const feature = available[name]; + if (!feature) { + missing.push(name); + continue; + } + mounts.push(...feature.mounts); + devices.push(...feature.devices); + Object.assign(env, feature.env); + if (feature.privileged) privileged = true; + } + + return { mounts, env, devices, privileged, missing }; +} + +// Cache directories are bind mounted straight onto their path in the +// workspace, which is cheaper than copying in and out around every job. +export async function prepareCache(cfg, job) { + if (!job.cache || !Array.isArray(job.cache.paths)) return []; + + const key = String(job.cache.key ?? 'default').replace(/[^A-Za-z0-9_.-]/g, '-').slice(0, 128); + const project = String(job.project_id ?? 'project').replace(/[^A-Za-z0-9_.-]/g, '-'); + const mounts = []; + + for (const relative of job.cache.paths) { + const normalized = relative.split('/').filter((s) => s && s !== '.' && s !== '..').join('/'); + if (!normalized) continue; + const host = path.join(cfg.cache_root, project, key, normalized); + await fs.mkdir(host, { recursive: true }); + mounts.push(`${host}:/workspace/${normalized}`); + } + + return mounts; +} + +export async function runJob({ cfg, client, runtime, job, logger = console }) { + const workspace = path.join(cfg.workspace_root, containerName('ws', job.id)); + const network = containerName('conductor-net', job.id); + const jobContainer = containerName('conductor', job.id); + + const log = createLogStream(client, job.endpoints.log, { masked: job.masked ?? [], logger }); + + const services = []; + let networkCreated = false; + let heartbeat = null; + let timeoutTimer = null; + let cancelled = false; + let timedOut = false; + let exitCode = null; + let failure = null; + + try { + await fs.mkdir(workspace, { recursive: true }); + + log.note(`[conductor] job ${job.name} on ${cfg.name}`); + log.note(`[conductor] commit ${String(job.sha).slice(0, 12)} attempt ${job.attempt}`); + log.note(`[conductor] image ${job.image}`); + + const features = resolveFeatures(job, cfg.features); + if (features.missing.length > 0) { + // The conductor should never send such a job, so this means the two + // sides disagree about what this worker offers. + throw new Error(`worker does not provide required feature(s): ${features.missing.join(', ')}`); + } + + await fetchSource(client, job, workspace); + + await fs.mkdir(path.join(workspace, SCRIPT_DIR), { recursive: true }); + await fs.writeFile(path.join(workspace, SCRIPT_PATH), buildScript(job.script), { mode: 0o755 }); + + const cacheMounts = await prepareCache(cfg, job); + + await runtime.createNetwork(network); + networkCreated = true; + + for (const service of job.services ?? []) { + const name = containerName('conductor-svc', job.id, service.alias); + log.note(`[conductor] starting service ${service.image} as ${service.alias}`); + await runtime.startService({ + name, + image: service.image, + network, + networkAlias: service.alias, + env: service.env ?? {}, + entrypoint: service.entrypoint?.length ? service.entrypoint[0] : null, + command: service.entrypoint?.length ? [...service.entrypoint.slice(1), ...(service.command ?? [])] : (service.command ?? []), + }); + services.push(name); + } + + const { child } = runtime.start({ + name: jobContainer, + image: job.image, + network, + workspace, + env: { ...features.env, ...job.env }, + mounts: [...features.mounts, ...cacheMounts], + devices: features.devices, + privileged: features.privileged, + entrypoint: cfg.shell, + command: [`/workspace/${SCRIPT_PATH}`], + }); + + child.stdout.on('data', (chunk) => log.write(chunk)); + child.stderr.on('data', (chunk) => log.write(chunk)); + + // Report in, and find out whether the run was cancelled underneath us. + const beatEvery = Math.max(5, Number(job.heartbeat_interval) || 30) * 1000; + heartbeat = setInterval(async () => { + try { + const result = await client.heartbeat(job.endpoints.heartbeat); + if (result.cancelled && !cancelled) { + cancelled = true; + log.note('[conductor] cancelled, stopping the container'); + await runtime.kill(jobContainer); + } + } catch (e) { + logger.warn?.(`heartbeat failed for ${job.name}: ${e.message}`); + } + }, beatEvery); + heartbeat.unref?.(); + + const limit = (Number(job.timeout) || cfg.default_timeout) * 1000; + timeoutTimer = setTimeout(async () => { + timedOut = true; + log.note(`[conductor] timed out after ${Math.round(limit / 1000)}s, stopping the container`); + await runtime.kill(jobContainer); + }, limit); + timeoutTimer.unref?.(); + + exitCode = await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', resolve); + }); + } catch (e) { + failure = e; + log.note(`[conductor] ${e.message}`); + } finally { + if (heartbeat) clearInterval(heartbeat); + if (timeoutTimer) clearTimeout(timeoutTimer); + } + + const success = failure === null && exitCode === 0 && !cancelled && !timedOut; + + // Artifacts are collected before teardown, and even after a failure when + // the pipeline asked for that. + if (job.artifacts && shouldCollect(job.artifacts.when, success)) { + try { + const files = await collectArtifacts(workspace, job.artifacts.paths, { logger }); + if (files.length > 0) { + log.note(`[conductor] uploading ${files.length} artifact(s)`); + await uploadArtifacts(client, job.endpoints.artifact, files, { logger }); + } else { + log.note('[conductor] no files matched the artifact patterns'); + } + } catch (e) { + log.note(`[conductor] artifact collection failed: ${e.message}`); + logger.warn?.(`artifact collection failed for ${job.name}: ${e.message}`); + } + } + + const error = failure + ? failure.message + : timedOut + ? 'job timed out' + : cancelled + ? 'job cancelled' + : exitCode === 0 + ? null + : `job exited ${exitCode}`; + + if (error) log.note(`[conductor] ${error}`); + else log.note('[conductor] done'); + + await log.close(); + + // Teardown first, so a slow conductor cannot hold containers open. + await cleanup(); + + try { + await client.done(job.endpoints.done, { success, exitCode, error }); + } catch (e) { + logger.error?.(`could not report completion of ${job.name}: ${e.message}`); + } + + return { success, exitCode, error, cancelled, timedOut }; + + async function cleanup() { + for (const name of services) await runtime.remove(name); + await runtime.remove(jobContainer); + if (networkCreated) await runtime.removeNetwork(network); + await removeWorkspace(); + } + + // Most images run as root, so files the job created in the bind mounted + // workspace are owned by root while the worker usually is not. Removing + // them from the host then fails with EACCES. The directory itself belongs + // to the worker, so emptying it from inside a container as root and then + // removing the empty directory works without forcing every job image to + // run as the worker's uid. + async function removeWorkspace() { + try { + await fs.rm(workspace, { recursive: true, force: true }); + return; + } catch (e) { + if (e.code !== 'EACCES' && e.code !== 'EPERM' && e.code !== 'ENOTEMPTY') { + logger.warn?.(`could not remove workspace ${workspace}: ${e.message}`); + return; + } + } + + try { + // The job image is already present locally, so nothing is pulled. + await runtime.runOnce({ + name: containerName('conductor-clean', job.id), + image: job.image, + workspace, + entrypoint: cfg.shell, + command: ['-c', 'rm -rf /workspace/..?* /workspace/.[!.]* /workspace/* 2>/dev/null || true'], + }); + await fs.rm(workspace, { recursive: true, force: true }); + } catch (e) { + logger.warn?.(`could not remove workspace ${workspace}: ${e.message}`); + } + } +} diff --git a/src/worker/logstream.js b/src/worker/logstream.js @@ -0,0 +1,162 @@ +// src/worker/logstream.js - batching log shipper with masking +// +// Job output arrives as many small writes; shipping each one would be a +// request per line. Output is accumulated and flushed on a timer, or as soon +// as it grows past a threshold. +// +// The offset the conductor has accepted is tracked locally, so a retry after +// a dropped connection resends from the right place rather than duplicating +// or losing output. A 409 carries the conductor's view of the offset and +// wins, since it is the side holding the file. +// +// Masking happens here, before anything leaves the host. Two details make it +// actually work: +// +// Masking is applied when a batch is assembled, not as each write +// arrives, and the last few characters are held back until more output +// appears. A secret written across two chunks is therefore still matched. +// Masking each write on its own would defeat the hold back entirely. +// +// While masking is active the stream is decoded as UTF-8 with a +// StringDecoder, so a multi byte character split across writes is not +// mangled. With no secrets configured the bytes are passed through +// untouched and the stream stays binary safe. + +import { StringDecoder } from 'node:string_decoder'; + +const FLUSH_BYTES = 16 * 1024; +const FLUSH_MS = 1000; +const REDACTION = '[masked]'; + +// Below this length a value is too common to redact usefully, and would +// turn ordinary output into noise. +const MIN_SECRET_LENGTH = 4; + +export function createLogStream(client, url, { masked = [], flushMs = FLUSH_MS, logger = console } = {}) { + // Longest first, so an overlapping shorter secret cannot partially + // reveal a longer one. + const secrets = [...new Set(masked.filter((s) => typeof s === 'string' && s.length >= MIN_SECRET_LENGTH))] + .sort((a, b) => b.length - a.length); + const masking = secrets.length > 0; + const holdBack = masking ? Math.max(...secrets.map((s) => s.length)) - 1 : 0; + + const decoder = masking ? new StringDecoder('utf8') : null; + let text = ''; + let bytes = Buffer.alloc(0); + let outbox = Buffer.alloc(0); + + let offset = 0; + let timer = null; + let flushing = null; + let ended = false; + let failed = null; + + function mask(value) { + let out = value; + for (const secret of secrets) out = out.split(secret).join(REDACTION); + return out; + } + + function buffered() { + return outbox.length + (masking ? text.length : bytes.length); + } + + // Moves everything that is safe to send into the outbox, masking on the + // way. Anything within holdBack of the end waits for more input, unless + // this is the final flush. + function assemble(final) { + if (!masking) { + if (bytes.length === 0) return; + outbox = Buffer.concat([outbox, bytes]); + bytes = Buffer.alloc(0); + return; + } + + if (final) text += decoder.end(); + const keep = final ? 0 : Math.min(holdBack, text.length); + const take = text.slice(0, text.length - keep); + text = text.slice(text.length - keep); + if (take.length === 0) return; + outbox = Buffer.concat([outbox, Buffer.from(mask(take), 'utf8')]); + } + + function schedule() { + if (timer !== null || ended) return; + timer = setTimeout(() => { timer = null; void flush(); }, flushMs); + timer.unref?.(); + } + + async function flush(final = false) { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + // Serialize, so two flushes cannot race on the offset. + while (flushing) await flushing; + + assemble(final); + if (outbox.length === 0) return; + + flushing = (async () => { + while (outbox.length > 0) { + const chunk = outbox; + outbox = Buffer.alloc(0); + try { + const result = await client.appendLog(url, chunk, offset); + if (result.conflict) { + // The conductor knows what it already has; drop that much and + // resend the remainder. + const skip = result.expected - offset; + offset = result.expected; + const remainder = skip > 0 ? chunk.subarray(Math.min(skip, chunk.length)) : chunk; + outbox = Buffer.concat([remainder, outbox]); + continue; + } + offset = result.size; + } catch (e) { + // Losing log output must not fail an otherwise good job. + failed = e; + logger.warn?.(`log shipping failed, dropping ${chunk.length} bytes: ${e.message}`); + } + } + })(); + + try { + await flushing; + } finally { + flushing = null; + } + } + + return { + write(chunk) { + if (ended) return; + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8'); + if (masking) text += decoder.write(buffer); + else bytes = Buffer.concat([bytes, buffer]); + + if (buffered() >= FLUSH_BYTES) void flush(); + else schedule(); + }, + + // A line from the worker rather than the job, so the log can explain a + // timeout, a cancellation or a setup failure. + note(line) { + this.write(`${line}\n`); + }, + + async close() { + if (ended) return; + await flush(true); + ended = true; + }, + + get offset() { + return offset; + }, + + get error() { + return failed; + }, + }; +} diff --git a/src/worker/script.js b/src/worker/script.js @@ -0,0 +1,29 @@ +// src/worker/script.js - turning a job's commands into a shell script +// +// The script is written into the workspace and run by path rather than piped +// to the shell's stdin, so that a command which itself reads stdin cannot +// swallow the rest of the script. + +// Single quoting is the only form that is safe for arbitrary text in POSIX +// sh: everything inside is literal, and an embedded quote is closed, +// escaped and reopened. +export function shellQuote(value) { + return `'${String(value).replace(/'/g, "'\\''")}'`; +} + +// Each command is echoed before it runs, so a reader can tell which one +// produced what. set -e stops at the first failure, and the exit code of +// that command becomes the exit code of the script. +export function buildScript(commands) { + const lines = ['#!/bin/sh', 'set -e', '']; + for (const command of commands) { + lines.push(`printf '%s\\n' ${shellQuote(`$ ${command}`)}`); + lines.push(command); + lines.push(''); + } + return lines.join('\n'); +} + +export const SCRIPT_DIR = '.conductor'; +export const SCRIPT_NAME = 'script.sh'; +export const SCRIPT_PATH = `${SCRIPT_DIR}/${SCRIPT_NAME}`; diff --git a/src/worker/source.js b/src/worker/source.js @@ -0,0 +1,77 @@ +// src/worker/source.js - getting the tree into the workspace +// +// Two modes, chosen per project by the conductor: +// +// archive download a tarball of the exact commit from the conductor and +// unpack it. The worker needs no repository credentials and can +// reach no ref other than the one it was given work for. +// clone git clone the repository directly, for repositories large +// enough that a tarball per job is wasteful. +// +// Unpacking shells out to the host tar. Both GNU and BSD tar accept these +// arguments; availability is checked once at startup. + +import { spawn, execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +const execFileAsync = promisify(execFile); + +export async function hasTar() { + try { + await execFileAsync('tar', ['--version'], { timeout: 10000 }); + return true; + } catch { + return false; + } +} + +export async function hasGit() { + try { + await execFileAsync('git', ['--version'], { timeout: 10000 }); + return true; + } catch { + return false; + } +} + +// Streams a gzipped tar into a directory without staging it on disk. +export async function extractTarGz(stream, directory) { + const child = spawn('tar', ['-xzf', '-', '-C', directory, '--no-same-owner'], { + stdio: ['pipe', 'ignore', 'pipe'], + }); + + let stderr = ''; + child.stderr.on('data', (chunk) => { stderr += chunk.toString().slice(0, 4096); }); + + const exited = new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`tar exited ${code}: ${stderr.trim()}`)); + }); + }); + + const source = stream instanceof Readable ? stream : Readable.fromWeb(stream); + await Promise.all([pipeline(source, child.stdin), exited]); +} + +export async function fetchSource(client, job, workspace, { timeout = 600000 } = {}) { + const source = job.source ?? {}; + + if (source.mode === 'clone') { + const env = { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '' }; + await execFileAsync('git', ['clone', '--quiet', '--no-checkout', '--', source.repo_url, workspace], { timeout, env }); + await execFileAsync('git', ['-C', workspace, 'checkout', '--quiet', source.sha], { timeout, env }); + return { mode: 'clone' }; + } + + if (source.mode === 'archive') { + const body = await client.source(source.url); + await extractTarGz(body, workspace); + return { mode: 'archive' }; + } + + throw new Error(`unsupported source mode: ${JSON.stringify(source.mode)}`); +} diff --git a/test/conductor.test.js b/test/conductor.test.js @@ -224,6 +224,40 @@ test('a claimed job carries everything the worker needs', async () => { assert.equal(job.source.mode, 'archive'); assert.match(job.source.url, /^http:\/\/conductor\.test\/api\/workers\/jobs\//); assert.ok(job.endpoints.log.endsWith('/log')); + + // The worker resolves features and cache keys from these two, so a + // missing field silently disables both. + assert.deepEqual(job.requires, []); + assert.equal(job.project_id, 'demo'); + assert.ok(Number.isInteger(job.heartbeat_interval) && job.heartbeat_interval > 0); + assert.deepEqual(job.masked, []); + }); +}); + +test('a job that requires a feature reports it to the worker', async () => { + await withHarness({}, async (h) => { + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + const roots = await drain(h, { arches: 'x86_64,aarch64' }); + for (const job of Object.values(roots)) await finish(h, job.id, { success: true }); + + const pkg = (await drain(h, { arches: 'x86_64', features: 'sign-key' }))['package:arch=x86_64,pkg=musl']; + assert.ok(pkg); + assert.deepEqual(pkg.requires, ['sign-key']); + }); +}); + +test('the standard conductor variables are present in the job environment', 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(job.env.CONDUCTOR_PROJECT, 'demo'); + assert.equal(job.env.CONDUCTOR_RUN_ID, run); + assert.equal(job.env.CONDUCTOR_RUN_NUMBER, '1'); + assert.equal(job.env.CONDUCTOR_JOB, 'lint'); + assert.equal(job.env.CONDUCTOR_SHA, h.sha); + assert.equal(job.env.CONDUCTOR_REF, 'refs/heads/main'); + assert.equal(job.env.CONDUCTOR_ATTEMPT, '1'); }); }); diff --git a/test/helpers/harness.js b/test/helpers/harness.js @@ -6,6 +6,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; +import net from 'node:net'; import path from 'node:path'; import crypto from 'node:crypto'; import { execFile } from 'node:child_process'; @@ -65,8 +66,10 @@ export async function startHarness(options = {}) { const configFile = path.join(root, 'conductor.yaml'); await fs.writeFile(configFile, [ 'server:', - ' port: 0', - ' public_url: http://conductor.test', + // A real port and a matching public_url are needed whenever something + // outside the process, such as a worker, has to reach back in. + ` port: ${options.port ?? 0}`, + ` public_url: ${options.publicUrl ?? 'http://conductor.test'}`, 'database:', ' path: ./state/conductor.db', 'storage:', @@ -154,7 +157,9 @@ export async function startHarness(options = {}) { async stop() { await app.close(); await services.db.close(); - await fs.rm(root, { recursive: true, force: true }); + // Best effort: a container that ran as root may have left files the + // test process cannot remove, and that must not mask a real failure. + await fs.rm(root, { recursive: true, force: true }).catch(() => {}); }, }; } @@ -162,3 +167,17 @@ export async function startHarness(options = {}) { function silentLogger() { return { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }; } + +// Asks the kernel for a free port and releases it. Only needed because the +// public_url has to be known before the server is built. +export async function freePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + server.close(() => resolve(port)); + }); + }); +} diff --git a/test/worker-docker.test.js b/test/worker-docker.test.js @@ -0,0 +1,340 @@ +// test/worker-docker.test.js - the whole loop, for real +// +// Runs actual containers against an actual conductor: trigger, claim, fetch +// source, run, stream logs, upload artifacts, report, and clean up. +// Skipped when no container runtime is available. +// +// The conductor is bound to a real port here rather than driven through +// inject, because the worker is a separate process talking over HTTP. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { startHarness, freePort } from './helpers/harness.js'; +import { createClient } from '../src/worker/client.js'; +import { createRuntime, containerName } from '../src/worker/docker.js'; +import { runJob } from '../src/worker/job.js'; + +const execFileAsync = promisify(execFile); + +const IMAGE = process.env.CONDUCTOR_TEST_IMAGE || 'alpine:3'; + +async function dockerUsable() { + if (process.env.CONDUCTOR_TEST_NO_DOCKER) return false; + try { + await execFileAsync('docker', ['version', '--format', '{{.Server.Version}}'], { timeout: 15000 }); + await execFileAsync('docker', ['image', 'inspect', IMAGE], { timeout: 15000 }) + .catch(() => execFileAsync('docker', ['pull', IMAGE], { timeout: 300000 })); + return true; + } catch { + return false; + } +} + +const available = await dockerUsable(); +const opts = { skip: available ? false : 'docker is not available' }; + +// Starts the harness on a real port so the worker can reach it over HTTP. +// The port has to be settled before the server is built, because the job +// payload embeds absolute callback URLs derived from public_url. +async function listening(options = {}) { + const port = await freePort(); + const url = `http://127.0.0.1:${port}`; + const h = await startHarness({ ...options, port, publicUrl: url }); + await h.app.listen({ port, host: '127.0.0.1' }); + + const workerCfg = { + conductor_url: url, + name: 'docker-test-worker', + token: h.worker.token, + arches: [], + features: {}, + concurrency: 1, + poll_interval: 1, + docker: 'docker', + shell: 'sh', + workspace_root: `${h.root}/work`, + cache_root: `${h.root}/cache`, + default_timeout: 300, + ...(options?.worker ?? {}), + }; + + return { + ...h, + url, + workerCfg, + client: createClient(workerCfg), + runtime: createRuntime(workerCfg, { logger: quiet }), + }; +} + +const quiet = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }; + +async function logOf(h, jobId) { + const res = await h.app.inject({ method: 'GET', url: `/api/jobs/${encodeURIComponent(jobId)}/log` }); + return res.body; +} + +// Anchored, because a substring filter would also match unrelated +// containers that happen to share the prefix. +async function exists(kind, name) { + const args = kind === 'network' + ? ['network', 'ls', '--filter', `name=^${name}$`, '--format', '{{.Name}}'] + : ['ps', '-a', '--filter', `name=^${name}$`, '--format', '{{.Names}}']; + const { stdout } = await execFileAsync('docker', args); + return stdout.trim().length > 0; +} + +test('a job runs in a container and its output reaches the conductor', opts, async () => { + const pipeline = ` +version: 1 +jobs: + hello: + image: ${IMAGE} + script: + - echo "hello from the job" + - echo "commit is $CONDUCTOR_SHA" + - echo "job is $CONDUCTOR_JOB" + - cat README.md +`; + const h = await listening({ pipeline }); + try { + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + const job = await h.client.poll({ arches: [], features: [], name: 'test' }); + assert.ok(job, 'expected a job'); + + const result = await runJob({ cfg: h.workerCfg, client: h.client, runtime: h.runtime, job, logger: quiet }); + assert.equal(result.success, true, `job failed: ${result.error}`); + assert.equal(result.exitCode, 0); + + const log = await logOf(h, job.id); + assert.match(log, /\$ echo "hello from the job"/, 'each command should be echoed'); + assert.match(log, /hello from the job/); + assert.match(log, new RegExp(`commit is ${h.sha}`), 'CONDUCTOR_SHA should be set'); + assert.match(log, /job is hello/); + // Proves the source tarball was fetched and unpacked. + assert.match(log, /test repository/); + + const detail = await h.app.inject({ method: 'GET', url: `/api/runs/${job.run_id}` }); + assert.equal(detail.json().run.state, 'success'); + } finally { + await h.stop(); + } +}); + +test('a failing command fails the job and preserves the log', opts, async () => { + const pipeline = ` +version: 1 +jobs: + boom: + image: ${IMAGE} + script: + - echo before + - exit 3 + - echo after +`; + const h = await listening({ pipeline }); + try { + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + const job = await h.client.poll({ arches: [], features: [], name: 'test' }); + + const result = await runJob({ cfg: h.workerCfg, client: h.client, runtime: h.runtime, job, logger: quiet }); + assert.equal(result.success, false); + assert.equal(result.exitCode, 3); + + const log = await logOf(h, job.id); + assert.match(log, /before/); + // set -e must stop the script at the failure. + assert.ok(!log.includes('after'), 'commands after a failure must not run'); + + const detail = await h.app.inject({ method: 'GET', url: `/api/runs/${job.run_id}` }); + assert.equal(detail.json().run.state, 'failed'); + } finally { + await h.stop(); + } +}); + +test('artifacts produced in the container are uploaded and downloadable', opts, async () => { + const pipeline = ` +version: 1 +jobs: + produce: + image: ${IMAGE} + script: + - mkdir -p dist/nested + - printf 'binary-content' > dist/app.bin + - printf 'deep' > dist/nested/deep.txt + artifacts: + paths: [dist/**] +`; + const h = await listening({ pipeline }); + try { + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + const job = await h.client.poll({ arches: [], features: [], name: 'test' }); + + const result = await runJob({ cfg: h.workerCfg, client: h.client, runtime: h.runtime, job, logger: quiet }); + assert.equal(result.success, true, `job failed: ${result.error}`); + + const detail = await h.app.inject({ method: 'GET', url: `/api/jobs/${encodeURIComponent(job.id)}` }); + const artifacts = detail.json().artifacts; + const paths = artifacts.map((a) => a.path).sort(); + assert.deepEqual(paths, ['dist/app.bin', 'dist/nested/deep.txt']); + + const binary = artifacts.find((a) => a.path === 'dist/app.bin'); + const download = await h.app.inject({ method: 'GET', url: `/api/artifacts/${binary.id}` }); + assert.equal(download.body, 'binary-content'); + } finally { + await h.stop(); + } +}); + +test('a worker feature injects a mount and environment the conductor never sees', opts, async () => { + const pipeline = ` +version: 1 +jobs: + signed: + image: ${IMAGE} + requires: [sign-key] + script: + - echo "key path is $SIGN_KEY" + - cat "$SIGN_KEY" +`; + const h = await listening({ + pipeline, + worker: { + features: { + 'sign-key': { + mounts: [], + env: { SIGN_KEY: '/keys/build.key' }, + devices: [], + privileged: false, + }, + }, + }, + }); + + try { + // The key lives on the worker host and is mounted in by the feature. + const fs = await import('node:fs/promises'); + await fs.mkdir(`${h.root}/keys`, { recursive: true }); + await fs.writeFile(`${h.root}/keys/build.key`, 'PRIVATE-KEY-MATERIAL'); + h.workerCfg.features['sign-key'].mounts = [`${h.root}/keys/build.key:/keys/build.key:ro`]; + + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + + // Without the feature the job is not offered at all. + const denied = await createClient({ ...h.workerCfg, token: h.worker.token }) + .poll({ arches: [], features: [], name: 'test' }); + assert.equal(denied, null, 'a worker lacking the feature must not be offered the job'); + + const job = await h.client.poll({ arches: [], features: ['sign-key'], name: 'test' }); + assert.ok(job, 'expected the job once the feature is advertised'); + + const result = await runJob({ cfg: h.workerCfg, client: h.client, runtime: h.runtime, job, logger: quiet }); + assert.equal(result.success, true, `job failed: ${result.error}`); + + const log = await logOf(h, job.id); + assert.match(log, /key path is \/keys\/build\.key/); + assert.match(log, /PRIVATE-KEY-MATERIAL/); + + // The conductor stored the requirement, never the key itself. + const stored = await h.services.db.get( + 'SELECT requires, spec FROM jobs WHERE id = {id}', { id: job.id } + ); + assert.equal(stored.requires, '["sign-key"]'); + assert.ok(!stored.spec.includes('PRIVATE-KEY-MATERIAL')); + } finally { + await h.stop(); + } +}); + +test('a service container is reachable from the job by its alias', opts, async () => { + const pipeline = ` +version: 1 +jobs: + talks: + image: ${IMAGE} + services: + - image: ${IMAGE} + alias: sidecar + command: [sleep, '60'] + script: + - ping -c1 -W2 sidecar +`; + const h = await listening({ pipeline }); + try { + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + const job = await h.client.poll({ arches: [], features: [], name: 'test' }); + + const result = await runJob({ cfg: h.workerCfg, client: h.client, runtime: h.runtime, job, logger: quiet }); + const log = await logOf(h, job.id); + assert.equal(result.success, true, `job failed: ${result.error}\n${log}`); + assert.match(log, /1 packets received|1 received/); + } finally { + await h.stop(); + } +}); + +test('a job that exceeds its timeout is stopped', opts, async () => { + const pipeline = ` +version: 1 +jobs: + slow: + image: ${IMAGE} + timeout: 2 + script: + - echo starting + - sleep 60 +`; + const h = await listening({ pipeline }); + try { + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + const job = await h.client.poll({ arches: [], features: [], name: 'test' }); + + const started = Date.now(); + const result = await runJob({ cfg: h.workerCfg, client: h.client, runtime: h.runtime, job, logger: quiet }); + const elapsed = (Date.now() - started) / 1000; + + assert.equal(result.success, false); + assert.equal(result.timedOut, true); + assert.ok(elapsed < 45, `should have been stopped promptly, took ${elapsed}s`); + assert.match(await logOf(h, job.id), /timed out/); + } finally { + await h.stop(); + } +}); + +test('containers, networks and workspaces are cleaned up', opts, async () => { + const pipeline = ` +version: 1 +jobs: + tidy: + image: ${IMAGE} + services: + - image: ${IMAGE} + alias: sidecar + command: [sleep, '60'] + script: + - echo work +`; + const h = await listening({ pipeline }); + try { + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + const job = await h.client.poll({ arches: [], features: [], name: 'test' }); + await runJob({ cfg: h.workerCfg, client: h.client, runtime: h.runtime, job, logger: quiet }); + + assert.equal(await exists('container', containerName('conductor', job.id)), false, + 'the job container should be gone'); + assert.equal(await exists('container', containerName('conductor-svc', job.id, 'sidecar')), false, + 'the service container should be gone'); + assert.equal(await exists('network', containerName('conductor-net', job.id)), false, + 'the job network should be gone'); + + const fs = await import('node:fs/promises'); + const left = await fs.readdir(`${h.root}/work`).catch(() => []); + assert.deepEqual(left, [], 'the workspace should be removed'); + } finally { + await h.stop(); + } +}); diff --git a/test/worker.test.js b/test/worker.test.js @@ -0,0 +1,319 @@ +// test/worker.test.js - worker units that need no container runtime +// +// The full loop against real Docker lives in worker-docker.test.js. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { loadWorkerConfig, featureNames } from '../src/worker/config.js'; +import { buildScript, shellQuote } from '../src/worker/script.js'; +import { containerName, createRuntime } from '../src/worker/docker.js'; +import { createLogStream } from '../src/worker/logstream.js'; +import { resolveFeatures, prepareCache } from '../src/worker/job.js'; +import { collectArtifacts, shouldCollect } from '../src/worker/artifacts.js'; + +async function tempDir(prefix = 'conductor-worker-') { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +async function withConfigEnv(env, fn) { + const saved = {}; + for (const key of Object.keys(process.env)) { + if (key.startsWith('CONDUCTOR_')) { + saved[key] = process.env[key]; + delete process.env[key]; + } + } + Object.assign(process.env, env); + try { + return await fn(); + } finally { + for (const key of Object.keys(process.env)) { + if (key.startsWith('CONDUCTOR_')) delete process.env[key]; + } + Object.assign(process.env, saved); + } +} + +test('worker config loads from json and reports features', async () => { + const dir = await tempDir(); + const file = path.join(dir, 'worker.json'); + await fs.writeFile(file, JSON.stringify({ + conductor_url: 'https://ci.example.com/', + name: 'pi', + token: 'abc', + arches: ['aarch64'], + features: { + dind: { privileged: true }, + 'sign-key': { mounts: ['/srv/k:/keys/k:ro'], env: { KEY: '/keys/k' } }, + }, + })); + + const cfg = await withConfigEnv({}, () => loadWorkerConfig(file)); + assert.equal(cfg.conductor_url, 'https://ci.example.com'); + assert.deepEqual(featureNames(cfg), ['dind', 'sign-key']); + assert.equal(cfg.features.dind.privileged, true); + assert.deepEqual(cfg.features['sign-key'].mounts, ['/srv/k:/keys/k:ro']); + await fs.rm(dir, { recursive: true, force: true }); +}); + +test('worker config reads the token from a file', async () => { + const dir = await tempDir(); + await fs.writeFile(path.join(dir, 'token'), 'secret-token\n'); + const file = path.join(dir, 'worker.json'); + await fs.writeFile(file, JSON.stringify({ token_file: path.join(dir, 'token') })); + + const cfg = await withConfigEnv({}, () => loadWorkerConfig(file)); + assert.equal(cfg.token, 'secret-token'); + await fs.rm(dir, { recursive: true, force: true }); +}); + +test('environment overrides the worker config file', async () => { + const dir = await tempDir(); + const file = path.join(dir, 'worker.json'); + await fs.writeFile(file, JSON.stringify({ token: 'x', arches: ['x86_64'], concurrency: 1 })); + + const cfg = await withConfigEnv({ + CONDUCTOR_WORKER_ARCHES: 'aarch64,riscv64', + CONDUCTOR_WORKER_CONCURRENCY: '4', + }, () => loadWorkerConfig(file)); + + assert.deepEqual(cfg.arches, ['aarch64', 'riscv64']); + assert.equal(cfg.concurrency, 4); + await fs.rm(dir, { recursive: true, force: true }); +}); + +test('worker config without a token is rejected', async () => { + const dir = await tempDir(); + const file = path.join(dir, 'worker.json'); + await fs.writeFile(file, JSON.stringify({ conductor_url: 'http://x' })); + await assert.rejects(withConfigEnv({}, () => loadWorkerConfig(file)), /no worker token/); + await fs.rm(dir, { recursive: true, force: true }); +}); + +test('a malformed feature is reported with its path', async () => { + const dir = await tempDir(); + const file = path.join(dir, 'worker.json'); + await fs.writeFile(file, JSON.stringify({ + token: 'x', + features: { bad: { mounts: ['no-colon'], nonsense: 1 } }, + })); + // Both problems are reported together; the order between them is not + // part of the contract. + await assert.rejects(withConfigEnv({}, () => loadWorkerConfig(file)), (e) => { + assert.match(e.message, /features\.bad\.mounts\[0\]/); + assert.match(e.message, /features\.bad\.nonsense/); + return true; + }); + await fs.rm(dir, { recursive: true, force: true }); +}); + +test('shell quoting survives quotes and metacharacters', () => { + assert.equal(shellQuote('simple'), "'simple'"); + assert.equal(shellQuote("it's"), "'it'\\''s'"); + assert.equal(shellQuote('$(rm -rf /)'), "'$(rm -rf /)'"); +}); + +test('a generated script echoes each command and stops on failure', () => { + const script = buildScript(['npm ci', "echo 'hi'"]); + assert.match(script, /^#!\/bin\/sh\nset -e\n/); + assert.ok(script.includes("printf '%s\\n' '$ npm ci'")); + assert.ok(script.includes('npm ci\n')); + // The echo of a command containing quotes stays a single argument. + assert.ok(script.includes("printf '%s\\n' '$ echo '\\''hi'\\'''")); +}); + +test('container names are valid for docker and stay unique', () => { + const a = containerName('conductor', 'run1:package:arch=x86_64,pkg=musl'); + const b = containerName('conductor', 'run1:package:arch=x86_64,pkg=busybox'); + assert.match(a, /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/); + assert.notEqual(a, b); + // Distinct jobs that differ only past the truncation point still differ. + const long = 'x'.repeat(200); + assert.notEqual(containerName('c', `${long}a`), containerName('c', `${long}b`)); +}); + +test('run arguments carry mounts, env, network and privilege', () => { + const runtime = createRuntime({ docker: 'docker', shell: 'sh' }); + const args = runtime.buildRunArgs({ + name: 'job1', + image: 'alpine:3', + network: 'net1', + workspace: '/tmp/ws', + env: { FOO: 'bar' }, + mounts: ['/srv/k:/keys/k:ro'], + privileged: true, + entrypoint: 'sh', + command: ['/workspace/.conductor/script.sh'], + }); + + assert.equal(args[0], 'run'); + assert.ok(args.includes('--rm')); + assert.deepEqual(args.slice(args.indexOf('--network'), args.indexOf('--network') + 2), ['--network', 'net1']); + assert.ok(args.includes('--privileged')); + assert.ok(args.includes('/srv/k:/keys/k:ro')); + assert.ok(args.includes('/tmp/ws:/workspace')); + assert.ok(args.includes('FOO=bar')); + // The image must come before its command. + assert.ok(args.indexOf('alpine:3') < args.indexOf('/workspace/.conductor/script.sh')); +}); + +test('features resolve into mounts, env and privilege', () => { + const available = { + dind: { mounts: [], env: {}, devices: [], privileged: true }, + 'sign-key': { mounts: ['/srv/k:/keys/k:ro'], env: { KEY: '/keys/k' }, devices: [], privileged: false }, + }; + const resolved = resolveFeatures({ requires: ['sign-key', 'dind'] }, available); + assert.deepEqual(resolved.mounts, ['/srv/k:/keys/k:ro']); + assert.deepEqual(resolved.env, { KEY: '/keys/k' }); + assert.equal(resolved.privileged, true); + assert.deepEqual(resolved.missing, []); +}); + +test('an unknown required feature is reported rather than ignored', () => { + const resolved = resolveFeatures({ requires: ['nope'] }, {}); + assert.deepEqual(resolved.missing, ['nope']); +}); + +test('cache paths become bind mounts and are created', async () => { + const dir = await tempDir(); + const mounts = await prepareCache( + { cache_root: dir }, + { project_id: 'demo', cache: { key: 'npm', paths: ['.npm', '../escape/attempt'] } } + ); + + assert.equal(mounts.length, 2); + assert.ok(mounts[0].endsWith(':/workspace/.npm')); + // Traversal is stripped, not honoured. + assert.ok(mounts[1].endsWith(':/workspace/escape/attempt')); + for (const mount of mounts) { + const host = mount.split(':')[0]; + assert.ok((await fs.stat(host)).isDirectory()); + assert.ok(host.startsWith(dir)); + } + await fs.rm(dir, { recursive: true, force: true }); +}); + +test('artifact collection globs, and refuses to leave the workspace', async () => { + const dir = await tempDir(); + const outside = await tempDir('conductor-outside-'); + await fs.writeFile(path.join(outside, 'secret.txt'), 'do not upload me'); + + await fs.mkdir(path.join(dir, 'dist/nested'), { recursive: true }); + await fs.mkdir(path.join(dir, '.conductor'), { recursive: true }); + await fs.writeFile(path.join(dir, 'dist/app.bin'), 'binary'); + await fs.writeFile(path.join(dir, 'dist/nested/deep.bin'), 'deep'); + await fs.writeFile(path.join(dir, '.conductor/script.sh'), 'set -e'); + await fs.symlink(path.join(outside, 'secret.txt'), path.join(dir, 'dist/escape.txt')); + + const found = await collectArtifacts(dir, ['dist/**'], { logger: { warn: () => {} } }); + const names = found.map((f) => f.path).sort(); + + assert.ok(names.includes('dist/app.bin')); + assert.ok(names.includes('dist/nested/deep.bin')); + assert.ok(!names.includes('dist/escape.txt'), 'symlink out of the workspace must be refused'); + assert.ok(!names.some((n) => n.startsWith('.conductor'))); + assert.equal(found.find((f) => f.path === 'dist/app.bin').size, 6); + + await fs.rm(dir, { recursive: true, force: true }); + await fs.rm(outside, { recursive: true, force: true }); +}); + +test('artifact when controls collection', () => { + assert.equal(shouldCollect('on_success', true), true); + assert.equal(shouldCollect('on_success', false), false); + assert.equal(shouldCollect('on_failure', false), true); + assert.equal(shouldCollect('on_failure', true), false); + assert.equal(shouldCollect('always', false), true); +}); + +// A client stub that records what the log stream sends. +function recordingClient() { + const calls = []; + let size = 0; + return { + calls, + get text() { + return calls.map((c) => c.chunk.toString('utf8')).join(''); + }, + async appendLog(url, chunk, offset) { + calls.push({ chunk, offset }); + size = offset + chunk.length; + return { size }; + }, + }; +} + +test('log output is batched and offsets advance', async () => { + const client = recordingClient(); + const log = createLogStream(client, 'http://x/log', { flushMs: 5 }); + + log.write('hello '); + log.write('world\n'); + await log.close(); + + assert.equal(client.text, 'hello world\n'); + assert.equal(client.calls[0].offset, 0); + assert.equal(log.offset, 12); +}); + +test('a conflicting offset resynchronises instead of duplicating', async () => { + const calls = []; + let first = true; + const client = { + async appendLog(url, chunk, offset) { + calls.push({ text: chunk.toString('utf8'), offset }); + if (first) { + first = false; + // The conductor already has the first four bytes. + return { conflict: true, expected: 4 }; + } + return { size: offset + chunk.length }; + }, + }; + + const log = createLogStream(client, 'http://x/log', { flushMs: 5 }); + log.write('abcdefgh'); + await log.close(); + + assert.equal(calls.length, 2); + assert.equal(calls[1].offset, 4); + assert.equal(calls[1].text, 'efgh', 'only the bytes the conductor lacks should be resent'); +}); + +test('secrets are masked before anything leaves the host', async () => { + const client = recordingClient(); + const log = createLogStream(client, 'http://x/log', { masked: ['hunter2'], flushMs: 5 }); + + log.write('password is hunter2 ok\n'); + await log.close(); + + assert.ok(!client.text.includes('hunter2')); + assert.ok(client.text.includes('[masked]')); +}); + +test('a secret split across two writes is still masked', async () => { + const client = recordingClient(); + const log = createLogStream(client, 'http://x/log', { masked: ['supersecret'], flushMs: 5 }); + + log.write('token=super'); + log.write('secret done\n'); + await log.close(); + + assert.ok(!client.text.includes('supersecret'), `leaked: ${client.text}`); + assert.ok(client.text.includes('[masked]')); +}); + +test('a failing conductor does not break the job', async () => { + const client = { + async appendLog() { + throw new Error('conductor unreachable'); + }, + }; + const log = createLogStream(client, 'http://x/log', { flushMs: 5, logger: { warn: () => {} } }); + log.write('some output\n'); + await log.close(); + assert.match(log.error.message, /unreachable/); +});