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