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:
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, /<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');