conductor

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

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 }