conductor

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

worker-docker.test.js (15252B)


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