conductor

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

parse.js (16081B)


      1 // src/lib/pipeline/parse.js - reads and validates a .conductor.yml
      2 //
      3 // Produces a normalized document: every task has every field filled in from
      4 // defaults, so later stages never have to ask whether something was set.
      5 // Nothing here expands matrices or resolves dependencies; see expand.js.
      6 
      7 import YAML from 'yaml';
      8 import {
      9   PipelineError,
     10   Problems,
     11   isPlainObject,
     12   checkUnknown,
     13   asString,
     14   asName,
     15   asBoolean,
     16   asInteger,
     17   asStringList,
     18   asEnvMap,
     19   asDuration,
     20   typeName,
     21   MATRIX_KEY_PATTERN,
     22   NAME_PATTERN,
     23 } from './schema.js';
     24 
     25 const TOP_KEYS = ['version', 'visibility', 'workdir', 'defaults', 'tasks'];
     26 
     27 // A repository decides whether its own build results are readable without
     28 // signing in. Unset means the project's setting stands.
     29 export const VISIBILITIES = ['public', 'private'];
     30 
     31 // Where the tree is put inside a task container, and the directory every
     32 // script starts in. Unset means the project's setting stands, and failing
     33 // that the server default.
     34 export const DEFAULT_WORKDIR = '/work';
     35 
     36 // An absolute path of ordinary directory names. The tree is delivered by
     37 // unpacking an archive at the container's root, so the path is built from
     38 // the archive's own entries: anything that could climb out of it, or that
     39 // is not a plain name, is refused rather than sanitised.
     40 export function parseWorkdir(problems, path, raw) {
     41   const value = asString(problems, path, raw);
     42   if (typeof value !== 'string') return null;
     43 
     44   if (!value.startsWith('/')) {
     45     problems.add(path, `must be an absolute path, got ${JSON.stringify(value)}`);
     46     return null;
     47   }
     48 
     49   const segments = value.split('/').filter(Boolean);
     50   if (segments.length === 0) {
     51     problems.add(path, 'must name a directory, not the container root');
     52     return null;
     53   }
     54 
     55   for (const segment of segments) {
     56     if (segment === '.' || segment === '..') {
     57       problems.add(path, `must not contain ${JSON.stringify(segment)}`);
     58       return null;
     59     }
     60     if (!/^[A-Za-z0-9._-]+$/.test(segment)) {
     61       problems.add(path, `${JSON.stringify(segment)} is not a usable directory name`);
     62       return null;
     63     }
     64   }
     65 
     66   return `/${segments.join('/')}`;
     67 }
     68 
     69 const JOB_KEYS = [
     70   'image', 'script', 'needs', 'arch', 'matrix', 'requires', 'services',
     71   'env', 'artifacts', 'allow_failure', 'timeout', 'max_attempts',
     72   'only',
     73 ];
     74 
     75 // Fields a task may inherit from defaults.
     76 const DEFAULT_KEYS = [
     77   'image', 'arch', 'requires', 'env', 'services',
     78   'allow_failure', 'timeout', 'max_attempts',
     79 ];
     80 
     81 const ONLY_KEYS = ['refs'];
     82 const ARTIFACT_KEYS = ['paths', 'expire', 'when'];
     83 const ARTIFACT_WHEN = ['on_success', 'on_failure', 'always'];
     84 
     85 const SERVICE_KEYS = ['image', 'alias', 'env', 'entrypoint', 'command'];
     86 
     87 export const SUPPORTED_VERSION = 1;
     88 
     89 export function parsePipeline(text, options = {}) {
     90   const source = options.source || '.conductor.yml';
     91   const problems = new Problems();
     92 
     93   let doc;
     94   try {
     95     doc = YAML.parse(text, { prettyErrors: true });
     96   } catch (e) {
     97     throw new PipelineError([{ path: '', message: `not valid YAML: ${e.message}` }], source);
     98   }
     99 
    100   if (doc === null || doc === undefined) {
    101     throw new PipelineError([{ path: '', message: 'file is empty' }], source);
    102   }
    103   if (!isPlainObject(doc)) {
    104     throw new PipelineError([{ path: '', message: `expected a mapping at the top level, got ${typeName(doc)}` }], source);
    105   }
    106 
    107   // The key was called jobs before a job became the thing that contains
    108   // these. Named outright rather than left to checkUnknown, whose nearest
    109   // match would be a guess at a word that used to be correct.
    110   if (doc.jobs !== undefined && doc.tasks === undefined) {
    111     throw new PipelineError(
    112       [{ path: 'jobs', message: 'was renamed to tasks; a job is now the pipeline run that these tasks belong to' }],
    113       source
    114     );
    115   }
    116 
    117   checkUnknown(problems, '', doc, TOP_KEYS);
    118 
    119   if (doc.version === undefined) {
    120     problems.add('version', `is required; this conductor understands version ${SUPPORTED_VERSION}`);
    121   } else if (doc.version !== SUPPORTED_VERSION) {
    122     problems.add('version', `unsupported version ${JSON.stringify(doc.version)}, expected ${SUPPORTED_VERSION}`);
    123   }
    124 
    125   let visibility = null;
    126   if (doc.visibility !== undefined) {
    127     const value = asString(problems, 'visibility', doc.visibility);
    128     if (value !== undefined && !VISIBILITIES.includes(value)) {
    129       problems.add('visibility', `expected one of ${VISIBILITIES.join(', ')}, got ${JSON.stringify(value)}`);
    130     } else if (value !== undefined) {
    131       visibility = value;
    132     }
    133   }
    134 
    135   const defaults = parseDefaults(problems, doc.defaults);
    136 
    137   if (doc.tasks === undefined) {
    138     problems.add('tasks', 'is required');
    139   } else if (!isPlainObject(doc.tasks)) {
    140     problems.add('tasks', `expected a mapping of task names, got ${typeName(doc.tasks)}`);
    141   } else if (Object.keys(doc.tasks).length === 0) {
    142     problems.add('tasks', 'must define at least one task');
    143   }
    144 
    145   const tasks = {};
    146   if (isPlainObject(doc.tasks)) {
    147     for (const [name, raw] of Object.entries(doc.tasks)) {
    148       const path = `tasks.${name}`;
    149       if (!NAME_PATTERN.test(name) || name.length > 100) {
    150         problems.add(path, 'task name must start with a letter or digit and contain only letters, digits, underscore, dot and hyphen');
    151         continue;
    152       }
    153       if (!isPlainObject(raw)) {
    154         problems.add(path, `expected a mapping, got ${typeName(raw)}`);
    155         continue;
    156       }
    157       tasks[name] = parseTask(problems, path, raw, defaults);
    158     }
    159   }
    160 
    161   const workdir = doc.workdir === undefined ? null : parseWorkdir(problems, 'workdir', doc.workdir);
    162 
    163   problems.throwIfAny(source);
    164   return { version: SUPPORTED_VERSION, visibility, workdir, defaults, tasks, source };
    165 }
    166 
    167 function parseDefaults(problems, raw) {
    168   const empty = {
    169     image: undefined,
    170     arch: undefined,
    171     requires: [],
    172     env: {},
    173     services: [],
    174     allow_failure: false,
    175     timeout: undefined,
    176     max_attempts: undefined,
    177   };
    178   if (raw === undefined) return empty;
    179   if (!isPlainObject(raw)) {
    180     problems.add('defaults', `expected a mapping, got ${typeName(raw)}`);
    181     return empty;
    182   }
    183 
    184   checkUnknown(problems, 'defaults', raw, DEFAULT_KEYS);
    185 
    186   return {
    187     image: raw.image === undefined ? undefined : asString(problems, 'defaults.image', raw.image),
    188     arch: raw.arch === undefined ? undefined : parseArch(problems, 'defaults.arch', raw.arch),
    189     requires: asStringList(problems, 'defaults.requires', raw.requires),
    190     env: asEnvMap(problems, 'defaults.env', raw.env),
    191     services: parseServices(problems, 'defaults.services', raw.services),
    192     allow_failure: raw.allow_failure === undefined ? false : asBoolean(problems, 'defaults.allow_failure', raw.allow_failure) ?? false,
    193     timeout: raw.timeout === undefined ? undefined : asDuration(problems, 'defaults.timeout', raw.timeout),
    194     max_attempts: raw.max_attempts === undefined ? undefined : asInteger(problems, 'defaults.max_attempts', raw.max_attempts, { min: 1, max: 10 }),
    195   };
    196 }
    197 
    198 function parseArch(problems, path, raw) {
    199   const list = asStringList(problems, path, raw, { max: 32 });
    200   const out = [];
    201   list.forEach((value, i) => {
    202     if (!NAME_PATTERN.test(value)) {
    203       problems.add(`${path}[${i}]`, `${JSON.stringify(value)} is not a valid architecture name`);
    204       return;
    205     }
    206     if (out.includes(value)) {
    207       problems.add(`${path}[${i}]`, `duplicate architecture ${JSON.stringify(value)}`);
    208       return;
    209     }
    210     out.push(value);
    211   });
    212   return out;
    213 }
    214 
    215 function parseTask(problems, path, raw, defaults) {
    216   checkUnknown(problems, path, raw, JOB_KEYS);
    217 
    218   const image = raw.image === undefined ? defaults.image : asString(problems, `${path}.image`, raw.image);
    219   if (image === undefined) {
    220     problems.add(`${path}.image`, 'is required; set it on the task or under defaults');
    221   }
    222 
    223   if (raw.script === undefined) {
    224     problems.add(`${path}.script`, 'is required');
    225   }
    226   const script = asStringList(problems, `${path}.script`, raw.script, { max: 65536 });
    227   if (raw.script !== undefined && script.length === 0) {
    228     problems.add(`${path}.script`, 'must contain at least one command');
    229   }
    230 
    231   const arch = raw.arch === undefined ? (defaults.arch ?? []) : parseArch(problems, `${path}.arch`, raw.arch);
    232   const matrix = parseMatrix(problems, `${path}.matrix`, raw.matrix);
    233 
    234   if (matrix.arch !== undefined) {
    235     problems.add(`${path}.matrix.arch`, 'use the arch key instead of a matrix dimension named arch');
    236   }
    237 
    238   return {
    239     image,
    240     script,
    241     needs: parseNeeds(problems, `${path}.needs`, raw.needs),
    242     arch,
    243     matrix,
    244     requires: raw.requires === undefined ? defaults.requires : asStringList(problems, `${path}.requires`, raw.requires, { max: 64 }),
    245     services: raw.services === undefined ? defaults.services : parseServices(problems, `${path}.services`, raw.services),
    246     env: { ...defaults.env, ...asEnvMap(problems, `${path}.env`, raw.env) },
    247     artifacts: parseArtifacts(problems, `${path}.artifacts`, raw.artifacts),
    248     allow_failure: raw.allow_failure === undefined
    249       ? defaults.allow_failure
    250       : asBoolean(problems, `${path}.allow_failure`, raw.allow_failure) ?? false,
    251     timeout: raw.timeout === undefined ? defaults.timeout : asDuration(problems, `${path}.timeout`, raw.timeout),
    252     max_attempts: raw.max_attempts === undefined
    253       ? defaults.max_attempts
    254       : asInteger(problems, `${path}.max_attempts`, raw.max_attempts, { min: 1, max: 10 }),
    255     only: parseOnly(problems, `${path}.only`, raw.only),
    256   };
    257 }
    258 
    259 // Restricts a task to certain refs. Deliberately not inheritable from
    260 // defaults: a rule that silently applied to every task is the kind of thing
    261 // that stops a pipeline running at all and takes an afternoon to find.
    262 //
    263 //   only:
    264 //     refs: [refs/heads/main, 'refs/tags/v*']
    265 //
    266 // Patterns match the whole ref, so refs/heads/main rather than main, with
    267 // * standing for any run of characters. Absent means the task always runs.
    268 function parseOnly(problems, path, raw) {
    269   if (raw === undefined || raw === null) return null;
    270   if (!isPlainObject(raw)) {
    271     problems.add(path, 'must be a mapping, for example: only: { refs: [refs/heads/main] }');
    272     return null;
    273   }
    274 
    275   for (const key of Object.keys(raw)) {
    276     if (!ONLY_KEYS.includes(key)) {
    277       problems.add(`${path}.${key}`, `unknown key, expected one of: ${ONLY_KEYS.join(', ')}`);
    278     }
    279   }
    280 
    281   if (raw.refs === undefined) {
    282     problems.add(path, 'needs a refs list, otherwise it restricts nothing');
    283     return null;
    284   }
    285 
    286   const refs = asStringList(problems, `${path}.refs`, raw.refs, { max: 64 });
    287   if (refs.length === 0) {
    288     problems.add(`${path}.refs`, 'must list at least one ref pattern');
    289     return null;
    290   }
    291 
    292   return { refs };
    293 }
    294 
    295 // A need is either a task name, or a mapping for the cases where the default
    296 // dimension matching is not what is wanted.
    297 function parseNeeds(problems, path, raw) {
    298   if (raw === undefined) return [];
    299   const list = Array.isArray(raw) ? raw : [raw];
    300   const out = [];
    301 
    302   list.forEach((item, i) => {
    303     const itemPath = `${path}[${i}]`;
    304     if (typeof item === 'string') {
    305       out.push({ task: item, match: 'shared' });
    306       return;
    307     }
    308     if (!isPlainObject(item)) {
    309       problems.add(itemPath, `expected a task name or a mapping, got ${typeName(item)}`);
    310       return;
    311     }
    312     checkUnknown(problems, itemPath, item, ['task', 'match']);
    313     const task = asString(problems, `${itemPath}.task`, item.task);
    314     const match = item.match === undefined ? 'shared' : asString(problems, `${itemPath}.match`, item.match);
    315     if (match !== undefined && !['shared', 'all'].includes(match)) {
    316       problems.add(`${itemPath}.match`, `expected shared or all, got ${JSON.stringify(match)}`);
    317       return;
    318     }
    319     if (task !== undefined) out.push({ task, match: match ?? 'shared' });
    320   });
    321 
    322   return out;
    323 }
    324 
    325 function parseMatrix(problems, path, raw) {
    326   if (raw === undefined) return {};
    327   if (!isPlainObject(raw)) {
    328     problems.add(path, `expected a mapping of dimension names to value lists, got ${typeName(raw)}`);
    329     return {};
    330   }
    331 
    332   const out = {};
    333   for (const [key, value] of Object.entries(raw)) {
    334     const keyPath = `${path}.${key}`;
    335     if (!MATRIX_KEY_PATTERN.test(key)) {
    336       problems.add(keyPath, 'dimension name must be a valid environment variable name');
    337       continue;
    338     }
    339     if (!Array.isArray(value)) {
    340       problems.add(keyPath, `expected a list of values, got ${typeName(value)}`);
    341       continue;
    342     }
    343     if (value.length === 0) {
    344       problems.add(keyPath, 'must contain at least one value');
    345       continue;
    346     }
    347 
    348     const values = [];
    349     value.forEach((item, i) => {
    350       if (typeof item !== 'string' && typeof item !== 'number' && typeof item !== 'boolean') {
    351         problems.add(`${keyPath}[${i}]`, `expected a scalar value, got ${typeName(item)}`);
    352         return;
    353       }
    354       const s = String(item);
    355       if (!NAME_PATTERN.test(s)) {
    356         problems.add(
    357           `${keyPath}[${i}]`,
    358           `${JSON.stringify(s)} may only contain letters, digits, underscore, dot and hyphen, ` +
    359           'because it becomes part of the task name'
    360         );
    361         return;
    362       }
    363       if (values.includes(s)) {
    364         problems.add(`${keyPath}[${i}]`, `duplicate value ${JSON.stringify(s)}`);
    365         return;
    366       }
    367       values.push(s);
    368     });
    369     out[key] = values;
    370   }
    371   return out;
    372 }
    373 
    374 function parseServices(problems, path, raw) {
    375   if (raw === undefined) return [];
    376   if (!Array.isArray(raw)) {
    377     problems.add(path, `expected a list of services, got ${typeName(raw)}`);
    378     return [];
    379   }
    380 
    381   const out = [];
    382   const aliases = new Set();
    383   raw.forEach((item, i) => {
    384     const itemPath = `${path}[${i}]`;
    385     const service = typeof item === 'string' ? { image: item } : item;
    386     if (!isPlainObject(service)) {
    387       problems.add(itemPath, `expected an image name or a mapping, got ${typeName(item)}`);
    388       return;
    389     }
    390     checkUnknown(problems, itemPath, service, SERVICE_KEYS);
    391 
    392     const image = asString(problems, `${itemPath}.image`, service.image);
    393     if (image === undefined) return;
    394 
    395     // Default alias is the image name without registry, path or tag, which
    396     // is what a task would naturally use as a hostname.
    397     const derived = image.split('/').pop().split(':')[0];
    398     const alias = service.alias === undefined
    399       ? derived
    400       : asName(problems, `${itemPath}.alias`, service.alias, { max: 63 });
    401     if (alias === undefined) return;
    402     if (aliases.has(alias)) {
    403       problems.add(`${itemPath}.alias`, `duplicate service alias ${JSON.stringify(alias)}`);
    404       return;
    405     }
    406     aliases.add(alias);
    407 
    408     out.push({
    409       image,
    410       alias,
    411       env: asEnvMap(problems, `${itemPath}.env`, service.env),
    412       entrypoint: asStringList(problems, `${itemPath}.entrypoint`, service.entrypoint),
    413       command: asStringList(problems, `${itemPath}.command`, service.command),
    414     });
    415   });
    416   return out;
    417 }
    418 
    419 function parseArtifacts(problems, path, raw) {
    420   if (raw === undefined) return null;
    421 
    422   // The common case is a bare list of paths.
    423   const spec = Array.isArray(raw) ? { paths: raw } : raw;
    424   if (!isPlainObject(spec)) {
    425     problems.add(path, `expected a list of paths or a mapping, got ${typeName(raw)}`);
    426     return null;
    427   }
    428   checkUnknown(problems, path, spec, ARTIFACT_KEYS);
    429 
    430   const paths = asStringList(problems, `${path}.paths`, spec.paths, { max: 512 });
    431   if (paths.length === 0) {
    432     problems.add(`${path}.paths`, 'must list at least one path');
    433     return null;
    434   }
    435   paths.forEach((p, i) => {
    436     if (p.startsWith('/')) problems.add(`${path}.paths[${i}]`, 'must be relative to the workspace');
    437   });
    438 
    439   const when = spec.when === undefined ? 'on_success' : asString(problems, `${path}.when`, spec.when);
    440   if (when !== undefined && !ARTIFACT_WHEN.includes(when)) {
    441     problems.add(`${path}.when`, `expected one of ${ARTIFACT_WHEN.join(', ')}, got ${JSON.stringify(when)}`);
    442   }
    443 
    444   return {
    445     paths,
    446     when: ARTIFACT_WHEN.includes(when) ? when : 'on_success',
    447     expire: spec.expire === undefined ? null : asDuration(problems, `${path}.expire`, spec.expire, { max: 365 * 24 * 3600 }),
    448   };
    449 }
    450 
    451