conductor

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

config.js (7099B)


      1 // src/worker/config.js - worker configuration
      2 //
      3 // The worker deliberately has no npm dependencies, so that a third party can
      4 // copy src/worker into a host and run it with nothing but node and a
      5 // container runtime. JSON is therefore the native config format. YAML is
      6 // accepted too, but only when the yaml package happens to be resolvable,
      7 // which it is in the official image.
      8 //
      9 // Features are the important part. A feature is a name the worker
     10 // advertises, plus whatever local resources a task asking for it should get:
     11 // mounts, environment, or a privileged container. The conductor only ever
     12 // sees the name, so a signing key can be handed to a build without the
     13 // server holding it.
     14 
     15 import fs from 'node:fs';
     16 import path from 'node:path';
     17 import os from 'node:os';
     18 
     19 const DEFAULTS = {
     20   conductor_url: 'http://127.0.0.1:8080',
     21   name: os.hostname(),
     22   token: null,
     23   token_file: null,
     24   arches: [],
     25   features: {},
     26   concurrency: 1,
     27   poll_interval: 5,
     28   // Container runtime CLI. Podman and nerdctl are argument compatible.
     29   docker: 'docker',
     30   // Shell used to run a task script inside its image.
     31   shell: 'sh',
     32   // Applied when a task does not ask for something longer.
     33   default_timeout: 3600,
     34 };
     35 
     36 const ENV_MAP = [
     37   ['CONDUCTOR_URL', 'conductor_url', String],
     38   ['CONDUCTOR_WORKER_NAME', 'name', String],
     39   ['CONDUCTOR_WORKER_TOKEN', 'token', String],
     40   ['CONDUCTOR_WORKER_TOKEN_FILE', 'token_file', String],
     41   ['CONDUCTOR_WORKER_ARCHES', 'arches', (v) => splitList(v)],
     42   ['CONDUCTOR_WORKER_CONCURRENCY', 'concurrency', toInt],
     43   ['CONDUCTOR_WORKER_POLL_INTERVAL', 'poll_interval', toInt],
     44   ['CONDUCTOR_WORKER_DOCKER', 'docker', String],
     45   ['CONDUCTOR_WORKER_SHELL', 'shell', String],
     46 ];
     47 
     48 function splitList(value) {
     49   return String(value).split(',').map((s) => s.trim()).filter(Boolean);
     50 }
     51 
     52 function toInt(v) {
     53   const n = parseInt(v, 10);
     54   if (!Number.isFinite(n)) throw new Error(`expected an integer, got ${JSON.stringify(v)}`);
     55   return n;
     56 }
     57 
     58 function isPlainObject(v) {
     59   return v !== null && typeof v === 'object' && !Array.isArray(v);
     60 }
     61 
     62 async function readConfigFile(file) {
     63   const text = fs.readFileSync(file, 'utf8');
     64   if (file.endsWith('.json')) return JSON.parse(text);
     65 
     66   try {
     67     const YAML = (await import('yaml')).default;
     68     return YAML.parse(text);
     69   } catch (e) {
     70     if (e instanceof SyntaxError || e.name === 'YAMLParseError') throw e;
     71     throw new Error(
     72       `cannot read ${file}: the yaml package is not installed. ` +
     73       'Install it, or use a .json config file.',
     74       { cause: e }
     75     );
     76   }
     77 }
     78 
     79 // A feature maps a name onto local resources. Everything is optional, so
     80 // `{ "dind": {} }` is a valid way to advertise a capability that needs no
     81 // setup of its own.
     82 function normalizeFeature(name, raw, problems) {
     83   if (raw === null || raw === undefined) return { mounts: [], env: {}, privileged: false, devices: [] };
     84   if (!isPlainObject(raw)) {
     85     problems.push(`features.${name}: expected a mapping`);
     86     return null;
     87   }
     88 
     89   const allowed = ['mounts', 'env', 'privileged', 'devices'];
     90   for (const key of Object.keys(raw)) {
     91     if (!allowed.includes(key)) problems.push(`features.${name}.${key}: unknown key`);
     92   }
     93 
     94   const mounts = [];
     95   for (const [i, mount] of (raw.mounts ?? []).entries()) {
     96     if (typeof mount !== 'string' || !mount.includes(':')) {
     97       problems.push(`features.${name}.mounts[${i}]: expected "host:container" or "host:container:ro"`);
     98       continue;
     99     }
    100     mounts.push(mount);
    101   }
    102 
    103   const env = {};
    104   for (const [key, value] of Object.entries(raw.env ?? {})) {
    105     if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
    106       problems.push(`features.${name}.env.${key}: not a valid environment variable name`);
    107       continue;
    108     }
    109     env[key] = String(value);
    110   }
    111 
    112   return {
    113     mounts,
    114     env,
    115     privileged: raw.privileged === true,
    116     devices: (raw.devices ?? []).map(String),
    117   };
    118 }
    119 
    120 // Looked at when no file was named. Present means use it, absent means
    121 // carry on with the environment alone, which is how the container image
    122 // runs with nothing mounted.
    123 const DEFAULT_PATHS = ['/etc/conductor/worker.json', '/etc/conductor/worker.yaml', './worker.json', './worker.yaml'];
    124 
    125 export async function loadWorkerConfig(explicitPath) {
    126   const named = explicitPath || process.env.CONDUCTOR_WORKER_CONFIG || null;
    127   let cfg = { ...DEFAULTS };
    128 
    129   // A file asked for by name must exist; a default one need not.
    130   if (named && !fs.existsSync(named)) throw new Error(`worker config not found: ${named}`);
    131   const file = named ?? DEFAULT_PATHS.find((candidate) => fs.existsSync(candidate)) ?? null;
    132 
    133   if (file) {
    134     const parsed = await readConfigFile(file);
    135     if (!isPlainObject(parsed)) throw new Error(`${file} must contain a mapping at the top level`);
    136     cfg = { ...cfg, ...parsed };
    137     cfg.source = file;
    138   }
    139 
    140   for (const [env, key, parse] of ENV_MAP) {
    141     const raw = process.env[env];
    142     if (raw === undefined || raw === '') continue;
    143     try {
    144       cfg[key] = parse(raw);
    145     } catch (e) {
    146       throw new Error(`invalid value for ${env}: ${e.message}`);
    147     }
    148   }
    149 
    150   const problems = [];
    151 
    152   if (typeof cfg.conductor_url !== 'string') problems.push('conductor_url must be a string');
    153   else {
    154     try {
    155       new URL(cfg.conductor_url);
    156     } catch {
    157       problems.push(`conductor_url must be an absolute URL, got ${JSON.stringify(cfg.conductor_url)}`);
    158     }
    159   }
    160 
    161   if (!Array.isArray(cfg.arches)) {
    162     problems.push('arches must be a list');
    163     cfg.arches = [];
    164   }
    165   if (!Number.isInteger(cfg.concurrency) || cfg.concurrency < 1 || cfg.concurrency > 64) {
    166     problems.push(`concurrency must be between 1 and 64, got ${cfg.concurrency}`);
    167   }
    168   if (!Number.isInteger(cfg.poll_interval) || cfg.poll_interval < 1) {
    169     problems.push(`poll_interval must be at least 1 second, got ${cfg.poll_interval}`);
    170   }
    171 
    172   const features = {};
    173   if (!isPlainObject(cfg.features)) {
    174     problems.push('features must be a mapping of feature name to definition');
    175   } else {
    176     for (const [name, raw] of Object.entries(cfg.features)) {
    177       if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name)) {
    178         problems.push(`features.${name}: invalid feature name`);
    179         continue;
    180       }
    181       const feature = normalizeFeature(name, raw, problems);
    182       if (feature) features[name] = feature;
    183     }
    184   }
    185   cfg.features = features;
    186 
    187   // The token may be inline, in a file, or in the environment. A file is
    188   // preferred so it never appears in the process list or in `docker inspect`.
    189   if (!cfg.token && cfg.token_file) {
    190     try {
    191       cfg.token = fs.readFileSync(cfg.token_file, 'utf8').trim();
    192     } catch (e) {
    193       problems.push(`cannot read token_file ${cfg.token_file}: ${e.message}`);
    194     }
    195   }
    196   if (!cfg.token) problems.push('no worker token: set token, token_file, or CONDUCTOR_WORKER_TOKEN');
    197 
    198   cfg.conductor_url = String(cfg.conductor_url).replace(/\/+$/, '');
    199 
    200   if (problems.length > 0) {
    201     throw new Error(`invalid worker configuration:\n  - ${problems.join('\n  - ')}`);
    202   }
    203 
    204   return cfg;
    205 }
    206 
    207 export function featureNames(cfg) {
    208   return Object.keys(cfg.features).sort();
    209 }