conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

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 }