conductor

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

worker.test.js (16429B)


      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 { loadWorkerConfig, featureNames } from '../src/worker/config.js';
     13 import { buildScript, shellQuote } from '../src/worker/script.js';
     14 import { containerName, createRuntime } from '../src/worker/docker.js';
     15 import { createLogStream } from '../src/worker/logstream.js';
     16 import { resolveFeatures, patternPrefix, copyOutPaths } from '../src/worker/task.js';
     17 import { collectArtifacts, shouldCollect } from '../src/worker/artifacts.js';
     18 
     19 async function tempDir(prefix = 'conductor-worker-') {
     20   return fs.mkdtemp(path.join(os.tmpdir(), prefix));
     21 }
     22 
     23 async function withConfigEnv(env, fn) {
     24   const saved = {};
     25   for (const key of Object.keys(process.env)) {
     26     if (key.startsWith('CONDUCTOR_')) {
     27       saved[key] = process.env[key];
     28       delete process.env[key];
     29     }
     30   }
     31   Object.assign(process.env, env);
     32   try {
     33     return await fn();
     34   } finally {
     35     for (const key of Object.keys(process.env)) {
     36       if (key.startsWith('CONDUCTOR_')) delete process.env[key];
     37     }
     38     Object.assign(process.env, saved);
     39   }
     40 }
     41 
     42 test('worker config loads from json and reports features', async () => {
     43   const dir = await tempDir();
     44   const file = path.join(dir, 'worker.json');
     45   await fs.writeFile(file, JSON.stringify({
     46     conductor_url: 'https://ci.example.com/',
     47     name: 'pi',
     48     token: 'abc',
     49     arches: ['aarch64'],
     50     features: {
     51       dind: { privileged: true },
     52       'sign-key': { mounts: ['/srv/k:/keys/k:ro'], env: { KEY: '/keys/k' } },
     53     },
     54   }));
     55 
     56   const cfg = await withConfigEnv({}, () => loadWorkerConfig(file));
     57   assert.equal(cfg.conductor_url, 'https://ci.example.com');
     58   assert.deepEqual(featureNames(cfg), ['dind', 'sign-key']);
     59   assert.equal(cfg.features.dind.privileged, true);
     60   assert.deepEqual(cfg.features['sign-key'].mounts, ['/srv/k:/keys/k:ro']);
     61   await fs.rm(dir, { recursive: true, force: true });
     62 });
     63 
     64 test('worker config reads the token from a file', async () => {
     65   const dir = await tempDir();
     66   await fs.writeFile(path.join(dir, 'token'), 'secret-token\n');
     67   const file = path.join(dir, 'worker.json');
     68   await fs.writeFile(file, JSON.stringify({ token_file: path.join(dir, 'token') }));
     69 
     70   const cfg = await withConfigEnv({}, () => loadWorkerConfig(file));
     71   assert.equal(cfg.token, 'secret-token');
     72   await fs.rm(dir, { recursive: true, force: true });
     73 });
     74 
     75 test('environment overrides the worker config file', async () => {
     76   const dir = await tempDir();
     77   const file = path.join(dir, 'worker.json');
     78   await fs.writeFile(file, JSON.stringify({ token: 'x', arches: ['x86_64'], concurrency: 1 }));
     79 
     80   const cfg = await withConfigEnv({
     81     CONDUCTOR_WORKER_ARCHES: 'aarch64,riscv64',
     82     CONDUCTOR_WORKER_CONCURRENCY: '4',
     83   }, () => loadWorkerConfig(file));
     84 
     85   assert.deepEqual(cfg.arches, ['aarch64', 'riscv64']);
     86   assert.equal(cfg.concurrency, 4);
     87   await fs.rm(dir, { recursive: true, force: true });
     88 });
     89 
     90 test('worker config without a token is rejected', async () => {
     91   const dir = await tempDir();
     92   const file = path.join(dir, 'worker.json');
     93   await fs.writeFile(file, JSON.stringify({ conductor_url: 'http://x' }));
     94   await assert.rejects(withConfigEnv({}, () => loadWorkerConfig(file)), /no worker token/);
     95   await fs.rm(dir, { recursive: true, force: true });
     96 });
     97 
     98 test('a malformed feature is reported with its path', async () => {
     99   const dir = await tempDir();
    100   const file = path.join(dir, 'worker.json');
    101   await fs.writeFile(file, JSON.stringify({
    102     token: 'x',
    103     features: { bad: { mounts: ['no-colon'], nonsense: 1 } },
    104   }));
    105   // Both problems are reported together; the order between them is not
    106   // part of the contract.
    107   await assert.rejects(withConfigEnv({}, () => loadWorkerConfig(file)), (e) => {
    108     assert.match(e.message, /features\.bad\.mounts\[0\]/);
    109     assert.match(e.message, /features\.bad\.nonsense/);
    110     return true;
    111   });
    112   await fs.rm(dir, { recursive: true, force: true });
    113 });
    114 
    115 test('shell quoting survives quotes and metacharacters', () => {
    116   assert.equal(shellQuote('simple'), "'simple'");
    117   assert.equal(shellQuote("it's"), "'it'\\''s'");
    118   assert.equal(shellQuote('$(rm -rf /)'), "'$(rm -rf /)'");
    119 });
    120 
    121 test('a generated script echoes each command and stops on failure', () => {
    122   const script = buildScript(['npm ci', "echo 'hi'"]);
    123   assert.match(script, /^#!\/bin\/sh\nset -e\n/);
    124   assert.ok(script.includes("printf '%s\\n' '$ npm ci'"));
    125   assert.ok(script.includes('npm ci\n'));
    126   // The echo of a command containing quotes stays a single argument.
    127   assert.ok(script.includes("printf '%s\\n' '$ echo '\\''hi'\\'''"));
    128 });
    129 
    130 test('container names are valid for docker and stay unique', () => {
    131   const a = containerName('conductor', 'run1:package:arch=x86_64,pkg=musl');
    132   const b = containerName('conductor', 'run1:package:arch=x86_64,pkg=busybox');
    133   assert.match(a, /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/);
    134   assert.notEqual(a, b);
    135   // Distinct tasks that differ only past the truncation point still differ.
    136   const long = 'x'.repeat(200);
    137   assert.notEqual(containerName('c', `${long}a`), containerName('c', `${long}b`));
    138 });
    139 
    140 test('job arguments carry mounts, env, network and privilege', () => {
    141   const runtime = createRuntime({ docker: 'docker', shell: 'sh' });
    142   const args = runtime.buildRunArgs({
    143     name: 'job1',
    144     image: 'alpine:3',
    145     network: 'net1',
    146     workdir: '/work',
    147     env: { FOO: 'bar' },
    148     mounts: ['/srv/k:/keys/k:ro'],
    149     privileged: true,
    150     entrypoint: 'sh',
    151     command: ['/tmp/entrypoint.sh'],
    152   });
    153 
    154   assert.equal(args[0], 'run');
    155   assert.ok(args.includes('--rm'));
    156   assert.deepEqual(args.slice(args.indexOf('--network'), args.indexOf('--network') + 2), ['--network', 'net1']);
    157   assert.ok(args.includes('--privileged'));
    158   assert.ok(args.includes('/srv/k:/keys/k:ro'));
    159   assert.deepEqual(args.slice(args.indexOf('--workdir'), args.indexOf('--workdir') + 2), ['--workdir', '/work']);
    160   assert.ok(args.includes('FOO=bar'));
    161   // The image must come before its command.
    162   assert.ok(args.indexOf('alpine:3') < args.indexOf('/tmp/entrypoint.sh'));
    163 });
    164 
    165 test('a task container is created without sharing anything with the worker', () => {
    166   // The whole point of copying the tree in: no path of the worker's is
    167   // handed to the daemon, so nothing has to line up between the two.
    168   const runtime = createRuntime({ docker: 'docker', shell: 'sh' });
    169   const args = runtime.buildCreateArgs({
    170     name: 'job1',
    171     image: 'alpine:3',
    172     network: 'net1',
    173     workdir: '/work',
    174     env: { FOO: 'bar' },
    175     entrypoint: 'sh',
    176     command: ['/tmp/entrypoint.sh'],
    177   });
    178 
    179   assert.equal(args[0], 'create');
    180   assert.ok(!args.includes('--attach'), 'create takes no attach flags');
    181   assert.ok(!args.includes('--volume'), 'nothing of the worker is mounted in');
    182   assert.ok(!args.some((a) => a.includes(':/work')), 'the tree is copied in, never bind mounted');
    183   assert.deepEqual(args.slice(args.indexOf('--workdir'), args.indexOf('--workdir') + 2), ['--workdir', '/work']);
    184   assert.ok(args.indexOf('alpine:3') < args.indexOf('/tmp/entrypoint.sh'));
    185 });
    186 
    187 test('a feature still contributes its bind mounts', () => {
    188   // Those name host paths an operator chose deliberately, which is a
    189   // different thing from the worker sharing its own directories.
    190   const runtime = createRuntime({ docker: 'docker', shell: 'sh' });
    191   const args = runtime.buildCreateArgs({
    192     name: 'job1',
    193     image: 'alpine:3',
    194     mounts: ['/srv/keys/build.rsa:/keys/build.rsa:ro'],
    195     entrypoint: 'sh',
    196     command: ['/tmp/entrypoint.sh'],
    197   });
    198 
    199   assert.ok(args.includes('/srv/keys/build.rsa:/keys/build.rsa:ro'));
    200 });
    201 
    202 test('features resolve into mounts, env and privilege', () => {
    203   const available = {
    204     dind: { mounts: [], env: {}, devices: [], privileged: true },
    205     'sign-key': { mounts: ['/srv/k:/keys/k:ro'], env: { KEY: '/keys/k' }, devices: [], privileged: false },
    206   };
    207   const resolved = resolveFeatures({ requires: ['sign-key', 'dind'] }, available);
    208   assert.deepEqual(resolved.mounts, ['/srv/k:/keys/k:ro']);
    209   assert.deepEqual(resolved.env, { KEY: '/keys/k' });
    210   assert.equal(resolved.privileged, true);
    211   assert.deepEqual(resolved.missing, []);
    212 });
    213 
    214 test('an unknown required feature is reported rather than ignored', () => {
    215   const resolved = resolveFeatures({ requires: ['nope'] }, {});
    216   assert.deepEqual(resolved.missing, ['nope']);
    217 });
    218 
    219 test('an artifact pattern reduces to the part that can be copied out', () => {
    220   // A stopped container cannot expand a glob, so only the leading
    221   // literal part of a pattern can be asked for by name.
    222   assert.equal(patternPrefix('out/**'), 'out');
    223   assert.equal(patternPrefix('build/*.tar.gz'), 'build');
    224   assert.equal(patternPrefix('a/b/c/**'), 'a/b/c');
    225   assert.equal(patternPrefix('dist/app.tar'), 'dist/app.tar');
    226 
    227   // A pattern that could match anywhere has no usable prefix.
    228   assert.equal(patternPrefix('**/*.log'), '');
    229   assert.equal(patternPrefix('*'), '');
    230 });
    231 
    232 test('overlapping artifact paths are copied out once', () => {
    233   assert.deepEqual(copyOutPaths(['out/**', 'out/deep/**', 'build/*']), ['build', 'out']);
    234   assert.deepEqual(copyOutPaths(['out/**', 'out/**']), ['out']);
    235 
    236   // One pattern matching anywhere means the whole tree comes back, and
    237   // there is no point copying anything else separately.
    238   assert.deepEqual(copyOutPaths(['**/*.log', 'out/**']), ['']);
    239 
    240   // A prefix must be a whole path segment: outer must not swallow out.
    241   assert.deepEqual(copyOutPaths(['out/**', 'outer/**']), ['out', 'outer']);
    242 });
    243 
    244 test('artifact collection globs, and refuses to leave the workspace', async () => {
    245   const dir = await tempDir();
    246   const outside = await tempDir('conductor-outside-');
    247   await fs.writeFile(path.join(outside, 'secret.txt'), 'do not upload me');
    248 
    249   await fs.mkdir(path.join(dir, 'dist/nested'), { recursive: true });
    250   await fs.mkdir(path.join(dir, '.conductor'), { recursive: true });
    251   await fs.writeFile(path.join(dir, 'dist/app.bin'), 'binary');
    252   await fs.writeFile(path.join(dir, 'dist/nested/deep.bin'), 'deep');
    253   await fs.writeFile(path.join(dir, '.conductor/script.sh'), 'set -e');
    254   await fs.symlink(path.join(outside, 'secret.txt'), path.join(dir, 'dist/escape.txt'));
    255 
    256   const found = await collectArtifacts(dir, ['dist/**'], { logger: { warn: () => {} } });
    257   const names = found.map((f) => f.path).sort();
    258 
    259   assert.ok(names.includes('dist/app.bin'));
    260   assert.ok(names.includes('dist/nested/deep.bin'));
    261   assert.ok(!names.includes('dist/escape.txt'), 'symlink out of the workspace must be refused');
    262   assert.ok(!names.some((n) => n.startsWith('.conductor')));
    263   assert.equal(found.find((f) => f.path === 'dist/app.bin').size, 6);
    264 
    265   await fs.rm(dir, { recursive: true, force: true });
    266   await fs.rm(outside, { recursive: true, force: true });
    267 });
    268 
    269 test('artifact when controls collection', () => {
    270   assert.equal(shouldCollect('on_success', true), true);
    271   assert.equal(shouldCollect('on_success', false), false);
    272   assert.equal(shouldCollect('on_failure', false), true);
    273   assert.equal(shouldCollect('on_failure', true), false);
    274   assert.equal(shouldCollect('always', false), true);
    275 });
    276 
    277 test('an idle worker keeps running instead of exiting', async () => {
    278   // A worker with nothing to do is waiting on a timer and nothing else.
    279   // If that timer does not hold the event loop open, node decides it has
    280   // finished and exits 0, which looks like a clean shutdown and is not.
    281   const dir = await tempDir();
    282   const config = path.join(dir, 'worker.json');
    283 
    284   // Points at a port nothing is listening on, so every poll fails and the
    285   // agent stays in its retry sleep.
    286   await fs.writeFile(config, JSON.stringify({
    287     conductor_url: 'http://127.0.0.1:1',
    288     token: 'irrelevant',
    289     poll_interval: 1,
    290     docker: 'true',
    291   }));
    292 
    293   const agent = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src/worker/agent.js');
    294   const child = spawn(process.execPath, [agent, '--config', config], { stdio: 'ignore' });
    295 
    296   const outcome = await new Promise((resolve) => {
    297     const timer = setTimeout(() => resolve('still running'), 4000);
    298     child.on('exit', (code) => {
    299       clearTimeout(timer);
    300       resolve(`exited with ${code}`);
    301     });
    302   });
    303 
    304   child.kill('SIGKILL');
    305   assert.equal(outcome, 'still running');
    306 
    307   await fs.rm(dir, { recursive: true, force: true });
    308 });
    309 
    310 test('a worker stops promptly on a signal rather than after its poll interval', async () => {
    311   const dir = await tempDir();
    312   const config = path.join(dir, 'worker.json');
    313   await fs.writeFile(config, JSON.stringify({
    314     conductor_url: 'http://127.0.0.1:1',
    315     token: 'irrelevant',
    316     // Long enough that waiting it out would fail this test.
    317     poll_interval: 60,
    318     docker: 'true',
    319   }));
    320 
    321   const agent = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src/worker/agent.js');
    322   const child = spawn(process.execPath, [agent, '--config', config], { stdio: 'ignore' });
    323 
    324   // Let it reach the sleep, then ask it to stop.
    325   await new Promise((r) => { setTimeout(r, 1500); });
    326   const started = Date.now();
    327   child.kill('SIGTERM');
    328 
    329   const code = await new Promise((resolve) => {
    330     const timer = setTimeout(() => resolve('timed out'), 10000);
    331     child.on('exit', (c) => { clearTimeout(timer); resolve(c); });
    332   });
    333 
    334   child.kill('SIGKILL');
    335   assert.equal(code, 0, 'should exit cleanly on SIGTERM');
    336   assert.ok(Date.now() - started < 8000, 'should not wait out the poll interval');
    337 
    338   await fs.rm(dir, { recursive: true, force: true });
    339 });
    340 
    341 // A client stub that records what the log stream sends.
    342 function recordingClient() {
    343   const calls = [];
    344   let size = 0;
    345   return {
    346     calls,
    347     get text() {
    348       return calls.map((c) => c.chunk.toString('utf8')).join('');
    349     },
    350     async appendLog(url, chunk, offset) {
    351       calls.push({ chunk, offset });
    352       size = offset + chunk.length;
    353       return { size };
    354     },
    355   };
    356 }
    357 
    358 test('log output is batched and offsets advance', async () => {
    359   const client = recordingClient();
    360   const log = createLogStream(client, 'http://x/log', { flushMs: 5 });
    361 
    362   log.write('hello ');
    363   log.write('world\n');
    364   await log.close();
    365 
    366   assert.equal(client.text, 'hello world\n');
    367   assert.equal(client.calls[0].offset, 0);
    368   assert.equal(log.offset, 12);
    369 });
    370 
    371 test('a conflicting offset resynchronises instead of duplicating', async () => {
    372   const calls = [];
    373   let first = true;
    374   const client = {
    375     async appendLog(url, chunk, offset) {
    376       calls.push({ text: chunk.toString('utf8'), offset });
    377       if (first) {
    378         first = false;
    379         // The conductor already has the first four bytes.
    380         return { conflict: true, expected: 4 };
    381       }
    382       return { size: offset + chunk.length };
    383     },
    384   };
    385 
    386   const log = createLogStream(client, 'http://x/log', { flushMs: 5 });
    387   log.write('abcdefgh');
    388   await log.close();
    389 
    390   assert.equal(calls.length, 2);
    391   assert.equal(calls[1].offset, 4);
    392   assert.equal(calls[1].text, 'efgh', 'only the bytes the conductor lacks should be resent');
    393 });
    394 
    395 test('secrets are masked before anything leaves the host', async () => {
    396   const client = recordingClient();
    397   const log = createLogStream(client, 'http://x/log', { masked: ['hunter2'], flushMs: 5 });
    398 
    399   log.write('password is hunter2 ok\n');
    400   await log.close();
    401 
    402   assert.ok(!client.text.includes('hunter2'));
    403   assert.ok(client.text.includes('[masked]'));
    404 });
    405 
    406 test('a secret split across two writes is still masked', async () => {
    407   const client = recordingClient();
    408   const log = createLogStream(client, 'http://x/log', { masked: ['supersecret'], flushMs: 5 });
    409 
    410   log.write('token=super');
    411   log.write('secret done\n');
    412   await log.close();
    413 
    414   assert.ok(!client.text.includes('supersecret'), `leaked: ${client.text}`);
    415   assert.ok(client.text.includes('[masked]'));
    416 });
    417 
    418 test('a failing conductor does not break the task', async () => {
    419   const client = {
    420     async appendLog() {
    421       throw new Error('conductor unreachable');
    422     },
    423   };
    424   const log = createLogStream(client, 'http://x/log', { flushMs: 5, logger: { warn: () => {} } });
    425   log.write('some output\n');
    426   await log.close();
    427   assert.match(log.error.message, /unreachable/);
    428 });