conductor

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

expand.js (11325B)


      1 // src/lib/pipeline/expand.js - matrix and architecture expansion
      2 //
      3 // Turns each task template into one concrete task per combination of its
      4 // dimensions, then resolves needs and after between the concrete tasks.
      5 //
      6 // Dimension matching is the part worth understanding. When a task declares
      7 // needs on a template that shares a dimension with it, the dependency is
      8 // matched on that dimension rather than fanned out across all of it. So an
      9 // aarch64 package task needs the aarch64 build, not every build. Dimensions
     10 // the dependency has but the dependent does not are fanned out, which is
     11 // what you want when a single publish task waits for every architecture.
     12 // Write `{ task: name, match: all }` to opt out.
     13 
     14 import { Problems, MATRIX_KEY_PATTERN } from './schema.js';
     15 
     16 // Matches ${{ arch }} and ${{ matrix.pkg }}, tolerating inner whitespace.
     17 const INTERPOLATION = /\$\{\{\s*([^}]*?)\s*\}\}/g;
     18 
     19 // Both halves may contain dots, so the split is on the last `.outputs.`.
     20 export const OUTPUT_REF = /^tasks\.(.+)\.outputs((?:\.[A-Za-z_][A-Za-z0-9_-]*)+)$/;
     21 
     22 // tasks.name is 191 characters in the mysql schema.
     23 export const MAX_TASK_NAME = 191;
     24 
     25 export function interpolate(problems, path, text, context) {
     26   if (typeof text !== 'string') return text;
     27 
     28   return text.replace(INTERPOLATION, (whole, expr) => {
     29     if (expr === 'arch') {
     30       if (context.arch === null) {
     31         problems.add(path, 'refers to ${{ arch }} but the task declares no arch');
     32         return whole;
     33       }
     34       return context.arch;
     35     }
     36 
     37     const matrixMatch = /^matrix\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(expr);
     38     if (matrixMatch) {
     39       const key = matrixMatch[1];
     40       if (!Object.hasOwn(context.matrix, key)) {
     41         const known = Object.keys(context.matrix);
     42         problems.add(
     43           path,
     44           `refers to \${{ matrix.${key} }} which the task does not define` +
     45           (known.length > 0 ? `; available: ${known.join(', ')}` : '')
     46         );
     47         return whole;
     48       }
     49       return context.matrix[key];
     50     }
     51 
     52     // Left in place: the value only exists once the other task has run, so
     53     // this is resolved again when the task is dispatched.
     54     const outputMatch = OUTPUT_REF.exec(expr);
     55     if (outputMatch) {
     56       if (!context.outputs) {
     57         problems.add(
     58           path,
     59           `cannot read \${{ ${expr} }} here; a task output is not known until the ` +
     60           'task is dispatched, and this is needed before that'
     61         );
     62         return whole;
     63       }
     64       context.outputs.add(outputMatch[1]);
     65       return whole;
     66     }
     67 
     68     problems.add(
     69       path,
     70       `unknown expression \${{ ${expr} }}; expected arch, matrix.<name> or ` +
     71       'tasks.<task>.outputs.<name>'
     72     );
     73     return whole;
     74   });
     75 }
     76 
     77 // Every unresolved task-output reference in a string.
     78 export function outputRefsIn(text) {
     79   const found = [];
     80   for (const [whole, expr] of String(text).matchAll(INTERPOLATION)) {
     81     const match = OUTPUT_REF.exec(expr.trim());
     82     if (match) found.push({ whole, ref: match[1], key: match[2].slice(1) });
     83   }
     84   return found;
     85 }
     86 
     87 // Replaces them once their values are known, keyed by the whole expression.
     88 export function fillOutputs(value, values) {
     89   if (typeof value === 'string') {
     90     if (values.size === 0) return value;
     91     return value.replace(INTERPOLATION, (whole) => (values.has(whole) ? values.get(whole) : whole));
     92   }
     93   if (Array.isArray(value)) return value.map((v) => fillOutputs(v, values));
     94   if (value && typeof value === 'object') {
     95     return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, fillOutputs(v, values)]));
     96   }
     97   return value;
     98 }
     99 
    100 function cartesian(dimensions) {
    101   let rows = [{}];
    102   for (const [key, values] of dimensions) {
    103     const next = [];
    104     for (const row of rows) {
    105       for (const value of values) next.push({ ...row, [key]: value });
    106     }
    107     rows = next;
    108   }
    109   return rows;
    110 }
    111 
    112 // build:arch=x86_64,pkg=musl
    113 function instanceName(baseName, dims) {
    114   const entries = Object.entries(dims);
    115   if (entries.length === 0) return baseName;
    116   return `${baseName}:${entries.map(([k, v]) => `${k}=${v}`).join(',')}`;
    117 }
    118 
    119 function dimensionsOf(task) {
    120   const dims = [];
    121   if (task.arch.length > 0) dims.push(['arch', task.arch]);
    122   for (const [key, values] of Object.entries(task.matrix)) dims.push([key, values]);
    123   return dims;
    124 }
    125 
    126 export function expandPipeline(pipeline, options = {}) {
    127   const problems = new Problems();
    128   const defaultTimeout = options.defaultTimeout ?? 3600;
    129   const defaultAttempts = options.defaultAttempts ?? 1;
    130 
    131   // Pass one: build every concrete task.
    132   const byBase = new Map();
    133   const instances = [];
    134 
    135   for (const [baseName, task] of Object.entries(pipeline.tasks)) {
    136     const dims = dimensionsOf(task);
    137     const combos = cartesian(dims);
    138     const made = [];
    139 
    140     for (const combo of combos) {
    141       const arch = combo.arch ?? null;
    142       const matrix = { ...combo };
    143       delete matrix.arch;
    144 
    145       const name = instanceName(baseName, combo);
    146       const path = `tasks.${baseName}`;
    147       if (name.length > MAX_TASK_NAME) {
    148         problems.add(path, `expands to a task name longer than ${MAX_TASK_NAME} characters: ${name}`);
    149         continue;
    150       }
    151 
    152       const outputs = new Set();
    153       const context = { arch, matrix, outputs };
    154       // requires picks the worker, which happens before any output exists.
    155       const early = { arch, matrix, outputs: null };
    156       const env = {};
    157       for (const [key, value] of Object.entries(task.env)) {
    158         env[key] = interpolate(problems, `${path}.env.${key}`, value, context);
    159       }
    160       // Dimension values are exposed to the script, so a matrix task rarely
    161       // needs interpolation at all.
    162       if (arch !== null) env.ARCH = arch;
    163       for (const [key, value] of Object.entries(matrix)) {
    164         if (MATRIX_KEY_PATTERN.test(key)) env[`MATRIX_${key.toUpperCase()}`] = value;
    165       }
    166 
    167       made.push({
    168         name,
    169         baseName,
    170         arch,
    171         matrix,
    172         dims: combo,
    173         image: interpolate(problems, `${path}.image`, task.image, context),
    174         script: task.script.map((line, i) => interpolate(problems, `${path}.script[${i}]`, line, context)),
    175         requires: task.requires.map((r, i) => interpolate(problems, `${path}.requires[${i}]`, r, early)),
    176         services: task.services.map((service, i) => ({
    177           image: interpolate(problems, `${path}.services[${i}].image`, service.image, context),
    178           alias: service.alias,
    179           env: Object.fromEntries(Object.entries(service.env).map(([k, v]) => [
    180             k, interpolate(problems, `${path}.services[${i}].env.${k}`, v, context),
    181           ])),
    182           entrypoint: service.entrypoint,
    183           command: service.command,
    184         })),
    185         env,
    186         artifacts: task.artifacts
    187           ? {
    188             ...task.artifacts,
    189             paths: task.artifacts.paths.map((p, i) => interpolate(problems, `${path}.artifacts.paths[${i}]`, p, context)),
    190           }
    191           : null,
    192         allow_failure: task.allow_failure,
    193         timeout: task.timeout ?? defaultTimeout,
    194         max_attempts: task.max_attempts ?? defaultAttempts,
    195         // Every instance of a matrix task inherits the ref rule, so a
    196         // restricted task stays restricted across all of its expansions.
    197         only: task.only ?? null,
    198         needs: [],
    199         after: [],
    200         outputRefs: outputs,
    201         output_refs: {},
    202       });
    203     }
    204 
    205     byBase.set(baseName, { task, dimensionNames: dims.map(([k]) => k), instances: made });
    206     instances.push(...made);
    207   }
    208 
    209   const byName = new Map(instances.map((i) => [i.name, i]));
    210 
    211   // Pass two: resolve dependencies now that every concrete task exists.
    212   for (const [baseName, entry] of byBase) {
    213     for (const instance of entry.instances) {
    214       instance.needs = resolveDeps(problems, { byBase, byName, baseName, entry, instance, field: 'needs' });
    215       instance.after = resolveDeps(problems, { byBase, byName, baseName, entry, instance, field: 'after' });
    216 
    217       // Reading a task's output is a reason to wait for it.
    218       instance.output_refs = resolveOutputRefs(problems, { byBase, byName, baseName, instance });
    219       delete instance.outputRefs;
    220       const needs = new Set([...instance.needs, ...Object.values(instance.output_refs).flat()]);
    221       instance.needs = [...needs].sort();
    222 
    223       // Naming the same dependency both ways is held to the stricter
    224       // reading, since needs already implies after.
    225       instance.after = instance.after.filter((name) => !needs.has(name));
    226     }
    227   }
    228 
    229   problems.throwIfAny(pipeline.source);
    230   return instances;
    231 }
    232 
    233 // One task's declared dependencies, turned into concrete task names. `needs`
    234 // and `after` resolve identically: dimension matching is about which
    235 // instance is meant, not about what waiting on it implies.
    236 function resolveDeps(problems, { byBase, byName, baseName, entry, instance, field }) {
    237   const resolved = new Set();
    238   const path = `tasks.${baseName}.${field}`;
    239 
    240   for (const need of entry.task[field]) {
    241     const names = matchTask(problems, path, need.task, need.match, { byBase, byName, baseName, instance });
    242     for (const name of names ?? []) resolved.add(name);
    243   }
    244 
    245   return [...resolved].sort();
    246 }
    247 
    248 // The concrete instances one written reference means, or null when it could
    249 // not be resolved and the reason has been recorded.
    250 function matchTask(problems, path, ref, match, { byBase, byName, baseName, instance }) {
    251   // An exact concrete task name wins, which is the escape hatch for
    252   // depending on one specific combination.
    253   if (byName.has(ref)) {
    254     if (ref === instance.name) {
    255       problems.add(path, `task ${JSON.stringify(baseName)} cannot depend on itself`);
    256       return null;
    257     }
    258     return [ref];
    259   }
    260 
    261   const target = byBase.get(ref);
    262   if (!target) {
    263     const known = [...byBase.keys()].filter((n) => n !== baseName);
    264     problems.add(
    265       path,
    266       `refers to unknown task ${JSON.stringify(ref)}` +
    267       (known.length > 0 ? `; defined tasks are ${known.join(', ')}` : '')
    268     );
    269     return null;
    270   }
    271   if (ref === baseName) {
    272     problems.add(path, `task ${JSON.stringify(baseName)} cannot depend on itself`);
    273     return null;
    274   }
    275 
    276   let candidates = target.instances;
    277   if (match === 'shared') {
    278     const shared = target.dimensionNames.filter((d) => Object.hasOwn(instance.dims, d));
    279     if (shared.length > 0) {
    280       candidates = candidates.filter((c) => shared.every((d) => c.dims[d] === instance.dims[d]));
    281       if (candidates.length === 0) {
    282         problems.add(
    283           path,
    284           `no instance of ${JSON.stringify(ref)} matches ${instance.name} on ` +
    285           `${shared.join(', ')}; add the missing value, or use { task: ${ref}, match: all }`
    286         );
    287         return null;
    288       }
    289     }
    290   }
    291 
    292   return candidates.map((c) => c.name);
    293 }
    294 
    295 // A reference may name a matrix task, in which case every instance of it is
    296 // waited for and searched: each one names its outputs differently, so the
    297 // name picks the instance out at dispatch.
    298 function resolveOutputRefs(problems, { byBase, byName, baseName, instance }) {
    299   const path = `tasks.${baseName}`;
    300   const out = {};
    301 
    302   for (const ref of instance.outputRefs) {
    303     const names = matchTask(problems, path, ref, 'shared', { byBase, byName, baseName, instance });
    304     if (names !== null) out[ref] = [...names].sort();
    305   }
    306 
    307   return out;
    308 }