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 }