conductor

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

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 }