conductor.test.js (68547B)
1 // test/conductor.test.js - end to end behaviour of the conductor service 2 // 3 // Drives a real server against a real git repository: trigger, dispatch, 4 // logs, artifacts, completion and failure handling. 5 6 import test from 'node:test'; 7 import assert from 'node:assert/strict'; 8 import { startHarness, DEFAULT_PIPELINE, data, apiError } from './helpers/harness.js'; 9 10 // Each test gets its own harness so that ordering never matters. 11 async function withHarness(options, fn) { 12 const h = await startHarness(options); 13 try { 14 return await fn(h); 15 } finally { 16 await h.stop(); 17 } 18 } 19 20 function tasksByName(payload) { 21 return Object.fromEntries(payload.tasks.map((j) => [j.name, j])); 22 } 23 24 // Straight from the database, so a test can assert on what the API chooses 25 // not to publish. 26 async function jobState(h, jobId) { 27 const job = await h.services.db.get('SELECT * FROM jobs WHERE id = {id}', { id: jobId }); 28 const tasks = await h.services.db.all( 29 'SELECT * FROM tasks WHERE job_id = {job} ORDER BY name', { job: jobId } 30 ); 31 const deps = await h.services.db.all( 32 `SELECT d.task_id, d.depends_on_id, d.kind FROM task_deps d 33 JOIN tasks t ON t.id = d.task_id WHERE t.job_id = {job}`, 34 { job: jobId } 35 ); 36 const edges = new Map(tasks.map((t) => [t.id, { needs: [], after: [] }])); 37 for (const d of deps) edges.get(d.task_id)?.[d.kind === 'after' ? 'after' : 'needs'].push(d.depends_on_id); 38 return { job, tasks: tasks.map((t) => ({ ...t, ...(edges.get(t.id) ?? { needs: [], after: [] }) })) }; 39 } 40 41 // The job a task belongs to. A worker is never told this, so a test that 42 // needs it asks the database, exactly as the conductor does. 43 async function jobIdOf(h, taskId) { 44 const row = await h.services.db.get('SELECT job_id FROM tasks WHERE id = {id}', { id: taskId }); 45 return row.job_id; 46 } 47 48 // Claims everything currently eligible for a worker with the given 49 // capabilities, keyed by task name. Tests assert on which tasks appear rather 50 // than on the order they arrive in, which is not part of the contract. 51 async function drain(h, query = {}) { 52 const claimed = {}; 53 for (let i = 0; i < 50; i += 1) { 54 const res = await h.claim(query); 55 if (res.statusCode === 204) break; 56 const task = res.json().data.task; 57 claimed[task.name] = task; 58 } 59 return claimed; 60 } 61 62 async function claimNamed(h, name, query) { 63 return (await drain(h, query))[name] ?? null; 64 } 65 66 async function finishRaw(h, taskId, { 67 success = true, state = null, exitCode = 0, error = null, outputs = [], 68 } = {}) { 69 return h.app.inject({ 70 method: 'POST', 71 url: `/api/v1/tasks/${taskId}/complete`, 72 headers: { ...h.auth, 'content-type': 'application/json' }, 73 payload: JSON.stringify({ success, state, exit_code: exitCode, error, outputs }), 74 }); 75 } 76 77 async function finish(h, taskId, opts) { 78 return data(await finishRaw(h, taskId, opts)); 79 } 80 81 test('health reports the selected drivers', async () => { 82 await withHarness({}, async (h) => { 83 const res = await h.app.inject({ method: 'GET', url: '/health' }); 84 assert.equal(res.statusCode, 200); 85 assert.deepEqual(data(res), { 86 service: 'conductor', database: 'sqlite', storage: 'local', auth: 'local', 87 }); 88 }); 89 }); 90 91 test('a signed trigger creates a job and expands the pipeline', async () => { 92 await withHarness({}, async (h) => { 93 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main', actor: 'alice' }); 94 assert.equal(res.statusCode, 200); 95 96 const body = data(res); 97 assert.equal(body.status, 'created'); 98 99 const job = await h.waitForJob(body.job_id); 100 assert.equal(job.state, 'running'); 101 assert.equal(job.actor, 'alice'); 102 assert.equal(job.title, 'initial commit'); 103 assert.equal(job.number, 1); 104 105 const detail = await jobState(h, body.job_id); 106 // lint + 2 build + 2 package + publish 107 assert.equal(detail.tasks.length, 6); 108 109 // Task ids are opaque, so the edge is checked by resolving the name 110 // rather than reconstructing an id the conductor no longer derives. 111 const tasks = tasksByName(detail); 112 assert.deepEqual( 113 tasks['package:arch=x86_64,pkg=musl'].needs, 114 [tasks['build:arch=x86_64'].id] 115 ); 116 assert.equal(tasks.publish.needs.length, 3); 117 }); 118 }); 119 120 test('an unsigned or wrongly signed trigger is refused', async () => { 121 await withHarness({}, async (h) => { 122 const unsigned = await h.app.inject({ 123 method: 'POST', 124 url: '/api/v1/projects/demo/trigger', 125 headers: { 'content-type': 'application/json' }, 126 payload: JSON.stringify({ sha: h.sha }), 127 }); 128 assert.equal(unsigned.statusCode, 401); 129 130 const wrong = await h.trigger({ sha: h.sha }, { secret: 'not-the-secret' }); 131 assert.equal(wrong.statusCode, 401); 132 }); 133 }); 134 135 test('github and gitlab push payloads are understood', async () => { 136 await withHarness({}, async (h) => { 137 const github = await h.trigger({ 138 after: h.sha, 139 before: '0'.repeat(40), 140 ref: 'refs/heads/main', 141 pusher: { name: 'octocat' }, 142 }); 143 assert.equal(github.statusCode, 200); 144 const githubJob = await h.waitForJob(github.json().data.job_id); 145 assert.equal(githubJob.state, 'running'); 146 147 const gitlab = await h.trigger({ 148 checkout_sha: h.sha, 149 ref: 'refs/heads/main', 150 user_username: 'gl-user', 151 }); 152 assert.equal(gitlab.statusCode, 200); 153 const gitlabJob = await h.waitForJob(gitlab.json().data.job_id); 154 assert.equal(gitlabJob.state, 'running'); 155 }); 156 }); 157 158 test('a branch deletion is ignored rather than failing', async () => { 159 await withHarness({}, async (h) => { 160 const res = await h.trigger({ sha: '0'.repeat(40), ref: 'refs/heads/gone' }); 161 assert.equal(res.statusCode, 200); 162 assert.equal(res.json().data.status, 'ignored'); 163 }); 164 }); 165 166 test('a broken pipeline is reported as a client error with detail', async () => { 167 await withHarness({ pipeline: 'version: 1\ntasks:\n a:\n script: [x]\n' }, async (h) => { 168 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 169 assert.equal(res.statusCode, 200); 170 const job = await h.waitForJob(res.json().data.job_id); 171 assert.equal(job.state, 'failed'); 172 assert.ok(job.error?.includes('tasks.a.image')); 173 }); 174 }); 175 176 test('a missing pipeline file is reported clearly', async () => { 177 await withHarness({ pipeline: null }, async (h) => { 178 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 179 assert.equal(res.statusCode, 200); 180 const job = await h.waitForJob(res.json().data.job_id); 181 assert.equal(job.state, 'failed'); 182 assert.match(job.error ?? '', /\.conductor\.yml not found/); 183 }); 184 }); 185 186 test('a pipeline may be spelled .conductor.yaml instead of .conductor.yml', async () => { 187 await withHarness({ repoConfigPath: '.conductor.yaml' }, async (h) => { 188 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 189 assert.equal(res.statusCode, 200); 190 const job = await h.waitForJob(res.json().data.job_id); 191 assert.equal(job.state, 'running'); 192 }); 193 }); 194 195 test('a project pointed at a specific file gets no fallback', async () => { 196 await withHarness({ configPath: 'ci/build.yml', repoConfigPath: '.conductor.yml' }, async (h) => { 197 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 198 assert.equal(res.statusCode, 200); 199 const job = await h.waitForJob(res.json().data.job_id); 200 assert.equal(job.state, 'failed'); 201 assert.match(job.error ?? '', /ci\/build\.yml not found/); 202 }); 203 }); 204 205 test('a repository carrying both spellings is refused rather than guessed at', async () => { 206 await withHarness({}, async (h) => { 207 const sha = await h.commit({ '.conductor.yaml': DEFAULT_PIPELINE }, 'add the other spelling'); 208 const res = await h.trigger({ sha, ref: 'refs/heads/main' }); 209 assert.equal(res.statusCode, 200); 210 const job = await h.waitForJob(res.json().data.job_id); 211 assert.equal(job.state, 'failed'); 212 assert.match(job.error ?? '', /both exist/); 213 }); 214 }); 215 216 test('a pipeline still using the jobs key is told what it was renamed to', async () => { 217 const old = 'version: 1\njobs:\n build:\n image: alpine\n script: [make]\n'; 218 await withHarness({}, async (h) => { 219 const sha = await h.commit({ '.conductor.yml': old }, 'pre-rename pipeline'); 220 const res = await h.trigger({ sha, ref: 'refs/heads/main' }); 221 assert.equal(res.statusCode, 200); 222 const job = await h.waitForJob(res.json().data.job_id); 223 assert.equal(job.state, 'failed'); 224 assert.match(job.error ?? '', /was renamed to tasks/); 225 }); 226 }); 227 228 test('a job can be created directly, without a push payload to imitate', async () => { 229 await withHarness({}, async (h) => { 230 const res = await h.createJob({ sha: h.sha, ref: 'refs/heads/main', actor: 'alice' }); 231 assert.equal(res.statusCode, 200); 232 233 const body = data(res); 234 assert.equal(body.status, 'created'); 235 236 const job = await h.waitForJob(body.job_id); 237 assert.equal(job.state, 'running'); 238 assert.equal(job.actor, 'alice'); 239 assert.equal(job.trigger_type, 'api'); 240 }); 241 }); 242 243 test('creating a job requires the same signature as a trigger', async () => { 244 await withHarness({}, async (h) => { 245 const unsigned = await h.app.inject({ 246 method: 'POST', 247 url: '/api/v1/projects/demo/jobs', 248 headers: { 'content-type': 'application/json' }, 249 payload: JSON.stringify({ sha: h.sha }), 250 }); 251 assert.equal(unsigned.statusCode, 401); 252 253 const wrong = await h.createJob({ sha: h.sha }, { secret: 'not-the-secret' }); 254 assert.equal(wrong.statusCode, 401); 255 }); 256 }); 257 258 test('creating a job insists on a real commit rather than inferring one', async () => { 259 await withHarness({}, async (h) => { 260 assert.equal((await h.createJob({ ref: 'refs/heads/main' })).statusCode, 400); 261 assert.equal((await h.createJob({ sha: 'abc' })).statusCode, 400); 262 }); 263 }); 264 265 test('a job can be read back, with the state of every task', async () => { 266 await withHarness({}, async (h) => { 267 const jobId = (await h.createJob({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; 268 await h.waitForJob(jobId); 269 270 const signature = h.sign(''); 271 const headers = { 'content-type': 'application/json', 'x-hub-signature-256': signature }; 272 273 const signed = await h.app.inject({ 274 method: 'GET', 275 url: `/api/v1/projects/demo/jobs/${jobId}`, 276 headers, 277 }); 278 assert.equal(signed.statusCode, 200, signed.body); 279 assert.equal(data(signed).id, jobId); 280 assert.equal(data(signed).state, 'running'); 281 282 const tasks = await h.app.inject({ method: 'GET', url: `/api/v1/jobs/${jobId}/tasks`, headers }); 283 const body = data(tasks); 284 assert.equal(body.tasks.length, 6); 285 assert.ok(body.tasks.every((t) => t.state === 'queued')); 286 assert.ok(body.tasks.some((t) => t.name === 'build:arch=x86_64')); 287 }); 288 }); 289 290 test('a public job is readable without any credential at all', async () => { 291 await withHarness({}, async (h) => { 292 const jobId = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; 293 await h.waitForJob(jobId); 294 295 const res = await h.app.inject({ method: 'GET', url: `/api/v1/projects/demo/jobs/${jobId}` }); 296 assert.equal(res.statusCode, 200, res.body); 297 assert.equal(res.json().data.visibility, 'public'); 298 }); 299 }); 300 301 test('a private job is absent to anyone without a session', async () => { 302 await withHarness({ visibility: 'private', bootstrap: true }, async (h) => { 303 const jobId = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; 304 await h.waitForJob(jobId); 305 306 const anon = await h.app.inject({ method: 'GET', url: `/api/v1/projects/demo/jobs/${jobId}` }); 307 assert.equal(anon.statusCode, 404); 308 309 // The trigger secret starts builds; it does not read them back. 310 const signed = await h.app.inject({ 311 method: 'GET', 312 url: `/api/v1/projects/demo/jobs/${jobId}`, 313 headers: { 'content-type': 'application/json', 'x-hub-signature-256': h.sign('') }, 314 }); 315 assert.equal(signed.statusCode, 404, signed.body); 316 317 const { headers } = await h.login(); 318 const admin = await h.app.inject({ method: 'GET', url: `/api/v1/projects/demo/jobs/${jobId}`, headers }); 319 assert.equal(admin.statusCode, 200, admin.body); 320 assert.equal(admin.json().data.visibility, 'private'); 321 }); 322 }); 323 324 test('a job of another project cannot be read through this one', async () => { 325 await withHarness({}, async (h) => { 326 const jobId = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; 327 await h.waitForJob(jobId); 328 const signature = h.sign(''); 329 const res = await h.app.inject({ 330 method: 'GET', 331 url: `/api/v1/projects/absent/jobs/${jobId}`, 332 headers: { 'content-type': 'application/json', 'x-hub-signature-256': signature }, 333 }); 334 assert.equal(res.statusCode, 404); 335 }); 336 }); 337 338 // --- reading a task back --- 339 340 // The two paths answer identically; only what may reach a private task 341 // differs between them. 342 function taskUrls(taskId, project = 'demo') { 343 return [`/api/v1/tasks/${taskId}`, `/api/v1/projects/${project}/tasks/${taskId}`]; 344 } 345 346 // package needs build, so nothing downstream is claimable until the first 347 // wave has finished. 348 async function claimDownstream(h, name) { 349 for (const task of Object.values(await drain(h, { arches: 'x86_64,aarch64' }))) { 350 await finish(h, task.id); 351 } 352 return claimNamed(h, name, { arches: 'x86_64', features: 'sign-key' }); 353 } 354 355 test('a task can be read back on its own, and by its full name', async () => { 356 await withHarness({}, async (h) => { 357 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 358 await h.waitForJob(res.json().data.job_id); 359 const task = await claimNamed(h, 'build:arch=x86_64', { arches: 'x86_64' }); 360 361 const [bare, scoped] = await Promise.all( 362 taskUrls(task.id).map((url) => h.app.inject({ method: 'GET', url })) 363 ); 364 assert.equal(bare.statusCode, 200, bare.body); 365 assert.equal(scoped.statusCode, 200, scoped.body); 366 assert.deepEqual(data(bare), data(scoped), 'both paths describe the same task'); 367 368 const { task: body } = data(bare); 369 assert.equal(body.id, task.id); 370 assert.equal(body.name, 'build:arch=x86_64'); 371 assert.equal(body.base_name, 'build'); 372 assert.equal(body.state, 'running'); 373 assert.equal(body.arch, 'x86_64'); 374 assert.equal(body.image, 'debian:bookworm-slim'); 375 assert.equal(body.visibility, 'public'); 376 assert.equal(body.sha, h.sha); 377 assert.equal(body.ref, 'refs/heads/main'); 378 assert.equal(body.project_id, 'demo'); 379 assert.equal(body.job_id, await jobIdOf(h, task.id)); 380 assert.equal(body.job_number, 1); 381 assert.equal(body.allow_failure, false, 'stored as 0 or 1, reported as a boolean'); 382 assert.deepEqual(body.artifact_paths, ['dist/**'], 'what it was told to collect'); 383 }); 384 }); 385 386 test('reading a task back carries its dependencies and matrix', async () => { 387 await withHarness({}, async (h) => { 388 const tr = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 389 await h.waitForJob(tr.json().data.job_id); 390 const task = await claimDownstream(h, 'package:arch=x86_64,pkg=musl'); 391 392 const res = await h.app.inject({ method: 'GET', url: `/api/v1/tasks/${task.id}` }); 393 assert.equal(res.statusCode, 200, res.body); 394 395 const { task: body } = data(res); 396 assert.deepEqual(body.matrix, { pkg: 'musl' }); 397 assert.deepEqual(body.needs, ['build:arch=x86_64']); 398 assert.equal(body.workdir, '/work', 'settled when the job was created'); 399 }); 400 }); 401 402 // The point of the endpoint: a task's environment can hold project 403 // variables, and its services carry an environment of their own. 404 test('reading a task back never exposes its environment, services or script', async () => { 405 await withHarness({}, async (h) => { 406 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 407 await h.waitForJob(res.json().data.job_id); 408 const task = await claimDownstream(h, 'package:arch=x86_64,pkg=musl'); 409 410 // The claim payload has them, which is what makes their absence below 411 // meaningful rather than incidental. 412 assert.ok(task.env.MATRIX_PKG, 'the worker is given the environment'); 413 assert.ok(Array.isArray(task.script) && task.script.length > 0); 414 415 for (const url of taskUrls(task.id)) { 416 const res = await h.app.inject({ method: 'GET', url }); 417 assert.equal(res.statusCode, 200, res.body); 418 419 const payload = data(res); 420 // Asserted before the loop below, which would otherwise pass happily 421 // against a payload that has no task in it at all. 422 assert.ok(payload.task, `${url} must describe a task`); 423 for (const key of ['env', 'services', 'script', 'spec', 'worker_token_id', 'log_key', 'storage_key']) { 424 assert.equal(payload.task[key], undefined, `${url} must not carry ${key}`); 425 } 426 // Belt and braces: a value smuggled in under any other name. 427 assert.ok(!JSON.stringify(payload).includes('MATRIX_PKG'), `${url} leaked an environment name`); 428 assert.ok(!JSON.stringify(payload).includes('./pkg.sh'), `${url} leaked the script`); 429 } 430 }); 431 }); 432 433 test('a task lists the artifacts it stored, with a usable download url', async () => { 434 await withHarness({}, async (h) => { 435 const tr = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 436 await h.waitForJob(tr.json().data.job_id); 437 const task = await claimNamed(h, 'build:arch=x86_64', { arches: 'x86_64' }); 438 439 await h.app.inject({ 440 method: 'POST', 441 url: `/api/v1/tasks/${task.id}/artifacts`, 442 headers: { ...h.auth, 'content-type': 'application/octet-stream', 'x-artifact-path': 'dist/app' }, 443 payload: Buffer.from('artifact bytes'), 444 }); 445 446 const res = await h.app.inject({ method: 'GET', url: `/api/v1/tasks/${task.id}/artifacts` }); 447 assert.equal(res.statusCode, 200, res.body); 448 449 const artifacts = data(res); 450 assert.equal(artifacts.length, 1); 451 assert.equal(artifacts[0].path, 'dist/app'); 452 assert.equal(artifacts[0].size, 14); 453 assert.match(artifacts[0].sha256, /^[0-9a-f]{64}$/); 454 assert.ok(artifacts[0].url.startsWith('http://conductor.test/'), artifacts[0].url); 455 456 // The url is the whole point of returning one, so follow it. 457 const download = await h.app.inject({ method: 'GET', url: new URL(artifacts[0].url).pathname }); 458 assert.equal(download.statusCode, 200); 459 assert.equal(download.body, 'artifact bytes'); 460 }); 461 }); 462 463 test('outputs are a page of their own, with masked values withheld', async () => { 464 await withHarness({}, async (h) => { 465 const tr = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 466 await h.waitForJob(tr.json().data.job_id); 467 const task = await claimNamed(h, 'lint', {}); 468 469 await h.services.db.run( 470 `INSERT INTO task_outputs (task_id, name, value, masked, created_at) 471 VALUES ({t}, 'version', {v}, 0, {now})`, 472 { t: task.id, v: h.services.secrets.seal('1.2.3', `task:${task.id}/output:version`), now: Date.now() } 473 ); 474 await h.services.db.run( 475 `INSERT INTO task_outputs (task_id, name, value, masked, created_at) 476 VALUES ({t}, 'password', {v}, 1, {now})`, 477 { t: task.id, v: h.services.secrets.seal('hunter2', `task:${task.id}/output:password`), now: Date.now() } 478 ); 479 480 const res = await h.app.inject({ method: 'GET', url: `/api/v1/tasks/${task.id}/outputs` }); 481 assert.equal(res.statusCode, 200, res.body); 482 483 const outputs = Object.fromEntries(data(res).map((o) => [o.name, o])); 484 assert.equal(outputs.version.value, '1.2.3'); 485 assert.equal(outputs.password.masked, true); 486 assert.equal(outputs.password.value, null); 487 assert.ok(!res.body.includes('hunter2')); 488 assert.equal(res.json().pagination.total, 2); 489 }); 490 }); 491 492 test('a log is read by byte offset and reports the total size', async () => { 493 await withHarness({}, async (h) => { 494 const tr = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 495 await h.waitForJob(tr.json().data.job_id); 496 const task = await claimNamed(h, 'lint', {}); 497 498 await h.app.inject({ 499 method: 'POST', 500 url: `/api/v1/tasks/${task.id}/log`, 501 headers: { ...h.auth, 'content-type': 'application/octet-stream', 'x-log-offset': '0' }, 502 payload: Buffer.from('hello world\n'), 503 }); 504 505 const whole = data(await h.app.inject({ method: 'GET', url: `/api/v1/tasks/${task.id}/log` })); 506 assert.equal(whole.data, 'hello world\n'); 507 assert.equal(whole.size, 12); 508 assert.equal(whole.offset, 0); 509 assert.equal(whole.expired, false); 510 511 // A tail asks from where it stopped and is given only what is new. 512 const tail = data(await h.app.inject({ method: 'GET', url: `/api/v1/tasks/${task.id}/log?offset=6` })); 513 assert.equal(tail.data, 'world\n'); 514 assert.equal(tail.offset, 6); 515 assert.equal(tail.size, 12); 516 517 // Asking from the end is how a caller learns there is nothing new. 518 const caught = data(await h.app.inject({ method: 'GET', url: `/api/v1/tasks/${task.id}/log?offset=12` })); 519 assert.equal(caught.data, ''); 520 assert.equal(caught.size, 12); 521 }); 522 }); 523 524 test('a swept log reports that it expired rather than that it is empty', async () => { 525 await withHarness({}, async (h) => { 526 const tr = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 527 await h.waitForJob(tr.json().data.job_id); 528 const task = await claimNamed(h, 'lint', {}); 529 530 await h.services.db.run( 531 'UPDATE tasks SET log_expired_at = {now} WHERE id = {id}', 532 { id: task.id, now: Date.now() } 533 ); 534 535 const log = data(await h.app.inject({ method: 'GET', url: `/api/v1/tasks/${task.id}/log` })); 536 assert.equal(log.expired, true); 537 538 const detail = data(await h.app.inject({ method: 'GET', url: `/api/v1/tasks/${task.id}` })); 539 assert.equal(detail.task.log_expired, true); 540 }); 541 }); 542 543 test('reading a task back needs no worker token', async () => { 544 await withHarness({}, async (h) => { 545 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 546 await h.waitForJob(res.json().data.job_id); 547 const task = await claimNamed(h, 'lint', {}); 548 549 // The worker protocol shares this prefix and puts a token hook over its 550 // own plugin. If that hook ever reaches these routes, this fails. 551 for (const url of taskUrls(task.id)) { 552 const res = await h.app.inject({ method: 'GET', url }); 553 assert.equal(res.statusCode, 200, `${url} should not require a token`); 554 } 555 }); 556 }); 557 558 test('a private task is absent without a session, by either path', async () => { 559 await withHarness({ visibility: 'private' }, async (h) => { 560 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 561 await h.waitForJob(res.json().data.job_id); 562 const task = await claimNamed(h, 'lint', {}); 563 564 for (const url of taskUrls(task.id)) { 565 const anon = await h.app.inject({ method: 'GET', url }); 566 assert.equal(anon.statusCode, 404, `${url} should be absent anonymously`); 567 } 568 569 // A trigger secret reads nothing, on either path. 570 const signed = { 'content-type': 'application/json', 'x-hub-signature-256': h.sign('') }; 571 for (const url of taskUrls(task.id)) { 572 const res2 = await h.app.inject({ method: 'GET', url, headers: signed }); 573 assert.equal(res2.statusCode, 404, `${url} should not accept a trigger secret`); 574 } 575 }); 576 }); 577 578 test('a private task is readable by an administrator, by either path', async () => { 579 // scrypt is slow, so the harness only creates the admin when asked. 580 await withHarness({ visibility: 'private', bootstrap: true }, async (h) => { 581 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 582 await h.waitForJob(res.json().data.job_id); 583 const task = await claimNamed(h, 'lint', {}); 584 const { headers } = await h.login(); 585 586 for (const url of taskUrls(task.id)) { 587 const res = await h.app.inject({ method: 'GET', url, headers }); 588 assert.equal(res.statusCode, 200, `${url}: ${res.body}`); 589 assert.equal(res.json().data.task.id, task.id); 590 } 591 }); 592 }); 593 594 test('a task of another project cannot be read through this one', async () => { 595 await withHarness({}, async (h) => { 596 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 597 await h.waitForJob(res.json().data.job_id); 598 const task = await claimNamed(h, 'lint', {}); 599 600 const res2 = await h.app.inject({ 601 method: 'GET', 602 url: `/api/v1/projects/absent/tasks/${task.id}`, 603 headers: { 'content-type': 'application/json', 'x-hub-signature-256': h.sign('') }, 604 }); 605 assert.equal(res2.statusCode, 404); 606 }); 607 }); 608 609 test('an unknown task is 404 by either path', async () => { 610 await withHarness({}, async (h) => { 611 for (const url of taskUrls('m2y0000000000000nope')) { 612 const res = await h.app.inject({ method: 'GET', url }); 613 assert.equal(res.statusCode, 404, `${url} should be 404`); 614 assert.equal(apiError(res).name, 'UnknownTask'); 615 } 616 }); 617 }); 618 619 test('a claim records what the machine advertises, even when there is no work', async () => { 620 await withHarness({}, async (h) => { 621 const idle = await h.claim({ name: 'builder-01', arches: ['x86_64'], features: ['unos-sign-keys'] }); 622 assert.equal(idle.statusCode, 204, 'nothing is queued yet'); 623 624 const agents = await h.services.workerTokens.agentsFor(h.worker.id); 625 assert.equal(agents.length, 1); 626 assert.equal(agents[0].name, 'builder-01'); 627 assert.deepEqual(agents[0].features, ['unos-sign-keys']); 628 assert.deepEqual(agents[0].arches, ['x86_64']); 629 }); 630 }); 631 632 test('a machine that changes its features updates its row rather than adding one', async () => { 633 await withHarness({}, async (h) => { 634 await h.claim({ name: 'builder-01', features: ['dind'] }); 635 await h.claim({ name: 'builder-01', features: ['dind', 'unos-sign-keys'] }); 636 637 const agents = await h.services.workerTokens.agentsFor(h.worker.id); 638 assert.equal(agents.length, 1, 'one machine is one row'); 639 assert.deepEqual(agents[0].features, ['dind', 'unos-sign-keys']); 640 }); 641 }); 642 643 test('machines sharing a token are recorded separately', async () => { 644 await withHarness({}, async (h) => { 645 await h.claim({ name: 'builder-01', features: ['unos-sign-keys'] }); 646 await h.claim({ name: 'builder-02', features: [] }); 647 648 const agents = await h.services.workerTokens.agentsFor(h.worker.id); 649 assert.deepEqual(agents.map((a) => a.name).sort(), ['builder-01', 'builder-02']); 650 }); 651 }); 652 653 test('the worker api rejects missing and invalid tokens', async () => { 654 await withHarness({}, async (h) => { 655 const none = await h.app.inject({ method: 'POST', url: '/api/v1/tasks/claim' }); 656 assert.equal(none.statusCode, 401); 657 658 const bad = await h.app.inject({ 659 method: 'POST', url: '/api/v1/tasks/claim', headers: { authorization: 'Bearer nope' }, 660 }); 661 assert.equal(bad.statusCode, 401); 662 }); 663 }); 664 665 test('independent tasks dispatch concurrently', async () => { 666 await withHarness({}, async (h) => { 667 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 668 await h.waitForJob(res.json().data.job_id); 669 670 // The prototype could only ever hand out one task at a time. 671 const [a, b, c] = await Promise.all([ 672 h.claim({ arches: 'x86_64,aarch64' }), 673 h.claim({ arches: 'x86_64,aarch64' }), 674 h.claim({ arches: 'x86_64,aarch64' }), 675 ]); 676 677 const names = [a, b, c].map((r) => r.json().data.task.name); 678 assert.equal(new Set(names).size, 3, `expected three distinct tasks, got ${names.join(', ')}`); 679 assert.deepEqual(names.slice().sort(), ['build:arch=aarch64', 'build:arch=x86_64', 'lint']); 680 }); 681 }); 682 683 test('a task is only offered to a worker that satisfies its requires', async () => { 684 await withHarness({}, async (h) => { 685 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 686 await h.waitForJob(res.json().data.job_id); 687 688 // Clear the three root tasks so only package and publish remain. 689 const roots = await drain(h, { arches: 'x86_64,aarch64' }); 690 assert.deepEqual(Object.keys(roots).sort(), ['build:arch=aarch64', 'build:arch=x86_64', 'lint']); 691 for (const task of Object.values(roots)) await finish(h, task.id, { success: true }); 692 693 // package requires sign-key, which this worker does not advertise. 694 const without = await h.claim({ arches: 'x86_64,aarch64' }); 695 assert.equal(without.statusCode, 204); 696 697 const with_ = await h.claim({ arches: 'x86_64,aarch64', features: 'sign-key' }); 698 assert.equal(with_.statusCode, 200); 699 assert.match(with_.json().data.task.name, /^package:/); 700 }); 701 }); 702 703 test('a worker is not offered work for an architecture it cannot build', async () => { 704 await withHarness({}, async (h) => { 705 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 706 await h.waitForJob(res.json().data.job_id); 707 708 const claimed = []; 709 for (let i = 0; i < 3; i += 1) { 710 const res = await h.claim({ arches: 'aarch64' }); 711 if (res.statusCode === 204) break; 712 claimed.push(res.json().data.task); 713 } 714 715 // lint has no arch so it is eligible; the x86_64 build is not. 716 assert.ok(!claimed.some((j) => j.arch === 'x86_64')); 717 assert.ok(claimed.some((j) => j.name === 'build:arch=aarch64')); 718 }); 719 }); 720 721 test('a claimed task carries everything the worker needs', async () => { 722 await withHarness({}, async (h) => { 723 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 724 await h.waitForJob(res.json().data.job_id); 725 const task = await claimNamed(h, 'build:arch=x86_64', { arches: 'x86_64' }); 726 727 assert.equal(task.image, 'debian:bookworm-slim'); 728 assert.deepEqual(task.script, ['./build.sh $ARCH']); 729 assert.equal(task.env.ARCH, 'x86_64'); 730 assert.equal(task.sha, h.sha); 731 assert.match(task.endpoints.source, /^http:\/\/conductor\.test\/api\/v1\/tasks\//); 732 assert.ok(task.endpoints.source.endsWith('/source.tar.gz')); 733 assert.ok(task.endpoints.log.endsWith('/log')); 734 735 // The worker resolves features from these, so a missing field 736 // silently disables them. 737 assert.deepEqual(task.requires, []); 738 assert.ok(Number.isInteger(task.heartbeat_interval) && task.heartbeat_interval > 0); 739 assert.deepEqual(task.masked, []); 740 741 // A worker is given a context and a script. Which project or job the 742 // work belongs to is none of its business, and the id it holds carries 743 // no structure to read it out of either. 744 assert.equal(task.project_id, undefined); 745 assert.equal(task.job_id, undefined); 746 assert.ok(!task.id.includes(':'), `task id should be opaque, got ${task.id}`); 747 }); 748 }); 749 750 test('a task that requires a feature reports it to the worker', async () => { 751 await withHarness({}, async (h) => { 752 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 753 await h.waitForJob(res.json().data.job_id); 754 const roots = await drain(h, { arches: 'x86_64,aarch64' }); 755 for (const task of Object.values(roots)) await finish(h, task.id, { success: true }); 756 757 const pkg = (await drain(h, { arches: 'x86_64', features: 'sign-key' }))['package:arch=x86_64,pkg=musl']; 758 assert.ok(pkg); 759 assert.deepEqual(pkg.requires, ['sign-key']); 760 }); 761 }); 762 763 test('the standard conductor variables are present in the task environment', async () => { 764 await withHarness({}, async (h) => { 765 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; 766 await h.waitForJob(job); 767 const task = await claimNamed(h, 'lint', {}); 768 769 // The worker is not told any of this, but the script is: these reach 770 // the container as ordinary environment, which is the only place the 771 // build legitimately needs them. 772 assert.equal(task.env.CONDUCTOR_PROJECT, 'demo'); 773 assert.equal(task.env.CONDUCTOR_JOB_ID, job); 774 assert.equal(task.env.CONDUCTOR_JOB_NUMBER, '1'); 775 assert.equal(task.env.CONDUCTOR_TASK, 'lint'); 776 assert.equal(task.env.CONDUCTOR_TASK_ID, task.id); 777 assert.equal(task.env.CONDUCTOR_SHA, h.sha); 778 assert.equal(task.env.CONDUCTOR_REF, 'refs/heads/main'); 779 assert.equal(task.env.CONDUCTOR_ATTEMPT, '1'); 780 }); 781 }); 782 783 test('the source tarball is served from the mirror', async () => { 784 await withHarness({}, async (h) => { 785 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 786 await h.waitForJob(res.json().data.job_id); 787 const task = await claimNamed(h, 'lint', {}); 788 789 const res2 = await h.app.inject({ 790 method: 'GET', 791 url: `/api/v1/tasks/${task.id}/source.tar.gz`, 792 headers: h.auth, 793 }); 794 assert.equal(res2.statusCode, 200); 795 assert.equal(res2.headers['content-type'], 'application/gzip'); 796 // gzip magic number 797 assert.equal(res2.rawPayload[0], 0x1f); 798 assert.equal(res2.rawPayload[1], 0x8b); 799 }); 800 }); 801 802 test('a worker cannot touch a task it does not hold', async () => { 803 await withHarness({}, async (h) => { 804 const tr = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 805 await h.waitForJob(tr.json().data.job_id); 806 const task = await claimNamed(h, 'lint', {}); 807 808 const other = await h.services.workerTokens.create('intruder'); 809 const res = await h.app.inject({ 810 method: 'POST', 811 url: `/api/v1/tasks/${task.id}/log`, 812 headers: { authorization: `Bearer ${other.token}`, 'content-type': 'application/octet-stream' }, 813 payload: Buffer.from('malicious'), 814 }); 815 assert.equal(res.statusCode, 403); 816 }); 817 }); 818 819 test('log appends are resumable and deduplicated by offset', async () => { 820 await withHarness({}, async (h) => { 821 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 822 await h.waitForJob(res.json().data.job_id); 823 const task = await claimNamed(h, 'lint', {}); 824 const url = `/api/v1/tasks/${task.id}/log`; 825 const headers = { ...h.auth, 'content-type': 'application/octet-stream' }; 826 827 const first = await h.app.inject({ 828 method: 'POST', url, headers: { ...headers, 'x-log-offset': '0' }, payload: Buffer.from('hello\n'), 829 }); 830 assert.deepEqual(data(first), { size: 6, written: 6, truncated: false }); 831 832 // A retry resends what was already stored plus new data. 833 const retry = await h.app.inject({ 834 method: 'POST', url, headers: { ...headers, 'x-log-offset': '0' }, payload: Buffer.from('hello\nworld\n'), 835 }); 836 assert.deepEqual(data(retry), { size: 12, written: 6, truncated: false }); 837 838 // A gap is refused, and says where to resume. 839 const gap = await h.app.inject({ 840 method: 'POST', url, headers: { ...headers, 'x-log-offset': '9999' }, payload: Buffer.from('x'), 841 }); 842 assert.equal(gap.statusCode, 409); 843 assert.equal(apiError(gap).details.expected_offset, 12); 844 845 const tail = await h.services.logs.read(await jobIdOf(h, task.id), task.id, { offset: 0, limit: 1024 }); 846 assert.equal(tail.data.toString('utf8'), 'hello\nworld\n'); 847 assert.equal(tail.size, 12); 848 849 const partial = await h.services.logs.read(await jobIdOf(h, task.id), task.id, { offset: 6, limit: 1024 }); 850 assert.equal(partial.data.toString('utf8'), 'world\n'); 851 }); 852 }); 853 854 test('an oversized log chunk is refused', async () => { 855 await withHarness({}, async (h) => { 856 const tr = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 857 await h.waitForJob(tr.json().data.job_id); 858 const task = await claimNamed(h, 'lint', {}); 859 860 // Artifacts are streamed and may be large, but a single log append is 861 // buffered, so it has to be bounded or a worker can exhaust memory. 862 const res = await h.app.inject({ 863 method: 'POST', 864 url: `/api/v1/tasks/${task.id}/log`, 865 headers: { ...h.auth, 'content-type': 'application/octet-stream' }, 866 payload: Buffer.alloc(2 * 1024 * 1024, 0x41), 867 }); 868 869 assert.equal(res.statusCode, 413); 870 assert.equal(await h.services.logs.size(await jobIdOf(h, task.id), task.id), 0, 'nothing should have been written'); 871 }); 872 }); 873 874 test('a finished log moves to storage and is still readable', async () => { 875 await withHarness({}, async (h) => { 876 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 877 await h.waitForJob(res.json().data.job_id); 878 const task = await claimNamed(h, 'lint', {}); 879 880 await h.app.inject({ 881 method: 'POST', 882 url: `/api/v1/tasks/${task.id}/log`, 883 headers: { ...h.auth, 'content-type': 'application/octet-stream' }, 884 payload: Buffer.from('compiling\ndone\n'), 885 }); 886 await finish(h, task.id, { success: true }); 887 888 const row = await h.services.db.get('SELECT log_key FROM tasks WHERE id = {id}', { id: task.id }); 889 assert.ok(row.log_key, 'the log should have moved to storage'); 890 const object = await h.services.storage.get(row.log_key); 891 const parts = []; 892 for await (const part of object.stream) parts.push(part); 893 assert.equal(Buffer.concat(parts).toString('utf8'), 'compiling\ndone\n'); 894 }); 895 }); 896 897 test('artifacts are stored, hashed, and stripped of traversal', async () => { 898 await withHarness({}, async (h) => { 899 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 900 await h.waitForJob(res.json().data.job_id); 901 const task = await claimNamed(h, 'build:arch=x86_64', { arches: 'x86_64' }); 902 903 const upload = await h.app.inject({ 904 method: 'POST', 905 url: `/api/v1/tasks/${task.id}/artifacts`, 906 headers: { 907 ...h.auth, 908 'content-type': 'application/octet-stream', 909 'x-artifact-path': '../../../etc/passwd', 910 }, 911 payload: Buffer.from('artifact bytes'), 912 }); 913 assert.equal(upload.statusCode, 200); 914 915 const body = data(upload); 916 assert.equal(body.path, 'etc/passwd', 'traversal should be stripped, not honoured'); 917 assert.equal(body.size, 14); 918 assert.match(body.sha256, /^[0-9a-f]{64}$/); 919 920 const artifacts = await h.services.db.all( 921 'SELECT id FROM artifacts WHERE task_id = {task}', { task: task.id } 922 ); 923 assert.equal(artifacts.length, 1); 924 925 const download = await h.app.inject({ 926 method: 'GET', 927 url: `/api/v1/projects/demo/jobs/${await jobIdOf(h, task.id)}/tasks/${task.id}/artifacts/${artifacts[0].id}`, 928 }); 929 assert.equal(download.statusCode, 200); 930 assert.equal(download.body, 'artifact bytes'); 931 }); 932 }); 933 934 test('an artifact shorter than its content-length is rejected', async () => { 935 await withHarness({}, async (h) => { 936 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 937 await h.waitForJob(res.json().data.job_id); 938 const task = await claimNamed(h, 'lint', {}); 939 940 const res2 = await h.app.inject({ 941 method: 'POST', 942 url: `/api/v1/tasks/${task.id}/artifacts`, 943 headers: { 944 ...h.auth, 945 'content-type': 'application/octet-stream', 946 'content-length': '500', 947 'x-artifact-path': 'short.bin', 948 }, 949 payload: Buffer.from('tiny'), 950 }); 951 assert.equal(res2.statusCode, 400); 952 }); 953 }); 954 955 test('success releases dependents, matched on shared dimensions', async () => { 956 await withHarness({}, async (h) => { 957 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; 958 await h.waitForJob(job); 959 960 const roots = await drain(h, { arches: 'x86_64,aarch64', features: 'sign-key' }); 961 await finish(h, roots['build:arch=x86_64'].id, { success: true }); 962 963 // Only the x86_64 package becomes eligible; the aarch64 one still waits 964 // on its own architecture's build. 965 const next = await drain(h, { arches: 'x86_64,aarch64', features: 'sign-key' }); 966 assert.ok(next['package:arch=x86_64,pkg=musl'], 'x86_64 package should be released'); 967 assert.ok(!next['package:arch=aarch64,pkg=musl'], 'aarch64 package should still be waiting'); 968 969 const state = tasksByName(await jobState(h, job)); 970 assert.equal(state['package:arch=aarch64,pkg=musl'].state, 'queued'); 971 }); 972 }); 973 974 test('failure skips transitive dependents, not just direct ones', async () => { 975 await withHarness({}, async (h) => { 976 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; 977 await h.waitForJob(job); 978 979 const build = await claimNamed(h, 'build:arch=x86_64', { arches: 'x86_64' }); 980 const outcome = await finish(h, build.id, { success: false, exitCode: 2, error: 'compile failed' }); 981 assert.equal(outcome.state, 'failed'); 982 983 const tasks = tasksByName(await jobState(h, job)); 984 // Direct dependent. 985 assert.equal(tasks['package:arch=x86_64,pkg=musl'].state, 'skipped'); 986 // Transitive dependent: the prototype left this queued and dispatchable. 987 assert.equal(tasks.publish.state, 'skipped'); 988 // Unrelated branches are untouched. 989 assert.equal(tasks['build:arch=aarch64'].state, 'queued'); 990 assert.equal(tasks['package:arch=aarch64,pkg=musl'].state, 'queued'); 991 }); 992 }); 993 994 test('a job settles as failed once every task is terminal', async () => { 995 await withHarness({}, async (h) => { 996 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; 997 await h.waitForJob(job); 998 999 const roots = await drain(h, { arches: 'x86_64,aarch64', features: 'sign-key' }); 1000 1001 // Failing the x86_64 build skips its package and, transitively, publish. 1002 await finish(h, roots['build:arch=x86_64'].id, { success: false, exitCode: 1 }); 1003 await finish(h, roots['build:arch=aarch64'].id, { success: true }); 1004 await finish(h, roots.lint.id, { success: true }); 1005 1006 const released = await drain(h, { arches: 'aarch64', features: 'sign-key' }); 1007 const last = await finish(h, released['package:arch=aarch64,pkg=musl'].id, { success: true }); 1008 1009 assert.equal(last.job_state, 'failed'); 1010 const detail = await jobState(h, job); 1011 assert.equal(detail.job.state, 'failed'); 1012 1013 // The failure fails the job, not the skips it caused. 1014 assert.ok( 1015 detail.tasks.some((t) => t.state === 'skipped'), 1016 'the cascade should have skipped something' 1017 ); 1018 assert.ok(detail.tasks.some((t) => t.state === 'failed' && t.allow_failure === 0)); 1019 }); 1020 }); 1021 1022 test('a job settles as success when everything passes', async () => { 1023 // Quoted, because an unquoted true in YAML is a boolean, not a command. 1024 const pipeline = ` 1025 version: 1 1026 tasks: 1027 a: 1028 image: alpine 1029 script: ['true'] 1030 b: 1031 image: alpine 1032 script: ['true'] 1033 needs: [a] 1034 `; 1035 await withHarness({ pipeline }, async (h) => { 1036 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; 1037 await h.waitForJob(job); 1038 1039 const a = await claimNamed(h, 'a', {}); 1040 await finish(h, a.id, { success: true }); 1041 const b = await claimNamed(h, 'b', {}); 1042 const last = await finish(h, b.id, { success: true }); 1043 1044 assert.equal(last.job_state, 'success'); 1045 assert.equal((await jobState(h, job)).job.state, 'success'); 1046 }); 1047 }); 1048 1049 test('an allowed failure does not fail the job or block dependents', async () => { 1050 const pipeline = ` 1051 version: 1 1052 tasks: 1053 flaky: 1054 image: alpine 1055 script: ['false'] 1056 allow_failure: true 1057 after: 1058 image: alpine 1059 script: ['true'] 1060 needs: [flaky] 1061 `; 1062 await withHarness({ pipeline }, async (h) => { 1063 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; 1064 await h.waitForJob(job); 1065 1066 const flaky = await claimNamed(h, 'flaky', {}); 1067 const outcome = await finish(h, flaky.id, { success: false, exitCode: 1 }); 1068 assert.deepEqual(outcome.skipped, []); 1069 1070 const after = await claimNamed(h, 'after', {}); 1071 assert.ok(after, 'dependent should still run after an allowed failure'); 1072 const last = await finish(h, after.id, { success: true }); 1073 assert.equal(last.job_state, 'success'); 1074 assert.equal((await jobState(h, job)).job.state, 'success'); 1075 }); 1076 }); 1077 1078 test('a task with retries left is requeued instead of failing the job', async () => { 1079 const pipeline = ` 1080 version: 1 1081 tasks: 1082 retried: 1083 image: alpine 1084 script: [maybe] 1085 max_attempts: 2 1086 `; 1087 await withHarness({ pipeline }, async (h) => { 1088 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; 1089 await h.waitForJob(job); 1090 1091 const first = await claimNamed(h, 'retried', {}); 1092 const outcome = await finish(h, first.id, { success: false, exitCode: 1 }); 1093 assert.equal(outcome.state, 'queued'); 1094 assert.equal(outcome.retry, true); 1095 1096 const second = await claimNamed(h, 'retried', {}); 1097 assert.equal(second.attempt, 2); 1098 const last = await finish(h, second.id, { success: false, exitCode: 1 }); 1099 assert.equal(last.state, 'failed'); 1100 assert.equal((await jobState(h, job)).job.state, 'failed'); 1101 }); 1102 }); 1103 1104 test('heartbeats keep a task alive and report cancellation', async () => { 1105 await withHarness({}, async (h) => { 1106 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; 1107 await h.waitForJob(job); 1108 const task = await claimNamed(h, 'lint', {}); 1109 1110 const beat = await h.app.inject({ 1111 method: 'POST', 1112 url: `/api/v1/tasks/${task.id}/heartbeat`, 1113 headers: { ...h.auth, 'content-type': 'application/json' }, 1114 payload: '{}', 1115 }); 1116 assert.deepEqual(data(beat), { cancelled: false }); 1117 1118 await h.services.scheduler.cancelJob(job); 1119 1120 const after = await h.app.inject({ 1121 method: 'POST', 1122 url: `/api/v1/tasks/${task.id}/heartbeat`, 1123 headers: { ...h.auth, 'content-type': 'application/json' }, 1124 payload: '{}', 1125 }); 1126 assert.equal(after.json().data.cancelled, true); 1127 assert.equal((await jobState(h, job)).job.state, 'cancelled'); 1128 }); 1129 }); 1130 1131 test('the reaper fails a task whose worker stopped reporting', async () => { 1132 await withHarness({}, async (h) => { 1133 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; 1134 await h.waitForJob(job); 1135 const task = await claimNamed(h, 'lint', {}); 1136 assert.equal(tasksByName(await jobState(h, job)).lint.state, 'running'); 1137 1138 // Backdate the heartbeat rather than waiting out the real timeout. 1139 await h.services.db.run( 1140 'UPDATE tasks SET heartbeat_at = {then} WHERE id = {id}', 1141 { then: Date.now() - 10 * 60 * 1000, id: task.id } 1142 ); 1143 1144 const reaped = await h.services.scheduler.reap(); 1145 assert.ok(reaped.length > 0); 1146 1147 const after = tasksByName(await jobState(h, job)).lint; 1148 assert.equal(after.state, 'failed'); 1149 assert.match(after.error, /stopped reporting/); 1150 1151 // The worker that lost the task can no longer complete it. 1152 const late = await finishRaw(h, task.id, { success: true }); 1153 assert.equal(late.statusCode, 409); 1154 assert.equal(apiError(late).name, 'TaskNotRunning'); 1155 }); 1156 }); 1157 1158 test('a second push produces an independent, numbered job', async () => { 1159 await withHarness({}, async (h) => { 1160 const first = data(await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })); 1161 await h.waitForJob(first.job_id); 1162 const sha2 = await h.commit({ 'README.md': 'changed\n' }, 'second commit'); 1163 const second = data(await h.trigger({ sha: sha2, ref: 'refs/heads/main' })); 1164 await h.waitForJob(second.job_id); 1165 1166 assert.notEqual(first.job_id, second.job_id); 1167 const detail = await jobState(h, second.job_id); 1168 assert.equal(detail.job.number, 2); 1169 assert.equal(detail.job.title, 'second commit'); 1170 assert.equal(detail.job.head_sha, sha2); 1171 }); 1172 }); 1173 1174 test('a jobs list belongs to its project', async () => { 1175 await withHarness({ visibility: 'public' }, async (h) => { 1176 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 1177 await h.waitForJob(res.json().data.job_id); 1178 1179 const mine = await h.app.inject({ method: 'GET', url: '/api/v1/projects/demo/jobs' }); 1180 assert.equal(mine.json().data.length, 1); 1181 1182 const other = await h.app.inject({ method: 'GET', url: '/api/v1/projects/absent/jobs' }); 1183 assert.equal(other.statusCode, 404); 1184 }); 1185 }); 1186 1187 test('a job is addressable with or without its project', async () => { 1188 await withHarness({ visibility: 'public' }, async (h) => { 1189 const jobId = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; 1190 await h.waitForJob(jobId); 1191 1192 for (const url of [`/api/v1/jobs/${jobId}`, `/api/v1/projects/demo/jobs/${jobId}`]) { 1193 const res = await h.app.inject({ method: 'GET', url }); 1194 assert.equal(res.statusCode, 200, `${url}: ${res.body}`); 1195 assert.equal(res.json().data.id, jobId); 1196 } 1197 1198 // Naming the wrong project asserts it rather than being ignored. 1199 const wrong = await h.app.inject({ method: 'GET', url: `/api/v1/projects/absent/jobs/${jobId}` }); 1200 assert.equal(wrong.statusCode, 404); 1201 }); 1202 }); 1203 1204 test('unknown jobs, tasks and artifacts return 404', async () => { 1205 await withHarness({}, async (h) => { 1206 const urls = [ 1207 '/api/v1/jobs/nope', 1208 '/api/v1/jobs/nope/tasks', 1209 '/api/v1/tasks/nope', 1210 '/api/v1/projects/nope', 1211 ]; 1212 for (const url of urls) { 1213 const res = await h.app.inject({ method: 'GET', url }); 1214 assert.equal(res.statusCode, 404, `${url} should be 404`); 1215 } 1216 }); 1217 }); 1218 1219 test('a disabled project refuses triggers', async () => { 1220 await withHarness({}, async (h) => { 1221 await h.services.projects.setEnabled('demo', false); 1222 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 1223 assert.equal(res.statusCode, 409); 1224 }); 1225 }); 1226 1227 test('a trigger for an unknown project is a 404', async () => { 1228 await withHarness({}, async (h) => { 1229 const res = await h.app.inject({ 1230 method: 'POST', 1231 url: '/api/v1/projects/absent/trigger', 1232 headers: { 'content-type': 'application/json' }, 1233 payload: JSON.stringify({ sha: h.sha }), 1234 }); 1235 assert.equal(res.statusCode, 404); 1236 }); 1237 }); 1238 1239 test('a commit that is not in the repository is refused', async () => { 1240 await withHarness({}, async (h) => { 1241 const res = await h.trigger({ sha: 'b'.repeat(40), ref: 'refs/heads/main' }); 1242 assert.equal(res.statusCode, 200); 1243 const job = await h.waitForJob(res.json().data.job_id); 1244 assert.equal(job.state, 'failed'); 1245 assert.match(job.error ?? '', /not present in the mirror/); 1246 }); 1247 }); 1248 1249 test('a task is told to fetch an archive, and never the repository itself', async () => { 1250 await withHarness({}, async (h) => { 1251 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 1252 await h.waitForJob(res.json().data.job_id); 1253 const task = await claimNamed(h, 'lint', {}); 1254 1255 assert.match(task.endpoints.source, /\/source\.tar\.gz$/); 1256 1257 // A worker holds no repository credentials and can reach no ref other 1258 // than the commit it was given work for, so the repository location 1259 // has no business being in the payload. 1260 assert.equal(task.source, undefined, 'there is one way to get the source, so there is no mode'); 1261 assert.ok(!JSON.stringify(task).includes(h.repoDir), 'the repository path must not reach the worker'); 1262 }); 1263 }); 1264 1265 test('the source archive is the bare tree, with no wrapping directory', async () => { 1266 // The worker hands it to docker cp with the working directory as the 1267 // destination, and docker creates that directory as it extracts. A 1268 // wrapping directory in the archive would land one level too deep. 1269 await withHarness({}, async (h) => { 1270 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 1271 await h.waitForJob(res.json().data.job_id); 1272 const task = await claimNamed(h, 'lint', {}); 1273 1274 const archive = await h.app.inject({ 1275 method: 'GET', 1276 url: new URL(task.endpoints.source).pathname, 1277 headers: h.auth, 1278 }); 1279 assert.equal(archive.statusCode, 200); 1280 1281 // spawnSync rather than the promisified execFile, which has no way to 1282 // supply stdin and simply waits for input that never comes. 1283 const { spawnSync } = await import('node:child_process'); 1284 const listing = spawnSync('tar', ['-tzf', '-'], { 1285 input: archive.rawPayload, 1286 encoding: 'utf8', 1287 }); 1288 assert.equal(listing.status, 0, `tar failed: ${listing.stderr}`); 1289 1290 const entries = listing.stdout.split('\n').filter(Boolean); 1291 assert.ok(entries.length > 0, 'the archive should not be empty'); 1292 assert.ok( 1293 entries.includes('.conductor.yml'), 1294 `the pipeline should sit at the root of the archive, got:\n${listing.stdout}`, 1295 ); 1296 }); 1297 }); 1298 1299 test('the working directory is resolved from the pipeline, then the project', async () => { 1300 const pipeline = [ 1301 'version: 1', 1302 'workdir: /usr/src/app', 1303 'defaults:', 1304 ' image: alpine:3', 1305 'tasks:', 1306 ' build:', 1307 " script: ['make']", 1308 '', 1309 ].join('\n'); 1310 1311 await withHarness({ pipeline }, async (h) => { 1312 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 1313 await h.waitForJob(res.json().data.job_id); 1314 const task = await claimNamed(h, 'build', {}); 1315 assert.equal(task.workdir, '/usr/src/app', 'the repository has the final say'); 1316 }); 1317 1318 // With the repository silent, the project decides. 1319 await withHarness({}, async (h) => { 1320 await h.services.db.run( 1321 'UPDATE projects SET workdir = {dir} WHERE id = {id}', 1322 { dir: '/srv/build', id: h.project.id } 1323 ); 1324 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 1325 await h.waitForJob(res.json().data.job_id); 1326 const task = await claimNamed(h, 'lint', {}); 1327 assert.equal(task.workdir, '/srv/build'); 1328 }); 1329 1330 // With neither, there is a default. 1331 await withHarness({}, async (h) => { 1332 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 1333 await h.waitForJob(res.json().data.job_id); 1334 const task = await claimNamed(h, 'lint', {}); 1335 assert.equal(task.workdir, '/work'); 1336 }); 1337 }); 1338 1339 test('an unusable working directory is refused rather than sanitised', async () => { 1340 for (const workdir of ['relative/path', '/', '/a/../b', '/has space']) { 1341 const pipeline = [ 1342 'version: 1', 1343 `workdir: ${JSON.stringify(workdir)}`, 1344 'defaults:', 1345 ' image: alpine:3', 1346 'tasks:', 1347 ' build:', 1348 " script: ['make']", 1349 '', 1350 ].join('\n'); 1351 1352 await withHarness({ pipeline }, async (h) => { 1353 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 1354 assert.equal(res.statusCode, 200); 1355 const job = await h.waitForJob(res.json().data.job_id); 1356 assert.equal(job.state, 'failed', `expected ${JSON.stringify(workdir)} to be refused`); 1357 assert.ok((job.error ?? '').includes('workdir'), `error should mention workdir: ${job.error}`); 1358 }); 1359 } 1360 }); 1361 1362 test('the trigger secret is not stored in the clear', async () => { 1363 await withHarness({}, async (h) => { 1364 const row = await h.services.db.get('SELECT trigger_secret FROM projects WHERE id = {id}', { id: 'demo' }); 1365 assert.ok(row.trigger_secret.startsWith('v1.'), 'expected an encrypted value'); 1366 assert.ok(!row.trigger_secret.includes('test-secret')); 1367 assert.equal(h.services.projects.triggerSecret(await h.services.projects.get('demo')), 'test-secret'); 1368 }); 1369 }); 1370 1371 test('worker tokens are stored only as hashes', async () => { 1372 await withHarness({}, async (h) => { 1373 const rows = await h.services.db.all('SELECT token_hash FROM worker_tokens'); 1374 assert.ok(rows.length > 0); 1375 for (const row of rows) { 1376 assert.match(row.token_hash, /^[0-9a-f]{64}$/); 1377 assert.notEqual(row.token_hash, h.worker.token); 1378 } 1379 }); 1380 }); 1381 1382 test('after runs its dependent whether the dependency passed or failed', async () => { 1383 const pipeline = ` 1384 version: 1 1385 defaults: 1386 image: alpine 1387 tasks: 1388 build: 1389 script: ['true'] 1390 test: 1391 script: ['false'] 1392 needs: [build] 1393 notify: 1394 script: ['true'] 1395 after: [test] 1396 `; 1397 await withHarness({ pipeline }, async (h) => { 1398 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; 1399 await h.waitForJob(job); 1400 1401 const build = await claimNamed(h, 'build', {}); 1402 await finish(h, build.id, { success: true }); 1403 1404 // Draining yields test alone: notify waits for it to finish, exactly as 1405 // needs would. after asks for less, not for nothing. 1406 const released = await drain(h, {}); 1407 assert.ok(released.test, 'test should be released by build'); 1408 assert.ok(!released.notify, 'notify should wait while test runs'); 1409 1410 const outcome = await finish(h, released.test.id, { success: false, exitCode: 1 }); 1411 // The failure has nothing downstream of it to skip. 1412 assert.deepEqual(outcome.skipped, []); 1413 1414 const notify = await claimNamed(h, 'notify', {}); 1415 assert.ok(notify, 'notify should run after a failed dependency'); 1416 const last = await finish(h, notify.id, { success: true }); 1417 1418 // The failure still fails the job. after decides what runs, not what counts. 1419 assert.equal(last.job_state, 'failed'); 1420 const tasks = tasksByName(await jobState(h, job)); 1421 assert.equal(tasks.notify.state, 'success'); 1422 assert.equal(tasks.test.state, 'failed'); 1423 }); 1424 }); 1425 1426 test('after runs its dependent even when the dependency was skipped', async () => { 1427 const pipeline = ` 1428 version: 1 1429 defaults: 1430 image: alpine 1431 tasks: 1432 build: 1433 script: ['false'] 1434 test: 1435 script: ['true'] 1436 needs: [build] 1437 cleanup: 1438 script: ['true'] 1439 after: [test] 1440 `; 1441 await withHarness({ pipeline }, async (h) => { 1442 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; 1443 await h.waitForJob(job); 1444 1445 const build = await claimNamed(h, 'build', {}); 1446 const outcome = await finish(h, build.id, { success: false, exitCode: 1 }); 1447 // test is skipped with its dependency, cleanup is not. 1448 assert.deepEqual(outcome.skipped, [(await jobState(h, job)).tasks.find((t) => t.name === 'test').id]); 1449 1450 const cleanup = await claimNamed(h, 'cleanup', {}); 1451 assert.ok(cleanup, 'cleanup should run once test settled as skipped'); 1452 await finish(h, cleanup.id, { success: true }); 1453 1454 const tasks = tasksByName(await jobState(h, job)); 1455 assert.equal(tasks.test.state, 'skipped'); 1456 assert.equal(tasks.cleanup.state, 'success'); 1457 }); 1458 }); 1459 1460 // --- task outputs and set-state --- 1461 1462 test('an output reaches the task that reads it, and implies the dependency', async () => { 1463 const pipeline = ` 1464 version: 1 1465 defaults: 1466 image: alpine 1467 tasks: 1468 build: 1469 script: ['true'] 1470 deploy: 1471 script: ['ship \${{ tasks.build.outputs.tag }}'] 1472 env: 1473 TAG: '\${{ tasks.build.outputs.tag }}' 1474 `; 1475 await withHarness({ pipeline }, async (h) => { 1476 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; 1477 await h.waitForJob(job); 1478 1479 // deploy was never declared to need build; reading its output says so. 1480 const tasks = tasksByName(await jobState(h, job)); 1481 assert.deepEqual(tasks.deploy.needs, [tasks.build.id]); 1482 1483 const build = await claimNamed(h, 'build', {}); 1484 assert.ok(build, 'build runs first'); 1485 assert.equal(await claimNamed(h, 'deploy', {}), null, 'deploy waits for it'); 1486 1487 await finish(h, build.id, { 1488 success: true, 1489 outputs: [{ name: 'tag', value: 'v1.2.3', masked: false }], 1490 }); 1491 1492 const deploy = await claimNamed(h, 'deploy', {}); 1493 assert.deepEqual(deploy.script, ['ship v1.2.3']); 1494 assert.equal(deploy.env.TAG, 'v1.2.3'); 1495 }); 1496 }); 1497 1498 test('a masked output stays masked in the log of whatever reads it', async () => { 1499 const pipeline = ` 1500 version: 1 1501 defaults: 1502 image: alpine 1503 tasks: 1504 build: 1505 script: ['true'] 1506 deploy: 1507 script: ['ship \${{ tasks.build.outputs.token }}'] 1508 `; 1509 await withHarness({ pipeline }, async (h) => { 1510 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; 1511 await h.waitForJob(job); 1512 1513 const build = await claimNamed(h, 'build', {}); 1514 await finish(h, build.id, { 1515 success: true, 1516 outputs: [{ name: 'token', value: 'sup3rsecret', masked: true }], 1517 }); 1518 1519 const deploy = await claimNamed(h, 'deploy', {}); 1520 assert.deepEqual(deploy.script, ['ship sup3rsecret'], 'the value is still delivered'); 1521 assert.ok( 1522 deploy.masked.includes('sup3rsecret'), 1523 'and the worker is told to redact it from this task too' 1524 ); 1525 }); 1526 }); 1527 1528 test('outputs rest sealed rather than readable in the table', async () => { 1529 const pipeline = ` 1530 version: 1 1531 tasks: 1532 build: 1533 image: alpine 1534 script: ['true'] 1535 `; 1536 await withHarness({ pipeline }, async (h) => { 1537 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; 1538 await h.waitForJob(job); 1539 1540 const build = await claimNamed(h, 'build', {}); 1541 await finish(h, build.id, { 1542 success: true, 1543 outputs: [{ name: 'tag', value: 'v1.2.3', masked: false }], 1544 }); 1545 1546 const row = await h.services.db.get( 1547 'SELECT value FROM task_outputs WHERE task_id = {id}', { id: build.id } 1548 ); 1549 assert.ok(!row.value.includes('v1.2.3'), `stored in the clear: ${row.value}`); 1550 }); 1551 }); 1552 1553 test('a task that reads an output that was never produced fails before it runs', async () => { 1554 const pipeline = ` 1555 version: 1 1556 defaults: 1557 image: alpine 1558 tasks: 1559 build: 1560 script: ['true'] 1561 deploy: 1562 script: ['ship \${{ tasks.build.outputs.tag }}'] 1563 `; 1564 await withHarness({ pipeline }, async (h) => { 1565 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; 1566 await h.waitForJob(job); 1567 1568 const build = await claimNamed(h, 'build', {}); 1569 await finish(h, build.id, { success: true }); 1570 1571 assert.equal(await claimNamed(h, 'deploy', {}), null, 'nothing to hand a worker'); 1572 1573 const tasks = tasksByName(await jobState(h, job)); 1574 assert.equal(tasks.deploy.state, 'failed'); 1575 assert.match(tasks.deploy.error, /produced no output "tag"/); 1576 assert.equal(tasks.deploy.started_at, null, 'it never started'); 1577 assert.equal((await jobState(h, job)).job.state, 'failed'); 1578 }); 1579 }); 1580 1581 test('set-state success overrides a non-zero exit', async () => { 1582 const pipeline = ` 1583 version: 1 1584 tasks: 1585 check: 1586 image: alpine 1587 script: ['false'] 1588 `; 1589 await withHarness({ pipeline }, async (h) => { 1590 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; 1591 await h.waitForJob(job); 1592 1593 const check = await claimNamed(h, 'check', {}); 1594 const outcome = await finish(h, check.id, { success: false, state: 'success', exitCode: 1 }); 1595 1596 assert.equal(outcome.state, 'success'); 1597 assert.equal(outcome.job_state, 'success'); 1598 const tasks = tasksByName(await jobState(h, job)); 1599 assert.equal(tasks.check.exit_code, 1, 'the exit code is still recorded'); 1600 }); 1601 }); 1602 1603 test('a skipped task is not a failure, and what needed it still runs', async () => { 1604 const pipeline = ` 1605 version: 1 1606 defaults: 1607 image: alpine 1608 tasks: 1609 package: 1610 script: ['true'] 1611 index: 1612 script: ['reindex'] 1613 needs: [package] 1614 `; 1615 await withHarness({ pipeline }, async (h) => { 1616 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; 1617 await h.waitForJob(job); 1618 1619 const pkg = await claimNamed(h, 'package', {}); 1620 const outcome = await finish(h, pkg.id, { success: false, state: 'skipped', exitCode: 0 }); 1621 assert.equal(outcome.state, 'skipped'); 1622 assert.equal(outcome.job_state, null, 'the job is not settled: index still has to run'); 1623 1624 const index = await claimNamed(h, 'index', {}); 1625 assert.ok(index, 'a skip is not a failure, so the indexer still runs'); 1626 const last = await finish(h, index.id, { success: true }); 1627 1628 assert.equal(last.job_state, 'success'); 1629 const tasks = tasksByName(await jobState(h, job)); 1630 assert.equal(tasks.package.state, 'skipped'); 1631 }); 1632 }); 1633 1634 // Regression: settling inside the completing transaction stranded jobs. 1635 test('a job whose settle was lost is recovered by the sweep', async () => { 1636 const pipeline = ` 1637 version: 1 1638 defaults: 1639 image: alpine 1640 tasks: 1641 build: 1642 script: ['true'] 1643 `; 1644 await withHarness({ pipeline }, async (h) => { 1645 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; 1646 await h.waitForJob(job); 1647 1648 const build = await claimNamed(h, 'build', {}); 1649 assert.equal((await finish(h, build.id, { success: true })).job_state, 'success'); 1650 1651 await h.services.db.run( 1652 "UPDATE jobs SET state = 'running', finished_at = NULL WHERE id = {id}", 1653 { id: job } 1654 ); 1655 1656 const settled = await h.services.scheduler.settleStalled(); 1657 assert.deepEqual(settled, [{ id: job, state: 'success' }]); 1658 1659 const row = await h.services.db.get('SELECT state, finished_at FROM jobs WHERE id = {id}', 1660 { id: job }); 1661 assert.equal(row.state, 'success'); 1662 assert.ok(row.finished_at > 0, 'the recovered job gets a finish time'); 1663 }); 1664 }); 1665 1666 test('the sweep leaves a job that still has work to do alone', async () => { 1667 const pipeline = ` 1668 version: 1 1669 defaults: 1670 image: alpine 1671 tasks: 1672 build: 1673 script: ['true'] 1674 publish: 1675 script: ['true'] 1676 needs: [build] 1677 `; 1678 await withHarness({ pipeline }, async (h) => { 1679 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; 1680 await h.waitForJob(job); 1681 1682 const build = await claimNamed(h, 'build', {}); 1683 await finish(h, build.id, { success: true }); 1684 1685 assert.deepEqual(await h.services.scheduler.settleStalled(), []); 1686 const row = await h.services.db.get('SELECT state FROM jobs WHERE id = {id}', { id: job }); 1687 assert.equal(row.state, 'running'); 1688 }); 1689 }); 1690 1691 test('a skipped task that owed a value fails the one that needed it', async () => { 1692 const pipeline = ` 1693 version: 1 1694 defaults: 1695 image: alpine 1696 tasks: 1697 package: 1698 script: ['true'] 1699 index: 1700 script: ['reindex \${{ tasks.package.outputs.path }}'] 1701 `; 1702 await withHarness({ pipeline }, async (h) => { 1703 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; 1704 await h.waitForJob(job); 1705 1706 const pkg = await claimNamed(h, 'package', {}); 1707 await finish(h, pkg.id, { success: false, state: 'skipped', exitCode: 0 }); 1708 1709 assert.equal(await claimNamed(h, 'index', {}), null); 1710 const tasks = tasksByName(await jobState(h, job)); 1711 assert.equal(tasks.index.state, 'failed'); 1712 assert.match(tasks.index.error, /produced no output "path"/); 1713 }); 1714 }); 1715 1716 test('set-state failed fails a task that exited zero', async () => { 1717 const pipeline = ` 1718 version: 1 1719 tasks: 1720 check: 1721 image: alpine 1722 script: ['true'] 1723 `; 1724 await withHarness({ pipeline }, async (h) => { 1725 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; 1726 await h.waitForJob(job); 1727 1728 const check = await claimNamed(h, 'check', {}); 1729 const outcome = await finish(h, check.id, { success: false, state: 'failed', exitCode: 0 }); 1730 assert.equal(outcome.state, 'failed'); 1731 assert.equal(outcome.job_state, 'failed'); 1732 }); 1733 }); 1734 1735 test('a worker that reports no state at all is still understood', async () => { 1736 const pipeline = ` 1737 version: 1 1738 tasks: 1739 check: 1740 image: alpine 1741 script: ['true'] 1742 `; 1743 await withHarness({ pipeline }, async (h) => { 1744 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; 1745 await h.waitForJob(job); 1746 1747 const check = await claimNamed(h, 'check', {}); 1748 const res = await h.app.inject({ 1749 method: 'POST', 1750 url: `/api/v1/tasks/${check.id}/complete`, 1751 headers: { ...h.auth, 'content-type': 'application/json' }, 1752 payload: JSON.stringify({ success: true, exit_code: 0 }), 1753 }); 1754 assert.equal(data(res).state, 'success'); 1755 }); 1756 }); 1757 1758 test('a retry drops what the failed attempt produced', async () => { 1759 const pipeline = ` 1760 version: 1 1761 tasks: 1762 flaky: 1763 image: alpine 1764 script: ['true'] 1765 max_attempts: 2 1766 `; 1767 await withHarness({ pipeline }, async (h) => { 1768 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; 1769 await h.waitForJob(job); 1770 1771 const first = await claimNamed(h, 'flaky', {}); 1772 await finish(h, first.id, { 1773 success: false, 1774 exitCode: 1, 1775 outputs: [{ name: 'tag', value: 'from-the-bad-run', masked: false }], 1776 }); 1777 1778 const rows = await h.services.db.all( 1779 'SELECT name FROM task_outputs WHERE task_id = {id}', { id: first.id } 1780 ); 1781 assert.deepEqual(rows, [], 'a requeued task carries nothing forward'); 1782 }); 1783 }); 1784 1785 test('after edges are stored as their own kind', async () => { 1786 const pipeline = ` 1787 version: 1 1788 defaults: 1789 image: alpine 1790 tasks: 1791 a: 1792 script: ['true'] 1793 b: 1794 script: ['true'] 1795 needs: [a] 1796 c: 1797 script: ['true'] 1798 after: [a] 1799 `; 1800 await withHarness({ pipeline }, async (h) => { 1801 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id; 1802 await h.waitForJob(job); 1803 1804 const tasks = tasksByName(await jobState(h, job)); 1805 assert.deepEqual(tasks.b.needs, [tasks.a.id]); 1806 assert.deepEqual(tasks.b.after, []); 1807 assert.deepEqual(tasks.c.needs, []); 1808 assert.deepEqual(tasks.c.after, [tasks.a.id]); 1809 }); 1810 });