conductor

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

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

Cover the task api, the job endpoints and both pipeline file names

Diffstat:
Mtest/conductor.test.js | 507++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mtest/config.test.js | 2+-
Mtest/dag.test.js | 6+++---
Mtest/db.test.js | 18+++++++++---------
Mtest/dialects.test.js | 40++++++++++++++++++++--------------------
Mtest/examples.test.js | 24++++++++++++------------
Mtest/git-env.test.js | 8++++----
Mtest/helpers/containers.js | 10+++++-----
Mtest/helpers/database.js | 8++++----
Mtest/helpers/harness.js | 27++++++++++++++++++++++-----
Mtest/helpers/minio.js | 2+-
Mtest/helpers/oidc.js | 2+-
Mtest/oidc.test.js | 2+-
Mtest/only.test.js | 46+++++++++++++++++++++++-----------------------
Mtest/pipeline.test.js | 188++++++++++++++++++++++++++++++++++++++++----------------------------------------
Mtest/query.test.js | 10+++++-----
Mtest/retention.test.js | 156++++++++++++++++++++++++++++++++++++++++----------------------------------------
Mtest/secretbox.test.js | 23+++++++++++++----------
Mtest/self-pipeline.test.js | 42+++++++++++++++++++++---------------------
Mtest/storage.test.js | 6+++---
Mtest/syntax.test.js | 4++--
Mtest/ui.test.js | 60++++++++++++++++++++++++++++++------------------------------
Mtest/worker.test.js | 10+++++-----
23 files changed, 693 insertions(+), 508 deletions(-)

diff --git a/test/conductor.test.js b/test/conductor.test.js @@ -5,7 +5,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { startHarness } from './helpers/harness.js'; +import { startHarness, DEFAULT_PIPELINE } from './helpers/harness.js'; // Tests that exercised the JSON read API, which has been removed. Kept // until the suite is rebuilt against the interface. @@ -21,37 +21,44 @@ async function withHarness(options, fn) { } } -function jobsByName(payload) { - return Object.fromEntries(payload.jobs.map((j) => [j.name, j])); +function tasksByName(payload) { + return Object.fromEntries(payload.tasks.map((j) => [j.name, j])); } -// Reads a run and its jobs straight from the database. There is no read API; +// Reads a job and its tasks straight from the database. There is no read API; // the interface is for people, and the tests inspect the data itself. -async function runState(h, runId) { - const run = await h.services.db.get('SELECT * FROM runs WHERE id = {id}', { id: runId }); - const jobs = await h.services.db.all( - 'SELECT * FROM jobs WHERE run_id = {run} ORDER BY name', { run: runId } +async function jobState(h, jobId) { + const job = await h.services.db.get('SELECT * FROM jobs WHERE id = {id}', { id: jobId }); + const tasks = await h.services.db.all( + 'SELECT * FROM tasks WHERE job_id = {job} ORDER BY name', { job: jobId } ); const deps = await h.services.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 { run, jobs: 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 { job, tasks: tasks.map((t) => ({ ...t, needs: needs.get(t.id) ?? [] })) }; +} + +// The job a task belongs to. A worker is never told this, so a test that +// needs it asks the database, exactly as the conductor does. +async function jobIdOf(h, taskId) { + const row = await h.services.db.get('SELECT job_id FROM tasks WHERE id = {id}', { id: taskId }); + return row.job_id; } // Claims everything currently eligible for a worker with the given -// capabilities, keyed by job name. Tests assert on which jobs appear rather +// capabilities, keyed by task name. Tests assert on which tasks appear rather // than on the order they arrive in, which is not part of the contract. async function drain(h, query = {}) { const claimed = {}; for (let i = 0; i < 50; i += 1) { - const res = await h.poll(query); + const res = await h.claim(query); if (res.statusCode === 204) break; - const job = res.json().job; - claimed[job.name] = job; + const task = res.json().task; + claimed[task.name] = task; } return claimed; } @@ -60,10 +67,10 @@ async function claimNamed(h, name, query) { return (await drain(h, query))[name] ?? null; } -async function finish(h, jobId, { success = true, exitCode = 0, error = null } = {}) { +async function finish(h, taskId, { success = true, exitCode = 0, error = null } = {}) { const res = await h.app.inject({ method: 'POST', - url: `/api/v1/workers/jobs/${encodeURIComponent(jobId)}/complete`, + url: `/api/v1/tasks/${taskId}/complete`, headers: { ...h.auth, 'content-type': 'application/json' }, payload: JSON.stringify({ success, exit_code: exitCode, error }), }); @@ -80,7 +87,7 @@ test('health reports the selected drivers', async () => { }); }); -test('a signed trigger creates a run and expands the pipeline', async () => { +test('a signed trigger creates a job and expands the pipeline', async () => { await withHarness({}, async (h) => { const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main', actor: 'alice' }); assert.equal(res.statusCode, 200); @@ -88,17 +95,22 @@ test('a signed trigger creates a run and expands the pipeline', async () => { const body = res.json(); assert.equal(body.status, 'created'); // lint + 2 build + 2 package + publish - assert.equal(body.jobs, 6); - - const detail = await runState(h, body.run_id); - assert.equal(detail.run.state, 'running'); - assert.equal(detail.run.actor, 'alice'); - assert.equal(detail.run.title, 'initial commit'); - assert.equal(detail.run.number, 1); - - const jobs = jobsByName(detail); - assert.deepEqual(jobs['package:arch=x86_64,pkg=musl'].needs, [`${body.run_id}:build:arch=x86_64`]); - assert.equal(jobs.publish.needs.length, 3); + assert.equal(body.tasks, 6); + + const detail = await jobState(h, body.job_id); + assert.equal(detail.job.state, 'running'); + assert.equal(detail.job.actor, 'alice'); + assert.equal(detail.job.title, 'initial commit'); + assert.equal(detail.job.number, 1); + + // Task ids are opaque, so the edge is checked by resolving the name + // rather than by reconstructing an id the conductor no longer derives. + const tasks = tasksByName(detail); + assert.deepEqual( + tasks['package:arch=x86_64,pkg=musl'].needs, + [tasks['build:arch=x86_64'].id] + ); + assert.equal(tasks.publish.needs.length, 3); }); }); @@ -146,12 +158,12 @@ test('a branch deletion is ignored rather than failing', async () => { }); test('a broken pipeline is reported as a client error with detail', async () => { - await withHarness({ pipeline: 'version: 1\njobs:\n a:\n script: [x]\n' }, async (h) => { + await withHarness({ pipeline: 'version: 1\ntasks:\n a:\n script: [x]\n' }, async (h) => { const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); assert.equal(res.statusCode, 422); const body = res.json(); assert.equal(body.error, 'invalid pipeline'); - assert.ok(body.problems.some((p) => p.path === 'jobs.a.image')); + assert.ok(body.problems.some((p) => p.path === 'tasks.a.image')); }); }); @@ -163,51 +175,194 @@ test('a missing pipeline file is reported clearly', async () => { }); }); +test('a pipeline may be spelled .conductor.yaml instead of .conductor.yml', async () => { + // The project was never told about the other spelling; it is only the + // default, so both conventional names are acceptable. + await withHarness({ repoConfigPath: '.conductor.yaml' }, async (h) => { + const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + assert.equal(res.statusCode, 200, res.body); + assert.equal(res.json().tasks, 6); + }); +}); + +test('a project pointed at a specific file gets no fallback', async () => { + // An explicit path means that file. Guessing at a neighbour would make a + // typo silently build the wrong pipeline. + await withHarness({ configPath: 'ci/build.yml', repoConfigPath: '.conductor.yml' }, async (h) => { + const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + assert.equal(res.statusCode, 422); + assert.match(res.json().detail, /ci\/build\.yml not found/); + }); +}); + +test('a repository carrying both spellings is refused rather than guessed at', async () => { + await withHarness({}, async (h) => { + const sha = await h.commit({ '.conductor.yaml': DEFAULT_PIPELINE }, 'add the other spelling'); + const res = await h.trigger({ sha, ref: 'refs/heads/main' }); + assert.equal(res.statusCode, 422); + assert.match(res.json().detail, /both exist/); + }); +}); + +test('a pipeline still using the jobs key is told what it was renamed to', async () => { + const old = 'version: 1\njobs:\n build:\n image: alpine\n script: [make]\n'; + await withHarness({}, async (h) => { + const sha = await h.commit({ '.conductor.yml': old }, 'pre-rename pipeline'); + const res = await h.trigger({ sha, ref: 'refs/heads/main' }); + assert.equal(res.statusCode, 422); + assert.match(res.json().detail, /was renamed to tasks/); + }); +}); + +test('a job can be created directly, without a push payload to imitate', async () => { + await withHarness({}, async (h) => { + const res = await h.createJob({ sha: h.sha, ref: 'refs/heads/main', actor: 'alice' }); + assert.equal(res.statusCode, 200, res.body); + + const body = res.json(); + assert.equal(body.status, 'created'); + assert.equal(body.tasks, 6); + + const detail = await jobState(h, body.job_id); + assert.equal(detail.job.state, 'running'); + assert.equal(detail.job.actor, 'alice'); + // Recorded as its own kind of trigger, so a job started deliberately is + // distinguishable from one a hook forwarded. + assert.equal(detail.job.trigger_type, 'api'); + }); +}); + +test('creating a job requires the same signature as a trigger', async () => { + await withHarness({}, async (h) => { + const unsigned = await h.app.inject({ + method: 'POST', + url: '/api/v1/projects/demo/jobs', + headers: { 'content-type': 'application/json' }, + payload: JSON.stringify({ sha: h.sha }), + }); + assert.equal(unsigned.statusCode, 401); + + const wrong = await h.createJob({ sha: h.sha }, { secret: 'not-the-secret' }); + assert.equal(wrong.statusCode, 401); + }); +}); + +test('creating a job insists on a real commit rather than inferring one', async () => { + await withHarness({}, async (h) => { + assert.equal((await h.createJob({ ref: 'refs/heads/main' })).statusCode, 400); + assert.equal((await h.createJob({ sha: 'abc' })).statusCode, 400); + }); +}); + +test('a job can be read back, with the state of every task', async () => { + await withHarness({}, async (h) => { + const jobId = (await h.createJob({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id; + + const signature = h.sign(''); + const signed = await h.app.inject({ + method: 'GET', + url: `/api/v1/projects/demo/jobs/${jobId}`, + headers: { 'content-type': 'application/json', 'x-hub-signature-256': signature }, + }); + assert.equal(signed.statusCode, 200, signed.body); + + const body = signed.json(); + assert.equal(body.job.id, jobId); + assert.equal(body.job.state, 'running'); + assert.equal(body.tasks.length, 6); + assert.ok(body.tasks.every((t) => t.state === 'queued')); + assert.ok(body.tasks.some((t) => t.name === 'build:arch=x86_64')); + }); +}); + +test('a public job is readable without any credential at all', async () => { + await withHarness({}, async (h) => { + const jobId = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id; + + const res = await h.app.inject({ method: 'GET', url: `/api/v1/projects/demo/jobs/${jobId}` }); + assert.equal(res.statusCode, 200, res.body); + assert.equal(res.json().job.visibility, 'public'); + }); +}); + +test('a private job needs the trigger secret, and is otherwise absent', async () => { + const pipeline = DEFAULT_PIPELINE.replace('version: 1\n', 'version: 1\nvisibility: private\n'); + await withHarness({ pipeline }, async (h) => { + const jobId = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id; + + // A 404 rather than a 403, so this cannot be used to find out which + // jobs exist. + const anon = await h.app.inject({ method: 'GET', url: `/api/v1/projects/demo/jobs/${jobId}` }); + assert.equal(anon.statusCode, 404); + + const signed = await h.app.inject({ + method: 'GET', + url: `/api/v1/projects/demo/jobs/${jobId}`, + headers: { 'content-type': 'application/json', 'x-hub-signature-256': h.sign('') }, + }); + assert.equal(signed.statusCode, 200, signed.body); + assert.equal(signed.json().job.visibility, 'private'); + }); +}); + +test('a job of another project cannot be read through this one', async () => { + await withHarness({}, async (h) => { + const jobId = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id; + const signature = h.sign(''); + const res = await h.app.inject({ + method: 'GET', + url: `/api/v1/projects/absent/jobs/${jobId}`, + headers: { 'content-type': 'application/json', 'x-hub-signature-256': signature }, + }); + assert.equal(res.statusCode, 404); + }); +}); + test('the worker api rejects missing and invalid tokens', async () => { await withHarness({}, async (h) => { - const none = await h.app.inject({ method: 'POST', url: '/api/v1/workers/jobs' }); + const none = await h.app.inject({ method: 'POST', url: '/api/v1/tasks/claim' }); assert.equal(none.statusCode, 401); const bad = await h.app.inject({ - method: 'POST', url: '/api/v1/workers/jobs', headers: { authorization: 'Bearer nope' }, + method: 'POST', url: '/api/v1/tasks/claim', headers: { authorization: 'Bearer nope' }, }); assert.equal(bad.statusCode, 401); }); }); -test('independent jobs dispatch concurrently', async () => { +test('independent tasks dispatch concurrently', async () => { await withHarness({}, async (h) => { await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); - // The prototype could only ever hand out one job at a time. + // The prototype could only ever hand out one task at a time. const [a, b, c] = await Promise.all([ - h.poll({ arches: 'x86_64,aarch64' }), - h.poll({ arches: 'x86_64,aarch64' }), - h.poll({ arches: 'x86_64,aarch64' }), + h.claim({ arches: 'x86_64,aarch64' }), + h.claim({ arches: 'x86_64,aarch64' }), + h.claim({ arches: 'x86_64,aarch64' }), ]); - const names = [a, b, c].map((r) => r.json().job.name); - assert.equal(new Set(names).size, 3, `expected three distinct jobs, got ${names.join(', ')}`); + const names = [a, b, c].map((r) => r.json().task.name); + assert.equal(new Set(names).size, 3, `expected three distinct tasks, got ${names.join(', ')}`); assert.deepEqual(names.slice().sort(), ['build:arch=aarch64', 'build:arch=x86_64', 'lint']); }); }); -test('a job is only offered to a worker that satisfies its requires', async () => { +test('a task is only offered to a worker that satisfies its requires', async () => { await withHarness({}, async (h) => { await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); - // Clear the three root jobs so only package and publish remain. + // Clear the three root tasks so only package and publish remain. const roots = await drain(h, { arches: 'x86_64,aarch64' }); assert.deepEqual(Object.keys(roots).sort(), ['build:arch=aarch64', 'build:arch=x86_64', 'lint']); - for (const job of Object.values(roots)) await finish(h, job.id, { success: true }); + for (const task of Object.values(roots)) await finish(h, task.id, { success: true }); // package requires sign-key, which this worker does not advertise. - const without = await h.poll({ arches: 'x86_64,aarch64' }); + const without = await h.claim({ arches: 'x86_64,aarch64' }); assert.equal(without.statusCode, 204); - const with_ = await h.poll({ arches: 'x86_64,aarch64', features: 'sign-key' }); + const with_ = await h.claim({ arches: 'x86_64,aarch64', features: 'sign-key' }); assert.equal(with_.statusCode, 200); - assert.match(with_.json().job.name, /^package:/); + assert.match(with_.json().task.name, /^package:/); }); }); @@ -217,9 +372,9 @@ test('a worker is not offered work for an architecture it cannot build', async ( const claimed = []; for (let i = 0; i < 3; i += 1) { - const res = await h.poll({ arches: 'aarch64' }); + const res = await h.claim({ arches: 'aarch64' }); if (res.statusCode === 204) break; - claimed.push(res.json().job); + claimed.push(res.json().task); } // lint has no arch so it is eligible; the x86_64 build is not. @@ -228,33 +383,39 @@ test('a worker is not offered work for an architecture it cannot build', async ( }); }); -test('a claimed job carries everything the worker needs', async () => { +test('a claimed task carries everything the worker needs', async () => { await withHarness({}, async (h) => { await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); - const job = await claimNamed(h, 'build:arch=x86_64', { arches: 'x86_64' }); + const task = await claimNamed(h, 'build:arch=x86_64', { arches: 'x86_64' }); - assert.equal(job.image, 'debian:bookworm-slim'); - assert.deepEqual(job.script, ['./build.sh $ARCH']); - assert.equal(job.env.ARCH, 'x86_64'); - assert.equal(job.sha, h.sha); - assert.match(job.endpoints.source, /^http:\/\/conductor\.test\/api\/v1\/workers\/jobs\//); - assert.ok(job.endpoints.source.endsWith('/source.tar.gz')); - assert.ok(job.endpoints.log.endsWith('/log')); + assert.equal(task.image, 'debian:bookworm-slim'); + assert.deepEqual(task.script, ['./build.sh $ARCH']); + assert.equal(task.env.ARCH, 'x86_64'); + assert.equal(task.sha, h.sha); + assert.match(task.endpoints.source, /^http:\/\/conductor\.test\/api\/v1\/tasks\//); + assert.ok(task.endpoints.source.endsWith('/source.tar.gz')); + assert.ok(task.endpoints.log.endsWith('/log')); // The worker resolves features from these, so a missing field // silently disables them. - assert.deepEqual(job.requires, []); - assert.equal(job.project_id, 'demo'); - assert.ok(Number.isInteger(job.heartbeat_interval) && job.heartbeat_interval > 0); - assert.deepEqual(job.masked, []); + assert.deepEqual(task.requires, []); + assert.ok(Number.isInteger(task.heartbeat_interval) && task.heartbeat_interval > 0); + assert.deepEqual(task.masked, []); + + // A worker is given a context and a script. Which project or job the + // work belongs to is none of its business, and the id it holds carries + // no structure to read it out of either. + assert.equal(task.project_id, undefined); + assert.equal(task.job_id, undefined); + assert.ok(!task.id.includes(':'), `task id should be opaque, got ${task.id}`); }); }); -test('a job that requires a feature reports it to the worker', async () => { +test('a task that requires a feature reports it to the worker', async () => { await withHarness({}, async (h) => { await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); const roots = await drain(h, { arches: 'x86_64,aarch64' }); - for (const job of Object.values(roots)) await finish(h, job.id, { success: true }); + for (const task of Object.values(roots)) await finish(h, task.id, { success: true }); const pkg = (await drain(h, { arches: 'x86_64', features: 'sign-key' }))['package:arch=x86_64,pkg=musl']; assert.ok(pkg); @@ -262,29 +423,33 @@ test('a job that requires a feature reports it to the worker', async () => { }); }); -test('the standard conductor variables are present in the job environment', async () => { +test('the standard conductor variables are present in the task environment', async () => { await withHarness({}, async (h) => { - const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; - const job = await claimNamed(h, 'lint', {}); + const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id; + const task = await claimNamed(h, 'lint', {}); - assert.equal(job.env.CONDUCTOR_PROJECT, 'demo'); - assert.equal(job.env.CONDUCTOR_RUN_ID, run); - assert.equal(job.env.CONDUCTOR_RUN_NUMBER, '1'); - assert.equal(job.env.CONDUCTOR_JOB, 'lint'); - assert.equal(job.env.CONDUCTOR_SHA, h.sha); - assert.equal(job.env.CONDUCTOR_REF, 'refs/heads/main'); - assert.equal(job.env.CONDUCTOR_ATTEMPT, '1'); + // The worker is not told any of this, but the script is: these reach + // the container as ordinary environment, which is the only place the + // build legitimately needs them. + assert.equal(task.env.CONDUCTOR_PROJECT, 'demo'); + assert.equal(task.env.CONDUCTOR_JOB_ID, job); + assert.equal(task.env.CONDUCTOR_JOB_NUMBER, '1'); + assert.equal(task.env.CONDUCTOR_TASK, 'lint'); + assert.equal(task.env.CONDUCTOR_TASK_ID, task.id); + assert.equal(task.env.CONDUCTOR_SHA, h.sha); + assert.equal(task.env.CONDUCTOR_REF, 'refs/heads/main'); + assert.equal(task.env.CONDUCTOR_ATTEMPT, '1'); }); }); test('the source tarball is served from the mirror', async () => { await withHarness({}, async (h) => { await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); - const job = await claimNamed(h, 'lint', {}); + const task = await claimNamed(h, 'lint', {}); const res = await h.app.inject({ method: 'GET', - url: `/api/v1/workers/jobs/${encodeURIComponent(job.id)}/source.tar.gz`, + url: `/api/v1/tasks/${task.id}/source.tar.gz`, headers: h.auth, }); assert.equal(res.statusCode, 200); @@ -295,15 +460,15 @@ test('the source tarball is served from the mirror', async () => { }); }); -test('a worker cannot touch a job it does not hold', async () => { +test('a worker cannot touch a task it does not hold', async () => { await withHarness({}, async (h) => { await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); - const job = await claimNamed(h, 'lint', {}); + const task = await claimNamed(h, 'lint', {}); const other = await h.services.workerTokens.create('intruder'); const res = await h.app.inject({ method: 'POST', - url: `/api/v1/workers/jobs/${encodeURIComponent(job.id)}/log`, + url: `/api/v1/tasks/${task.id}/log`, headers: { authorization: `Bearer ${other.token}`, 'content-type': 'application/octet-stream' }, payload: Buffer.from('malicious'), }); @@ -314,8 +479,8 @@ test('a worker cannot touch a job it does not hold', async () => { test('log appends are resumable and deduplicated by offset', async () => { await withHarness({}, async (h) => { await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); - const job = await claimNamed(h, 'lint', {}); - const url = `/api/v1/workers/jobs/${encodeURIComponent(job.id)}/log`; + const task = await claimNamed(h, 'lint', {}); + const url = `/api/v1/tasks/${task.id}/log`; const headers = { ...h.auth, 'content-type': 'application/octet-stream' }; const first = await h.app.inject({ @@ -336,11 +501,11 @@ test('log appends are resumable and deduplicated by offset', async () => { assert.equal(gap.statusCode, 409); assert.equal(gap.json().expected_offset, 12); - const tail = await h.services.logs.read(job.run_id, job.id, { offset: 0, limit: 1024 }); + const tail = await h.services.logs.read(await jobIdOf(h, task.id), task.id, { offset: 0, limit: 1024 }); assert.equal(tail.data.toString('utf8'), 'hello\nworld\n'); assert.equal(tail.size, 12); - const partial = await h.services.logs.read(job.run_id, job.id, { offset: 6, limit: 1024 }); + const partial = await h.services.logs.read(await jobIdOf(h, task.id), task.id, { offset: 6, limit: 1024 }); assert.equal(partial.data.toString('utf8'), 'world\n'); }); }); @@ -348,36 +513,36 @@ test('log appends are resumable and deduplicated by offset', async () => { test('an oversized log chunk is refused', async () => { await withHarness({}, async (h) => { await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); - const job = await claimNamed(h, 'lint', {}); + const task = await claimNamed(h, 'lint', {}); // Artifacts are streamed and may be large, but a single log append is // buffered, so it has to be bounded or a worker can exhaust memory. const res = await h.app.inject({ method: 'POST', - url: `/api/v1/workers/jobs/${encodeURIComponent(job.id)}/log`, + url: `/api/v1/tasks/${task.id}/log`, headers: { ...h.auth, 'content-type': 'application/octet-stream' }, payload: Buffer.alloc(2 * 1024 * 1024, 0x41), }); assert.equal(res.statusCode, 413); - assert.equal(await h.services.logs.size(job.run_id, job.id), 0, 'nothing should have been written'); + assert.equal(await h.services.logs.size(await jobIdOf(h, task.id), task.id), 0, 'nothing should have been written'); }); }); test('a finished log moves to storage and is still readable', async () => { await withHarness({}, async (h) => { await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); - const job = await claimNamed(h, 'lint', {}); + const task = await claimNamed(h, 'lint', {}); await h.app.inject({ method: 'POST', - url: `/api/v1/workers/jobs/${encodeURIComponent(job.id)}/log`, + url: `/api/v1/tasks/${task.id}/log`, headers: { ...h.auth, 'content-type': 'application/octet-stream' }, payload: Buffer.from('compiling\ndone\n'), }); - await finish(h, job.id, { success: true }); + await finish(h, task.id, { success: true }); - const row = await h.services.db.get('SELECT log_key FROM jobs WHERE id = {id}', { id: job.id }); + const row = await h.services.db.get('SELECT log_key FROM tasks WHERE id = {id}', { id: task.id }); assert.ok(row.log_key, 'the log should have moved to storage'); const object = await h.services.storage.get(row.log_key); const parts = []; @@ -389,11 +554,11 @@ test('a finished log moves to storage and is still readable', async () => { test('artifacts are stored, hashed, and stripped of traversal', async () => { await withHarness({}, async (h) => { await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); - const job = await claimNamed(h, 'build:arch=x86_64', { arches: 'x86_64' }); + const task = await claimNamed(h, 'build:arch=x86_64', { arches: 'x86_64' }); const upload = await h.app.inject({ method: 'POST', - url: `/api/v1/workers/jobs/${encodeURIComponent(job.id)}/artifacts`, + url: `/api/v1/tasks/${task.id}/artifacts`, headers: { ...h.auth, 'content-type': 'application/octet-stream', @@ -409,13 +574,13 @@ test('artifacts are stored, hashed, and stripped of traversal', async () => { assert.match(body.sha256, /^[0-9a-f]{64}$/); const artifacts = await h.services.db.all( - 'SELECT id FROM artifacts WHERE job_id = {job}', { job: job.id } + 'SELECT id FROM artifacts WHERE task_id = {task}', { task: task.id } ); assert.equal(artifacts.length, 1); const download = await h.app.inject({ method: 'GET', - url: `/api/v1/projects/${job.project_id}/runs/${job.run_id}/jobs/${encodeURIComponent(job.id)}/artifacts/${artifacts[0].id}`, + url: `/api/v1/projects/demo/jobs/${await jobIdOf(h, task.id)}/tasks/${task.id}/artifacts/${artifacts[0].id}`, }); assert.equal(download.statusCode, 200); assert.equal(download.body, 'artifact bytes'); @@ -425,11 +590,11 @@ test('artifacts are stored, hashed, and stripped of traversal', async () => { test('an artifact shorter than its content-length is rejected', async () => { await withHarness({}, async (h) => { await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); - const job = await claimNamed(h, 'lint', {}); + const task = await claimNamed(h, 'lint', {}); const res = await h.app.inject({ method: 'POST', - url: `/api/v1/workers/jobs/${encodeURIComponent(job.id)}/artifacts`, + url: `/api/v1/tasks/${task.id}/artifacts`, headers: { ...h.auth, 'content-type': 'application/octet-stream', @@ -444,7 +609,7 @@ test('an artifact shorter than its content-length is rejected', async () => { test('success releases dependents, matched on shared dimensions', async () => { await withHarness({}, async (h) => { - const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; + const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id; const roots = await drain(h, { arches: 'x86_64,aarch64', features: 'sign-key' }); await finish(h, roots['build:arch=x86_64'].id, { success: true }); @@ -455,33 +620,33 @@ test('success releases dependents, matched on shared dimensions', async () => { assert.ok(next['package:arch=x86_64,pkg=musl'], 'x86_64 package should be released'); assert.ok(!next['package:arch=aarch64,pkg=musl'], 'aarch64 package should still be waiting'); - const state = jobsByName(await runState(h, run)); + const state = tasksByName(await jobState(h, job)); assert.equal(state['package:arch=aarch64,pkg=musl'].state, 'queued'); }); }); test('failure skips transitive dependents, not just direct ones', async () => { await withHarness({}, async (h) => { - const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; + const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id; const build = await claimNamed(h, 'build:arch=x86_64', { arches: 'x86_64' }); const outcome = await finish(h, build.id, { success: false, exitCode: 2, error: 'compile failed' }); assert.equal(outcome.state, 'failed'); - const jobs = jobsByName(await runState(h, run)); + const tasks = tasksByName(await jobState(h, job)); // Direct dependent. - assert.equal(jobs['package:arch=x86_64,pkg=musl'].state, 'skipped'); + assert.equal(tasks['package:arch=x86_64,pkg=musl'].state, 'skipped'); // Transitive dependent: the prototype left this queued and dispatchable. - assert.equal(jobs.publish.state, 'skipped'); + assert.equal(tasks.publish.state, 'skipped'); // Unrelated branches are untouched. - assert.equal(jobs['build:arch=aarch64'].state, 'queued'); - assert.equal(jobs['package:arch=aarch64,pkg=musl'].state, 'queued'); + assert.equal(tasks['build:arch=aarch64'].state, 'queued'); + assert.equal(tasks['package:arch=aarch64,pkg=musl'].state, 'queued'); }); }); -test('a run settles as failed once every job is terminal', async () => { +test('a job settles as failed once every task is terminal', async () => { await withHarness({}, async (h) => { - const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; + const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id; const roots = await drain(h, { arches: 'x86_64,aarch64', features: 'sign-key' }); @@ -493,16 +658,16 @@ test('a run settles as failed once every job is terminal', async () => { const released = await drain(h, { arches: 'aarch64', features: 'sign-key' }); const last = await finish(h, released['package:arch=aarch64,pkg=musl'].id, { success: true }); - assert.equal(last.run_state, 'failed'); - assert.equal((await runState(h, run)).run.state, 'failed'); + assert.equal(last.job_state, 'failed'); + assert.equal((await jobState(h, job)).job.state, 'failed'); }); }); -test('a run settles as success when everything passes', async () => { +test('a job settles as success when everything passes', async () => { // Quoted, because an unquoted true in YAML is a boolean, not a command. const pipeline = ` version: 1 -jobs: +tasks: a: image: alpine script: ['true'] @@ -512,22 +677,22 @@ jobs: needs: [a] `; await withHarness({ pipeline }, async (h) => { - const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; + const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id; const a = await claimNamed(h, 'a', {}); await finish(h, a.id, { success: true }); const b = await claimNamed(h, 'b', {}); const last = await finish(h, b.id, { success: true }); - assert.equal(last.run_state, 'success'); - assert.equal((await runState(h, run)).run.state, 'success'); + assert.equal(last.job_state, 'success'); + assert.equal((await jobState(h, job)).job.state, 'success'); }); }); -test('an allowed failure does not fail the run or block dependents', async () => { +test('an allowed failure does not fail the job or block dependents', async () => { const pipeline = ` version: 1 -jobs: +tasks: flaky: image: alpine script: ['false'] @@ -538,31 +703,31 @@ jobs: needs: [flaky] `; await withHarness({ pipeline }, async (h) => { - const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; + const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id; const flaky = await claimNamed(h, 'flaky', {}); const outcome = await finish(h, flaky.id, { success: false, exitCode: 1 }); assert.deepEqual(outcome.skipped, []); const after = await claimNamed(h, 'after', {}); - assert.ok(after, 'dependent should still run after an allowed failure'); + assert.ok(after, 'dependent should still job after an allowed failure'); const last = await finish(h, after.id, { success: true }); - assert.equal(last.run_state, 'success'); - assert.equal((await runState(h, run)).run.state, 'success'); + assert.equal(last.job_state, 'success'); + assert.equal((await jobState(h, job)).job.state, 'success'); }); }); -test('a job with retries left is requeued instead of failing the run', async () => { +test('a task with retries left is requeued instead of failing the job', async () => { const pipeline = ` version: 1 -jobs: +tasks: retried: image: alpine script: [maybe] max_attempts: 2 `; await withHarness({ pipeline }, async (h) => { - const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; + const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id; const first = await claimNamed(h, 'retried', {}); const outcome = await finish(h, first.id, { success: false, exitCode: 1 }); @@ -573,93 +738,93 @@ jobs: assert.equal(second.attempt, 2); const last = await finish(h, second.id, { success: false, exitCode: 1 }); assert.equal(last.state, 'failed'); - assert.equal((await runState(h, run)).run.state, 'failed'); + assert.equal((await jobState(h, job)).job.state, 'failed'); }); }); -test('heartbeats keep a job alive and report cancellation', async () => { +test('heartbeats keep a task alive and report cancellation', async () => { await withHarness({}, async (h) => { - const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; - const job = await claimNamed(h, 'lint', {}); + const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id; + const task = await claimNamed(h, 'lint', {}); const beat = await h.app.inject({ method: 'POST', - url: `/api/v1/workers/jobs/${encodeURIComponent(job.id)}/heartbeat`, + url: `/api/v1/tasks/${task.id}/heartbeat`, headers: { ...h.auth, 'content-type': 'application/json' }, payload: '{}', }); assert.deepEqual(beat.json(), { cancelled: false }); - await h.services.scheduler.cancelRun(run); + await h.services.scheduler.cancelJob(job); const after = await h.app.inject({ method: 'POST', - url: `/api/v1/workers/jobs/${encodeURIComponent(job.id)}/heartbeat`, + url: `/api/v1/tasks/${task.id}/heartbeat`, headers: { ...h.auth, 'content-type': 'application/json' }, payload: '{}', }); assert.equal(after.json().cancelled, true); - assert.equal((await runState(h, run)).run.state, 'cancelled'); + assert.equal((await jobState(h, job)).job.state, 'cancelled'); }); }); -test('the reaper fails a job whose worker stopped reporting', async () => { +test('the reaper fails a task whose worker stopped reporting', async () => { await withHarness({}, async (h) => { - const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; - const job = await claimNamed(h, 'lint', {}); - assert.equal(jobsByName(await runState(h, run)).lint.state, 'running'); + const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id; + const task = await claimNamed(h, 'lint', {}); + assert.equal(tasksByName(await jobState(h, job)).lint.state, 'running'); // Backdate the heartbeat rather than waiting out the real timeout. await h.services.db.run( - 'UPDATE jobs SET heartbeat_at = {then} WHERE id = {id}', - { then: Date.now() - 10 * 60 * 1000, id: job.id } + 'UPDATE tasks SET heartbeat_at = {then} WHERE id = {id}', + { then: Date.now() - 10 * 60 * 1000, id: task.id } ); const reaped = await h.services.scheduler.reap(); assert.ok(reaped.length > 0); - const after = jobsByName(await runState(h, run)).lint; + const after = tasksByName(await jobState(h, job)).lint; assert.equal(after.state, 'failed'); assert.match(after.error, /stopped reporting/); - // The worker that lost the job can no longer complete it. - const late = await finish(h, job.id, { success: true }); + // The worker that lost the task can no longer complete it. + const late = await finish(h, task.id, { success: true }); assert.match(String(late.error ?? ''), /not running|another worker/); }); }); -test('a second push produces an independent, numbered run', async () => { +test('a second push produces an independent, numbered job', async () => { await withHarness({}, async (h) => { const first = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json(); const sha2 = await h.commit({ 'README.md': 'changed\n' }, 'second commit'); const second = (await h.trigger({ sha: sha2, ref: 'refs/heads/main' })).json(); - assert.notEqual(first.run_id, second.run_id); - const detail = await runState(h, second.run_id); - assert.equal(detail.run.number, 2); - assert.equal(detail.run.title, 'second commit'); - assert.equal(detail.run.head_sha, sha2); + assert.notEqual(first.job_id, second.job_id); + const detail = await jobState(h, second.job_id); + assert.equal(detail.job.number, 2); + assert.equal(detail.job.title, 'second commit'); + assert.equal(detail.job.head_sha, sha2); }); }); -test('runs can be listed and filtered by project', apiGone, async () => { +test('jobs can be listed and filtered by project', apiGone, async () => { await withHarness({}, async (h) => { await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); - const all = await h.app.inject({ method: 'GET', url: '/api/runs' }); - assert.equal(all.json().runs.length, 1); + const all = await h.app.inject({ method: 'GET', url: '/api/jobs' }); + assert.equal(all.json().jobs.length, 1); - const mine = await h.app.inject({ method: 'GET', url: '/api/runs?project=demo' }); - assert.equal(mine.json().runs.length, 1); + const mine = await h.app.inject({ method: 'GET', url: '/api/jobs?project=demo' }); + assert.equal(mine.json().jobs.length, 1); - const other = await h.app.inject({ method: 'GET', url: '/api/runs?project=absent' }); - assert.equal(other.json().runs.length, 0); + const other = await h.app.inject({ method: 'GET', url: '/api/jobs?project=absent' }); + assert.equal(other.json().jobs.length, 0); }); }); -test('unknown runs, jobs and artifacts return 404', apiGone, async () => { +test('unknown jobs, tasks and artifacts return 404', apiGone, async () => { await withHarness({}, async (h) => { - for (const url of ['/api/runs/nope', '/api/jobs/nope', '/api/jobs/nope/log', '/api/artifacts/nope']) { + for (const url of ['/api/jobs/nope', '/api/tasks/nope', '/api/tasks/nope/log', '/api/artifacts/nope']) { const res = await h.app.inject({ method: 'GET', url }); assert.equal(res.statusCode, 404, `${url} should be 404`); } @@ -694,18 +859,18 @@ test('a commit that is not in the repository is refused', async () => { }); }); -test('a job is told to fetch an archive, and never the repository itself', async () => { +test('a task is told to fetch an archive, and never the repository itself', async () => { await withHarness({}, async (h) => { await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); - const job = await claimNamed(h, 'lint', {}); + const task = await claimNamed(h, 'lint', {}); - assert.match(job.endpoints.source, /\/source\.tar\.gz$/); + assert.match(task.endpoints.source, /\/source\.tar\.gz$/); // A worker holds no repository credentials and can reach no ref other // than the commit it was given work for, so the repository location // has no business being in the payload. - assert.equal(job.source, undefined, 'there is one way to get the source, so there is no mode'); - assert.ok(!JSON.stringify(job).includes(h.repoDir), 'the repository path must not reach the worker'); + assert.equal(task.source, undefined, 'there is one way to get the source, so there is no mode'); + assert.ok(!JSON.stringify(task).includes(h.repoDir), 'the repository path must not reach the worker'); }); }); @@ -715,11 +880,11 @@ test('the source archive is the bare tree, with no wrapping directory', async () // wrapping directory in the archive would land one level too deep. await withHarness({}, async (h) => { await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); - const job = await claimNamed(h, 'lint', {}); + const task = await claimNamed(h, 'lint', {}); const archive = await h.app.inject({ method: 'GET', - url: new URL(job.endpoints.source).pathname, + url: new URL(task.endpoints.source).pathname, headers: h.auth, }); assert.equal(archive.statusCode, 200); @@ -748,7 +913,7 @@ test('the working directory is resolved from the pipeline, then the project', as 'workdir: /usr/src/app', 'defaults:', ' image: alpine:3', - 'jobs:', + 'tasks:', ' build:', " script: ['make']", '', @@ -756,8 +921,8 @@ test('the working directory is resolved from the pipeline, then the project', as await withHarness({ pipeline }, async (h) => { await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); - const job = await claimNamed(h, 'build', {}); - assert.equal(job.workdir, '/usr/src/app', 'the repository has the final say'); + const task = await claimNamed(h, 'build', {}); + assert.equal(task.workdir, '/usr/src/app', 'the repository has the final say'); }); // With the repository silent, the project decides. @@ -767,15 +932,15 @@ test('the working directory is resolved from the pipeline, then the project', as { dir: '/srv/build', id: h.project.id } ); await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); - const job = await claimNamed(h, 'lint', {}); - assert.equal(job.workdir, '/srv/build'); + const task = await claimNamed(h, 'lint', {}); + assert.equal(task.workdir, '/srv/build'); }); // With neither, there is a default. await withHarness({}, async (h) => { await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); - const job = await claimNamed(h, 'lint', {}); - assert.equal(job.workdir, '/work'); + const task = await claimNamed(h, 'lint', {}); + assert.equal(task.workdir, '/work'); }); }); @@ -786,7 +951,7 @@ test('an unusable working directory is refused rather than sanitised', async () `workdir: ${JSON.stringify(workdir)}`, 'defaults:', ' image: alpine:3', - 'jobs:', + 'tasks:', ' build:', " script: ['make']", '', diff --git a/test/config.test.js b/test/config.test.js @@ -7,7 +7,7 @@ import os from 'node:os'; import path from 'node:path'; import { loadConfig, stateDirs, parseKey } from '../src/lib/config.js'; -// loadConfig reads process.env, so every case runs with a clean slate. +// loadConfig reads process.env, so every case jobs with a clean slate. async function withEnv(env, fn) { const saved = {}; for (const key of Object.keys(process.env)) { diff --git a/test/dag.test.js b/test/dag.test.js @@ -60,13 +60,13 @@ test('depth reflects the longest path, not insertion order', () => { assert.equal(d.get('d'), 2); }); -test('independent jobs are runnable at the same time', () => { - // The prototype could only ever return one job here. +test('independent tasks are runnable at the same time', () => { + // The prototype could only ever return one task here. const ready = runnable(GRAPH, states()).map((j) => j.name); assert.deepEqual(ready.sort(), ['a', 'island']); }); -test('a job becomes runnable only once every dependency succeeds', () => { +test('a task becomes runnable only once every dependency succeeds', () => { assert.deepEqual( runnable(GRAPH, states({ a: 'success', b: 'success' })).map((j) => j.name).sort(), ['c', 'island'] diff --git a/test/db.test.js b/test/db.test.js @@ -55,7 +55,7 @@ test('migrations apply once and are idempotent', async () => { assert.equal(first.applied.length, first.total, 'a fresh database applies every migration'); const second = await runMigrations(db, { logger: quiet }); - assert.equal(second.applied.length, 0, 'a second run must do nothing'); + assert.equal(second.applied.length, 0, 'a second job must do nothing'); assert.equal(second.total, first.total); } finally { await cleanup(); @@ -120,15 +120,15 @@ test('a conditional update claims exactly once', async () => { const t = Date.now(); await db.run('INSERT INTO projects (id,name,repo_url,created_at,updated_at) VALUES ({i},{n},{u},{t},{t})', { i: 'p', n: 'p', u: 'u', t }); - await db.run('INSERT INTO runs (id,project_id,number,head_sha,created_at) VALUES ({i},{p},{n},{s},{t})', + await db.run('INSERT INTO jobs (id,project_id,number,head_sha,created_at) VALUES ({i},{p},{n},{s},{t})', { i: 'r', p: 'p', n: 1, s: 'sha', t }); await db.run( - 'INSERT INTO jobs (id,run_id,name,base_name,image,requires,spec,timeout,created_at) ' + + 'INSERT INTO tasks (id,job_id,name,base_name,image,requires,spec,timeout,created_at) ' + 'VALUES ({i},{r},{n},{n},{img},{req},{spec},{to},{t})', { i: 'r:b', r: 'r', n: 'b', img: 'alpine', req: '[]', spec: '{}', to: 60, t } ); - const claim = 'UPDATE jobs SET state = {to} WHERE id = {id} AND state = {from}'; + const claim = 'UPDATE tasks SET state = {to} WHERE id = {id} AND state = {from}'; const first = await db.run(claim, { to: 'running', id: 'r:b', from: 'queued' }); const second = await db.run(claim, { to: 'running', id: 'r:b', from: 'queued' }); assert.equal(first.changes, 1); @@ -138,23 +138,23 @@ test('a conditional update claims exactly once', async () => { } }); -test('deleting a run cascades to its jobs', async () => { +test('deleting a job cascades to its tasks', async () => { const { db, cleanup } = await tempDb(); try { await runMigrations(db, { logger: quiet }); const t = Date.now(); await db.run('INSERT INTO projects (id,name,repo_url,created_at,updated_at) VALUES ({i},{n},{u},{t},{t})', { i: 'p', n: 'p', u: 'u', t }); - await db.run('INSERT INTO runs (id,project_id,number,head_sha,created_at) VALUES ({i},{p},{n},{s},{t})', + await db.run('INSERT INTO jobs (id,project_id,number,head_sha,created_at) VALUES ({i},{p},{n},{s},{t})', { i: 'r', p: 'p', n: 1, s: 'sha', t }); await db.run( - 'INSERT INTO jobs (id,run_id,name,base_name,image,requires,spec,timeout,created_at) ' + + 'INSERT INTO tasks (id,job_id,name,base_name,image,requires,spec,timeout,created_at) ' + 'VALUES ({i},{r},{n},{n},{img},{req},{spec},{to},{t})', { i: 'r:b', r: 'r', n: 'b', img: 'alpine', req: '[]', spec: '{}', to: 60, t } ); - await db.run('DELETE FROM runs WHERE id = {id}', { id: 'r' }); - assert.equal((await db.get('SELECT COUNT(*) AS c FROM jobs', {})).c, 0); + await db.run('DELETE FROM jobs WHERE id = {id}', { id: 'r' }); + assert.equal((await db.get('SELECT COUNT(*) AS c FROM tasks', {})).c, 0); } finally { await cleanup(); } diff --git a/test/dialects.test.js b/test/dialects.test.js @@ -1,8 +1,8 @@ // test/dialects.test.js - the same behaviour on postgres and mysql // -// Everything else runs on sqlite, so the other two dialects were only +// Everything else jobs on sqlite, so the other two dialects were only // exercised by reading them. This applies every migration against a real -// server and then runs the retention sweep, which is the query that most +// server and then jobs the retention sweep, which is the query that most // depends on the dialect: it joins, filters on nulls, orders, and binds a // LIMIT, and the drivers do not all accept those the same way. // @@ -28,40 +28,40 @@ const NOW = 1_800_000_000_000; // compared against what that dialect already proves. async function seed(db, storage) { await db.run( - `INSERT INTO projects (id, name, repo_url, enabled, run_counter, created_at, updated_at) + `INSERT INTO projects (id, name, repo_url, enabled, job_counter, created_at, updated_at) VALUES ('demo', 'Demo', '/tmp/repo', 1, 0, {now}, {now})`, { now: NOW } ); for (let i = 1; i <= 5; i += 1) { - // Run 5 is recent; the rest are long past. + // Job 5 is recent; the rest are long past. const at = NOW - (i === 5 ? 1 : 100) * DAY; await db.run( - `INSERT INTO runs (id, project_id, number, head_sha, trigger_type, state, + `INSERT INTO jobs (id, project_id, number, head_sha, trigger_type, state, visibility, created_at, started_at, finished_at) VALUES ({id}, 'demo', {number}, {sha}, 'push', 'failed', 'public', {at}, {at}, {at})`, { id: `r${i}`, number: i, sha: 'a'.repeat(40), at } ); await db.run( - `INSERT INTO jobs (id, run_id, name, base_name, image, requires, spec, state, + `INSERT INTO tasks (id, job_id, name, base_name, image, requires, spec, state, allow_failure, attempt, max_attempts, timeout, log_key, log_size, created_at, finished_at) - VALUES ({id}, {run}, 'build', 'build', 'alpine:3', '[]', '{}', 'failed', + VALUES ({id}, {job}, 'build', 'build', 'alpine:3', '[]', '{}', 'failed', 0, 1, 1, 3600, {logKey}, 10, {at}, {at})`, - { id: `r${i}:build`, run: `r${i}`, logKey: `logs/r${i}`, at } + { id: `r${i}:build`, job: `r${i}`, logKey: `logs/r${i}`, at } ); await db.run( - `INSERT INTO artifacts (id, job_id, run_id, path, storage_key, size, sha256, + `INSERT INTO artifacts (id, task_id, job_id, path, storage_key, size, sha256, created_at, expires_at) - VALUES ({id}, {job}, {run}, 'out.txt', {key}, 5, {sha}, {at}, {expires})`, + VALUES ({id}, {task}, {job}, 'out.txt', {key}, 5, {sha}, {at}, {expires})`, { id: `a${i}`, - job: `r${i}:build`, - run: `r${i}`, + task: `r${i}:build`, + job: `r${i}`, key: `art/r${i}`, sha: 'b'.repeat(64), at, - // Run 1 carries an explicit deadline, which nothing protects. + // Job 1 carries an explicit deadline, which nothing protects. expires: i === 1 ? NOW - 1 : null, } ); @@ -80,7 +80,7 @@ for (const dialect of ['postgres', 'mysql']) { storage: { path: path.join(root, 'storage') }, log: { spool_path: path.join(root, 'logs'), max_size: 1 << 20 }, retention: { - artifact_keep_runs: 2, + artifact_keep_jobs: 2, artifact_keep_days: 7, log_keep_days: 7, sweep_interval: 3600, @@ -101,7 +101,7 @@ for (const dialect of ['postgres', 'mysql']) { const first = await retention.sweep({ now: NOW }); - // Run 1 by its own deadline, runs 2 and 3 by policy. Runs 4 and 5 + // Job 1 by its own deadline, jobs 2 and 3 by policy. Jobs 4 and 5 // are the last two, so they are kept whatever their age. assert.equal(first.artifacts, 3, 'three artifacts should go on the first pass'); @@ -117,12 +117,12 @@ for (const dialect of ['postgres', 'mysql']) { const third = await retention.sweep({ now: NOW }); assert.equal(third.logs, 0, 'a swept log must not be found again'); - const withLogs = await db.all('SELECT id FROM jobs WHERE log_key IS NOT NULL ORDER BY id', {}); - assert.deepEqual(withLogs.map((r) => r.id), ['r5:build'], 'only the recent run keeps its log'); + const withLogs = await db.all('SELECT id FROM tasks WHERE log_key IS NOT NULL ORDER BY id', {}); + assert.deepEqual(withLogs.map((r) => r.id), ['r5:build'], 'only the recent job keeps its log'); - // The jobs themselves are history and stay. - const jobs = await db.get('SELECT COUNT(*) AS c FROM jobs', {}); - assert.equal(Number(jobs.c), 5, 'sweeping a log must not remove the job'); + // The tasks themselves are history and stay. + const tasks = await db.get('SELECT COUNT(*) AS c FROM tasks', {}); + assert.equal(Number(tasks.c), 5, 'sweeping a log must not remove the task'); } finally { await db?.close().catch(() => {}); await fs.rm(root, { recursive: true, force: true }); diff --git a/test/examples.test.js b/test/examples.test.js @@ -20,38 +20,38 @@ test('every example pipeline compiles', async () => { for (const file of files) { const text = await fs.readFile(path.join(EXAMPLES, file), 'utf8'); const pipeline = compilePipeline(text, { source: file }); - assert.ok(pipeline.jobs.length > 0, `${file} produced no jobs`); + assert.ok(pipeline.tasks.length > 0, `${file} produced no tasks`); } }); test('the distribution example fans in per architecture', async () => { const text = await fs.readFile(path.join(EXAMPLES, 'distro.conductor.yml'), 'utf8'); const pipeline = compilePipeline(text, { source: 'distro.conductor.yml' }); - const jobs = Object.fromEntries(pipeline.jobs.map((j) => [j.name, j])); + const tasks = Object.fromEntries(pipeline.tasks.map((j) => [j.name, j])); // check, two toolchains, twelve packages, two images, one publish. - assert.equal(pipeline.jobs.length, 18); + assert.equal(pipeline.tasks.length, 18); // A package waits only on its own architecture's toolchain. - assert.deepEqual(jobs['package:arch=aarch64,pkg=musl'].needs, ['toolchain:arch=aarch64']); - assert.deepEqual(jobs['package:arch=x86_64,pkg=grub'].needs, ['toolchain:arch=x86_64']); + assert.deepEqual(tasks['package:arch=aarch64,pkg=musl'].needs, ['toolchain:arch=aarch64']); + assert.deepEqual(tasks['package:arch=x86_64,pkg=grub'].needs, ['toolchain:arch=x86_64']); // An image waits on every package for its architecture, and no others. - const imageNeeds = jobs['image:arch=x86_64'].needs; + const imageNeeds = tasks['image:arch=x86_64'].needs; assert.equal(imageNeeds.length, 6); assert.ok(imageNeeds.every((n) => n.includes('arch=x86_64'))); // publish has no architecture, so it waits for both, plus check. - assert.equal(jobs.publish.needs.length, 3); - assert.equal(jobs.publish.depth, 3); + assert.equal(tasks.publish.needs.length, 3); + assert.equal(tasks.publish.depth, 3); }); test('the node example wires services and features', async () => { const text = await fs.readFile(path.join(EXAMPLES, 'node-app.conductor.yml'), 'utf8'); const pipeline = compilePipeline(text, { source: 'node-app.conductor.yml' }); - const jobs = Object.fromEntries(pipeline.jobs.map((j) => [j.name, j])); + const tasks = Object.fromEntries(pipeline.tasks.map((j) => [j.name, j])); - assert.deepEqual(jobs['test:suite=unit'].services.map((s) => s.alias), ['postgres']); - assert.deepEqual(jobs.image.requires, ['docker']); - assert.equal(jobs['test:suite=integration'].artifacts.when, 'always'); + assert.deepEqual(tasks['test:suite=unit'].services.map((s) => s.alias), ['postgres']); + assert.deepEqual(tasks.image.requires, ['docker']); + assert.equal(tasks['test:suite=integration'].artifacts.when, 'always'); }); diff --git a/test/git-env.test.js b/test/git-env.test.js @@ -19,7 +19,7 @@ import { gitEnv } from '../src/lib/git.js'; const execFileAsync = promisify(execFile); -test('git runs with safe.directory set', () => { +test('git jobs with safe.directory set', () => { const env = gitEnv({}); assert.equal(env.GIT_CONFIG_COUNT, '1'); assert.equal(env.GIT_CONFIG_KEY_0, 'safe.directory'); @@ -75,7 +75,7 @@ test('a file missing from a commit reads as missing, whatever sits in the workin // absence. The conductor decides a project has no pipeline from that // message, so the wrong wording turned a missing pipeline into a 500. // Reproduced here rather than left to the happy accident that the test - // suite runs from a directory containing a .conductor.yml. + // suite jobs from a directory containing a .conductor.yml. const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'conductor-cwd-')); const work = path.join(dir, 'work'); const mirror = path.join(dir, 'mirror.git'); @@ -95,7 +95,7 @@ test('a file missing from a commit reads as missing, whatever sits in the workin const sha = stdout.trim(); // A name that is absent from the commit but present in the directory - // the suite runs from. + // the suite jobs from. const decoy = 'package.json'; assert.ok(existsSync(path.join(process.cwd(), decoy)), `expected ${decoy} in the working directory`); @@ -110,7 +110,7 @@ test('a file missing from a commit reads as missing, whatever sits in the workin stderr = error.stderr ?? ''; } - // Run inside the bare mirror there is no work tree to confuse it. + // Job inside the bare mirror there is no work tree to confuse it. assert.match(stderr, /does not exist/); } finally { await fs.rm(dir, { recursive: true, force: true }); diff --git a/test/helpers/containers.js b/test/helpers/containers.js @@ -1,9 +1,9 @@ -// test/helpers/containers.js - cleaning up after runs that crashed +// test/helpers/containers.js - cleaning up after jobs that crashed // -// A test run that is killed never reaches its cleanup hook and leaves its +// A test job that is killed never reaches its cleanup hook and leaves its // containers behind, so each helper tidies up before it starts. // -// Doing that by name prefix alone is wrong, and was: node --test runs +// Doing that by name prefix alone is wrong, and was: node --test jobs // test files in parallel processes, two files each start their own MinIO, // and whichever starts second force-removes the container the first is // using. The first then waits out its whole timeout and fails with the @@ -11,7 +11,7 @@ // which is exactly the wrong thing to go looking for. // // So age is the thing that makes a container stale, not its name. A -// sibling that started seconds ago is left alone; a leftover from a run +// sibling that started seconds ago is left alone; a leftover from a job // that died half an hour ago is removed. import { execFile } from 'node:child_process'; @@ -20,7 +20,7 @@ import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); // Comfortably longer than any test takes, comfortably shorter than the -// gap between one run and the next. +// gap between one job and the next. export const STALE_AGE = 30 * 60 * 1000; export async function reapStale(prefix, maxAgeMs = STALE_AGE) { diff --git a/test/helpers/database.js b/test/helpers/database.js @@ -1,10 +1,10 @@ // test/helpers/database.js - real postgres and mysql for the tests // -// The suite otherwise runs entirely on sqlite, which means the postgres +// The suite otherwise jobs entirely on sqlite, which means the postgres // and mysql migrations and the dialect specific query compilation were // only ever read, never executed. A migration that does not apply, or a // query that binds its parameters in a way one driver dislikes, would -// reach whoever runs that database rather than the test suite. +// reach whoever jobs that database rather than the test suite. // // Set CONDUCTOR_TEST_POSTGRES_URL or CONDUCTOR_TEST_MYSQL_URL to use a // server you already have instead of starting a container. @@ -48,7 +48,7 @@ export function dockerAvailableSync() { // Whether a given dialect can be exercised: a url was supplied, or docker // is here to start one. The drivers are optional dependencies, so a -// checkout that skipped them cannot run these either. +// checkout that skipped them cannot job these either. export function dialectAvailable(dialect) { if (envUrl(dialect)) return true; if (!dockerAvailableSync()) return false; @@ -82,7 +82,7 @@ async function freePort() { // Whether the server is actually usable yet. // // Not pg_isready or mysqladmin ping over docker exec, which is the -// obvious choice and the wrong one: both images run a temporary server +// obvious choice and the wrong one: both images job a temporary server // during initialisation to create the database, and those clients answer // for it over the local socket. The probe then passes, the temporary // server shuts down, and the first real query dies with ECONNRESET. diff --git a/test/helpers/harness.js b/test/helpers/harness.js @@ -20,7 +20,7 @@ export const DEFAULT_PIPELINE = ` version: 1 defaults: image: debian:bookworm-slim -jobs: +tasks: lint: script: [make lint] build: @@ -61,7 +61,12 @@ export async function createRepo(dir, { pipeline = DEFAULT_PIPELINE, configPath export async function startHarness(options = {}) { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'conductor-it-')); const repoDir = path.join(root, 'repo'); - const sha = await createRepo(repoDir, options); + // The file in the repository and the project's config_path are usually + // the same, but not when the point of the test is that they differ. + const sha = await createRepo(repoDir, { + ...options, + configPath: options.repoConfigPath ?? options.configPath ?? '.conductor.yml', + }); const configFile = path.join(root, 'conductor.yaml'); await fs.writeFile(configFile, [ @@ -140,7 +145,7 @@ export async function startHarness(options = {}) { trigger_secret: 'test-secret', config_path: options.configPath ?? '.conductor.yml', - // Public by default so that tests about scheduling can read runs back + // Public by default so that tests about scheduling can read jobs back // without signing in. Visibility itself is covered by its own suite, // which creates private projects explicitly. visibility: options.visibility ?? 'public', @@ -187,10 +192,22 @@ export async function startHarness(options = {}) { }); }, - async poll(body = {}) { + // Starts a task the deliberate way, rather than by forwarding a push. + async createJob(payload, { secret = 'test-secret' } = {}) { + const body = JSON.stringify(payload); + const signature = `sha256=${crypto.createHmac('sha256', secret).update(body).digest('hex')}`; + return app.inject({ + method: 'POST', + url: '/api/v1/projects/demo/jobs', + headers: { 'content-type': 'application/json', 'x-hub-signature-256': signature }, + payload: body, + }); + }, + + async claim(body = {}) { return app.inject({ method: 'POST', - url: '/api/v1/workers/jobs', + url: '/api/v1/tasks/claim', headers: { ...this.auth, 'content-type': 'application/json' }, payload: JSON.stringify(body), }); diff --git a/test/helpers/minio.js b/test/helpers/minio.js @@ -1,6 +1,6 @@ // test/helpers/minio.js - a real object store for the tests // -// Runs MinIO, which speaks real S3. The signing code here is written +// Jobs MinIO, which speaks real S3. The signing code here is written // against node:crypto rather than taken from a library, so verifying it // against a real server is the only way to know it works: a stub would // accept whatever signature we happened to produce, including a wrong one. diff --git a/test/helpers/oidc.js b/test/helpers/oidc.js @@ -1,6 +1,6 @@ // test/helpers/oidc.js - a real identity provider for the tests // -// Runs navikt/mock-oauth2-server, which speaks real OIDC discovery and +// Jobs navikt/mock-oauth2-server, which speaks real OIDC discovery and // signs real tokens with a real key set. Faking the provider would test the // fake: the interesting failures here are discovery paths, signature // verification and claim shapes, and none of those survive a stub. diff --git a/test/oidc.test.js b/test/oidc.test.js @@ -1,6 +1,6 @@ // test/oidc.test.js - authentication against a real identity provider // -// Runs navikt/mock-oauth2-server in a container and verifies real signed +// Jobs navikt/mock-oauth2-server in a container and verifies real signed // tokens against its real key set. Skipped when docker is unavailable. // // The two things worth proving here cannot be proven against a stub: that diff --git a/test/only.test.js b/test/only.test.js @@ -1,4 +1,4 @@ -// test/only.test.js - restricting jobs to particular refs +// test/only.test.js - restricting tasks to particular refs import test from 'node:test'; import assert from 'node:assert/strict'; @@ -9,7 +9,7 @@ const PIPELINE = ` version: 1 defaults: image: alpine:3 -jobs: +tasks: build: script: ['make'] publish: @@ -20,7 +20,7 @@ jobs: `; function names(text, ref) { - return compilePipeline(text, { ref }).jobs.map((j) => j.name).sort(); + return compilePipeline(text, { ref }).tasks.map((j) => j.name).sort(); } test('a pattern without a star matches exactly', () => { @@ -33,7 +33,7 @@ test('a pattern without a star matches exactly', () => { assert.ok(!refMatches('main', 'refs/heads/main')); }); -test('a star matches any run of characters', () => { +test('a star matches any job of characters', () => { assert.ok(refMatches('refs/tags/v*', 'refs/tags/v1.2.0')); assert.ok(refMatches('refs/tags/v*', 'refs/tags/v')); assert.ok(!refMatches('refs/tags/v*', 'refs/heads/v1')); @@ -52,25 +52,25 @@ test('the rest of a pattern is literal, not a regular expression', () => { assert.ok(!refMatches('refs/heads/fix+', 'refs/heads/fixx')); }); -test('a restricted job is left out of runs for other refs', () => { +test('a restricted task is left out of jobs for other refs', () => { assert.deepEqual(names(PIPELINE, 'refs/heads/main'), ['build', 'publish']); assert.deepEqual(names(PIPELINE, 'refs/tags/v1.2.0'), ['build', 'publish']); assert.deepEqual(names(PIPELINE, 'refs/heads/feature'), ['build']); }); test('it is left out rather than recorded as skipped', () => { - // The scheduler reads a skipped job as a reason to fail the run, so a - // publish step that was never meant to run here must not appear at all. - const { jobs } = compilePipeline(PIPELINE, { ref: 'refs/heads/feature' }); - assert.ok(!jobs.some((j) => j.name === 'publish')); + // The scheduler reads a skipped task as a reason to fail the job, so a + // publish step that was never meant to job here must not appear at all. + const { tasks } = compilePipeline(PIPELINE, { ref: 'refs/heads/feature' }); + assert.ok(!tasks.some((j) => j.name === 'publish')); }); -test('jobs depending on an excluded job are excluded too', () => { +test('tasks depending on an excluded task are excluded too', () => { const text = ` version: 1 defaults: image: alpine:3 -jobs: +tasks: build: script: ['make'] publish: @@ -88,19 +88,19 @@ jobs: assert.deepEqual(names(text, 'refs/heads/main'), ['announce', 'build', 'publish']); }); -test('depth and ordering describe the run that will happen', () => { - const { jobs } = compilePipeline(PIPELINE, { ref: 'refs/heads/feature' }); - assert.equal(jobs.length, 1); - assert.equal(jobs[0].name, 'build'); - assert.equal(jobs[0].depth, 0); +test('depth and ordering describe the job that will happen', () => { + const { tasks } = compilePipeline(PIPELINE, { ref: 'refs/heads/feature' }); + assert.equal(tasks.length, 1); + assert.equal(tasks[0].name, 'build'); + assert.equal(tasks[0].depth, 0); }); test('without a ref nothing is filtered, so a pipeline can still be validated', () => { - const { jobs } = compilePipeline(PIPELINE); - assert.deepEqual(jobs.map((j) => j.name).sort(), ['build', 'publish']); + const { tasks } = compilePipeline(PIPELINE); + assert.deepEqual(tasks.map((j) => j.name).sort(), ['build', 'publish']); }); -test('a restricted job cannot match a run that has no ref', () => { +test('a restricted task cannot match a job that has no ref', () => { assert.deepEqual(names(PIPELINE, ''), ['build']); }); @@ -117,7 +117,7 @@ test('a malformed rule is reported rather than ignored', () => { version: 1 defaults: image: alpine:3 -jobs: +tasks: publish: script: ['make publish'] ${fragment} @@ -136,15 +136,15 @@ jobs: }); test('the rule is not inherited from defaults', () => { - // Inheriting it would restrict every job at once, which is a hard - // mistake to spot when the symptom is an empty run. + // Inheriting it would restrict every task at once, which is a hard + // mistake to spot when the symptom is an empty job. const text = ` version: 1 defaults: image: alpine:3 only: refs: [refs/heads/main] -jobs: +tasks: build: script: ['make'] `; diff --git a/test/pipeline.test.js b/test/pipeline.test.js @@ -6,7 +6,7 @@ import { compilePipeline, parsePipeline, PipelineError } from '../src/lib/pipeli const MINIMAL = ` version: 1 -jobs: +tasks: build: image: alpine script: [make] @@ -23,78 +23,78 @@ function errorPaths(fn) { } function byName(pipeline) { - return Object.fromEntries(pipeline.jobs.map((j) => [j.name, j])); + return Object.fromEntries(pipeline.tasks.map((j) => [j.name, j])); } test('a minimal pipeline compiles', () => { const p = compilePipeline(MINIMAL); - assert.equal(p.jobs.length, 1); - assert.equal(p.jobs[0].name, 'build'); - assert.deepEqual(p.jobs[0].script, ['make']); - assert.equal(p.jobs[0].depth, 0); + assert.equal(p.tasks.length, 1); + assert.equal(p.tasks[0].name, 'build'); + assert.deepEqual(p.tasks[0].script, ['make']); + assert.equal(p.tasks[0].depth, 0); }); test('version is required and pinned', () => { - assert.deepEqual(errorPaths(() => compilePipeline('jobs:\n a:\n image: x\n script: [y]\n')), ['version']); - assert.deepEqual(errorPaths(() => compilePipeline('version: 2\njobs:\n a:\n image: x\n script: [y]\n')), ['version']); + assert.deepEqual(errorPaths(() => compilePipeline('tasks:\n a:\n image: x\n script: [y]\n')), ['version']); + assert.deepEqual(errorPaths(() => compilePipeline('version: 2\ntasks:\n a:\n image: x\n script: [y]\n')), ['version']); }); test('empty and malformed documents are rejected clearly', () => { assert.throws(() => compilePipeline(''), /file is empty/); assert.throws(() => compilePipeline('- a\n- b\n'), /expected a mapping at the top level/); - assert.throws(() => compilePipeline('version: 1\njobs: {\n'), /not valid YAML/); + assert.throws(() => compilePipeline('version: 1\ntasks: {\n'), /not valid YAML/); }); test('image and script are required', () => { - const paths = errorPaths(() => compilePipeline('version: 1\njobs:\n a: {}\n')); - assert.deepEqual(paths.sort(), ['jobs.a.image', 'jobs.a.script']); + const paths = errorPaths(() => compilePipeline('version: 1\ntasks:\n a: {}\n')); + assert.deepEqual(paths.sort(), ['tasks.a.image', 'tasks.a.script']); }); test('image may come from defaults', () => { - const p = compilePipeline('version: 1\ndefaults:\n image: alpine\njobs:\n a:\n script: [x]\n'); - assert.equal(p.jobs[0].image, 'alpine'); + const p = compilePipeline('version: 1\ndefaults:\n image: alpine\ntasks:\n a:\n script: [x]\n'); + assert.equal(p.tasks[0].image, 'alpine'); }); test('unknown keys are rejected, with a suggestion when close', () => { try { - compilePipeline('version: 1\njobs:\n a:\n image: x\n script: [y]\n scripts: [z]\n'); + compilePipeline('version: 1\ntasks:\n a:\n image: x\n script: [y]\n scripts: [z]\n'); throw new Error('expected rejection'); } catch (e) { - assert.match(e.message, /jobs\.a\.scripts: unknown key, did you mean script/); + assert.match(e.message, /tasks\.a\.scripts: unknown key, did you mean script/); } }); test('all problems are reported at once', () => { const paths = errorPaths(() => compilePipeline(` version: 1 -jobs: +tasks: a: script: [x] b: image: y `)); - assert.ok(paths.includes('jobs.a.image')); - assert.ok(paths.includes('jobs.b.script')); + assert.ok(paths.includes('tasks.a.image')); + assert.ok(paths.includes('tasks.b.script')); }); test('durations accept seconds, units and composites', () => { const p = compilePipeline(` version: 1 -jobs: +tasks: a: { image: x, script: [y], timeout: 90 } b: { image: x, script: [y], timeout: 30m } c: { image: x, script: [y], timeout: 1h30m } `); - const jobs = byName(p); - assert.equal(jobs.a.timeout, 90); - assert.equal(jobs.b.timeout, 1800); - assert.equal(jobs.c.timeout, 5400); + const tasks = byName(p); + assert.equal(tasks.a.timeout, 90); + assert.equal(tasks.b.timeout, 1800); + assert.equal(tasks.c.timeout, 5400); }); test('durations accept day and week units', () => { const p = compilePipeline(` version: 1 -jobs: +tasks: a: image: x script: [y] @@ -108,28 +108,28 @@ jobs: paths: [d] expire: 2w `); - const jobs = byName(p); - assert.equal(jobs.a.artifacts.expire, 30 * 86400); - assert.equal(jobs.b.artifacts.expire, 14 * 86400); + const tasks = byName(p); + assert.equal(tasks.a.artifacts.expire, 30 * 86400); + assert.equal(tasks.b.artifacts.expire, 14 * 86400); }); test('a malformed duration is rejected', () => { assert.deepEqual( - errorPaths(() => compilePipeline('version: 1\njobs:\n a: { image: x, script: [y], timeout: soon }\n')), - ['jobs.a.timeout'] + errorPaths(() => compilePipeline('version: 1\ntasks:\n a: { image: x, script: [y], timeout: soon }\n')), + ['tasks.a.timeout'] ); }); -test('arch expands into one job per architecture', () => { - const p = compilePipeline('version: 1\njobs:\n b:\n image: x\n script: [y]\n arch: [x86_64, aarch64]\n'); - assert.deepEqual(p.jobs.map((j) => j.name).sort(), ['b:arch=aarch64', 'b:arch=x86_64']); +test('arch expands into one task per architecture', () => { + const p = compilePipeline('version: 1\ntasks:\n b:\n image: x\n script: [y]\n arch: [x86_64, aarch64]\n'); + assert.deepEqual(p.tasks.map((j) => j.name).sort(), ['b:arch=aarch64', 'b:arch=x86_64']); assert.equal(byName(p)['b:arch=x86_64'].env.ARCH, 'x86_64'); }); test('a matrix expands to the cartesian product', () => { const p = compilePipeline(` version: 1 -jobs: +tasks: p: image: x script: [y] @@ -137,9 +137,9 @@ jobs: pkg: [musl, busybox] mode: [debug, release] `); - assert.equal(p.jobs.length, 4); + assert.equal(p.tasks.length, 4); // Dimensions keep their declaration order, so names are predictable. - assert.deepEqual(p.jobs.map((j) => j.name).sort(), [ + assert.deepEqual(p.tasks.map((j) => j.name).sort(), [ 'p:pkg=busybox,mode=debug', 'p:pkg=busybox,mode=release', 'p:pkg=musl,mode=debug', @@ -153,7 +153,7 @@ jobs: test('needs match on shared dimensions rather than fanning out', () => { const p = compilePipeline(` version: 1 -jobs: +tasks: build: image: x script: [y] @@ -166,15 +166,15 @@ jobs: pkg: [musl] needs: [build] `); - const jobs = byName(p); - assert.deepEqual(jobs['package:arch=x86_64,pkg=musl'].needs, ['build:arch=x86_64']); - assert.deepEqual(jobs['package:arch=aarch64,pkg=musl'].needs, ['build:arch=aarch64']); + const tasks = byName(p); + assert.deepEqual(tasks['package:arch=x86_64,pkg=musl'].needs, ['build:arch=x86_64']); + assert.deepEqual(tasks['package:arch=aarch64,pkg=musl'].needs, ['build:arch=aarch64']); }); -test('a job without the shared dimension depends on every instance', () => { +test('a task without the shared dimension depends on every instance', () => { const p = compilePipeline(` version: 1 -jobs: +tasks: build: image: x script: [y] @@ -190,7 +190,7 @@ jobs: test('match all opts out of dimension matching', () => { const p = compilePipeline(` version: 1 -jobs: +tasks: build: image: x script: [y] @@ -200,7 +200,7 @@ jobs: script: [y] arch: [x86_64, aarch64] needs: - - job: build + - task: build match: all `); assert.deepEqual(byName(p)['check:arch=x86_64'].needs, ['build:arch=aarch64', 'build:arch=x86_64']); @@ -209,7 +209,7 @@ jobs: test('needs may name one concrete instance', () => { const p = compilePipeline(` version: 1 -jobs: +tasks: build: image: x script: [y] @@ -226,7 +226,7 @@ test('an unsatisfiable dimension match is reported, not silently dropped', () => try { compilePipeline(` version: 1 -jobs: +tasks: build: image: x script: [y] @@ -244,18 +244,18 @@ jobs: } }); -test('needs on an unknown job lists what is defined', () => { +test('needs on an unknown task lists what is defined', () => { try { - compilePipeline('version: 1\njobs:\n a:\n image: x\n script: [y]\n needs: [nope]\n'); + compilePipeline('version: 1\ntasks:\n a:\n image: x\n script: [y]\n needs: [nope]\n'); throw new Error('expected rejection'); } catch (e) { - assert.match(e.message, /refers to unknown job "nope"/); + assert.match(e.message, /refers to unknown task "nope"/); } }); test('self dependency is rejected', () => { assert.throws( - () => compilePipeline('version: 1\njobs:\n a:\n image: x\n script: [y]\n needs: [a]\n'), + () => compilePipeline('version: 1\ntasks:\n a:\n image: x\n script: [y]\n needs: [a]\n'), /cannot depend on itself/ ); }); @@ -263,7 +263,7 @@ test('self dependency is rejected', () => { test('a dependency cycle names the cycle', () => { assert.throws(() => compilePipeline(` version: 1 -jobs: +tasks: a: { image: x, script: [s], needs: [c] } b: { image: x, script: [s], needs: [a] } c: { image: x, script: [s], needs: [b] } @@ -273,7 +273,7 @@ jobs: test('interpolation substitutes arch and matrix values', () => { const p = compilePipeline(` version: 1 -jobs: +tasks: b: image: 'builder:\${{ arch }}' arch: [x86_64] @@ -283,46 +283,46 @@ jobs: env: TAG: '\${{ matrix.pkg }}-\${{ arch }}' `); - const job = p.jobs[0]; - assert.equal(job.image, 'builder:x86_64'); - assert.deepEqual(job.script, ['build musl for x86_64']); - assert.equal(job.env.TAG, 'musl-x86_64'); + const task = p.tasks[0]; + assert.equal(task.image, 'builder:x86_64'); + assert.deepEqual(task.script, ['build musl for x86_64']); + assert.equal(task.env.TAG, 'musl-x86_64'); }); test('interpolating an undefined dimension is an error', () => { try { - compilePipeline('version: 1\njobs:\n a:\n image: x\n script: ["\${{ matrix.nope }}"]\n'); + compilePipeline('version: 1\ntasks:\n a:\n image: x\n script: ["\${{ matrix.nope }}"]\n'); throw new Error('expected rejection'); } catch (e) { - assert.match(e.message, /matrix\.nope \}\} which the job does not define/); + assert.match(e.message, /matrix\.nope \}\} which the task does not define/); } }); test('referring to arch without declaring one is an error', () => { assert.throws( - () => compilePipeline('version: 1\njobs:\n a:\n image: "x:\${{ arch }}"\n script: [y]\n'), - /the job declares no arch/ + () => compilePipeline('version: 1\ntasks:\n a:\n image: "x:\${{ arch }}"\n script: [y]\n'), + /the task declares no arch/ ); }); -test('matrix values must be safe for use in a job name', () => { +test('matrix values must be safe for use in a task name', () => { assert.deepEqual( - errorPaths(() => compilePipeline('version: 1\njobs:\n a:\n image: x\n script: [y]\n matrix:\n k: ["has space"]\n')), - ['jobs.a.matrix.k[0]'] + errorPaths(() => compilePipeline('version: 1\ntasks:\n a:\n image: x\n script: [y]\n matrix:\n k: ["has space"]\n')), + ['tasks.a.matrix.k[0]'] ); }); test('arch may not also be a matrix dimension', () => { assert.deepEqual( - errorPaths(() => compilePipeline('version: 1\njobs:\n a:\n image: x\n script: [y]\n matrix:\n arch: [x86_64]\n')), - ['jobs.a.matrix.arch'] + errorPaths(() => compilePipeline('version: 1\ntasks:\n a:\n image: x\n script: [y]\n matrix:\n arch: [x86_64]\n')), + ['tasks.a.matrix.arch'] ); }); test('services get a derived alias and reject duplicates', () => { const p = compilePipeline(` version: 1 -jobs: +tasks: a: image: x script: [y] @@ -331,11 +331,11 @@ jobs: - image: registry.example.com/team/postgres:16 alias: db `); - assert.deepEqual(p.jobs[0].services.map((s) => s.alias), ['docker', 'db']); + assert.deepEqual(p.tasks[0].services.map((s) => s.alias), ['docker', 'db']); assert.throws(() => compilePipeline(` version: 1 -jobs: +tasks: a: image: x script: [y] @@ -344,24 +344,24 @@ jobs: }); test('artifacts accept a bare list and reject absolute paths', () => { - const p = compilePipeline('version: 1\njobs:\n a:\n image: x\n script: [y]\n artifacts: [dist/**]\n'); - assert.deepEqual(p.jobs[0].artifacts.paths, ['dist/**']); - assert.equal(p.jobs[0].artifacts.when, 'on_success'); + const p = compilePipeline('version: 1\ntasks:\n a:\n image: x\n script: [y]\n artifacts: [dist/**]\n'); + assert.deepEqual(p.tasks[0].artifacts.paths, ['dist/**']); + assert.equal(p.tasks[0].artifacts.when, 'on_success'); assert.deepEqual( - errorPaths(() => compilePipeline('version: 1\njobs:\n a:\n image: x\n script: [y]\n artifacts: [/etc/passwd]\n')), - ['jobs.a.artifacts.paths[0]'] + errorPaths(() => compilePipeline('version: 1\ntasks:\n a:\n image: x\n script: [y]\n artifacts: [/etc/passwd]\n')), + ['tasks.a.artifacts.paths[0]'] ); }); test('artifact when is constrained', () => { assert.deepEqual( - errorPaths(() => compilePipeline('version: 1\njobs:\n a:\n image: x\n script: [y]\n artifacts:\n paths: [d]\n when: maybe\n')), - ['jobs.a.artifacts.when'] + errorPaths(() => compilePipeline('version: 1\ntasks:\n a:\n image: x\n script: [y]\n artifacts:\n paths: [d]\n when: maybe\n')), + ['tasks.a.artifacts.when'] ); }); -test('defaults are inherited and overridden per job', () => { +test('defaults are inherited and overridden per task', () => { const p = compilePipeline(` version: 1 defaults: @@ -370,7 +370,7 @@ defaults: env: SHARED: yes requires: [docker] -jobs: +tasks: a: script: [x] b: @@ -380,20 +380,20 @@ jobs: env: OWN: no `); - const jobs = byName(p); - assert.equal(jobs.a.image, 'base'); - assert.equal(jobs.a.timeout, 600); - assert.deepEqual(jobs.a.requires, ['docker']); - assert.equal(jobs.b.image, 'custom'); - assert.equal(jobs.b.timeout, 3600); - assert.equal(jobs.b.env.SHARED, 'yes'); - assert.equal(jobs.b.env.OWN, 'no'); + const tasks = byName(p); + assert.equal(tasks.a.image, 'base'); + assert.equal(tasks.a.timeout, 600); + assert.deepEqual(tasks.a.requires, ['docker']); + assert.equal(tasks.b.image, 'custom'); + assert.equal(tasks.b.timeout, 3600); + assert.equal(tasks.b.env.SHARED, 'yes'); + assert.equal(tasks.b.env.OWN, 'no'); }); test('env values must be scalars with valid names', () => { const paths = errorPaths(() => compilePipeline(` version: 1 -jobs: +tasks: a: image: x script: [y] @@ -401,14 +401,14 @@ jobs: 'bad name': 1 GOOD: { nested: true } `)); - assert.deepEqual(paths.sort(), ['jobs.a.env.GOOD', 'jobs.a.env.bad name']); + assert.deepEqual(paths.sort(), ['tasks.a.env.GOOD', 'tasks.a.env.bad name']); }); test('parsePipeline normalizes without expanding', () => { const doc = parsePipeline(MINIMAL); assert.equal(doc.version, 1); - assert.deepEqual(Object.keys(doc.jobs), ['build']); - assert.deepEqual(doc.jobs.build.needs, []); + assert.deepEqual(Object.keys(doc.tasks), ['build']); + assert.deepEqual(doc.tasks.build.needs, []); }); test('a realistic distribution pipeline produces the expected graph', () => { @@ -416,7 +416,7 @@ test('a realistic distribution pipeline produces the expected graph', () => { version: 1 defaults: image: debian:bookworm-slim -jobs: +tasks: lint: script: [make lint] build: @@ -434,9 +434,9 @@ jobs: script: [./mk/publish.sh] `); - assert.equal(p.jobs.length, 1 + 2 + 4 + 1); - const jobs = byName(p); - assert.equal(jobs.publish.needs.length, 5); - assert.equal(jobs.publish.depth, 2); - assert.deepEqual(jobs['package:arch=aarch64,pkg=musl'].requires, ['sign-key']); + assert.equal(p.tasks.length, 1 + 2 + 4 + 1); + const tasks = byName(p); + assert.equal(tasks.publish.needs.length, 5); + assert.equal(tasks.publish.depth, 2); + assert.deepEqual(tasks['package:arch=aarch64,pkg=musl'].requires, ['sign-key']); }); diff --git a/test/query.test.js b/test/query.test.js @@ -5,13 +5,13 @@ import assert from 'node:assert/strict'; import { compileQuery, bindParams } from '../src/lib/db/query.js'; test('compiles markers to the placeholder each dialect expects', () => { - const sql = 'SELECT * FROM jobs WHERE run_id = {run} AND state = {state}'; + const sql = 'SELECT * FROM tasks WHERE job_id = {job} AND state = {state}'; - assert.equal(compileQuery(sql, 'sqlite').sql, 'SELECT * FROM jobs WHERE run_id = ? AND state = ?'); - assert.equal(compileQuery(sql, 'mysql').sql, 'SELECT * FROM jobs WHERE run_id = ? AND state = ?'); - assert.equal(compileQuery(sql, 'postgres').sql, 'SELECT * FROM jobs WHERE run_id = $1 AND state = $2'); + assert.equal(compileQuery(sql, 'sqlite').sql, 'SELECT * FROM tasks WHERE job_id = ? AND state = ?'); + assert.equal(compileQuery(sql, 'mysql').sql, 'SELECT * FROM tasks WHERE job_id = ? AND state = ?'); + assert.equal(compileQuery(sql, 'postgres').sql, 'SELECT * FROM tasks WHERE job_id = $1 AND state = $2'); - assert.deepEqual(compileQuery(sql, 'postgres').keys, ['run', 'state']); + assert.deepEqual(compileQuery(sql, 'postgres').keys, ['job', 'state']); }); test('numbers postgres placeholders per occurrence, including repeats', () => { diff --git a/test/retention.test.js b/test/retention.test.js @@ -1,8 +1,8 @@ // test/retention.test.js - deleting artifacts and logs once they are old // -// This is the one part of the conductor whose job is to destroy data, so +// This is the one part of the conductor whose task is to destroy data, so // the rules are pinned down here rather than left to read from the -// implementation. Runs and jobs are seeded directly with chosen +// implementation. Jobs and tasks are seeded directly with chosen // timestamps, since waiting fourteen days for a test is not practical. import test from 'node:test'; @@ -21,7 +21,7 @@ async function withHarness(options, fn) { } } -// Seeds a finished run with one job and one artifact, at a chosen age. +// Seeds a finished job with one task and one artifact, at a chosen age. // Returns the identifiers so a test can assert on what survived. async function seedRun(h, { number, @@ -33,19 +33,19 @@ async function seedRun(h, { }) { const db = h.services.db; const at = NOW - ageDays * DAY; - const runId = `run-${number}`; - const jobId = `${runId}:build`; + const jobId = `job-${number}`; + const taskId = `${jobId}:build`; const artifactId = `art-${number}`; - const artifactKey = `artifacts/${runId}/build/out.txt`; - const logKey = `logs/${runId}/build.log`; + const artifactKey = `artifacts/${jobId}/build/out.txt`; + const logKey = `logs/${jobId}/build.log`; await db.run( - `INSERT INTO runs (id, project_id, number, ref, head_sha, trigger_type, + `INSERT INTO jobs (id, project_id, number, ref, head_sha, trigger_type, state, visibility, created_at, started_at, finished_at) VALUES ({id}, {project}, {number}, {ref}, {sha}, 'push', {state}, 'public', {at}, {at}, {finished})`, { - id: runId, + id: jobId, project: h.project.id, number, ref: 'refs/heads/main', @@ -57,14 +57,14 @@ async function seedRun(h, { ); await db.run( - `INSERT INTO jobs (id, run_id, name, base_name, image, requires, spec, state, + `INSERT INTO tasks (id, job_id, name, base_name, image, requires, spec, state, allow_failure, attempt, max_attempts, timeout, log_key, log_size, created_at, finished_at) - VALUES ({id}, {run}, 'build', 'build', 'alpine:3', '[]', '{}', {state}, + VALUES ({id}, {job}, 'build', 'build', 'alpine:3', '[]', '{}', {state}, 0, 1, 1, 3600, {logKey}, {logSize}, {at}, {finished})`, { - id: jobId, - run: runId, + id: taskId, + job: jobId, state: finished ? state : 'running', logKey, logSize, @@ -74,13 +74,13 @@ async function seedRun(h, { ); await db.run( - `INSERT INTO artifacts (id, job_id, run_id, path, storage_key, size, sha256, + `INSERT INTO artifacts (id, task_id, job_id, path, storage_key, size, sha256, created_at, expires_at) - VALUES ({id}, {job}, {run}, 'out.txt', {key}, 512, {sha}, {at}, {expires})`, + VALUES ({id}, {task}, {job}, 'out.txt', {key}, 512, {sha}, {at}, {expires})`, { id: artifactId, + task: taskId, job: jobId, - run: runId, key: artifactKey, sha: 'b'.repeat(64), at, @@ -92,7 +92,7 @@ async function seedRun(h, { await h.services.storage.put(artifactKey, Buffer.from('artifact body')); await h.services.storage.put(logKey, Buffer.from('log body')); - return { runId, jobId, artifactId, artifactKey, logKey }; + return { jobId, taskId, artifactId, artifactKey, logKey }; } async function artifactIds(h) { @@ -109,7 +109,7 @@ async function objectExists(h, key) { } test('an artifact past its own deadline is deleted', async () => { - await withHarness({ retention: { artifact_keep_runs: 0, artifact_keep_days: 0 } }, async (h) => { + await withHarness({ retention: { artifact_keep_jobs: 0, artifact_keep_days: 0 } }, async (h) => { const fresh = await seedRun(h, { number: 1, ageDays: 0, artifactExpiresAt: NOW + DAY }); const stale = await seedRun(h, { number: 2, ageDays: 0, artifactExpiresAt: NOW - 1 }); @@ -123,50 +123,50 @@ test('an artifact past its own deadline is deleted', async () => { }); test('an explicit deadline overrides the policy in both directions', async () => { - // Shorter than the policy would allow, on the newest run. - await withHarness({ retention: { artifact_keep_runs: 10, artifact_keep_days: 30 } }, async (h) => { + // Shorter than the policy would allow, on the newest job. + await withHarness({ retention: { artifact_keep_jobs: 10, artifact_keep_days: 30 } }, async (h) => { await seedRun(h, { number: 1, ageDays: 0, artifactExpiresAt: NOW - 1 }); await h.services.retention.sweep({ now: NOW }); - assert.deepEqual(await artifactIds(h), [], 'a job may expire its artifacts early'); + assert.deepEqual(await artifactIds(h), [], 'a task may expire its artifacts early'); }); - // Longer than the policy would allow, on a run far outside it. - await withHarness({ retention: { artifact_keep_runs: 1, artifact_keep_days: 1 } }, async (h) => { + // Longer than the policy would allow, on a job far outside it. + await withHarness({ retention: { artifact_keep_jobs: 1, artifact_keep_days: 1 } }, async (h) => { await seedRun(h, { number: 1, ageDays: 400, artifactExpiresAt: NOW + DAY }); await seedRun(h, { number: 2, ageDays: 0 }); await h.services.retention.sweep({ now: NOW }); - assert.ok((await artifactIds(h)).includes('art-1'), 'a job may keep its artifacts longer'); + assert.ok((await artifactIds(h)).includes('art-1'), 'a task may keep its artifacts longer'); }); }); -test('an explicit deadline also beats the last good run', async () => { +test('an explicit deadline also beats the last good job', async () => { // The protection exists so a quiet project keeps something downloadable. - // A job that asked for a short life is asking on purpose, and a bulky + // A task that asked for a short life is asking on purpose, and a bulky // intermediate should not become immortal by being green. - await withHarness({ retention: { artifact_keep_runs: 0, artifact_keep_days: 0 } }, async (h) => { + await withHarness({ retention: { artifact_keep_jobs: 0, artifact_keep_days: 0 } }, async (h) => { await seedRun(h, { number: 1, ageDays: 0, state: 'success', artifactExpiresAt: NOW - 1 }); await h.services.retention.sweep({ now: NOW }); assert.deepEqual(await artifactIds(h), []); }); }); -test('the last few runs keep their artifacts however old they are', async () => { - await withHarness({ retention: { artifact_keep_runs: 3, artifact_keep_days: 0 } }, async (h) => { +test('the last few jobs keep their artifacts however old they are', async () => { + await withHarness({ retention: { artifact_keep_jobs: 3, artifact_keep_days: 0 } }, async (h) => { for (let n = 1; n <= 6; n += 1) { await seedRun(h, { number: n, ageDays: 400, state: n === 6 ? 'failed' : 'failed' }); } await h.services.retention.sweep({ now: NOW }); - // The three highest numbered runs, regardless of age. + // The three highest numbered jobs, regardless of age. assert.deepEqual(await artifactIds(h), ['art-4', 'art-5', 'art-6']); }); }); -test('recent artifacts are kept however many runs have followed', async () => { - // The two rules are combined by whichever keeps longer, so a run outside +test('recent artifacts are kept however many jobs have followed', async () => { + // The two rules are combined by whichever keeps longer, so a job outside // the count window still survives while it is inside the age window. - await withHarness({ retention: { artifact_keep_runs: 2, artifact_keep_days: 30 } }, async (h) => { + await withHarness({ retention: { artifact_keep_jobs: 2, artifact_keep_days: 30 } }, async (h) => { for (let n = 1; n <= 5; n += 1) { await seedRun(h, { number: n, ageDays: 1, state: 'failed' }); } @@ -178,7 +178,7 @@ test('recent artifacts are kept however many runs have followed', async () => { }); test('an artifact outside both rules is deleted', async () => { - await withHarness({ retention: { artifact_keep_runs: 2, artifact_keep_days: 7 } }, async (h) => { + await withHarness({ retention: { artifact_keep_jobs: 2, artifact_keep_days: 7 } }, async (h) => { const old = await seedRun(h, { number: 1, ageDays: 30, state: 'failed' }); await seedRun(h, { number: 2, ageDays: 1, state: 'failed' }); await seedRun(h, { number: 3, ageDays: 1, state: 'failed' }); @@ -191,22 +191,22 @@ test('an artifact outside both rules is deleted', async () => { }); }); -test('the most recent successful run is kept whatever the policy says', async () => { - await withHarness({ retention: { artifact_keep_runs: 1, artifact_keep_days: 1 } }, async (h) => { +test('the most recent successful job is kept whatever the policy says', async () => { + await withHarness({ retention: { artifact_keep_jobs: 1, artifact_keep_days: 1 } }, async (h) => { await seedRun(h, { number: 1, ageDays: 400, state: 'success' }); await seedRun(h, { number: 2, ageDays: 300, state: 'failed' }); await seedRun(h, { number: 3, ageDays: 0, state: 'failed' }); await h.services.retention.sweep({ now: NOW }); - // run 1 is the last green one, run 3 is inside both windows, and the - // failed run between them has nothing protecting it. + // job 1 is the last green one, job 3 is inside both windows, and the + // failed job between them has nothing protecting it. assert.deepEqual(await artifactIds(h), ['art-1', 'art-3']); }); }); test('only the latest success is protected, not every success', async () => { - await withHarness({ retention: { artifact_keep_runs: 1, artifact_keep_days: 1 } }, async (h) => { + await withHarness({ retention: { artifact_keep_jobs: 1, artifact_keep_days: 1 } }, async (h) => { await seedRun(h, { number: 1, ageDays: 400, state: 'success' }); await seedRun(h, { number: 2, ageDays: 300, state: 'success' }); await seedRun(h, { number: 3, ageDays: 0, state: 'failed' }); @@ -218,9 +218,9 @@ test('only the latest success is protected, not every success', async () => { }); test('a project may keep artifacts forever despite a server default', async () => { - await withHarness({ retention: { artifact_keep_runs: 1, artifact_keep_days: 1 } }, async (h) => { + await withHarness({ retention: { artifact_keep_jobs: 1, artifact_keep_days: 1 } }, async (h) => { await h.services.db.run( - 'UPDATE projects SET artifact_keep_runs = 0, artifact_keep_days = 0 WHERE id = {id}', + 'UPDATE projects SET artifact_keep_jobs = 0, artifact_keep_days = 0 WHERE id = {id}', { id: h.project.id } ); @@ -237,7 +237,7 @@ test('a project may keep artifacts forever despite a server default', async () = }); test('a project setting overrides the server default', async () => { - await withHarness({ retention: { artifact_keep_runs: 0, artifact_keep_days: 365 } }, async (h) => { + await withHarness({ retention: { artifact_keep_jobs: 0, artifact_keep_days: 365 } }, async (h) => { await h.services.db.run( 'UPDATE projects SET artifact_keep_days = 7 WHERE id = {id}', { id: h.project.id } @@ -250,8 +250,8 @@ test('a project setting overrides the server default', async () => { }); }); -test('an unfinished run is never swept', async () => { - await withHarness({ retention: { artifact_keep_runs: 0, artifact_keep_days: 1 } }, async (h) => { +test('an unfinished job is never swept', async () => { + await withHarness({ retention: { artifact_keep_jobs: 0, artifact_keep_days: 1 } }, async (h) => { // Backdated far enough to be well outside the window, but still going. await seedRun(h, { number: 1, ageDays: 400, state: 'running', finished: false }); @@ -263,7 +263,7 @@ test('an unfinished run is never swept', async () => { }); }); -test('logs older than the limit are removed but the job remains', async () => { +test('logs older than the limit are removed but the task remains', async () => { await withHarness({ retention: { log_keep_days: 14 } }, async (h) => { const old = await seedRun(h, { number: 1, ageDays: 30, state: 'failed' }); const recent = await seedRun(h, { number: 2, ageDays: 1, state: 'failed' }); @@ -274,16 +274,16 @@ test('logs older than the limit are removed but the job remains', async () => { assert.equal(await objectExists(h, old.logKey), false); assert.equal(await objectExists(h, recent.logKey), true); - // The job itself is history and stays, so a run remains explainable + // The task itself is history and stays, so a job remains explainable // after its output has gone. - const job = await h.services.db.get( - 'SELECT id, log_key, log_expired_at, state FROM jobs WHERE id = {id}', - { id: old.jobId } + const task = await h.services.db.get( + 'SELECT id, log_key, log_expired_at, state FROM tasks WHERE id = {id}', + { id: old.taskId } ); - assert.ok(job, 'the job row must survive'); - assert.equal(job.log_key, null); - assert.equal(job.state, 'failed'); - assert.equal(job.log_expired_at, NOW); + assert.ok(task, 'the task row must survive'); + assert.equal(task.log_key, null); + assert.equal(task.state, 'failed'); + assert.equal(task.log_expired_at, NOW); }); }); @@ -293,7 +293,7 @@ test('a swept log is distinguishable from one that never existed', async () => { await h.services.retention.sweep({ now: NOW }); const gone = await h.services.db.get( - 'SELECT log_key, log_expired_at FROM jobs WHERE id = {id}', { id: swept.jobId } + 'SELECT log_key, log_expired_at FROM tasks WHERE id = {id}', { id: swept.taskId } ); // Both have no log_key, so the timestamp is the only thing telling // "expired" apart from "never wrote anything". @@ -325,7 +325,7 @@ test('zero keeps logs forever', async () => { }); test('a storage failure leaves the row for the next pass', async () => { - await withHarness({ retention: { artifact_keep_runs: 0, artifact_keep_days: 1 } }, async (h) => { + await withHarness({ retention: { artifact_keep_jobs: 0, artifact_keep_days: 1 } }, async (h) => { await seedRun(h, { number: 1, ageDays: 30, state: 'failed' }); const real = h.services.storage.delete; @@ -344,7 +344,7 @@ test('a storage failure leaves the row for the next pass', async () => { }); test('a sweep deletes no more than its batch', async () => { - await withHarness({ retention: { artifact_keep_runs: 0, artifact_keep_days: 1 } }, async (h) => { + await withHarness({ retention: { artifact_keep_jobs: 0, artifact_keep_days: 1 } }, async (h) => { for (let n = 1; n <= 5; n += 1) { await seedRun(h, { number: n, ageDays: 30, state: 'failed' }); } @@ -359,7 +359,7 @@ test('a sweep deletes no more than its batch', async () => { }); test('one project policy does not reach into another', async () => { - await withHarness({ retention: { artifact_keep_runs: 0, artifact_keep_days: 1 } }, async (h) => { + await withHarness({ retention: { artifact_keep_jobs: 0, artifact_keep_days: 1 } }, async (h) => { await h.services.projects.create({ id: 'other', name: 'Other', @@ -375,23 +375,23 @@ test('one project policy does not reach into another', async () => { // An old artifact under the project that keeps everything. await h.services.db.run( - `INSERT INTO runs (id, project_id, number, ref, head_sha, trigger_type, + `INSERT INTO jobs (id, project_id, number, ref, head_sha, trigger_type, state, visibility, created_at, started_at, finished_at) - VALUES ('other-run', 'other', 1, 'refs/heads/main', {sha}, 'push', + VALUES ('other-job', 'other', 1, 'refs/heads/main', {sha}, 'push', 'failed', 'public', {at}, {at}, {at})`, { sha: 'c'.repeat(40), at: NOW - 900 * DAY } ); await h.services.db.run( - `INSERT INTO jobs (id, run_id, name, base_name, image, requires, spec, state, + `INSERT INTO tasks (id, job_id, name, base_name, image, requires, spec, state, allow_failure, attempt, max_attempts, timeout, log_size, created_at, finished_at) - VALUES ('other-run:build', 'other-run', 'build', 'build', 'alpine:3', '[]', '{}', + VALUES ('other-task', 'other-job', 'build', 'build', 'alpine:3', '[]', '{}', 'failed', 0, 1, 1, 3600, 0, {at}, {at})`, { at: NOW - 900 * DAY } ); await h.services.db.run( - `INSERT INTO artifacts (id, job_id, run_id, path, storage_key, size, sha256, created_at) - VALUES ('art-other', 'other-run:build', 'other-run', 'out.txt', + `INSERT INTO artifacts (id, task_id, job_id, path, storage_key, size, sha256, created_at) + VALUES ('art-other', 'other-task', 'other-job', 'out.txt', 'artifacts/other/out.txt', 1, {sha}, {at})`, { sha: 'd'.repeat(64), at: NOW - 900 * DAY } ); @@ -404,13 +404,13 @@ test('one project policy does not reach into another', async () => { test('artifacts.expire in a pipeline reaches the stored artifact', async () => { // The key has been in the schema since the beginning and was parsed, - // stored in the job spec, and then ignored by everything. This is the + // stored in the task spec, and then ignored by everything. This is the // path from pipeline text to a deadline on the row. const pipeline = [ 'version: 1', 'defaults:', ' image: alpine:3', - 'jobs:', + 'tasks:', ' build:', " script: ['make']", ' artifacts:', @@ -428,13 +428,13 @@ test('artifacts.expire in a pipeline reaches the stored artifact', async () => { const before = Date.now(); for (const name of ['build', 'keep']) { - const res = await h.poll({}); + const res = await h.claim({}); assert.equal(res.statusCode, 200, `expected to claim ${name}`); - const job = res.json().job; + const task = res.json().task; const upload = await h.app.inject({ method: 'POST', - url: `/api/v1/workers/jobs/${encodeURIComponent(job.id)}/artifacts`, + url: `/api/v1/tasks/${task.id}/artifacts`, headers: { ...h.auth, 'content-type': 'application/octet-stream', @@ -446,9 +446,9 @@ test('artifacts.expire in a pipeline reaches the stored artifact', async () => { } const rows = await h.services.db.all( - `SELECT j.base_name AS name, a.expires_at - FROM artifacts a JOIN jobs j ON j.id = a.job_id - ORDER BY j.base_name`, + `SELECT t.base_name AS name, a.expires_at + FROM artifacts a JOIN tasks t ON t.id = a.task_id + ORDER BY t.base_name`, {} ); @@ -458,7 +458,7 @@ test('artifacts.expire in a pipeline reaches the stored artifact', async () => { assert.ok(byName.build >= before + 3600 * 1000, 'expire should be an hour out'); assert.ok(byName.build <= Date.now() + 3600 * 1000); - // The job that said nothing is left to the project policy. + // The task that said nothing is left to the project policy. assert.equal(byName.keep, null); }); }); @@ -466,7 +466,7 @@ test('artifacts.expire in a pipeline reaches the stored artifact', async () => { test('the api reports and accepts project retention', { skip: 'the JSON API was removed' }, async () => { await withHarness({ bootstrap: true, - retention: { artifact_keep_runs: 7, artifact_keep_days: 21, log_keep_days: 9 }, + retention: { artifact_keep_jobs: 7, artifact_keep_days: 21, log_keep_days: 9 }, }, async (h) => { const login = await h.app.inject({ method: 'POST', @@ -481,7 +481,7 @@ test('the api reports and accepts project retention', { skip: 'the JSON API was const defaults = await h.app.inject({ method: 'GET', url: '/api/retention', headers: auth }); assert.equal(defaults.statusCode, 200); assert.deepEqual(defaults.json().defaults, { - artifact_keep_runs: 7, + artifact_keep_jobs: 7, artifact_keep_days: 21, log_keep_days: 9, }); @@ -495,10 +495,10 @@ test('the api reports and accepts project retention', { skip: 'the JSON API was method: 'PATCH', url: `/api/projects/${h.project.id}`, headers: auth, - payload: { artifact_keep_runs: 3, artifact_keep_days: 0, log_keep_days: 30 }, + payload: { artifact_keep_jobs: 3, artifact_keep_days: 0, log_keep_days: 30 }, }); assert.equal(patched.statusCode, 200); - assert.equal(patched.json().project.artifact_keep_runs, 3); + assert.equal(patched.json().project.artifact_keep_jobs, 3); assert.equal(patched.json().project.artifact_keep_days, 0, 'zero is a value, not an absence'); assert.equal(patched.json().project.log_keep_days, 30); @@ -507,9 +507,9 @@ test('the api reports and accepts project retention', { skip: 'the JSON API was method: 'PATCH', url: `/api/projects/${h.project.id}`, headers: auth, - payload: { artifact_keep_runs: null }, + payload: { artifact_keep_jobs: null }, }); - assert.equal(cleared.json().project.artifact_keep_runs, null); + assert.equal(cleared.json().project.artifact_keep_jobs, null); const rejected = await h.app.inject({ method: 'PATCH', diff --git a/test/secretbox.test.js b/test/secretbox.test.js @@ -4,7 +4,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import crypto from 'node:crypto'; import { createSecretBox } from '../src/lib/secretbox.js'; -import { jobId, slugify, randomId, timeOrderedId, hashToken, safeEqualHex, JOB_ID_MAX } from '../src/lib/ids.js'; +import { newJobId, newTaskId, slugify, randomId, timeOrderedId, hashToken, safeEqualHex } from '../src/lib/ids.js'; const KEY = crypto.randomBytes(32); @@ -64,16 +64,19 @@ test('a key of the wrong size is refused at construction', () => { assert.throws(() => createSecretBox(crypto.randomBytes(16)), /32 byte Buffer/); }); -test('job ids stay readable and within the column width', () => { - const runId = timeOrderedId(); - assert.equal(jobId(runId, 'build'), `${runId}:build`); +test('task ids are opaque, and say nothing about their job', () => { + const jobId = newJobId(); + const ids = Array.from({ length: 200 }, () => newTaskId()); - const long = 'a'.repeat(300); - const id = jobId(runId, long); - assert.ok(id.length <= JOB_ID_MAX, `${id.length} exceeds ${JOB_ID_MAX}`); - assert.ok(id.startsWith(`${runId}:`)); - // Distinct long names must not collide after truncation. - assert.notEqual(id, jobId(runId, `${long}b`)); + for (const id of ids) { + // A worker holds one of these. It must not be able to read the job, + // the project or the task name out of it. + assert.match(id, /^[0-9a-hjkmnp-tv-z]{20}$/, `${id} should be a plain base32 id`); + assert.ok(!id.includes(jobId), 'a task id must not embed its job id'); + } + + // No name means no natural key, so uniqueness has to come from the id. + assert.equal(new Set(ids).size, ids.length, 'task ids must not collide'); }); test('time ordered ids sort by creation time', () => { diff --git a/test/self-pipeline.test.js b/test/self-pipeline.test.js @@ -1,8 +1,8 @@ -// test/self-pipeline.test.js - the pipeline this project runs on itself +// test/self-pipeline.test.js - the pipeline this project jobs on itself // // .conductor.yml is not covered by examples.test.js, and it is the one // pipeline whose breakage stops the project from building at all. It also -// encodes decisions that are easy to undo by accident: which jobs may run +// encodes decisions that are easy to undo by accident: which tasks may job // on any worker, and which need a docker socket. import test from 'node:test'; @@ -20,25 +20,25 @@ async function selfPipeline(arches = ['x86_64'], ref = 'refs/heads/main') { return compilePipeline(text, { source: '.conductor.yml', arches, ref }); } -test('the pipeline this project runs on itself compiles', async () => { - const { jobs } = await selfPipeline(); - assert.ok(jobs.length > 0, '.conductor.yml produced no jobs'); +test('the pipeline this project jobs on itself compiles', async () => { + const { tasks } = await selfPipeline(); + assert.ok(tasks.length > 0, '.conductor.yml produced no tasks'); }); -test('its jobs declare the features they actually need', async () => { - const { jobs } = await selfPipeline(); - const byName = new Map(jobs.map((job) => [job.name, job])); +test('its tasks declare the features they actually need', async () => { + const { tasks } = await selfPipeline(); + const byName = new Map(tasks.map((task) => [task.name, task])); for (const name of ['style', 'test', 'integration', 'images', 'smoke', 'publish']) { - assert.ok(byName.has(name), `expected a ${name} job`); + assert.ok(byName.has(name), `expected a ${name} task`); } - // The fast job has to actually skip the container-backed tests, or it + // The fast task has to actually skip the container-backed tests, or it // fails on any worker without a docker socket. assert.equal(byName.get('test').env.CONDUCTOR_TEST_NO_DOCKER, '1'); // Anything starting containers has to say so, otherwise it gets handed - // to a worker that cannot run it. + // to a worker that cannot job it. for (const name of ['integration', 'images', 'smoke', 'publish']) { assert.ok( byName.get(name).requires.includes('docker'), @@ -46,9 +46,9 @@ test('its jobs declare the features they actually need', async () => { ); } - // Conversely, the jobs meant to run anywhere must not demand features. + // Conversely, the tasks meant to job anywhere must not demand features. for (const name of ['style', 'test']) { - assert.deepEqual(byName.get(name).requires, [], `${name} should run on any worker`); + assert.deepEqual(byName.get(name).requires, [], `${name} should job on any worker`); } }); @@ -56,26 +56,26 @@ test('publishing is restricted to main and release tags', async () => { // The cost of getting this wrong is a branch build overwriting :latest // on Docker Hub, which is not something a test should leave to trust. const onMain = await selfPipeline(); - assert.ok(onMain.jobs.some((j) => j.name === 'publish')); + assert.ok(onMain.tasks.some((j) => j.name === 'publish')); for (const ref of ['refs/heads/feature', 'refs/heads/main-2', 'refs/pull/7/head', '']) { - const { jobs } = compilePipeline( + const { tasks } = compilePipeline( await fs.readFile(path.join(ROOT, '.conductor.yml'), 'utf8'), { source: '.conductor.yml', arches: ['x86_64'], ref }, ); assert.ok( - !jobs.some((j) => j.name === 'publish'), - `publish must not run for ${JSON.stringify(ref)}`, + !tasks.some((j) => j.name === 'publish'), + `publish must not job for ${JSON.stringify(ref)}`, ); - // The rest of the pipeline still has to run on a branch. - assert.ok(jobs.some((j) => j.name === 'test'), `test should still run for ${JSON.stringify(ref)}`); + // The rest of the pipeline still has to job on a branch. + assert.ok(tasks.some((j) => j.name === 'test'), `test should still job for ${JSON.stringify(ref)}`); } for (const ref of ['refs/heads/main', 'refs/tags/v1.2.0']) { - const { jobs } = compilePipeline( + const { tasks } = compilePipeline( await fs.readFile(path.join(ROOT, '.conductor.yml'), 'utf8'), { source: '.conductor.yml', arches: ['x86_64'], ref }, ); - assert.ok(jobs.some((j) => j.name === 'publish'), `publish should run for ${ref}`); + assert.ok(tasks.some((j) => j.name === 'publish'), `publish should job for ${ref}`); } }); diff --git a/test/storage.test.js b/test/storage.test.js @@ -1,6 +1,6 @@ // test/storage.test.js - object storage backends // -// The local backend is always exercised. The S3 backend runs against a real +// The local backend is always exercised. The S3 backend jobs against a real // MinIO, started automatically when docker is available, because the // signing code is written by hand here and a stub would happily accept a // signature a real server rejects. @@ -37,7 +37,7 @@ test('key validation rejects traversal and absolute paths', () => { for (const bad of ['../etc/passwd', '/abs', 'a//b', 'a/../b', 'a/./b', '', 'a\\b', 'a\0b']) { assert.throws(() => assertKey(bad), new RegExp('storage key'), `expected ${JSON.stringify(bad)} to be rejected`); } - assert.equal(assertKey('artifacts/run/job/dist/app.tar.gz'), 'artifacts/run/job/dist/app.tar.gz'); + assert.equal(assertKey('artifacts/job/task/dist/app.tar.gz'), 'artifacts/job/task/dist/app.tar.gz'); }); test('sanitizeRelativePath strips traversal from worker supplied paths', () => { @@ -220,7 +220,7 @@ test('s3 accepts a stream when the size is known', s3Options, async () => { test('a presigned url is usable without credentials', s3Options, async () => { const st = await s3Storage(); - const key = 'artifacts/run/job/dist/app bin+v1.tar.gz'; + const key = 'artifacts/job/task/dist/app bin+v1.tar.gz'; await st.put(key, Buffer.from('presigned content')); const url = await st.presign(key, { expires: 300 }); diff --git a/test/syntax.test.js b/test/syntax.test.js @@ -2,11 +2,11 @@ // // Most modules are covered because a test imports them, but entry points // and the dashboard are never imported by anything: the entry points start -// listeners, and the dashboard only ever runs in a browser. A syntax error +// listeners, and the dashboard only ever jobs in a browser. A syntax error // in either would otherwise reach a deployment. // // node --check parses without executing, which is what makes this safe to -// run over files that would start a server on import. +// job over files that would start a server on import. import test from 'node:test'; import assert from 'node:assert/strict'; diff --git a/test/ui.test.js b/test/ui.test.js @@ -62,7 +62,7 @@ test('attrs omits absent values and renders bare booleans', () => { // --- anonymous --- -test('an anonymous visitor sees public runs and an invitation to sign in', async () => { +test('an anonymous visitor sees public jobs and an invitation to sign in', async () => { await withUi({ visibility: 'public' }, async (h) => { await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); @@ -77,15 +77,15 @@ test('an anonymous visitor sees public runs and an invitation to sign in', async }); }); -test('an anonymous visitor cannot see a private run', async () => { +test('an anonymous visitor cannot see a private job', async () => { await withUi({ visibility: 'private' }, async (h) => { - const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; + const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id; const list = await get(h, '/'); assert.ok(!list.body.includes('#1</a>')); - const detail = await get(h, `/runs/${run}`); - assert.match(detail.body, /No such run/); + const detail = await get(h, `/jobs/${job}`); + assert.match(detail.body, /No such job/); }); }); @@ -260,7 +260,7 @@ test('the users page is administrator only and warns about what deletion destroy const page = await get(h, '/users', admin); assert.match(page.body, /dave/); // The confirmation has to say what goes with the account. - assert.match(page.body, /also deletes 1 project\(s\) and all of their run history/); + assert.match(page.body, /also deletes 1 project\(s\) and all of their job history/); const asViewer = await signIn(h, 'dave', 'dave-password'); const denied = await get(h, '/users', asViewer); @@ -270,64 +270,64 @@ test('the users page is administrator only and warns about what deletion destroy // --- live regions --- -test('a running run polls, and a finished one stops', async () => { +test('a running job polls, and a finished one stops', async () => { await withUi({ visibility: 'public' }, async (h) => { - const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; + const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id; - const running = await get(h, `/runs/${run}`); - assert.match(running.body, /hx-get="\/partials\/runs\/[^"]+\/jobs"/); + const running = await get(h, `/jobs/${job}`); + assert.match(running.body, /hx-get="\/partials\/jobs\/[^"]+\/tasks"/); assert.match(running.body, /hx-trigger="every 3s"/); - await h.services.scheduler.cancelRun(run); + await h.services.scheduler.cancelJob(job); - const finished = await get(h, `/runs/${run}`); - assert.ok(!finished.body.includes('/jobs" hx-trigger'), 'a settled run must stop polling'); + const finished = await get(h, `/jobs/${job}`); + assert.ok(!finished.body.includes('/tasks" hx-trigger'), 'a settled job must stop polling'); }); }); -test('a job page streams the log and stops polling once it completes', async () => { +test('a task page streams the log and stops polling once it completes', async () => { await withUi({ visibility: 'public' }, async (h) => { await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); - const job = (await h.poll({})).json().job; + const task = (await h.claim({})).json().task; await h.app.inject({ method: 'POST', - url: `/api/v1/workers/jobs/${encodeURIComponent(job.id)}/log`, + url: `/api/v1/tasks/${task.id}/log`, headers: { ...h.auth, 'content-type': 'application/octet-stream' }, payload: Buffer.from('compiling the thing\n'), }); - const live = await get(h, `/jobs/${encodeURIComponent(job.id)}`); + const live = await get(h, `/tasks/${encodeURIComponent(task.id)}`); assert.match(live.body, /compiling the thing/); assert.match(live.body, /hx-trigger="every 2s"/); await h.app.inject({ method: 'POST', - url: `/api/v1/workers/jobs/${encodeURIComponent(job.id)}/complete`, + url: `/api/v1/tasks/${task.id}/complete`, headers: { ...h.auth, 'content-type': 'application/json' }, payload: JSON.stringify({ success: true, exit_code: 0 }), }); - const done = await get(h, `/jobs/${encodeURIComponent(job.id)}`); + const done = await get(h, `/tasks/${encodeURIComponent(task.id)}`); assert.match(done.body, /compiling the thing/); - assert.ok(!done.body.includes('hx-trigger="every 2s"'), 'a finished job must stop polling'); + assert.ok(!done.body.includes('hx-trigger="every 2s"'), 'a finished task must stop polling'); }); }); -test('log output is escaped, so a job cannot inject markup into the page', async () => { +test('log output is escaped, so a task cannot inject markup into the page', async () => { await withUi({ visibility: 'public' }, async (h) => { await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); - const job = (await h.poll({})).json().job; + const task = (await h.claim({})).json().task; await h.app.inject({ method: 'POST', - url: `/api/v1/workers/jobs/${encodeURIComponent(job.id)}/log`, + url: `/api/v1/tasks/${task.id}/log`, headers: { ...h.auth, 'content-type': 'application/octet-stream' }, payload: Buffer.from('<img src=x onerror="alert(1)">\n'), }); - const page = await get(h, `/jobs/${encodeURIComponent(job.id)}`); - assert.ok(!page.body.includes('<img src=x'), 'job output must not become markup'); + const page = await get(h, `/tasks/${encodeURIComponent(task.id)}`); + assert.ok(!page.body.includes('<img src=x'), 'task output must not become markup'); assert.match(page.body, /&lt;img src=x/); }); }); @@ -395,7 +395,7 @@ test('the stylesheet and htmx are served, and nothing else is', async () => { test('the project page shows retention, with server defaults as placeholders', async () => { await withUi({ - retention: { artifact_keep_runs: 8, artifact_keep_days: 25, log_keep_days: 11 }, + retention: { artifact_keep_jobs: 8, artifact_keep_days: 25, log_keep_days: 11 }, }, async (h) => { const session = await signIn(h); const page = await get(h, `/projects/${h.project.id}`, session); @@ -405,7 +405,7 @@ test('the project page shows retention, with server defaults as placeholders', a // An unset field shows the default it inherits rather than an empty // box that looks like nothing is configured. - assert.match(page.body, /name="artifact_keep_runs"[^>]*placeholder="8"/); + assert.match(page.body, /name="artifact_keep_jobs"[^>]*placeholder="8"/); assert.match(page.body, /name="artifact_keep_days"[^>]*placeholder="25"/); assert.match(page.body, /name="log_keep_days"[^>]*placeholder="11"/); }); @@ -413,19 +413,19 @@ test('the project page shows retention, with server defaults as placeholders', a test('saving retention through the form stores it, and empty means inherit', async () => { await withUi({ - retention: { artifact_keep_runs: 8, artifact_keep_days: 25, log_keep_days: 11 }, + retention: { artifact_keep_jobs: 8, artifact_keep_days: 25, log_keep_days: 11 }, }, async (h) => { const session = await signIn(h); const saved = await h.app.inject({ method: 'PATCH', url: `/projects/${h.project.id}/retention`, - ...form(session, { artifact_keep_runs: '3', artifact_keep_days: '0', log_keep_days: '' }), + ...form(session, { artifact_keep_jobs: '3', artifact_keep_days: '0', log_keep_days: '' }), }); assert.ok(saved.statusCode < 400, `unexpected ${saved.statusCode}: ${saved.body}`); const project = await h.services.projects.get(h.project.id); - assert.equal(project.artifact_keep_runs, 3); + assert.equal(project.artifact_keep_jobs, 3); // Zero and empty have to end up different, or a project cannot say // "keep forever" as distinct from "use the default". assert.equal(project.artifact_keep_days, 0); diff --git a/test/worker.test.js b/test/worker.test.js @@ -13,7 +13,7 @@ import { loadWorkerConfig, featureNames } from '../src/worker/config.js'; import { buildScript, shellQuote } from '../src/worker/script.js'; import { containerName, createRuntime } from '../src/worker/docker.js'; import { createLogStream } from '../src/worker/logstream.js'; -import { resolveFeatures, patternPrefix, copyOutPaths } from '../src/worker/job.js'; +import { resolveFeatures, patternPrefix, copyOutPaths } from '../src/worker/task.js'; import { collectArtifacts, shouldCollect } from '../src/worker/artifacts.js'; async function tempDir(prefix = 'conductor-worker-') { @@ -132,12 +132,12 @@ test('container names are valid for docker and stay unique', () => { const b = containerName('conductor', 'run1:package:arch=x86_64,pkg=busybox'); assert.match(a, /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/); assert.notEqual(a, b); - // Distinct jobs that differ only past the truncation point still differ. + // Distinct tasks that differ only past the truncation point still differ. const long = 'x'.repeat(200); assert.notEqual(containerName('c', `${long}a`), containerName('c', `${long}b`)); }); -test('run arguments carry mounts, env, network and privilege', () => { +test('job arguments carry mounts, env, network and privilege', () => { const runtime = createRuntime({ docker: 'docker', shell: 'sh' }); const args = runtime.buildRunArgs({ name: 'job1', @@ -162,7 +162,7 @@ test('run arguments carry mounts, env, network and privilege', () => { assert.ok(args.indexOf('alpine:3') < args.indexOf('/tmp/entrypoint.sh')); }); -test('a job container is created without sharing anything with the worker', () => { +test('a task container is created without sharing anything with the worker', () => { // The whole point of copying the tree in: no path of the worker's is // handed to the daemon, so nothing has to line up between the two. const runtime = createRuntime({ docker: 'docker', shell: 'sh' }); @@ -415,7 +415,7 @@ test('a secret split across two writes is still masked', async () => { assert.ok(client.text.includes('[masked]')); }); -test('a failing conductor does not break the job', async () => { +test('a failing conductor does not break the task', async () => { const client = { async appendLog() { throw new Error('conductor unreachable');