conductor

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

parse.js (15926B)


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