agent.js (5251B)
1 #!/usr/bin/env node 2 // src/worker/agent.js - the worker 3 // 4 // Polls a conductor for work and runs it in containers. Pull based, so the 5 // worker needs no inbound connectivity and can sit behind any amount of NAT. 6 // 7 // Deliberately free of npm dependencies: copy src/worker onto a host with 8 // node, tar and a container runtime and it runs. That matters because the 9 // point of this design is accepting build capacity from machines you do not 10 // administer. 11 // 12 // Usage: 13 // node src/worker/agent.js [--config worker.json] [--once] 14 15 import { loadWorkerConfig, featureNames } from './config.js'; 16 import { createClient } from './client.js'; 17 import { createRuntime } from './docker.js'; 18 19 import { runTask } from './task.js'; 20 21 const stamp = () => new Date().toISOString().replace('T', ' ').slice(0, 19); 22 23 const logger = { 24 info: (m) => console.log(`${stamp()} ${m}`), 25 warn: (m) => console.warn(`${stamp()} warning: ${m}`), 26 error: (m) => console.error(`${stamp()} error: ${m}`), 27 debug: () => {}, 28 }; 29 30 function parseArgv(argv) { 31 const out = { config: null, once: false }; 32 for (let i = 0; i < argv.length; i += 1) { 33 if (argv[i] === '--config' || argv[i] === '-c') { 34 out.config = argv[i + 1]; 35 i += 1; 36 } else if (argv[i] === '--once') { 37 out.once = true; 38 } else if (argv[i] === '--help' || argv[i] === '-h') { 39 console.log('usage: agent.js [--config <file>] [--once]'); 40 process.exit(0); 41 } 42 } 43 return out; 44 } 45 46 export async function main(argv = process.argv.slice(2)) { 47 const args = parseArgv(argv); 48 const cfg = await loadWorkerConfig(args.config); 49 const client = createClient(cfg); 50 const runtime = createRuntime(cfg, { logger }); 51 52 // Fail at startup rather than on the first task, when a missing tool would 53 // otherwise look like a broken pipeline. 54 // 55 // The runtime is the only thing a worker needs. The tree is unpacked 56 // into the task container over the docker API rather than on this side, 57 // so there is nothing else to look for on PATH. 58 if (!(await runtime.available())) { 59 throw new Error(`container runtime ${JSON.stringify(cfg.docker)} is not usable; is the daemon running?`); 60 } 61 62 const features = featureNames(cfg); 63 logger.info(`worker ${cfg.name} polling ${cfg.conductor_url}`); 64 logger.info(` arches: ${cfg.arches.length > 0 ? cfg.arches.join(', ') : '(any)'}`); 65 logger.info(` features: ${features.length > 0 ? features.join(', ') : '(none)'}`); 66 logger.info(` concurrency: ${cfg.concurrency}`); 67 68 let stopping = false; 69 const running = new Set(); 70 71 // An idle worker is waiting on a timer and nothing else. The timer has 72 // to hold the event loop open, or node finds it has no work left and 73 // exits cleanly the first time there is nothing to build. Waking the 74 // sleepers on a signal keeps shutdown immediate despite that. 75 const sleepers = new Set(); 76 77 function sleep(ms) { 78 return new Promise((resolve) => { 79 const wake = () => { 80 clearTimeout(timer); 81 sleepers.delete(wake); 82 resolve(); 83 }; 84 const timer = setTimeout(wake, ms); 85 sleepers.add(wake); 86 }); 87 } 88 89 const shutdown = (signal) => { 90 if (stopping) { 91 logger.warn('second signal, exiting now'); 92 process.exit(1); 93 } 94 stopping = true; 95 logger.info(`${signal} received, finishing ${running.size} running task(s)`); 96 for (const wake of [...sleepers]) wake(); 97 }; 98 for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => shutdown(signal)); 99 100 // One loop per concurrency slot. Each polls independently, so a slow task 101 // in one slot does not stall the others. 102 async function slot(index) { 103 let backoff = cfg.poll_interval; 104 105 while (!stopping) { 106 let task = null; 107 try { 108 task = await client.claim({ arches: cfg.arches, features, name: cfg.name }); 109 backoff = cfg.poll_interval; 110 } catch (e) { 111 // A conductor that is down or restarting should not become a busy 112 // loop, so back off up to a minute. 113 logger.warn(`poll failed: ${e.message}`); 114 await sleep(backoff * 1000); 115 backoff = Math.min(backoff * 2, 60); 116 continue; 117 } 118 119 if (!task) { 120 if (args.once) return; 121 await sleep(cfg.poll_interval * 1000); 122 continue; 123 } 124 125 running.add(task.id); 126 logger.info(`[${index}] ${task.name} (${task.image})`); 127 const started = Date.now(); 128 try { 129 const result = await runTask({ cfg, client, runtime, task, logger }); 130 const seconds = ((Date.now() - started) / 1000).toFixed(1); 131 logger.info(`[${index}] ${task.name} ${result.success ? 'succeeded' : 'failed'} in ${seconds}s`); 132 } catch (e) { 133 logger.error(`[${index}] ${task.name} crashed the worker loop: ${e.stack ?? e.message}`); 134 } finally { 135 running.delete(task.id); 136 } 137 138 if (args.once) return; 139 } 140 } 141 142 await Promise.all(Array.from({ length: cfg.concurrency }, (_, i) => slot(i + 1))); 143 logger.info('stopped'); 144 } 145 146 // Only run when executed directly, so the module stays importable by tests. 147 if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) { 148 main().catch((e) => { 149 logger.error(e.message); 150 process.exit(1); 151 }); 152 }