conductor

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

expand.js (7814B)


      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 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 // tasks.name is 191 characters in the mysql schema.
     20 export const MAX_TASK_NAME = 191;
     21 
     22 export function interpolate(problems, path, text, context) {
     23   if (typeof text !== 'string') return text;
     24 
     25   return text.replace(INTERPOLATION, (whole, expr) => {
     26     if (expr === 'arch') {
     27       if (context.arch === null) {
     28         problems.add(path, 'refers to ${{ arch }} but the task declares no arch');
     29         return whole;
     30       }
     31       return context.arch;
     32     }
     33 
     34     const matrixMatch = /^matrix\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(expr);
     35     if (matrixMatch) {
     36       const key = matrixMatch[1];
     37       if (!Object.hasOwn(context.matrix, key)) {
     38         const known = Object.keys(context.matrix);
     39         problems.add(
     40           path,
     41           `refers to \${{ matrix.${key} }} which the task does not define` +
     42           (known.length > 0 ? `; available: ${known.join(', ')}` : '')
     43         );
     44         return whole;
     45       }
     46       return context.matrix[key];
     47     }
     48 
     49     problems.add(path, `unknown expression \${{ ${expr} }}; expected arch or matrix.<name>`);
     50     return whole;
     51   });
     52 }
     53 
     54 function cartesian(dimensions) {
     55   let rows = [{}];
     56   for (const [key, values] of dimensions) {
     57     const next = [];
     58     for (const row of rows) {
     59       for (const value of values) next.push({ ...row, [key]: value });
     60     }
     61     rows = next;
     62   }
     63   return rows;
     64 }
     65 
     66 // build:arch=x86_64,pkg=musl
     67 function instanceName(baseName, dims) {
     68   const entries = Object.entries(dims);
     69   if (entries.length === 0) return baseName;
     70   return `${baseName}:${entries.map(([k, v]) => `${k}=${v}`).join(',')}`;
     71 }
     72 
     73 function dimensionsOf(task) {
     74   const dims = [];
     75   if (task.arch.length > 0) dims.push(['arch', task.arch]);
     76   for (const [key, values] of Object.entries(task.matrix)) dims.push([key, values]);
     77   return dims;
     78 }
     79 
     80 export function expandPipeline(pipeline, options = {}) {
     81   const problems = new Problems();
     82   const defaultTimeout = options.defaultTimeout ?? 3600;
     83   const defaultAttempts = options.defaultAttempts ?? 1;
     84 
     85   // Pass one: build every concrete task.
     86   const byBase = new Map();
     87   const instances = [];
     88 
     89   for (const [baseName, task] of Object.entries(pipeline.tasks)) {
     90     const dims = dimensionsOf(task);
     91     const combos = cartesian(dims);
     92     const made = [];
     93 
     94     for (const combo of combos) {
     95       const arch = combo.arch ?? null;
     96       const matrix = { ...combo };
     97       delete matrix.arch;
     98 
     99       const name = instanceName(baseName, combo);
    100       const path = `tasks.${baseName}`;
    101       if (name.length > MAX_TASK_NAME) {
    102         problems.add(path, `expands to a task name longer than ${MAX_TASK_NAME} characters: ${name}`);
    103         continue;
    104       }
    105 
    106       const context = { arch, matrix };
    107       const env = {};
    108       for (const [key, value] of Object.entries(task.env)) {
    109         env[key] = interpolate(problems, `${path}.env.${key}`, value, context);
    110       }
    111       // Dimension values are exposed to the script, so a matrix task rarely
    112       // needs interpolation at all.
    113       if (arch !== null) env.ARCH = arch;
    114       for (const [key, value] of Object.entries(matrix)) {
    115         if (MATRIX_KEY_PATTERN.test(key)) env[`MATRIX_${key.toUpperCase()}`] = value;
    116       }
    117 
    118       made.push({
    119         name,
    120         baseName,
    121         arch,
    122         matrix,
    123         dims: combo,
    124         image: interpolate(problems, `${path}.image`, task.image, context),
    125         script: task.script.map((line, i) => interpolate(problems, `${path}.script[${i}]`, line, context)),
    126         requires: task.requires.map((r, i) => interpolate(problems, `${path}.requires[${i}]`, r, context)),
    127         services: task.services.map((service, i) => ({
    128           image: interpolate(problems, `${path}.services[${i}].image`, service.image, context),
    129           alias: service.alias,
    130           env: Object.fromEntries(Object.entries(service.env).map(([k, v]) => [
    131             k, interpolate(problems, `${path}.services[${i}].env.${k}`, v, context),
    132           ])),
    133           entrypoint: service.entrypoint,
    134           command: service.command,
    135         })),
    136         env,
    137         artifacts: task.artifacts
    138           ? {
    139             ...task.artifacts,
    140             paths: task.artifacts.paths.map((p, i) => interpolate(problems, `${path}.artifacts.paths[${i}]`, p, context)),
    141           }
    142           : null,
    143         allow_failure: task.allow_failure,
    144         timeout: task.timeout ?? defaultTimeout,
    145         max_attempts: task.max_attempts ?? defaultAttempts,
    146         // Every instance of a matrix task inherits the ref rule, so a
    147         // restricted task stays restricted across all of its expansions.
    148         only: task.only ?? null,
    149         needs: [],
    150       });
    151     }
    152 
    153     byBase.set(baseName, { task, dimensionNames: dims.map(([k]) => k), instances: made });
    154     instances.push(...made);
    155   }
    156 
    157   const byName = new Map(instances.map((i) => [i.name, i]));
    158 
    159   // Pass two: resolve needs now that every concrete task exists.
    160   for (const [baseName, entry] of byBase) {
    161     for (const instance of entry.instances) {
    162       const resolved = new Set();
    163 
    164       for (const need of entry.task.needs) {
    165         const path = `tasks.${baseName}.needs`;
    166 
    167         // An exact concrete task name wins, which is the escape hatch for
    168         // depending on one specific combination.
    169         if (byName.has(need.task)) {
    170           if (need.task === instance.name) {
    171             problems.add(path, `task ${JSON.stringify(baseName)} cannot depend on itself`);
    172           } else {
    173             resolved.add(need.task);
    174           }
    175           continue;
    176         }
    177 
    178         const target = byBase.get(need.task);
    179         if (!target) {
    180           const known = [...byBase.keys()].filter((n) => n !== baseName);
    181           problems.add(
    182             path,
    183             `refers to unknown task ${JSON.stringify(need.task)}` +
    184             (known.length > 0 ? `; defined tasks are ${known.join(', ')}` : '')
    185           );
    186           continue;
    187         }
    188         if (need.task === baseName) {
    189           problems.add(path, `task ${JSON.stringify(baseName)} cannot depend on itself`);
    190           continue;
    191         }
    192 
    193         let candidates = target.instances;
    194         if (need.match === 'shared') {
    195           const shared = target.dimensionNames.filter((d) => Object.hasOwn(instance.dims, d));
    196           if (shared.length > 0) {
    197             candidates = candidates.filter((c) => shared.every((d) => c.dims[d] === instance.dims[d]));
    198             if (candidates.length === 0) {
    199               problems.add(
    200                 path,
    201                 `no instance of ${JSON.stringify(need.task)} matches ${instance.name} on ` +
    202                 `${shared.join(', ')}; add the missing value, or use { task: ${need.task}, match: all }`
    203               );
    204               continue;
    205             }
    206           }
    207         }
    208 
    209         for (const candidate of candidates) resolved.add(candidate.name);
    210       }
    211 
    212       instance.needs = [...resolved].sort();
    213     }
    214   }
    215 
    216   problems.throwIfAny(pipeline.source);
    217   return instances;
    218 }