UNPKG

generator-vsts

Version:

Generates an app with CI/CD in Visual Studio Team Services

744 lines (645 loc) 25.8 kB
// This is the code that deals with VSTS const fs = require('fs'); const url = require('url'); const path = require('path'); const async = require('async'); const https = require('https'); const request = require('request'); const util = require('./utility.js'); const BUILD_API_VERSION = '2.0'; const PROJECT_API_VERSION = '1.0'; const RELEASE_API_VERSION = '3.0-preview.1'; const DISTRIBUTED_TASK_API_VERSION = '3.0-preview.1'; const SERVICE_ENDPOINTS_API_VERSION = '3.0-preview.1'; function run(args, gen, done) { 'use strict'; var build = {}; var approverId; var queueId = 0; var release = {}; var azureSub = {}; var teamProject = {}; var azureEndpoint = {}; var approverUniqueName; var approverDisplayName; var dockerEndpoint = {}; var dockerRegistryEndpoint = {}; var token = encodePat(args.pat); async.series([ function (mainSeries) { findOrCreateProject(args.vsts, args.project, token, gen, function (err, tp) { teamProject = tp; mainSeries(err, tp); }); }, function (mainSeries) { async.parallel([ function (inParallel) { findQueue(args.queue, args.vsts, teamProject, token, gen, function (err, id) { queueId = id; gen.log(`+ Found agent queue`); inParallel(err, id); }); }, function (inParallel) { findAzureSub(args.vsts, args.azureSub, token, gen, function (err, sub) { azureSub = sub; if (sub === undefined) { err = { message: `${args.azureSub} Azure subscription not found` }; gen.log.error(`${args.azureSub} Azure subscription not found.`); } else { gen.log(`+ Found ${args.azureSub} Azure subscription`); } inParallel(err, sub); }); } ], mainSeries); }, function (mainSeries) { async.parallel([ function (inParallel) { findOrCreateDockerServiceEndpoint(args.vsts, teamProject.id, args.dockerHost, args.dockerCertPath, token, gen, function (err, ep) { dockerEndpoint = ep; inParallel(err, dockerEndpoint); }); }, function (inParallel) { findOrCreateDockerRegistryServiceEndpoint(args.vsts, teamProject.id, args.dockerRegistry, args.dockerRegistryId, args.dockerRegistryPassword, args.dockerRegistryEmail, token, gen, function (err, ep) { dockerRegistryEndpoint = ep; inParallel(err, dockerRegistryEndpoint); }); } ], mainSeries); }, function (mainSeries) { async.parallel([ function (inParallel) { findOrCreateBuild(args.vsts, teamProject, token, queueId, dockerEndpoint, dockerRegistryEndpoint, args.dockerRegistryId, args.buildJson, args.target, gen, function (err, bld) { build = bld; approverId = bld.authoredBy.id; approverDisplayName = bld.authoredBy.displayName; approverUniqueName = bld.authoredBy.uniqueName; inParallel(err, bld); }); }, function (inParallel) { findOrCreateAzureServiceEndpoint(args.vsts, teamProject.id, azureSub, token, gen, function (err, ep) { azureEndpoint = ep; inParallel(err, azureEndpoint); }); } ], mainSeries); }, function (mainSeries) { var relArgs = { token: token, build: build, queueId: queueId, account: args.vsts, target: args.target, appName: args.appName, approverId: approverId, teamProject: teamProject, template: args.releaseJson, endpoint: azureEndpoint.id, dockerHostEndpoint: dockerEndpoint, approverUniqueName: approverUniqueName, dockerRegistryId: args.dockerRegistryId, approverDisplayName: approverDisplayName, dockerRegistryEndpoint: dockerRegistryEndpoint, }; findOrCreateRelease(relArgs, gen, function (err, rel) { mainSeries(err, rel); }); } ], function (err, results) { // This is just for test and will be undefined during normal use if (done) { done(err); } if (err) { // To get the stacktrace run with the --debug built-in option when // running the generator. gen.env.error(err.message); } }); } function findOrCreateProject(account, project, token, gen, callback) { 'use strict'; var options = { method: 'GET', headers: { 'cache-control': 'no-cache', 'authorization': `Basic ${token}` }, url: `https://${account}.visualstudio.com/DefaultCollection/_apis/projects/${project}`, qs: { 'api-version': PROJECT_API_VERSION } }; request(options, function (err, res, body) { if (err) { callback(err); return; } // Test for this before you try and parse the body. // When a 203 is returned the body is HTML instead of // JSON and will throw an exception if you try and parse. if (res.statusCode === 203) { // You get this when the site tries to send you to the // login page. gen.log.error('Unable to authenticate with Team Services. Check account name and personal access token.'); callback({ message: 'Unable to authenticate with Team Services. Check account name and personal access token.' }); return; } var obj = JSON.parse(body); if (res.statusCode === 404) { gen.log(`+ Creating ${project} Team Project`); var teamProject = {}; // Create the project // Use series to issue the REST API to create the project, // wait for it to be created or fail, and get the final id. async.series([ function (thisSeries) { createProject(account, project, token, gen, function (err, project) { teamProject = project; thisSeries(err); }); }, function (thisSeries) { var status = ''; // Wait for Team Services to report that the project was created. // Use whilst to keep calling the the REST API until the status is // either failed or succeeded. async.whilst( function () { return status !== 'failed' && status !== 'succeeded'; }, function (finished) { checkStatus(teamProject.url, token, gen, function (err, stat) { status = stat.status; finished(err); }); }, thisSeries ); }, function (thisSeries) { // Get the real id of the team project now that is exist. request(options, function (err, res, body) { if (err) { thisSeries(err); return; } if (res.statusCode !== 200) { gen.log.error('Unable to find newly created project.'); thisSeries({ message: 'Unable to find newly created project.' }); return; } var project = JSON.parse(body); thisSeries(err, project); }); } ], function (err, result) { // By the time I get there the series would have completed and // the first two entries in result would be null. I only want // to return the team project and not the array because when we // find the team project if it already exist we only return the // team project. callback(err, result[2]); }); } else { // Return the team project we just found. gen.log(`+ Found Team project`); callback(err, obj); } }); } function createProject(account, project, token, gen, callback) { 'use strict'; var options = { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Basic ${token}` }, json: true, url: `https://${account}.visualstudio.com/DefaultCollection/_apis/projects`, qs: { 'api-version': PROJECT_API_VERSION }, body: { name: project, capabilities: { versioncontrol: { sourceControlType: 'Git' }, processTemplate: { templateTypeId: '6b724908-ef14-45cf-84f8-768b5384da45' } } } }; request(options, function (err, res, body) { callback(err, body); }); } // Simply issues a get to the provided URI and returns // the body as JSON. Call this when the action taken // requires time to process. function checkStatus(uri, token, gen, callback) { 'use strict'; var options = { method: 'GET', headers: { authorization: `Basic ${token}` }, url: uri }; request(options, function (err, res, body) { callback(err, JSON.parse(body)); }); } function findQueue(name, account, teamProject, token, gen, callback) { 'use strict'; var options = { method: 'GET', headers: { 'cache-control': 'no-cache', 'authorization': `Basic ${token}` }, url: `https://${account}.visualstudio.com/DefaultCollection/${teamProject.id}/_apis/distributedtask/queues`, qs: { 'api-version': DISTRIBUTED_TASK_API_VERSION, 'queueName': name } }; request(options, function (err, res, body) { var obj = JSON.parse(body); if (res.statusCode >= 400) { callback(new Error(res.statusMessage), null); } else if (res.statusCode >= 300) { // When it is a 300 the obj is a error // object from the server callback(obj); } else { // Setting to null is the all clear signal to the async // series to continue callback(null, obj.value[0].id); } }); } function findOrCreateRelease(args, gen, callback) { 'use strict'; var name = args.target === 'docker' ? `${args.appName}-Docker-CD` : `${args.appName}-CD`; var options = { method: 'GET', headers: { 'cache-control': 'no-cache', 'authorization': `Basic ${args.token}` }, url: `https://${args.account}.vsrm.visualstudio.com/DefaultCollection/${args.teamProject.id}/_apis/release/definitions`, qs: { 'api-version': RELEASE_API_VERSION } }; request(options, function (error, response, body) { var obj = JSON.parse(body); var rel = obj.value.find(function (i) { return i.name === name; }); if (rel === undefined) { createRelease(args, gen, callback); } else { gen.log(`+ Found release definition`); callback(error, rel); } }); } function createRelease(args, gen, callback) { 'use strict'; gen.log(`+ Creating CD release definition`); // Load the template and replace values. var tokens = { '{{BuildId}}': args.build.id, '"{{QueueId}}"': args.queueId, '{{WebAppName}}': args.appName, '{{BuildName}}': args.build.name, '{{ApproverId}}': args.approverId, '{{ProjectId}}': args.teamProject.id, '{{ConnectedServiceID}}': args.endpoint, '{{ProjectName}}': args.teamProject.name, '{{ApproverUniqueName}}': args.approverUniqueName, '{{ApproverDisplayName}}': args.approverDisplayName, '{{ProjectLowerCase}}': args.teamProject.name.toLowerCase(), '{{dockerHostEndpoint}}': args.dockerHostEndpoint ? args.dockerHostEndpoint.id : null, '{{dockerRegistryId}}': args.dockerRegistryId ? args.dockerRegistryId.toLowerCase() : null, '{{dockerRegistryEndpoint}}': args.dockerRegistryEndpoint ? args.dockerRegistryEndpoint.id : null, '{{ReleaseDefName}}': args.target === 'docker' ? `${args.teamProject.name}-Docker-CD` : `${args.teamProject.name}-CD` }; var contents = fs.readFileSync(args.template, 'utf8'); var options = { method: 'POST', headers: { 'cache-control': 'no-cache', 'content-type': 'application/json', 'authorization': `Basic ${args.token}` }, json: true, url: `https://${args.account}.vsrm.visualstudio.com/DefaultCollection/${args.teamProject.id}/_apis/release/definitions`, qs: { 'api-version': RELEASE_API_VERSION }, body: JSON.parse(util.tokenize(contents, tokens)) }; // I have witnessed the release returning a 403 if you try // to create it too quickly. The release REST API appears // to return 403 for several reasons and could cause an // infinite loop on this code waiting on RM to become ready. var status = ''; async.whilst( function () { return status !== 'failed' && status !== 'succeeded'; }, function (finished) { request(options, function (err, resp, body) { if (resp.statusCode == 400) { status = "failed"; finished(new Error(resp.statusMessage), null); } else if (resp.statusCode >= 300) { status = "in progress"; finished(err, null); } else { status = "succeeded"; finished(err, body); } }); }, function (err, results) { callback(err, results); } ); } function findOrCreateDockerRegistryServiceEndpoint( account, projectId, dockerRegistry, dockerRegistryId, dockerRegistryPassword, dockerRegistryEmail, token, gen, callback) { 'use strict'; // There is nothing to do if (dockerRegistry === undefined) { callback(null, null); return; } var options = { method: 'GET', headers: { 'cache-control': 'no-cache', 'authorization': `Basic ${token}` }, url: `https://${account}.visualstudio.com/${projectId}/_apis/distributedtask/serviceendpoints`, qs: { 'api-version': SERVICE_ENDPOINTS_API_VERSION } }; request(options, function (error, response, body) { var obj = JSON.parse(body); // TODO: Test that authorization.parameters.registry === dockerHost. But that requires // a second REST call once you know the ID of the dockerregistry type service endpoint. // For now assume any dockerregistry service endpoint is safe to use. var endpoint = obj.value.find(function (i) { return i.type === 'dockerregistry'; }); if (endpoint === undefined) { createDockerRegistryServiceEndpoint(account, projectId, dockerRegistry, dockerRegistryId, dockerRegistryPassword, dockerRegistryEmail, token, gen, callback); } else { gen.log('+ Found Docker Registry Service Endpoint'); callback(error, endpoint); } }); } function createDockerRegistryServiceEndpoint(account, projectId, dockerRegistry, dockerRegistryId, dockerRegistryPassword, dockerRegistryEmail, token, gen, callback) { 'use strict'; gen.log('+ Creating Docker Registry Service Endpoint'); var options = { method: 'POST', headers: { 'cache-control': 'no-cache', 'content-type': 'application/json', 'authorization': `Basic ${token}` }, json: true, url: `https://${account}.visualstudio.com/${projectId}/_apis/distributedtask/serviceendpoints`, qs: { 'api-version': SERVICE_ENDPOINTS_API_VERSION }, body: { authorization: { parameters: { email: dockerRegistryEmail, password: dockerRegistryPassword, registry: dockerRegistry, username: dockerRegistryId }, scheme: 'UsernamePassword' }, data: {}, name: 'Docker Registry', type: 'dockerregistry', url: 'http://hub.docker.com' } }; request(options, function (error, response, body) { callback(error, body); }); } function findOrCreateDockerServiceEndpoint(account, projectId, dockerHost, dockerCertPath, token, gen, callback) { 'use strict'; // There is nothing to do if (dockerHost === undefined) { callback(null, null); return; } var options = { method: 'GET', headers: { 'cache-control': 'no-cache', 'authorization': `Basic ${token}` }, url: `https://${account}.visualstudio.com/${projectId}/_apis/distributedtask/serviceendpoints`, qs: { 'api-version': SERVICE_ENDPOINTS_API_VERSION } }; request(options, function (error, response, body) { var obj = JSON.parse(body); // The i.url is returned with a trailing / so just use starts with just in case // the dockerHost is passed in without it var endpoint = obj.value.find(function (i) { return i.url.startsWith(dockerHost); }); if (endpoint === undefined) { createDockerServiceEndpoint(account, projectId, dockerHost, dockerCertPath, token, gen, callback); } else { gen.log('+ Found Docker Service Endpoint'); callback(error, endpoint); } }); } function createDockerServiceEndpoint(account, projectId, dockerHost, dockerCertPath, token, gen, callback) { 'use strict'; gen.log('+ Creating Docker Service Endpoint'); // Find the contents of the files. var ca = path.join(dockerCertPath, 'ca.pem'); var key = path.join(dockerCertPath, 'key.pem'); var cert = path.join(dockerCertPath, 'cert.pem'); var caContents, keyContents, certContents; async.map([ca, key, cert], fs.readFile, function (err, results) { caContents = results[0].toString(); keyContents = results[1].toString(); certContents = results[2].toString(); var options = { method: 'POST', headers: { 'cache-control': 'no-cache', 'content-type': 'application/json', 'authorization': `Basic ${token}` }, json: true, url: `https://${account}.visualstudio.com/${projectId}/_apis/distributedtask/serviceendpoints`, qs: { 'api-version': SERVICE_ENDPOINTS_API_VERSION }, body: { authorization: { parameters: { cacert: caContents, cert: certContents, certificate: '', key: keyContents }, scheme: 'Certificate' }, data: {}, name: 'Docker', type: 'dockerhost', url: dockerHost } }; request(options, function (error, response, body) { callback(error, body); }); }); } function findOrCreateAzureServiceEndpoint(account, projectId, sub, token, gen, callback) { 'use strict'; var options = { method: 'GET', headers: { 'cache-control': 'no-cache', 'authorization': `Basic ${token}` }, url: `https://${account}.visualstudio.com/${projectId}/_apis/distributedtask/serviceendpoints`, qs: { 'api-version': SERVICE_ENDPOINTS_API_VERSION } }; request(options, function (error, response, body) { var obj = JSON.parse(body); var endpoint = obj.value.find(function (i) { return i.data.subscriptionName === sub.displayName; }); if (endpoint === undefined) { createAzureServiceEndpoint(account, projectId, sub, token, gen, callback); } else { gen.log(`+ Found Azure Service Endpoint '${sub.displayName}'`); callback(error, endpoint); } }); } function createAzureServiceEndpoint(account, projectId, sub, token, gen, callback) { 'use strict'; gen.log(`+ Creating ${sub.displayName} Azure Service Endpoint`); var options = { method: 'POST', headers: { 'cache-control': 'no-cache', 'content-type': 'application/json', 'authorization': `Basic ${token}` }, json: true, url: `https://${account}.visualstudio.com/${projectId}/_apis/distributedtask/serviceendpoints`, qs: { 'api-version': SERVICE_ENDPOINTS_API_VERSION }, body: { authorization: { parameters: { serviceprincipalid: '', serviceprincipalkey: '', tenantid: sub.subscriptionTenantId }, scheme: 'ServicePrincipal' }, data: { subscriptionId: sub.subscriptionId, subscriptionName: sub.displayName, creationMode: 'Automatic' }, name: sub.displayName, type: 'azurerm', url: 'https://management.core.windows.net/' } }; request(options, function (err, response, obj) { if (err) { callback(err); return; } // Service endpoints are not created instantly // we need to wait for the status to be Failed or // Ready before proceeding. var url = `https://${account}.visualstudio.com/${projectId}/_apis/distributedtask/serviceendpoints/${obj.id}?api-version=${SERVICE_ENDPOINTS_API_VERSION}`; var status = ''; async.whilst( function () { return status !== 'Failed' && status !== 'Ready'; }, function (finished) { checkStatus(url, token, gen, function (err, ep) { status = ep.operationStatus.state; finished(err, ep); }); }, function (err, body) { if (body.operationStatus.state === 'Failed') { err = { message: `Failed to create Azure Service Endpoint. ${body.operationStatus.statusMessage}` }; gen.log.error(`Failed to create Azure Service Endpoint. ${body.operationStatus.statusMessage}`); } callback(err, body); } ); }); } function findAzureSub(account, subName, token, gen, callback) { 'use strict'; var options = { method: 'GET', headers: { 'cache-control': 'no-cache', 'content-type': 'application/json', 'authorization': `Basic ${token}` }, url: `https://${account}.visualstudio.com/_apis/distributedtask/serviceendpointproxy/azurermsubscriptions` }; request(options, function (error, response, body) { var obj = JSON.parse(body); var sub = obj.value.find(function (i) { return i.displayName === subName; }); callback(error, sub); }); } function findOrCreateBuild( account, teamProject, token, queueId, dockerHostEndpoint, dockerRegistryEndpoint, dockerRegistryId, filename, target, gen, callback) { 'use strict'; var name = target === 'docker' ? `${teamProject.name}-Docker-CI` : `${teamProject.name}-CI`; var options = { method: 'GET', headers: { 'cache-control': 'no-cache', 'authorization': `Basic ${token}` }, url: `https://${account}.visualstudio.com/DefaultCollection/${teamProject.id}/_apis/build/definitions`, qs: { 'api-version': BUILD_API_VERSION } }; request(options, function (error, response, body) { var obj = JSON.parse(body); var bld = obj.value.find(function (i) { return i.name === name; }); if (bld === undefined) { createBuild(account, teamProject, token, queueId, dockerHostEndpoint, dockerRegistryEndpoint, dockerRegistryId, filename, target, gen, callback); } else { gen.log(`+ Found build definition`); callback(error, bld); } }); } function createBuild(account, teamProject, token, queueId, dockerHostEndpoint, dockerRegistryEndpoint, dockerRegistryId, filename, target, gen, callback) { 'use strict'; gen.log('+ Creating CI build definition'); // Load the template and replace values. var contents = fs.readFileSync(filename, 'utf8'); var tokens = { '{{BuildDefName}}': target === 'docker' ? `${teamProject.name}-Docker-CI` : `${teamProject.name}-CI`, '{{VSTS}}': account, '{{Project}}': teamProject.name, '{{QueueId}}': queueId, '{{dockerHostEndpoint}}': dockerHostEndpoint ? dockerHostEndpoint.id : null, '{{dockerRegistryEndpoint}}': dockerRegistryEndpoint ? dockerRegistryEndpoint.id : null, '{{dockerRegistryId}}': dockerRegistryId ? dockerRegistryId.toLowerCase() : null, '{{ProjectLowerCase}}': teamProject.name.toLowerCase() }; contents = util.tokenize(contents, tokens); // Validates my contents is valid JSON and stripes all the new lines var payload = JSON.parse(contents); var options = { method: 'POST', headers: { 'cache-control': 'no-cache', 'content-type': 'application/json', 'authorization': `Basic ${token}` }, json: true, url: `https://${account}.visualstudio.com/DefaultCollection/${teamProject.id}/_apis/build/definitions`, qs: { 'api-version': BUILD_API_VERSION }, body: payload }; request(options, function (error, response, body) { callback(error, body); }); } function encodePat(pat) { var b = new Buffer(':' + pat); var s = b.toString('base64'); return s; } // // Exports the portions of the file we want to share with files that require // it. // module.exports = { run: run, encodePat: encodePat, findQueue: findQueue, findAzureSub: findAzureSub, findOrCreateBuild: findOrCreateBuild, findOrCreateRelease: findOrCreateRelease, findOrCreateProject: findOrCreateProject, findOrCreateAzureServiceEndpoint: findOrCreateAzureServiceEndpoint, findOrCreateDockerServiceEndpoint: findOrCreateDockerServiceEndpoint, findOrCreateDockerRegistryServiceEndpoint: findOrCreateDockerRegistryServiceEndpoint };