diff --git a/bin/server b/bin/server index aee338254..fed8701f4 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 @@ -120,6 +123,7 @@ datastore.setup() notifications: notificationConfig, ecosystem, templateFactory, + templateTagFactory, pipelineFactory, jobFactory, userFactory, diff --git a/lib/server.js b/lib/server.js index 248476bd5..a4b96bf2e 100644 --- a/lib/server.js +++ b/lib/server.js @@ -88,6 +88,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 5424b4ed7..275db75dd 100644 --- a/plugins/templates/README.md +++ b/plugins/templates/README.md @@ -27,18 +27,18 @@ server.register({ ### Routes -#### Get all templates -`page` and `count` optional +#### Template +##### Get all templates -`GET /templates?page={pageNumber}&count={countNumber}` +`GET /templates` -#### Get single template +##### Get a single template You can get a single template by providing the template name and the specific version or the tag. `GET /templates/{name}/{tag}` or `GET /templates/{name}/{version}` -**Arguments** +###### Arguments 'name', 'tag' or 'version' @@ -46,18 +46,16 @@ You can get a single template by providing the template name and the specific ve * `tag` - Tag of the template (e.g. `stable`, `latest`, etc) * `version` - Version of the template -#### Create a template -Create a template will store the template data (`config`, `name`, `version`, `description`, `maintainer`, `labels`) into the datastore. +##### Create a template +Creating a template will store the template data (`config`, `name`, `version`, `description`, `maintainer`) into the datastore. -If the exact template and version already exist, the only thing that can be changed is `labels`. +`version` will be auto-bumped. For example, if `mytemplate@1.0.0` already exists and the version passed in is `1.0.0`, the newly created template will be version `1.0.1`. -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` -**Arguments** +###### Arguments 'name', 'version', 'description', 'maintainer', labels @@ -83,3 +81,24 @@ Example payload: } } ``` + +#### Template Tag +Template tag allows fetching on template version by tag. For example, tag `mytemplate@1.1.0` as `stable`. + +##### 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`) + +##### Delete a 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/{templateName}/tags/{tagName}` diff --git a/plugins/templates/createTag.js b/plugins/templates/createTag.js new file mode 100644 index 000000000..7f28475d1 --- /dev/null +++ b/plugins/templates/createTag.js @@ -0,0 +1,84 @@ +'use strict'; + +const boom = require('boom'); +const joi = require('joi'); +const schema = require('screwdriver-data-schema'); +const baseSchema = schema.models.templateTag.base; +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/{templateName}/tags/{tagName}', + 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 name = request.params.templateName; + const tag = request.params.tagName; + const version = request.payload.version; + + return Promise.all([ + pipelineFactory.get(pipelineId), + templateFactory.get({ name, version }), + templateTagFactory.get({ name, tag }) + ]).then(([pipeline, template, templateTag]) => { + // If template doesn't exist, throw error + if (!template) { + throw boom.notFound(`Template ${name}@${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 = version; + + return templateTag.update().then(newTag => reply(newTag.toJson()).code(200)); + } + + // If template exists, then create the tag + return templateTagFactory.create({ name, tag, version }) + .then((newTag) => { + const location = urlLib.format({ + host: request.headers.host, + port: request.headers.port, + protocol: request.server.info.protocol, + pathname: `${request.path}/${newTag.id}` + }); + + return reply(newTag.toJson()).header('Location', location).code(201); + }); + }).catch(err => reply(boom.wrap(err))); + }, + validate: { + params: { + templateName: joi.reach(baseSchema, 'name'), + tagName: joi.reach(baseSchema, 'tag') + }, + payload: { + version: joi.reach(baseSchema, 'version') + } + } + } +}); 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..e2c8c895a --- /dev/null +++ b/plugins/templates/removeTag.js @@ -0,0 +1,68 @@ +'use strict'; + +const boom = require('boom'); +const joi = require('joi'); +const schema = require('screwdriver-data-schema'); +const baseSchema = schema.models.templateTag.base; + +/* 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/{templateName}/tags/{tagName}', + 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 name = request.params.templateName; + const tag = request.params.tagName; + + return templateTagFactory.get({ name, tag }) + .then((templateTag) => { + if (!templateTag) { + throw boom.notFound('Template tag does not exist'); + } + + return Promise.all([ + pipelineFactory.get(pipelineId), + templateFactory.get({ + 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: { + params: { + templateName: joi.reach(baseSchema, 'name'), + tagName: joi.reach(baseSchema, 'tag') + } + } + } +}); diff --git a/test/plugins/templates.test.js b/test/plugins/templates.test.js index 463814650..e686d2813 100644 --- a/test/plugins/templates.test.js +++ b/test/plugins/templates.test.js @@ -20,36 +20,28 @@ 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', () => { @@ -70,10 +62,13 @@ describe('template plugin test', () => { templateFactoryMock = { create: sinon.stub(), list: sinon.stub(), - getTemplate: sinon.stub() + getTemplate: sinon.stub(), + get: sinon.stub() }; templateTagFactoryMock = { - get: sinon.stub() + create: sinon.stub(), + get: sinon.stub(), + remove: sinon.stub() }; pipelineFactoryMock = { get: sinon.stub() @@ -401,4 +396,163 @@ describe('template plugin test', () => { }); }); }); + + describe('DELETE /templates/tags', () => { + let options; + let templateMock; + let pipelineMock; + const testTemplateTag = decorateObj({ + id: 1, + name: 'testtemplate', + tag: 'stable', + remove: sinon.stub().resolves(null) + }); + + beforeEach(() => { + options = { + method: 'DELETE', + url: '/templates/testtemplate/tags/stable', + 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 = { + version: '1.2.0' + }; + const testTemplateTag = decorateObj(hoek.merge({ id: 1 }, payload)); + + beforeEach(() => { + options = { + method: 'PUT', + url: '/templates/testtemplate/tags/stable', + 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, { + name: 'testtemplate', + tag: 'stable', + version: '1.2.0' + }); + 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); + }); + }); + }); });