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 });