schema.js (6831B)
1 // src/lib/pipeline/schema.js - validation primitives for pipeline documents 2 // 3 // A .conductor.yml is written by hand and frequently by someone who is not 4 // watching the conductor log, so validation collects every problem and 5 // reports them together with a path, rather than failing on the first one. 6 // Unknown keys are errors, not warnings: a silently ignored typo in a 7 // pipeline is a bad afternoon. 8 9 export class PipelineError extends Error { 10 constructor(errors, source) { 11 const list = errors.map((e) => ` ${e.path}: ${e.message}`).join('\n'); 12 super(`invalid pipeline${source ? ` in ${source}` : ''}:\n${list}`); 13 this.name = 'PipelineError'; 14 this.errors = errors; 15 this.source = source; 16 } 17 } 18 19 // Collects errors so that one pass reports everything wrong with a document. 20 export class Problems { 21 constructor() { 22 this.items = []; 23 } 24 25 add(path, message) { 26 this.items.push({ path, message }); 27 return undefined; 28 } 29 30 get length() { 31 return this.items.length; 32 } 33 34 throwIfAny(source) { 35 if (this.items.length > 0) throw new PipelineError(this.items, source); 36 } 37 } 38 39 export function isPlainObject(v) { 40 return v !== null && typeof v === 'object' && !Array.isArray(v); 41 } 42 43 export function checkUnknown(problems, path, value, allowed) { 44 for (const key of Object.keys(value)) { 45 if (!allowed.includes(key)) { 46 const hint = suggest(key, allowed); 47 problems.add(`${path}.${key}`, `unknown key${hint ? `, did you mean ${hint}` : ''}`); 48 } 49 } 50 } 51 52 // Cheap edit distance, only to improve the message on a near miss. 53 function suggest(key, allowed) { 54 let best = null; 55 let bestScore = Infinity; 56 for (const candidate of allowed) { 57 const score = distance(key, candidate); 58 if (score < bestScore) { 59 bestScore = score; 60 best = candidate; 61 } 62 } 63 return bestScore <= 2 ? best : null; 64 } 65 66 function distance(a, b) { 67 const rows = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]); 68 for (let j = 0; j <= b.length; j += 1) rows[0][j] = j; 69 for (let i = 1; i <= a.length; i += 1) { 70 for (let j = 1; j <= b.length; j += 1) { 71 rows[i][j] = Math.min( 72 rows[i - 1][j] + 1, 73 rows[i][j - 1] + 1, 74 rows[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1) 75 ); 76 } 77 } 78 return rows[a.length][b.length]; 79 } 80 81 export function asString(problems, path, value, { max = 4096 } = {}) { 82 if (typeof value !== 'string') return problems.add(path, `expected a string, got ${typeName(value)}`); 83 if (value.length === 0) return problems.add(path, 'must not be empty'); 84 if (value.length > max) return problems.add(path, `must be at most ${max} characters`); 85 return value; 86 } 87 88 export function asBoolean(problems, path, value) { 89 if (typeof value !== 'boolean') return problems.add(path, `expected true or false, got ${typeName(value)}`); 90 return value; 91 } 92 93 export function asInteger(problems, path, value, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) { 94 if (typeof value !== 'number' || !Number.isInteger(value)) { 95 return problems.add(path, `expected an integer, got ${typeName(value)}`); 96 } 97 if (value < min || value > max) return problems.add(path, `must be between ${min} and ${max}`); 98 return value; 99 } 100 101 // Accepts a single string as a one element list, which is how people 102 // naturally write a single value. 103 export function asStringList(problems, path, value, opts = {}) { 104 if (value === undefined) return []; 105 const list = Array.isArray(value) ? value : [value]; 106 const out = []; 107 list.forEach((item, i) => { 108 const s = asString(problems, `${path}[${i}]`, item, opts); 109 if (s !== undefined) out.push(s); 110 }); 111 return out; 112 } 113 114 // Scalar values only. YAML readily produces numbers and booleans here, and 115 // silently stringifying them hides mistakes, so they are converted but 116 // anything structured is rejected. 117 export function asEnvMap(problems, path, value) { 118 if (value === undefined) return {}; 119 if (!isPlainObject(value)) { 120 problems.add(path, `expected a mapping of names to values, got ${typeName(value)}`); 121 return {}; 122 } 123 const out = {}; 124 for (const [key, raw] of Object.entries(value)) { 125 if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { 126 problems.add(`${path}.${key}`, 'is not a valid environment variable name'); 127 continue; 128 } 129 if (raw === null) { 130 out[key] = ''; 131 } else if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') { 132 out[key] = String(raw); 133 } else { 134 problems.add(`${path}.${key}`, `expected a scalar value, got ${typeName(raw)}`); 135 } 136 } 137 return out; 138 } 139 140 export const MAX_DURATION = 7 * 24 * 60 * 60; 141 142 // Accepts 90, '90s', '30m', '1h', '30d', '2w', or a composite such as 143 // '1h30m'. A bare number is seconds. 144 export function asDuration(problems, path, value, { max = MAX_DURATION } = {}) { 145 let seconds; 146 147 if (typeof value === 'number') { 148 if (!Number.isInteger(value)) return problems.add(path, 'expected a whole number of seconds'); 149 seconds = value; 150 } else if (typeof value === 'string') { 151 const text = value.trim(); 152 if (/^\d+$/.test(text)) { 153 seconds = parseInt(text, 10); 154 } else { 155 const matches = [...text.matchAll(/(\d+)([smhdw])/g)]; 156 const consumed = matches.reduce((n, m) => n + m[0].length, 0); 157 if (matches.length === 0 || consumed !== text.length) { 158 return problems.add( 159 path, 160 `expected a duration such as 90s, 30m, 1h30m or 30d, got ${JSON.stringify(value)}` 161 ); 162 } 163 const unit = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 }; 164 seconds = matches.reduce((n, m) => n + parseInt(m[1], 10) * unit[m[2]], 0); 165 } 166 } else { 167 return problems.add(path, `expected a duration, got ${typeName(value)}`); 168 } 169 170 if (seconds < 1) return problems.add(path, 'must be at least 1 second'); 171 if (seconds > max) return problems.add(path, `must be at most ${max} seconds`); 172 return seconds; 173 } 174 175 export function typeName(v) { 176 if (v === null) return 'null'; 177 if (Array.isArray(v)) return 'a list'; 178 if (v === undefined) return 'nothing'; 179 if (typeof v === 'object') return 'a mapping'; 180 return `a ${typeof v}`; 181 } 182 183 // Task and matrix value names appear in generated task names, storage keys and 184 // environment variables, so the character set is deliberately narrow. 185 export const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; 186 export const MATRIX_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; 187 188 export function asName(problems, path, value, { max = 100 } = {}) { 189 const s = asString(problems, path, value, { max }); 190 if (s === undefined) return undefined; 191 if (!NAME_PATTERN.test(s)) { 192 return problems.add( 193 path, 194 `${JSON.stringify(s)} must start with a letter or digit and contain only ` + 195 'letters, digits, underscore, dot and hyphen' 196 ); 197 } 198 return s; 199 }