conductor

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

worker-docker.test.js (13803B)


      1 // test/worker-docker.test.js - the whole loop, for real
      2 
      3 import test from 'node:test';
      4 import assert from 'node:assert/strict';
      5 import fs from 'node:fs/promises';
      6 import os from 'node:os';
      7 import { execFile } from 'node:child_process';
      8 import { promisify } from 'node:util';
      9 import { startHarness, data, freePort } from './helpers/harness.js';
     10 import { createClient } from '../src/worker/client.js';
     11 import { createRuntime, containerName } from '../src/worker/docker.js';
     12 import { runTask } from '../src/worker/task.js';
     13 
     14 const execFileAsync = promisify(execFile);
     15 
     16 const IMAGE = process.env.CONDUCTOR_TEST_IMAGE || 'alpine:3';
     17 
     18 async function dockerUsable() {
     19   if (process.env.CONDUCTOR_TEST_NO_DOCKER) return false;
     20   try {
     21     await execFileAsync('docker', ['version', '--format', '{{.Server.Version}}'], { timeout: 15000 });
     22     await execFileAsync('docker', ['image', 'inspect', IMAGE], { timeout: 15000 })
     23       .catch(() => execFileAsync('docker', ['pull', IMAGE], { timeout: 300000 }));
     24     return true;
     25   } catch {
     26     return false;
     27   }
     28 }
     29 
     30 const available = await dockerUsable();
     31 const opts = { skip: available ? false : 'docker is not available' };
     32 
     33 const quiet = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} };
     34 
     35 // The port must settle before the server is built: task payloads carry
     36 // absolute callback URLs derived from public_url.
     37 async function listening(options = {}) {
     38   const port = await freePort();
     39   const url = `http://127.0.0.1:${port}`;
     40   const h = await startHarness({ ...options, port, publicUrl: url });
     41   await h.app.listen({ port, host: '127.0.0.1' });
     42 
     43   const workerCfg = {
     44     conductor_url: url,
     45     name: 'docker-test-worker',
     46     token: h.worker.token,
     47     arches: [],
     48     features: {},
     49     concurrency: 1,
     50     poll_interval: 1,
     51     docker: 'docker',
     52     shell: 'sh',
     53     default_timeout: 300,
     54     ...(options?.worker ?? {}),
     55   };
     56 
     57   return {
     58     ...h,
     59     url,
     60     workerCfg,
     61     client: createClient(workerCfg),
     62     runtime: createRuntime(workerCfg, { logger: quiet }),
     63   };
     64 }
     65 
     66 async function queue(h) {
     67   const triggered = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
     68   const jobId = data(triggered).job_id;
     69   await h.waitForJob(jobId);
     70   return jobId;
     71 }
     72 
     73 async function logOf(h, taskId) {
     74   const res = await h.app.inject({
     75     method: 'GET', url: `/api/v1/tasks/${encodeURIComponent(taskId)}/log`,
     76   });
     77   return data(res).data;
     78 }
     79 
     80 async function artifactsOf(h, taskId) {
     81   const res = await h.app.inject({
     82     method: 'GET', url: `/api/v1/tasks/${encodeURIComponent(taskId)}/artifacts`,
     83   });
     84   return data(res);
     85 }
     86 
     87 function download(h, jobId, taskId, artifactId) {
     88   return h.app.inject({
     89     method: 'GET',
     90     url: `/api/v1/projects/demo/jobs/${jobId}/tasks/${encodeURIComponent(taskId)}`
     91       + `/artifacts/${artifactId}`,
     92   });
     93 }
     94 
     95 async function stateOf(h, jobId) {
     96   return data(await h.app.inject({ method: 'GET', url: `/api/v1/jobs/${jobId}` })).state;
     97 }
     98 
     99 async function exists(kind, name) {
    100   const args = kind === 'network'
    101     ? ['network', 'ls', '--filter', `name=^${name}$`, '--format', '{{.Name}}']
    102     : ['ps', '-a', '--filter', `name=^${name}$`, '--format', '{{.Names}}'];
    103   const { stdout } = await execFileAsync('docker', args);
    104   return stdout.trim().length > 0;
    105 }
    106 
    107 test('a task runs in a container and its output reaches the conductor', opts, async () => {
    108   const pipeline = `
    109 version: 1
    110 tasks:
    111   hello:
    112     image: ${IMAGE}
    113     script:
    114       - echo "hello from the task"
    115       - echo "commit is $CONDUCTOR_SHA"
    116       - echo "task is $CONDUCTOR_TASK"
    117       - cat README.md
    118 `;
    119   const h = await listening({ pipeline });
    120   try {
    121     const jobId = await queue(h);
    122     const task = await h.client.claim({ arches: [], features: [], name: 'test' });
    123     assert.ok(task, 'expected a task');
    124 
    125     const result = await runTask({ cfg: h.workerCfg, client: h.client, runtime: h.runtime, task, logger: quiet });
    126     assert.equal(result.success, true, `task failed: ${result.error}`);
    127     assert.equal(result.exitCode, 0);
    128 
    129     const log = await logOf(h, task.id);
    130     assert.match(log, /\$ echo "hello from the task"/, 'each command should be echoed');
    131     assert.match(log, /hello from the task/);
    132     assert.match(log, new RegExp(`commit is ${h.sha}`));
    133     assert.match(log, /task is hello/);
    134     assert.match(log, /test repository/, 'the source tarball should have been unpacked');
    135 
    136     assert.equal(await stateOf(h, jobId), 'success');
    137   } finally {
    138     await h.stop();
    139   }
    140 });
    141 
    142 test('a failing command fails the task and preserves the log', opts, async () => {
    143   const pipeline = `
    144 version: 1
    145 tasks:
    146   boom:
    147     image: ${IMAGE}
    148     script:
    149       - echo before
    150       - exit 3
    151       - echo after
    152 `;
    153   const h = await listening({ pipeline });
    154   try {
    155     const jobId = await queue(h);
    156     const task = await h.client.claim({ arches: [], features: [], name: 'test' });
    157 
    158     const result = await runTask({ cfg: h.workerCfg, client: h.client, runtime: h.runtime, task, logger: quiet });
    159     assert.equal(result.success, false);
    160     assert.equal(result.exitCode, 3);
    161 
    162     const log = await logOf(h, task.id);
    163     assert.match(log, /before/);
    164     assert.ok(!log.includes('after'), 'set -e must stop the script at the failure');
    165 
    166     assert.equal(await stateOf(h, jobId), 'failed');
    167   } finally {
    168     await h.stop();
    169   }
    170 });
    171 
    172 test('artifacts produced in the container are uploaded and downloadable', opts, async () => {
    173   const pipeline = `
    174 version: 1
    175 tasks:
    176   produce:
    177     image: ${IMAGE}
    178     script:
    179       - mkdir -p dist/nested
    180       - printf 'binary-content' > dist/app.bin
    181       - printf 'deep' > dist/nested/deep.txt
    182     artifacts:
    183       paths: [dist/**]
    184 `;
    185   const h = await listening({ pipeline });
    186   try {
    187     const jobId = await queue(h);
    188     const task = await h.client.claim({ arches: [], features: [], name: 'test' });
    189 
    190     const result = await runTask({ cfg: h.workerCfg, client: h.client, runtime: h.runtime, task, logger: quiet });
    191     assert.equal(result.success, true, `task failed: ${result.error}`);
    192 
    193     const artifacts = await artifactsOf(h, task.id);
    194     assert.deepEqual(artifacts.map((a) => a.path).sort(), ['dist/app.bin', 'dist/nested/deep.txt']);
    195 
    196     const binary = artifacts.find((a) => a.path === 'dist/app.bin');
    197     assert.equal((await download(h, jobId, task.id, binary.id)).body, 'binary-content');
    198   } finally {
    199     await h.stop();
    200   }
    201 });
    202 
    203 test('a worker feature injects a mount and environment the conductor never sees', opts, async () => {
    204   const pipeline = `
    205 version: 1
    206 tasks:
    207   signed:
    208     image: ${IMAGE}
    209     requires: [sign-key]
    210     script:
    211       - echo "key path is $SIGN_KEY"
    212       - cat "$SIGN_KEY"
    213 `;
    214   const h = await listening({
    215     pipeline,
    216     worker: {
    217       features: {
    218         'sign-key': { mounts: [], env: { SIGN_KEY: '/keys/build.key' }, devices: [], privileged: false },
    219       },
    220     },
    221   });
    222 
    223   try {
    224     await fs.mkdir(`${h.root}/keys`, { recursive: true });
    225     await fs.writeFile(`${h.root}/keys/build.key`, 'PRIVATE-KEY-MATERIAL');
    226     h.workerCfg.features['sign-key'].mounts = [`${h.root}/keys/build.key:/keys/build.key:ro`];
    227 
    228     await queue(h);
    229 
    230     const denied = await createClient({ ...h.workerCfg, token: h.worker.token })
    231       .claim({ arches: [], features: [], name: 'test' });
    232     assert.equal(denied, null, 'a worker lacking the feature must not be offered the task');
    233 
    234     const task = await h.client.claim({ arches: [], features: ['sign-key'], name: 'test' });
    235     assert.ok(task, 'expected the task once the feature is advertised');
    236 
    237     const result = await runTask({ cfg: h.workerCfg, client: h.client, runtime: h.runtime, task, logger: quiet });
    238     assert.equal(result.success, true, `task failed: ${result.error}`);
    239 
    240     const log = await logOf(h, task.id);
    241     assert.match(log, /key path is \/keys\/build\.key/);
    242     assert.match(log, /PRIVATE-KEY-MATERIAL/);
    243 
    244     const stored = await h.services.db.get(
    245       'SELECT requires, spec FROM tasks WHERE id = {id}', { id: task.id }
    246     );
    247     assert.equal(stored.requires, '["sign-key"]');
    248     assert.ok(!stored.spec.includes('PRIVATE-KEY-MATERIAL'));
    249   } finally {
    250     await h.stop();
    251   }
    252 });
    253 
    254 test('a service container is reachable from the task by its alias', opts, async () => {
    255   const pipeline = `
    256 version: 1
    257 tasks:
    258   talks:
    259     image: ${IMAGE}
    260     services:
    261       - image: ${IMAGE}
    262         alias: sidecar
    263         command: [sleep, '60']
    264     script:
    265       - ping -c1 -W2 sidecar
    266 `;
    267   const h = await listening({ pipeline });
    268   try {
    269     await queue(h);
    270     const task = await h.client.claim({ arches: [], features: [], name: 'test' });
    271 
    272     const result = await runTask({ cfg: h.workerCfg, client: h.client, runtime: h.runtime, task, logger: quiet });
    273     const log = await logOf(h, task.id);
    274     assert.equal(result.success, true, `task failed: ${result.error}\n${log}`);
    275     assert.match(log, /1 packets received|1 received/);
    276   } finally {
    277     await h.stop();
    278   }
    279 });
    280 
    281 test('a task that exceeds its timeout is stopped', opts, async () => {
    282   const pipeline = `
    283 version: 1
    284 tasks:
    285   slow:
    286     image: ${IMAGE}
    287     timeout: 2
    288     script:
    289       - echo starting
    290       - sleep 60
    291 `;
    292   const h = await listening({ pipeline });
    293   try {
    294     await queue(h);
    295     const task = await h.client.claim({ arches: [], features: [], name: 'test' });
    296 
    297     const started = Date.now();
    298     const result = await runTask({ cfg: h.workerCfg, client: h.client, runtime: h.runtime, task, logger: quiet });
    299     const elapsed = (Date.now() - started) / 1000;
    300 
    301     assert.equal(result.success, false);
    302     assert.equal(result.timedOut, true);
    303     assert.ok(elapsed < 45, `should have been stopped promptly, took ${elapsed}s`);
    304     assert.match(await logOf(h, task.id), /timed out/);
    305   } finally {
    306     await h.stop();
    307   }
    308 });
    309 
    310 test('containers, networks and scratch directories are cleaned up', opts, async () => {
    311   const pipeline = `
    312 version: 1
    313 tasks:
    314   tidy:
    315     image: ${IMAGE}
    316     services:
    317       - image: ${IMAGE}
    318         alias: sidecar
    319         command: [sleep, '60']
    320     script:
    321       - echo work
    322 `;
    323   const h = await listening({ pipeline });
    324   try {
    325     const before = new Set(
    326       (await fs.readdir(os.tmpdir())).filter((n) => n.startsWith('conductor-task-'))
    327     );
    328 
    329     await queue(h);
    330     const task = await h.client.claim({ arches: [], features: [], name: 'test' });
    331     await runTask({ cfg: h.workerCfg, client: h.client, runtime: h.runtime, task, logger: quiet });
    332 
    333     assert.equal(await exists('container', containerName('conductor', task.id)), false,
    334       'the task container should be gone');
    335     assert.equal(await exists('container', containerName('conductor-svc', task.id, 'sidecar')), false,
    336       'the service container should be gone');
    337     assert.equal(await exists('network', containerName('conductor-net', task.id)), false,
    338       'the task network should be gone');
    339 
    340     const leaked = (await fs.readdir(os.tmpdir()))
    341       .filter((n) => n.startsWith('conductor-task-'))
    342       .filter((n) => !before.has(n));
    343     assert.deepEqual(leaked, [], 'the scratch directory should be removed');
    344   } finally {
    345     await h.stop();
    346   }
    347 });
    348 
    349 test('a task runs with no mounts, and nothing of the worker is shared with it', opts, async () => {
    350   const pipeline = `
    351 version: 1
    352 tasks:
    353   isolated:
    354     image: ${IMAGE}
    355     script:
    356       - pwd
    357       - cat README.md
    358       - cat /proc/mounts
    359 `;
    360   const h = await listening({ pipeline });
    361   try {
    362     await queue(h);
    363     const task = await h.client.claim({ arches: [], features: [], name: 'test' });
    364 
    365     const created = h.runtime.buildCreateArgs({
    366       name: 'inspect', image: IMAGE, workdir: task.workdir, entrypoint: 'sh', command: ['/tmp/entrypoint.sh'],
    367     });
    368     assert.ok(!created.includes('--volume'), `no volume should be passed, got: ${created.join(' ')}`);
    369 
    370     const result = await runTask({ cfg: h.workerCfg, client: h.client, runtime: h.runtime, task, logger: quiet });
    371     assert.equal(result.success, true, `task failed: ${result.error}`);
    372 
    373     const log = await logOf(h, task.id);
    374     assert.match(log, /^\/work$/m, 'the task should run in the working directory');
    375     assert.match(log, /test repository/, 'the tree should have arrived');
    376 
    377     const mounts = log.split('\n').filter((line) => / \/\S+ \S+ (rw|ro)[,\s]/.test(line));
    378     assert.ok(mounts.length > 0, 'expected /proc/mounts in the log');
    379 
    380     const shared = mounts
    381       .map((line) => line.split(' ')[1])
    382       .filter((target) => !/^\/(proc|sys|dev|etc\/(hostname|hosts|resolv\.conf))/.test(target))
    383       .filter((target) => target !== '/');
    384     assert.deepEqual(shared, [], `nothing should be mounted into the task, found: ${shared.join(', ')}`);
    385   } finally {
    386     await h.stop();
    387   }
    388 });
    389 
    390 test('a pipeline chooses where its tree lands', opts, async () => {
    391   const pipeline = `
    392 version: 1
    393 workdir: /usr/src/app
    394 tasks:
    395   build:
    396     image: ${IMAGE}
    397     script:
    398       - pwd
    399       - cat README.md
    400       - mkdir -p out && printf 'made here' > out/result.txt
    401     artifacts:
    402       paths: [out/**]
    403 `;
    404   const h = await listening({ pipeline });
    405   try {
    406     const jobId = await queue(h);
    407     const task = await h.client.claim({ arches: [], features: [], name: 'test' });
    408     assert.equal(task.workdir, '/usr/src/app');
    409 
    410     const result = await runTask({ cfg: h.workerCfg, client: h.client, runtime: h.runtime, task, logger: quiet });
    411     assert.equal(result.success, true, `task failed: ${result.error}`);
    412 
    413     const log = await logOf(h, task.id);
    414     assert.match(log, /^\/usr\/src\/app$/m, 'the script should run there');
    415     assert.match(log, /test repository/, 'and the tree should be there, not at /work');
    416 
    417     const artifacts = await artifactsOf(h, task.id);
    418     assert.deepEqual(artifacts.map((a) => a.path), ['out/result.txt']);
    419     assert.equal((await download(h, jobId, task.id, artifacts[0].id)).body, 'made here');
    420   } finally {
    421     await h.stop();
    422   }
    423 });