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