conductor

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

commit 876f2f34a765135d939972f9dbaac807efffcbf9
parent f997ab1673eacee94e95040b6a698507bd03eb6d
Author: finwo <finwo@pm.me>
Date:   Sun, 20 Sep 2026 03:24:29 +0200

Rename a run to a job and a job to a task throughout

Diffstat:
Msrc/admin-cli.js | 36++++++++++++++++++------------------
Msrc/conductor/app.js | 13+++++++------
Msrc/conductor/retention.js | 74+++++++++++++++++++++++++++++++++++++-------------------------------------
Msrc/conductor/routes/artifacts.js | 24++++++++++++------------
Asrc/conductor/routes/jobs.js | 247+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/conductor/routes/tasks.js | 307+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/conductor/scheduler.js | 397++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Msrc/conductor/ui/layout.js | 2+-
Msrc/conductor/ui/pages.js | 162++++++++++++++++++++++++++++++++++++++++----------------------------------------
Msrc/conductor/ui/routes.js | 204++++++++++++++++++++++++++++++++++++++++----------------------------------------
Msrc/lib/auth/oidc.js | 4++--
Msrc/lib/config.js | 10+++++-----
Msrc/lib/db/index.js | 2+-
Msrc/lib/db/query.js | 2+-
Msrc/lib/ids.js | 30+++++++++++-------------------
Msrc/lib/log.js | 41+++++++++++++++++++++--------------------
Msrc/lib/pipeline/dag.js | 46+++++++++++++++++++++++-----------------------
Msrc/lib/pipeline/expand.js | 100++++++++++++++++++++++++++++++++++++++++----------------------------------------
Msrc/lib/pipeline/index.js | 18+++++++++---------
Msrc/lib/pipeline/only.js | 32++++++++++++++++----------------
Msrc/lib/pipeline/parse.js | 70++++++++++++++++++++++++++++++++++++++++------------------------------
Msrc/lib/pipeline/schema.js | 2+-
Msrc/lib/projects.js | 20++++++++++----------
Msrc/lib/storage/index.js | 4++--
Msrc/lib/users.js | 6+++---
Msrc/worker/agent.js | 28++++++++++++++--------------
Msrc/worker/artifacts.js | 8++++----
Msrc/worker/client.js | 17+++++++++--------
Msrc/worker/config.js | 6+++---
Msrc/worker/docker.js | 16++++++++--------
Msrc/worker/logstream.js | 6+++---
Msrc/worker/script.js | 4++--
Msrc/worker/source.js | 10+++++-----
Msrc/worker/task.js | 96++++++++++++++++++++++++++++++++++++++++----------------------------------------
34 files changed, 1325 insertions(+), 719 deletions(-)

diff --git a/src/admin-cli.js b/src/admin-cli.js @@ -13,12 +13,12 @@ // node src/admin-cli.js var:set <project> <NAME> <value> [--visible] // node src/admin-cli.js var:list <project> // node src/admin-cli.js var:remove <project> <NAME> -// node src/admin-cli.js user:add <username> <password> [--role admin|viewer] +// node src/admin-cli.js user:add <username> <password> [--role admin|user] // node src/admin-cli.js user:list // node src/admin-cli.js user:passwd <username> <password> // node src/admin-cli.js user:remove <username> -// node src/admin-cli.js run:trigger <project> <sha> [--ref r] [--base b] -// node src/admin-cli.js run:cancel <run-id> +// node src/admin-cli.js job:trigger <project> <sha> [--ref r] [--base b] +// node src/admin-cli.js job:cancel <job-id> // // This runs against the database directly, so it works before the first // account exists and when the server is down. @@ -67,12 +67,12 @@ function usage(message) { ' var:set <project> <NAME> <value> [--visible]', ' var:list <project>', ' var:remove <project> <NAME>', - ' user:add <username> <password> [--role admin|viewer]', + ' user:add <username> <password> [--role admin|user]', ' user:list', ' user:passwd <username> <password>', ' user:remove <username>', - ' run:trigger <project> <sha> [--ref r] [--base b]', - ' run:cancel <run-id>', + ' job:trigger <project> <sha> [--ref r] [--base b]', + ' job:cancel <job-id>', ].join('\n') ); process.exit(message ? 1 : 0); @@ -137,9 +137,9 @@ try { branch: p.default_branch, config: p.config_path, enabled: p.enabled ? 'yes' : 'no', - runs: p.run_counter, + jobs: p.job_counter, })); - table(rows, ['id', 'repo_url', 'branch', 'config', 'source', 'enabled', 'runs']); + table(rows, ['id', 'repo_url', 'branch', 'config', 'source', 'enabled', 'jobs']); break; } @@ -230,7 +230,7 @@ try { const user = await users.create({ username, password, - role: flags.role ? String(flags.role) : 'viewer', + role: flags.role ? String(flags.role) : 'user', }); console.log(`created user ${user.username} with role ${user.role}`); break; @@ -268,28 +268,28 @@ try { break; } - case 'run:trigger': { + case 'job:trigger': { const [projectId, sha] = positional; - if (!projectId || !sha) usage('run:trigger needs a project and a commit'); + if (!projectId || !sha) usage('job:trigger needs a project and a commit'); const project = await projects.get(projectId); if (!project) usage(`unknown project ${projectId}`); - const result = await scheduler.createRun(project, { + const result = await scheduler.createJob(project, { headSha: sha, ref: flags.ref ? String(flags.ref) : null, baseSha: flags.base ? String(flags.base) : null, trigger: 'manual', actor: 'admin-cli', }); - console.log(`created run ${result.runId} with ${result.jobCount} job(s)`); + console.log(`created job ${result.jobId} with ${result.taskCount} task(s)`); break; } - case 'run:cancel': { - const [runId] = positional; - if (!runId) usage('run:cancel needs a run id'); - const result = await scheduler.cancelRun(runId); - console.log(result.ok ? `cancelled ${runId}` : `could not cancel: ${result.reason}`); + case 'job:cancel': { + const [jobId] = positional; + if (!jobId) usage('job:cancel needs a job id'); + const result = await scheduler.cancelJob(jobId); + console.log(result.ok ? `cancelled ${jobId}` : `could not cancel: ${result.reason}`); break; } diff --git a/src/conductor/app.js b/src/conductor/app.js @@ -20,8 +20,8 @@ import { createAuth } from '../lib/auth/index.js'; import { createScheduler } from './scheduler.js'; import { createRetention } from './retention.js'; -import workerRoutes from './routes/workers.js'; -import triggerRoutes from './routes/trigger.js'; +import taskRoutes from './routes/tasks.js'; +import jobRoutes from './routes/jobs.js'; import artifactRoutes from './routes/artifacts.js'; import uiRoutes from './ui/routes.js'; import staticRoutes from '../lib/static.js'; @@ -81,10 +81,11 @@ export async function buildServer(services, options = {}) { auth: cfg.auth.mode, })); - // The only HTTP surface besides the interface: the trigger edge, the - // worker edge, and artifact downloads. Everything else is the UI. - await fastify.register(triggerRoutes, { ...services, prefix: '/api/v1' }); - await fastify.register(workerRoutes, { ...services, prefix: '/api/v1' }); + // The only HTTP surface besides the interface: creating and reading jobs, + // the task edge workers poll, and artifact downloads. Everything else + // is the UI. + await fastify.register(jobRoutes, { ...services, prefix: '/api/v1' }); + await fastify.register(taskRoutes, { ...services, prefix: '/api/v1' }); await fastify.register(artifactRoutes, { ...services, prefix: '/api/v1' }); // Signing in, managing projects and registering workers all happen in the diff --git a/src/conductor/retention.js b/src/conductor/retention.js @@ -1,20 +1,20 @@ // src/conductor/retention.js - deleting build output that is past its time // -// Everything a run produces is kept forever unless something removes it, -// and logs are the worst of it: written for every job, read for almost +// Everything a job produces is kept forever unless something removes it, +// and logs are the worst of it: written for every task, read for almost // none, and never stopping. This sweeps both. // // Artifacts follow two rules per project, and an artifact survives if -// either wants it. The last artifact_keep_runs runs keep their artifacts +// either wants it. The last artifact_keep_jobs jobs keep their artifacts // however old they are, and anything younger than artifact_keep_days is -// kept however many runs have followed. The most recent successful run is +// kept however many jobs have followed. The most recent successful job is // kept regardless, so a project that has gone quiet still has something // to download. // -// A job that set artifacts.expire in its pipeline overrides all of that +// A task that set artifacts.expire in its pipeline overrides all of that // with an exact deadline, in either direction. That is deliberate: the // point of the key is to drop a bulky intermediate early, and a -// node_modules tree is not worth keeping just because its run was green. +// node_modules tree is not worth keeping just because its job was green. // // Logs go by age alone. // @@ -72,49 +72,49 @@ export function createRetention({ cfg, db, storage, logs, logger = {} }) { } async function sweepProjectArtifacts(project, now, batch) { - const keepRuns = setting(project.artifact_keep_runs, defaults.artifact_keep_runs); + const keepJobs = setting(project.artifact_keep_jobs, defaults.artifact_keep_jobs); const keepDays = setting(project.artifact_keep_days, defaults.artifact_keep_days); // Both rules off means keep everything, and with the rules combined by // whichever keeps longer there is nothing left to delete. - if (keepRuns === 0 && keepDays === 0) return { deleted: 0, bytes: 0 }; + if (keepJobs === 0 && keepDays === 0) return { deleted: 0, bytes: 0 }; - const protectedRuns = new Set(); + const protectedJobs = new Set(); - if (keepRuns > 0) { + if (keepJobs > 0) { const recent = await db.all( - `SELECT id FROM runs WHERE project_id = {project} + `SELECT id FROM jobs WHERE project_id = {project} ORDER BY number DESC LIMIT {limit}`, - { project: project.id, limit: keepRuns } + { project: project.id, limit: keepJobs } ); - for (const run of recent) protectedRuns.add(run.id); + for (const job of recent) protectedJobs.add(job.id); } - // The last green run, however old. Looked up separately rather than + // The last green job, however old. Looked up separately rather than // folded into the query above, since it may be far outside the recent // window on a project that has been failing for a while. const lastGood = await db.get( - `SELECT id FROM runs WHERE project_id = {project} AND state = 'success' + `SELECT id FROM jobs WHERE project_id = {project} AND state = 'success' ORDER BY number DESC LIMIT 1`, { project: project.id } ); - if (lastGood) protectedRuns.add(lastGood.id); + if (lastGood) protectedJobs.add(lastGood.id); - // With no age rule, anything outside the run window goes; otherwise + // With no age rule, anything outside the job window goes; otherwise // only what is also older than the cutoff. const cutoff = keepDays > 0 ? now - keepDays * DAY : now; const candidates = await db.all( - `SELECT a.id, a.storage_key, a.size, a.run_id + `SELECT a.id, a.storage_key, a.size, a.job_id FROM artifacts a - JOIN runs r ON r.id = a.run_id - WHERE r.project_id = {project} + JOIN jobs j ON j.id = a.job_id + WHERE j.project_id = {project} AND a.expires_at IS NULL - AND r.created_at < {cutoff} - AND r.finished_at IS NOT NULL - ORDER BY r.created_at + AND j.created_at < {cutoff} + AND j.finished_at IS NOT NULL + ORDER BY j.created_at LIMIT {batch}`, { project: project.id, cutoff, batch } ); @@ -122,7 +122,7 @@ export function createRetention({ cfg, db, storage, logs, logger = {} }) { let deleted = 0; let bytes = 0; for (const row of candidates) { - if (protectedRuns.has(row.run_id)) continue; + if (protectedJobs.has(row.job_id)) continue; if (!(await deleteObject(row.storage_key, 'artifact'))) continue; await db.run('DELETE FROM artifacts WHERE id = {id}', { id: row.id }); deleted += 1; @@ -138,14 +138,14 @@ export function createRetention({ cfg, db, storage, logs, logger = {} }) { const cutoff = now - keepDays * DAY; const rows = await db.all( - `SELECT j.id, j.run_id, j.log_key, j.log_size - FROM jobs j - JOIN runs r ON r.id = j.run_id - WHERE r.project_id = {project} - AND j.log_expired_at IS NULL - AND j.finished_at IS NOT NULL - AND j.finished_at < {cutoff} - ORDER BY j.finished_at + `SELECT t.id, t.job_id, t.log_key, t.log_size + FROM tasks t + JOIN jobs j ON j.id = t.job_id + WHERE j.project_id = {project} + AND t.log_expired_at IS NULL + AND t.finished_at IS NOT NULL + AND t.finished_at < {cutoff} + ORDER BY t.finished_at LIMIT {batch}`, { project: project.id, cutoff, batch } ); @@ -155,14 +155,14 @@ export function createRetention({ cfg, db, storage, logs, logger = {} }) { for (const row of rows) { if (!(await deleteObject(row.log_key, 'log'))) continue; - // A job that never finished archiving still has a spool file, and a - // job with nothing to say has neither. Both are fine to ask about. - await logs.remove(row.run_id, row.id).catch((e) => { + // A task that never finished archiving still has a spool file, and a + // task with nothing to say has neither. Both are fine to ask about. + await logs.remove(row.job_id, row.id).catch((e) => { logger.warn?.(`retention: could not remove spooled log for ${row.id}: ${e.message}`); }); await db.run( - 'UPDATE jobs SET log_key = NULL, log_expired_at = {now} WHERE id = {id}', + 'UPDATE tasks SET log_key = NULL, log_expired_at = {now} WHERE id = {id}', { id: row.id, now } ); deleted += 1; @@ -184,7 +184,7 @@ export function createRetention({ cfg, db, storage, logs, logger = {} }) { total.artifactBytes += expired.bytes; const projects = await db.all( - `SELECT id, artifact_keep_runs, artifact_keep_days, log_keep_days + `SELECT id, artifact_keep_jobs, artifact_keep_days, log_keep_days FROM projects`, {} ); diff --git a/src/conductor/routes/artifacts.js b/src/conductor/routes/artifacts.js @@ -1,18 +1,18 @@ // src/conductor/routes/artifacts.js - artifact downloads // -// The one read endpoint that is not the interface: the job page links here, +// The one read endpoint that is not the interface: the task page links here, // so a browser can download build output without the conductor proxying the // bytes when the store can presign them. // -// Visibility matches the interface: a public run is downloadable by anyone, -// a private one by its owner or an administrator. The project, run and job +// Visibility matches the interface: a public job is downloadable by anyone, +// a private one by its owner or an administrator. The project, job and task // in the path must all agree with the artifact, so a guessed identifier // cannot reach anything. import { StorageNotFound } from '../../lib/storage/index.js'; export default async function artifactRoutes(fastify, { db, storage, auth = null }) { - async function viewer(req) { + async function currentUser(req) { if (!auth) return null; try { return await auth.identify(req); @@ -25,28 +25,28 @@ export default async function artifactRoutes(fastify, { db, storage, auth = null if (user && user.role === 'admin') return { sql: '1 = 1', params: {} }; if (user) { return { - sql: "(r.visibility = 'public' OR p.owner_id = {viewerId})", + sql: "(j.visibility = 'public' OR p.owner_id = {viewerId})", params: { viewerId: user.id }, }; } - return { sql: "r.visibility = 'public'", params: {} }; + return { sql: "j.visibility = 'public'", params: {} }; } - fastify.get('/projects/:project/runs/:run/jobs/:job/artifacts/:artifact', async (req, reply) => { - const visible = scope(await viewer(req)); + fastify.get('/projects/:project/jobs/:job/tasks/:task/artifacts/:artifact', async (req, reply) => { + const visible = scope(await currentUser(req)); const artifact = await db.get( `SELECT a.id, a.path, a.storage_key, a.size FROM artifacts a + JOIN tasks t ON t.id = a.task_id JOIN jobs j ON j.id = a.job_id - JOIN runs r ON r.id = a.run_id - JOIN projects p ON p.id = r.project_id + JOIN projects p ON p.id = j.project_id WHERE a.id = {id} - AND a.job_id = {job} AND a.run_id = {run} AND r.project_id = {project} + AND a.task_id = {task} AND a.job_id = {job} AND j.project_id = {project} AND ${visible.sql}`, { id: req.params.artifact, + task: req.params.task, job: req.params.job, - run: req.params.run, project: req.params.project, ...visible.params, } diff --git a/src/conductor/routes/jobs.js b/src/conductor/routes/jobs.js @@ -0,0 +1,247 @@ +// src/conductor/routes/jobs.js - creating jobs, and reading them back +// +// A job is a pipeline run: compiling the repository's .conductor.yml at one +// commit produces the tasks that a worker will later claim. There are two +// ways in, differing only in the shape of what they accept. +// +// POST /projects/:project/trigger takes a push payload. It accepts the +// generic one produced by hooks/post-receive and the push events of +// GitHub, Gitea and GitLab, since they all carry the same three facts. +// +// POST /projects/:project/jobs takes those facts directly, for anything +// driving the conductor deliberately rather than forwarding a hook. +// +// Nothing in either payload is trusted beyond which commit to look at: the +// pipeline is always read from the repository at that commit. +// +// A project with a trigger secret requires a valid signature. A project +// without one accepts unsigned requests, which is convenient on a private +// network and a bad idea anywhere else, so it is logged. + +import crypto from 'node:crypto'; +import { SHA_PATTERN } from '../../lib/git.js'; +import { PipelineError } from '../../lib/pipeline/index.js'; +import { canManageProject } from '../../lib/projects.js'; + +const ZERO_SHA = '0'.repeat(40); + +export default async function jobRoutes(fastify, { auth, db, projects, scheduler, logger }) { + // HMAC is computed over the exact bytes received, so the raw body has to + // survive JSON parsing. + fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => { + req.rawBody = body; + if (body.length === 0) return done(null, {}); + try { + done(null, JSON.parse(body.toString('utf8'))); + } catch (e) { + e.statusCode = 400; + done(e); + } + }); + + // Shared by both creation routes: resolve the project, check the secret. + // Returns null once it has already answered. + async function authorizedProject(req, reply) { + const project = await projects.get(req.params.project); + if (!project) { + reply.code(404).send({ error: 'unknown project' }); + return null; + } + if (project.enabled !== 1) { + reply.code(409).send({ error: 'project is disabled' }); + return null; + } + + const secret = projects.triggerSecret(project); + if (secret) { + if (!verifySignature(req, secret)) { + reply.code(401).send({ error: 'invalid or missing signature' }); + return null; + } + } else { + logger.warn?.(`project ${project.id} accepted an unsigned request; set a trigger secret`); + } + return project; + } + + // Compiles the pipeline and records the job, or answers with the reason + // it could not. + async function create(reply, req, project, { ref, base, sha, actor, trigger }) { + try { + const { jobId, taskCount } = await scheduler.createJob(project, { + ref, + baseSha: base, + headSha: sha, + trigger, + actor, + }); + return reply.send({ status: 'created', job_id: jobId, tasks: taskCount }); + } catch (e) { + if (e instanceof PipelineError) { + // A broken pipeline is the caller's problem, not a server fault. + return reply.code(422).send({ error: 'invalid pipeline', detail: e.message, problems: e.errors }); + } + req.log.error({ err: e }, `could not create a job for ${project.id}`); + return reply.code(500).send({ error: 'could not create job', detail: String(e.message ?? e) }); + } + } + + fastify.post('/projects/:project/trigger', async (req, reply) => { + const project = await authorizedProject(req, reply); + if (!project) return reply; + + const push = normalizePush(req.body); + if (!push) { + return reply.code(400).send({ + error: 'could not read a commit from the payload; expected sha, or a GitHub, Gitea or GitLab push event', + }); + } + // A branch deletion is not a thing to build, and saying so is more + // useful than refusing it. + if (push.sha === ZERO_SHA) { + return reply.send({ status: 'ignored', reason: 'branch deletion' }); + } + if (!SHA_PATTERN.test(push.sha)) { + return reply.code(400).send({ error: `not a full commit id: ${JSON.stringify(push.sha)}` }); + } + + return create(reply, req, project, { ...push, trigger: 'push' }); + }); + + // The deliberate form. Same authentication, no payload archaeology: a + // caller that means to start a build says so, and a missing or malformed + // commit is an error rather than something to infer. + fastify.post('/projects/:project/jobs', async (req, reply) => { + const project = await authorizedProject(req, reply); + if (!project) return reply; + + const body = req.body ?? {}; + const sha = typeof body.sha === 'string' ? body.sha : null; + if (!sha) return reply.code(400).send({ error: 'sha is required' }); + if (!SHA_PATTERN.test(sha)) { + return reply.code(400).send({ error: `not a full commit id: ${JSON.stringify(sha)}` }); + } + + return create(reply, req, project, { + sha, + base: usableSha(body.base), + ref: typeof body.ref === 'string' ? body.ref : null, + actor: typeof body.actor === 'string' ? body.actor : null, + trigger: typeof body.trigger === 'string' ? body.trigger.slice(0, 32) : 'api', + }); + }); + + // Read one back. A caller that can start a job should be able to see how + // it went, and scraping the interface for that is no kind of answer. + // + // Three ways to be allowed: the job is public, the caller holds the + // project's trigger secret, or the caller is signed in and may manage the + // project. Refusal is a 404 either way, so this cannot be used to probe + // which jobs exist. + fastify.get('/projects/:project/jobs/:job', async (req, reply) => { + const project = await projects.get(req.params.project); + if (!project) return reply.code(404).send({ error: 'unknown job' }); + + const job = await db.get( + `SELECT id, project_id, number, ref, head_sha, trigger_type, actor, title, + state, visibility, error, created_at, started_at, finished_at + FROM jobs + WHERE id = {id} AND project_id = {project}`, + { id: req.params.job, project: project.id } + ); + if (!job) return reply.code(404).send({ error: 'unknown job' }); + + if (job.visibility !== 'public' && !(await mayRead(req, project))) { + return reply.code(404).send({ error: 'unknown job' }); + } + + const tasks = await db.all( + `SELECT id, name, state, arch, attempt, exit_code, error, + worker_name, created_at, started_at, finished_at + FROM tasks + WHERE job_id = {job} + ORDER BY created_at, name`, + { job: job.id } + ); + + return reply.send({ job, tasks }); + }); + + async function mayRead(req, project) { + const secret = projects.triggerSecret(project); + if (secret && verifySignature(req, secret)) return true; + + const user = await auth.identify(req).catch(() => null); + return canManageProject(user, project); + } +} + +function verifySignature(req, secret) { + const raw = req.rawBody ?? Buffer.alloc(0); + + // GitHub and Gitea: sha256=<hex> over the body. + const hubSignature = req.headers['x-hub-signature-256']; + if (typeof hubSignature === 'string' && hubSignature.length > 0) { + const expected = `sha256=${crypto.createHmac('sha256', secret).update(raw).digest('hex')}`; + return timingSafeEqual(expected, hubSignature); + } + + // GitLab: the secret itself, compared rather than signed. + const gitlabToken = req.headers['x-gitlab-token']; + if (typeof gitlabToken === 'string' && gitlabToken.length > 0) { + return timingSafeEqual(secret, gitlabToken); + } + + return false; +} + +function timingSafeEqual(a, b) { + const left = Buffer.from(String(a), 'utf8'); + const right = Buffer.from(String(b), 'utf8'); + if (left.length !== right.length) return false; + return crypto.timingSafeEqual(left, right); +} + +// Reduces the supported payload shapes to { sha, base, ref, actor }. +function normalizePush(body) { + if (!body || typeof body !== 'object') return null; + + // hooks/post-receive, and anything else driving the API directly. + if (typeof body.sha === 'string') { + return { + sha: body.sha, + base: usableSha(body.base), + ref: typeof body.ref === 'string' ? body.ref : null, + actor: typeof body.actor === 'string' ? body.actor : null, + }; + } + + // GitLab sends both checkout_sha and after; checkout_sha is the one that + // refers to the tip of the pushed branch. + if (typeof body.checkout_sha === 'string') { + return { + sha: body.checkout_sha, + base: usableSha(body.before), + ref: typeof body.ref === 'string' ? body.ref : null, + actor: body.user_username ?? body.user_name ?? null, + }; + } + + // GitHub and Gitea. + if (typeof body.after === 'string') { + return { + sha: body.after, + base: usableSha(body.before), + ref: typeof body.ref === 'string' ? body.ref : null, + actor: body.pusher?.name ?? body.pusher?.login ?? body.sender?.login ?? null, + }; + } + + return null; +} + +// A new branch reports an all zero parent, which is not a commit. +function usableSha(value) { + if (typeof value !== 'string' || value === ZERO_SHA || !SHA_PATTERN.test(value)) return null; + return value; +} diff --git a/src/conductor/routes/tasks.js b/src/conductor/routes/tasks.js @@ -0,0 +1,307 @@ +// src/conductor/routes/tasks.js - the API workers poll and report to +// +// Every route here requires a worker token. A worker may only touch a task it +// currently holds, which is checked against worker_token_id rather than +// trusting the task id it sends. +// +// A worker is handed a context and a script and nothing else. It is never +// told which project or job a task belongs to, and the task id it receives +// carries no structure it could read that out of. The identifiers a build +// legitimately wants are in the task's environment, where the worker treats +// them as opaque strings on their way into the container. + +import { pipeline as streamPipeline } from 'node:stream/promises'; +import { LogOffsetError } from '../../lib/log.js'; +import { hashingTransform } from '../../lib/stream.js'; +import { sanitizeRelativePath, keys as storageKeys } from '../../lib/storage/index.js'; +import { maskBuffer } from '../../lib/variables.js'; +import { newArtifactId } from '../../lib/ids.js'; + +// Log chunks are small and frequent; artifacts are streamed, so this only +// bounds a single log append. +const LOG_CHUNK_LIMIT = 1024 * 1024; + +export default async function taskRoutes(fastify, { cfg, db, git, logs, storage, projects, scheduler, workerTokens, variables }) { + // Raw bodies: log chunks and artifacts arrive as octet streams and must + // not be parsed. + fastify.addContentTypeParser('application/octet-stream', (req, payload, done) => done(null, payload)); + + async function requireWorker(req, reply) { + const header = req.headers.authorization || ''; + const presented = header.startsWith('Bearer ') ? header.slice(7).trim() : ''; + const token = await workerTokens.verify(presented, { ip: req.ip }); + if (!token) { + reply.code(401).send({ error: 'invalid or missing worker token' }); + return; + } + req.worker = token; + } + + // Confirms the task exists and is held by the calling worker. + async function heldTask(req, reply) { + const task = await db.get( + `SELECT t.id, t.job_id, t.name, t.state, t.worker_token_id, t.log_size, t.spec, + j.project_id, j.head_sha + FROM tasks t JOIN jobs j ON j.id = t.job_id + WHERE t.id = {id}`, + { id: req.params.task } + ); + if (!task) { + reply.code(404).send({ error: 'unknown task' }); + return null; + } + if (task.worker_token_id !== req.worker.id) { + reply.code(403).send({ error: 'task is not held by this worker' }); + return null; + } + return task; + } + + fastify.addHook('preHandler', requireWorker); + + // Ask for work. The worker describes what it can run; the scheduler + // decides. Returns 204 when there is nothing to do. + fastify.post('/tasks/claim', async (req, reply) => { + const body = req.body ?? {}; + const arches = splitList(body.arches); + const features = splitList(body.features); + const name = typeof body.name === 'string' ? body.name.slice(0, 255) : req.worker.name; + + const task = await scheduler.claim({ + arches, + features, + tokenId: req.worker.id, + workerName: name, + // A worker registered by a user only ever sees that user's projects. + ownerId: req.worker.owner_id ?? null, + }); + if (!task) return reply.code(204).send(); + + const base = cfg.server.public_url.replace(/\/+$/, ''); + const at = (suffix) => `${base}/api/v1/tasks/${task.id}/${suffix}`; + + return reply.send({ + task: { + id: task.id, + name: task.name, + arch: task.arch, + image: task.image, + // The worker maps these onto its own local mounts, environment and + // privileges. Without them no feature is applied at all. + requires: task.requires, + script: task.script, + env: task.env, + services: task.services, + artifacts: task.artifacts, + // Where the tree is unpacked and the script runs. Resolved when + // the job was created, so it does not shift under a retry. + workdir: task.workdir, + timeout: task.timeout, + attempt: task.attempt, + sha: task.sha, + ref: task.ref, + // Values the worker must redact from the log stream. Populated + // once project variables exist; masking also happens on ingest. + masked: task.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)), + endpoints: { + // The tree comes from the conductor, so a worker never needs + // credentials for the repository and cannot reach any commit + // other than the one it was given work for. + source: at('source.tar.gz'), + log: at('log'), + artifact: at('artifacts'), + heartbeat: at('heartbeat'), + done: at('complete'), + }, + }, + }); + }); + + // The tree at the task's commit, as a gzipped tar. + fastify.get('/tasks/:task/source.tar.gz', async (req, reply) => { + const task = await heldTask(req, reply); + if (!task) return reply; + + const project = await projects.get(task.project_id); + if (!project) return reply.code(404).send({ error: 'unknown project' }); + + // The tree rooted at the archive, with no wrapping directory: a + // worker extracts it at whatever path the task is to run in, and + // docker creates that path on the way. + reply.header('content-type', 'application/gzip'); + reply.header('content-disposition', `attachment; filename="${task.head_sha.slice(0, 12)}.tar.gz"`); + return reply.send(git.archiveStream(project, task.head_sha)); + }); + + // Append to the live log. X-Log-Offset makes a retry after a dropped + // connection safe. + fastify.post('/tasks/:task/log', { bodyLimit: LOG_CHUNK_LIMIT }, async (req, reply) => { + const task = await heldTask(req, reply); + if (!task) return reply; + + const chunks = []; + let total = 0; + for await (const chunk of req.body) { + total += chunk.length; + if (total > LOG_CHUNK_LIMIT) return reply.code(413).send({ error: 'log chunk too large' }); + chunks.push(chunk); + } + + const rawOffset = req.headers['x-log-offset']; + const offset = rawOffset === undefined ? undefined : Number(rawOffset); + if (offset !== undefined && !Number.isInteger(offset)) { + return reply.code(400).send({ error: 'x-log-offset must be an integer' }); + } + + // The worker masks before sending; this repeats it on ingest so a + // worker that does not still cannot write a secret to disk. It works + // within a chunk only, which is why the worker holds back a boundary. + let payload = Buffer.concat(chunks); + if (variables) { + try { + payload = maskBuffer(payload, await variables.maskedValues(task.project_id)); + } catch (e) { + req.log.warn({ err: e }, `could not mask log output for ${task.id}`); + } + } + + try { + const result = await logs.append(task.job_id, task.id, payload, offset); + await db.run('UPDATE tasks SET log_size = {size}, heartbeat_at = {now} WHERE id = {id}', + { id: task.id, size: result.size, now: Date.now() }); + return reply.send(result); + } catch (e) { + if (e instanceof LogOffsetError) { + // Tell the worker where to resume from. + return reply.code(409).send({ error: e.message, expected_offset: e.expected }); + } + throw e; + } + }); + + // artifacts.expire in the pipeline, as an absolute time. Returns null + // when the task said nothing, leaving the artifact to the project policy. + function artifactExpiry(specJson, now) { + if (!specJson) return null; + try { + const spec = typeof specJson === 'string' ? JSON.parse(specJson) : specJson; + const seconds = spec?.artifacts?.expire; + if (!Number.isFinite(seconds) || seconds <= 0) return null; + return now + Math.round(seconds * 1000); + } catch { + // A spec that will not parse is a problem, but not this request's + // problem: the artifact is already stored. + return null; + } + } + + // Upload one artifact. The path is worker supplied, so it is sanitized + // before it can influence a storage key. + fastify.post('/tasks/:task/artifacts', async (req, reply) => { + const task = await heldTask(req, reply); + if (!task) return reply; + + const relPath = sanitizeRelativePath(req.headers['x-artifact-path']); + if (!relPath) return reply.code(400).send({ error: 'x-artifact-path is missing or unusable' }); + + const declared = Number(req.headers['content-length']); + if (!Number.isInteger(declared) || declared < 0) { + return reply.code(411).send({ error: 'content-length is required for artifact uploads' }); + } + + // Hash on the way past, so the digest costs nothing extra even when the + // backend cannot report one. + const hasher = hashingTransform(); + const key = storageKeys.artifact(task.job_id, task.id, relPath); + + let stored; + try { + [, stored] = await Promise.all([ + streamPipeline(req.body, hasher), + storage.put(key, hasher, { size: declared }), + ]); + } catch (e) { + // A body shorter or longer than content-length is the worker's + // mistake, not a server fault. + if (/size mismatch/.test(e.message)) { + return reply.code(400).send({ error: e.message }); + } + throw e; + } + + const digest = hasher.digest(); + const now = Date.now(); + + await db.run( + `INSERT INTO artifacts (id, task_id, job_id, path, storage_key, size, sha256, created_at, expires_at) + VALUES ({id}, {task}, {job}, {path}, {key}, {size}, {sha}, {now}, {expires})`, + { + id: newArtifactId(), + task: task.id, + job: task.job_id, + path: relPath, + key, + size: stored.size ?? declared, + sha: digest, + now, + // An explicit deadline from the pipeline. Absent, the project's + // retention policy decides, which is the usual case. + expires: artifactExpiry(task.spec, now), + } + ); + + return reply.send({ path: relPath, size: stored.size ?? declared, sha256: digest }); + }); + + // Keeps the task alive, and is how a worker learns it should stop. + fastify.post('/tasks/:task/heartbeat', async (req, reply) => { + const result = await scheduler.heartbeat(req.params.task, req.worker.id); + if (!result.known) return reply.code(404).send({ error: 'unknown task for this worker' }); + return reply.send({ cancelled: result.cancelled, state: result.state }); + }); + + // Report the outcome. The log is copied to storage here, whatever the + // result, so a failed task keeps its output. + fastify.post('/tasks/:task/complete', async (req, reply) => { + const task = await heldTask(req, reply); + if (!task) return reply; + + const body = req.body ?? {}; + const success = body.success === true; + const exitCode = Number.isInteger(body.exit_code) ? body.exit_code : null; + const error = typeof body.error === 'string' ? body.error.slice(0, 4096) : null; + + const outcome = await scheduler.complete(task.id, { + success, + exitCode, + error, + tokenId: req.worker.id, + }); + if (!outcome.ok) return reply.code(409).send({ error: outcome.reason }); + + // A retry keeps writing to the same log, so only archive it once the + // task has actually stopped. + if (outcome.state !== 'queued') { + try { + await scheduler.finalizeLog(task.job_id, task.id); + } catch (e) { + req.log.error({ err: e }, `failed to archive log for ${task.id}`); + } + } + + return reply.send({ + state: outcome.state, + retry: outcome.retry === true, + skipped: outcome.skipped ?? [], + job_state: outcome.jobState ?? null, + }); + }); +} + +function splitList(value) { + const items = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : []; + return items.map((s) => String(s).trim()).filter(Boolean).slice(0, 64); +} diff --git a/src/conductor/scheduler.js b/src/conductor/scheduler.js @@ -1,144 +1,182 @@ -// src/conductor/scheduler.js - run creation, dispatch and completion +// src/conductor/scheduler.js - job creation, dispatch and completion // -// Scheduling reads the dependency edges in job_deps directly. Two defects in +// A trigger creates a job. Compiling the repository's pipeline turns that job +// into tasks, and a task is what a worker runs. +// +// Scheduling reads the dependency edges in task_deps directly. Two defects in // the prototype came from not doing that: // -// Dispatch was serialized. A single integer level per job was compared -// against the lowest queued level, so only one job could run at a time -// even when the graph allowed the whole width of it. Here any job whose +// Dispatch was serialized. A single integer level per task was compared +// against the lowest queued level, so only one task could run at a time +// even when the graph allowed the whole width of it. Here any task whose // dependencies are all satisfied is eligible, and several workers can -// claim different jobs concurrently. +// claim different tasks concurrently. // // Failure skipped only direct dependents. Transitive dependents stayed // queued and were later dispatched with their inputs missing. Here the // whole reachable set is skipped. // // Claiming is a conditional UPDATE guarded on the previous state, so two -// workers polling at the same instant cannot both win the same job. +// workers polling at the same instant cannot both win the same task. import { compilePipeline, PipelineError, transitiveDependents, DEFAULT_WORKDIR } from '../lib/pipeline/index.js'; import { inClause } from '../lib/db/query.js'; -import { newRunId, jobId as makeJobId } from '../lib/ids.js'; +import { newJobId, newTaskId } from '../lib/ids.js'; import { keys as storageKeys } from '../lib/storage/index.js'; -export const RUN_STATES = ['pending', 'running', 'success', 'failed', 'cancelled']; -export const JOB_STATES = ['queued', 'running', 'success', 'failed', 'skipped', 'cancelled']; +export const JOB_STATES = ['pending', 'running', 'success', 'failed', 'cancelled']; +export const TASK_STATES = ['queued', 'running', 'success', 'failed', 'skipped', 'cancelled']; // States that let a dependent proceed. const TERMINAL = ['success', 'failed', 'skipped', 'cancelled']; +// The two spellings a repository may use without being configured for it. +export const CONVENTIONAL_CONFIG_PATHS = ['.conductor.yml', '.conductor.yaml']; + export function createScheduler({ cfg, db, git, logs, storage, projects, variables = null, logger = console }) { - // A job is eligible when it is queued, its run is live, and no dependency + // A task is eligible when it is queued, its job is live, and no dependency // is outstanding. A failed dependency marked allow_failure still counts as // satisfied, which is the whole point of the flag. const ELIGIBLE = ` - SELECT j.id, j.run_id, j.name, j.arch, j.image, j.requires, j.spec, - j.timeout, j.attempt, j.max_attempts, - r.head_sha, r.project_id, r.ref, r.number - FROM jobs j - JOIN runs r ON r.id = j.run_id - JOIN projects p ON p.id = r.project_id - WHERE j.state = 'queued' - AND r.state = 'running' + SELECT t.id, t.job_id, t.name, t.arch, t.image, t.requires, t.spec, + t.timeout, t.attempt, t.max_attempts, + j.head_sha, j.project_id, j.ref, j.number + FROM tasks t + JOIN jobs j ON j.id = t.job_id + JOIN projects p ON p.id = j.project_id + WHERE t.state = 'queued' + AND j.state = 'running' AND NOT EXISTS ( SELECT 1 - FROM job_deps d - JOIN jobs dj ON dj.id = d.depends_on_id - WHERE d.job_id = j.id - AND dj.state <> 'success' - AND NOT (dj.state = 'failed' AND dj.allow_failure = 1) + FROM task_deps d + JOIN tasks dt ON dt.id = d.depends_on_id + WHERE d.task_id = t.id + AND dt.state <> 'success' + AND NOT (dt.state = 'failed' AND dt.allow_failure = 1) ) `; - async function loadGraph(tx, runId) { - const jobs = await tx.all( - 'SELECT id, name, state, allow_failure FROM jobs WHERE run_id = {run}', - { run: runId } + async function loadGraph(tx, jobId) { + const tasks = await tx.all( + 'SELECT id, name, state, allow_failure FROM tasks WHERE job_id = {job}', + { job: jobId } ); const deps = await tx.all( - `SELECT d.job_id, d.depends_on_id - FROM job_deps d - JOIN jobs j ON j.id = d.job_id - WHERE j.run_id = {run}`, - { run: runId } + `SELECT d.task_id, d.depends_on_id + FROM task_deps d + JOIN tasks t ON t.id = d.task_id + WHERE t.job_id = {job}`, + { job: jobId } ); - const needs = new Map(jobs.map((j) => [j.id, []])); - for (const d of deps) needs.get(d.job_id)?.push(d.depends_on_id); - return jobs.map((j) => ({ ...j, needs: needs.get(j.id) ?? [] })); + const needs = new Map(tasks.map((t) => [t.id, []])); + for (const d of deps) needs.get(d.task_id)?.push(d.depends_on_id); + return tasks.map((t) => ({ ...t, needs: needs.get(t.id) ?? [] })); + } + + // Reads the pipeline file at a commit. + // + // A project that has not been told otherwise accepts either conventional + // spelling, so a repository may use .conductor.yml or .conductor.yaml + // without anyone configuring it. Only the conventional names get that + // fallback: a project pointed at ci/build.yml means that file and nothing + // else. Finding both is an error rather than a coin toss, since two + // pipelines in one repository will disagree sooner or later. + async function readPipelineFile(project, sha) { + const wanted = project.config_path; + const alternate = CONVENTIONAL_CONFIG_PATHS.find((p) => p !== wanted); + const candidates = CONVENTIONAL_CONFIG_PATHS.includes(wanted) ? [wanted, alternate] : [wanted]; + + const found = []; + for (const path of candidates) { + const text = await git.readFile(project, sha, path); + if (text !== null) found.push({ path, text }); + } + + if (found.length > 1) { + throw new PipelineError( + [{ + path: '', + message: `${found.map((f) => f.path).join(' and ')} both exist at ` + + `${sha.slice(0, 12)}; keep one`, + }], + wanted + ); + } + return found[0] ?? null; } - // Marks the run finished once nothing is left to do. - async function settleRun(tx, runId) { + // Marks the job finished once nothing is left to do. + async function settleJob(tx, jobId) { const outstanding = await tx.get( - "SELECT COUNT(*) AS c FROM jobs WHERE run_id = {run} AND state IN ('queued', 'running')", - { run: runId } + "SELECT COUNT(*) AS c FROM tasks WHERE job_id = {job} AND state IN ('queued', 'running')", + { job: jobId } ); if (outstanding.c > 0) return null; const bad = await tx.get( - `SELECT COUNT(*) AS c FROM jobs - WHERE run_id = {run} + `SELECT COUNT(*) AS c FROM tasks + WHERE job_id = {job} AND (state IN ('skipped', 'cancelled') OR (state = 'failed' AND allow_failure = 0))`, - { run: runId } + { job: jobId } ); const state = bad.c > 0 ? 'failed' : 'success'; await tx.run( - "UPDATE runs SET state = {state}, finished_at = {now} WHERE id = {run} AND state = 'running'", - { state, run: runId, now: Date.now() } + "UPDATE jobs SET state = {state}, finished_at = {now} WHERE id = {job} AND state = 'running'", + { state, job: jobId, now: Date.now() } ); return state; } return { - // Reads the pipeline at the pushed commit and records the run. The + // Reads the pipeline at the pushed commit and records the job. The // repository is the source of truth, so nothing the trigger claims about // the contents is trusted. - async createRun(project, { ref, baseSha, headSha, trigger = 'push', actor = null }) { + async createJob(project, { ref, baseSha, headSha, trigger = 'push', actor = null }) { await git.sync(project, { force: true }); if (!(await git.hasCommit(project, headSha))) { throw new Error(`commit ${headSha} is not present in the mirror of ${project.id}`); } - const text = await git.readFile(project, headSha, project.config_path); - if (text === null) { + const found = await readPipelineFile(project, headSha); + if (found === null) { throw new PipelineError( [{ path: '', message: `${project.config_path} not found at ${headSha.slice(0, 12)}` }], project.config_path ); } - const pipeline = compilePipeline(text, { - source: project.config_path, - defaultTimeout: cfg.scheduler.default_job_timeout, + const pipeline = compilePipeline(found.text, { + source: found.path, + defaultTimeout: cfg.scheduler.default_task_timeout, defaultAttempts: 1, - // Decides which jobs this push runs at all, so a publish step - // restricted to main is absent from a branch run rather than + // Decides which tasks this push runs at all, so a publish step + // restricted to main is absent from a branch's job rather than // recorded against it. ref: ref ?? '', }); const info = await git.commitInfo(project, headSha).catch(() => ({ subject: null })); - const runId = newRunId(); + const jobId = newJobId(); const now = Date.now(); // The repository may declare its own visibility; otherwise the - // project's setting stands. Stored per run, because the answer can + // project's setting stands. Stored per job, because the answer can // legitimately change from one commit to the next. const visibility = pipeline.visibility ?? project.visibility ?? 'private'; - // Same shape of answer for where the tree is unpacked inside a job + // Same shape of answer for where the tree is unpacked inside a task // container: the repository knows what its build expects, the // project can set a house default, and failing both there is one. const workdir = pipeline.workdir ?? project.workdir ?? DEFAULT_WORKDIR; await db.transaction(async (tx) => { - const number = await projects.nextRunNumber(tx, project.id); - const empty = pipeline.jobs.length === 0; + const number = await projects.nextJobNumber(tx, project.id); + const empty = pipeline.tasks.length === 0; await tx.run( - `INSERT INTO runs + `INSERT INTO jobs (id, project_id, number, ref, base_sha, head_sha, trigger_type, actor, title, state, pipeline, visibility, created_at, started_at, finished_at) VALUES @@ -146,7 +184,7 @@ export function createScheduler({ cfg, db, git, logs, storage, projects, variabl {title}, {state}, {pipeline}, {visibility}, {now}, {now}, {finished})`, { visibility, - id: runId, + id: jobId, project: project.id, number, ref: ref ?? null, @@ -156,67 +194,70 @@ export function createScheduler({ cfg, db, git, logs, storage, projects, variabl actor, title: info.subject ?? null, state: empty ? 'success' : 'running', - pipeline: JSON.stringify({ version: pipeline.version, jobs: pipeline.jobs }), + pipeline: JSON.stringify({ version: pipeline.version, tasks: pipeline.tasks }), now, finished: empty ? now : null, } ); - const idByName = new Map(pipeline.jobs.map((j) => [j.name, makeJobId(runId, j.name)])); + // Task ids are minted here rather than derived from the name, so + // the dependency edges below are the only thing that maps a name + // onto an id. + const idByName = new Map(pipeline.tasks.map((t) => [t.name, newTaskId()])); - for (const job of pipeline.jobs) { + for (const task of pipeline.tasks) { await tx.run( - `INSERT INTO jobs - (id, run_id, name, base_name, arch, image, requires, spec, state, + `INSERT INTO tasks + (id, job_id, name, base_name, arch, image, requires, spec, state, allow_failure, attempt, max_attempts, timeout, log_size, created_at) VALUES - ({id}, {run}, {name}, {base}, {arch}, {image}, {requires}, {spec}, 'queued', + ({id}, {job}, {name}, {base}, {arch}, {image}, {requires}, {spec}, 'queued', {allow}, 0, {attempts}, {timeout}, 0, {now})`, { - id: idByName.get(job.name), - run: runId, - name: job.name, - base: job.baseName, - arch: job.arch, - image: job.image, - requires: JSON.stringify(job.requires), + id: idByName.get(task.name), + job: jobId, + name: task.name, + base: task.baseName, + arch: task.arch, + image: task.image, + requires: JSON.stringify(task.requires), spec: JSON.stringify({ - script: job.script, - env: job.env, - services: job.services, - artifacts: job.artifacts, - matrix: job.matrix, - needs: job.needs, - depth: job.depth, - // Recorded per job rather than looked up when a worker - // polls, so a run stays reproducible after the project + script: task.script, + env: task.env, + services: task.services, + artifacts: task.artifacts, + matrix: task.matrix, + needs: task.needs, + depth: task.depth, + // Recorded per task rather than looked up when a worker + // polls, so a job stays reproducible after the project // or the pipeline changes. workdir, }), - allow: job.allow_failure ? 1 : 0, - attempts: job.max_attempts, - timeout: job.timeout, + allow: task.allow_failure ? 1 : 0, + attempts: task.max_attempts, + timeout: task.timeout, now, } ); } - for (const job of pipeline.jobs) { - for (const need of job.needs) { + for (const task of pipeline.tasks) { + for (const need of task.needs) { await tx.run( - 'INSERT INTO job_deps (job_id, depends_on_id) VALUES ({job}, {dep})', - { job: idByName.get(job.name), dep: idByName.get(need) } + 'INSERT INTO task_deps (task_id, depends_on_id) VALUES ({task}, {dep})', + { task: idByName.get(task.name), dep: idByName.get(need) } ); } } }); - logger.info?.(`run ${runId} created for ${project.id} with ${pipeline.jobs.length} job(s)`); - return { runId, jobCount: pipeline.jobs.length }; + logger.info?.(`job ${jobId} created for ${project.id} with ${pipeline.tasks.length} task(s)`); + return { jobId, taskCount: pipeline.tasks.length }; }, - // Hands one job to a worker. The worker advertises what it can run; - // jobs asking for anything it lacks are passed over. + // Hands one task to a worker. The worker advertises what it can run; + // tasks asking for anything it lacks are passed over. // // ownerId scopes the worker to one user's projects. That is the whole // basis of letting people contribute their own hardware: a worker @@ -226,13 +267,13 @@ export function createScheduler({ cfg, db, git, logs, storage, projects, variabl const archFilter = inClause('arch', arches); const featureSet = new Set(features); - // Oldest run first, so a queue drains in order rather than starving - // whichever run happens to sort last. + // Oldest job first, so a queue drains in order rather than starving + // whichever job happens to sort last. const candidates = await db.all( `${ELIGIBLE} - AND (j.arch IS NULL${archFilter.empty ? '' : ` OR j.arch IN (${archFilter.sql})`}) + AND (t.arch IS NULL${archFilter.empty ? '' : ` OR t.arch IN (${archFilter.sql})`}) ${ownerId === null ? '' : 'AND p.owner_id = {owner}'} - ORDER BY r.created_at ASC, j.created_at ASC + ORDER BY j.created_at ASC, t.created_at ASC LIMIT 100`, { ...archFilter.params, ...(ownerId === null ? {} : { owner: ownerId }) } ); @@ -248,7 +289,7 @@ export function createScheduler({ cfg, db, git, logs, storage, projects, variabl const now = Date.now(); const claimed = await db.run( - `UPDATE jobs + `UPDATE tasks SET state = 'running', attempt = attempt + 1, worker_token_id = {token}, worker_name = {worker}, claimed_at = {now}, heartbeat_at = {now}, started_at = {now} @@ -262,7 +303,7 @@ export function createScheduler({ cfg, db, git, logs, storage, projects, variabl const attempt = candidate.attempt + 1; // Project variables are resolved at dispatch and handed to the - // worker, never written into the jobs table, so the only place a + // worker, never written into the tasks table, so the only place a // secret rests is project_variables. let injected = { env: {}, masked: [] }; if (variables) { @@ -273,9 +314,12 @@ export function createScheduler({ cfg, db, git, logs, storage, projects, variabl } } + // project_id and job_id are returned for the route to use, not for + // the worker. What the worker is handed is assembled in the route, + // and deliberately carries neither. return { id: candidate.id, - run_id: candidate.run_id, + job_id: candidate.job_id, project_id: candidate.project_id, name: candidate.name, arch: candidate.arch, @@ -290,23 +334,26 @@ export function createScheduler({ cfg, db, git, logs, storage, projects, variabl // Values the worker must redact from the log stream. masked: injected.masked, // Project variables, then pipeline env, then the facts about this - // run. Ordered so a pipeline cannot quietly redefine the last - // group, and a variable cannot shadow an explicit job setting. + // task. Ordered so a pipeline cannot quietly redefine the last + // group, and a variable cannot shadow an explicit task setting. + // + // These name the project and job the worker is not told about, + // which is deliberate: they are opaque strings bound for the + // script, and the worker only copies them into the container. env: { ...injected.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_JOB_ID: candidate.job_id, + CONDUCTOR_JOB_NUMBER: String(candidate.number), + CONDUCTOR_TASK: candidate.name, + CONDUCTOR_TASK_ID: candidate.id, CONDUCTOR_SHA: candidate.head_sha, CONDUCTOR_REF: candidate.ref ?? '', CONDUCTOR_ATTEMPT: String(attempt), }, services: spec.services, artifacts: spec.artifacts, - // Runs created before this existed have no workdir recorded. workdir: spec.workdir ?? DEFAULT_WORKDIR, }; } @@ -314,138 +361,138 @@ export function createScheduler({ cfg, db, git, logs, storage, projects, variabl return null; }, - async heartbeat(jobId, tokenId) { - const job = await db.get( - 'SELECT id, state, worker_token_id FROM jobs WHERE id = {id}', - { id: jobId } + async heartbeat(taskId, tokenId) { + const task = await db.get( + 'SELECT id, state, worker_token_id FROM tasks WHERE id = {id}', + { id: taskId } ); - if (!job) return { known: false }; - if (job.worker_token_id !== tokenId) return { known: false }; + if (!task) return { known: false }; + if (task.worker_token_id !== tokenId) return { known: false }; - if (job.state === 'running') { - await db.run('UPDATE jobs SET heartbeat_at = {now} WHERE id = {id}', { id: jobId, now: Date.now() }); + if (task.state === 'running') { + await db.run('UPDATE tasks SET heartbeat_at = {now} WHERE id = {id}', { id: taskId, now: Date.now() }); return { known: true, cancelled: false }; } - // The job was cancelled or reaped out from under the worker, which is + // The task was cancelled or reaped out from under the worker, which is // how a worker learns to stop. - return { known: true, cancelled: true, state: job.state }; + return { known: true, cancelled: true, state: task.state }; }, // Records the outcome, retries if attempts remain, and otherwise skips // everything downstream. - async complete(jobId, { success, exitCode = null, error = null, tokenId = null }) { + async complete(taskId, { success, exitCode = null, error = null, tokenId = null }) { return db.transaction(async (tx) => { - const job = await tx.get( - `SELECT id, run_id, name, state, attempt, max_attempts, allow_failure, worker_token_id - FROM jobs WHERE id = {id}`, - { id: jobId } + const task = await tx.get( + `SELECT id, job_id, name, state, attempt, max_attempts, allow_failure, worker_token_id + FROM tasks WHERE id = {id}`, + { id: taskId } ); - if (!job) return { ok: false, reason: 'unknown job' }; - if (tokenId !== null && job.worker_token_id !== tokenId) { - return { ok: false, reason: 'job belongs to another worker' }; + if (!task) return { ok: false, reason: 'unknown task' }; + if (tokenId !== null && task.worker_token_id !== tokenId) { + return { ok: false, reason: 'task belongs to another worker' }; } - if (job.state !== 'running') { - return { ok: false, reason: `job is ${job.state}, not running` }; + if (task.state !== 'running') { + return { ok: false, reason: `task is ${task.state}, not running` }; } const now = Date.now(); if (success) { await tx.run( - "UPDATE jobs SET state = 'success', exit_code = {code}, finished_at = {now} WHERE id = {id}", - { id: jobId, code: exitCode, now } + "UPDATE tasks SET state = 'success', exit_code = {code}, finished_at = {now} WHERE id = {id}", + { id: taskId, code: exitCode, now } ); - const runState = await settleRun(tx, job.run_id); - return { ok: true, state: 'success', runState }; + const jobState = await settleJob(tx, task.job_id); + return { ok: true, state: 'success', jobState }; } // Transient failure with attempts left: back to the queue rather - // than failing the run. - if (job.attempt < job.max_attempts) { + // than failing the job. + if (task.attempt < task.max_attempts) { await tx.run( - `UPDATE jobs + `UPDATE tasks SET state = 'queued', exit_code = {code}, error = {error}, worker_token_id = NULL, worker_name = NULL, claimed_at = NULL, heartbeat_at = NULL, started_at = NULL WHERE id = {id}`, - { id: jobId, code: exitCode, error } + { id: taskId, code: exitCode, error } ); - return { ok: true, state: 'queued', retry: true, attempt: job.attempt }; + return { ok: true, state: 'queued', retry: true, attempt: task.attempt }; } await tx.run( - "UPDATE jobs SET state = 'failed', exit_code = {code}, error = {error}, finished_at = {now} WHERE id = {id}", - { id: jobId, code: exitCode, error, now } + "UPDATE tasks SET state = 'failed', exit_code = {code}, error = {error}, finished_at = {now} WHERE id = {id}", + { id: taskId, code: exitCode, error, now } ); let skipped = []; - if (!job.allow_failure) { - skipped = await skipDependents(tx, job.run_id, [jobId], now); + if (!task.allow_failure) { + skipped = await skipDependents(tx, task.job_id, [taskId], now); } - const runState = await settleRun(tx, job.run_id); - return { ok: true, state: 'failed', skipped, runState }; + const jobState = await settleJob(tx, task.job_id); + return { ok: true, state: 'failed', skipped, jobState }; }); }, - // Every job reachable from the failed ones, not just their immediate + // Every task reachable from the failed ones, not just their immediate // dependents. - async skipDependents(runId, fromJobIds) { - return db.transaction((tx) => skipDependents(tx, runId, fromJobIds, Date.now())); + async skipDependents(jobId, fromTaskIds) { + return db.transaction((tx) => skipDependents(tx, jobId, fromTaskIds, Date.now())); }, - // Requeues or fails jobs whose worker stopped reporting. + // Requeues or fails tasks whose worker stopped reporting. async reap() { const cutoff = Date.now() - cfg.scheduler.heartbeat_timeout * 1000; const stale = await db.all( - `SELECT id, run_id, name, attempt, max_attempts, worker_name - FROM jobs + `SELECT id, job_id, name, attempt, max_attempts, worker_name + FROM tasks WHERE state = 'running' AND heartbeat_at IS NOT NULL AND heartbeat_at < {cutoff}`, { cutoff } ); const results = []; - for (const job of stale) { - const outcome = await this.complete(job.id, { + for (const task of stale) { + const outcome = await this.complete(task.id, { success: false, - error: `worker ${job.worker_name ?? 'unknown'} stopped reporting for more than ` + + error: `worker ${task.worker_name ?? 'unknown'} stopped reporting for more than ` + `${cfg.scheduler.heartbeat_timeout}s`, }); if (outcome.ok) { - logger.warn?.(`reaped ${job.name} (${job.id}): ${outcome.retry ? 'requeued' : 'failed'}`); - results.push({ id: job.id, ...outcome }); + logger.warn?.(`reaped ${task.name} (${task.id}): ${outcome.retry ? 'requeued' : 'failed'}`); + results.push({ id: task.id, ...outcome }); } } return results; }, - async cancelRun(runId, reason = 'cancelled by request') { + async cancelJob(jobId, reason = 'cancelled by request') { return db.transaction(async (tx) => { - const run = await tx.get('SELECT id, state FROM runs WHERE id = {id}', { id: runId }); - if (!run) return { ok: false, reason: 'unknown run' }; - if (run.state !== 'running') return { ok: false, reason: `run is ${run.state}` }; + const job = await tx.get('SELECT id, state FROM jobs WHERE id = {id}', { id: jobId }); + if (!job) return { ok: false, reason: 'unknown job' }; + if (job.state !== 'running') return { ok: false, reason: `job is ${job.state}` }; const now = Date.now(); await tx.run( - `UPDATE jobs SET state = 'cancelled', error = {reason}, finished_at = {now} - WHERE run_id = {run} AND state IN ('queued', 'running')`, - { run: runId, reason, now } + `UPDATE tasks SET state = 'cancelled', error = {reason}, finished_at = {now} + WHERE job_id = {job} AND state IN ('queued', 'running')`, + { job: jobId, reason, now } ); await tx.run( - "UPDATE runs SET state = 'cancelled', finished_at = {now} WHERE id = {run}", - { run: runId, now } + "UPDATE jobs SET state = 'cancelled', finished_at = {now} WHERE id = {job}", + { job: jobId, now } ); return { ok: true }; }); }, - // Called once a job finishes so the live log stops being the spool copy. - async finalizeLog(runId, jobId) { - const key = storageKeys.log(runId, jobId); - const { size } = await logs.finalize(runId, jobId, storage, key); + // Called once a task finishes so the live log stops being the spool copy. + async finalizeLog(jobId, taskId) { + const key = storageKeys.log(jobId, taskId); + const { size } = await logs.finalize(jobId, taskId, storage, key); if (size > 0) { - await db.run('UPDATE jobs SET log_key = {key}, log_size = {size} WHERE id = {id}', - { id: jobId, key, size }); + await db.run('UPDATE tasks SET log_key = {key}, log_size = {size} WHERE id = {id}', + { id: taskId, key, size }); } return { key, size }; }, @@ -453,20 +500,20 @@ export function createScheduler({ cfg, db, git, logs, storage, projects, variabl TERMINAL, }; - async function skipDependents(tx, runId, fromJobIds, now) { - const graph = await loadGraph(tx, runId); + async function skipDependents(tx, jobId, fromTaskIds, now) { + const graph = await loadGraph(tx, jobId); const reachable = transitiveDependents( - graph.map((j) => ({ name: j.id, needs: j.needs })), - fromJobIds + graph.map((t) => ({ name: t.id, needs: t.needs })), + fromTaskIds ); if (reachable.size === 0) return []; - const queued = new Set(graph.filter((j) => j.state === 'queued').map((j) => j.id)); + const queued = new Set(graph.filter((t) => t.state === 'queued').map((t) => t.id)); const toSkip = [...reachable].filter((id) => queued.has(id)); for (const id of toSkip) { await tx.run( - `UPDATE jobs SET state = 'skipped', error = {error}, finished_at = {now} + `UPDATE tasks SET state = 'skipped', error = {error}, finished_at = {now} WHERE id = {id} AND state = 'queued'`, { id, error: 'skipped because a dependency did not succeed', now } ); diff --git a/src/conductor/ui/layout.js b/src/conductor/ui/layout.js @@ -8,7 +8,7 @@ import { html, raw, esc } from './html.js'; export function layout({ title, user, body, active = '', localLogin = true }) { const nav = [ - { href: '/', label: 'runs', key: 'runs' }, + { href: '/', label: 'jobs', key: 'jobs' }, user ? { href: '/projects', label: 'projects', key: 'projects' } : null, user ? { href: '/workers', label: 'workers', key: 'workers' } : null, // Accounts cannot sign in while the provider owns identity, so the page diff --git a/src/conductor/ui/pages.js b/src/conductor/ui/pages.js @@ -2,7 +2,7 @@ // // Each live region follows the same htmx pattern: the fragment carries its // own polling attribute only while there is something left to happen. When -// the run or job finishes, the replacement fragment has no trigger, so +// the job or task finishes, the replacement fragment has no trigger, so // polling stops by itself rather than needing to be cancelled. import { html, raw } from './html.js'; @@ -10,126 +10,126 @@ import { badge, notice, oneTimeSecret, shortSha, ago, duration, ACTIVE_RUN_STATE const poll = (url, seconds = 3) => raw(`hx-get="${url}" hx-trigger="every ${seconds}s" hx-swap="outerHTML"`); -// --- runs --- +// --- jobs --- -export function runsPage({ runs, user, anonymous }) { +export function jobsPage({ jobs, user, anonymous }) { return html` - <h2>Runs</h2> - ${anonymous ? html`<p class="muted">Showing public runs. <a href="/login">Sign in</a> to see your own.</p>` : ''} - ${runsTable(runs)} + <h2>Jobs</h2> + ${anonymous ? html`<p class="muted">Showing public jobs. <a href="/login">Sign in</a> to see your own.</p>` : ''} + ${jobsTable(jobs)} `; } -export function runsTable(runs) { - const live = runs.some((r) => ACTIVE_RUN_STATES.includes(r.state)); - return html`<div id="runs" ${live ? poll('/partials/runs', 4) : ''}> +export function jobsTable(jobs) { + const live = jobs.some((r) => ACTIVE_RUN_STATES.includes(r.state)); + return html`<div id="jobs" ${live ? poll('/partials/jobs', 4) : ''}> <table> <thead><tr> - <th>run</th><th>project</th><th>ref</th><th>commit</th><th>state</th><th>started</th> + <th>job</th><th>project</th><th>ref</th><th>commit</th><th>state</th><th>started</th> </tr></thead> <tbody> - ${runs.length === 0 - ? html`<tr><td colspan="6" class="muted">No runs to show.</td></tr>` - : runs.map((run) => html`<tr> - <td><a href="/runs/${run.id}">#${run.number}</a></td> - <td>${run.project_id}</td> - <td class="muted">${(run.ref ?? '').replace('refs/heads/', '')}</td> - <td class="mono muted">${shortSha(run.head_sha)}</td> - <td>${badge(run.state)}</td> - <td class="muted">${ago(run.created_at)}</td> + ${jobs.length === 0 + ? html`<tr><td colspan="6" class="muted">No jobs to show.</td></tr>` + : jobs.map((job) => html`<tr> + <td><a href="/jobs/${job.id}">#${job.number}</a></td> + <td>${job.project_id}</td> + <td class="muted">${(job.ref ?? '').replace('refs/heads/', '')}</td> + <td class="mono muted">${shortSha(job.head_sha)}</td> + <td>${badge(job.state)}</td> + <td class="muted">${ago(job.created_at)}</td> </tr>`)} </tbody> </table> </div>`; } -export function runPage({ run, jobs, canManage }) { +export function jobPage({ job, tasks, canManage }) { return html` <div class="panel"> - <h2>${run.title || `Run #${run.number}`}</h2> + <h2>${job.title || `Job #${job.number}`}</h2> <div class="row"> - ${field('project', html`<a href="/runs?project=${run.project_id}">${run.project_id}</a>`)} - ${field('state', badge(run.state))} - ${field('ref', (run.ref ?? '').replace('refs/heads/', '') || '-')} - ${field('commit', html`<span class="mono">${shortSha(run.head_sha)}</span>`)} - ${field('trigger', `${run.trigger_type}${run.actor ? ` by ${run.actor}` : ''}`)} - ${field('visibility', run.visibility)} - ${field('duration', duration(run.started_at, run.finished_at))} + ${field('project', html`<a href="/jobs?project=${job.project_id}">${job.project_id}</a>`)} + ${field('state', badge(job.state))} + ${field('ref', (job.ref ?? '').replace('refs/heads/', '') || '-')} + ${field('commit', html`<span class="mono">${shortSha(job.head_sha)}</span>`)} + ${field('trigger', `${job.trigger_type}${job.actor ? ` by ${job.actor}` : ''}`)} + ${field('visibility', job.visibility)} + ${field('duration', duration(job.started_at, job.finished_at))} </div> ${canManage ? html`<div class="actions"> - ${run.state === 'running' - ? html`<button hx-post="/runs/${run.id}/cancel" hx-target="#jobs" hx-swap="outerHTML">cancel</button>` + ${job.state === 'running' + ? html`<button hx-post="/jobs/${job.id}/cancel" hx-target="#tasks" hx-swap="outerHTML">cancel</button>` : ''} - <button hx-post="/runs/${run.id}/retry" hx-swap="none">run again</button> + <button hx-post="/jobs/${job.id}/retry" hx-swap="none">run again</button> </div>` : ''} </div> - ${jobsTable(run, jobs)} + ${tasksTable(job, tasks)} `; } -export function jobsTable(run, jobs) { - const live = ACTIVE_RUN_STATES.includes(run.state); +export function tasksTable(job, tasks) { + const live = ACTIVE_RUN_STATES.includes(job.state); // Grouped by graph depth, which is how the pipeline reads. - const byId = new Map(jobs.map((j) => [j.id, j])); - const depthOf = (job, seen = new Set()) => { - if (seen.has(job.id)) return 0; - seen.add(job.id); - const deps = (job.needs ?? []).map((id) => byId.get(id)).filter(Boolean); + const byId = new Map(tasks.map((j) => [j.id, j])); + const depthOf = (task, seen = new Set()) => { + if (seen.has(task.id)) return 0; + seen.add(task.id); + const deps = (task.needs ?? []).map((id) => byId.get(id)).filter(Boolean); return deps.length === 0 ? 0 : Math.max(...deps.map((d) => depthOf(d, seen) + 1)); }; const stages = new Map(); - for (const job of jobs) { - const depth = depthOf(job); + for (const task of tasks) { + const depth = depthOf(task); if (!stages.has(depth)) stages.set(depth, []); - stages.get(depth).push(job); + stages.get(depth).push(task); } - return html`<div id="jobs" ${live ? poll(`/partials/runs/${run.id}/jobs`, 3) : ''}> + return html`<div id="tasks" ${live ? poll(`/partials/jobs/${job.id}/tasks`, 3) : ''}> ${[...stages.keys()].sort((a, b) => a - b).map((depth) => html` <div class="stage"> <h3>stage ${depth + 1}</h3> <table><tbody> - ${stages.get(depth).sort((a, b) => a.name.localeCompare(b.name)).map((job) => html`<tr> - <td><a href="/jobs/${job.id}">${job.name}</a></td> - <td>${badge(job.state)}</td> - <td class="muted">${job.arch ?? ''}</td> - <td class="muted">${job.worker_name ?? ''}</td> - <td class="muted">${duration(job.started_at, job.finished_at)}</td> - <td class="muted">${job.exit_code === null ? '' : `exit ${job.exit_code}`}</td> + ${stages.get(depth).sort((a, b) => a.name.localeCompare(b.name)).map((task) => html`<tr> + <td><a href="/tasks/${task.id}">${task.name}</a></td> + <td>${badge(task.state)}</td> + <td class="muted">${task.arch ?? ''}</td> + <td class="muted">${task.worker_name ?? ''}</td> + <td class="muted">${duration(task.started_at, task.finished_at)}</td> + <td class="muted">${task.exit_code === null ? '' : `exit ${task.exit_code}`}</td> </tr>`)} </tbody></table> </div>`)} </div>`; } -// --- jobs --- +// --- tasks --- -export function jobPage({ job, artifacts, log }) { +export function taskPage({ task, artifacts, log }) { return html` <div class="panel"> - <h2>${job.name}</h2> + <h2>${task.name}</h2> <div class="row"> - ${field('state', badge(job.state))} - ${field('run', html`<a href="/runs/${job.run_id}">back to run</a>`)} - ${field('image', html`<span class="mono">${job.image}</span>`)} - ${field('arch', job.arch ?? '-')} - ${field('attempt', `${job.attempt} of ${job.max_attempts}`)} - ${field('worker', job.worker_name ?? '-')} - ${field('duration', duration(job.started_at, job.finished_at))} + ${field('state', badge(task.state))} + ${field('job', html`<a href="/jobs/${task.job_id}">back to job</a>`)} + ${field('image', html`<span class="mono">${task.image}</span>`)} + ${field('arch', task.arch ?? '-')} + ${field('attempt', `${task.attempt} of ${task.max_attempts}`)} + ${field('worker', task.worker_name ?? '-')} + ${field('duration', duration(task.started_at, task.finished_at))} </div> - ${job.error ? notice(job.error) : ''} + ${task.error ? notice(task.error) : ''} </div> <h3>log</h3> - <div class="logpane">${jobLog(job, log)}</div> + <div class="logpane">${taskLog(task, log)}</div> ${artifacts.length === 0 ? '' : html` <h3>artifacts</h3> <table><tbody> ${artifacts.map((a) => html`<tr> - <td><a href="/api/v1/projects/${job.project_id}/runs/${job.run_id}/jobs/${job.id}/artifacts/${a.id}">${a.path}</a></td> + <td><a href="/api/v1/projects/${task.project_id}/jobs/${task.job_id}/tasks/${task.id}/artifacts/${a.id}">${a.path}</a></td> <td class="muted">${a.size} bytes</td> <td class="mono muted">${a.sha256.slice(0, 12)}</td> </tr>`)} @@ -137,9 +137,9 @@ export function jobPage({ job, artifacts, log }) { `; } -export function jobLog(job, log) { - const live = ACTIVE_JOB_STATES.includes(job.state); - return html`<pre id="log" class="log" ${live ? poll(`/partials/jobs/${job.id}/log`, 2) : ''}>${ +export function taskLog(task, log) { + const live = ACTIVE_JOB_STATES.includes(task.state); + return html`<pre id="log" class="log" ${live ? poll(`/partials/tasks/${task.id}/log`, 2) : ''}>${ log || (live ? 'Waiting for output.' : 'No output.') }</pre>`; } @@ -190,7 +190,7 @@ export function projectsPage({ projects, user }) { export function projectsTable(projects, user) { return html`<table id="projects"> - <thead><tr><th>project</th><th>repository</th><th>visibility</th><th>owner</th><th>runs</th><th></th></tr></thead> + <thead><tr><th>project</th><th>repository</th><th>visibility</th><th>owner</th><th>jobs</th><th></th></tr></thead> <tbody> ${projects.length === 0 ? html`<tr><td colspan="6" class="muted">No projects yet.</td></tr>` @@ -221,7 +221,7 @@ export function projectPage({ project, variables, triggerUrl, secret, user, rete ${field('pipeline', html`<span class="mono">${project.config_path}</span>`)} ${field('owner', project.owner_id === null ? 'shared' : (project.owner_id === user.id ? 'you' : project.owner_id))} - ${field('runs', project.run_counter)} + ${field('jobs', project.run_counter)} </div> <p class="muted">Trigger endpoint</p> <input class="wide mono" type="text" readonly value="${triggerUrl}" onclick="this.select()"> @@ -251,7 +251,7 @@ export function projectPage({ project, variables, triggerUrl, secret, user, rete <p class="muted"> A pipeline may override visibility per commit with a top level visibility key, and the working directory with a workdir key. - Left empty, jobs run in ${retention.workdir}. + Left empty, tasks run in ${retention.workdir}. </p> <div class="actions"><button type="submit">save</button></div> </form> @@ -265,10 +265,10 @@ export function projectPage({ project, variables, triggerUrl, secret, user, rete </p> <form hx-patch="/projects/${project.id}/retention" hx-target="body" hx-swap="none"> <div class="grid"> - <label>keep artifacts for runs - <input name="artifact_keep_runs" type="number" min="0" inputmode="numeric" - placeholder="${retention.artifact_keep_runs}" - value="${project.artifact_keep_runs ?? ''}"> + <label>keep artifacts for jobs + <input name="artifact_keep_jobs" type="number" min="0" inputmode="numeric" + placeholder="${retention.artifact_keep_jobs}" + value="${project.artifact_keep_jobs ?? ''}"> </label> <label>keep artifacts for days <input name="artifact_keep_days" type="number" min="0" inputmode="numeric" @@ -283,8 +283,8 @@ export function projectPage({ project, variables, triggerUrl, secret, user, rete </div> <p class="muted"> An artifact is kept if either rule wants it, so the last - ${project.artifact_keep_runs ?? retention.artifact_keep_runs} runs survive - whatever their age. The most recent successful run is always kept. A job + ${project.artifact_keep_jobs ?? retention.artifact_keep_jobs} jobs survive + whatever their age. The most recent successful job is always kept. A task that sets artifacts.expire overrides all of this. </p> <div class="actions"><button type="submit">save</button></div> @@ -293,7 +293,7 @@ export function projectPage({ project, variables, triggerUrl, secret, user, rete <div class="panel"> <h3>Variables</h3> - <p class="muted">Injected into every job. Masked values are redacted from logs.</p> + <p class="muted">Injected into every task. Masked values are redacted from logs.</p> ${variablesTable(project, variables)} <form hx-put="/projects/${project.id}/variables" hx-target="#variables" hx-swap="outerHTML"> <div class="grid"> @@ -315,7 +315,7 @@ export function projectPage({ project, variables, triggerUrl, secret, user, rete </button> <button class="danger" hx-delete="/projects/${project.id}" - hx-confirm="Delete ${project.id} and all of its runs?" + hx-confirm="Delete ${project.id} and all of its jobs?" hx-swap="none">delete project</button> </div> </div> @@ -343,8 +343,8 @@ export function workersPage({ tokens, user, created }) { return html` <h2>Workers</h2> <p class="muted"> - A worker you register only ever receives jobs from your own projects. - ${user.role === 'admin' ? 'A shared worker receives jobs from any project.' : ''} + A worker you register only ever receives tasks from your own projects. + ${user.role === 'admin' ? 'A shared worker receives tasks from any project.' : ''} </p> ${created ? oneTimeSecret( `Worker token for ${created.name}`, @@ -408,7 +408,7 @@ export function usersPage({ users, localLogin }) { <label>username<input name="username" required></label> <label>password<input name="password" type="password" required></label> <label>role - <select name="role"><option value="viewer">viewer</option><option value="admin">admin</option></select> + <select name="role"><option value="user">user</option><option value="admin">admin</option></select> </label> </div> <div class="actions"><button type="submit">create</button></div> @@ -450,7 +450,7 @@ function deleteWarning(user) { } const parts = []; if (user.project_count > 0) { - parts.push(`${user.project_count} project(s) and all of their run history`); + parts.push(`${user.project_count} project(s) and all of their job history`); } if (user.worker_count > 0) parts.push(`${user.worker_count} worker token(s)`); return `Remove ${user.username}? This also deletes ${parts.join(' and ')}. This cannot be undone.`; diff --git a/src/conductor/ui/routes.js b/src/conductor/ui/routes.js @@ -12,7 +12,7 @@ import crypto from 'node:crypto'; import { toHtml, html } from './html.js'; import { layout } from './layout.js'; import { - runsPage, runsTable, runPage, jobsTable, jobPage, jobLog, loginPage, + jobsPage, jobsTable, jobPage, tasksTable, taskPage, taskLog, loginPage, projectsPage, projectPage, variablesTable, workersPage, workersTable, usersPage, usersTable, } from './pages.js'; @@ -49,7 +49,7 @@ export default async function uiRoutes(fastify, services) { return send(reply, layout({ title, user, body, active, localLogin: auth.localLogin })); } - const viewer = (req) => auth.identify(req).catch(() => null); + const currentUser = (req) => auth.identify(req).catch(() => null); // Only a path on this conductor, so a crafted link cannot bounce someone // to another site after signing in. @@ -80,7 +80,7 @@ export default async function uiRoutes(fastify, services) { // A signed in user, or a redirect to sign in. With OIDC that means the // provider; otherwise the built-in sign in page. async function required(req, reply) { - const user = await viewer(req); + const user = await currentUser(req); if (user) return user; if (auth.oidc) { @@ -122,189 +122,189 @@ export default async function uiRoutes(fastify, services) { .send(toHtml(html`<p class="error">${message}</p>`)); } - // --- runs --- + // --- jobs --- - async function visibleRuns(user, projectId = null) { + async function visibleJobs(user, projectId = null) { const scope = user && user.role === 'admin' ? { sql: '1 = 1', params: {} } : user - ? { sql: "(r.visibility = 'public' OR p.owner_id = {viewerId})", params: { viewerId: user.id } } - : { sql: "r.visibility = 'public'", params: {} }; + ? { sql: "(j.visibility = 'public' OR p.owner_id = {viewerId})", params: { viewerId: user.id } } + : { sql: "j.visibility = 'public'", params: {} }; return db.all( - `SELECT r.id, r.project_id, r.number, r.ref, r.head_sha, r.state, r.visibility, - r.title, r.created_at, r.started_at, r.finished_at - FROM runs r JOIN projects p ON p.id = r.project_id - WHERE ${scope.sql} ${projectId ? 'AND r.project_id = {project}' : ''} - ORDER BY r.created_at DESC LIMIT 50`, + `SELECT j.id, j.project_id, j.number, j.ref, j.head_sha, j.state, j.visibility, + j.title, j.created_at, j.started_at, j.finished_at + FROM jobs j JOIN projects p ON p.id = j.project_id + WHERE ${scope.sql} ${projectId ? 'AND j.project_id = {project}' : ''} + ORDER BY j.created_at DESC LIMIT 50`, { ...scope.params, ...(projectId ? { project: projectId } : {}) } ); } fastify.get('/', async (req, reply) => { - const user = await viewer(req); - const runs = await visibleRuns(user, typeof req.query.project === 'string' ? req.query.project : null); + const user = await currentUser(req); + const jobs = await visibleJobs(user, typeof req.query.project === 'string' ? req.query.project : null); return page(reply, { - title: 'runs', + title: 'jobs', user, - active: 'runs', - body: runsPage({ runs, user, anonymous: !user }), + active: 'jobs', + body: jobsPage({ jobs, user, anonymous: !user }), }); }); - fastify.get('/runs', async (req, reply) => { - const user = await viewer(req); - const runs = await visibleRuns(user, typeof req.query.project === 'string' ? req.query.project : null); - return page(reply, { title: 'runs', user, active: 'runs', body: runsPage({ runs, user, anonymous: !user }) }); + fastify.get('/jobs', async (req, reply) => { + const user = await currentUser(req); + const jobs = await visibleJobs(user, typeof req.query.project === 'string' ? req.query.project : null); + return page(reply, { title: 'jobs', user, active: 'jobs', body: jobsPage({ jobs, user, anonymous: !user }) }); }); - fastify.get('/partials/runs', async (req, reply) => { - const user = await viewer(req); - return send(reply, runsTable(await visibleRuns(user))); + fastify.get('/partials/jobs', async (req, reply) => { + const user = await currentUser(req); + return send(reply, jobsTable(await visibleJobs(user))); }); - // Loads a run the caller may see, along with whether they may act on it. - async function runFor(req, reply) { - const user = await viewer(req); - const run = await db.get('SELECT * FROM runs WHERE id = {id}', { id: req.params.id }); - if (!run) { + // Loads a job the caller may see, along with whether they may act on it. + async function jobFor(req, reply) { + const user = await currentUser(req); + const job = await db.get('SELECT * FROM jobs WHERE id = {id}', { id: req.params.id }); + if (!job) { reply.code(404); - return { user, run: null }; + return { user, job: null }; } - const project = await projects.get(run.project_id); - if (run.visibility !== 'public' && !canManageProject(user, project)) { + const project = await projects.get(job.project_id); + if (job.visibility !== 'public' && !canManageProject(user, project)) { reply.code(404); - return { user, run: null }; + return { user, job: null }; } - return { user, run, project, canManage: canManageProject(user, project) }; + return { user, job, project, canManage: canManageProject(user, project) }; } - async function jobsOf(runId) { - const jobs = await db.all( + async function tasksOf(jobId) { + const tasks = await db.all( `SELECT id, name, arch, state, worker_name, exit_code, started_at, finished_at - FROM jobs WHERE run_id = {run} ORDER BY name`, - { run: runId } + FROM tasks WHERE job_id = {job} ORDER BY name`, + { job: jobId } ); const deps = await db.all( - `SELECT d.job_id, d.depends_on_id FROM job_deps d - JOIN jobs j ON j.id = d.job_id WHERE j.run_id = {run}`, - { run: runId } + `SELECT d.task_id, d.depends_on_id FROM task_deps d + JOIN tasks t ON t.id = d.task_id WHERE t.job_id = {job}`, + { job: jobId } ); - const needs = new Map(jobs.map((j) => [j.id, []])); - for (const d of deps) needs.get(d.job_id)?.push(d.depends_on_id); - return jobs.map((j) => ({ ...j, needs: needs.get(j.id) ?? [] })); + const needs = new Map(tasks.map((t) => [t.id, []])); + for (const d of deps) needs.get(d.task_id)?.push(d.depends_on_id); + return tasks.map((j) => ({ ...j, needs: needs.get(j.id) ?? [] })); } - fastify.get('/runs/:id', async (req, reply) => { - const { user, run, canManage } = await runFor(req, reply); - if (!run) return page(reply, { title: 'not found', user, body: html`<p class="error">No such run.</p>` }); + fastify.get('/jobs/:id', async (req, reply) => { + const { user, job, canManage } = await jobFor(req, reply); + if (!job) return page(reply, { title: 'not found', user, body: html`<p class="error">No such job.</p>` }); return page(reply, { - title: `run #${run.number}`, + title: `job #${job.number}`, user, - active: 'runs', - body: runPage({ run, jobs: await jobsOf(run.id), canManage }), + active: 'jobs', + body: jobPage({ job, tasks: await tasksOf(job.id), canManage }), }); }); - fastify.get('/partials/runs/:id/jobs', async (req, reply) => { - const { run } = await runFor(req, reply); - if (!run) return reply.send(''); - return send(reply, jobsTable(run, await jobsOf(run.id))); + fastify.get('/partials/jobs/:id/tasks', async (req, reply) => { + const { job } = await jobFor(req, reply); + if (!job) return reply.send(''); + return send(reply, tasksTable(job, await tasksOf(job.id))); }); - fastify.post('/runs/:id/cancel', async (req, reply) => { + fastify.post('/jobs/:id/cancel', async (req, reply) => { if (!fromUi(req, reply)) return reply; const user = await required(req, reply); if (!user) return reply; - const { run, canManage } = await runFor(req, reply); - if (!run || !canManage) return fail(reply, 'Not yours to manage.', 403); + const { job, canManage } = await jobFor(req, reply); + if (!job || !canManage) return fail(reply, 'Not yours to manage.', 403); - await scheduler.cancelRun(run.id, `cancelled by ${user.username}`); - const fresh = await db.get('SELECT * FROM runs WHERE id = {id}', { id: run.id }); - return send(reply, jobsTable(fresh, await jobsOf(run.id))); + await scheduler.cancelJob(job.id, `cancelled by ${user.username}`); + const fresh = await db.get('SELECT * FROM jobs WHERE id = {id}', { id: job.id }); + return send(reply, tasksTable(fresh, await tasksOf(job.id))); }); - fastify.post('/runs/:id/retry', async (req, reply) => { + fastify.post('/jobs/:id/retry', async (req, reply) => { if (!fromUi(req, reply)) return reply; const user = await required(req, reply); if (!user) return reply; - const { run, project, canManage } = await runFor(req, reply); - if (!run || !canManage) return fail(reply, 'Not yours to manage.', 403); + const { job, project, canManage } = await jobFor(req, reply); + if (!job || !canManage) return fail(reply, 'Not yours to manage.', 403); try { - const created = await scheduler.createRun(project, { - ref: run.ref, - baseSha: run.base_sha, - headSha: run.head_sha, + const created = await scheduler.createJob(project, { + ref: job.ref, + baseSha: job.base_sha, + headSha: job.head_sha, trigger: 'manual', actor: user.username, }); - return refresh(reply, `/runs/${created.runId}`); + return refresh(reply, `/jobs/${created.jobId}`); } catch (e) { return fail(reply, e instanceof PipelineError ? e.message : String(e.message ?? e), 422); } }); - // --- jobs --- + // --- tasks --- - fastify.get('/jobs/:id', async (req, reply) => { - const user = await viewer(req); - const job = await db.get( - `SELECT j.*, r.visibility, r.project_id FROM jobs j - JOIN runs r ON r.id = j.run_id WHERE j.id = {id}`, + fastify.get('/tasks/:id', async (req, reply) => { + const user = await currentUser(req); + const task = await db.get( + `SELECT t.*, j.visibility, j.project_id FROM tasks t + JOIN jobs j ON j.id = t.job_id WHERE t.id = {id}`, { id: req.params.id } ); - if (!job) return page(reply, { title: 'not found', user, body: html`<p class="error">No such job.</p>` }); + if (!task) return page(reply, { title: 'not found', user, body: html`<p class="error">No such task.</p>` }); - const project = await projects.get(job.project_id); - if (job.visibility !== 'public' && !canManageProject(user, project)) { - return page(reply, { title: 'not found', user, body: html`<p class="error">No such job.</p>` }); + const project = await projects.get(task.project_id); + if (task.visibility !== 'public' && !canManageProject(user, project)) { + return page(reply, { title: 'not found', user, body: html`<p class="error">No such task.</p>` }); } const artifacts = await db.all( - 'SELECT id, path, size, sha256 FROM artifacts WHERE job_id = {job} ORDER BY path', - { job: job.id } + 'SELECT id, path, size, sha256 FROM artifacts WHERE task_id = {task} ORDER BY path', + { task: task.id } ); return page(reply, { - title: job.name, + title: task.name, user, - active: 'runs', - body: jobPage({ job, artifacts, log: await tailOf(job) }), + active: 'jobs', + body: taskPage({ task, artifacts, log: await tailOf(task) }), }); }); - fastify.get('/partials/jobs/:id/log', async (req, reply) => { - const user = await viewer(req); - const job = await db.get( - `SELECT j.*, r.visibility, r.project_id FROM jobs j - JOIN runs r ON r.id = j.run_id WHERE j.id = {id}`, + fastify.get('/partials/tasks/:id/log', async (req, reply) => { + const user = await currentUser(req); + const task = await db.get( + `SELECT t.*, j.visibility, j.project_id FROM tasks t + JOIN jobs j ON j.id = t.job_id WHERE t.id = {id}`, { id: req.params.id } ); - if (!job) return reply.send(''); + if (!task) return reply.send(''); - const project = await projects.get(job.project_id); - if (job.visibility !== 'public' && !canManageProject(user, project)) return reply.send(''); + const project = await projects.get(task.project_id); + if (task.visibility !== 'public' && !canManageProject(user, project)) return reply.send(''); - return send(reply, jobLog(job, await tailOf(job))); + return send(reply, taskLog(task, await tailOf(task))); }); // The last window of output. Replacing the whole pane each poll keeps the // fragment self contained, and a cap keeps a runaway log from being // re-sent in full every two seconds. - async function tailOf(job) { - if (!job.log_key) { - const size = await logs.size(job.run_id, job.id); + async function tailOf(task) { + if (!task.log_key) { + const size = await logs.size(task.job_id, task.id); const offset = Math.max(0, size - LOG_TAIL_BYTES); - const chunk = await logs.read(job.run_id, job.id, { offset, limit: LOG_TAIL_BYTES }); + const chunk = await logs.read(task.job_id, task.id, { offset, limit: LOG_TAIL_BYTES }); return chunk.data.toString('utf8'); } try { - const start = Math.max(0, (job.log_size ?? 0) - LOG_TAIL_BYTES); - const object = await services.storage.get(job.log_key, { range: { start } }); + const start = Math.max(0, (task.log_size ?? 0) - LOG_TAIL_BYTES); + const object = await services.storage.get(task.log_key, { range: { start } }); const parts = []; for await (const part of object.stream) parts.push(part); return Buffer.concat(parts).toString('utf8'); @@ -316,7 +316,7 @@ export default async function uiRoutes(fastify, services) { // --- session --- fastify.get('/login', async (req, reply) => { - const user = await viewer(req); + const user = await currentUser(req); if (user) return reply.redirect('/', 302); // With the provider in charge, asking for the sign in page is asking to @@ -525,7 +525,7 @@ export default async function uiRoutes(fastify, services) { const body = req.body ?? {}; const values = {}; - for (const key of ['artifact_keep_runs', 'artifact_keep_days', 'log_keep_days']) { + for (const key of ['artifact_keep_jobs', 'artifact_keep_days', 'log_keep_days']) { if (!Object.hasOwn(body, key)) continue; const raw = String(body[key] ?? '').trim(); // An empty field means "follow the server default", which is a null @@ -614,7 +614,7 @@ export default async function uiRoutes(fastify, services) { const name = String(req.body?.name ?? '').trim(); if (!name) return fail(reply, 'A name is required.'); - // Only an administrator can create capacity that runs anyone's work. + // Only an administrator can create capacity that jobs anyone's work. const shared = user.role === 'admin' && req.body?.shared === 'true'; const created = await workerTokens.create(name, { ownerId: shared ? null : user.id }); @@ -688,7 +688,7 @@ export default async function uiRoutes(fastify, services) { await users.create({ username: String(req.body?.username ?? ''), password: String(req.body?.password ?? ''), - role: req.body?.role === 'admin' ? 'admin' : 'viewer', + role: req.body?.role === 'admin' ? 'admin' : 'user', }); } catch (e) { return fail(reply, e.message); diff --git a/src/lib/auth/oidc.js b/src/lib/auth/oidc.js @@ -16,7 +16,7 @@ // groups, and there is no standard. const ADMIN = 'admin'; -const VIEWER = 'viewer'; +const USER_ROLE = 'user'; const DISCOVERY_TIMEOUT = 10000; const TOKEN_TIMEOUT = 10000; @@ -106,7 +106,7 @@ export function createOidc(cfg) { issuer, subject: claims.sub, username: claims.preferred_username ?? claims.email ?? claims.sub, - role: roles.includes(adminRole) ? ADMIN : VIEWER, + role: roles.includes(adminRole) ? ADMIN : USER_ROLE, roles, }; } diff --git a/src/lib/config.js b/src/lib/config.js @@ -81,11 +81,11 @@ const DEFAULTS = { max_size: 64 * 1024 * 1024, }, // What happens to build output over time. A project may override any of - // these; see migrations/sqlite/004_retention.sql for how the artifact + // these; see migrations/sqlite/001_initial.sql for how the artifact // rules combine. Zero means keep forever. retention: { // The last this many runs keep their artifacts whatever their age. - artifact_keep_runs: 10, + artifact_keep_jobs: 10, // Artifacts younger than this are kept whatever has followed them. artifact_keep_days: 30, // Logs go purely by age, and are the reason this exists at all: they @@ -103,14 +103,14 @@ const DEFAULTS = { // considered lost and is requeued or failed. heartbeat_timeout: 120, reap_interval: 30, - default_job_timeout: 3600, + default_task_timeout: 3600, max_attempts: 3, }, }; // [environment variable, dotted config path, parser] const ENV_MAP = [ - ['CONDUCTOR_RETENTION_ARTIFACT_RUNS', 'retention.artifact_keep_runs', toInt], + ['CONDUCTOR_RETENTION_ARTIFACT_JOBS', 'retention.artifact_keep_jobs', toInt], ['CONDUCTOR_RETENTION_ARTIFACT_DAYS', 'retention.artifact_keep_days', toInt], ['CONDUCTOR_RETENTION_LOG_DAYS', 'retention.log_keep_days', toInt], ['CONDUCTOR_RETENTION_SWEEP_INTERVAL','retention.sweep_interval', toInt], @@ -265,7 +265,7 @@ function validate(cfg) { // Retention deletes things, so a nonsensical value here is worth // refusing at startup rather than discovering from missing artifacts. - for (const key of ['artifact_keep_runs', 'artifact_keep_days', 'log_keep_days']) { + for (const key of ['artifact_keep_jobs', 'artifact_keep_days', 'log_keep_days']) { const value = cfg.retention[key]; if (!Number.isInteger(value) || value < 0) { errors.push(`retention.${key} must be a whole number of zero or more, got ${JSON.stringify(value)}`); diff --git a/src/lib/db/index.js b/src/lib/db/index.js @@ -11,7 +11,7 @@ // Params is an object, and queries use named {name} markers which each driver // compiles to its own placeholder syntax. See query.js. // -// db.get('SELECT * FROM jobs WHERE run_id = {run}', { run: runId }) +// db.get('SELECT * FROM tasks WHERE job_id = {job}', { job: jobId }) // // Portability rules that apply to every query written against this layer: // - named {name} markers only, never ? or $n directly diff --git a/src/lib/db/query.js b/src/lib/db/query.js @@ -2,7 +2,7 @@ // // Queries are written once, against every dialect, using {name} markers: // -// db.get('SELECT * FROM jobs WHERE run_id = {run} AND state = {state}', +// db.get('SELECT * FROM tasks WHERE job_id = {job} AND state = {state}', // { run: runId, state: 'queued' }) // // The marker is compiled to whatever the driver wants (? for sqlite and diff --git a/src/lib/ids.js b/src/lib/ids.js @@ -18,7 +18,7 @@ export function randomId(length = 24) { return out; } -// Sortable by creation time, which keeps run listings stable without relying +// Sortable by creation time, which keeps job listings stable without relying // on a clock skewed created_at. 8 chars of millisecond timestamp in base32 // followed by 12 random chars. export function timeOrderedId(now = Date.now()) { @@ -31,29 +31,21 @@ export function timeOrderedId(now = Date.now()) { return stamp + randomId(12); } -export const newRunId = () => timeOrderedId(); +export const newJobId = () => timeOrderedId(); export const newProjectId = () => randomId(16); export const newArtifactId = () => randomId(24); export const newUserId = () => randomId(16); export const newWorkerTokenId = () => randomId(16); -// Job ids stay human readable, since they appear in logs, storage keys and -// URLs. The jobs.id column is 96 characters, so an unusually long job name -// falls back to a truncated form with a hash suffix rather than being -// rejected or silently colliding. -export const JOB_ID_MAX = 96; - -export function jobId(runId, jobName) { - const direct = `${runId}:${jobName}`; - if (direct.length <= JOB_ID_MAX) return direct; - - const digest = crypto.createHash('sha256').update(jobName).digest('hex').slice(0, 12); - const room = JOB_ID_MAX - runId.length - 1 - 1 - digest.length; - if (room < 1) { - throw new Error(`run id ${runId} leaves no room for a job id`); - } - return `${runId}:${jobName.slice(0, room)}~${digest}`; -} +// Task ids carry no structure at all. A worker holding one cannot tell which +// job or project it belongs to, which is the point: a worker is given a +// context and a script, and has no business knowing what they are for. +// +// It also removes a class of nuisance. The previous form embedded the task +// name, so an id could contain ':' ',' and '=', and every use had to escape +// it for a URL, sanitize it for a container name and encode it for a storage +// key. Time ordered so that tasks of one job still group naturally. +export const newTaskId = () => timeOrderedId(); // Worker and session tokens. The plaintext is shown once and only its hash // is stored, so a database leak does not yield usable credentials. diff --git a/src/lib/log.js b/src/lib/log.js @@ -1,8 +1,8 @@ -// src/lib/log.js - job log spool +// src/lib/log.js - task log spool // -// Logs are written to local disk while a job runs, then copied to object +// Logs are written to local disk while a task runs, then copied to object // storage when it finishes. The spool is always local even when S3 is -// configured, because a running job produces many small appends and a live +// configured, because a running task produces many small appends and a live // tail wants cheap random reads; neither suits an object store. // // Appends carry the offset the worker believes it is writing at, which makes @@ -44,10 +44,11 @@ export function createLogStore(cfg) { } } - // Job ids contain a colon, which is fine on every filesystem we target, - // but the run id still gives a directory per run to keep listings sane. - function spoolPath(runId, jobId) { - return path.join(root, encodeURIComponent(runId), `${encodeURIComponent(jobId)}.log`); + // A directory per job keeps listings sane, and lets a whole job's spool + // be removed in one go. Both ids are encoded anyway, which costs nothing + // now that neither carries a separator. + function spoolPath(jobId, taskId) { + return path.join(root, encodeURIComponent(jobId), `${encodeURIComponent(taskId)}.log`); } async function currentSize(file) { @@ -62,15 +63,15 @@ export function createLogStore(cfg) { return { spoolPath, - async size(runId, jobId) { - return currentSize(spoolPath(runId, jobId)); + async size(jobId, taskId) { + return currentSize(spoolPath(jobId, taskId)); }, // Returns the new total size. When offset is omitted the chunk is simply // appended, which is what a worker that never retries will do. - async append(runId, jobId, chunk, offset) { - const file = spoolPath(runId, jobId); - const key = `${runId}/${jobId}`; + async append(jobId, taskId, chunk, offset) { + const file = spoolPath(jobId, taskId); + const key = `${jobId}/${taskId}`; const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8'); return withLock(key, async () => { @@ -112,8 +113,8 @@ export function createLogStore(cfg) { // Reads a window for the live tail. Returns the total size too, so the // caller knows whether more is already available. - async read(runId, jobId, { offset = 0, limit = 256 * 1024 } = {}) { - const file = spoolPath(runId, jobId); + async read(jobId, taskId, { offset = 0, limit = 256 * 1024 } = {}) { + const file = spoolPath(jobId, taskId); const size = await currentSize(file); if (size === 0 || offset >= size) { return { data: Buffer.alloc(0), offset: Math.min(offset, size), size }; @@ -133,8 +134,8 @@ export function createLogStore(cfg) { // Copies the finished log into object storage and drops the spool copy. // Storage is the durable home; the spool only exists to serve a tail. - async finalize(runId, jobId, storage, storageKey) { - const file = spoolPath(runId, jobId); + async finalize(jobId, taskId, storage, storageKey) { + const file = spoolPath(jobId, taskId); const size = await currentSize(file); if (size === 0) return { key: null, size: 0 }; @@ -152,12 +153,12 @@ export function createLogStore(cfg) { return { key: storageKey, size }; }, - async remove(runId, jobId) { - await fs.rm(spoolPath(runId, jobId), { force: true }); + async remove(jobId, taskId) { + await fs.rm(spoolPath(jobId, taskId), { force: true }); }, - async removeRun(runId) { - await fs.rm(path.join(root, encodeURIComponent(runId)), { recursive: true, force: true }); + async removeJob(jobId) { + await fs.rm(path.join(root, encodeURIComponent(jobId)), { recursive: true, force: true }); }, }; } diff --git a/src/lib/pipeline/dag.js b/src/lib/pipeline/dag.js @@ -1,7 +1,7 @@ // src/lib/pipeline/dag.js - dependency graph checks and traversal // -// The prototype stored a single integer level per job and treated the lowest -// queued level as the next runnable job. That serialized every run and, on +// The prototype stored a single integer level per task and treated the lowest +// queued level as the next runnable task. That serialized every run and, on // failure, only skipped direct dependents, so a transitive dependent could // still be dispatched with its dependency missing. Both problems come from // throwing the graph away, so the graph is kept here and used directly. @@ -14,10 +14,10 @@ export class CycleError extends Error { } } -// Returns the jobs in an order where every dependency precedes its +// Returns the tasks in an order where every dependency precedes its // dependents. Throws CycleError naming the cycle when none exists. -export function topologicalOrder(jobs) { - const byName = new Map(jobs.map((j) => [j.name, j])); +export function topologicalOrder(tasks) { + const byName = new Map(tasks.map((j) => [j.name, j])); const state = new Map(); const order = []; const stack = []; @@ -45,26 +45,26 @@ export function topologicalOrder(jobs) { return order.map((name) => byName.get(name)); } -// Depth of each job, where a job with no dependencies is 0. Used only for +// Depth of each task, where a task with no dependencies is 0. Used only for // display; scheduling reads the edges, never the depth. -export function depths(jobs) { - const byName = new Map(jobs.map((j) => [j.name, j])); +export function depths(tasks) { + const byName = new Map(tasks.map((j) => [j.name, j])); const out = new Map(); - for (const job of topologicalOrder(jobs)) { - const deps = job.needs.filter((d) => byName.has(d)); - out.set(job.name, deps.length === 0 ? 0 : Math.max(...deps.map((d) => out.get(d))) + 1); + for (const task of topologicalOrder(tasks)) { + const deps = task.needs.filter((d) => byName.has(d)); + out.set(task.name, deps.length === 0 ? 0 : Math.max(...deps.map((d) => out.get(d))) + 1); } return out; } -// Every job reachable by following dependents from the given names. This is +// Every task reachable by following dependents from the given names. This is // what a failure must skip: direct dependents alone leave transitive ones // runnable against a dependency that never produced anything. -export function transitiveDependents(jobs, startNames) { - const dependents = new Map(jobs.map((j) => [j.name, []])); - for (const job of jobs) { - for (const dep of job.needs) { - if (dependents.has(dep)) dependents.get(dep).push(job.name); +export function transitiveDependents(tasks, startNames) { + const dependents = new Map(tasks.map((j) => [j.name, []])); + for (const task of tasks) { + for (const dep of task.needs) { + if (dependents.has(dep)) dependents.get(dep).push(task.name); } } @@ -81,11 +81,11 @@ export function transitiveDependents(jobs, startNames) { return seen; } -// Jobs whose dependencies have all reached a satisfying state. Passed the -// current state of every job by name. -export function runnable(jobs, stateByName, { satisfied = ['success'] } = {}) { - return jobs.filter((job) => { - if (stateByName.get(job.name) !== 'queued') return false; - return job.needs.every((dep) => satisfied.includes(stateByName.get(dep))); +// Tasks whose dependencies have all reached a satisfying state. Passed the +// current state of every task by name. +export function runnable(tasks, stateByName, { satisfied = ['success'] } = {}) { + return tasks.filter((task) => { + if (stateByName.get(task.name) !== 'queued') return false; + return task.needs.every((dep) => satisfied.includes(stateByName.get(dep))); }); } diff --git a/src/lib/pipeline/expand.js b/src/lib/pipeline/expand.js @@ -1,23 +1,23 @@ // src/lib/pipeline/expand.js - matrix and architecture expansion // -// Turns each job template into one concrete job per combination of its -// dimensions, then resolves needs between the concrete jobs. +// Turns each task template into one concrete task per combination of its +// dimensions, then resolves needs between the concrete tasks. // -// Dimension matching is the part worth understanding. When a job declares +// Dimension matching is the part worth understanding. When a task declares // needs on a template that shares a dimension with it, the dependency is // matched on that dimension rather than fanned out across all of it. So an -// aarch64 package job needs the aarch64 build, not every build. Dimensions +// aarch64 package task needs the aarch64 build, not every build. Dimensions // the dependency has but the dependent does not are fanned out, which is -// what you want when a single publish job waits for every architecture. -// Write `{ job: name, match: all }` to opt out. +// what you want when a single publish task waits for every architecture. +// Write `{ task: name, match: all }` to opt out. import { Problems, MATRIX_KEY_PATTERN } from './schema.js'; // Matches ${{ arch }} and ${{ matrix.pkg }}, tolerating inner whitespace. const INTERPOLATION = /\$\{\{\s*([^}]*?)\s*\}\}/g; -// jobs.name is 191 characters in the mysql schema. -export const MAX_JOB_NAME = 191; +// tasks.name is 191 characters in the mysql schema. +export const MAX_TASK_NAME = 191; export function interpolate(problems, path, text, context) { if (typeof text !== 'string') return text; @@ -25,7 +25,7 @@ export function interpolate(problems, path, text, context) { return text.replace(INTERPOLATION, (whole, expr) => { if (expr === 'arch') { if (context.arch === null) { - problems.add(path, 'refers to ${{ arch }} but the job declares no arch'); + problems.add(path, 'refers to ${{ arch }} but the task declares no arch'); return whole; } return context.arch; @@ -38,7 +38,7 @@ export function interpolate(problems, path, text, context) { const known = Object.keys(context.matrix); problems.add( path, - `refers to \${{ matrix.${key} }} which the job does not define` + + `refers to \${{ matrix.${key} }} which the task does not define` + (known.length > 0 ? `; available: ${known.join(', ')}` : '') ); return whole; @@ -70,10 +70,10 @@ function instanceName(baseName, dims) { return `${baseName}:${entries.map(([k, v]) => `${k}=${v}`).join(',')}`; } -function dimensionsOf(job) { +function dimensionsOf(task) { const dims = []; - if (job.arch.length > 0) dims.push(['arch', job.arch]); - for (const [key, values] of Object.entries(job.matrix)) dims.push([key, values]); + if (task.arch.length > 0) dims.push(['arch', task.arch]); + for (const [key, values] of Object.entries(task.matrix)) dims.push([key, values]); return dims; } @@ -82,12 +82,12 @@ export function expandPipeline(pipeline, options = {}) { const defaultTimeout = options.defaultTimeout ?? 3600; const defaultAttempts = options.defaultAttempts ?? 1; - // Pass one: build every concrete job. + // Pass one: build every concrete task. const byBase = new Map(); const instances = []; - for (const [baseName, job] of Object.entries(pipeline.jobs)) { - const dims = dimensionsOf(job); + for (const [baseName, task] of Object.entries(pipeline.tasks)) { + const dims = dimensionsOf(task); const combos = cartesian(dims); const made = []; @@ -97,18 +97,18 @@ export function expandPipeline(pipeline, options = {}) { delete matrix.arch; const name = instanceName(baseName, combo); - const path = `jobs.${baseName}`; - if (name.length > MAX_JOB_NAME) { - problems.add(path, `expands to a job name longer than ${MAX_JOB_NAME} characters: ${name}`); + const path = `tasks.${baseName}`; + if (name.length > MAX_TASK_NAME) { + problems.add(path, `expands to a task name longer than ${MAX_TASK_NAME} characters: ${name}`); continue; } const context = { arch, matrix }; const env = {}; - for (const [key, value] of Object.entries(job.env)) { + for (const [key, value] of Object.entries(task.env)) { env[key] = interpolate(problems, `${path}.env.${key}`, value, context); } - // Dimension values are exposed to the script, so a matrix job rarely + // Dimension values are exposed to the script, so a matrix task rarely // needs interpolation at all. if (arch !== null) env.ARCH = arch; for (const [key, value] of Object.entries(matrix)) { @@ -121,10 +121,10 @@ export function expandPipeline(pipeline, options = {}) { arch, matrix, dims: combo, - image: interpolate(problems, `${path}.image`, job.image, context), - script: job.script.map((line, i) => interpolate(problems, `${path}.script[${i}]`, line, context)), - requires: job.requires.map((r, i) => interpolate(problems, `${path}.requires[${i}]`, r, context)), - services: job.services.map((service, i) => ({ + image: interpolate(problems, `${path}.image`, task.image, context), + script: task.script.map((line, i) => interpolate(problems, `${path}.script[${i}]`, line, context)), + requires: task.requires.map((r, i) => interpolate(problems, `${path}.requires[${i}]`, r, context)), + services: task.services.map((service, i) => ({ image: interpolate(problems, `${path}.services[${i}].image`, service.image, context), alias: service.alias, env: Object.fromEntries(Object.entries(service.env).map(([k, v]) => [ @@ -134,59 +134,59 @@ export function expandPipeline(pipeline, options = {}) { command: service.command, })), env, - artifacts: job.artifacts + artifacts: task.artifacts ? { - ...job.artifacts, - paths: job.artifacts.paths.map((p, i) => interpolate(problems, `${path}.artifacts.paths[${i}]`, p, context)), + ...task.artifacts, + paths: task.artifacts.paths.map((p, i) => interpolate(problems, `${path}.artifacts.paths[${i}]`, p, context)), } : null, - allow_failure: job.allow_failure, - timeout: job.timeout ?? defaultTimeout, - max_attempts: job.max_attempts ?? defaultAttempts, - // Every instance of a matrix job inherits the ref rule, so a - // restricted job stays restricted across all of its expansions. - only: job.only ?? null, + allow_failure: task.allow_failure, + timeout: task.timeout ?? defaultTimeout, + max_attempts: task.max_attempts ?? defaultAttempts, + // Every instance of a matrix task inherits the ref rule, so a + // restricted task stays restricted across all of its expansions. + only: task.only ?? null, needs: [], }); } - byBase.set(baseName, { job, dimensionNames: dims.map(([k]) => k), instances: made }); + byBase.set(baseName, { task, dimensionNames: dims.map(([k]) => k), instances: made }); instances.push(...made); } const byName = new Map(instances.map((i) => [i.name, i])); - // Pass two: resolve needs now that every concrete job exists. + // Pass two: resolve needs now that every concrete task exists. for (const [baseName, entry] of byBase) { for (const instance of entry.instances) { const resolved = new Set(); - for (const need of entry.job.needs) { - const path = `jobs.${baseName}.needs`; + for (const need of entry.task.needs) { + const path = `tasks.${baseName}.needs`; - // An exact concrete job name wins, which is the escape hatch for + // An exact concrete task name wins, which is the escape hatch for // depending on one specific combination. - if (byName.has(need.job)) { - if (need.job === instance.name) { - problems.add(path, `job ${JSON.stringify(baseName)} cannot depend on itself`); + if (byName.has(need.task)) { + if (need.task === instance.name) { + problems.add(path, `task ${JSON.stringify(baseName)} cannot depend on itself`); } else { - resolved.add(need.job); + resolved.add(need.task); } continue; } - const target = byBase.get(need.job); + const target = byBase.get(need.task); if (!target) { const known = [...byBase.keys()].filter((n) => n !== baseName); problems.add( path, - `refers to unknown job ${JSON.stringify(need.job)}` + - (known.length > 0 ? `; defined jobs are ${known.join(', ')}` : '') + `refers to unknown task ${JSON.stringify(need.task)}` + + (known.length > 0 ? `; defined tasks are ${known.join(', ')}` : '') ); continue; } - if (need.job === baseName) { - problems.add(path, `job ${JSON.stringify(baseName)} cannot depend on itself`); + if (need.task === baseName) { + problems.add(path, `task ${JSON.stringify(baseName)} cannot depend on itself`); continue; } @@ -198,8 +198,8 @@ export function expandPipeline(pipeline, options = {}) { if (candidates.length === 0) { problems.add( path, - `no instance of ${JSON.stringify(need.job)} matches ${instance.name} on ` + - `${shared.join(', ')}; add the missing value, or use { job: ${need.job}, match: all }` + `no instance of ${JSON.stringify(need.task)} matches ${instance.name} on ` + + `${shared.join(', ')}; add the missing value, or use { task: ${need.task}, match: all }` ); continue; } diff --git a/src/lib/pipeline/index.js b/src/lib/pipeline/index.js @@ -1,6 +1,6 @@ // src/lib/pipeline/index.js - pipeline compilation // -// compilePipeline turns the text of a .conductor.yml into the concrete job +// compilePipeline turns the text of a .conductor.yml into the concrete task // graph the scheduler stores. Every failure mode raises PipelineError with // the full list of problems, so a bad pipeline is reported once rather than // one mistake per push. @@ -14,7 +14,7 @@ import { PipelineError } from './schema.js'; export { parsePipeline } from './parse.js'; export { expandPipeline, interpolate } from './expand.js'; export { topologicalOrder, depths, transitiveDependents, runnable, CycleError } from './dag.js'; -export { refMatches, jobRunsOnRef, selectForRef } from './only.js'; +export { refMatches, taskRunsOnRef, selectForRef } from './only.js'; export { PipelineError } from './schema.js'; export { SUPPORTED_VERSION, VISIBILITIES, DEFAULT_WORKDIR, parseWorkdir } from './parse.js'; @@ -22,29 +22,29 @@ export function compilePipeline(text, options = {}) { const pipeline = parsePipeline(text, options); const expanded = expandPipeline(pipeline, options); - // Jobs restricted to other refs drop out before the graph is checked, so + // Tasks restricted to other refs drop out before the graph is checked, so // ordering and depth describe the run that will actually happen. Without // a ref nothing is filtered, which keeps validation of a pipeline // separate from deciding what one push will run. - const jobs = options.ref === undefined ? expanded : selectForRef(expanded, options.ref); + const tasks = options.ref === undefined ? expanded : selectForRef(expanded, options.ref); try { - topologicalOrder(jobs); + topologicalOrder(tasks); } catch (e) { if (e instanceof CycleError) { - throw new PipelineError([{ path: 'jobs', message: e.message }], pipeline.source); + throw new PipelineError([{ path: 'tasks', message: e.message }], pipeline.source); } throw e; } - const depth = depths(jobs); - for (const job of jobs) job.depth = depth.get(job.name); + const depth = depths(tasks); + for (const task of tasks) task.depth = depth.get(task.name); return { version: pipeline.version, source: pipeline.source, visibility: pipeline.visibility, workdir: pipeline.workdir, - jobs, + tasks, }; } diff --git a/src/lib/pipeline/only.js b/src/lib/pipeline/only.js @@ -1,16 +1,16 @@ -// src/lib/pipeline/only.js - restricting jobs to particular refs +// src/lib/pipeline/only.js - restricting tasks to particular refs // -// A job carrying an `only` rule runs when the ref that triggered the run +// A task carrying an `only` rule runs when the ref that triggered the run // matches one of its patterns, and is left out of the run entirely when it // does not. Left out rather than recorded as skipped, because the -// scheduler treats a skipped job as a reason to fail the run: that is the +// scheduler treats a skipped task as a reason to fail the run: that is the // right reading when a dependency collapsed, and the wrong one for a // publish step that was never meant to run on this branch. // -// Anything depending on an excluded job is excluded with it. The -// alternative, quietly dropping the dependency, would let a job run +// Anything depending on an excluded task is excluded with it. The +// alternative, quietly dropping the dependency, would let a task run // without something it declared it needed, which is a worse surprise than -// the job not running. +// the task not running. import { transitiveDependents } from './dag.js'; @@ -29,23 +29,23 @@ export function refMatches(pattern, ref) { return new RegExp(`^${source}$`).test(ref); } -export function jobRunsOnRef(job, ref) { - if (!job.only) return true; +export function taskRunsOnRef(task, ref) { + if (!task.only) return true; // A run with no ref at all, which a manual or api trigger may produce, - // cannot match a ref pattern. Restricted jobs stay out rather than + // cannot match a ref pattern. Restricted tasks stay out rather than // being handed a ref they were never written for. if (typeof ref !== 'string' || ref === '') return false; - return job.only.refs.some((pattern) => refMatches(pattern, ref)); + return task.only.refs.some((pattern) => refMatches(pattern, ref)); } -// The jobs that belong in a run for this ref, in their original order. -export function selectForRef(jobs, ref) { - const excluded = new Set(jobs.filter((job) => !jobRunsOnRef(job, ref)).map((job) => job.name)); - if (excluded.size === 0) return jobs; +// The tasks that belong in a run for this ref, in their original order. +export function selectForRef(tasks, ref) { + const excluded = new Set(tasks.filter((task) => !taskRunsOnRef(task, ref)).map((task) => task.name)); + if (excluded.size === 0) return tasks; - for (const name of transitiveDependents(jobs, [...excluded])) excluded.add(name); + for (const name of transitiveDependents(tasks, [...excluded])) excluded.add(name); - return jobs.filter((job) => !excluded.has(job.name)); + return tasks.filter((task) => !excluded.has(task.name)); } diff --git a/src/lib/pipeline/parse.js b/src/lib/pipeline/parse.js @@ -1,6 +1,6 @@ // src/lib/pipeline/parse.js - reads and validates a .conductor.yml // -// Produces a normalized document: every job has every field filled in from +// Produces a normalized document: every task has every field filled in from // defaults, so later stages never have to ask whether something was set. // Nothing here expands matrices or resolves dependencies; see expand.js. @@ -22,13 +22,13 @@ import { NAME_PATTERN, } from './schema.js'; -const TOP_KEYS = ['version', 'visibility', 'workdir', 'defaults', 'jobs']; +const TOP_KEYS = ['version', 'visibility', 'workdir', 'defaults', 'tasks']; // A repository decides whether its own build results are readable without // signing in. Unset means the project's setting stands. export const VISIBILITIES = ['public', 'private']; -// Where the tree is put inside a job container, and the directory every +// Where the tree is put inside a task container, and the directory every // script starts in. Unset means the project's setting stands, and failing // that the server default. export const DEFAULT_WORKDIR = '/work'; @@ -72,7 +72,7 @@ const JOB_KEYS = [ 'only', ]; -// Fields a job may inherit from defaults. +// Fields a task may inherit from defaults. const DEFAULT_KEYS = [ 'image', 'arch', 'requires', 'env', 'services', 'allow_failure', 'timeout', 'max_attempts', @@ -104,6 +104,16 @@ export function parsePipeline(text, options = {}) { throw new PipelineError([{ path: '', message: `expected a mapping at the top level, got ${typeName(doc)}` }], source); } + // The key was called jobs before a job became the thing that contains + // these. Named outright rather than left to checkUnknown, whose nearest + // match would be a guess at a word that used to be correct. + if (doc.jobs !== undefined && doc.tasks === undefined) { + throw new PipelineError( + [{ path: 'jobs', message: 'was renamed to tasks; a job is now the pipeline run that these tasks belong to' }], + source + ); + } + checkUnknown(problems, '', doc, TOP_KEYS); if (doc.version === undefined) { @@ -124,34 +134,34 @@ export function parsePipeline(text, options = {}) { const defaults = parseDefaults(problems, doc.defaults); - if (doc.jobs === undefined) { - problems.add('jobs', 'is required'); - } else if (!isPlainObject(doc.jobs)) { - problems.add('jobs', `expected a mapping of job names, got ${typeName(doc.jobs)}`); - } else if (Object.keys(doc.jobs).length === 0) { - problems.add('jobs', 'must define at least one job'); + if (doc.tasks === undefined) { + problems.add('tasks', 'is required'); + } else if (!isPlainObject(doc.tasks)) { + problems.add('tasks', `expected a mapping of task names, got ${typeName(doc.tasks)}`); + } else if (Object.keys(doc.tasks).length === 0) { + problems.add('tasks', 'must define at least one task'); } - const jobs = {}; - if (isPlainObject(doc.jobs)) { - for (const [name, raw] of Object.entries(doc.jobs)) { - const path = `jobs.${name}`; + const tasks = {}; + if (isPlainObject(doc.tasks)) { + for (const [name, raw] of Object.entries(doc.tasks)) { + const path = `tasks.${name}`; if (!NAME_PATTERN.test(name) || name.length > 100) { - problems.add(path, 'job name must start with a letter or digit and contain only letters, digits, underscore, dot and hyphen'); + problems.add(path, 'task name must start with a letter or digit and contain only letters, digits, underscore, dot and hyphen'); continue; } if (!isPlainObject(raw)) { problems.add(path, `expected a mapping, got ${typeName(raw)}`); continue; } - jobs[name] = parseJob(problems, path, raw, defaults); + tasks[name] = parseTask(problems, path, raw, defaults); } } const workdir = doc.workdir === undefined ? null : parseWorkdir(problems, 'workdir', doc.workdir); problems.throwIfAny(source); - return { version: SUPPORTED_VERSION, visibility, workdir, defaults, jobs, source }; + return { version: SUPPORTED_VERSION, visibility, workdir, defaults, tasks, source }; } function parseDefaults(problems, raw) { @@ -202,12 +212,12 @@ function parseArch(problems, path, raw) { return out; } -function parseJob(problems, path, raw, defaults) { +function parseTask(problems, path, raw, defaults) { checkUnknown(problems, path, raw, JOB_KEYS); const image = raw.image === undefined ? defaults.image : asString(problems, `${path}.image`, raw.image); if (image === undefined) { - problems.add(`${path}.image`, 'is required; set it on the job or under defaults'); + problems.add(`${path}.image`, 'is required; set it on the task or under defaults'); } if (raw.script === undefined) { @@ -246,15 +256,15 @@ function parseJob(problems, path, raw, defaults) { }; } -// Restricts a job to certain refs. Deliberately not inheritable from -// defaults: a rule that silently applied to every job is the kind of thing +// Restricts a task to certain refs. Deliberately not inheritable from +// defaults: a rule that silently applied to every task is the kind of thing // that stops a pipeline running at all and takes an afternoon to find. // // only: // refs: [refs/heads/main, 'refs/tags/v*'] // // Patterns match the whole ref, so refs/heads/main rather than main, with -// * standing for any run of characters. Absent means the job always runs. +// * standing for any run of characters. Absent means the task always runs. function parseOnly(problems, path, raw) { if (raw === undefined || raw === null) return null; if (!isPlainObject(raw)) { @@ -282,7 +292,7 @@ function parseOnly(problems, path, raw) { return { refs }; } -// A need is either a job name, or a mapping for the cases where the default +// A need is either a task name, or a mapping for the cases where the default // dimension matching is not what is wanted. function parseNeeds(problems, path, raw) { if (raw === undefined) return []; @@ -292,21 +302,21 @@ function parseNeeds(problems, path, raw) { list.forEach((item, i) => { const itemPath = `${path}[${i}]`; if (typeof item === 'string') { - out.push({ job: item, match: 'shared' }); + out.push({ task: item, match: 'shared' }); return; } if (!isPlainObject(item)) { - problems.add(itemPath, `expected a job name or a mapping, got ${typeName(item)}`); + problems.add(itemPath, `expected a task name or a mapping, got ${typeName(item)}`); return; } - checkUnknown(problems, itemPath, item, ['job', 'match']); - const job = asString(problems, `${itemPath}.job`, item.job); + checkUnknown(problems, itemPath, item, ['task', 'match']); + const task = asString(problems, `${itemPath}.task`, item.task); const match = item.match === undefined ? 'shared' : asString(problems, `${itemPath}.match`, item.match); if (match !== undefined && !['shared', 'all'].includes(match)) { problems.add(`${itemPath}.match`, `expected shared or all, got ${JSON.stringify(match)}`); return; } - if (job !== undefined) out.push({ job, match: match ?? 'shared' }); + if (task !== undefined) out.push({ task, match: match ?? 'shared' }); }); return out; @@ -346,7 +356,7 @@ function parseMatrix(problems, path, raw) { problems.add( `${keyPath}[${i}]`, `${JSON.stringify(s)} may only contain letters, digits, underscore, dot and hyphen, ` + - 'because it becomes part of the job name' + 'because it becomes part of the task name' ); return; } @@ -383,7 +393,7 @@ function parseServices(problems, path, raw) { if (image === undefined) return; // Default alias is the image name without registry, path or tag, which - // is what a job would naturally use as a hostname. + // is what a task would naturally use as a hostname. const derived = image.split('/').pop().split(':')[0]; const alias = service.alias === undefined ? derived diff --git a/src/lib/pipeline/schema.js b/src/lib/pipeline/schema.js @@ -180,7 +180,7 @@ export function typeName(v) { return `a ${typeof v}`; } -// Job and matrix value names appear in generated job names, storage keys and +// Task and matrix value names appear in generated task names, storage keys and // environment variables, so the character set is deliberately narrow. export const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; export const MATRIX_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; diff --git a/src/lib/projects.js b/src/lib/projects.js @@ -31,8 +31,8 @@ export function assertWorkdir(value) { const COLUMNS = ` id, name, repo_url, default_branch, config_path, workdir, - trigger_secret, enabled, run_counter, owner_id, visibility, - artifact_keep_runs, artifact_keep_days, log_keep_days, + trigger_secret, enabled, job_counter, owner_id, visibility, + artifact_keep_jobs, artifact_keep_days, log_keep_days, created_at, updated_at `; @@ -83,7 +83,7 @@ export function createProjects({ db, secrets }) { await db.run( `INSERT INTO projects (id, name, repo_url, default_branch, config_path, workdir, - trigger_secret, enabled, run_counter, owner_id, visibility, + trigger_secret, enabled, job_counter, owner_id, visibility, created_at, updated_at) VALUES ({id}, {name}, {repo_url}, {branch}, {config_path}, {workdir}, @@ -140,7 +140,7 @@ export function createProjects({ db, secrets }) { // default; zero means keep forever, and has to stay distinguishable // from null or a project could not opt out of a default that deletes. async setRetention(id, values) { - const columns = ['artifact_keep_runs', 'artifact_keep_days', 'log_keep_days']; + const columns = ['artifact_keep_jobs', 'artifact_keep_days', 'log_keep_days']; const updates = []; const params = { id, now: Date.now() }; @@ -184,13 +184,13 @@ export function createProjects({ db, secrets }) { await db.run('DELETE FROM projects WHERE id = {id}', { id }); }, - // Allocates the next run number. Must run inside the same transaction - // as the run insert, or two concurrent pushes can collide on the + // Allocates the next job number. Must run inside the same transaction + // as the job insert, or two concurrent pushes can collide on the // unique (project_id, number) index. - async nextRunNumber(tx, projectId) { - await tx.run('UPDATE projects SET run_counter = run_counter + 1 WHERE id = {id}', { id: projectId }); - const row = await tx.get('SELECT run_counter FROM projects WHERE id = {id}', { id: projectId }); - return row.run_counter; + async nextJobNumber(tx, projectId) { + await tx.run('UPDATE projects SET job_counter = job_counter + 1 WHERE id = {id}', { id: projectId }); + const row = await tx.get('SELECT job_counter FROM projects WHERE id = {id}', { id: projectId }); + return row.job_counter; }, }; } diff --git a/src/lib/storage/index.js b/src/lib/storage/index.js @@ -23,6 +23,6 @@ export function createStorage(cfg) { // Key layout, kept in one place so every caller agrees. export const keys = { - artifact: (runId, jobId, relPath) => `artifacts/${runId}/${jobId}/${relPath}`, - log: (runId, jobId) => `logs/${runId}/${jobId}.log`, + artifact: (jobId, taskId, relPath) => `artifacts/${jobId}/${taskId}/${relPath}`, + log: (jobId, taskId) => `logs/${jobId}/${taskId}.log`, }; diff --git a/src/lib/users.js b/src/lib/users.js @@ -6,7 +6,7 @@ import { newUserId } from './ids.js'; import { hashPassword, verifyPassword, generatePassword, validatePassword } from './auth/password.js'; -export const ROLES = ['admin', 'viewer']; +export const ROLES = ['admin', 'user']; // A provider may hand over anything as a display name, so it is reduced to // the character set local accounts already use. @@ -52,7 +52,7 @@ export function createUsers({ db, logger = console }) { // administrator a way to lock someone out immediately. async upsertExternal({ externalId, username, role }) { if (!externalId) throw new Error('an external account needs a stable identifier'); - const desired = ROLES.includes(role) ? role : 'viewer'; + const desired = ROLES.includes(role) ? role : 'user'; return db.transaction(async (tx) => { const existing = await tx.get( @@ -106,7 +106,7 @@ export function createUsers({ db, logger = console }) { return (await db.get('SELECT COUNT(*) AS c FROM users')).c; }, - async create({ username, password, role = 'viewer' }) { + async create({ username, password, role = 'user' }) { if (typeof username !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.@-]{1,63}$/.test(username)) { throw new Error('username must be 2 to 64 characters of letters, digits, underscore, dot, at or hyphen'); } diff --git a/src/worker/agent.js b/src/worker/agent.js @@ -16,7 +16,7 @@ import { loadWorkerConfig, featureNames } from './config.js'; import { createClient } from './client.js'; import { createRuntime } from './docker.js'; -import { runJob } from './job.js'; +import { runTask } from './task.js'; const stamp = () => new Date().toISOString().replace('T', ' ').slice(0, 19); @@ -49,11 +49,11 @@ export async function main(argv = process.argv.slice(2)) { const client = createClient(cfg); const runtime = createRuntime(cfg, { logger }); - // Fail at startup rather than on the first job, when a missing tool would + // Fail at startup rather than on the first task, when a missing tool would // otherwise look like a broken pipeline. // // The runtime is the only thing a worker needs. The tree is unpacked - // into the job container over the docker API rather than on this side, + // into the task container over the docker API rather than on this side, // so there is nothing else to look for on PATH. if (!(await runtime.available())) { throw new Error(`container runtime ${JSON.stringify(cfg.docker)} is not usable; is the daemon running?`); @@ -92,20 +92,20 @@ export async function main(argv = process.argv.slice(2)) { process.exit(1); } stopping = true; - logger.info(`${signal} received, finishing ${running.size} running job(s)`); + logger.info(`${signal} received, finishing ${running.size} running task(s)`); for (const wake of [...sleepers]) wake(); }; for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => shutdown(signal)); - // One loop per concurrency slot. Each polls independently, so a slow job + // One loop per concurrency slot. Each polls independently, so a slow task // in one slot does not stall the others. async function slot(index) { let backoff = cfg.poll_interval; while (!stopping) { - let job = null; + let task = null; try { - job = await client.poll({ arches: cfg.arches, features, name: cfg.name }); + task = await client.claim({ 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 @@ -116,23 +116,23 @@ export async function main(argv = process.argv.slice(2)) { continue; } - if (!job) { + if (!task) { if (args.once) return; await sleep(cfg.poll_interval * 1000); continue; } - running.add(job.id); - logger.info(`[${index}] ${job.name} (${job.image})`); + running.add(task.id); + logger.info(`[${index}] ${task.name} (${task.image})`); const started = Date.now(); try { - const result = await runJob({ cfg, client, runtime, job, logger }); + const result = await runTask({ cfg, client, runtime, task, logger }); const seconds = ((Date.now() - started) / 1000).toFixed(1); - logger.info(`[${index}] ${job.name} ${result.success ? 'succeeded' : 'failed'} in ${seconds}s`); + logger.info(`[${index}] ${task.name} ${result.success ? 'succeeded' : 'failed'} in ${seconds}s`); } catch (e) { - logger.error(`[${index}] ${job.name} crashed the worker loop: ${e.stack ?? e.message}`); + logger.error(`[${index}] ${task.name} crashed the worker loop: ${e.stack ?? e.message}`); } finally { - running.delete(job.id); + running.delete(task.id); } if (args.once) return; diff --git a/src/worker/artifacts.js b/src/worker/artifacts.js @@ -1,9 +1,9 @@ -// src/worker/artifacts.js - collecting and uploading job output +// src/worker/artifacts.js - collecting and uploading task 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 +// nothing to install. Matches are confined to the workspace: a task can write // a symlink pointing anywhere it likes, and following one would upload a -// file the job was never given. +// file the task was never given. import fs from 'node:fs/promises'; import path from 'node:path'; @@ -72,7 +72,7 @@ export async function uploadArtifacts(client, url, files, { logger = console } = 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 + // An artifact that cannot be stored is worth reporting, but the task // itself already succeeded or failed on its own merits. logger.warn?.(`could not upload ${file.path}: ${e.message}`); } diff --git a/src/worker/client.js b/src/worker/client.js @@ -2,7 +2,7 @@ // // 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 +// agent decides what a failure means, since a failed claim and a failed log // append want very different handling. export class ConductorError extends Error { @@ -35,19 +35,20 @@ export function createClient(cfg) { } return { - // Returns a job, or null when there is nothing to do. - async poll({ arches, features, name }) { - const res = await fetch(`${base}/api/v1/workers/jobs`, { + // Returns a task, or null when there is nothing to do. This is the only + // URL the worker knows; everything else it needs arrives with the task. + async claim({ arches, features, name }) { + const res = await fetch(`${base}/api/v1/tasks/claim`, { method: 'POST', headers: { ...auth, 'content-type': 'application/json' }, body: JSON.stringify({ arches, features, name }), }); if (res.status === 204) return null; - if (!res.ok) throw await readError(res, 'poll'); - return (await res.json()).job; + if (!res.ok) throw await readError(res, 'claim'); + return (await res.json()).task; }, - // The tree at the job's commit. Returned as a web stream so it can be + // The tree at the task'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 }); @@ -88,7 +89,7 @@ export function createClient(cfg) { return await res.json(); }, - // Reports that the job is alive, and learns whether it should stop. + // Reports that the task is alive, and learns whether it should stop. async heartbeat(url) { const res = await fetch(url, { method: 'POST', diff --git a/src/worker/config.js b/src/worker/config.js @@ -7,7 +7,7 @@ // 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: +// advertises, plus whatever local resources a task 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. @@ -27,9 +27,9 @@ const DEFAULTS = { 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 used to run a task script inside its image. shell: 'sh', - // Applied when a job does not ask for something longer. + // Applied when a task does not ask for something longer. default_timeout: 3600, }; diff --git a/src/worker/docker.js b/src/worker/docker.js @@ -14,9 +14,9 @@ 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. +// Task ids are already within what docker accepts, but the sanitising stays +// because the suffix is a caller supplied service alias and the id is not +// this function's to trust. The hash keeps names unique if either is folded. 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); @@ -38,7 +38,7 @@ export function createRuntime(cfg, { logger = console } = {}) { } } - // Turns a job and the worker's feature definitions into run arguments. + // Turns a task and the worker's feature definitions into run arguments. function buildRunArgs({ name, image, network, workdir = null, env = {}, mounts = [], privileged = false, devices = [], command = [], entrypoint = null, detach = false, networkAlias = null }) { const args = ['run', '--rm', '--name', name]; if (detach) args.push('--detach'); @@ -63,7 +63,7 @@ export function createRuntime(cfg, { logger = console } = {}) { return args; } - // A job container is created rather than run, so its filesystem can be + // A task container is created rather than run, so its filesystem can be // populated before it starts and read after it exits. Nothing from the // worker's own filesystem is mounted into it: the source arrives over // the docker API, which means the worker needs no shared directory with @@ -167,12 +167,12 @@ export function createRuntime(cfg, { logger = console } = {}) { } }, - // Services run detached; their output is not part of the job log. + // Services run detached; their output is not part of the task log. async startService(options) { return run(buildRunArgs({ ...options, detach: true })); }, - // Starts the job container and hands back the process. The caller reads + // Starts the task 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 }); @@ -181,7 +181,7 @@ export function createRuntime(cfg, { logger = console } = {}) { }, // Runs a container to completion and returns its output. Used for - // short housekeeping tasks rather than for jobs. + // short housekeeping work rather than for running a task. async runOnce(options, { timeout = 120000 } = {}) { return run(buildRunArgs({ ...options, detach: false }), { timeout }); }, diff --git a/src/worker/logstream.js b/src/worker/logstream.js @@ -1,6 +1,6 @@ // src/worker/logstream.js - batching log shipper with masking // -// Job output arrives as many small writes; shipping each one would be a +// Task 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. // @@ -114,7 +114,7 @@ export function createLogStream(client, url, { masked = [], flushMs = FLUSH_MS, } offset = result.size; } catch (e) { - // Losing log output must not fail an otherwise good job. + // Losing log output must not fail an otherwise good task. failed = e; logger.warn?.(`log shipping failed, dropping ${chunk.length} bytes: ${e.message}`); } @@ -139,7 +139,7 @@ export function createLogStream(client, url, { masked = [], flushMs = FLUSH_MS, else schedule(); }, - // A line from the worker rather than the job, so the log can explain a + // A line from the worker rather than the task, so the log can explain a // timeout, a cancellation or a setup failure. note(line) { this.write(`${line}\n`); diff --git a/src/worker/script.js b/src/worker/script.js @@ -1,11 +1,11 @@ -// src/worker/script.js - turning a job's commands into a shell script +// src/worker/script.js - turning a task's commands into a shell script // // The script is copied into the container 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. // // It lives outside the tree, so that a repository cannot shadow it and -// nothing the job does to its own working directory can lose it. +// nothing the task does to its own working directory can lose it. // 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, diff --git a/src/worker/source.js b/src/worker/source.js @@ -1,6 +1,6 @@ -// src/worker/source.js - getting the tree into the job container +// src/worker/source.js - getting the tree into the task container // -// The conductor serves a tarball of the exact commit a job was created +// The conductor serves a tarball of the exact commit a task was created // for. The worker never sees a repository, holds no credentials for one, // and cannot reach any ref other than the one it was given work for. // @@ -16,9 +16,9 @@ import { Readable } from 'node:stream'; import { createGunzip } from 'node:zlib'; -export async function sourceIntoContainer({ client, runtime, job, container, workdir }) { - const url = job.endpoints?.source; - if (!url) throw new Error('the job carries no source endpoint'); +export async function sourceIntoContainer({ client, runtime, task, container, workdir }) { + const url = task.endpoints?.source; + if (!url) throw new Error('the task carries no source endpoint'); const body = await client.source(url); const stream = body instanceof Readable ? body : Readable.fromWeb(body); diff --git a/src/worker/task.js b/src/worker/task.js @@ -1,4 +1,4 @@ -// src/worker/job.js - running one job +// src/worker/task.js - running one task // // Sequence: create the container, put the tree and the script into it, // start any services on a private network, run it, ship its output, @@ -9,10 +9,10 @@ // the worker needs no directory the host can also see, and therefore no // mounts, no matching paths, and no storage of its own. What little it // does write goes to a scratch directory that lives and dies with the -// job. +// task. // // Cleanup runs whatever happened. A worker that leaks containers or -// networks stops being usable after a few dozen jobs. +// networks stops being usable after a few dozen tasks. import fs from 'node:fs/promises'; import os from 'node:os'; @@ -21,7 +21,7 @@ import { containerName } from './docker.js'; import { createLogStream } from './logstream.js'; import { sourceIntoContainer } from './source.js'; -// Where a job runs when the conductor did not say. The conductor resolves +// Where a task runs when the conductor did not say. The conductor resolves // this from the pipeline and the project, so this only covers a worker // talking to one too old to have an opinion. const FALLBACK_WORKDIR = '/work'; @@ -44,7 +44,7 @@ export function patternPrefix(pattern) { return literal.join('/'); } -// The set of paths to copy out for a job's artifact patterns, with any +// The set of paths to copy out for a task's artifact patterns, with any // path that is covered by another removed. export function copyOutPaths(patterns) { const prefixes = (patterns ?? []).map(patternPrefix); @@ -54,15 +54,15 @@ export function copyOutPaths(patterns) { return unique.filter((p, i) => !unique.slice(0, i).some((other) => p === other || p.startsWith(`${other}/`))); } -// Resolves the features a job asked for into local resources. -export function resolveFeatures(job, available) { +// Resolves the features a task asked for into local resources. +export function resolveFeatures(task, available) { const mounts = []; const env = {}; const devices = []; let privileged = false; const missing = []; - for (const name of job.requires ?? []) { + for (const name of task.requires ?? []) { const feature = available[name]; if (!feature) { missing.push(name); @@ -77,17 +77,17 @@ export function resolveFeatures(job, available) { return { mounts, env, devices, privileged, missing }; } -export async function runJob({ cfg, client, runtime, job, logger = console }) { - const network = containerName('conductor-net', job.id); - const jobContainer = containerName('conductor', job.id); - const workdir = job.workdir || FALLBACK_WORKDIR; +export async function runTask({ cfg, client, runtime, task, logger = console }) { + const network = containerName('conductor-net', task.id); + const taskContainer = containerName('conductor', task.id); + const workdir = task.workdir || FALLBACK_WORKDIR; // Worker local, and only ever written by the worker: the generated // script on its way in, and artifacts on their way out. The container // never sees it. let scratch = null; - const log = createLogStream(client, job.endpoints.log, { masked: job.masked ?? [], logger }); + const log = createLogStream(client, task.endpoints.log, { masked: task.masked ?? [], logger }); const services = []; let networkCreated = false; @@ -100,15 +100,15 @@ export async function runJob({ cfg, client, runtime, job, logger = console }) { let failure = null; try { - scratch = await fs.mkdtemp(path.join(os.tmpdir(), 'conductor-job-')); + scratch = await fs.mkdtemp(path.join(os.tmpdir(), 'conductor-task-')); - 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}`); + log.note(`[conductor] task ${task.name} on ${cfg.name}`); + log.note(`[conductor] commit ${String(task.sha).slice(0, 12)} attempt ${task.attempt}`); + log.note(`[conductor] image ${task.image}`); - const features = resolveFeatures(job, cfg.features); + const features = resolveFeatures(task, cfg.features); if (features.missing.length > 0) { - // The conductor should never send such a job, so this means the two + // The conductor should never send such a task, 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(', ')}`); } @@ -116,8 +116,8 @@ export async function runJob({ cfg, client, runtime, job, logger = console }) { await runtime.createNetwork(network); networkCreated = true; - for (const service of job.services ?? []) { - const name = containerName('conductor-svc', job.id, service.alias); + for (const service of task.services ?? []) { + const name = containerName('conductor-svc', task.id, service.alias); log.note(`[conductor] starting service ${service.image} as ${service.alias}`); await runtime.startService({ name, @@ -135,11 +135,11 @@ export async function runJob({ cfg, client, runtime, job, logger = console }) { // starts and read after it exits. Feature mounts are still bind // mounts, since those name host paths the operator chose deliberately. await runtime.create({ - name: jobContainer, - image: job.image, + name: taskContainer, + image: task.image, network, workdir, - env: { ...features.env, ...job.env }, + env: { ...features.env, ...task.env }, mounts: features.mounts, devices: features.devices, privileged: features.privileged, @@ -148,38 +148,38 @@ export async function runJob({ cfg, client, runtime, job, logger = console }) { }); containerCreated = true; - await sourceIntoContainer({ client, runtime, job, container: jobContainer, workdir }); + await sourceIntoContainer({ client, runtime, task, container: taskContainer, workdir }); const scriptPath = path.join(scratch, SCRIPT_NAME); - await fs.writeFile(scriptPath, buildScript(job.script), { mode: 0o755 }); - await runtime.copyIn(jobContainer, scriptPath, SCRIPT_PATH); + await fs.writeFile(scriptPath, buildScript(task.script), { mode: 0o755 }); + await runtime.copyIn(taskContainer, scriptPath, SCRIPT_PATH); - const { child } = runtime.startCreated(jobContainer); + const { child } = runtime.startCreated(taskContainer); 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; + const beatEvery = Math.max(5, Number(task.heartbeat_interval) || 30) * 1000; heartbeat = setInterval(async () => { try { - const result = await client.heartbeat(job.endpoints.heartbeat); + const result = await client.heartbeat(task.endpoints.heartbeat); if (result.cancelled && !cancelled) { cancelled = true; log.note('[conductor] cancelled, stopping the container'); - await runtime.kill(jobContainer); + await runtime.kill(taskContainer); } } catch (e) { - logger.warn?.(`heartbeat failed for ${job.name}: ${e.message}`); + logger.warn?.(`heartbeat failed for ${task.name}: ${e.message}`); } }, beatEvery); heartbeat.unref?.(); - const limit = (Number(job.timeout) || cfg.default_timeout) * 1000; + const limit = (Number(task.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); + await runtime.kill(taskContainer); }, limit); timeoutTimer.unref?.(); @@ -200,31 +200,31 @@ export async function runJob({ cfg, client, runtime, job, logger = console }) { // Artifacts are collected before teardown, and even after a failure // when the pipeline asked for that. They come out of the stopped // container, which still has its filesystem until it is removed. - if (containerCreated && job.artifacts && shouldCollect(job.artifacts.when, success)) { + if (containerCreated && task.artifacts && shouldCollect(task.artifacts.when, success)) { try { const staged = await stageArtifacts(); - const files = await collectArtifacts(staged, job.artifacts.paths, { logger }); + const files = await collectArtifacts(staged, task.artifacts.paths, { logger }); if (files.length > 0) { log.note(`[conductor] uploading ${files.length} artifact(s)`); - await uploadArtifacts(client, job.endpoints.artifact, files, { logger }); + await uploadArtifacts(client, task.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}`); + logger.warn?.(`artifact collection failed for ${task.name}: ${e.message}`); } } const error = failure ? failure.message : timedOut - ? 'job timed out' + ? 'task timed out' : cancelled - ? 'job cancelled' + ? 'task cancelled' : exitCode === 0 ? null - : `job exited ${exitCode}`; + : `task exited ${exitCode}`; if (error) log.note(`[conductor] ${error}`); else log.note('[conductor] done'); @@ -235,9 +235,9 @@ export async function runJob({ cfg, client, runtime, job, logger = console }) { await cleanup(); try { - await client.done(job.endpoints.done, { success, exitCode, error }); + await client.done(task.endpoints.done, { success, exitCode, error }); } catch (e) { - logger.error?.(`could not report completion of ${job.name}: ${e.message}`); + logger.error?.(`could not report completion of ${task.name}: ${e.message}`); } return { success, exitCode, error, cancelled, timedOut }; @@ -253,10 +253,10 @@ export async function runJob({ cfg, client, runtime, job, logger = console }) { const staged = path.join(scratch, 'artifacts'); await fs.mkdir(staged, { recursive: true }); - for (const relative of copyOutPaths(job.artifacts.paths)) { + for (const relative of copyOutPaths(task.artifacts.paths)) { if (relative === '') { // The trailing /. copies the contents rather than the directory. - await runtime.copyOut(jobContainer, `${workdir}/.`, staged); + await runtime.copyOut(taskContainer, `${workdir}/.`, staged); break; } @@ -264,7 +264,7 @@ export async function runJob({ cfg, client, runtime, job, logger = console }) { // unlike copying in. const parent = path.join(staged, path.dirname(relative)); await fs.mkdir(parent, { recursive: true }); - await runtime.copyOut(jobContainer, `${workdir}/${relative}`, parent); + await runtime.copyOut(taskContainer, `${workdir}/${relative}`, parent); } return staged; @@ -272,10 +272,10 @@ export async function runJob({ cfg, client, runtime, job, logger = console }) { async function cleanup() { for (const name of services) await runtime.remove(name); - await runtime.remove(jobContainer); + await runtime.remove(taskContainer); if (networkCreated) await runtime.removeNetwork(network); - // Nothing here was written by the job, so there is no root owned + // Nothing here was written by the task, so there is no root owned // output to work around: removing the container took that with it. if (scratch) { await fs.rm(scratch, { recursive: true, force: true })