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 }