From e082dd1bfd0094b0a9a85ecd35b7929072e07d6e Mon Sep 17 00:00:00 2001 From: Dao Lam Date: Thu, 13 Jul 2017 16:35:03 -0700 Subject: [PATCH] feat: Add create and remove template tag routes --- bin/server | 4 + lib/server.js | 1 + plugins/templates/README.md | 29 ++++- plugins/templates/createTag.js | 79 ++++++++++++++ plugins/templates/index.js | 6 +- plugins/templates/removeTag.js | 65 +++++++++++ test/plugins/templates.test.js | 192 ++++++++++++++++++++++++++++++--- 7 files changed, 356 insertions(+), 20 deletions(-) create mode 100644 plugins/templates/createTag.js create mode 100644 plugins/templates/removeTag.js diff --git a/bin/server b/bin/server index 4f65f7b39..220c84304 100755 --- a/bin/server +++ b/bin/server @@ -76,6 +76,9 @@ const Models = require('screwdriver-models'); const templateFactory = Models.TemplateFactory.getInstance({ datastore }); +const templateTagFactory = Models.TemplateTagFactory.getInstance({ + datastore +}); const pipelineFactory = Models.PipelineFactory.getInstance({ datastore, scm @@ -117,6 +120,7 @@ datastore.setup() notifications: notificationConfig, ecosystem, templateFactory, + templateTagFactory, pipelineFactory, jobFactory, userFactory, diff --git a/lib/server.js b/lib/server.js index 29e9864dd..32a0798ad 100644 --- a/lib/server.js +++ b/lib/server.js @@ -80,6 +80,7 @@ module.exports = (config) => { // Instantiating the server with the factories will apply a shallow copy server.app = { templateFactory: config.templateFactory, + templateTagFactory: config.templateTagFactory, pipelineFactory: config.pipelineFactory, jobFactory: config.jobFactory, userFactory: config.userFactory, diff --git a/plugins/templates/README.md b/plugins/templates/README.md index f24fc9f42..f33109a4b 100644 --- a/plugins/templates/README.md +++ b/plugins/templates/README.md @@ -32,18 +32,18 @@ server.register({ `GET /templates?page={pageNumber}&count={countNumber}` -#### Get single template +#### Get a single template `GET /templates/{id}` #### Create a template -Create a template will store the template data (`config`, `name`, `version`, `description`, `maintainer`, `labels`) into the datastore. +Creating a template will store the template data (`config`, `name`, `version`, `description`, `maintainer`, `labels`) into the datastore. If the exact template and version already exist, the only thing that can be changed is `labels`. If the template already exists but not the version, the new version will be stored provided that the build has correct permissions. -This endpoint is only accessible in `build` scope. +*Note: This endpoint is only accessible in `build` scope and the permission is tied to the pipeline that first creates the template.* `POST /templates` @@ -73,3 +73,26 @@ Example payload: } } ``` + +#### Create a tag for a template version + +Tagging a template version allows fetching on template version by tag. For example, tag `mytemplate@1.1.0` as `stable`. + +*Note: This endpoint is only accessible in `build` scope and the permission is tied to the pipeline that creates the template.* + +`POST /templates/tags` with the following payload + +* `name` - Name of the template (ex: `mytemplate`) +* `version` - Version of the template (ex: `1.1.0`) +* `tag` - Name of the tag (ex: `stable`) + +#### Delete a template tag + +Delete the template tag. This does not delete the template itself. + +*Note: This endpoint is only accessible in `build` scope and the permission is tied to the pipeline that creates the template.* + +`DELETE /templates/tags` with the following payload + +* `name` - Name of the template (ex: `mytemplate`) +* `tag` - Name of the tag (ex: `stable`) diff --git a/plugins/templates/createTag.js b/plugins/templates/createTag.js new file mode 100644 index 000000000..df7745fef --- /dev/null +++ b/plugins/templates/createTag.js @@ -0,0 +1,79 @@ +'use strict'; + +const boom = require('boom'); +const schema = require('screwdriver-data-schema'); +const urlLib = require('url'); + +/* Currently, only build scope is allowed to tag template due to security reasons. + * The same pipeline that publishes the template has the permission to tag it. + */ +module.exports = () => ({ + method: 'PUT', + path: '/templates/tags', + config: { + description: 'Add or update a template tag', + notes: 'Add or update a specific template', + tags: ['api', 'templates'], + auth: { + strategies: ['token', 'session'], + scope: ['build'] + }, + plugins: { + 'hapi-swagger': { + security: [{ token: [] }] + } + }, + handler: (request, reply) => { + const pipelineFactory = request.server.app.pipelineFactory; + const templateFactory = request.server.app.templateFactory; + const templateTagFactory = request.server.app.templateTagFactory; + const pipelineId = request.auth.credentials.pipelineId; + const config = request.payload; + + return Promise.all([ + pipelineFactory.get(pipelineId), + templateFactory.get({ + name: config.name, + version: config.version + }), + templateTagFactory.get({ + name: config.name, + tag: config.tag + }) + ]).then(([pipeline, template, templateTag]) => { + // If template doesn't exist, throw error + if (!template) { + throw boom.notFound(`Template ${config.name}@${config.version} not found`); + } + + // If template exists, but this build's pipelineId is not the same as template's pipelineId + // Then this build does not have permission to tag the template + if (pipeline.id !== template.pipelineId) { + throw boom.unauthorized('Not allowed to tag this template'); + } + + // If template tag exists, then the only thing it can update is the version + if (templateTag) { + templateTag.version = config.version; + + return templateTag.update().then(tag => reply(tag.toJson()).code(200)); + } + + // If template exists, then create the tag + return templateTagFactory.create(config).then((tag) => { + const location = urlLib.format({ + host: request.headers.host, + port: request.headers.port, + protocol: request.server.info.protocol, + pathname: `${request.path}/${tag.id}` + }); + + return reply(tag.toJson()).header('Location', location).code(201); + }); + }).catch(err => reply(boom.wrap(err))); + }, + validate: { + payload: schema.models.templateTag.create + } + } +}); diff --git a/plugins/templates/index.js b/plugins/templates/index.js index 67cdb0868..01702e7c1 100644 --- a/plugins/templates/index.js +++ b/plugins/templates/index.js @@ -1,9 +1,11 @@ 'use strict'; const createRoute = require('./create'); +const createTagRoute = require('./createTag'); const getRoute = require('./get'); const listRoute = require('./list'); const listVersionsRoute = require('./listVersions'); +const removeTagRoute = require('./removeTag'); /** * Template API Plugin @@ -15,9 +17,11 @@ const listVersionsRoute = require('./listVersions'); exports.register = (server, options, next) => { server.route([ createRoute(), + createTagRoute(), getRoute(), listRoute(), - listVersionsRoute() + listVersionsRoute(), + removeTagRoute() ]); next(); diff --git a/plugins/templates/removeTag.js b/plugins/templates/removeTag.js new file mode 100644 index 000000000..42ac58760 --- /dev/null +++ b/plugins/templates/removeTag.js @@ -0,0 +1,65 @@ +'use strict'; + +const boom = require('boom'); +const schema = require('screwdriver-data-schema'); + +/* Currently, only build scope is allowed to tag template due to security reasons. + * The same pipeline that publishes the template has the permission to tag it. + */ +module.exports = () => ({ + method: 'DELETE', + path: '/templates/tags', + config: { + description: 'Delete a template tag', + notes: 'Delete a specific template', + tags: ['api', 'templates'], + auth: { + strategies: ['token', 'session'], + scope: ['build'] + }, + plugins: { + 'hapi-swagger': { + security: [{ token: [] }] + } + }, + handler: (request, reply) => { + const pipelineFactory = request.server.app.pipelineFactory; + const templateFactory = request.server.app.templateFactory; + const templateTagFactory = request.server.app.templateTagFactory; + const pipelineId = request.auth.credentials.pipelineId; + const config = request.payload; + + return templateTagFactory.get({ + name: config.name, + tag: config.tag + }) + .then((templateTag) => { + if (!templateTag) { + throw boom.notFound('Template tag does not exist'); + } + + return Promise.all([ + pipelineFactory.get(pipelineId), + templateFactory.get({ + name: config.name, + version: templateTag.version + }) + ]) + .then(([pipeline, template]) => { + // Check for permission + if (pipeline.id !== template.pipelineId) { + throw boom.unauthorized('Not allowed to delete this template tag'); + } + + // Remove the template tag, not the template + return templateTag.remove(); + }); + }) + .then(() => reply().code(204)) + .catch(err => reply(boom.wrap(err))); + }, + validate: { + payload: schema.models.templateTag.remove + } + } +}); diff --git a/test/plugins/templates.test.js b/test/plugins/templates.test.js index a15c262be..f45f721af 100644 --- a/test/plugins/templates.test.js +++ b/test/plugins/templates.test.js @@ -20,40 +20,33 @@ const TEMPLATE_DESCRIPTION = [ sinon.assert.expose(assert, { prefix: '' }); -const decorateTemplateMock = (template) => { - const mock = hoek.clone(template); +const decorateObj = (obj) => { + const mock = hoek.clone(obj); - mock.toJson = sinon.stub().returns(template); - - return mock; -}; - -const decoratePipelineMock = (template) => { - const mock = hoek.clone(template); - - mock.toJson = sinon.stub().returns(template); + mock.toJson = sinon.stub().returns(obj); return mock; }; const getTemplateMocks = (templates) => { if (Array.isArray(templates)) { - return templates.map(decorateTemplateMock); + return templates.map(decorateObj); } - return decorateTemplateMock(templates); + return decorateObj(templates); }; const getPipelineMocks = (pipelines) => { if (Array.isArray(pipelines)) { - return pipelines.map(decoratePipelineMock); + return pipelines.map(decorateObj); } - return decoratePipelineMock(pipelines); + return decorateObj(pipelines); }; describe('template plugin test', () => { let templateFactoryMock; + let templateTagFactoryMock; let pipelineFactoryMock; let plugin; let server; @@ -69,7 +62,13 @@ describe('template plugin test', () => { templateFactoryMock = { create: sinon.stub(), list: sinon.stub(), - getTemplate: sinon.stub() + getTemplate: sinon.stub(), + get: sinon.stub() + }; + templateTagFactoryMock = { + create: sinon.stub(), + get: sinon.stub(), + remove: sinon.stub() }; pipelineFactoryMock = { get: sinon.stub() @@ -81,6 +80,7 @@ describe('template plugin test', () => { server = new hapi.Server(); server.app = { templateFactory: templateFactoryMock, + templateTagFactory: templateTagFactoryMock, pipelineFactory: pipelineFactoryMock }; server.connection({ @@ -359,4 +359,164 @@ describe('template plugin test', () => { }); }); }); + + describe('DELETE /templates/tags', () => { + let options; + let templateMock; + let pipelineMock; + const payload = { + name: 'testtemplate', + tag: 'stable' + }; + const testTemplateTag = decorateObj(hoek.merge({ + id: 1, + remove: sinon.stub().resolves(null) + }, payload)); + + beforeEach(() => { + options = { + method: 'DELETE', + url: '/templates/tags', + payload, + credentials: { + scope: ['build'] + } + }; + + templateMock = getTemplateMocks(testtemplate); + templateFactoryMock.get.resolves(templateMock); + + templateTagFactoryMock.get.resolves(testTemplateTag); + + pipelineMock = getPipelineMocks(testpipeline); + pipelineFactoryMock.get.resolves(pipelineMock); + }); + + it('returns 401 when pipelineId does not match', () => { + templateMock.pipelineId = 8888; + + return server.inject(options).then((reply) => { + assert.equal(reply.statusCode, 401); + }); + }); + + it('returns 404 when template tag does not exist', () => { + templateTagFactoryMock.get.resolves(null); + + return server.inject(options).then((reply) => { + assert.equal(reply.statusCode, 404); + }); + }); + + it('deletes template tag if has good permission and tag exists', () => + server.inject(options).then((reply) => { + assert.calledOnce(testTemplateTag.remove); + assert.equal(reply.statusCode, 204); + })); + }); + + describe('PUT /templates/tags', () => { + let options; + let templateMock; + let pipelineMock; + const payload = { + name: 'testtemplate', + tag: 'stable', + version: '1.2.0' + }; + const testTemplateTag = decorateObj(hoek.merge({ id: 1 }, payload)); + + beforeEach(() => { + options = { + method: 'PUT', + url: '/templates/tags', + payload, + credentials: { + scope: ['build'] + } + }; + + templateMock = getTemplateMocks(testtemplate); + templateFactoryMock.get.resolves(templateMock); + + templateTagFactoryMock.get.resolves(null); + + pipelineMock = getPipelineMocks(testpipeline); + pipelineFactoryMock.get.resolves(pipelineMock); + }); + + it('returns 401 when pipelineId does not match', () => { + templateMock.pipelineId = 8888; + + return server.inject(options).then((reply) => { + assert.equal(reply.statusCode, 401); + }); + }); + + it('returns 404 when template does not exist', () => { + templateFactoryMock.get.resolves(null); + + return server.inject(options).then((reply) => { + assert.equal(reply.statusCode, 404); + }); + }); + + it('creates template tag if has good permission and tag does not exist', () => { + templateTagFactoryMock.create.resolves(testTemplateTag); + + return server.inject(options).then((reply) => { + const expectedLocation = { + host: reply.request.headers.host, + port: reply.request.headers.port, + protocol: reply.request.server.info.protocol, + pathname: `${options.url}/1` + }; + + assert.deepEqual(reply.result, hoek.merge({ id: 1 }, payload)); + assert.strictEqual(reply.headers.location, urlLib.format(expectedLocation)); + assert.calledWith(templateFactoryMock.get, { + name: 'testtemplate', + version: '1.2.0' + }); + assert.calledWith(templateTagFactoryMock.get, { + name: 'testtemplate', + tag: 'stable' + }); + assert.calledWith(templateTagFactoryMock.create, payload); + assert.equal(reply.statusCode, 201); + }); + }); + + it('update template tag if has good permission and tag exists', () => { + const template = hoek.merge({ + update: sinon.stub().resolves(testTemplateTag) + }, testTemplateTag); + + templateTagFactoryMock.get.resolves(template); + + return server.inject(options).then((reply) => { + assert.calledWith(templateFactoryMock.get, { + name: 'testtemplate', + version: '1.2.0' + }); + assert.calledWith(templateTagFactoryMock.get, { + name: 'testtemplate', + tag: 'stable' + }); + assert.calledOnce(template.update); + assert.notCalled(templateTagFactoryMock.create); + assert.equal(reply.statusCode, 200); + }); + }); + + it('returns 500 when the template tag model fails to create', () => { + const testError = new Error('templateModelCreateError'); + + templateTagFactoryMock.create.rejects(testError); + + return server.inject(options).then((reply) => { + assert.equal(reply.statusCode, 500); + }); + }); + }); });