diff --git a/bin/server b/bin/server index 934a19d5b..b6023cf91 100755 --- a/bin/server +++ b/bin/server @@ -229,6 +229,16 @@ const buildClusterFactory = Models.BuildClusterFactory.getInstance({ datastoreRO, scm }); +const pipelineTemplateFactory = Models.PipelineTemplateFactory.getInstance({ + datastore, + datastoreRO, + scm +}); +const pipelineTemplateVersionFactory = Models.PipelineTemplateVersionFactory.getInstance({ + datastore, + datastoreRO, + scm +}); // @TODO run setup for SCM and Executor // datastoreConfig.ddlSync => sync datastore schema (ddl) via api (default: true) @@ -244,6 +254,8 @@ datastore.setup(datastoreConfig.ddlSyncEnabled).then(() => commandFactory, commandTagFactory, templateFactory, + pipelineTemplateFactory, + pipelineTemplateVersionFactory, templateTagFactory, pipelineFactory, jobFactory, diff --git a/lib/server.js b/lib/server.js index 12e8f3028..909a9b290 100644 --- a/lib/server.js +++ b/lib/server.js @@ -126,6 +126,11 @@ module.exports = async config => { commandTagFactory: config.commandTagFactory, templateFactory: config.templateFactory, templateTagFactory: config.templateTagFactory, + pipelineTemplateFactory: config.pipelineTemplateFactory, + pipelineTemplateVersionFactory: config.pipelineTemplateVersionFactory, + templateMetaFactory: config.templateMetaFactory, + jobTemplateTagFactory: config.jobTemplateTagFactory, + pipelineTemplateTagFactory: config.pipelineTemplateTagFactory, stageFactory: config.stageFactory, stageBuildFactory: config.stageBuildFactory, triggerFactory: config.triggerFactory, diff --git a/package.json b/package.json index 0b3b19c26..08cc44061 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "screwdriver-config-parser": "^8.0.1", "screwdriver-coverage-bookend": "^2.0.0", "screwdriver-coverage-sonar": "^4.1.1", - "screwdriver-data-schema": "^22.9.8", + "screwdriver-data-schema": "^22.9.12", "screwdriver-datastore-sequelize": "^8.0.0", "screwdriver-executor-base": "^9.0.1", "screwdriver-executor-docker": "^6.0.0", @@ -118,12 +118,12 @@ "screwdriver-notifications-email": "^3.0.0", "screwdriver-notifications-slack": "^5.0.0", "screwdriver-request": "^2.0.1", - "screwdriver-scm-base": "^8.1.0", + "screwdriver-scm-base": "^8.1.1", "screwdriver-scm-bitbucket": "^5.0.1", - "screwdriver-scm-github": "^12.1.1", + "screwdriver-scm-github": "^12.2.4", "screwdriver-scm-gitlab": "^3.1.0", "screwdriver-scm-router": "^7.0.0", - "screwdriver-template-validator": "^6.0.0", + "screwdriver-template-validator": "^7.0.0", "screwdriver-workflow-parser": "^4.1.1", "sqlite3": "^5.1.4", "stream": "0.0.2", diff --git a/plugins/builds/index.js b/plugins/builds/index.js index d76a82f9d..541d6d090 100644 --- a/plugins/builds/index.js +++ b/plugins/builds/index.js @@ -1068,7 +1068,7 @@ const buildsPlugin = { */ const isORTrigger = !joinListNames.includes(current.job.name); - if (joinListNames.length === 0 || isORTrigger) { + if (isORTrigger) { const internalBuildConfig = { jobFactory, buildFactory, @@ -1177,12 +1177,27 @@ const buildsPlugin = { finishedInternalBuilds = finishedInternalBuilds.concat(parallelBuilds); } - fillParentBuilds(parentBuilds, current, finishedInternalBuilds); - // If next build is internal, look at the finished builds for this event const nextJobId = joinObj[nextJobName].id; - const nextBuild = finishedInternalBuilds.find( - b => b.jobId === nextJobId && b.eventId === current.event.id - ); + + let nextBuild; + + // If next build is internal, look at the finished builds for this event + nextBuild = finishedInternalBuilds.find(b => b.jobId === nextJobId && b.eventId === current.event.id); + + if (!nextBuild) { + // If the build to join fails and it succeeds on restart, depending on the timing, the latest build will be that of a child event. + // In that case, `nextBuild` will be null and will not be triggered even though there is a build that should be triggered. + // Now we need to check for the existence of a build that should be triggered in its own event. + nextBuild = await buildFactory.get({ + eventId: current.event.id, + jobId: nextJobId + }); + + finishedInternalBuilds = finishedInternalBuilds.concat(nextBuild); + } + + fillParentBuilds(parentBuilds, current, finishedInternalBuilds); + let newBuild; // Create next build @@ -1328,6 +1343,7 @@ const buildsPlugin = { const joinList = nextJobs[nextJobName].join; const joinListNames = joinList.map(j => j.name); + const isORTrigger = !joinListNames.includes(triggerName); if (nextBuild) { // update current build info in parentBuilds @@ -1363,6 +1379,17 @@ const buildsPlugin = { }); } + if (isORTrigger) { + if (!['CREATED', null, undefined].includes(newBuild.status)) { + return newBuild; + } + + newBuild.status = 'QUEUED'; + await newBuild.update(); + + return newBuild.start(); + } + const { hasFailure, done } = await getParentBuildStatus({ newBuild, joinListNames, diff --git a/plugins/pipelines/README.md b/plugins/pipelines/README.md index a9c2dd834..d24edd4ea 100644 --- a/plugins/pipelines/README.md +++ b/plugins/pipelines/README.md @@ -206,3 +206,117 @@ handler: async (request, h) => { // ... } ``` + +#### Pipeline Templates +##### Get all pipeline templates + +`GET /pipeline/templates` + +Can use additional options for sorting, pagination and count: +`GET /pipeline/templates?sort=ascending&sortBy=name&page=1&count=50` + +##### Get all versions for a pipeline template + +`GET /pipeline/templates/{namespace}/{name}/versions` + +Can use additional options for sorting, pagination and count: +`GET /pipeline/templates/{namespace}/{name}/versions?sort=ascending&page=1&count=50` + +##### Create a pipeline template +Creating a template will store the template meta (`name`, `namespace`, `maintainer`, `latestVersion`, `trustedSinceVersion`, `pipelineId`) and template version (`description`, `version`, `config`, `createTime`, `templateId`) into the datastore. + +`version` will be auto-bumped. For example, if `mypipelinetemplate@1.0.0` already exists and the version passed in is `1.0.0`, the newly created template will be version `1.0.1`. + + +`POST /pipeline/template` +###### Arguments + +'name', 'namespace', 'version', 'description', 'maintainer', 'config' + +* `name` - Name of the template +* `namespace` - Namespace of the template +* `version` - Version of the template +* `description` - Description of the template +* `maintainer` - Maintainer of the template +* `config` - Config of the template. This field is an object that includes `steps`, `image`, and optional `secrets`, `environments`. Similar to what's inside the `pipeline` + +Example payload: +```json +{ + "name": "example-template", + "namespace": "my-namespace", + "version": "1.3.1", + "description": "An example template", + "maintainer": "example@gmail.com", + "config": { + "steps": [{ + "echo": "echo hello" + }] + } +} +``` + +##### Validate a pipeline template +Validate a pipeline template and return a JSON containing the boolean property ‘valid’ indicating if the template is valid or not + +`POST /pipeline/template/validate` + +###### Arguments + +'name', 'namespace', 'version', 'description', 'maintainer', 'config' + +* `name` - Name of the template +* `namespace` - Namespace of the template +* `version` - Version of the template +* `description` - Description of the template +* `maintainer` - Maintainer of the template +* `config` - Config of the template. This field is an object that includes `steps`, `image`, and optional `secrets`, `environments`. Similar to what's inside the `pipeline` + +Example payload: +```json +{ + "name": "example-template", + "namespace": "my-namespace", + "version": "1.3.1", + "description": "An example template", + "maintainer": "example@gmail.com", + "config": { + "steps": [{ + "echo": "echo hello" + }] + } +} +``` + +#### Get a pipeline template by namespace and name + +`GET /pipeline/template/{namespace}/{name}` + +##### Get a specific pipeline template by id + +`GET /pipeline/template/{id}` + +##### Get version of a pipeline template by name, namespace, version or tag + +`GET /pipeline/template/{namespace}/{name}/{versionOrTag}` + + +#### Template Tag +Template tag allows fetching on template version by tag. For example, tag `mytemplate@1.1.0` as `stable`. + +##### Get all tags for a pipeline template by name, namespace + +`GET /pipeline/templates/{namespace}/{name}/tags` + +Can use additional options for sorting, pagination and count: +`GET /pipeline/templates/{namespace}/{name}/tags?sort=ascending&sortBy=name&page=1&count=50` + +##### Create/Update a tag + +If the template tag already exists, it will update the tag with the new version. If the template tag doesn't exist yet, this endpoint will create the tag. + +*Note: This endpoint is only accessible in `build` scope and the permission is tied to the pipeline that creates the template.* + +`PUT /templates/{templateName}/tags/{tagName}` with the following payload + +* `version` - Exact version of the template (ex: `1.1.0`) \ No newline at end of file diff --git a/plugins/pipelines/index.js b/plugins/pipelines/index.js index 03b1fb06a..b33d14024 100644 --- a/plugins/pipelines/index.js +++ b/plugins/pipelines/index.js @@ -29,6 +29,15 @@ const latestCommitEvent = require('./latestCommitEvent'); const getAdmin = require('./admins/get'); const deleteCache = require('./caches/delete'); const openPrRoute = require('./openPr'); +const createTemplateRoute = require('./templates/create'); +const validateTemplateRoute = require('./templates/validate'); +const listTemplatesRoute = require('./templates/list'); +const listTemplateVersionsRoute = require('./templates/listVersions'); +const listTagsRoute = require('./templates/listTags'); +const getTemplateRoute = require('./templates/get'); +const getTemplateByIdRoute = require('./templates/getTemplateById'); +const createTagRoute = require('./templates/createTag'); +const getVersionRoute = require('./templates/getVersion'); /** * Pipeline API Plugin @@ -195,7 +204,16 @@ const pipelinesPlugin = { latestCommitEvent(), getAdmin(), deleteCache(), - openPrRoute() + openPrRoute(), + createTemplateRoute(), + validateTemplateRoute(), + listTemplatesRoute(), + listTemplateVersionsRoute(), + listTagsRoute(), + getVersionRoute(), + getTemplateByIdRoute(), + getTemplateRoute(), + createTagRoute() ]); } }; diff --git a/plugins/pipelines/templates/create.js b/plugins/pipelines/templates/create.js new file mode 100644 index 000000000..8dfc3bc8d --- /dev/null +++ b/plugins/pipelines/templates/create.js @@ -0,0 +1,61 @@ +'use strict'; + +const boom = require('@hapi/boom'); +const schema = require('screwdriver-data-schema'); +const validator = require('screwdriver-template-validator').parsePipelineTemplate; +const templateSchema = schema.api.templateValidator; + +module.exports = () => ({ + method: 'POST', + path: '/pipeline/template', + options: { + description: 'Create a new pipeline template', + notes: 'Create a specific pipeline template', + tags: ['api', 'pipelineTemplate'], + auth: { + strategies: ['token'], + scope: ['build'] + }, + + handler: async (request, h) => { + const { pipelineTemplateVersionFactory, pipelineTemplateFactory } = request.server.app; + + const config = await validator(request.payload.yaml); + + if (config.errors.length > 0) { + throw boom.badRequest(`Template has invalid format: ${config.errors.length} error(s).`, config.errors); + } + + const pipelineTemplate = await pipelineTemplateFactory.get({ + name: config.template.name, + namespace: config.template.namespace + }); + + const { isPR, pipelineId } = request.auth.credentials; + + // If template name exists, but this build's pipelineId is not the same as template's pipelineId + // Then this build does not have permission to publish + if (isPR || (pipelineTemplate && pipelineId !== pipelineTemplate.pipelineId)) { + throw boom.forbidden('Not allowed to publish this template'); + } + + const template = await pipelineTemplateVersionFactory.create( + { + ...config.template, + pipelineId + }, + pipelineTemplateFactory + ); + + const location = new URL( + `${request.path}/${template.id}`, + `${request.server.info.protocol}://${request.headers.host}` + ).toString(); + + return h.response(template.toJson()).header('Location', location).code(201); + }, + validate: { + payload: templateSchema.input + } + } +}); diff --git a/plugins/pipelines/templates/createTag.js b/plugins/pipelines/templates/createTag.js new file mode 100644 index 000000000..81a732610 --- /dev/null +++ b/plugins/pipelines/templates/createTag.js @@ -0,0 +1,74 @@ +'use strict'; + +const boom = require('@hapi/boom'); +const joi = require('joi'); +const schema = require('screwdriver-data-schema'); +const baseSchema = schema.models.templateTag.base; +const metaSchema = schema.models.templateMeta.base; + +module.exports = () => ({ + method: 'PUT', + path: '/pipeline/template/{namespace}/{name}/tags/{tag}', + options: { + description: 'Add or update a pipeline template tag', + notes: 'Add or update a specific template', + tags: ['api', 'templates'], + auth: { + strategies: ['token'], + scope: ['build'] + }, + handler: async (request, h) => { + const { pipelineFactory, pipelineTemplateVersionFactory, pipelineTemplateTagFactory } = request.server.app; + + const { isPR, pipelineId } = request.auth.credentials; + + const { name, namespace, tag } = request.params; + + const { version } = request.payload; + + const [pipeline, template, templateTag] = await Promise.all([ + pipelineFactory.get(pipelineId), + pipelineTemplateVersionFactory.get({ name, namespace, version }), + pipelineTemplateTagFactory.get({ name, namespace, tag }) + ]); + + // If template doesn't exist, throw error + if (!template) { + throw boom.notFound(`PipelineTemplate ${name}@${version} not found`); + } + + // check if build has permission to publish + if (pipeline.id !== template.pipelineId || isPR) { + throw boom.forbidden('Not allowed to tag this pipeline template'); + } + + // If template tag exists, update the version + if (templateTag) { + templateTag.version = version; + + const newTag = await templateTag.update(); + + return h.response(newTag.toJson()).code(200); + } + + const newTag = await pipelineTemplateTagFactory.create({ namespace, name, tag, version }); + + const location = new URL( + `${request.path}/${newTag.id}`, + `${request.server.info.protocol}://${request.headers.host}` + ).toString(); + + return h.response(newTag.toJson()).header('Location', location).code(201); + }, + validate: { + params: joi.object({ + namespace: metaSchema.extract('namespace'), + name: metaSchema.extract('name'), + tag: baseSchema.extract('tag') + }), + payload: joi.object({ + version: baseSchema.extract('version') + }) + } + } +}); diff --git a/plugins/pipelines/templates/get.js b/plugins/pipelines/templates/get.js new file mode 100644 index 000000000..4f24a60a5 --- /dev/null +++ b/plugins/pipelines/templates/get.js @@ -0,0 +1,47 @@ +'use strict'; + +const boom = require('@hapi/boom'); +const joi = require('joi'); +const schema = require('screwdriver-data-schema'); +const getSchema = schema.models.templateMeta.get; +const metaSchema = schema.models.templateMeta.base; + +module.exports = () => ({ + method: 'GET', + path: '/pipeline/template/{namespace}/{name}', + options: { + description: 'Get a specific template by namespace and name', + notes: 'Returns template meta for the specified namespace and name', + tags: ['api', 'pipeline', 'template'], + auth: { + strategies: ['token'], + scope: ['user', 'build'] + }, + handler: async (request, h) => { + console.log('request.params', request.params); + + const { namespace, name } = request.params; + const { pipelineTemplateFactory } = request.server.app; + + const pipelineTemplate = await pipelineTemplateFactory.get({ + name, + namespace + }); + + if (!pipelineTemplate) { + throw boom.notFound('Pipeline Template does not exist'); + } + + return h.response(pipelineTemplate).code(200); + }, + response: { + schema: getSchema + }, + validate: { + params: joi.object({ + namespace: metaSchema.extract('namespace'), + name: metaSchema.extract('name') + }) + } + } +}); diff --git a/plugins/pipelines/templates/getTemplateById.js b/plugins/pipelines/templates/getTemplateById.js new file mode 100644 index 000000000..0cd03b9b3 --- /dev/null +++ b/plugins/pipelines/templates/getTemplateById.js @@ -0,0 +1,40 @@ +'use strict'; + +const boom = require('@hapi/boom'); +const joi = require('joi'); +const schema = require('screwdriver-data-schema'); +const getSchema = schema.models.templateMeta.get; +const idSchema = schema.models.templateMeta.base.extract('id'); + +module.exports = () => ({ + method: 'GET', + path: '/pipeline/template/{id}', + options: { + description: 'Get a single template', + notes: 'Returns a template record', + tags: ['api', 'templates'], + auth: { + strategies: ['token'], + scope: ['user', 'build'] + }, + handler: async (request, h) => { + const { pipelineTemplateFactory } = request.server.app; + + const pipelineTemplate = await pipelineTemplateFactory.get({ id: request.params.id }); + + if (!pipelineTemplate) { + throw boom.notFound('Pipeline Template does not exist'); + } + + return h.response(pipelineTemplate).code(200); + }, + response: { + schema: getSchema + }, + validate: { + params: joi.object({ + id: idSchema + }) + } + } +}); diff --git a/plugins/pipelines/templates/getVersion.js b/plugins/pipelines/templates/getVersion.js new file mode 100644 index 000000000..0e5c8759b --- /dev/null +++ b/plugins/pipelines/templates/getVersion.js @@ -0,0 +1,45 @@ +'use strict'; + +const boom = require('@hapi/boom'); +const joi = require('joi'); +const schema = require('screwdriver-data-schema'); +const metaSchema = schema.models.templateMeta.base; +const versionSchema = schema.models.pipelineTemplateVersions.base.extract('version'); +const tagSchema = schema.models.templateTag.base.extract('tag'); + +module.exports = () => ({ + method: 'GET', + path: '/pipeline/template/{namespace}/{name}/{versionOrTag}', + options: { + description: 'Get a specific template version details by version number or tag', + notes: 'Returns template meta and version for namespace, name and version/tag', + tags: ['api', 'pipeline', 'template'], + auth: { + strategies: ['token'], + scope: ['user', 'build'] + }, + handler: async (request, h) => { + const { namespace, name, versionOrTag } = request.params; + const { pipelineTemplateVersionFactory } = request.server.app; + + const pipelineTemplate = await pipelineTemplateVersionFactory.getWithMetadata({ + name, + namespace, + versionOrTag + }); + + if (!pipelineTemplate) { + throw boom.notFound('Pipeline Template does not exist'); + } + + return h.response(pipelineTemplate).code(200); + }, + validate: { + params: joi.object({ + namespace: metaSchema.extract('namespace'), + name: metaSchema.extract('name'), + versionOrTag: joi.alternatives().try(versionSchema, tagSchema) + }) + } + } +}); diff --git a/plugins/pipelines/templates/list.js b/plugins/pipelines/templates/list.js new file mode 100644 index 000000000..986bebe31 --- /dev/null +++ b/plugins/pipelines/templates/list.js @@ -0,0 +1,55 @@ +'use strict'; + +const boom = require('@hapi/boom'); +const schema = require('screwdriver-data-schema'); +const joi = require('joi'); +const listSchema = joi.array().items(schema.models.templateMeta.get).label('List of pipeline templates'); +const listCountSchema = joi + .object() + .keys({ + count: joi.number(), + rows: listSchema + }) + .label('Pipeline Template Count and List of templates'); + +module.exports = () => ({ + method: 'GET', + path: '/pipeline/templates', + options: { + description: 'List all the pipeline templates', + notes: 'Returns an array template meta for all the pipeline templates', + tags: ['api', 'pipeline', 'template'], + auth: { + strategies: ['token'], + scope: ['user', 'build'] + }, + handler: async (request, h) => { + const { pipelineTemplateFactory } = request.server.app; + + const { page, sort, sortBy, count } = request.query; + const config = { sort }; + + if (sortBy) { + config.sortBy = sortBy; + } + + if (page || count) { + config.paginate = { page, count }; + } + + const pipelineTemplates = await pipelineTemplateFactory.list(config); + + if (!pipelineTemplates || pipelineTemplates.length === 0) { + throw boom.notFound('Pipeline templates do not exist'); + } + + return h.response(pipelineTemplates).code(200); + }, + response: { + schema: joi.alternatives().try(listSchema, listCountSchema) + }, + validate: { + query: schema.api.pagination + } + } +}); diff --git a/plugins/pipelines/templates/listTags.js b/plugins/pipelines/templates/listTags.js new file mode 100644 index 000000000..fc4dd8b33 --- /dev/null +++ b/plugins/pipelines/templates/listTags.js @@ -0,0 +1,53 @@ +'use strict'; + +const joi = require('joi'); +const schema = require('screwdriver-data-schema'); +const listSchema = joi.array().items(schema.models.templateTag.base).label('List of templates'); +const metaSchema = schema.models.templateMeta.base; + +module.exports = () => ({ + method: 'GET', + path: '/pipeline/templates/{namespace}/{name}/tags', + options: { + description: 'Get all pipeline template tags for a given template name and namespace', + notes: 'Returns all pipeline template tags for a given template name and namespace', + tags: ['api', 'templates', 'tags'], + auth: { + strategies: ['token'], + scope: ['user', 'build'] + }, + + handler: async (request, h) => { + const { pipelineTemplateTagFactory } = request.server.app; + const config = { + params: request.params, + sort: request.query.sort + }; + + if (request.query.page || request.query.count) { + config.paginate = { + page: request.query.page, + count: request.query.count + }; + } + + const tags = await pipelineTemplateTagFactory.list(config); + + return h.response(tags); + }, + response: { + schema: listSchema + }, + validate: { + params: joi.object({ + namespace: metaSchema.extract('namespace'), + name: metaSchema.extract('name') + }), + query: schema.api.pagination.concat( + joi.object({ + search: joi.forbidden() + }) + ) + } + } +}); diff --git a/plugins/pipelines/templates/listVersions.js b/plugins/pipelines/templates/listVersions.js new file mode 100644 index 000000000..475af79b8 --- /dev/null +++ b/plugins/pipelines/templates/listVersions.js @@ -0,0 +1,61 @@ +'use strict'; + +const boom = require('@hapi/boom'); +const joi = require('joi'); +const schema = require('screwdriver-data-schema'); +const listSchema = joi + .array() + .items(schema.models.pipelineTemplateVersions.get) + .label('List of versions of a template'); +const nameSchema = schema.models.templateMeta.base.extract('name'); +const namespaceSchema = schema.models.templateMeta.base.extract('namespace'); + +module.exports = () => ({ + method: 'GET', + path: '/pipeline/templates/{namespace}/{name}/versions', + options: { + description: 'Get all template versions for a given template name with pagination', + notes: 'Returns all template records for a given template name', + tags: ['api', 'templates', 'versions'], + auth: { + strategies: ['token'], + scope: ['user', 'build'] + }, + handler: async (request, h) => { + const { pipelineTemplateVersionFactory } = request.server.app; + const config = { + params: request.params, + sort: request.query.sort + }; + + if (request.query.page || request.query.count) { + config.paginate = { + page: request.query.page, + count: request.query.count + }; + } + + const templates = await pipelineTemplateVersionFactory.list(config); + + if (!templates || templates.length === 0) { + throw boom.notFound('Template does not exist'); + } + + return h.response(templates).code(200); + }, + response: { + schema: listSchema + }, + validate: { + params: joi.object({ + namespace: namespaceSchema, + name: nameSchema + }), + query: schema.api.pagination.concat( + joi.object({ + search: joi.forbidden() + }) + ) + } + } +}); diff --git a/plugins/pipelines/templates/validate.js b/plugins/pipelines/templates/validate.js new file mode 100644 index 000000000..22f1ddcea --- /dev/null +++ b/plugins/pipelines/templates/validate.js @@ -0,0 +1,38 @@ +'use strict'; + +const boom = require('@hapi/boom'); +const schema = require('screwdriver-data-schema'); +const templateSchema = schema.api.templateValidator; +const pipelineValidator = require('screwdriver-template-validator').parsePipelineTemplate; + +module.exports = () => ({ + method: 'POST', + path: '/pipeline/template/validate', + options: { + description: 'Validate a given sd-template.yaml', + notes: 'returns the parsed config, validation errors, or both', + tags: ['api', 'validation', 'yaml'], + auth: { + strategies: ['token'], + scope: ['user', 'pipeline'] + }, + + handler: async (request, h) => { + try { + const { yaml: templateString } = request.payload; + + const result = await pipelineValidator(templateString); + + return h.response(result); + } catch (err) { + throw boom.badRequest(err.toString()); + } + }, + validate: { + payload: templateSchema.input + }, + response: { + schema: templateSchema.output + } + } +}); diff --git a/plugins/pipelines/update.js b/plugins/pipelines/update.js index e4be27973..17c52fa78 100644 --- a/plugins/pipelines/update.js +++ b/plugins/pipelines/update.js @@ -150,12 +150,16 @@ module.exports = () => ({ if (!oldPipeline.badges) { oldPipeline.badges = badges; } else { + const newBadges = {}; + Object.keys(oldPipeline.badges).forEach(badgeKey => { - oldPipeline.badges[badgeKey] = { + newBadges[badgeKey] = { ...oldPipeline.badges[badgeKey], ...badges[badgeKey] }; }); + + oldPipeline.badges = newBadges; } } diff --git a/plugins/template-validator.js b/plugins/template-validator.js index f76c7b35d..f6adde2d6 100644 --- a/plugins/template-validator.js +++ b/plugins/template-validator.js @@ -3,7 +3,7 @@ const boom = require('@hapi/boom'); const schema = require('screwdriver-data-schema'); const templateSchema = schema.api.templateValidator; -const validator = require('screwdriver-template-validator'); +const validator = require('screwdriver-template-validator').parseJobTemplate; /** * Hapi Template Validator Plugin diff --git a/plugins/templates/create.js b/plugins/templates/create.js index 70c468c4b..48eb07365 100644 --- a/plugins/templates/create.js +++ b/plugins/templates/create.js @@ -3,7 +3,7 @@ const urlLib = require('url'); const boom = require('@hapi/boom'); const schema = require('screwdriver-data-schema'); -const validator = require('screwdriver-template-validator'); +const validator = require('screwdriver-template-validator').parseJobTemplate; const templateSchema = schema.api.templateValidator; const hoek = require('@hapi/hoek'); diff --git a/test/plugins/builds.test.js b/test/plugins/builds.test.js index 6938963ab..a34197426 100644 --- a/test/plugins/builds.test.js +++ b/test/plugins/builds.test.js @@ -3502,6 +3502,126 @@ describe('build plugin test', () => { }); }); + it('starts single external job with normal join when it circles back to original pipeline', () => { + // For a pipeline like this: + // ~sd@2:a -> a -> ~sd@2:c (requires[ b, ~sd@123:a ]) + // ~sd@2:b ------➚ + // If user is at `a`, it should trigger `sd@2:c` + // ~sd@123:a is or trigger, so create + eventMock.workflowGraph = { + nodes: [ + { name: '~pr' }, + { name: '~commit' }, + { name: 'a', id: 1 }, + { name: '~sd@2:a', id: 4 }, + { name: 'sd@2:c', id: 6 } + ], + edges: [ + { src: '~pr', dest: 'a' }, + { src: '~commit', dest: 'a' }, + { src: '~sd@2:a', dest: 'a' }, + { src: 'a', dest: 'sd@2:c' } + ] + }; + buildMock.parentBuilds = { + 2: { eventId: '8887', jobs: { a: 12345 } } + }; + const parentBuilds = { + 123: { eventId: '8888', jobs: { a: 12345 } }, + 2: { eventId: '8887', jobs: { a: 12345 } } + }; + const buildC = { + jobId: 3, + status: 'CREATED', + parentBuilds, + start: sinon.stub().resolves() + }; + const updatedBuildC = Object.assign(buildC, { + parentBuilds, + start: sinon.stub().resolves() + }); + const jobCConfig = { + baseBranch: 'master', + configPipelineSha: 'abc123', + eventId: 8887, + jobId: 3, + parentBuildId: [12345], + parentBuilds: { + 123: { eventId: '8888', jobs: { a: 12345 } }, + 2: { eventId: '8887', jobs: { a: 12345, b: null } } + }, + prRef: '', + prSource: '', + prInfo: '', + scmContext: 'github:github.com', + sha: '58393af682d61de87789fb4961645c42180cec5a', + start: false, + username: 12345 + }; + + buildC.update = sinon.stub().resolves(updatedBuildC); + const externalEventMock = { + sha: '58393af682d61de87789fb4961645c42180cec5a', + pr: {}, + id: 8887, + configPipelineSha: 'abc123', + pipelineId: 123, + baseBranch: 'master', + builds: [ + { + id: 888, + jobId: 4, + status: 'SUCCESS' + } + ], + getBuilds: sinon.stub().resolves([ + { + id: 888, + jobId: 4, + status: 'SUCCESS' + } + ]), + workflowGraph: { + nodes: [ + { name: '~pr' }, + { name: '~commit' }, + { name: 'a', id: 4 }, + { name: 'b', id: 8 }, + { name: 'c', id: 6 }, + { name: '~sd@123:c', id: 3 } + ], + edges: [ + { src: '~pr', dest: 'a' }, + { src: '~commit', dest: 'a' }, + { src: 'a', dest: '~sd@123:c' }, + { src: '~sd@123:c', dest: 'c' }, + { src: 'b', dest: 'c', join: true } + ] + } + }; + + buildFactoryMock.getLatestBuilds.resolves([ + { + jobId: 3, + status: 'SUCCESS' + } + ]); + eventFactoryMock.get.withArgs('8887').resolves(externalEventMock); + eventFactoryMock.get.withArgs(8889).resolves({ ...externalEventMock, id: '8889' }); + eventFactoryMock.list.resolves([{ ...externalEventMock, id: '8889' }]); + buildFactoryMock.create.onCall(0).resolves(buildC); + buildFactoryMock.get.withArgs(5555).resolves({ status: 'SUCCESS' }); // d is done + + return newServer.inject(options).then(() => { + assert.notCalled(eventFactoryMock.create); + assert.calledOnce(buildFactoryMock.getLatestBuilds); + assert.calledOnce(buildFactoryMock.create); + assert.calledWith(buildFactoryMock.create, jobCConfig); + assert.calledOnce(buildC.update); + assert.calledOnce(updatedBuildC.start); + }); + }); + it('creates a single event for downstream triggers in the same pipeline', () => { // For a pipeline like this: // -> b @@ -4116,6 +4236,89 @@ describe('build plugin test', () => { }); }); + it('triggers when the target build is present in both its own event and a child event, the latest being the child event', () => { + // (Internal join restart case) + // For a pipeline like this: + // a -> d + // b -> + // c -> + // + // 1. `a` fails. + // 2. `b` succeeds. + // 3. Restart of `a` succeeds. + // 4. `c` succeeds. + // 5. `d` is triggered within the parent event + // + // Currently, the build of `d` is triggered in the parent event, not in the child event created by restart. + // If you want to fix it so that it is triggered within a child event, please fix this test. + + const buildD = { + jobId: 4, + status: 'CREATED', + parentBuilds: { + 123: { + eventId: '8888', + jobs: { a: 12345 } + } + }, + start: sinon.stub().resolves(), + eventId: '8888', + id: 889 + }; + + eventMock.workflowGraph.edges = [ + { src: '~commit', dest: 'a' }, + { src: '~commit', dest: 'b' }, + { src: '~commit', dest: 'c' }, + { src: 'a', dest: 'd', join: true }, + { src: 'b', dest: 'd', join: true }, + { src: 'c', dest: 'd', join: true } + ]; + + const updatedBuildD = Object.assign(buildD, { + parentBuilds: { + 123: { eventId: '8888', jobs: { a: 12345, b: 12346, c: 12347 } } + }, + start: sinon.stub().resolves() + }); + + buildD.update = sinon.stub().resolves(updatedBuildD); + buildFactoryMock.getLatestBuilds.resolves([ + { + jobId: 1, + id: 12345, + eventId: '8888', + status: 'SUCCESS' + }, + { + jobId: 2, + id: 12346, + eventId: '8888', + status: 'SUCCESS' + }, + { + jobId: 3, + id: 12347, + eventId: '8888', + status: 'SUCCESS' + }, + { + jobId: 4, + id: 12348, + eventId: '8889', + status: 'CREATED' + } + ]); + + buildFactoryMock.get.withArgs({ eventId: buildD.eventId, jobId: buildD.jobId }).returns(buildD); + buildFactoryMock.get.withArgs(buildD.id).resolves(buildD); + + return newServer.inject(options).then(() => { + assert.notCalled(buildFactoryMock.create); + assert.calledOnce(updatedBuildD.start); + }); + }); + it('triggers if all jobs in external join are done with parent event', () => { // (External join restart case) // For pipelines like this: diff --git a/test/plugins/data/pipeline-template-create.input.json b/test/plugins/data/pipeline-template-create.input.json new file mode 100644 index 000000000..ae659729b --- /dev/null +++ b/test/plugins/data/pipeline-template-create.input.json @@ -0,0 +1,3 @@ +{ + "yaml": "{ \"namespace\": \"template_namespace\", \"name\": \"template_name\", \"version\": \"1.2\", \"description\": \"template description\", \"maintainer\": \"name@domain.org\", \"config\": { \"jobs\": { \"main\": { \"steps\": [ { \"init\": \"npm install\" }, { \"test\": \"npm test\" } ] } } } }" + } \ No newline at end of file diff --git a/test/plugins/data/pipeline-template-validator.input.json b/test/plugins/data/pipeline-template-validator.input.json new file mode 100644 index 000000000..697879989 --- /dev/null +++ b/test/plugins/data/pipeline-template-validator.input.json @@ -0,0 +1,3 @@ +{ + "yaml": "{ \"namespace\": \"template_namespace\", \"name\": \"template_name\", \"version\": \"1.2.3\", \"description\": \"template description\", \"maintainer\": \"name@domain.org\", \"config\": { \"jobs\": { \"main\": { \"steps\": [ { \"init\": \"npm install\" }, { \"test\": \"npm test\" } ] } } } }" +} \ No newline at end of file diff --git a/test/plugins/data/pipeline-template-validator.missing-version.json b/test/plugins/data/pipeline-template-validator.missing-version.json new file mode 100644 index 000000000..ae8178740 --- /dev/null +++ b/test/plugins/data/pipeline-template-validator.missing-version.json @@ -0,0 +1,3 @@ +{ + "yaml": "{ \"namespace\": \"template_namespace\", \"name\": \"template_name\", \"description\": \"template description\", \"maintainer\": \"name@domain.org\", \"config\": { \"jobs\": { \"main\": { \"steps\": [ { \"init\": \"npm install\" }, { \"test\": \"npm test\" } ] } } } }" + } \ No newline at end of file diff --git a/test/plugins/data/pipeline-template.json b/test/plugins/data/pipeline-template.json new file mode 100644 index 000000000..0199f80c4 --- /dev/null +++ b/test/plugins/data/pipeline-template.json @@ -0,0 +1,13 @@ +{ + "id": 123, + "pipelineId": 123, + "namespace": "my-namespace", + "name": "example-template", + "description": "An example template", + "maintainer": "example@gmail.com", + "trustedSinceVersion": "1.3.2", + "latestVersion": "1.3.2", + "createTime": "2023-06-12T21:52:22.706Z", + "updateTime": "2023-06-29T11:23:45.706Z" +} + \ No newline at end of file diff --git a/test/plugins/data/pipelineTemplateTags.json b/test/plugins/data/pipelineTemplateTags.json new file mode 100644 index 000000000..d430871d5 --- /dev/null +++ b/test/plugins/data/pipelineTemplateTags.json @@ -0,0 +1,14 @@ +[{ + "id": 7969, + "name": "screwdriver/pipeline", + "tag": "stable", + "version": "0.0.4", + "templateType": "PIPELINE" +}, +{ + "id": 7970, + "name": "screwdriver/pipeline", + "tag": "latest", + "version": "1.0.5", + "templateType": "PIPELINE" +}] \ No newline at end of file diff --git a/test/plugins/data/pipelineTemplateVersion.json b/test/plugins/data/pipelineTemplateVersion.json new file mode 100644 index 000000000..ba44036a8 --- /dev/null +++ b/test/plugins/data/pipelineTemplateVersion.json @@ -0,0 +1,31 @@ +{ + "id": 1, + "templateId": 1234, + "description": "An example template", + "version": "1.0.0", + "config": { + "jobs": { + "main": { + "image": "node:18", + "steps": [ + { + "printLine": "echo 'Testing template creation V1'" + } + ], + "requires": [ + "~pr", + "~commit" + ] + } + } + }, + "pipelineId": 123, + "namespace": "my-namespace", + "name": "example-template", + "maintainer": "example@gmail.com", + "templateType": "PIPELINE", + "trustedSinceVersion": "1.3.2", + "latestVersion": "1.3.2", + "createTime": "2023-06-12T21:52:22.706Z", + "updateTime": "2023-06-29T11:23:45.706Z" +} \ No newline at end of file diff --git a/test/plugins/data/pipelineTemplateVersions.json b/test/plugins/data/pipelineTemplateVersions.json new file mode 100644 index 000000000..68e016deb --- /dev/null +++ b/test/plugins/data/pipelineTemplateVersions.json @@ -0,0 +1,51 @@ +[ + { + "id": 1, + "templateId": 123, + "description": "An example template", + "version": "1.0.0", + "config": { + "jobs": { + "main": { + "image": "node:18", + "steps": [ + { + "printLine": "echo 'Testing template creation V1'" + } + ], + "requires": [ + "~pr", + "~commit" + ] + } + } + }, + "createTime": "2023-06-12T21:52:22.706Z" + }, + { + "id": 2, + "templateId": 123, + "description": "An example template", + "version": "2.0.0", + "config": { + "jobs": { + "main": { + "image": "node:18", + "steps": [ + { + "printLine": "echo 'Testing template creation V1'" + }, + { + "printLine2": "echo 'Testing template creation V2'" + } + ], + "requires": [ + "~pr", + "~commit" + ] + } + } + }, + "createTime": "2023-06-12T21:52:22.706Z" + } +] \ No newline at end of file diff --git a/test/plugins/data/pipelineTemplates.json b/test/plugins/data/pipelineTemplates.json new file mode 100644 index 000000000..2d5a4a3a9 --- /dev/null +++ b/test/plugins/data/pipelineTemplates.json @@ -0,0 +1,26 @@ +[ + { + "id": 123, + "pipelineId": 123, + "namespace": "my-namespace", + "name": "example-template", + "maintainer": "example@gmail.com", + "templateType": "PIPELINE", + "trustedSinceVersion": "1.3.2", + "latestVersion": "1.3.2", + "createTime": "2023-06-12T21:52:22.706Z", + "updateTime": "2023-06-29T11:23:45.706Z" + }, + { + "id": 534, + "pipelineId": 789, + "namespace": "some-namespace", + "name": "example-template-1", + "maintainer": "example1@gmail.com", + "templateType": "PIPELINE", + "trustedSinceVersion": "2.1.0", + "latestVersion": "2.1.0", + "createTime": "2023-06-12T21:52:22.706Z", + "updateTime": "2023-08-12T11:23:45.706Z" + } +] \ No newline at end of file diff --git a/test/plugins/pipelines.templates.test.js b/test/plugins/pipelines.templates.test.js new file mode 100644 index 000000000..f99347818 --- /dev/null +++ b/test/plugins/pipelines.templates.test.js @@ -0,0 +1,889 @@ +'use strict'; + +const { assert } = require('chai'); +const badgeMaker = require('badge-maker'); +const sinon = require('sinon'); +const hapi = require('@hapi/hapi'); +const hoek = require('@hapi/hoek'); +const testPipeline = require('./data/pipeline.json'); +const testTemplate = require('./data/pipeline-template.json'); +const testTemplates = require('./data/pipelineTemplates.json'); +const testTemplateGet = testTemplates[0]; +const testTemplateVersions = require('./data/pipelineTemplateVersions.json'); +const testTemplateVersionsGet = require('./data/pipelineTemplateVersion.json'); +const testTemplateTags = require('./data/pipelineTemplateTags.json'); +const TEMPLATE_INVALID = require('./data/pipeline-template-validator.missing-version.json'); +const TEMPLATE_VALID = require('./data/pipeline-template-validator.input.json'); +const TEMPLATE_VALID_NEW_VERSION = require('./data/pipeline-template-create.input.json'); + +sinon.assert.expose(assert, { prefix: '' }); + +const decorateObj = obj => { + const mock = hoek.clone(obj); + + mock.toJson = sinon.stub().returns(obj); + + return mock; +}; + +const getTemplateMocks = templates => { + if (Array.isArray(templates)) { + return templates.map(decorateObj); + } + + return decorateObj(templates); +}; + +const getPipelineMocks = pipelines => { + if (Array.isArray(pipelines)) { + return pipelines.map(decorateObj); + } + + return decorateObj(pipelines); +}; + +describe('pipeline plugin test', () => { + let pipelineFactoryMock; + let userFactoryMock; + let collectionFactoryMock; + let eventFactoryMock; + let tokenFactoryMock; + let bannerFactoryMock; + let jobFactoryMock; + let stageFactoryMock; + let triggerFactoryMock; + let secretFactoryMock; + let bannerMock; + let screwdriverAdminDetailsMock; + let scmMock; + let pipelineTemplateFactoryMock; + let pipelineTemplateVersionFactoryMock; + let pipelineTemplateTagFactoryMock; + let plugin; + let server; + const password = 'this_is_a_password_that_needs_to_be_atleast_32_characters'; + + before(() => { + sinon.stub(badgeMaker, 'makeBadge').callsFake(format => `${format.label}: ${format.message}`); + }); + + beforeEach(async () => { + pipelineFactoryMock = { + create: sinon.stub(), + get: sinon.stub(), + update: sinon.stub(), + list: sinon.stub(), + scm: { + getScmContexts: sinon.stub(), + parseUrl: sinon.stub(), + decorateUrl: sinon.stub(), + getCommitSha: sinon.stub().resolves('sha'), + addDeployKey: sinon.stub(), + getReadOnlyInfo: sinon.stub().returns({ readOnlyEnabled: false }), + getDisplayName: sinon.stub().returns() + } + }; + userFactoryMock = { + get: sinon.stub(), + scm: { + parseUrl: sinon.stub(), + openPr: sinon.stub() + } + }; + collectionFactoryMock = { + create: sinon.stub(), + list: sinon.stub() + }; + eventFactoryMock = { + create: sinon.stub().resolves(null), + list: sinon.stub().resolves(null) + }; + stageFactoryMock = { + list: sinon.stub() + }; + tokenFactoryMock = { + get: sinon.stub(), + create: sinon.stub() + }; + jobFactoryMock = { + get: sinon.stub() + }; + triggerFactoryMock = { + getTriggers: sinon.stub() + }; + bannerFactoryMock = { + scm: { + getDisplayName: sinon.stub().returns() + } + }; + secretFactoryMock = { + create: sinon.stub(), + get: sinon.stub() + }; + bannerMock = { + name: 'banners', + register: s => { + s.expose('screwdriverAdminDetails', screwdriverAdminDetailsMock); + } + }; + screwdriverAdminDetailsMock = sinon.stub(); + pipelineTemplateFactoryMock = { + get: sinon.stub(), + list: sinon.stub(), + create: sinon.stub() + }; + pipelineTemplateVersionFactoryMock = { + create: sinon.stub(), + list: sinon.stub(), + get: sinon.stub(), + getWithMetadata: sinon.stub() + }; + + pipelineTemplateTagFactoryMock = { + list: sinon.stub(), + get: sinon.stub(), + create: sinon.stub() + }; + + /* eslint-disable global-require */ + plugin = require('../../plugins/pipelines'); + /* eslint-enable global-require */ + server = new hapi.Server({ + port: 1234 + }); + server.app = { + eventFactory: eventFactoryMock, + jobFactory: jobFactoryMock, + stageFactory: stageFactoryMock, + triggerFactory: triggerFactoryMock, + pipelineFactory: pipelineFactoryMock, + userFactory: userFactoryMock, + collectionFactory: collectionFactoryMock, + tokenFactory: tokenFactoryMock, + bannerFactory: bannerFactoryMock, + secretFactory: secretFactoryMock, + pipelineTemplateFactory: pipelineTemplateFactoryMock, + pipelineTemplateVersionFactory: pipelineTemplateVersionFactoryMock, + pipelineTemplateTagFactory: pipelineTemplateTagFactoryMock, + ecosystem: { + badges: '{{subject}}/{{status}}/{{color}}' + } + }; + + server.auth.scheme('custom', () => ({ + authenticate: (request, h) => + h.authenticated({ + credentials: { + scope: ['user'] + } + }) + })); + server.auth.strategy('token', 'custom'); + + server.register([ + { plugin: bannerMock }, + { + plugin, + options: { + password, + scm: scmMock, + admins: ['github:batman'] + } + }, + { + // eslint-disable-next-line global-require + plugin: require('../../plugins/secrets'), + options: { + password + } + } + ]); + }); + + afterEach(() => { + server = null; + }); + + after(() => { + sinon.restore(); + }); + + describe('POST /pipeline/template', () => { + let options; + let templateMock; + const testId = 123; + let expected; + + beforeEach(() => { + options = { + method: 'POST', + url: '/pipeline/template', + payload: TEMPLATE_VALID, + auth: { + credentials: { + scope: ['build'], + pipelineId: 123 + }, + strategy: ['token'] + } + }; + + expected = { + namespace: 'template_namespace', + name: 'template_name', + version: '1.2.3', + description: 'template description', + maintainer: 'name@domain.org', + config: { + jobs: { main: { steps: [{ init: 'npm install' }, { test: 'npm test' }] } }, + shared: {}, + parameters: {} + }, + pipelineId: 123 + }; + + templateMock = getTemplateMocks(testTemplate); + pipelineTemplateVersionFactoryMock.create.resolves(templateMock); + pipelineTemplateFactoryMock.get.resolves(templateMock); + }); + + it('returns 403 when pipelineId does not match', () => { + templateMock.pipelineId = 321; + + return server.inject(options).then(reply => { + assert.equal(reply.statusCode, 403); + }); + }); + + it('returns 403 if it is a PR build', () => { + options.auth.credentials.isPR = true; + + return server.inject(options).then(reply => { + assert.equal(reply.statusCode, 403); + }); + }); + + it('creates template if template does not exist yet', () => { + pipelineTemplateFactoryMock.get.resolves(null); + + return server.inject(options).then(reply => { + const expectedLocation = new URL( + `${options.url}/${testId}`, + `${reply.request.server.info.protocol}://${reply.request.headers.host}` + ).toString(); + + assert.deepEqual(reply.result, testTemplate); + assert.strictEqual(reply.headers.location, expectedLocation); + assert.calledWith(pipelineTemplateFactoryMock.get, { + name: 'template_name', + namespace: 'template_namespace' + }); + assert.calledWith(pipelineTemplateVersionFactoryMock.create, expected, pipelineTemplateFactoryMock); + assert.equal(reply.statusCode, 201); + }); + }); + + it('creates template if has good permission and it is a new version', () => { + options.payload = TEMPLATE_VALID_NEW_VERSION; + expected.version = '1.2'; + pipelineTemplateFactoryMock.get.resolves(templateMock); + + return server.inject(options).then(reply => { + const expectedLocation = new URL( + `${options.url}/${testId}`, + `${reply.request.server.info.protocol}://${reply.request.headers.host}` + ).toString(); + + assert.deepEqual(reply.result, testTemplate); + assert.strictEqual(reply.headers.location, expectedLocation); + assert.calledWith(pipelineTemplateFactoryMock.get, { + name: 'template_name', + namespace: 'template_namespace' + }); + assert.calledWith(pipelineTemplateVersionFactoryMock.create, expected, pipelineTemplateFactoryMock); + assert.equal(reply.statusCode, 201); + }); + }); + + it('returns 500 when the template model fails to get', () => { + const testError = new Error('templateModelGetError'); + + pipelineTemplateFactoryMock.get.rejects(testError); + + return server.inject(options).then(reply => { + assert.equal(reply.statusCode, 500); + }); + }); + + it('returns 500 when the template model fails to create', () => { + const testError = new Error('templateModelCreateError'); + + pipelineTemplateVersionFactoryMock.create.rejects(testError); + + return server.inject(options).then(reply => { + assert.equal(reply.statusCode, 500); + }); + }); + + it('returns 400 when the template is invalid', () => { + options.payload = TEMPLATE_INVALID; + pipelineTemplateFactoryMock.get.resolves(null); + + return server.inject(options).then(reply => { + assert.equal(reply.statusCode, 400); + }); + }); + }); + + describe('POST /pipeline/template/validate', () => { + it('returns OK for a successful template yaml', () => + server + .inject({ + method: 'POST', + url: '/pipeline/template/validate', + payload: TEMPLATE_VALID + }) + .then(reply => { + assert.strictEqual(reply.statusCode, 200); + + const payload = JSON.parse(reply.payload); + + assert.deepEqual(payload, { + errors: [], + template: { + namespace: 'template_namespace', + name: 'template_name', + version: '1.2.3', + description: 'template description', + maintainer: 'name@domain.org', + config: { + jobs: { main: { steps: [{ init: 'npm install' }, { test: 'npm test' }] } }, + shared: {}, + parameters: {} + } + } + }); + })); + + it('returns OK and error yaml for bad yaml', () => + server + .inject({ + method: 'POST', + url: '/pipeline/template/validate', + payload: TEMPLATE_INVALID + }) + .then(reply => { + assert.strictEqual(reply.statusCode, 200); + + const payload = JSON.parse(reply.payload); + + assert.deepEqual(payload.template, { + namespace: 'template_namespace', + name: 'template_name', + description: 'template description', + maintainer: 'name@domain.org', + config: { + jobs: { main: { steps: [{ init: 'npm install' }, { test: 'npm test' }] } } + } + }); + + assert.deepEqual(payload.errors, [ + { + context: { + key: 'version', + label: 'version' + }, + message: '"version" is required', + path: ['version'], + type: 'any.required' + } + ]); + })); + + it('returns BAD REQUEST for template that cannot be parsed', () => + server + .inject({ + method: 'POST', + url: '/pipeline/template/validate', + payload: { + yaml: 'error: :' + } + }) + .then(reply => { + assert.strictEqual(reply.statusCode, 400); + + const payload = JSON.parse(reply.payload); + + assert.match(payload.message, /YAMLException/); + })); + + it('returns BAD REQUEST for invalid API input', () => + server + .inject({ + method: 'POST', + url: '/pipeline/template/validate', + payload: { yaml: 1 } + }) + .then(reply => { + assert.strictEqual(reply.statusCode, 400); + + const payload = JSON.parse(reply.payload); + + assert.match(payload.message, /Invalid request payload input/); + })); + }); + + describe('GET /pipeline/templates', () => { + let options; + + beforeEach(() => { + options = { + method: 'GET', + url: '/pipeline/templates', + auth: { + credentials: { + username: 'foo', + scmContext: 'github:github.com', + scope: ['user'] + }, + strategy: ['token'] + } + }; + + pipelineTemplateFactoryMock.list.resolves(testTemplates); + }); + + it('returns 200 for getting all templates', () => { + return server.inject(options).then(reply => { + assert.deepEqual(reply.result, testTemplates); + assert.equal(reply.statusCode, 200); + }); + }); + + it('returns 500 when the template model fails to get', () => { + const testError = new Error('templateModelGetError'); + + pipelineTemplateFactoryMock.list.rejects(testError); + + return server.inject(options).then(reply => { + assert.equal(reply.statusCode, 500); + }); + }); + + it('returns 200 and all templates with sortBy query', () => { + options.url = '/pipeline/templates?sortBy=name'; + + return server.inject(options).then(reply => { + assert.deepEqual(reply.result, testTemplates); + assert.equal(reply.statusCode, 200); + assert.calledWith(pipelineTemplateFactoryMock.list, { + sortBy: 'name', + sort: 'descending' + }); + }); + }); + + it('returns 200 and all templates with pagination', () => { + pipelineTemplateFactoryMock.list.resolves(testTemplates); + options.url = '/pipeline/templates?count=30'; + + return server.inject(options).then(reply => { + assert.equal(reply.statusCode, 200); + assert.deepEqual(reply.result, testTemplates); + assert.calledWith(pipelineTemplateFactoryMock.list, { + paginate: { + page: undefined, + count: 30 + }, + sort: 'descending' + }); + }); + }); + }); + + describe('GET /pipeline/templates/{namespace}/{name}/versions', () => { + let options; + + beforeEach(() => { + options = { + method: 'GET', + url: '/pipeline/templates/screwdriver/nodejs/versions', + auth: { + credentials: { + username: 'foo', + scmContext: 'github:github.com', + scope: ['user'] + }, + strategy: ['token'] + } + }; + + pipelineTemplateVersionFactoryMock.list.resolves(testTemplateVersions); + }); + + it('returns 200 for getting all template versions for name and namespace', () => + server.inject(options).then(reply => { + assert.deepEqual(reply.result, testTemplateVersions); + assert.equal(reply.statusCode, 200); + })); + + it('returns 200 and all versions for a pipeline template name and namespace with pagination', () => { + pipelineTemplateVersionFactoryMock.list.resolves(testTemplateVersions); + options.url = '/pipeline/templates/screwdriver/nodejs/versions?count=30'; + + return server.inject(options).then(reply => { + assert.equal(reply.statusCode, 200); + assert.deepEqual(reply.result, testTemplateVersions); + assert.calledWith(pipelineTemplateVersionFactoryMock.list, { + params: { + name: 'nodejs', + namespace: 'screwdriver' + }, + paginate: { + page: undefined, + count: 30 + }, + sort: 'descending' + }); + }); + }); + + it('returns 404 when template does not exist', () => { + pipelineTemplateVersionFactoryMock.list.resolves(null); + + return server.inject(options).then(reply => { + assert.equal(reply.statusCode, 404); + }); + }); + + it('returns 500 when the template model fails to get', () => { + const testError = new Error('templateModelGetError'); + + pipelineTemplateVersionFactoryMock.list.rejects(testError); + + return server.inject(options).then(reply => { + assert.equal(reply.statusCode, 500); + }); + }); + }); + + describe('GET /pipeline/templates/{namespace}/{name}/tags', () => { + let options; + + beforeEach(() => { + options = { + method: 'GET', + url: '/pipeline/templates/screwdriver/nodejs/tags', + auth: { + credentials: { + username: 'foo', + scmContext: 'github:github.com', + scope: ['user'] + }, + strategy: ['token'] + } + }; + + pipelineTemplateTagFactoryMock.list.resolves(testTemplateTags); + }); + + it('returns 200 for getting all template tags for name and namespace', () => + server.inject(options).then(reply => { + assert.deepEqual(reply.result, testTemplateTags); + assert.equal(reply.statusCode, 200); + })); + + it('returns 200 and all tags for a pipeline template name and namespace with pagination', () => { + options.url = '/pipeline/templates/screwdriver/nodejs/tags?count=30'; + + return server.inject(options).then(reply => { + assert.equal(reply.statusCode, 200); + assert.deepEqual(reply.result, testTemplateTags); + assert.calledWith(pipelineTemplateTagFactoryMock.list, { + params: { + name: 'nodejs', + namespace: 'screwdriver' + }, + paginate: { + page: undefined, + count: 30 + }, + sort: 'descending' + }); + }); + }); + + it('returns 200 and an empty array when there are no template tags', () => { + pipelineTemplateTagFactoryMock.list.resolves([]); + + return server.inject(options).then(reply => { + assert.deepEqual(reply.result, []); + assert.equal(reply.statusCode, 200); + }); + }); + + it('returns 500 when the pipeline template model fails to get', () => { + const testError = new Error('templateModelGetError'); + + pipelineTemplateTagFactoryMock.list.rejects(testError); + + return server.inject(options).then(reply => { + assert.equal(reply.statusCode, 500); + }); + }); + }); + + describe('GET /pipeline/template/{namespace}/{name}', () => { + let options; + + beforeEach(() => { + options = { + method: 'GET', + url: '/pipeline/template/screwdriver/nodejs', + auth: { + credentials: { + username: 'foo', + scmContext: 'github:github.com', + scope: ['user'] + }, + strategy: ['token'] + } + }; + + pipelineTemplateFactoryMock.get.resolves(testTemplateGet); + }); + + it('returns 200 for getting a pipeline template', () => + server.inject(options).then(reply => { + assert.deepEqual(reply.result, testTemplateGet); + assert.equal(reply.statusCode, 200); + })); + + it('returns 404 when pipeline template does not exist', () => { + pipelineTemplateFactoryMock.get.resolves(null); + + return server.inject(options).then(reply => { + assert.equal(reply.statusCode, 404); + }); + }); + + it('returns 500 when the pipeline template model fails to get', () => { + const testError = new Error('templateModelGetError'); + + pipelineTemplateFactoryMock.get.rejects(testError); + + return server.inject(options).then(reply => { + assert.equal(reply.statusCode, 500); + }); + }); + }); + + describe('GET /pipeline/template/{id}', () => { + let options; + + beforeEach(() => { + options = { + method: 'GET', + url: '/pipeline/template/123', + auth: { + credentials: { + username: 'foo', + scmContext: 'github:github.com', + scope: ['user'] + }, + strategy: ['token'] + } + }; + + pipelineTemplateFactoryMock.get.resolves(testTemplateGet); + }); + + it('returns 200 for getting a template', () => + server.inject(options).then(reply => { + assert.deepEqual(reply.result, testTemplateGet); + assert.equal(reply.statusCode, 200); + })); + + it('returns 404 when template does not exist', () => { + pipelineTemplateFactoryMock.get.resolves(null); + + return server.inject(options).then(reply => { + assert.equal(reply.statusCode, 404); + }); + }); + + it('returns 500 when the template model fails to get', () => { + const testError = new Error('templateModelGetError'); + + pipelineTemplateFactoryMock.get.rejects(testError); + + return server.inject(options).then(reply => { + assert.equal(reply.statusCode, 500); + }); + }); + }); + + describe('GET /pipeline/template/{namespace}/{name}/{versionOrTag}', () => { + let options; + + beforeEach(() => { + options = { + method: 'GET', + url: '/pipeline/template/screwdriver/nodejs/1.2.3', + auth: { + credentials: { + username: 'foo', + scmContext: 'github:github.com', + scope: ['user'] + }, + strategy: ['token'] + } + }; + + pipelineTemplateVersionFactoryMock.getWithMetadata.resolves(testTemplateVersionsGet); + }); + + it('returns 200 for getting a template', () => + server.inject(options).then(reply => { + assert.deepEqual(reply.result, testTemplateVersionsGet); + assert.equal(reply.statusCode, 200); + })); + + it('returns 404 when template does not exist', () => { + pipelineTemplateVersionFactoryMock.getWithMetadata.resolves(null); + + return server.inject(options).then(reply => { + assert.equal(reply.statusCode, 404); + }); + }); + + it('returns 500 when the template model fails to get', () => { + const testError = new Error('templateModelGetError'); + + pipelineTemplateVersionFactoryMock.getWithMetadata.rejects(testError); + + return server.inject(options).then(reply => { + assert.equal(reply.statusCode, 500); + }); + }); + }); + + describe('PUT /pipeline/template/{namespace}/{name}/tags/{tag}', () => { + let options; + let templateMock; + const testId = 123; + let pipeline; + const payload = { + version: '1.2.3' + }; + const testTemplateTag = decorateObj(hoek.merge({ id: 123 }, payload)); + + beforeEach(() => { + options = { + method: 'PUT', + url: '/pipeline/template/screwdriver/nodejs/tags/stable', + payload: { + version: '1.2.3' + }, + auth: { + credentials: { + username: 'foo', + scmContext: 'github:github.com', + scope: ['build'] + }, + strategy: ['token'] + } + }; + + pipeline = getPipelineMocks(testPipeline); + pipelineFactoryMock.get.resolves(pipeline); + templateMock = getTemplateMocks(testTemplate); + pipelineTemplateVersionFactoryMock.get.resolves(templateMock); + }); + + it('creates template tag if template tag does not exist yet', () => { + pipelineTemplateTagFactoryMock.create.resolves(testTemplateTag); + + return server.inject(options).then(reply => { + const expectedLocation = new URL( + `${options.url}/${testId}`, + `${reply.request.server.info.protocol}://${reply.request.headers.host}` + ).toString(); + + assert.deepEqual(reply.result, hoek.merge({ id: 123 }, payload)); + assert.strictEqual(reply.headers.location, expectedLocation); + assert.calledWith(pipelineTemplateVersionFactoryMock.get, { + name: 'nodejs', + namespace: 'screwdriver', + version: '1.2.3' + }); + assert.calledWith(pipelineTemplateTagFactoryMock.get, { + name: 'nodejs', + namespace: 'screwdriver', + tag: 'stable' + }); + assert.calledWith(pipelineTemplateTagFactoryMock.create, { + namespace: 'screwdriver', + name: 'nodejs', + tag: 'stable', + version: '1.2.3' + }); + assert.equal(reply.statusCode, 201); + }); + }); + + it('updates template version if the tag already exists', () => { + const template = hoek.merge( + { + update: sinon.stub().resolves(testTemplateTag) + }, + testTemplateTag + ); + + pipelineTemplateTagFactoryMock.get.resolves(template); + + return server.inject(options).then(reply => { + assert.deepEqual(reply.result.version, template.version); + assert.calledWith(pipelineTemplateVersionFactoryMock.get, { + name: 'nodejs', + namespace: 'screwdriver', + version: '1.2.3' + }); + assert.calledWith(pipelineTemplateTagFactoryMock.get, { + name: 'nodejs', + namespace: 'screwdriver', + tag: 'stable' + }); + assert.calledOnce(template.update); + assert.notCalled(pipelineTemplateVersionFactoryMock.create); + assert.equal(reply.statusCode, 200); + }); + }); + + it('returns 403 when pipelineId does not match', () => { + templateMock = getTemplateMocks(testTemplate); + templateMock.pipelineId = 321; + pipelineTemplateVersionFactoryMock.get.resolves(templateMock); + + return server.inject(options).then(reply => { + assert.equal(reply.statusCode, 403); + }); + }); + + it('returns 404 when template does not exist', () => { + pipelineTemplateVersionFactoryMock.get.resolves(null); + + return server.inject(options).then(reply => { + assert.equal(reply.statusCode, 404); + }); + }); + + it('returns 403 if it is a PR build', () => { + options.auth.credentials.isPR = true; + + return server.inject(options).then(reply => { + assert.equal(reply.statusCode, 403); + }); + }); + }); +}); diff --git a/test/plugins/pipelines.test.js b/test/plugins/pipelines.test.js index a1d9c58da..006950cae 100644 --- a/test/plugins/pipelines.test.js +++ b/test/plugins/pipelines.test.js @@ -189,6 +189,8 @@ describe('pipeline plugin test', () => { let bannerMock; let screwdriverAdminDetailsMock; let scmMock; + let pipelineTemplateFactoryMock; + let pipelineTemplateVersionFactoryMock; let plugin; let server; const password = 'this_is_a_password_that_needs_to_be_atleast_32_characters'; @@ -263,6 +265,12 @@ describe('pipeline plugin test', () => { } }; screwdriverAdminDetailsMock = sinon.stub(); + pipelineTemplateFactoryMock = { + get: sinon.stub() + }; + pipelineTemplateVersionFactoryMock = { + create: sinon.stub() + }; /* eslint-disable global-require */ plugin = require('../../plugins/pipelines'); @@ -281,6 +289,8 @@ describe('pipeline plugin test', () => { tokenFactory: tokenFactoryMock, bannerFactory: bannerFactoryMock, secretFactory: secretFactoryMock, + pipelineTemplateFactory: pipelineTemplateFactoryMock, + pipelineTemplateVersionFactory: pipelineTemplateVersionFactoryMock, ecosystem: { badges: '{{subject}}/{{status}}/{{color}}' }