Skip to content
This repository has been archived by the owner on Mar 22, 2024. It is now read-only.

Commit

Permalink
Merge pull request #526 from Financial-Times/features/review-app
Browse files Browse the repository at this point in the history
Add `nht review-app` task
  • Loading branch information
taktran authored Oct 29, 2018
2 parents 4308714 + 15341f4 commit 657737d
Show file tree
Hide file tree
Showing 6 changed files with 256 additions and 27 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ In order to use `n-heroku-tools` the following commands are available in your co
configure [options] [source] [target] gets environment variables from Vault and uploads them to the current app
scale [options] [source] [target] downloads process information from next-service-registry and scales/sizes the application servers
provision [options] [app] provisions a new instance of an application server
review-app [options] [app] create a heroku review app and print out the app name created
destroy [options] [app] deletes the app from heroku
deploy-hashed-assets [options] deploys hashed asset files to S3 (if AWS keys set correctly)
deploy-static [options] <source> [otherSources...] Deploys static <source> to S3. Requires AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY env vars
Expand Down
1 change: 1 addition & 0 deletions bin/n-heroku-tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ require('../tasks/float')(program, utils);
require('../tasks/drydock')(program, utils);
require('../tasks/smoke')(program, utils);
require('../tasks/gtg')(program, utils);
require('../tasks/review-app')(program, utils);

program
.command('*')
Expand Down
24 changes: 24 additions & 0 deletions lib/heroku-api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
function herokuApi ({ endpoint, authToken }) {
return fetch(
`https://api.heroku.com${endpoint}`,
{
headers: {
Accept: 'Accept: application/vnd.heroku+json; version=3',
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`
}
}
).then(response => {
const { ok, status, statusText } = response;
if (!ok) {
let err = new Error(`BadResponse: ${status} ${statusText}`);
err.name = 'BAD_RESPONSE';
err.status = status;
throw err;
}

return response.json();
});
}

module.exports = herokuApi;
53 changes: 26 additions & 27 deletions lib/pipelines.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,41 @@
const herokuAuthToken = require('./heroku-auth-token');
const co = require('co');
const shellpromise = require('shellpromise');
const api = require('./heroku-api');
const spawn = require('child_process').spawn;

function api (url, token){
return fetch(
'https://api.heroku.com' + url,
{
headers: {
'Accept': 'Accept: application/vnd.heroku+json; version=3',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
}
}
).then(response => {
if(!response.ok){
let err = new Error(`BadResponse: ${response.status} ${response.statusText}`);
err.name = 'BAD_RESPONSE';
err.status = response.status;
throw err;
}

return response.json();
async function info (pipelineName){
const authToken = await herokuAuthToken();
const pipeline = await api({
endpoint: `/pipelines/${pipelineName}`,
authToken
});

return pipeline;
}

function getApps (pipelineName){
return co(function* (){
let token = yield herokuAuthToken();
let pipelines = yield api('/pipelines', token);
let authToken = yield herokuAuthToken();
let pipelines = yield api({
endpoint: '/pipelines',
authToken
});
let pipeline = pipelines.find(p => p.name === pipelineName);
if(!pipeline){
throw new Error('Could not find pipeline ' + pipeline);
}

let couplings = yield api('/pipelines/' + pipeline.id + '/pipeline-couplings', token);
let couplings = yield api({
endpoint: `/pipelines/${pipeline.id}/pipeline-couplings`,
authToken
});
let result = { staging:null, production:{ us:null,eu:null }, all:[] };
for(let coupling of couplings){
let app = yield api('/apps/' + coupling.app.id, token);
let app = yield api({
endpoint: `/apps/${coupling.app.id}`,
authToken
});
result.all.push(app.name);
if(coupling.stage === 'staging'){
result.staging = app.name;
Expand Down Expand Up @@ -91,9 +89,10 @@ function destroyPipeline (pipeline, silent){
}

module.exports = {
getApps: getApps,
promote: promote,
create: create,
addAppToPipeline: addAppToPipeline,
info,
getApps,
promote,
create,
addAppToPipeline,
destroy: destroyPipeline
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"mime": "^1.3.4",
"node-vault": "^0.5.6",
"nodemon": "^1.11.0",
"p-retry": "^2.0.0",
"semver": "^5.0.3",
"shellpromise": "^1.0.0"
},
Expand Down
203 changes: 203 additions & 0 deletions tasks/review-app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
const pRetry = require('p-retry');

const herokuAuthToken = require('../lib/heroku-auth-token');
const { info: pipelineInfo } = require('../lib/pipelines');
const REVIEW_APPS_URL = 'https://api.heroku.com/review-apps';

const DEFAULT_HEADERS = {
'Accept': 'application/vnd.heroku+json; version=3',
'Content-Type': 'application/json'
};

const NUM_RETRIES = 30;
const RETRY_EXP_BACK_OFF_FACTOR = 1;
const RETRY_INTERVAL = 10 * 1000;
const REVIEW_APP_STATUSES = {
pending: 'pending',
deleted: 'deleted',
creating: 'creating',
created: 'created'
};

const getReviewAppUrl = reviewAppId => `https://api.heroku.com/review-apps/${reviewAppId}`;
const getPipelineReviewAppsUrl = pipelineId => `https://api.heroku.com/pipelines/${pipelineId}/review-apps`;
const getAppUrl = appId => `https://api.heroku.com/apps/${appId}`;
const getGithubArchiveUrl = ({ repoName, branch }) => `https://api.github.com/repos/Financial-Times/${repoName}/tarball/${branch}`;

function herokuHeaders ({ useReviewAppApi } = {}) {
const defaultHeaders = useReviewAppApi
? Object.assign({}, DEFAULT_HEADERS, {
Accept: 'application/vnd.heroku+json; version=3.review-apps',
})
: DEFAULT_HEADERS;
return herokuAuthToken()
.then(key => {
return {
...defaultHeaders,
Authorization: `Bearer ${key}`
};
});
}

const throwIfNotOk = async res => {
const { ok, status, url } = res;
if (!ok) {
const errorBody = await res.json();

console.error('Fetch error:', status, url, errorBody); // eslint-disable-line no-console
throw errorBody;
}
return res;
};

const getGithubArchiveRedirectUrl = ({ repoName, branch, githubToken }) => {
const url = getGithubArchiveUrl({ repoName, branch });

return fetch(url, {
headers: {
Authorization: `token ${githubToken}`
},
redirect: 'manual' // Don't follow redirect, just want the URL
}).then(res => {
if (res.status !== 302) {
throw new Error(`Unexpected response for ${url} (${status})`);
}

const { headers: { _headers: { location } } } = res;
const [ redirectUrl ] = location || [];

return redirectUrl;
});
};

const waitTillReviewAppCreated = (data) => {
const { id } = data;
const checkForCreatedStatus = async () => {
const headers = await herokuHeaders({ useReviewAppApi: true });
const result = await fetch(getReviewAppUrl(id), {
headers
})
.then(throwIfNotOk)
.then(res => res.json())
.then(data => {
const { status, message, app } = data;

if (status === REVIEW_APP_STATUSES.deleted) {
throw new pRetry.AbortError(`Review app was deleted: ${message}`);
}

if (status !== REVIEW_APP_STATUSES.created) {
const appIdOutput = (status === REVIEW_APP_STATUSES.creating)
? `, appId: ${app.id}`
: '';
throw new Error(`Review app not created yet. Current status: ${status}${appIdOutput}`);
};

return app.id;
});
return result;
};

return pRetry(checkForCreatedStatus, {
factor: RETRY_EXP_BACK_OFF_FACTOR,
retries: NUM_RETRIES,
minTimeout: RETRY_INTERVAL,
onFailedAttempt: (err) => {
const { attemptNumber, message } = err;
console.error(`${attemptNumber}/${NUM_RETRIES}: ${message}`); // eslint-disable-line no-console
}
});
};

const getAppName = async (appId) => {
const headers = await herokuHeaders();
return fetch(getAppUrl(appId), {
headers
})
.then(throwIfNotOk)
.then(res => res.json())
.then((result) => {
const { name } = result;
return name;
});
};

const deleteGitBranchReviewApp = ({ pipelineId, branch, headers }) => {
const getReviewAppId = (pipelineId) => fetch(getPipelineReviewAppsUrl(pipelineId), {
headers
})
.then(throwIfNotOk)
.then(res => res.json())
.then((reviewApps = []) =>
reviewApps.find(
({ branch: reviewAppBranch }) => branch === reviewAppBranch)
)
.then(({ id }) => id);
const deleteReviewApp = (reviewAppId) => fetch(getReviewAppUrl(reviewAppId), {
headers,
method: 'delete'
}).then(throwIfNotOk);

return getReviewAppId(pipelineId).then(deleteReviewApp);
};

async function task (app, options) {
const { repoName, branch, commit, githubToken } = options;

const { id: pipelineId } = await pipelineInfo(app);
const headers = await herokuHeaders({ useReviewAppApi: true });
const body = {
pipeline: pipelineId,
branch,
source_blob: {
url: await getGithubArchiveRedirectUrl({ repoName, branch, githubToken }),
version: commit
}
};
const createReviewApp = () => fetch(REVIEW_APPS_URL, {
headers,
method: 'post',
body: JSON.stringify(body)
});

return createReviewApp()
.then(res => {
const { status } = res;
if (status === 409) {
console.error(`Review app already created for ${branch} branch. Deleting existing review app first.`); // eslint-disable-line no-console
return deleteGitBranchReviewApp({ pipelineId, branch, headers })
.then(createReviewApp);
}
return res;
})
.then(throwIfNotOk)
.then(res => res.json())
.then(waitTillReviewAppCreated)
.then(getAppName)
.then(appName => {
console.log(appName); // eslint-disable-line no-console
});
}

/**
* Assume
* * app is VAULT_SOURCE, and is package.json name (could assume it's the package.json name, like `nht configure`)
*/
module.exports = function (program) {
program
.command('review-app [app]')
.description('Create a heroku review app and print out the app name created')
.option('-r, --repo-name <name>', 'github repository name')
.option('-b, --branch <name>', 'branch of the review app')
.option('-c, --commit <commit>', 'commit SHA-1')
.option('-g, --github-token <token>', 'github personal token to access source code (generate from https://github.com/settings/tokens)')
.action(async function (app, options) {
try {
await task(app, options);
} catch (error) {
console.error(error); // eslint-disable-line no-console
process.exit(1);
return;
}
});
};

0 comments on commit 657737d

Please sign in to comment.