conductor

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

worker.test.js (21973B)


      1 // test/worker.test.js - worker units that need no container runtime
      2 //
      3 // The full loop against real Docker lives in worker-docker.test.js.
      4 
      5 import test from 'node:test';
      6 import assert from 'node:assert/strict';
      7 import fs from 'node:fs/promises';
      8 import os from 'node:os';
      9 import path from 'node:path';
     10 import { spawn } from 'node:child_process';
     11 import { fileURLToPath } from 'node:url';
     12 import { EventEmitter } from 'node:events';
     13 import { PassThrough, Readable } from 'node:stream';
     14 import { gzipSync } from 'node:zlib';
     15 import { createServer } from 'node:http';
     16 import { loadWorkerConfig, featureNames } from '../src/worker/config.js';
     17 import { buildScript, shellQuote } from '../src/worker/script.js';
     18 import { containerName, createRuntime } from '../src/worker/docker.js';
     19 import { createLogStream } from '../src/worker/logstream.js';
     20 import { resolveFeatures, patternPrefix, copyOutPaths, runTask } from '../src/worker/task.js';
     21 import { collectArtifacts, shouldCollect } from '../src/worker/artifacts.js';
     22 
     23 async function tempDir(prefix = 'conductor-worker-') {
     24   return fs.mkdtemp(path.join(os.tmpdir(), prefix));
     25 }
     26 
     27 async function withConfigEnv(env, fn) {
     28   const saved = {};
     29   for (const key of Object.keys(process.env)) {
     30     if (key.startsWith('CONDUCTOR_')) {
     31       saved[key] = process.env[key];
     32       delete process.env[key];
     33     }
     34   }
     35   Object.assign(process.env, env);
     36   try {
     37     return await fn();
     38   } finally {
     39     for (const key of Object.keys(process.env)) {
     40       if (key.startsWith('CONDUCTOR_')) delete process.env[key];
     41     }
     42     Object.assign(process.env, saved);
     43   }
     44 }
     45 
     46 test('worker config loads from json and reports features', async () => {
     47   const dir = await tempDir();
     48   const file = path.join(dir, 'worker.json');
     49   await fs.writeFile(file, JSON.stringify({
     50     conductor_url: 'https://ci.example.com/',
     51     name: 'pi',
     52     token: 'abc',
     53     arches: ['aarch64'],
     54     features: {
     55       dind: { privileged: true },
     56       'sign-key': { mounts: ['/srv/k:/keys/k:ro'], env: { KEY: '/keys/k' } },
     57     },
     58   }));
     59 
     60   const cfg = await withConfigEnv({}, () => loadWorkerConfig(file));
     61   assert.equal(cfg.conductor_url, 'https://ci.example.com');
     62   assert.deepEqual(featureNames(cfg), ['dind', 'sign-key']);
     63   assert.equal(cfg.features.dind.privileged, true);
     64   assert.deepEqual(cfg.features['sign-key'].mounts, ['/srv/k:/keys/k:ro']);
     65   await fs.rm(dir, { recursive: true, force: true });
     66 });
     67 
     68 test('worker config reads the token from a file', async () => {
     69   const dir = await tempDir();
     70   await fs.writeFile(path.join(dir, 'token'), 'secret-token\n');
     71   const file = path.join(dir, 'worker.json');
     72   await fs.writeFile(file, JSON.stringify({ token_file: path.join(dir, 'token') }));
     73 
     74   const cfg = await withConfigEnv({}, () => loadWorkerConfig(file));
     75   assert.equal(cfg.token, 'secret-token');
     76   await fs.rm(dir, { recursive: true, force: true });
     77 });
     78 
     79 test('environment overrides the worker config file', async () => {
     80   const dir = await tempDir();
     81   const file = path.join(dir, 'worker.json');
     82   await fs.writeFile(file, JSON.stringify({ token: 'x', arches: ['x86_64'], concurrency: 1 }));
     83 
     84   const cfg = await withConfigEnv({
     85     CONDUCTOR_WORKER_ARCHES: 'aarch64,riscv64',
     86     CONDUCTOR_WORKER_CONCURRENCY: '4',
     87   }, () => loadWorkerConfig(file));
     88 
     89   assert.deepEqual(cfg.arches, ['aarch64', 'riscv64']);
     90   assert.equal(cfg.concurrency, 4);
     91   await fs.rm(dir, { recursive: true, force: true });
     92 });
     93 
     94 test('worker config without a token is rejected', async () => {
     95   const dir = await tempDir();
     96   const file = path.join(dir, 'worker.json');
     97   await fs.writeFile(file, JSON.stringify({ conductor_url: 'http://x' }));
     98   await assert.rejects(withConfigEnv({}, () => loadWorkerConfig(file)), /no worker token/);
     99   await fs.rm(dir, { recursive: true, force: true });
    100 });
    101 
    102 test('a malformed feature is reported with its path', async () => {
    103   const dir = await tempDir();
    104   const file = path.join(dir, 'worker.json');
    105   await fs.writeFile(file, JSON.stringify({
    106     token: 'x',
    107     features: { bad: { mounts: ['no-colon'], nonsense: 1 } },
    108   }));
    109   // Both problems are reported together; the order between them is not
    110   // part of the contract.
    111   await assert.rejects(withConfigEnv({}, () => loadWorkerConfig(file)), (e) => {
    112     assert.match(e.message, /features\.bad\.mounts\[0\]/);
    113     assert.match(e.message, /features\.bad\.nonsense/);
    114     return true;
    115   });
    116   await fs.rm(dir, { recursive: true, force: true });
    117 });
    118 
    119 test('shell quoting survives quotes and metacharacters', () => {
    120   assert.equal(shellQuote('simple'), "'simple'");
    121   assert.equal(shellQuote("it's"), "'it'\\''s'");
    122   assert.equal(shellQuote('$(rm -rf /)'), "'$(rm -rf /)'");
    123 });
    124 
    125 test('a generated script echoes each command and stops on failure', () => {
    126   const script = buildScript(['npm ci', "echo 'hi'"]);
    127   assert.match(script, /^#!\/bin\/sh\nset -e\n/);
    128   assert.ok(script.includes("printf '%s\\n' '$ npm ci'"));
    129   assert.ok(script.includes('npm ci\n'));
    130   // The echo of a command containing quotes stays a single argument.
    131   assert.ok(script.includes("printf '%s\\n' '$ echo '\\''hi'\\'''"));
    132 });
    133 
    134 test('container names are valid for docker and stay unique', () => {
    135   const a = containerName('conductor', 'run1:package:arch=x86_64,pkg=musl');
    136   const b = containerName('conductor', 'run1:package:arch=x86_64,pkg=busybox');
    137   assert.match(a, /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/);
    138   assert.notEqual(a, b);
    139   // Distinct tasks that differ only past the truncation point still differ.
    140   const long = 'x'.repeat(200);
    141   assert.notEqual(containerName('c', `${long}a`), containerName('c', `${long}b`));
    142 });
    143 
    144 test('job arguments carry mounts, env, network and privilege', () => {
    145   const runtime = createRuntime({ docker: 'docker', shell: 'sh' });
    146   const args = runtime.buildRunArgs({
    147     name: 'job1',
    148     image: 'alpine:3',
    149     network: 'net1',
    150     workdir: '/work',
    151     env: { FOO: 'bar' },
    152     mounts: ['/srv/k:/keys/k:ro'],
    153     privileged: true,
    154     entrypoint: 'sh',
    155     command: ['/tmp/entrypoint.sh'],
    156   });
    157 
    158   assert.equal(args[0], 'run');
    159   assert.ok(args.includes('--rm'));
    160   assert.deepEqual(args.slice(args.indexOf('--network'), args.indexOf('--network') + 2), ['--network', 'net1']);
    161   assert.ok(args.includes('--privileged'));
    162   assert.ok(args.includes('/srv/k:/keys/k:ro'));
    163   assert.deepEqual(args.slice(args.indexOf('--workdir'), args.indexOf('--workdir') + 2), ['--workdir', '/work']);
    164   assert.ok(args.includes('FOO=bar'));
    165   // The image must come before its command.
    166   assert.ok(args.indexOf('alpine:3') < args.indexOf('/tmp/entrypoint.sh'));
    167 });
    168 
    169 test('a task container is created without sharing anything with the worker', () => {
    170   // The whole point of copying the tree in: no path of the worker's is
    171   // handed to the daemon, so nothing has to line up between the two.
    172   const runtime = createRuntime({ docker: 'docker', shell: 'sh' });
    173   const args = runtime.buildCreateArgs({
    174     name: 'job1',
    175     image: 'alpine:3',
    176     network: 'net1',
    177     workdir: '/work',
    178     env: { FOO: 'bar' },
    179     entrypoint: 'sh',
    180     command: ['/tmp/entrypoint.sh'],
    181   });
    182 
    183   assert.equal(args[0], 'create');
    184   assert.ok(!args.includes('--attach'), 'create takes no attach flags');
    185   assert.ok(!args.includes('--volume'), 'nothing of the worker is mounted in');
    186   assert.ok(!args.some((a) => a.includes(':/work')), 'the tree is copied in, never bind mounted');
    187   assert.deepEqual(args.slice(args.indexOf('--workdir'), args.indexOf('--workdir') + 2), ['--workdir', '/work']);
    188   assert.ok(args.indexOf('alpine:3') < args.indexOf('/tmp/entrypoint.sh'));
    189 });
    190 
    191 test('a feature still contributes its bind mounts', () => {
    192   // Those name host paths an operator chose deliberately, which is a
    193   // different thing from the worker sharing its own directories.
    194   const runtime = createRuntime({ docker: 'docker', shell: 'sh' });
    195   const args = runtime.buildCreateArgs({
    196     name: 'job1',
    197     image: 'alpine:3',
    198     mounts: ['/srv/keys/build.rsa:/keys/build.rsa:ro'],
    199     entrypoint: 'sh',
    200     command: ['/tmp/entrypoint.sh'],
    201   });
    202 
    203   assert.ok(args.includes('/srv/keys/build.rsa:/keys/build.rsa:ro'));
    204 });
    205 
    206 test('features resolve into mounts, env and privilege', () => {
    207   const available = {
    208     dind: { mounts: [], env: {}, devices: [], privileged: true },
    209     'sign-key': { mounts: ['/srv/k:/keys/k:ro'], env: { KEY: '/keys/k' }, devices: [], privileged: false },
    210   };
    211   const resolved = resolveFeatures({ requires: ['sign-key', 'dind'] }, available);
    212   assert.deepEqual(resolved.mounts, ['/srv/k:/keys/k:ro']);
    213   assert.deepEqual(resolved.env, { KEY: '/keys/k' });
    214   assert.equal(resolved.privileged, true);
    215   assert.deepEqual(resolved.missing, []);
    216 });
    217 
    218 test('an unknown required feature is reported rather than ignored', () => {
    219   const resolved = resolveFeatures({ requires: ['nope'] }, {});
    220   assert.deepEqual(resolved.missing, ['nope']);
    221 });
    222 
    223 test('an artifact pattern reduces to the part that can be copied out', () => {
    224   // A stopped container cannot expand a glob, so only the leading
    225   // literal part of a pattern can be asked for by name.
    226   assert.equal(patternPrefix('out/**'), 'out');
    227   assert.equal(patternPrefix('build/*.tar.gz'), 'build');
    228   assert.equal(patternPrefix('a/b/c/**'), 'a/b/c');
    229   assert.equal(patternPrefix('dist/app.tar'), 'dist/app.tar');
    230 
    231   // A pattern that could match anywhere has no usable prefix.
    232   assert.equal(patternPrefix('**/*.log'), '');
    233   assert.equal(patternPrefix('*'), '');
    234 });
    235 
    236 test('overlapping artifact paths are copied out once', () => {
    237   assert.deepEqual(copyOutPaths(['out/**', 'out/deep/**', 'build/*']), ['build', 'out']);
    238   assert.deepEqual(copyOutPaths(['out/**', 'out/**']), ['out']);
    239 
    240   // One pattern matching anywhere means the whole tree comes back, and
    241   // there is no point copying anything else separately.
    242   assert.deepEqual(copyOutPaths(['**/*.log', 'out/**']), ['']);
    243 
    244   // A prefix must be a whole path segment: outer must not swallow out.
    245   assert.deepEqual(copyOutPaths(['out/**', 'outer/**']), ['out', 'outer']);
    246 });
    247 
    248 test('artifact collection globs, and refuses to leave the workspace', async () => {
    249   const dir = await tempDir();
    250   const outside = await tempDir('conductor-outside-');
    251   await fs.writeFile(path.join(outside, 'secret.txt'), 'do not upload me');
    252 
    253   await fs.mkdir(path.join(dir, 'dist/nested'), { recursive: true });
    254   await fs.mkdir(path.join(dir, '.conductor'), { recursive: true });
    255   await fs.writeFile(path.join(dir, 'dist/app.bin'), 'binary');
    256   await fs.writeFile(path.join(dir, 'dist/nested/deep.bin'), 'deep');
    257   await fs.writeFile(path.join(dir, '.conductor/script.sh'), 'set -e');
    258   await fs.symlink(path.join(outside, 'secret.txt'), path.join(dir, 'dist/escape.txt'));
    259 
    260   const found = await collectArtifacts(dir, ['dist/**'], { logger: { warn: () => {} } });
    261   const names = found.map((f) => f.path).sort();
    262 
    263   assert.ok(names.includes('dist/app.bin'));
    264   assert.ok(names.includes('dist/nested/deep.bin'));
    265   assert.ok(!names.includes('dist/escape.txt'), 'symlink out of the workspace must be refused');
    266   assert.ok(!names.some((n) => n.startsWith('.conductor')));
    267   assert.equal(found.find((f) => f.path === 'dist/app.bin').size, 6);
    268 
    269   await fs.rm(dir, { recursive: true, force: true });
    270   await fs.rm(outside, { recursive: true, force: true });
    271 });
    272 
    273 test('artifact when controls collection', () => {
    274   assert.equal(shouldCollect('on_success', true), true);
    275   assert.equal(shouldCollect('on_success', false), false);
    276   assert.equal(shouldCollect('on_failure', false), true);
    277   assert.equal(shouldCollect('on_failure', true), false);
    278   assert.equal(shouldCollect('always', false), true);
    279 });
    280 
    281 test('an idle worker keeps running instead of exiting', async () => {
    282   // A worker with nothing to do is waiting on a timer and nothing else.
    283   // If that timer does not hold the event loop open, node decides it has
    284   // finished and exits 0, which looks like a clean shutdown and is not.
    285   const dir = await tempDir();
    286   const config = path.join(dir, 'worker.json');
    287 
    288   // Points at a port nothing is listening on, so every poll fails and the
    289   // agent stays in its retry sleep.
    290   await fs.writeFile(config, JSON.stringify({
    291     conductor_url: 'http://127.0.0.1:1',
    292     token: 'irrelevant',
    293     poll_interval: 1,
    294     docker: 'true',
    295   }));
    296 
    297   const agent = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src/worker/agent.js');
    298   const child = spawn(process.execPath, [agent, '--config', config], { stdio: 'ignore' });
    299 
    300   const outcome = await new Promise((resolve) => {
    301     const timer = setTimeout(() => resolve('still running'), 4000);
    302     child.on('exit', (code) => {
    303       clearTimeout(timer);
    304       resolve(`exited with ${code}`);
    305     });
    306   });
    307 
    308   child.kill('SIGKILL');
    309   assert.equal(outcome, 'still running');
    310 
    311   await fs.rm(dir, { recursive: true, force: true });
    312 });
    313 
    314 test('a worker stops promptly on a signal rather than after its poll interval', async () => {
    315   const dir = await tempDir();
    316   const config = path.join(dir, 'worker.json');
    317   await fs.writeFile(config, JSON.stringify({
    318     conductor_url: 'http://127.0.0.1:1',
    319     token: 'irrelevant',
    320     // Long enough that waiting it out would fail this test.
    321     poll_interval: 60,
    322     docker: 'true',
    323   }));
    324 
    325   const agent = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src/worker/agent.js');
    326   const child = spawn(process.execPath, [agent, '--config', config], { stdio: 'ignore' });
    327 
    328   // Let it reach the sleep, then ask it to stop.
    329   await new Promise((r) => { setTimeout(r, 1500); });
    330   const started = Date.now();
    331   child.kill('SIGTERM');
    332 
    333   const code = await new Promise((resolve) => {
    334     const timer = setTimeout(() => resolve('timed out'), 10000);
    335     child.on('exit', (c) => { clearTimeout(timer); resolve(c); });
    336   });
    337 
    338   child.kill('SIGKILL');
    339   assert.equal(code, 0, 'should exit cleanly on SIGTERM');
    340   assert.ok(Date.now() - started < 8000, 'should not wait out the poll interval');
    341 
    342   await fs.rm(dir, { recursive: true, force: true });
    343 });
    344 
    345 // A client stub that records what the log stream sends.
    346 function recordingClient() {
    347   const calls = [];
    348   let size = 0;
    349   return {
    350     calls,
    351     get text() {
    352       return calls.map((c) => c.chunk.toString('utf8')).join('');
    353     },
    354     async appendLog(url, chunk, offset) {
    355       calls.push({ chunk, offset });
    356       size = offset + chunk.length;
    357       return { size };
    358     },
    359   };
    360 }
    361 
    362 test('log output is batched and offsets advance', async () => {
    363   const client = recordingClient();
    364   const log = createLogStream(client, 'http://x/log', { flushMs: 5 });
    365 
    366   log.write('hello ');
    367   log.write('world\n');
    368   await log.close();
    369 
    370   assert.equal(client.text, 'hello world\n');
    371   assert.equal(client.calls[0].offset, 0);
    372   assert.equal(log.offset, 12);
    373 });
    374 
    375 test('a conflicting offset resynchronises instead of duplicating', async () => {
    376   const calls = [];
    377   let first = true;
    378   const client = {
    379     async appendLog(url, chunk, offset) {
    380       calls.push({ text: chunk.toString('utf8'), offset });
    381       if (first) {
    382         first = false;
    383         // The conductor already has the first four bytes.
    384         return { conflict: true, expected: 4 };
    385       }
    386       return { size: offset + chunk.length };
    387     },
    388   };
    389 
    390   const log = createLogStream(client, 'http://x/log', { flushMs: 5 });
    391   log.write('abcdefgh');
    392   await log.close();
    393 
    394   assert.equal(calls.length, 2);
    395   assert.equal(calls[1].offset, 4);
    396   assert.equal(calls[1].text, 'efgh', 'only the bytes the conductor lacks should be resent');
    397 });
    398 
    399 test('secrets are masked before anything leaves the host', async () => {
    400   const client = recordingClient();
    401   const log = createLogStream(client, 'http://x/log', { masked: ['hunter2'], flushMs: 5 });
    402 
    403   log.write('password is hunter2 ok\n');
    404   await log.close();
    405 
    406   assert.ok(!client.text.includes('hunter2'));
    407   assert.ok(client.text.includes('[masked]'));
    408 });
    409 
    410 test('a secret split across two writes is still masked', async () => {
    411   const client = recordingClient();
    412   const log = createLogStream(client, 'http://x/log', { masked: ['supersecret'], flushMs: 5 });
    413 
    414   log.write('token=super');
    415   log.write('secret done\n');
    416   await log.close();
    417 
    418   assert.ok(!client.text.includes('supersecret'), `leaked: ${client.text}`);
    419   assert.ok(client.text.includes('[masked]'));
    420 });
    421 
    422 test('a secret named while the task runs is masked from then on', async () => {
    423   const client = recordingClient();
    424   const log = createLogStream(client, 'http://x/log', { flushMs: 5 });
    425 
    426   assert.equal(log.addSecret('abcdef123456'), true);
    427   log.write('token is abcdef123456 ok\n');
    428   await log.close();
    429 
    430   assert.ok(!client.text.includes('abcdef123456'), `leaked: ${client.text}`);
    431   assert.ok(client.text.includes('[masked]'));
    432 });
    433 
    434 test('naming a secret also masks output still buffered', async () => {
    435   const client = recordingClient();
    436   const log = createLogStream(client, 'http://x/log', { flushMs: 50 });
    437 
    438   log.write('echoed abcdef123456 early\n');
    439   log.addSecret('abcdef123456');
    440   await log.close();
    441 
    442   assert.ok(!client.text.includes('abcdef123456'), `leaked: ${client.text}`);
    443 });
    444 
    445 test('a value too short to mask is refused rather than made into noise', async () => {
    446   const client = recordingClient();
    447   const log = createLogStream(client, 'http://x/log', { flushMs: 5 });
    448 
    449   assert.equal(log.addSecret('ab'), false);
    450   log.write('ab is everywhere\n');
    451   await log.close();
    452 
    453   assert.equal(client.text, 'ab is everywhere\n');
    454 });
    455 
    456 test('a failing conductor does not break the task', async () => {
    457   const client = {
    458     async appendLog() {
    459       throw new Error('conductor unreachable');
    460     },
    461   };
    462   const log = createLogStream(client, 'http://x/log', { flushMs: 5, logger: { warn: () => {} } });
    463   log.write('some output\n');
    464   await log.close();
    465   assert.match(log.error.message, /unreachable/);
    466 });
    467 
    468 const quiet = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} };
    469 
    470 function setupTask(overrides = {}) {
    471   return {
    472     id: 'task-setup',
    473     name: 'build',
    474     image: 'busybox',
    475     sha: 'a'.repeat(40),
    476     attempt: 1,
    477     script: 'true',
    478     env: {},
    479     masked: [],
    480     timeout: 600,
    481     heartbeat_interval: 5,
    482     workdir: '/workspace',
    483     endpoints: {
    484       log: 'http://x/log',
    485       source: 'http://x/source.tar.gz',
    486       heartbeat: 'http://x/heartbeat',
    487       done: 'http://x/done',
    488       artifact: 'http://x/artifacts',
    489     },
    490     ...overrides,
    491   };
    492 }
    493 
    494 function exitingChild(code = 0) {
    495   const child = new EventEmitter();
    496   child.stdout = new PassThrough();
    497   child.stderr = new PassThrough();
    498   setImmediate(() => {
    499     child.stdout.end();
    500     child.stderr.end();
    501     child.emit('close', code);
    502   });
    503   return child;
    504 }
    505 
    506 test('a heartbeat goes out while a task is still being set up', async () => {
    507   const beats = [];
    508   const client = {
    509     async appendLog(url, chunk, offset) { return { size: offset + chunk.length }; },
    510     async heartbeat() { beats.push(Date.now()); return { cancelled: false }; },
    511     async source() { return Readable.from([gzipSync(Buffer.alloc(0))]); },
    512     async done() { return {}; },
    513   };
    514 
    515   // Stands in for an image pull that outlasts the conductor's reaper clock.
    516   const runtime = {
    517     async createNetwork() {},
    518     async removeNetwork() {},
    519     async create() {
    520       const until = Date.now() + 8000;
    521       while (Date.now() < until && beats.length === 0) {
    522         await new Promise((r) => { setTimeout(r, 25); });
    523       }
    524     },
    525     async copyStreamIn(name, stream) {
    526       stream.resume();
    527       await new Promise((r) => { stream.on('end', r); stream.on('error', r); });
    528     },
    529     async copyIn() {},
    530     async copyOut() {},
    531     async kill() {},
    532     async remove() {},
    533     startCreated: () => ({ child: exitingChild(0), args: [] }),
    534   };
    535 
    536   const result = await runTask({
    537     cfg: { name: 'w', shell: 'sh', default_timeout: 600, features: {} },
    538     client,
    539     runtime,
    540     task: setupTask(),
    541     logger: quiet,
    542   });
    543 
    544   assert.ok(beats.length > 0, 'the conductor heard nothing while the image was pulling');
    545   assert.equal(result.success, true);
    546 });
    547 
    548 test('a task claimed as the worker stops is handed back, not started', async () => {
    549   const events = [];
    550   let task = null;
    551   const server = createServer((req, res) => {
    552     events.push(req.url);
    553     res.setHeader('content-type', 'application/json');
    554     if (req.url === '/api/v1/tasks/claim') {
    555       // Slow enough that the signal lands while the claim is in flight.
    556       setTimeout(() => res.end(JSON.stringify({ ok: true, data: { task } })), 1200);
    557       return;
    558     }
    559     res.end(JSON.stringify({ ok: true, data: {} }));
    560   });
    561 
    562   await new Promise((r) => server.listen(0, '127.0.0.1', r));
    563   const port = server.address().port;
    564   const at = (p) => `http://127.0.0.1:${port}${p}`;
    565   task = setupTask({
    566     endpoints: {
    567       log: at('/log'),
    568       source: at('/source.tar.gz'),
    569       heartbeat: at('/heartbeat'),
    570       done: at('/done'),
    571       artifact: at('/artifacts'),
    572     },
    573   });
    574 
    575   const dir = await tempDir();
    576   const config = path.join(dir, 'worker.json');
    577   await fs.writeFile(config, JSON.stringify({
    578     conductor_url: `http://127.0.0.1:${port}`,
    579     token: 'irrelevant',
    580     poll_interval: 60,
    581     docker: 'true',
    582   }));
    583 
    584   const agent = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src/worker/agent.js');
    585   const child = spawn(process.execPath, [agent, '--config', config], { stdio: 'ignore' });
    586 
    587   await new Promise((r) => { setTimeout(r, 700); });
    588   child.kill('SIGTERM');
    589 
    590   const code = await new Promise((resolve) => {
    591     const timer = setTimeout(() => resolve('timed out'), 10000);
    592     child.on('exit', (c) => { clearTimeout(timer); resolve(c); });
    593   });
    594   child.kill('SIGKILL');
    595   server.close();
    596 
    597   assert.equal(code, 0, 'should still exit cleanly');
    598   assert.ok(events.includes('/done'), `expected the task to be handed back, saw ${events.join(', ')}`);
    599   assert.ok(
    600     !events.includes('/source.tar.gz'),
    601     `the task was started despite the signal, saw ${events.join(', ')}`
    602   );
    603 
    604   await fs.rm(dir, { recursive: true, force: true });
    605 });