task.js (11938B)
1 // src/worker/task.js - running one task 2 // 3 // Sequence: create the container, put the tree and the script into it, 4 // start any services on a private network, run it, ship its output, 5 // take the artifacts back out, report, and clean up. 6 // 7 // Nothing is shared with the container through the filesystem. The tree 8 // arrives over the docker API and the artifacts leave the same way, so 9 // the worker needs no directory the host can also see, and therefore no 10 // mounts, no matching paths, and no storage of its own. What little it 11 // does write goes to a scratch directory that lives and dies with the 12 // task. 13 // 14 // Cleanup runs whatever happened. A worker that leaks containers or 15 // networks stops being usable after a few dozen tasks. 16 17 import fs from 'node:fs/promises'; 18 import os from 'node:os'; 19 import path from 'node:path'; 20 import { containerName } from './docker.js'; 21 import { createDirectives, createScanner } from './directives.js'; 22 import { createLogStream } from './logstream.js'; 23 import { sourceIntoContainer } from './source.js'; 24 25 // Where a task runs when the conductor did not say. The conductor resolves 26 // this from the pipeline and the project, so this only covers a worker 27 // talking to one too old to have an opinion. 28 const FALLBACK_WORKDIR = '/work'; 29 import { buildScript, SCRIPT_NAME, SCRIPT_PATH } from './script.js'; 30 import { collectArtifacts, uploadArtifacts, shouldCollect } from './artifacts.js'; 31 32 // The part of an artifact pattern before its first wildcard, which is the 33 // most that can be copied out without asking the container to expand a 34 // glob it is no longer running to expand. An empty string means the 35 // pattern could match anywhere, so the whole tree has to come back. 36 export function patternPrefix(pattern) { 37 const segments = String(pattern).split('/'); 38 const literal = []; 39 for (const segment of segments) { 40 if (/[*?[\]{}]/.test(segment)) break; 41 literal.push(segment); 42 } 43 // A pattern with no wildcard at all names one path, and its last 44 // segment is a file rather than a directory to descend into. 45 return literal.join('/'); 46 } 47 48 // The set of paths to copy out for a task's artifact patterns, with any 49 // path that is covered by another removed. 50 export function copyOutPaths(patterns) { 51 const prefixes = (patterns ?? []).map(patternPrefix); 52 if (prefixes.some((p) => p === '')) return ['']; 53 54 const unique = [...new Set(prefixes)].sort(); 55 return unique.filter((p, i) => !unique.slice(0, i).some((other) => p === other || p.startsWith(`${other}/`))); 56 } 57 58 // Resolves the features a task asked for into local resources. 59 export function resolveFeatures(task, available) { 60 const mounts = []; 61 const env = {}; 62 const devices = []; 63 let privileged = false; 64 const missing = []; 65 66 for (const name of task.requires ?? []) { 67 const feature = available[name]; 68 if (!feature) { 69 missing.push(name); 70 continue; 71 } 72 mounts.push(...feature.mounts); 73 devices.push(...feature.devices); 74 Object.assign(env, feature.env); 75 if (feature.privileged) privileged = true; 76 } 77 78 return { mounts, env, devices, privileged, missing }; 79 } 80 81 export async function runTask({ cfg, client, runtime, task, logger = console }) { 82 const network = containerName('conductor-net', task.id); 83 const taskContainer = containerName('conductor', task.id); 84 const workdir = task.workdir || FALLBACK_WORKDIR; 85 86 // Worker local, and only ever written by the worker: the generated 87 // script on its way in, and artifacts on their way out. The container 88 // never sees it. 89 let scratch = null; 90 91 const log = createLogStream(client, task.endpoints.log, { masked: task.masked ?? [], logger }); 92 93 const directives = createDirectives({ 94 onMask: (value, name) => { 95 if (!log.addSecret(value)) { 96 log.note(`[conductor] output ${name} is too short to mask reliably`); 97 } 98 }, 99 onProblem: (message) => log.note(`[conductor] ${message}`), 100 }); 101 102 const services = []; 103 let networkCreated = false; 104 let containerCreated = false; 105 let heartbeat = null; 106 let timeoutTimer = null; 107 let cancelled = false; 108 let timedOut = false; 109 let exitCode = null; 110 let failure = null; 111 let flushScanners = null; 112 113 // Before setup: the reaper clock starts at claim, and a pull can outrun it. 114 const beatEvery = Math.max(5, Number(task.heartbeat_interval) || 30) * 1000; 115 heartbeat = setInterval(async () => { 116 try { 117 const result = await client.heartbeat(task.endpoints.heartbeat); 118 if (result.cancelled && !cancelled) { 119 cancelled = true; 120 log.note('[conductor] cancelled, stopping the container'); 121 if (containerCreated) await runtime.kill(taskContainer); 122 } 123 } catch (e) { 124 logger.warn?.(`heartbeat failed for ${task.name}: ${e.message}`); 125 } 126 }, beatEvery); 127 heartbeat.unref?.(); 128 129 try { 130 scratch = await fs.mkdtemp(path.join(os.tmpdir(), 'conductor-task-')); 131 132 log.note(`[conductor] task ${task.name} on ${cfg.name}`); 133 log.note(`[conductor] commit ${String(task.sha).slice(0, 12)} attempt ${task.attempt}`); 134 log.note(`[conductor] image ${task.image}`); 135 136 const features = resolveFeatures(task, cfg.features); 137 if (features.missing.length > 0) { 138 // The conductor should never send such a task, so this means the two 139 // sides disagree about what this worker offers. 140 throw new Error(`worker does not provide required feature(s): ${features.missing.join(', ')}`); 141 } 142 143 await runtime.createNetwork(network); 144 networkCreated = true; 145 146 for (const service of task.services ?? []) { 147 const name = containerName('conductor-svc', task.id, service.alias); 148 log.note(`[conductor] starting service ${service.image} as ${service.alias}`); 149 await runtime.startService({ 150 name, 151 image: service.image, 152 network, 153 networkAlias: service.alias, 154 env: service.env ?? {}, 155 entrypoint: service.entrypoint?.length ? service.entrypoint[0] : null, 156 command: service.entrypoint?.length ? [...service.entrypoint.slice(1), ...(service.command ?? [])] : (service.command ?? []), 157 }); 158 services.push(name); 159 } 160 161 // Created rather than run, so its filesystem can be filled before it 162 // starts and read after it exits. Feature mounts are still bind 163 // mounts, since those name host paths the operator chose deliberately. 164 await runtime.create({ 165 name: taskContainer, 166 image: task.image, 167 network, 168 workdir, 169 env: { ...features.env, ...task.env }, 170 mounts: features.mounts, 171 devices: features.devices, 172 privileged: features.privileged, 173 entrypoint: cfg.shell, 174 command: [SCRIPT_PATH], 175 }); 176 containerCreated = true; 177 178 await sourceIntoContainer({ client, runtime, task, container: taskContainer, workdir }); 179 180 const scriptPath = path.join(scratch, SCRIPT_NAME); 181 await fs.writeFile(scriptPath, buildScript(task.script), { mode: 0o755 }); 182 await runtime.copyIn(taskContainer, scriptPath, SCRIPT_PATH); 183 184 if (cancelled) throw new Error('cancelled during setup'); 185 186 const { child } = runtime.startCreated(taskContainer); 187 188 // One per stream; the two interleave. 189 const scanners = [createScanner(directives), createScanner(directives)]; 190 child.stdout.on('data', (chunk) => log.write(scanners[0].push(chunk))); 191 child.stderr.on('data', (chunk) => log.write(scanners[1].push(chunk))); 192 flushScanners = () => { 193 for (const scanner of scanners) log.write(scanner.end()); 194 }; 195 196 const limit = (Number(task.timeout) || cfg.default_timeout) * 1000; 197 timeoutTimer = setTimeout(async () => { 198 timedOut = true; 199 log.note(`[conductor] timed out after ${Math.round(limit / 1000)}s, stopping the container`); 200 await runtime.kill(taskContainer); 201 }, limit); 202 timeoutTimer.unref?.(); 203 204 exitCode = await new Promise((resolve, reject) => { 205 child.on('error', reject); 206 child.on('close', resolve); 207 }); 208 } catch (e) { 209 failure = e; 210 log.note(`[conductor] ${e.message}`); 211 } finally { 212 if (heartbeat) clearInterval(heartbeat); 213 if (timeoutTimer) clearTimeout(timeoutTimer); 214 } 215 216 if (flushScanners) flushScanners(); 217 218 // A directive only overrides what the exit code implied. Being killed is 219 // not something a script gets to talk its way out of. 220 const hard = failure !== null || cancelled || timedOut; 221 const state = hard 222 ? 'failed' 223 : (directives.state ?? (exitCode === 0 ? 'success' : 'failed')); 224 const success = state === 'success'; 225 226 // Artifacts are collected before teardown, and even after a failure 227 // when the pipeline asked for that. They come out of the stopped 228 // container, which still has its filesystem until it is removed. 229 if (containerCreated && task.artifacts && shouldCollect(task.artifacts.when, success)) { 230 try { 231 const staged = await stageArtifacts(); 232 const files = await collectArtifacts(staged, task.artifacts.paths, { logger }); 233 if (files.length > 0) { 234 log.note(`[conductor] uploading ${files.length} artifact(s)`); 235 await uploadArtifacts(client, task.endpoints.artifact, files, { logger }); 236 } else { 237 log.note('[conductor] no files matched the artifact patterns'); 238 } 239 } catch (e) { 240 log.note(`[conductor] artifact collection failed: ${e.message}`); 241 logger.warn?.(`artifact collection failed for ${task.name}: ${e.message}`); 242 } 243 } 244 245 const error = failure 246 ? failure.message 247 : timedOut 248 ? 'task timed out' 249 : cancelled 250 ? 'task cancelled' 251 : state !== 'failed' 252 ? null 253 : exitCode === 0 254 ? 'task set its state to failed' 255 : `task exited ${exitCode}`; 256 257 if (error) log.note(`[conductor] ${error}`); 258 else if (state === 'skipped') log.note('[conductor] skipped'); 259 else log.note('[conductor] done'); 260 261 await log.close(); 262 263 // Teardown first, so a slow conductor cannot hold containers open. 264 await cleanup(); 265 266 try { 267 await client.done(task.endpoints.done, { 268 success, state, exitCode, error, outputs: directives.outputs, 269 }); 270 } catch (e) { 271 logger.error?.(`could not report completion of ${task.name}: ${e.message}`); 272 } 273 274 return { success, state, exitCode, error, cancelled, timedOut, outputs: directives.outputs }; 275 276 // Copies what the artifact patterns could possibly match out of the 277 // stopped container, into a directory the globbing can then walk. 278 // 279 // Only the leading literal part of each pattern can be copied, since 280 // the container is no longer running to expand a glob. A pattern that 281 // could match anywhere brings the whole tree back, which is the price 282 // of asking for one. 283 async function stageArtifacts() { 284 const staged = path.join(scratch, 'artifacts'); 285 await fs.mkdir(staged, { recursive: true }); 286 287 for (const relative of copyOutPaths(task.artifacts.paths)) { 288 if (relative === '') { 289 // The trailing /. copies the contents rather than the directory. 290 await runtime.copyOut(taskContainer, `${workdir}/.`, staged); 291 break; 292 } 293 294 // Copying out of a container does need the parent to exist here, 295 // unlike copying in. 296 const parent = path.join(staged, path.dirname(relative)); 297 await fs.mkdir(parent, { recursive: true }); 298 await runtime.copyOut(taskContainer, `${workdir}/${relative}`, parent); 299 } 300 301 return staged; 302 } 303 304 async function cleanup() { 305 for (const name of services) await runtime.remove(name); 306 await runtime.remove(taskContainer); 307 if (networkCreated) await runtime.removeNetwork(network); 308 309 // Nothing here was written by the task, so there is no root owned 310 // output to work around: removing the container took that with it. 311 if (scratch) { 312 await fs.rm(scratch, { recursive: true, force: true }) 313 .catch((e) => logger.warn?.(`could not remove scratch ${scratch}: ${e.message}`)); 314 } 315 } 316 }