worker-docker.test.js.disabled (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 });