conductor.test.js (48881B)
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 } from './helpers/harness.js'; 9 10 // Tests that exercised the JSON read API, which has been removed. Kept 11 // until the suite is rebuilt against the interface. 12 const apiGone = { skip: 'the JSON API was removed' }; 13 14 // Each test gets its own harness so that ordering never matters. 15 async function withHarness(options, fn) { 16 const h = await startHarness(options); 17 try { 18 return await fn(h); 19 } finally { 20 await h.stop(); 21 } 22 } 23 24 function tasksByName(payload) { 25 return Object.fromEntries(payload.tasks.map((j) => [j.name, j])); 26 } 27 28 // Reads a job and its tasks straight from the database. There is no read API; 29 // the interface is for people, and the tests inspect the data itself. 30 async function jobState(h, jobId) { 31 const job = await h.services.db.get('SELECT * FROM jobs WHERE id = {id}', { id: jobId }); 32 const tasks = await h.services.db.all( 33 'SELECT * FROM tasks WHERE job_id = {job} ORDER BY name', { job: jobId } 34 ); 35 const deps = await h.services.db.all( 36 `SELECT d.task_id, d.depends_on_id FROM task_deps d 37 JOIN tasks t ON t.id = d.task_id WHERE t.job_id = {job}`, 38 { job: jobId } 39 ); 40 const needs = new Map(tasks.map((t) => [t.id, []])); 41 for (const d of deps) needs.get(d.task_id)?.push(d.depends_on_id); 42 return { job, tasks: tasks.map((t) => ({ ...t, needs: needs.get(t.id) ?? [] })) }; 43 } 44 45 // The job a task belongs to. A worker is never told this, so a test that 46 // needs it asks the database, exactly as the conductor does. 47 async function jobIdOf(h, taskId) { 48 const row = await h.services.db.get('SELECT job_id FROM tasks WHERE id = {id}', { id: taskId }); 49 return row.job_id; 50 } 51 52 // Claims everything currently eligible for a worker with the given 53 // capabilities, keyed by task name. Tests assert on which tasks appear rather 54 // than on the order they arrive in, which is not part of the contract. 55 async function drain(h, query = {}) { 56 const claimed = {}; 57 for (let i = 0; i < 50; i += 1) { 58 const res = await h.claim(query); 59 if (res.statusCode === 204) break; 60 const task = res.json().task; 61 claimed[task.name] = task; 62 } 63 return claimed; 64 } 65 66 async function claimNamed(h, name, query) { 67 return (await drain(h, query))[name] ?? null; 68 } 69 70 async function finish(h, taskId, { success = true, exitCode = 0, error = null } = {}) { 71 const res = await h.app.inject({ 72 method: 'POST', 73 url: `/api/v1/tasks/${taskId}/complete`, 74 headers: { ...h.auth, 'content-type': 'application/json' }, 75 payload: JSON.stringify({ success, exit_code: exitCode, error }), 76 }); 77 return res.json(); 78 } 79 80 test('health reports the selected drivers', async () => { 81 await withHarness({}, async (h) => { 82 const res = await h.app.inject({ method: 'GET', url: '/health' }); 83 assert.equal(res.statusCode, 200); 84 assert.deepEqual(res.json(), { 85 ok: true, service: 'conductor', database: 'sqlite', storage: 'local', auth: 'local', 86 }); 87 }); 88 }); 89 90 test('a signed trigger creates a job and expands the pipeline', async () => { 91 await withHarness({}, async (h) => { 92 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main', actor: 'alice' }); 93 assert.equal(res.statusCode, 200); 94 95 const body = res.json(); 96 assert.equal(body.status, 'created'); 97 98 const job = await h.waitForJob(body.job_id); 99 assert.equal(job.state, 'running'); 100 assert.equal(job.actor, 'alice'); 101 assert.equal(job.title, 'initial commit'); 102 assert.equal(job.number, 1); 103 104 const detail = await jobState(h, body.job_id); 105 // lint + 2 build + 2 package + publish 106 assert.equal(detail.tasks.length, 6); 107 108 // Task ids are opaque, so the edge is checked by resolving the name 109 // rather than reconstructing an id the conductor no longer derives. 110 const tasks = tasksByName(detail); 111 assert.deepEqual( 112 tasks['package:arch=x86_64,pkg=musl'].needs, 113 [tasks['build:arch=x86_64'].id] 114 ); 115 assert.equal(tasks.publish.needs.length, 3); 116 }); 117 }); 118 119 test('an unsigned or wrongly signed trigger is refused', async () => { 120 await withHarness({}, async (h) => { 121 const unsigned = await h.app.inject({ 122 method: 'POST', 123 url: '/api/v1/projects/demo/trigger', 124 headers: { 'content-type': 'application/json' }, 125 payload: JSON.stringify({ sha: h.sha }), 126 }); 127 assert.equal(unsigned.statusCode, 401); 128 129 const wrong = await h.trigger({ sha: h.sha }, { secret: 'not-the-secret' }); 130 assert.equal(wrong.statusCode, 401); 131 }); 132 }); 133 134 test('github and gitlab push payloads are understood', async () => { 135 await withHarness({}, async (h) => { 136 const github = await h.trigger({ 137 after: h.sha, 138 before: '0'.repeat(40), 139 ref: 'refs/heads/main', 140 pusher: { name: 'octocat' }, 141 }); 142 assert.equal(github.statusCode, 200); 143 const githubJob = await h.waitForJob(github.json().job_id); 144 assert.equal(githubJob.state, 'running'); 145 146 const gitlab = await h.trigger({ 147 checkout_sha: h.sha, 148 ref: 'refs/heads/main', 149 user_username: 'gl-user', 150 }); 151 assert.equal(gitlab.statusCode, 200); 152 const gitlabJob = await h.waitForJob(gitlab.json().job_id); 153 assert.equal(gitlabJob.state, 'running'); 154 }); 155 }); 156 157 test('a branch deletion is ignored rather than failing', async () => { 158 await withHarness({}, async (h) => { 159 const res = await h.trigger({ sha: '0'.repeat(40), ref: 'refs/heads/gone' }); 160 assert.equal(res.statusCode, 200); 161 assert.equal(res.json().status, 'ignored'); 162 }); 163 }); 164 165 test('a broken pipeline is reported as a client error with detail', async () => { 166 await withHarness({ pipeline: 'version: 1\ntasks:\n a:\n script: [x]\n' }, async (h) => { 167 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 168 assert.equal(res.statusCode, 200); 169 const job = await h.waitForJob(res.json().job_id); 170 assert.equal(job.state, 'failed'); 171 assert.ok(job.error?.includes('tasks.a.image')); 172 }); 173 }); 174 175 test('a missing pipeline file is reported clearly', async () => { 176 await withHarness({ pipeline: null }, async (h) => { 177 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 178 assert.equal(res.statusCode, 200); 179 const job = await h.waitForJob(res.json().job_id); 180 assert.equal(job.state, 'failed'); 181 assert.match(job.error ?? '', /\.conductor\.yml not found/); 182 }); 183 }); 184 185 test('a pipeline may be spelled .conductor.yaml instead of .conductor.yml', async () => { 186 await withHarness({ repoConfigPath: '.conductor.yaml' }, async (h) => { 187 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 188 assert.equal(res.statusCode, 200); 189 const job = await h.waitForJob(res.json().job_id); 190 assert.equal(job.state, 'running'); 191 }); 192 }); 193 194 test('a project pointed at a specific file gets no fallback', async () => { 195 await withHarness({ configPath: 'ci/build.yml', repoConfigPath: '.conductor.yml' }, async (h) => { 196 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 197 assert.equal(res.statusCode, 200); 198 const job = await h.waitForJob(res.json().job_id); 199 assert.equal(job.state, 'failed'); 200 assert.match(job.error ?? '', /ci\/build\.yml not found/); 201 }); 202 }); 203 204 test('a repository carrying both spellings is refused rather than guessed at', async () => { 205 await withHarness({}, async (h) => { 206 const sha = await h.commit({ '.conductor.yaml': DEFAULT_PIPELINE }, 'add the other spelling'); 207 const res = await h.trigger({ sha, ref: 'refs/heads/main' }); 208 assert.equal(res.statusCode, 200); 209 const job = await h.waitForJob(res.json().job_id); 210 assert.equal(job.state, 'failed'); 211 assert.match(job.error ?? '', /both exist/); 212 }); 213 }); 214 215 test('a pipeline still using the jobs key is told what it was renamed to', async () => { 216 const old = 'version: 1\njobs:\n build:\n image: alpine\n script: [make]\n'; 217 await withHarness({}, async (h) => { 218 const sha = await h.commit({ '.conductor.yml': old }, 'pre-rename pipeline'); 219 const res = await h.trigger({ sha, ref: 'refs/heads/main' }); 220 assert.equal(res.statusCode, 200); 221 const job = await h.waitForJob(res.json().job_id); 222 assert.equal(job.state, 'failed'); 223 assert.match(job.error ?? '', /was renamed to tasks/); 224 }); 225 }); 226 227 test('a job can be created directly, without a push payload to imitate', async () => { 228 await withHarness({}, async (h) => { 229 const res = await h.createJob({ sha: h.sha, ref: 'refs/heads/main', actor: 'alice' }); 230 assert.equal(res.statusCode, 200); 231 232 const body = res.json(); 233 assert.equal(body.status, 'created'); 234 235 const job = await h.waitForJob(body.job_id); 236 assert.equal(job.state, 'running'); 237 assert.equal(job.actor, 'alice'); 238 assert.equal(job.trigger_type, 'api'); 239 }); 240 }); 241 242 test('creating a job requires the same signature as a trigger', async () => { 243 await withHarness({}, async (h) => { 244 const unsigned = await h.app.inject({ 245 method: 'POST', 246 url: '/api/v1/projects/demo/jobs', 247 headers: { 'content-type': 'application/json' }, 248 payload: JSON.stringify({ sha: h.sha }), 249 }); 250 assert.equal(unsigned.statusCode, 401); 251 252 const wrong = await h.createJob({ sha: h.sha }, { secret: 'not-the-secret' }); 253 assert.equal(wrong.statusCode, 401); 254 }); 255 }); 256 257 test('creating a job insists on a real commit rather than inferring one', async () => { 258 await withHarness({}, async (h) => { 259 assert.equal((await h.createJob({ ref: 'refs/heads/main' })).statusCode, 400); 260 assert.equal((await h.createJob({ sha: 'abc' })).statusCode, 400); 261 }); 262 }); 263 264 test('a job can be read back, with the state of every task', async () => { 265 await withHarness({}, async (h) => { 266 const jobId = (await h.createJob({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id; 267 await h.waitForJob(jobId); 268 269 const signature = h.sign(''); 270 const signed = await h.app.inject({ 271 method: 'GET', 272 url: `/api/v1/projects/demo/jobs/${jobId}`, 273 headers: { 'content-type': 'application/json', 'x-hub-signature-256': signature }, 274 }); 275 assert.equal(signed.statusCode, 200, signed.body); 276 277 const body = signed.json(); 278 assert.equal(body.job.id, jobId); 279 assert.equal(body.job.state, 'running'); 280 assert.equal(body.tasks.length, 6); 281 assert.ok(body.tasks.every((t) => t.state === 'queued')); 282 assert.ok(body.tasks.some((t) => t.name === 'build:arch=x86_64')); 283 }); 284 }); 285 286 test('a public job is readable without any credential at all', async () => { 287 await withHarness({}, async (h) => { 288 const jobId = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id; 289 await h.waitForJob(jobId); 290 291 const res = await h.app.inject({ method: 'GET', url: `/api/v1/projects/demo/jobs/${jobId}` }); 292 assert.equal(res.statusCode, 200, res.body); 293 assert.equal(res.json().job.visibility, 'public'); 294 }); 295 }); 296 297 test('a private job needs the trigger secret, and is otherwise absent', async () => { 298 const pipeline = DEFAULT_PIPELINE.replace('version: 1\n', 'version: 1\nvisibility: private\n'); 299 await withHarness({ pipeline }, async (h) => { 300 const jobId = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id; 301 await h.waitForJob(jobId); 302 303 const anon = await h.app.inject({ method: 'GET', url: `/api/v1/projects/demo/jobs/${jobId}` }); 304 assert.equal(anon.statusCode, 404); 305 306 const signed = await h.app.inject({ 307 method: 'GET', 308 url: `/api/v1/projects/demo/jobs/${jobId}`, 309 headers: { 'content-type': 'application/json', 'x-hub-signature-256': h.sign('') }, 310 }); 311 assert.equal(signed.statusCode, 200, signed.body); 312 assert.equal(signed.json().job.visibility, 'private'); 313 }); 314 }); 315 316 test('a job of another project cannot be read through this one', async () => { 317 await withHarness({}, async (h) => { 318 const jobId = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id; 319 await h.waitForJob(jobId); 320 const signature = h.sign(''); 321 const res = await h.app.inject({ 322 method: 'GET', 323 url: `/api/v1/projects/absent/jobs/${jobId}`, 324 headers: { 'content-type': 'application/json', 'x-hub-signature-256': signature }, 325 }); 326 assert.equal(res.statusCode, 404); 327 }); 328 }); 329 330 // --- reading a task back --- 331 332 // The two paths answer identically; only what may reach a private task 333 // differs between them. 334 function taskUrls(taskId, project = 'demo') { 335 return [`/api/v1/tasks/${taskId}`, `/api/v1/projects/${project}/tasks/${taskId}`]; 336 } 337 338 // package needs build, so nothing downstream is claimable until the first 339 // wave has finished. 340 async function claimDownstream(h, name) { 341 for (const task of Object.values(await drain(h, { arches: 'x86_64,aarch64' }))) { 342 await finish(h, task.id); 343 } 344 return claimNamed(h, name, { arches: 'x86_64', features: 'sign-key' }); 345 } 346 347 test('a task can be read back on its own, and by its full name', async () => { 348 await withHarness({}, async (h) => { 349 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 350 await h.waitForJob(res.json().job_id); 351 const task = await claimNamed(h, 'build:arch=x86_64', { arches: 'x86_64' }); 352 353 const [bare, scoped] = await Promise.all( 354 taskUrls(task.id).map((url) => h.app.inject({ method: 'GET', url })) 355 ); 356 assert.equal(bare.statusCode, 200, bare.body); 357 assert.equal(scoped.statusCode, 200, scoped.body); 358 assert.deepEqual(bare.json(), scoped.json(), 'both paths describe the same task'); 359 360 const { task: body } = bare.json(); 361 assert.equal(body.id, task.id); 362 assert.equal(body.name, 'build:arch=x86_64'); 363 assert.equal(body.base_name, 'build'); 364 assert.equal(body.state, 'running'); 365 assert.equal(body.arch, 'x86_64'); 366 assert.equal(body.image, 'debian:bookworm-slim'); 367 assert.equal(body.visibility, 'public'); 368 assert.equal(body.sha, h.sha); 369 assert.equal(body.ref, 'refs/heads/main'); 370 assert.equal(body.project_id, 'demo'); 371 assert.equal(body.job_id, await jobIdOf(h, task.id)); 372 assert.equal(body.job_number, 1); 373 assert.equal(body.allow_failure, false, 'stored as 0 or 1, reported as a boolean'); 374 assert.deepEqual(body.artifact_paths, ['dist/**'], 'what it was told to collect'); 375 }); 376 }); 377 378 test('reading a task back carries its dependencies and matrix', async () => { 379 await withHarness({}, async (h) => { 380 const tr = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 381 await h.waitForJob(tr.json().job_id); 382 const task = await claimDownstream(h, 'package:arch=x86_64,pkg=musl'); 383 384 const res = await h.app.inject({ method: 'GET', url: `/api/v1/tasks/${task.id}` }); 385 assert.equal(res.statusCode, 200, res.body); 386 387 const { task: body } = res.json(); 388 assert.deepEqual(body.matrix, { pkg: 'musl' }); 389 assert.deepEqual(body.needs, ['build:arch=x86_64']); 390 assert.equal(body.workdir, '/work', 'settled when the job was created'); 391 }); 392 }); 393 394 // The point of the endpoint: a task's environment can hold project 395 // variables, and its services carry an environment of their own. 396 test('reading a task back never exposes its environment, services or script', async () => { 397 await withHarness({}, async (h) => { 398 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 399 await h.waitForJob(res.json().job_id); 400 const task = await claimDownstream(h, 'package:arch=x86_64,pkg=musl'); 401 402 // The claim payload has them, which is what makes their absence below 403 // meaningful rather than incidental. 404 assert.ok(task.env.MATRIX_PKG, 'the worker is given the environment'); 405 assert.ok(Array.isArray(task.script) && task.script.length > 0); 406 407 for (const url of taskUrls(task.id)) { 408 const res = await h.app.inject({ method: 'GET', url }); 409 assert.equal(res.statusCode, 200, res.body); 410 411 const payload = res.json(); 412 for (const key of ['env', 'services', 'script', 'spec', 'worker_token_id', 'log_key', 'storage_key']) { 413 assert.equal(payload.task[key], undefined, `${url} must not carry ${key}`); 414 } 415 // Belt and braces: a value smuggled in under any other name. 416 assert.ok(!JSON.stringify(payload).includes('MATRIX_PKG'), `${url} leaked an environment name`); 417 assert.ok(!JSON.stringify(payload).includes('./pkg.sh'), `${url} leaked the script`); 418 } 419 }); 420 }); 421 422 test('a task lists the artifacts it stored, with a usable download url', async () => { 423 await withHarness({}, async (h) => { 424 const tr = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 425 await h.waitForJob(tr.json().job_id); 426 const task = await claimNamed(h, 'build:arch=x86_64', { arches: 'x86_64' }); 427 428 await h.app.inject({ 429 method: 'POST', 430 url: `/api/v1/tasks/${task.id}/artifacts`, 431 headers: { ...h.auth, 'content-type': 'application/octet-stream', 'x-artifact-path': 'dist/app' }, 432 payload: Buffer.from('artifact bytes'), 433 }); 434 435 const res = await h.app.inject({ method: 'GET', url: `/api/v1/tasks/${task.id}` }); 436 assert.equal(res.statusCode, 200, res.body); 437 438 const { artifacts } = res.json(); 439 assert.equal(artifacts.length, 1); 440 assert.equal(artifacts[0].path, 'dist/app'); 441 assert.equal(artifacts[0].size, 14); 442 assert.match(artifacts[0].sha256, /^[0-9a-f]{64}$/); 443 assert.ok(artifacts[0].url.startsWith('http://conductor.test/'), artifacts[0].url); 444 445 // The url is the whole point of returning one, so follow it. 446 const download = await h.app.inject({ method: 'GET', url: new URL(artifacts[0].url).pathname }); 447 assert.equal(download.statusCode, 200); 448 assert.equal(download.body, 'artifact bytes'); 449 }); 450 }); 451 452 test('reading a task back needs no worker token', async () => { 453 await withHarness({}, async (h) => { 454 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 455 await h.waitForJob(res.json().job_id); 456 const task = await claimNamed(h, 'lint', {}); 457 458 // The worker protocol shares this prefix and puts a token hook over its 459 // own plugin. If that hook ever reaches these routes, this fails. 460 for (const url of taskUrls(task.id)) { 461 const res = await h.app.inject({ method: 'GET', url }); 462 assert.equal(res.statusCode, 200, `${url} should not require a token`); 463 } 464 }); 465 }); 466 467 test('a private task is absent without a credential, and needs its project named', async () => { 468 const pipeline = DEFAULT_PIPELINE.replace('version: 1\n', 'version: 1\nvisibility: private\n'); 469 await withHarness({ pipeline }, async (h) => { 470 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 471 await h.waitForJob(res.json().job_id); 472 const task = await claimNamed(h, 'lint', {}); 473 474 for (const url of taskUrls(task.id)) { 475 const anon = await h.app.inject({ method: 'GET', url }); 476 assert.equal(anon.statusCode, 404, `${url} should be absent anonymously`); 477 } 478 479 const signed = { 'content-type': 'application/json', 'x-hub-signature-256': h.sign('') }; 480 481 const scoped = await h.app.inject({ 482 method: 'GET', 483 url: `/api/v1/projects/demo/tasks/${task.id}`, 484 headers: signed, 485 }); 486 assert.equal(scoped.statusCode, 200, scoped.body); 487 assert.equal(scoped.json().task.visibility, 'private'); 488 489 // Without a project in the path there is no secret to check against, so 490 // a signature proves nothing here. 491 const bare = await h.app.inject({ method: 'GET', url: `/api/v1/tasks/${task.id}`, headers: signed }); 492 assert.equal(bare.statusCode, 404, 'a signature is meaningless without a project'); 493 }); 494 }); 495 496 test('a private task is readable by an administrator, by either path', async () => { 497 const pipeline = DEFAULT_PIPELINE.replace('version: 1\n', 'version: 1\nvisibility: private\n'); 498 // scrypt is slow, so the harness only creates the admin when asked. 499 await withHarness({ pipeline, bootstrap: true }, async (h) => { 500 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 501 await h.waitForJob(res.json().job_id); 502 const task = await claimNamed(h, 'lint', {}); 503 const { cookie } = await h.login(); 504 505 for (const url of taskUrls(task.id)) { 506 const res = await h.app.inject({ method: 'GET', url, headers: { cookie } }); 507 assert.equal(res.statusCode, 200, `${url}: ${res.body}`); 508 assert.equal(res.json().task.id, task.id); 509 } 510 }); 511 }); 512 513 test('a task of another project cannot be read through this one', async () => { 514 await withHarness({}, async (h) => { 515 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 516 await h.waitForJob(res.json().job_id); 517 const task = await claimNamed(h, 'lint', {}); 518 519 const res2 = await h.app.inject({ 520 method: 'GET', 521 url: `/api/v1/projects/absent/tasks/${task.id}`, 522 headers: { 'content-type': 'application/json', 'x-hub-signature-256': h.sign('') }, 523 }); 524 assert.equal(res2.statusCode, 404); 525 }); 526 }); 527 528 test('an unknown task is 404 by either path', async () => { 529 await withHarness({}, async (h) => { 530 for (const url of taskUrls('m2y0000000000000nope')) { 531 const res = await h.app.inject({ method: 'GET', url }); 532 assert.equal(res.statusCode, 404, `${url} should be 404`); 533 assert.equal(res.json().error, 'unknown task'); 534 } 535 }); 536 }); 537 538 test('the worker api rejects missing and invalid tokens', async () => { 539 await withHarness({}, async (h) => { 540 const none = await h.app.inject({ method: 'POST', url: '/api/v1/tasks/claim' }); 541 assert.equal(none.statusCode, 401); 542 543 const bad = await h.app.inject({ 544 method: 'POST', url: '/api/v1/tasks/claim', headers: { authorization: 'Bearer nope' }, 545 }); 546 assert.equal(bad.statusCode, 401); 547 }); 548 }); 549 550 test('independent tasks dispatch concurrently', async () => { 551 await withHarness({}, async (h) => { 552 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 553 await h.waitForJob(res.json().job_id); 554 555 // The prototype could only ever hand out one task at a time. 556 const [a, b, c] = await Promise.all([ 557 h.claim({ arches: 'x86_64,aarch64' }), 558 h.claim({ arches: 'x86_64,aarch64' }), 559 h.claim({ arches: 'x86_64,aarch64' }), 560 ]); 561 562 const names = [a, b, c].map((r) => r.json().task.name); 563 assert.equal(new Set(names).size, 3, `expected three distinct tasks, got ${names.join(', ')}`); 564 assert.deepEqual(names.slice().sort(), ['build:arch=aarch64', 'build:arch=x86_64', 'lint']); 565 }); 566 }); 567 568 test('a task is only offered to a worker that satisfies its requires', async () => { 569 await withHarness({}, async (h) => { 570 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 571 await h.waitForJob(res.json().job_id); 572 573 // Clear the three root tasks so only package and publish remain. 574 const roots = await drain(h, { arches: 'x86_64,aarch64' }); 575 assert.deepEqual(Object.keys(roots).sort(), ['build:arch=aarch64', 'build:arch=x86_64', 'lint']); 576 for (const task of Object.values(roots)) await finish(h, task.id, { success: true }); 577 578 // package requires sign-key, which this worker does not advertise. 579 const without = await h.claim({ arches: 'x86_64,aarch64' }); 580 assert.equal(without.statusCode, 204); 581 582 const with_ = await h.claim({ arches: 'x86_64,aarch64', features: 'sign-key' }); 583 assert.equal(with_.statusCode, 200); 584 assert.match(with_.json().task.name, /^package:/); 585 }); 586 }); 587 588 test('a worker is not offered work for an architecture it cannot build', async () => { 589 await withHarness({}, async (h) => { 590 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 591 await h.waitForJob(res.json().job_id); 592 593 const claimed = []; 594 for (let i = 0; i < 3; i += 1) { 595 const res = await h.claim({ arches: 'aarch64' }); 596 if (res.statusCode === 204) break; 597 claimed.push(res.json().task); 598 } 599 600 // lint has no arch so it is eligible; the x86_64 build is not. 601 assert.ok(!claimed.some((j) => j.arch === 'x86_64')); 602 assert.ok(claimed.some((j) => j.name === 'build:arch=aarch64')); 603 }); 604 }); 605 606 test('a claimed task carries everything the worker needs', async () => { 607 await withHarness({}, async (h) => { 608 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 609 await h.waitForJob(res.json().job_id); 610 const task = await claimNamed(h, 'build:arch=x86_64', { arches: 'x86_64' }); 611 612 assert.equal(task.image, 'debian:bookworm-slim'); 613 assert.deepEqual(task.script, ['./build.sh $ARCH']); 614 assert.equal(task.env.ARCH, 'x86_64'); 615 assert.equal(task.sha, h.sha); 616 assert.match(task.endpoints.source, /^http:\/\/conductor\.test\/api\/v1\/tasks\//); 617 assert.ok(task.endpoints.source.endsWith('/source.tar.gz')); 618 assert.ok(task.endpoints.log.endsWith('/log')); 619 620 // The worker resolves features from these, so a missing field 621 // silently disables them. 622 assert.deepEqual(task.requires, []); 623 assert.ok(Number.isInteger(task.heartbeat_interval) && task.heartbeat_interval > 0); 624 assert.deepEqual(task.masked, []); 625 626 // A worker is given a context and a script. Which project or job the 627 // work belongs to is none of its business, and the id it holds carries 628 // no structure to read it out of either. 629 assert.equal(task.project_id, undefined); 630 assert.equal(task.job_id, undefined); 631 assert.ok(!task.id.includes(':'), `task id should be opaque, got ${task.id}`); 632 }); 633 }); 634 635 test('a task that requires a feature reports it to the worker', async () => { 636 await withHarness({}, async (h) => { 637 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 638 await h.waitForJob(res.json().job_id); 639 const roots = await drain(h, { arches: 'x86_64,aarch64' }); 640 for (const task of Object.values(roots)) await finish(h, task.id, { success: true }); 641 642 const pkg = (await drain(h, { arches: 'x86_64', features: 'sign-key' }))['package:arch=x86_64,pkg=musl']; 643 assert.ok(pkg); 644 assert.deepEqual(pkg.requires, ['sign-key']); 645 }); 646 }); 647 648 test('the standard conductor variables are present in the task environment', async () => { 649 await withHarness({}, async (h) => { 650 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id; 651 await h.waitForJob(job); 652 const task = await claimNamed(h, 'lint', {}); 653 654 // The worker is not told any of this, but the script is: these reach 655 // the container as ordinary environment, which is the only place the 656 // build legitimately needs them. 657 assert.equal(task.env.CONDUCTOR_PROJECT, 'demo'); 658 assert.equal(task.env.CONDUCTOR_JOB_ID, job); 659 assert.equal(task.env.CONDUCTOR_JOB_NUMBER, '1'); 660 assert.equal(task.env.CONDUCTOR_TASK, 'lint'); 661 assert.equal(task.env.CONDUCTOR_TASK_ID, task.id); 662 assert.equal(task.env.CONDUCTOR_SHA, h.sha); 663 assert.equal(task.env.CONDUCTOR_REF, 'refs/heads/main'); 664 assert.equal(task.env.CONDUCTOR_ATTEMPT, '1'); 665 }); 666 }); 667 668 test('the source tarball is served from the mirror', async () => { 669 await withHarness({}, async (h) => { 670 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 671 await h.waitForJob(res.json().job_id); 672 const task = await claimNamed(h, 'lint', {}); 673 674 const res2 = await h.app.inject({ 675 method: 'GET', 676 url: `/api/v1/tasks/${task.id}/source.tar.gz`, 677 headers: h.auth, 678 }); 679 assert.equal(res2.statusCode, 200); 680 assert.equal(res2.headers['content-type'], 'application/gzip'); 681 // gzip magic number 682 assert.equal(res2.rawPayload[0], 0x1f); 683 assert.equal(res2.rawPayload[1], 0x8b); 684 }); 685 }); 686 687 test('a worker cannot touch a task it does not hold', async () => { 688 await withHarness({}, async (h) => { 689 const tr = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 690 await h.waitForJob(tr.json().job_id); 691 const task = await claimNamed(h, 'lint', {}); 692 693 const other = await h.services.workerTokens.create('intruder'); 694 const res = await h.app.inject({ 695 method: 'POST', 696 url: `/api/v1/tasks/${task.id}/log`, 697 headers: { authorization: `Bearer ${other.token}`, 'content-type': 'application/octet-stream' }, 698 payload: Buffer.from('malicious'), 699 }); 700 assert.equal(res.statusCode, 403); 701 }); 702 }); 703 704 test('log appends are resumable and deduplicated by offset', async () => { 705 await withHarness({}, async (h) => { 706 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 707 await h.waitForJob(res.json().job_id); 708 const task = await claimNamed(h, 'lint', {}); 709 const url = `/api/v1/tasks/${task.id}/log`; 710 const headers = { ...h.auth, 'content-type': 'application/octet-stream' }; 711 712 const first = await h.app.inject({ 713 method: 'POST', url, headers: { ...headers, 'x-log-offset': '0' }, payload: Buffer.from('hello\n'), 714 }); 715 assert.deepEqual(first.json(), { size: 6, written: 6, truncated: false }); 716 717 // A retry resends what was already stored plus new data. 718 const retry = await h.app.inject({ 719 method: 'POST', url, headers: { ...headers, 'x-log-offset': '0' }, payload: Buffer.from('hello\nworld\n'), 720 }); 721 assert.deepEqual(retry.json(), { size: 12, written: 6, truncated: false }); 722 723 // A gap is refused, and says where to resume. 724 const gap = await h.app.inject({ 725 method: 'POST', url, headers: { ...headers, 'x-log-offset': '9999' }, payload: Buffer.from('x'), 726 }); 727 assert.equal(gap.statusCode, 409); 728 assert.equal(gap.json().expected_offset, 12); 729 730 const tail = await h.services.logs.read(await jobIdOf(h, task.id), task.id, { offset: 0, limit: 1024 }); 731 assert.equal(tail.data.toString('utf8'), 'hello\nworld\n'); 732 assert.equal(tail.size, 12); 733 734 const partial = await h.services.logs.read(await jobIdOf(h, task.id), task.id, { offset: 6, limit: 1024 }); 735 assert.equal(partial.data.toString('utf8'), 'world\n'); 736 }); 737 }); 738 739 test('an oversized log chunk is refused', async () => { 740 await withHarness({}, async (h) => { 741 const tr = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 742 await h.waitForJob(tr.json().job_id); 743 const task = await claimNamed(h, 'lint', {}); 744 745 // Artifacts are streamed and may be large, but a single log append is 746 // buffered, so it has to be bounded or a worker can exhaust memory. 747 const res = await h.app.inject({ 748 method: 'POST', 749 url: `/api/v1/tasks/${task.id}/log`, 750 headers: { ...h.auth, 'content-type': 'application/octet-stream' }, 751 payload: Buffer.alloc(2 * 1024 * 1024, 0x41), 752 }); 753 754 assert.equal(res.statusCode, 413); 755 assert.equal(await h.services.logs.size(await jobIdOf(h, task.id), task.id), 0, 'nothing should have been written'); 756 }); 757 }); 758 759 test('a finished log moves to storage and is still readable', async () => { 760 await withHarness({}, async (h) => { 761 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 762 await h.waitForJob(res.json().job_id); 763 const task = await claimNamed(h, 'lint', {}); 764 765 await h.app.inject({ 766 method: 'POST', 767 url: `/api/v1/tasks/${task.id}/log`, 768 headers: { ...h.auth, 'content-type': 'application/octet-stream' }, 769 payload: Buffer.from('compiling\ndone\n'), 770 }); 771 await finish(h, task.id, { success: true }); 772 773 const row = await h.services.db.get('SELECT log_key FROM tasks WHERE id = {id}', { id: task.id }); 774 assert.ok(row.log_key, 'the log should have moved to storage'); 775 const object = await h.services.storage.get(row.log_key); 776 const parts = []; 777 for await (const part of object.stream) parts.push(part); 778 assert.equal(Buffer.concat(parts).toString('utf8'), 'compiling\ndone\n'); 779 }); 780 }); 781 782 test('artifacts are stored, hashed, and stripped of traversal', async () => { 783 await withHarness({}, async (h) => { 784 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 785 await h.waitForJob(res.json().job_id); 786 const task = await claimNamed(h, 'build:arch=x86_64', { arches: 'x86_64' }); 787 788 const upload = await h.app.inject({ 789 method: 'POST', 790 url: `/api/v1/tasks/${task.id}/artifacts`, 791 headers: { 792 ...h.auth, 793 'content-type': 'application/octet-stream', 794 'x-artifact-path': '../../../etc/passwd', 795 }, 796 payload: Buffer.from('artifact bytes'), 797 }); 798 assert.equal(upload.statusCode, 200); 799 800 const body = upload.json(); 801 assert.equal(body.path, 'etc/passwd', 'traversal should be stripped, not honoured'); 802 assert.equal(body.size, 14); 803 assert.match(body.sha256, /^[0-9a-f]{64}$/); 804 805 const artifacts = await h.services.db.all( 806 'SELECT id FROM artifacts WHERE task_id = {task}', { task: task.id } 807 ); 808 assert.equal(artifacts.length, 1); 809 810 const download = await h.app.inject({ 811 method: 'GET', 812 url: `/api/v1/projects/demo/jobs/${await jobIdOf(h, task.id)}/tasks/${task.id}/artifacts/${artifacts[0].id}`, 813 }); 814 assert.equal(download.statusCode, 200); 815 assert.equal(download.body, 'artifact bytes'); 816 }); 817 }); 818 819 test('an artifact shorter than its content-length is rejected', 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().job_id); 823 const task = await claimNamed(h, 'lint', {}); 824 825 const res2 = await h.app.inject({ 826 method: 'POST', 827 url: `/api/v1/tasks/${task.id}/artifacts`, 828 headers: { 829 ...h.auth, 830 'content-type': 'application/octet-stream', 831 'content-length': '500', 832 'x-artifact-path': 'short.bin', 833 }, 834 payload: Buffer.from('tiny'), 835 }); 836 assert.equal(res2.statusCode, 400); 837 }); 838 }); 839 840 test('success releases dependents, matched on shared dimensions', async () => { 841 await withHarness({}, async (h) => { 842 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id; 843 await h.waitForJob(job); 844 845 const roots = await drain(h, { arches: 'x86_64,aarch64', features: 'sign-key' }); 846 await finish(h, roots['build:arch=x86_64'].id, { success: true }); 847 848 // Only the x86_64 package becomes eligible; the aarch64 one still waits 849 // on its own architecture's build. 850 const next = await drain(h, { arches: 'x86_64,aarch64', features: 'sign-key' }); 851 assert.ok(next['package:arch=x86_64,pkg=musl'], 'x86_64 package should be released'); 852 assert.ok(!next['package:arch=aarch64,pkg=musl'], 'aarch64 package should still be waiting'); 853 854 const state = tasksByName(await jobState(h, job)); 855 assert.equal(state['package:arch=aarch64,pkg=musl'].state, 'queued'); 856 }); 857 }); 858 859 test('failure skips transitive dependents, not just direct ones', async () => { 860 await withHarness({}, async (h) => { 861 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id; 862 await h.waitForJob(job); 863 864 const build = await claimNamed(h, 'build:arch=x86_64', { arches: 'x86_64' }); 865 const outcome = await finish(h, build.id, { success: false, exitCode: 2, error: 'compile failed' }); 866 assert.equal(outcome.state, 'failed'); 867 868 const tasks = tasksByName(await jobState(h, job)); 869 // Direct dependent. 870 assert.equal(tasks['package:arch=x86_64,pkg=musl'].state, 'skipped'); 871 // Transitive dependent: the prototype left this queued and dispatchable. 872 assert.equal(tasks.publish.state, 'skipped'); 873 // Unrelated branches are untouched. 874 assert.equal(tasks['build:arch=aarch64'].state, 'queued'); 875 assert.equal(tasks['package:arch=aarch64,pkg=musl'].state, 'queued'); 876 }); 877 }); 878 879 test('a job settles as failed once every task is terminal', async () => { 880 await withHarness({}, async (h) => { 881 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id; 882 await h.waitForJob(job); 883 884 const roots = await drain(h, { arches: 'x86_64,aarch64', features: 'sign-key' }); 885 886 // Failing the x86_64 build skips its package and, transitively, publish. 887 await finish(h, roots['build:arch=x86_64'].id, { success: false, exitCode: 1 }); 888 await finish(h, roots['build:arch=aarch64'].id, { success: true }); 889 await finish(h, roots.lint.id, { success: true }); 890 891 const released = await drain(h, { arches: 'aarch64', features: 'sign-key' }); 892 const last = await finish(h, released['package:arch=aarch64,pkg=musl'].id, { success: true }); 893 894 assert.equal(last.job_state, 'failed'); 895 assert.equal((await jobState(h, job)).job.state, 'failed'); 896 }); 897 }); 898 899 test('a job settles as success when everything passes', async () => { 900 // Quoted, because an unquoted true in YAML is a boolean, not a command. 901 const pipeline = ` 902 version: 1 903 tasks: 904 a: 905 image: alpine 906 script: ['true'] 907 b: 908 image: alpine 909 script: ['true'] 910 needs: [a] 911 `; 912 await withHarness({ pipeline }, async (h) => { 913 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id; 914 await h.waitForJob(job); 915 916 const a = await claimNamed(h, 'a', {}); 917 await finish(h, a.id, { success: true }); 918 const b = await claimNamed(h, 'b', {}); 919 const last = await finish(h, b.id, { success: true }); 920 921 assert.equal(last.job_state, 'success'); 922 assert.equal((await jobState(h, job)).job.state, 'success'); 923 }); 924 }); 925 926 test('an allowed failure does not fail the job or block dependents', async () => { 927 const pipeline = ` 928 version: 1 929 tasks: 930 flaky: 931 image: alpine 932 script: ['false'] 933 allow_failure: true 934 after: 935 image: alpine 936 script: ['true'] 937 needs: [flaky] 938 `; 939 await withHarness({ pipeline }, async (h) => { 940 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id; 941 await h.waitForJob(job); 942 943 const flaky = await claimNamed(h, 'flaky', {}); 944 const outcome = await finish(h, flaky.id, { success: false, exitCode: 1 }); 945 assert.deepEqual(outcome.skipped, []); 946 947 const after = await claimNamed(h, 'after', {}); 948 assert.ok(after, 'dependent should still run after an allowed failure'); 949 const last = await finish(h, after.id, { success: true }); 950 assert.equal(last.job_state, 'success'); 951 assert.equal((await jobState(h, job)).job.state, 'success'); 952 }); 953 }); 954 955 test('a task with retries left is requeued instead of failing the job', async () => { 956 const pipeline = ` 957 version: 1 958 tasks: 959 retried: 960 image: alpine 961 script: [maybe] 962 max_attempts: 2 963 `; 964 await withHarness({ pipeline }, async (h) => { 965 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id; 966 await h.waitForJob(job); 967 968 const first = await claimNamed(h, 'retried', {}); 969 const outcome = await finish(h, first.id, { success: false, exitCode: 1 }); 970 assert.equal(outcome.state, 'queued'); 971 assert.equal(outcome.retry, true); 972 973 const second = await claimNamed(h, 'retried', {}); 974 assert.equal(second.attempt, 2); 975 const last = await finish(h, second.id, { success: false, exitCode: 1 }); 976 assert.equal(last.state, 'failed'); 977 assert.equal((await jobState(h, job)).job.state, 'failed'); 978 }); 979 }); 980 981 test('heartbeats keep a task alive and report cancellation', async () => { 982 await withHarness({}, async (h) => { 983 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id; 984 await h.waitForJob(job); 985 const task = await claimNamed(h, 'lint', {}); 986 987 const beat = await h.app.inject({ 988 method: 'POST', 989 url: `/api/v1/tasks/${task.id}/heartbeat`, 990 headers: { ...h.auth, 'content-type': 'application/json' }, 991 payload: '{}', 992 }); 993 assert.deepEqual(beat.json(), { cancelled: false }); 994 995 await h.services.scheduler.cancelJob(job); 996 997 const after = await h.app.inject({ 998 method: 'POST', 999 url: `/api/v1/tasks/${task.id}/heartbeat`, 1000 headers: { ...h.auth, 'content-type': 'application/json' }, 1001 payload: '{}', 1002 }); 1003 assert.equal(after.json().cancelled, true); 1004 assert.equal((await jobState(h, job)).job.state, 'cancelled'); 1005 }); 1006 }); 1007 1008 test('the reaper fails a task whose worker stopped reporting', async () => { 1009 await withHarness({}, async (h) => { 1010 const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().job_id; 1011 await h.waitForJob(job); 1012 const task = await claimNamed(h, 'lint', {}); 1013 assert.equal(tasksByName(await jobState(h, job)).lint.state, 'running'); 1014 1015 // Backdate the heartbeat rather than waiting out the real timeout. 1016 await h.services.db.run( 1017 'UPDATE tasks SET heartbeat_at = {then} WHERE id = {id}', 1018 { then: Date.now() - 10 * 60 * 1000, id: task.id } 1019 ); 1020 1021 const reaped = await h.services.scheduler.reap(); 1022 assert.ok(reaped.length > 0); 1023 1024 const after = tasksByName(await jobState(h, job)).lint; 1025 assert.equal(after.state, 'failed'); 1026 assert.match(after.error, /stopped reporting/); 1027 1028 // The worker that lost the task can no longer complete it. 1029 const late = await finish(h, task.id, { success: true }); 1030 assert.match(String(late.error ?? ''), /not running|another worker/); 1031 }); 1032 }); 1033 1034 test('a second push produces an independent, numbered job', async () => { 1035 await withHarness({}, async (h) => { 1036 const first = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json(); 1037 await h.waitForJob(first.job_id); 1038 const sha2 = await h.commit({ 'README.md': 'changed\n' }, 'second commit'); 1039 const second = (await h.trigger({ sha: sha2, ref: 'refs/heads/main' })).json(); 1040 await h.waitForJob(second.job_id); 1041 1042 assert.notEqual(first.job_id, second.job_id); 1043 const detail = await jobState(h, second.job_id); 1044 assert.equal(detail.job.number, 2); 1045 assert.equal(detail.job.title, 'second commit'); 1046 assert.equal(detail.job.head_sha, sha2); 1047 }); 1048 }); 1049 1050 test('jobs can be listed and filtered by project', apiGone, async () => { 1051 await withHarness({}, async (h) => { 1052 await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 1053 1054 const all = await h.app.inject({ method: 'GET', url: '/api/jobs' }); 1055 assert.equal(all.json().jobs.length, 1); 1056 1057 const mine = await h.app.inject({ method: 'GET', url: '/api/jobs?project=demo' }); 1058 assert.equal(mine.json().jobs.length, 1); 1059 1060 const other = await h.app.inject({ method: 'GET', url: '/api/jobs?project=absent' }); 1061 assert.equal(other.json().jobs.length, 0); 1062 }); 1063 }); 1064 1065 test('unknown jobs, tasks and artifacts return 404', apiGone, async () => { 1066 await withHarness({}, async (h) => { 1067 for (const url of ['/api/jobs/nope', '/api/tasks/nope', '/api/tasks/nope/log', '/api/artifacts/nope']) { 1068 const res = await h.app.inject({ method: 'GET', url }); 1069 assert.equal(res.statusCode, 404, `${url} should be 404`); 1070 } 1071 }); 1072 }); 1073 1074 test('a disabled project refuses triggers', async () => { 1075 await withHarness({}, async (h) => { 1076 await h.services.projects.setEnabled('demo', false); 1077 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 1078 assert.equal(res.statusCode, 409); 1079 }); 1080 }); 1081 1082 test('a trigger for an unknown project is a 404', async () => { 1083 await withHarness({}, async (h) => { 1084 const res = await h.app.inject({ 1085 method: 'POST', 1086 url: '/api/v1/projects/absent/trigger', 1087 headers: { 'content-type': 'application/json' }, 1088 payload: JSON.stringify({ sha: h.sha }), 1089 }); 1090 assert.equal(res.statusCode, 404); 1091 }); 1092 }); 1093 1094 test('a commit that is not in the repository is refused', async () => { 1095 await withHarness({}, async (h) => { 1096 const res = await h.trigger({ sha: 'b'.repeat(40), ref: 'refs/heads/main' }); 1097 assert.equal(res.statusCode, 200); 1098 const job = await h.waitForJob(res.json().job_id); 1099 assert.equal(job.state, 'failed'); 1100 assert.match(job.error ?? '', /not present in the mirror/); 1101 }); 1102 }); 1103 1104 test('a task is told to fetch an archive, and never the repository itself', async () => { 1105 await withHarness({}, async (h) => { 1106 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 1107 await h.waitForJob(res.json().job_id); 1108 const task = await claimNamed(h, 'lint', {}); 1109 1110 assert.match(task.endpoints.source, /\/source\.tar\.gz$/); 1111 1112 // A worker holds no repository credentials and can reach no ref other 1113 // than the commit it was given work for, so the repository location 1114 // has no business being in the payload. 1115 assert.equal(task.source, undefined, 'there is one way to get the source, so there is no mode'); 1116 assert.ok(!JSON.stringify(task).includes(h.repoDir), 'the repository path must not reach the worker'); 1117 }); 1118 }); 1119 1120 test('the source archive is the bare tree, with no wrapping directory', async () => { 1121 // The worker hands it to docker cp with the working directory as the 1122 // destination, and docker creates that directory as it extracts. A 1123 // wrapping directory in the archive would land one level too deep. 1124 await withHarness({}, async (h) => { 1125 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 1126 await h.waitForJob(res.json().job_id); 1127 const task = await claimNamed(h, 'lint', {}); 1128 1129 const archive = await h.app.inject({ 1130 method: 'GET', 1131 url: new URL(task.endpoints.source).pathname, 1132 headers: h.auth, 1133 }); 1134 assert.equal(archive.statusCode, 200); 1135 1136 // spawnSync rather than the promisified execFile, which has no way to 1137 // supply stdin and simply waits for input that never comes. 1138 const { spawnSync } = await import('node:child_process'); 1139 const listing = spawnSync('tar', ['-tzf', '-'], { 1140 input: archive.rawPayload, 1141 encoding: 'utf8', 1142 }); 1143 assert.equal(listing.status, 0, `tar failed: ${listing.stderr}`); 1144 1145 const entries = listing.stdout.split('\n').filter(Boolean); 1146 assert.ok(entries.length > 0, 'the archive should not be empty'); 1147 assert.ok( 1148 entries.includes('.conductor.yml'), 1149 `the pipeline should sit at the root of the archive, got:\n${listing.stdout}`, 1150 ); 1151 }); 1152 }); 1153 1154 test('the working directory is resolved from the pipeline, then the project', async () => { 1155 const pipeline = [ 1156 'version: 1', 1157 'workdir: /usr/src/app', 1158 'defaults:', 1159 ' image: alpine:3', 1160 'tasks:', 1161 ' build:', 1162 " script: ['make']", 1163 '', 1164 ].join('\n'); 1165 1166 await withHarness({ pipeline }, async (h) => { 1167 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 1168 await h.waitForJob(res.json().job_id); 1169 const task = await claimNamed(h, 'build', {}); 1170 assert.equal(task.workdir, '/usr/src/app', 'the repository has the final say'); 1171 }); 1172 1173 // With the repository silent, the project decides. 1174 await withHarness({}, async (h) => { 1175 await h.services.db.run( 1176 'UPDATE projects SET workdir = {dir} WHERE id = {id}', 1177 { dir: '/srv/build', id: h.project.id } 1178 ); 1179 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 1180 await h.waitForJob(res.json().job_id); 1181 const task = await claimNamed(h, 'lint', {}); 1182 assert.equal(task.workdir, '/srv/build'); 1183 }); 1184 1185 // With neither, there is a default. 1186 await withHarness({}, async (h) => { 1187 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 1188 await h.waitForJob(res.json().job_id); 1189 const task = await claimNamed(h, 'lint', {}); 1190 assert.equal(task.workdir, '/work'); 1191 }); 1192 }); 1193 1194 test('an unusable working directory is refused rather than sanitised', async () => { 1195 for (const workdir of ['relative/path', '/', '/a/../b', '/has space']) { 1196 const pipeline = [ 1197 'version: 1', 1198 `workdir: ${JSON.stringify(workdir)}`, 1199 'defaults:', 1200 ' image: alpine:3', 1201 'tasks:', 1202 ' build:', 1203 " script: ['make']", 1204 '', 1205 ].join('\n'); 1206 1207 await withHarness({ pipeline }, async (h) => { 1208 const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); 1209 assert.equal(res.statusCode, 200); 1210 const job = await h.waitForJob(res.json().job_id); 1211 assert.equal(job.state, 'failed', `expected ${JSON.stringify(workdir)} to be refused`); 1212 assert.ok((job.error ?? '').includes('workdir'), `error should mention workdir: ${job.error}`); 1213 }); 1214 } 1215 }); 1216 1217 test('the trigger secret is not stored in the clear', async () => { 1218 await withHarness({}, async (h) => { 1219 const row = await h.services.db.get('SELECT trigger_secret FROM projects WHERE id = {id}', { id: 'demo' }); 1220 assert.ok(row.trigger_secret.startsWith('v1.'), 'expected an encrypted value'); 1221 assert.ok(!row.trigger_secret.includes('test-secret')); 1222 assert.equal(h.services.projects.triggerSecret(await h.services.projects.get('demo')), 'test-secret'); 1223 }); 1224 }); 1225 1226 test('worker tokens are stored only as hashes', async () => { 1227 await withHarness({}, async (h) => { 1228 const rows = await h.services.db.all('SELECT token_hash FROM worker_tokens'); 1229 assert.ok(rows.length > 0); 1230 for (const row of rows) { 1231 assert.match(row.token_hash, /^[0-9a-f]{64}$/); 1232 assert.notEqual(row.token_hash, h.worker.token); 1233 } 1234 }); 1235 });