agent.js (5739B)
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 async function handBack(task) { 101 try { 102 await client.done(task.endpoints.done, { 103 success: false, 104 state: 'failed', 105 exitCode: null, 106 error: 'worker shut down before the task started', 107 }); 108 } catch (e) { 109 logger.warn(`could not hand back ${task.name}: ${e.message}`); 110 } 111 } 112 113 // One loop per concurrency slot. Each polls independently, so a slow task 114 // in one slot does not stall the others. 115 async function slot(index) { 116 let backoff = cfg.poll_interval; 117 118 while (!stopping) { 119 let task = null; 120 try { 121 task = await client.claim({ arches: cfg.arches, features, name: cfg.name }); 122 backoff = cfg.poll_interval; 123 } catch (e) { 124 // A conductor that is down or restarting should not become a busy 125 // loop, so back off up to a minute. 126 logger.warn(`poll failed: ${e.message}`); 127 await sleep(backoff * 1000); 128 backoff = Math.min(backoff * 2, 60); 129 continue; 130 } 131 132 if (!task) { 133 if (args.once) return; 134 await sleep(cfg.poll_interval * 1000); 135 continue; 136 } 137 138 // Signal landed mid-claim; starting it would orphan it until the reaper. 139 if (stopping) { 140 await handBack(task); 141 return; 142 } 143 144 running.add(task.id); 145 logger.info(`[${index}] ${task.name} (${task.image})`); 146 const started = Date.now(); 147 try { 148 const result = await runTask({ cfg, client, runtime, task, logger }); 149 const seconds = ((Date.now() - started) / 1000).toFixed(1); 150 logger.info(`[${index}] ${task.name} ${result.success ? 'succeeded' : 'failed'} in ${seconds}s`); 151 } catch (e) { 152 logger.error(`[${index}] ${task.name} crashed the worker loop: ${e.stack ?? e.message}`); 153 } finally { 154 running.delete(task.id); 155 } 156 157 if (args.once) return; 158 } 159 } 160 161 await Promise.all(Array.from({ length: cfg.concurrency }, (_, i) => slot(i + 1))); 162 logger.info('stopped'); 163 } 164 165 // Only run when executed directly, so the module stays importable by tests. 166 if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) { 167 main().catch((e) => { 168 logger.error(e.message); 169 process.exit(1); 170 }); 171 }