conductor

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

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 }