UNPKG

@codefresh-io/yaml-validator

Version:

An NPM module/CLI for validating the Codefresh YAML

1,285 lines (1,188 loc) 350 kB
/* eslint-env node, mocha */ /* eslint-disable no-unused-expressions,no-template-curly-in-string */ 'use strict'; const _ = require('lodash'); const chai = require('chai'); const { randomUUID } = require('crypto'); const fs = require('fs'); const jsyaml = require('js-yaml'); const path = require('path'); const colors = require('colors'); const { expect } = chai; const sinonChai = require('sinon-chai'); chai.use(sinonChai); const Mustache = require('mustache'); const Validator = require('../validator'); const { isWebUri } = require('../schema/1.0/validations/registry'); const { CustomDocumentationLinks } = require('../schema/1.0/documentation-links'); const yamlTemplateForDuplicateStepNamesTest = JSON.stringify({ 'version': '1.0', 'steps': { '{{stepName0}}': { 'type': 'parallel', 'steps': { '{{stepName0_1}}': { 'title': 'Step1A', 'image': 'alpine', 'commands': [ 'echo "Step1A" > first.txt' ] }, '{{stepName0_2}}': { 'title': 'Step1B', 'image': 'alpine', 'commands': [ 'echo "Step1B" > second.txt' ] } } }, '{{stepName1}}': { 'type': 'parallel', 'steps': { '{{stepName1_1}}': { 'title': 'Step1A', 'image': 'alpine', 'commands': [ 'echo "Step1A" > first.txt' ] }, '{{stepName1_2}}': { 'title': 'Step1B', 'image': 'alpine', 'commands': [ 'echo "Step1B" > second.txt' ] } } } } }); const yamlTemplateForNestedDuplicateStepNamesTest = JSON.stringify({ 'version': '1.0', 'steps': { '{{stepName0}}': { 'type': 'parallel', 'steps': { '{{stepName0_1}}': { 'title': 'Step1A', 'image': 'alpine', 'commands': [ 'echo "Step1A" > first.txt' ] }, '{{stepName0_2}}': { 'title': 'Step1B', 'image': 'alpine', 'commands': [ 'echo "Step1B" > second.txt' ] }, '{{stepName0_3}}': { 'type': 'parallel', 'steps': { '{{stepName0_3_1}}': { 'title': 'Step1A', 'image': 'alpine', 'commands': [ 'echo "Step1A" > first.txt' ] } } } } }, '{{stepName1}}': { 'type': 'parallel', 'steps': { '{{stepName1_1}}': { 'title': 'Step1A', 'image': 'alpine', 'commands': [ 'echo "Step1A" > first.txt' ] }, '{{stepName1_2}}': { 'title': 'Step1B', 'image': 'alpine', 'commands': [ 'echo "Step1B" > second.txt' ] } } } } }); function validate(model, outputFormat, yaml) { return Validator.validate(model, outputFormat, yaml); } function validateWithContext(model, outputFormat, yaml, context, opts) { return Validator.validateWithContext(model, outputFormat, yaml, context, opts); } function validateForErrorWithContext(model, expectedError, done, outputFormat = 'message', yaml, context, opts) { try { validateWithContext(model, outputFormat, yaml, context, opts); done(new Error('should have failed')); } catch (e) { if (outputFormat === 'message') { expect(e.details).to.deep.equal(expectedError.details); expect(e.warningDetails).deep.equal(expectedError.warningDetails); } if (outputFormat === 'lint') { expect(e.message).to.deep.equal(expectedError.message); expect(e.warningMessage).deep.equal(expectedError.warningMessage); expect(e.summarize).deep.equal(expectedError.summarize); expect(e.documentationLinks).deep.equal(expectedError.documentationLinks); } done(); } } function validateForError(model, expectedMessage, done, outputFormat = 'message', yaml) { try { validate(model, outputFormat, yaml); done(new Error('Validation should have failed')); } catch (e) { if (outputFormat === 'message') { expect(e.message).to.match(new RegExp(`.*${expectedMessage}.*`)); } if (outputFormat === 'printify' || outputFormat === 'lint') { expect(e.details[0].message).to.equal(expectedMessage.message); expect(e.details[0].type).to.equal(expectedMessage.type); expect(e.details[0].level).to.equal(expectedMessage.level); expect(e.details[0].stepName).to.equal(expectedMessage.stepName); expect(e.details[0].docsLink).to.equal(expectedMessage.docsLink); expect(e.details[0].actionItems).to.equal(expectedMessage.actionItems); expect(e.details[0].lines).to.equal(expectedMessage.lines); } done(); } } const currentPath = './__tests__/'; describe('Validate Codefresh YAML', () => { describe('Root elements', () => { it('No version', (done) => { validateForError({ steps: { jim: {} } }, '"version" is required', done); }); it('No steps', (done) => { validateForError({ version: '1.0' }, '"steps" is required', done); }); it('Unknown root element', (done) => { validateForError({ version: '1.0', whatIsThisElement: '', steps: { jim: { image: 'bob' } } }, '"whatIsThisElement" is not allowed', done); }); it('Unknown version', (done) => { validateForError({ version: '0.1', steps: { jim: { image: 'bob' } } }, 'Current version: 0.1 is invalid. please change version to 1.0', done); }); it('incorrect build_version', (done) => { validateForError({ version: '1.0', build_version: 'v3', steps: {} }, '"build_version" must be one of', done); }); it('valid build_version', (done) => { validate({ version: '1.0', build_version: 'v2', steps: {} }); done(); }); describe('Hooks', () => { it('valid hooks', (done) => { validate({ version: '1.0', steps: {}, hooks: { on_elected: ['echo test'], on_success: { exec: ['echo test'], }, on_finish: { image: 'alpine', commands: ['echo test'], }, on_fail: { exec: { image: 'alpine', commands: ['echo test'], }, metadata: { set: [ { test: [ { test: 'test' } ] } ] }, annotations: { set: [ { entity_type: 'build', annotations: [{ test: 'test' }] } ], unset: [ { entity_type: 'build', annotations: ['test'] } ] } } } }); done(); }); it('valid pipeline hooks with plugins / costume steps', (done) => { validate({ version: '1.0', steps: {}, hooks: { on_elected: ['echo test'], on_finish: { steps: { freestyle: { title: 'some title', type: 'freestyle', arguments: { image: 'ubuntu:latest', commands: ['echo test'] }, }, clone: { title: 'clone title', type: 'git-clone', repo: 'codefresh/repo' }, }, }, on_success: { steps: { deploy: { type: 'helm:1.0.0', arguments: { chart_name: 'test_chart', release_name: 'first', kube_context: 'my-kubernetes-context', tiller_namespace: 'kube-system', namespace: 'project', custom_values: [ 'KEY1=VAL1', 'KEY2=VAL2', 'KEY3=VAL3', ], custom_value_files: [ '/path/to/values.yaml', '/path/to/values2.yaml' ], cmd_ps: '--wait --timeout 5' } } } }, on_fail: { steps: { exec: { image: 'alpine', commands: ['echo test'], }, clone: { title: 'clone title', type: 'git-clone', repo: 'codefresh/repo' }, build: { title: 'Building Docker image', type: 'build', image_name: 'user/sandbox', working_directory: '${{clone}}', tag: '${{CF_BRANCH_TAG_NORMALIZED}}', dockerfile: 'Dockerfile', } }, } } }); done(); }); it('valid pipeline hooks that contain only metadata/annotations', (done) => { validate({ version: '1.0', steps: {}, hooks: { on_success: { metadata: { set: [{ '${{steps.build.imageId}}': [{ 'CF_QUALITY': true, 'COMMIT_HASH': '${{CF_SHORT_REVISION}}', 'COMMIT_URL': '${{CF_COMMIT_URL}}', 'DD_VERSION': '${{CF_SHORT_REVISION}}', }] }] } }, on_fail: { annotations: { set: [ { entity_type: 'build', annotations: [{ test: 'test' }] } ], unset: [ { entity_type: 'build', annotations: ['test'] } ] } }, }, }); done(); }); it('invalid hooks', (done) => { validateForError({ version: '1.0', steps: {}, hooks: { on_elected: ['echo test'], on_success: { exec: ['echo test'], }, on_finish: { image: 'alpine', commands: ['echo test'], }, on_fail: { exec: { image: 'alpine', commands: ['echo test'], }, metadata: { set: [ { test: [ { test: 'test' } ] } ] }, annotations: { set: [ { entity_type: 'build', annotations: [{ test: 'test' }] } ], unset: [ { entity_type: 'build', annotations: ['test'] } ] } }, on_something: { image: 'alpine' } } }, '"on_something" is not allowed', done); }); }); describe('strict_fail_fast', () => { it.each([ true, false, undefined, ])('should pass if "strict_fail_fast" is valid data type: %s', (strictFailFast) => { validate({ version: '1.0', steps: { mock: { image: 'mock-image' } }, strict_fail_fast: strictFailFast, }); }); it.each([ 0, 42, '', 'mock', null, {}, [], ])(`should not pass if "strict_fail_fast" is invalid data type: %s`, (strictFailFast, done) => { validateForError({ version: '1.0', steps: { mock: { image: 'mock-image' } }, strict_fail_fast: strictFailFast, }, `"strict_fail_fast" must be a boolean`, done); }); }); }); describe('Steps', () => { describe('Common step attributes', () => { it('Unrecognized type', () => { validate({ version: '1.0', steps: { jim: { type: 'invalid:1.0.0' } } }); }); it('Working directory on a push step', (done) => { validateForError({ version: '1.0', steps: { jim: { 'type': 'push', 'candidate': 'bob', 'working_directory': 'meow' } } }, '"working_directory" is not allowed', done); }); it('Credentials on a build step', (done) => { validateForError({ version: '1.0', steps: { jim: { 'type': 'build', 'image_name': 'owner/jimb', 'credentials': { username: 'jim', password: 'bob' } } } }, '"credentials" is not allowed', done); }); it('no_cache on a build step', () => { validate({ version: '1.0', steps: { jim: { 'type': 'build', 'image_name': 'owner/jimb', 'no_cache': true } } }); }); it('no_cf_cache on a build step', () => { validate({ version: '1.0', steps: { jim: { 'type': 'build', 'image_name': 'owner/jimb', 'no_cf_cache': true } } }); }); it('Non-bool no_cache on a build step', (done) => { validateForError({ version: '1.0', steps: { jim: { 'type': 'build', 'image_name': 'owner/jimb', 'no_cache': 'please do sir' } } }, '"no_cache" must be a boolean', done); }); it('Non-bool no_cache on a build step', (done) => { validateForError({ version: '1.0', steps: { jim: { 'type': 'build', 'image_name': 'owner/jimb', 'no_cf_cache': 'please do sir' } } }, '"no_cf_cache" must be a boolean', done); }); it('squash on a build step', () => { validate({ version: '1.0', steps: { jim: { 'type': 'build', 'image_name': 'owner/jimb', 'squash': true } } }); }); it('Non-bool squash on a build step', (done) => { validateForError({ version: '1.0', steps: { jim: { 'type': 'build', 'image_name': 'owner/jimb', 'squash': 'please do sir' } } }, '"squash" must be a boolean', done); }); it('Empty credentials', (done) => { validateForError({ version: '1.0', steps: { jim: { type: 'git-clone', repo: 'jim', credentials: {} } } }, '"username" is required', done); }); it('Non-string working directory', (done) => { validateForError({ version: '1.0', steps: { jim: { 'image': 'myimage', 'working_directory': {} } } }, '"working_directory" must be a string', done); }); it('Non-string description', (done) => { validateForError({ version: '1.0', steps: { jim: { 'type': 'build', 'image_name': 'owner/jimb', 'description': {} } } }, '"description" must be a string', done); }); it('Non-string title', (done) => { validateForError({ version: '1.0', steps: { jim: { 'type': 'build', 'image_name': 'owner/jimb', 'title': {} } } }, '"title" must be a string', done); }); it('Non object docker_machine', (done) => { validateForError({ version: '1.0', steps: { jim: { 'type': 'build', 'image_name': 'owner/jimb', 'docker_machine': 'google' } } }, '"docker_machine" must be an object', done); }); it('Non-boolean fail-fast', (done) => { validateForError({ version: '1.0', steps: { jim: { 'type': 'build', 'image_name': 'owner/jimb', 'fail_fast': {} } } }, '"fail_fast" must be a boolean', done); }); it('Non-string tag', (done) => { validateForError({ version: '1.0', steps: { jim: { 'type': 'build', 'image_name': 'owner/jim', 'tag': [] } } }, '"tag" must be a string', done); }); it('Unknown post-step metadata operation', (done) => { validateForError({ version: '1.0', steps: { push: { 'type': 'push', 'candidate': 'teh-image', 'on_finish': { metadata: { put: [ { '${{build_prj.image}}': [ { 'qa': 'pending' } ] } ] } } } } }, '"put" is not allowed', done); }); it('Unknown post-step metadata entry', (done) => { validateForError({ version: '1.0', steps: { push: { 'type': 'push', 'candidate': 'teh-image', 'on_finish': { metadata: { set: { '${{build_prj.image}}': [ { 'qa': 'pending' } ] } } } } } }, '"set" must be an array', done); }); it('Unspecified image to annotate', (done) => { validateForError({ version: '1.0', steps: { push: { 'type': 'push', 'candidate': 'teh-image', 'on_finish': { metadata: { set: [ { 'qa': 'pending' } ] } } } } }, '"qa" must be an array', done); }); it('Invalid post-step metadata annotation key', (done) => { validateForError({ version: '1.0', steps: { push: { 'type': 'push', 'candidate': 'teh-image', 'on_finish': { metadata: { set: [ { '${{build_prj.image}}': [ 'an invalid key' ] } ] } } } } }, '"an invalid key" fails to match', done); }); it('Invalid post-step metadata annotation value', (done) => { validateForError({ version: '1.0', steps: { push: { 'type': 'push', 'candidate': 'teh-image', 'on_finish': { metadata: { set: [ { '${{build_prj.image}}': [ { 'key1': [] } ] } ] } } } } }, '"key1" must be a', done); }); it('Invalid post-step metadata annotation evaluation expression', (done) => { validateForError({ version: '1.0', steps: { push: { 'type': 'push', 'candidate': 'teh-image', 'on_finish': { metadata: { set: [ { '${{build_prj.image}}': [ { 'jimbob': { eval: 'jimbob == jimbob' } } ] } ] } } } } }, '"evaluate" is required', done); }); it('Unknown post-step annotate operation', (done) => { validateForError({ version: '1.0', steps: { push: { 'type': 'push', 'candidate': 'teh-image', 'on_finish': { annotations: { put: [ { entity_type: 'image', entity_id: '${{build_prj.image}}', annotations: [ { 'qa': 'pending' } ], } ] } } } } }, '"put" is not allowed', done); }); it('Unknown build annotate operation', (done) => { validateForError({ version: '1.0', steps: { build: { type: 'build', image_name: 'owner/name', annotations: { put: [ { entity_type: 'image', entity_id: '${{build_prj.image}}', annotations: [ { 'qa': 'pending' } ], } ] } } } }, '"put" is not allowed', done); }); it('Unknown post-step annotation entry', (done) => { validateForError({ version: '1.0', steps: { push: { 'type': 'push', 'candidate': 'teh-image', 'on_finish': { annotations: { set: { entity_type: 'image', entity_id: '${{build_prj.image}}', annotations: [ { 'qa': 'pending' } ], }, } } } } }, '"set" must be an array', done); }); it('Unknown post-step annotation entry', (done) => { validateForError({ version: '1.0', steps: { push: { 'type': 'push', 'candidate': 'teh-image', 'on_finish': { annotations: { unset: { entity_type: 'image', entity_id: '${{build_prj.image}}', annotations: [ { 'qa': 'pending' } ], }, } } } } }, '"unset" must be an array', done); }); it('Unknown build annotation entry', (done) => { validateForError({ version: '1.0', steps: { build: { type: 'build', image_name: 'owner/name', annotations: { set: { entity_type: 'image', entity_id: '${{build_prj.image}}', annotations: [ { 'qa': 'pending' } ], }, } } } }, '"set" must be an array', done); }); it('Invalid post-step annotation key', (done) => { validateForError({ version: '1.0', steps: { push: { 'type': 'push', 'candidate': 'teh-image', 'on_finish': { annotations: { set: [{ entity_type: 'image', entity_id: '${{build_prj.image}}', annotations: [ { 'an invalid key': 'pending' } ], }] } } } } }, '"an invalid key" is not allowed', done); }); it('Invalid post-step annotation key', (done) => { validateForError({ version: '1.0', steps: { push: { 'type': 'push', 'candidate': 'teh-image', 'on_finish': { annotations: { unset: [{ entity_type: 'image', entity_id: '${{build_prj.image}}', annotations: ['an invalid key'], }] } } } } }, '"an invalid key" fails to match', done); }); it('Invalid post-step metadata annotation value', (done) => { validateForError({ version: '1.0', steps: { push: { 'type': 'push', 'candidate': 'teh-image', 'on_finish': { annotations: { set: [{ entity_type: 'image', entity_id: '${{build_prj.image}}', annotations: [ { 'key': [] } ], }] } } } } }, '"key" must be a', done); }); it('Invalid post-step annotation evaluation expression', (done) => { validateForError({ version: '1.0', steps: { push: { 'type': 'push', 'candidate': 'teh-image', 'on_finish': { annotations: { set: [{ entity_type: 'image', entity_id: '${{build_prj.image}}', annotations: [{ 'key': { eval: 'jimbob == jimbob' } }], }] } } } } }, '"evaluate" is required', done); }); describe('timeout', () => { describe('not defined', () => { it('should pass if timeout was not defined', () => { validate({ version: '1.0', steps: { mock: { image: 'mock-image' } }, }); }); it.each([ null, undefined, ])('should pass if timeout is %s', (timeout) => { validate({ version: '1.0', steps: { mock: { image: 'mock-image', timeout } }, }); }); }); describe('defined', () => { const units = ['s', 'm', 'h']; const getRandomUnit = () => { return units[Math.floor(Math.random() * units.length)]; }; const getRandomInt = () => Math.floor(Math.random() * 1000); const getRandomFloat = () => Math.random() * 1000; // TODO: Delete once timeout is required. // eslint-disable-next-line no-unused-vars const getInvalidUnit = () => { const char = String.fromCharCode(Math.floor(Math.random() * 65535)); return units.includes(char) ? getInvalidUnit() : char; }; const validIntegerTimeouts = [`0${getRandomUnit()}`]; for (let i = 0; i < 50; i += 1) { validIntegerTimeouts.push(`${getRandomInt()}${getRandomUnit()}`); } it.each(validIntegerTimeouts)('should pass if timeout is valid: %s', (timeout) => { validate({ version: '1.0', steps: { mock: { image: 'mock-image', timeout } }, }); }); const validFloatTimeouts = [ `0.0${getRandomUnit()}`, `.5${getRandomUnit()}`, `1.${getRandomUnit()}`, ]; for (let i = 0; i < 50; i += 1) { validFloatTimeouts.push(`${getRandomFloat()}${getRandomUnit()}`); } it.each(validFloatTimeouts)('should pass if timeout is valid: %s', (timeout) => { validate({ version: '1.0', steps: { mock: { image: 'mock-image', timeout } }, }); }); // TODO: Delete once timeout is required. ⬇️ it.each([ 0, 42, false, true, {}, [], ])(`should fail with warning if timeout is invalid data type: %s`, (timeout, done) => { const expectedError = { details: [], warningDetails: [ { // eslint-disable-next-line max-len actionItems: 'Please adjust the timeout to match the format "<duration><units>" where duration is int|float and units are s|m|h (e.g. "1m", "30s", "1.5h"). It will be ignored otherwise.', context: { key: 'steps', }, docsLink: 'https://codefresh.io/docs/docs/pipelines/steps/freestyle/', level: 'step', lines: undefined, message: `"timeout" must be a string${timeout ? `. Current value: ${timeout} ` : ''}`, path: 'steps', stepName: 'mock', type: 'Warning', }, ], }; try { Validator.validate({ version: '1.0', steps: { mock: { image: 'mock-image', timeout } }, }, 'message'); done(new Error('should have failed')); } catch (error) { expect(error.details).to.deep.equal(expectedError.details); expect(error.warningDetails).deep.equal(expectedError.warningDetails); done(); } }); // END: Delete once timeout is required. ⬆️ // TODO: Uncomment once timeout is required. ⬇️ // it.each([ // 0, // 42, // false, // true, // {}, // [], // ])(`should not pass if timeout is invalid data type: %s`, (timeout, done) => { // validateForError({ // version: '1.0', // steps: { mock: { image: 'mock-image', timeout } }, // }, `"timeout" must be a string`, done); // }); // it('should not pass if timeout is an empty string', (done) => { // validateForError({ // version: '1.0', // steps: { mock: { image: 'mock-image', timeout: '' } }, // }, `"timeout" is not allowed to be empty`, done); // }); // const invalidUnits = []; // for (let i = 0; i < 1000; i += 1) { // invalidUnits.push(`${getRandomInt()}${getInvalidUnit()}`); // } // it.each(invalidUnits)('should not pass if timeout unit is invalid: %s', (timeout, done) => { // validateForError({ // version: '1.0', // steps: { mock: { image: 'mock-image', timeout } }, // }, `fails to match the "\\<duration\\>\\<units\\> where duration is int\\|float and units are s\\|m\\|h" pattern`, done); // }); // const missedUnits = []; // for (let i = 0; i < 50; i += 1) { // missedUnits.push(i % 2 ? `${getRandomInt()}` : `${getRandomFloat()}`); // } // it.each(missedUnits)('should not pass if units are missed: %s', (timeout, done) => { // validateForError({ // version: '1.0', // steps: { mock: { image: 'mock-image', timeout } }, // }, `fails to match the "\\<duration\\>\\<units\\> where duration is int\\|float and units are s\\|m\\|h" pattern`, done); // }); // it.each([ // `1.5.1${getRandomUnit()}`, // `1,5${getRandomUnit()}`, // ])('should not pass if timeout duration is invalid: %s', (timeout, done) => { // validateForError({ // version: '1.0', // steps: { mock: { image: 'mock-image', timeout } }, // }, `fails to match the "\\<duration\\>\\<units\\> where duration is int\\|float and units are s\\|m\\|h" pattern`, done); // }); // END: Uncomment once timeout is required. ⬆️ }); }); describe('strict_fail_fast', () => { it.each([ true, false, undefined, ])('should pass if "strict_fail_fast" is valid data type: %s', (strictFailFast) => { validate({ version: '1.0', steps: { mock: { image: 'mock-image', strict_fail_fast: strictFailFast } }, }); }); it.each([ 0, 42, '', 'mock', null, {}, [], ])(`should not pass if "strict_fail_fast" is invalid data type: %s`, (strictFailFast, done) => { validateForError({ version: '1.0', steps: { mock: { image: 'mock-image', strict_fail_fast: strictFailFast } }, }, `"strict_fail_fast" must be a boolean`, done); }); }); describe('type', () => { it(`should not fail if version is not specified for built-in step type`, () => { Validator.validate({ version: '1.0', steps: { mock: { image: 'mock-image', type: 'freestyle' } }, }, 'message'); }); it(`should not fail if type is not specified`, () => { Validator.validate({ version: '1.0', steps: { mock: { image: 'mock-image' } }, }, 'message'); }); it.each([ '${{TYPE}}', '${{PART}}-of-type', 'type:${{VERSION}}', 'type:part-of-${{VERSION}}', '${{PART}}-of-type:part-of-${{VERSION}}', ])('should not fail if type contains variables: "%s"', (type) => { Validator.validate({ version: '1.0', steps: { mock: { type } }, }, 'message'); }); it.each([ 'foo', 42, 0, false, '1.0.foo', 'foo:bar' ])(`should fail with warning if version is not semver: "%s"`, (version, done) => { const mockType = randomUUID(); const expectedError = { details: [], warningDetails: [ { message: `Invalid semantic version "${version}" for step.`, actionItems: `Use "type: <type_name>:<version-number>". For example, "type: ${mockType}:1.2.3"`, context: { key: 'steps', }, docsLink: CustomDocumentationLinks['steps-versioning'], level: 'step', lines: undefined,