diff --git a/Makefile b/Makefile index 7aa705b7..4a593c65 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,10 @@ node_modules/@financial-times/n-gage/index.mk: -include node_modules/@financial-times/n-gage/index.mk unit-test: - export PORT=5134; mocha -r loadvars.js + jest + +unit-test-watch: + jest --watch minus-eslint: ci-n-ui-check _verify_lintspaces _verify_pa11y_testable diff --git a/README.md b/README.md index 8c764746..4e6830b0 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,11 @@ # n-heroku-tools -Yak button - This library is a command line tool that orchestrates [Heroku](https://www.heroku.com/) and [Amazon S3](https://aws.amazon.com/s3/) deployments for [Next](https://github.com/Financial-Times/next/wiki), based on configuration in the [Next service registry](https://next-registry.ft.com/v2/) and [Vault](https://www.vaultproject.io/). -
- ### Installation In order to use this tool, run + ``` npm install @financial-times/n-heroku-tools --save-dev ``` @@ -17,31 +14,22 @@ npm install @financial-times/n-heroku-tools --save-dev In order to use `n-heroku-tools` the following commands are available in your command line: - Usage: n-heroku-tools [options] [command] - - - Commands: - - deploy [options] [app] runs haikro deployment scripts with sensible defaults for Next projects - 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] [otherSources...] Deploys static to S3. Requires AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY env vars - run [options] Runs the local app through the router - rebuild [options] [apps...] Trigger a rebuild of the latest master on Circle - test-urls [options] [app] Tests that a given set of urls for an app respond as expected. Expects the config file ./test/smoke.js to exist - ship [options] Ships code. Deploys using pipelines, also running the configure and scale steps automatically - float [options] Deploys code to a test app and checks it doesn't die - drydock [options] [name] Creates a new pipeline with a staging and EU production app - smoke [options] [app] [DEPRECATED - Use n-test directly]. Tests that a given set of urls for an app respond as expected. Expects the config file ./test/smoke.js to exist - * - - Options: - - -h, --help output usage information - -V, --version output the version number +``` +Usage: n-heroku-tools [options] [command] + +Options: + -V, --version output the version number + -h, --help output usage information + +Commands: + configure [options] [source] [target] gets environment variables from Vault and uploads them to the current app + deploy-hashed-assets [options] deploys hashed asset files to S3 (if AWS keys set correctly) + deploy-static [options] [otherSources...] Deploys static to S3. Requires AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY env vars + run [options] Runs the local app through the router + rebuild [options] [apps...] Trigger a rebuild of the latest master on Circle + gtg [app] Runs gtg checks for an app + review-app [options] [appName] Create or find an existing heroku review app and print out the app name. [appName] is the package.json name (which is also the value of VAULT_NAME). On the first build of a branch, Heroku will create a review app with a build. On subsequent builds, Heroku will automatically generate a new build, which this task looks for. See https://devcenter.heroku.com/articles/review-apps-beta for more details of the internals + * +``` *Note*: The README.md is automatically generated. Run `make docs` to update it. diff --git a/ascii/canoe.ascii b/ascii/canoe.ascii deleted file mode 100644 index 0cd0b76a..00000000 --- a/ascii/canoe.ascii +++ /dev/null @@ -1,5 +0,0 @@ - \\ - \\ O, - \___________\\/ )_________/ -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ \\~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - \\ diff --git a/ascii/ship-in-bottle.ascii b/ascii/ship-in-bottle.ascii deleted file mode 100644 index 379c8ce2..00000000 --- a/ascii/ship-in-bottle.ascii +++ /dev/null @@ -1,18 +0,0 @@ - ______________________________________________ - .-' _ '. - .' |-' | - .' | | - _.' p _\_/_ p | - _.' | .' | '. | | - __..' | / | \ | | - ___..' .T\ ======+====== /T. | - ;;;\:::: .' | \ / | \ / | '. | - ;;;|:::: .' | \/ | \/ | '. | - ;;;/:::: .' | \ | \ | '. | - ''.__ .' | \ | \ | '. | - ''._ <_________|_____>_____|__________>|_________> | - '._ (___________|___________|___________|___________) | - '. \;;;Dani;;;o;;;;;o;;;;;o;;;;;o;;;;;o;;;;;o;;;;/ | - '.~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ | - '. ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ | - '-.______________________________________________.' diff --git a/ascii/yacht.ascii b/ascii/yacht.ascii deleted file mode 100644 index f3780821..00000000 --- a/ascii/yacht.ascii +++ /dev/null @@ -1,18 +0,0 @@ - . - .'| .8 - . | .8: - . | .8;: .8 - . | .8;;: | .8; - . n .8;;;: | .8;;; - . M.8;;;;;: |,8;;;;; - . .,"n8;;;;;;: |8;;;;;; - . .', n;;;;;;;: M;;;;;;;; - . ,' , n;;;;;;;;: n;;;;;;;;; - . ,' , N;;;;;;;;: n;;;;;;;;; - . ' , N;;;;;;;;;: N;;;;;;;;;; - .,' . N;;;;;;;;;: N;;;;;;;;;; - .. , N6666666666 N6666666666 - I , M M - ---nnnnn_______M___________M______mmnnn - "-. / - __________"-_______________________/_________ diff --git a/bin/n-heroku-tools.js b/bin/n-heroku-tools.js index e13abe81..ee017e4e 100755 --- a/bin/n-heroku-tools.js +++ b/bin/n-heroku-tools.js @@ -4,7 +4,6 @@ require('isomorphic-fetch'); let program = require('commander'); -let logger = require('../lib/logger'); const utils = { list: val => { @@ -12,30 +11,18 @@ const utils = { }, exit: err => { - logger.error(err); - if (err.stack) { - logger.error(err.stack); - } + console.error(err); // eslint-disable-line no-console process.exit(1); } }; program.version(require('../package.json').version); -require('../tasks/deploy')(program, utils); require('../tasks/configure')(program, utils); -require('../tasks/scale')(program, utils); -require('../tasks/provision')(program, utils); -require('../tasks/destroy')(program, utils); require('../tasks/deploy-hashed-assets')(program, utils); require('../tasks/deploy-static')(program, utils); require('../tasks/run')(program, utils); require('../tasks/rebuild')(program, utils); -require('../tasks/test-urls')(program, utils); -require('../tasks/ship')(program, utils); -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); diff --git a/lib/__mocks__/github-api.js b/lib/__mocks__/github-api.js new file mode 100644 index 00000000..2ae83c36 --- /dev/null +++ b/lib/__mocks__/github-api.js @@ -0,0 +1,5 @@ +module.exports = { + getGithubArchiveRedirectUrl: jest.fn(() => { + return Promise.resolve('https://github.com/some-tarball-link'); + }) +}; diff --git a/lib/__mocks__/shellpromise.js b/lib/__mocks__/shellpromise.js new file mode 100644 index 00000000..ca2bb694 --- /dev/null +++ b/lib/__mocks__/shellpromise.js @@ -0,0 +1,2 @@ +const HEROKU_AUTH_TOKEN = 'herokuToken123'; +module.exports = () => Promise.resolve(HEROKU_AUTH_TOKEN); diff --git a/lib/commit.js b/lib/commit.js deleted file mode 100644 index 334e87f1..00000000 --- a/lib/commit.js +++ /dev/null @@ -1,10 +0,0 @@ -'use strict'; - -let exec = require('./exec'); - -module.exports = function () { - return exec('git rev-parse HEAD') - .then(function (commit) { - return commit.trim(); - }); -}; diff --git a/lib/github-api.js b/lib/github-api.js new file mode 100644 index 00000000..2943bbd5 --- /dev/null +++ b/lib/github-api.js @@ -0,0 +1,27 @@ +const getGithubArchiveUrl = ({ repoName, branch }) => `https://api.github.com/repos/Financial-Times/${repoName}/tarball/${branch}`; + +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(async res => { + const { status } = res; + if (status !== 302) { + const error = await res.json(); + throw new Error(`Unexpected response for ${url} (${status}): ${JSON.stringify(error)}`); + } + + const { headers: { _headers: { location } } } = res; + const [ redirectUrl ] = location || []; + + return redirectUrl; + }); +}; + +module.exports = { + getGithubArchiveRedirectUrl +}; diff --git a/lib/heroku-api.js b/lib/heroku-api.js index 56432a91..bb87980d 100644 --- a/lib/heroku-api.js +++ b/lib/heroku-api.js @@ -1,17 +1,25 @@ -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}` - } +const merge = require('lodash.merge'); + +function herokuApi ({ endpoint, authToken, options = {} }) { + + const defaultFetchOptions = { + headers: { + Accept: 'application/vnd.heroku+json; version=3', + 'Content-Type': 'application/json', + Authorization: `Bearer ${authToken}` } + }; + + const fetchOptions = merge(defaultFetchOptions, options); + const url = `https://api.heroku.com${endpoint}`; + + return fetch( + url, + fetchOptions ).then(response => { const { ok, status, statusText } = response; if (!ok) { - let err = new Error(`BadResponse: ${status} ${statusText}`); + let err = new Error(`BadResponse: ${url} - ${status} ${statusText}`); err.name = 'BAD_RESPONSE'; err.status = status; throw err; diff --git a/lib/heroku-api.unit.test.js b/lib/heroku-api.unit.test.js new file mode 100644 index 00000000..ac0ffe93 --- /dev/null +++ b/lib/heroku-api.unit.test.js @@ -0,0 +1,57 @@ +require('isomorphic-fetch'); +const nock = require('nock'); +const herokuApi = require('./heroku-api'); + +describe('heroku-api', () => { + let nockScope; + beforeAll(() => { + nockScope = nock('https://api.heroku.com'); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + it('calls the endpoint', async () => { + const endpoint = '/something'; + nockScope.get(endpoint) + .reply(200, { + something: 'yes' + }); + const output = await herokuApi({ endpoint }); + + expect(output).toEqual({ + something: 'yes' + }); + }); + + it('uses the auth token', async () => { + const endpoint = '/'; + const authToken = 'some123'; + nockScope.get(endpoint) + .reply(200, function () { + const { headers } = this.req; + return { + headers + }; + }); + const { headers: { authorization } } = await herokuApi({ endpoint, authToken }); + const [ bearerToken ] = authorization; + expect(bearerToken).toEqual(`Bearer ${authToken}`); + }); + + it('throws an error on failure', async () => { + const endpoint = '/'; + const authToken = 'some123'; + nockScope.get(endpoint) + .reply(400, {}); + + try { + await herokuApi({ endpoint, authToken }); + } catch (error) { + const { name, status } = error; + expect(name).toEqual('BAD_RESPONSE'); + expect(status).toEqual(400); + }; + }); +}); diff --git a/lib/heroku-auth-token.js b/lib/heroku-auth-token.js index 47262c85..dff77017 100644 --- a/lib/heroku-auth-token.js +++ b/lib/heroku-auth-token.js @@ -1,21 +1,35 @@ 'use strict'; let shellpromise = require('shellpromise'); +let authToken; -module.exports = function () { +const getAuthFromCli = () => { + return shellpromise('heroku auth:whoami 2>/dev/null') + .then(function () { + return shellpromise('heroku auth:token 2>/dev/null'); + }) + .then(function (token) { + return token.trim(); + }) + .catch(function (err) { + console.error(err); // eslint-disable-line no-console + throw new Error('Please make sure the Heroku CLI is authenticated by running `heroku auth:token`'); + }); +}; + +module.exports = async function () { if (process.env.HEROKU_AUTH_TOKEN) { + return Promise.resolve(process.env.HEROKU_AUTH_TOKEN); + } else { - return shellpromise('heroku auth:whoami 2>/dev/null') - .then(function () { - return shellpromise('heroku auth:token 2>/dev/null'); - }) - .then(function (token) { - return token.trim(); - }) - .catch(function (err) { - console.error(err); // eslint-disable-line no-console - throw new Error('Please make sure the Heroku CLI is authenticated by running `heroku auth:token`'); - }); + + if (authToken) { + return authToken; + } else { + authToken = await getAuthFromCli(); + return authToken; + } + } }; diff --git a/lib/heroku-config-vars.js b/lib/heroku-config-vars.js new file mode 100644 index 00000000..dfd9b86a --- /dev/null +++ b/lib/heroku-config-vars.js @@ -0,0 +1,88 @@ +/** +* Retrieves and sets config vars by calling the Heroku API. +*/ +const herokuApi = require('./heroku-api'); + +class HerokuConfigVars { + + constructor (settings) { + /** + * @param target - The target app to be configured. + * @param pipelineId - Unique identifier of the Heroku pipeline. + * @param authToken - Heroku authentication token. + */ + const { target, pipelineId, authToken } = settings; + + this.authToken = authToken; + + this.headers = {}; + this.endpoint = null; + + /** + * Review apps are configured on the pipeline since we don't want to have to wait + * for the app to be created. This is done with a prototype API, which uses a + * different URL and Accept header. + * + * This prototype API currently supports the test environment only, so staging + * and prod apps are directly configured using the standard API. + * + * Prototype API docs: https://devcenter.heroku.com/articles/review-apps-beta#pipeline-config-vars + */ + + if (target === 'review-app') { + this.headers.Accept = 'application/vnd.heroku+json; version=3.pipelines'; + this.endpoint = `/pipelines/${pipelineId}/stage/review/config-vars`; + } else { + this.endpoint = `/apps/${target}/config-vars`; + } + } + + /** + * Gets the config vars from Heroku. + */ + get () { + return this.makeApiCall(); + } + + /** + * Sets the config vars in Heroku. + */ + set (patch) { + return this.makeApiCall({ + method: 'patch', + body: JSON.stringify(patch) + }); + } + + /** + * Calls the Heroku API with the correct endpoint, authentication token and headers. + * It accepts options, which are merged into one object with the headers. + * @return object - The api response containing config vars. + */ + async makeApiCall (options = {}) { + + let herokuApiResponse; + + try { + herokuApiResponse = await herokuApi({ + endpoint: this.endpoint, + authToken: this.authToken, + options: { + headers: this.headers, + ...options + } + }); + } catch(err) { + if (err.name === 'BAD_RESPONSE' && err.status === 404) { + throw new Error('The specified app does not seem to exist in Heroku'); + } else { + throw err; + } + } + + return herokuApiResponse; + } + +} + +module.exports = HerokuConfigVars; diff --git a/lib/logger.js b/lib/logger.js deleted file mode 100644 index a319a35f..00000000 --- a/lib/logger.js +++ /dev/null @@ -1,52 +0,0 @@ -'use strict'; -const colors = require('colors'); -const util = require('util'); - -const readFileSync = require('fs').readFileSync; -const SHIP_IN_BOTTLE = readFileSync(__dirname + '/../ascii/ship-in-bottle.ascii', 'utf-8'); -const YACHT = readFileSync(__dirname + '/../ascii/yacht.ascii', 'utf-8'); -const CANOE = readFileSync(__dirname + '/../ascii/canoe.ascii', 'utf-8'); - -function log (args, color){ - let msg = util.format.apply(null, args); - if(color){ - msg = colors[color](msg); - } - - console.log(msg); // eslint-disable-line no-console -} - -function logArt (art, color, replacements){ - if(process.env.CI){ - return; - } - if (replacements) { - Object.keys(replacements).forEach(k => { - art = art.replace(RegExp(`\\{${k}\\}`, 'g'), replacements[k]); - }); - } - console.log(colors[color](art)); // eslint-disable-line no-console -} - -module.exports = { - info: function () { - log([].slice.apply(arguments), 'cyan'); - }, - warn: function () { - log([].slice.apply(arguments), 'yellow'); - }, - error: function () { - log([].slice.apply(arguments), 'red'); - }, - log: function () { - log([].slice.apply(arguments)); - }, - success: function () { - log([].slice.apply(arguments), 'green'); - }, - art : { - ship : () => logArt(SHIP_IN_BOTTLE, 'green'), - yacht: () => logArt(YACHT, 'green'), - canoe: () => logArt(CANOE, 'green') - } -}; diff --git a/test/pipeline.test.js b/lib/pipelines.unit.test.js similarity index 98% rename from test/pipeline.test.js rename to lib/pipelines.unit.test.js index 82110d01..927f3dd0 100644 --- a/test/pipeline.test.js +++ b/lib/pipelines.unit.test.js @@ -1,6 +1,5 @@ 'use strict'; require('isomorphic-fetch'); -const expect = require('chai').expect; const co = require('co'); const mockery = require('mockery'); const sinon = require('sinon'); diff --git a/lib/review-apps.js b/lib/review-apps.js new file mode 100644 index 00000000..2e354edd --- /dev/null +++ b/lib/review-apps.js @@ -0,0 +1,267 @@ +const pRetry = require('p-retry'); + +const { info: pipelineInfo } = require('../lib/pipelines'); +const herokuAuthToken = require('../lib/heroku-auth-token'); +const { getGithubArchiveRedirectUrl } = require('./github-api'); + +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 = 60; +const RETRY_EXP_BACK_OFF_FACTOR = 1; +const MIN_TIMEOUT = 10 * 1000; +const REVIEW_APP_STATUSES = { + pending: 'pending', + deleted: 'deleted', + creating: 'creating', + created: 'created', + errored: 'errored' +}; +const BUILD_STATUSES = { + succeeded: 'succeeded', + failed: 'failed' +}; + +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 getBuildsUrl = appId => `https://api.heroku.com/apps/${appId}/builds`; + +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 waitTillReviewAppCreated = ({ commit, minTimeout = MIN_TIMEOUT } = {}) => reviewApp => { + const { id } = reviewApp; + const checkForCreatedStatus = async () => { + const headers = await herokuHeaders({ useReviewAppApi: true }); + const result = await fetch(getReviewAppUrl(id), { + headers + }) + .then(throwIfNotOk) + .then(res => res.json()) + .then(async data => { + const { status, message, app = {} } = data; + const appId = !!app ? app.id : undefined; + if (status === REVIEW_APP_STATUSES.deleted) { + throw new pRetry.AbortError(`Review app was deleted: ${message}`); + } + + if ((status === REVIEW_APP_STATUSES.errored)) { + if (!appId) { + throw new pRetry.AbortError(`Review app errored: ${message}`); + } + + try { + const { + output_stream_url + } = await getAppBuildWithCommit({ appId, commit }); + console.error(`App (${appId}, commit: ${commit}) errored.\n\nFor Heroku output see:\n${output_stream_url}`); // eslint-disable-line no-console + } catch (e) { + console.error(`Could not get app build for app id ${appId}, commit: ${commit}, ${e}`); // eslint-disable-line no-console + } + + throw new pRetry.AbortError(`Review app errored: (appId: ${appId}) ${message}`); + } + + if (status !== REVIEW_APP_STATUSES.created) { + const appIdOutput = (status === REVIEW_APP_STATUSES.creating) + ? `, appId: ${appId}` + : ''; + throw new Error(`Review app not created yet. Current status: ${status}${appIdOutput}`); + }; + + return appId; + }); + return result; + }; + + return pRetry(checkForCreatedStatus, { + factor: RETRY_EXP_BACK_OFF_FACTOR, + retries: NUM_RETRIES, + minTimeout, + 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 findCreatedReviewApp = async ({ pipelineId, branch }) => { + const headers = await herokuHeaders({ useReviewAppApi: true }); + return fetch(getPipelineReviewAppsUrl(pipelineId), { + headers + }) + .then(throwIfNotOk) + .then(res => res.json()) + .then((reviewApps = []) => + reviewApps.find(({ branch: reviewAppBranch }) => reviewAppBranch === branch)); +}; + +const getBuilds = async (appId) => { + const headers = await herokuHeaders(); + return fetch(getBuildsUrl(appId), { + headers + }) + .then(throwIfNotOk) + .then(res => res.json()); +}; + +const getAppBuildWithCommit = ({ appId, commit }) => { + return getBuilds(appId) + .then(builds => { + const build = builds.find(({ source_blob: { version } }) => version === commit); + + return build; + }); +}; + +const waitForReviewAppBuild = ({ commit, minTimeout = MIN_TIMEOUT }) => async (appId) => { + const checkForBuildAppId = () => + getAppBuildWithCommit({ commit, appId }) + .then(async build => { + if (!build) { + throw new Error(`No review app build found for app id '${appId}';, commit '${commit}'`); + } + + const { status, output_stream_url } = build; + if ((status === BUILD_STATUSES.failed)) { + throw new pRetry.AbortError(`Review app build failed, appId: ${appId}, commit: ${commit}.\n\nFor Heroku output see:\n${output_stream_url}`); + } + + if (status !== BUILD_STATUSES.succeeded) { + throw new Error(`Review app build for app id '${appId}' (commit '${commit}') not done yet: ${status}`); + } + + return build; + }) + .then(({ app: { id } }) => id); + + return pRetry(checkForBuildAppId, { + factor: RETRY_EXP_BACK_OFF_FACTOR, + retries: NUM_RETRIES, + minTimeout, + onFailedAttempt: (err) => { + const { attemptNumber, message } = err; + console.error(`${attemptNumber}/${NUM_RETRIES}: ${message}`); // eslint-disable-line no-console + } + }); +}; + +const createReviewApp = async ({ pipelineId, repoName, commit, branch, githubToken }) => { + const headers = await herokuHeaders({ useReviewAppApi: true }); + const body = { + pipeline: pipelineId, + branch, + source_blob: { + url: await getGithubArchiveRedirectUrl({ repoName, branch, githubToken }), + version: commit + } + }; + return fetch(REVIEW_APPS_URL, { + headers, + method: 'post', + body: JSON.stringify(body) + }); +}; + +/** +* Get the review app name based on the review app build. +* Create a review app if it does not exist, otherwise +* use the existing review app and make a new build +* +* @param {string} appName Heroku application name +* @param {string} repoName GitHub repository name +* @param {string} branch GitHub branch name +* @param {string} commit git commit SHA-1 to find the build +* @param {string} githubToken GitHub token for getting source code +*/ +const getReviewAppName = async ({ + appName, + repoName, + branch, + commit, + githubToken +}) => { + const { id: pipelineId } = await pipelineInfo(appName); + + return createReviewApp({ + pipelineId, + repoName, + commit, + branch, + githubToken + }) + .then(res => { + const { status } = res; + if (status === 409) { + console.error(`Review app already created for '${branch}' branch. Using existing review app for build.`); // eslint-disable-line no-console + return findCreatedReviewApp({ + pipelineId, + branch + }) + .then(reviewApp => { + if (!reviewApp) { + throw new Error(`No review app found for pipeline ${pipelineId}, branch ${branch}`); + } + + return reviewApp; + }) + .then(waitTillReviewAppCreated({ commit })) + .then(waitForReviewAppBuild({ commit })) + .then(getAppName); + } + return Promise.resolve(res) + .then(res => res.json()) + .then(waitTillReviewAppCreated({ commit })) + .then(getAppName); + }); +}; + +module.exports = { + createReviewApp, + findCreatedReviewApp, + getAppName, + getBuilds, + getAppBuildWithCommit, + waitForReviewAppBuild, + waitTillReviewAppCreated, + getReviewAppName +}; diff --git a/lib/review-apps.unit.test.js b/lib/review-apps.unit.test.js new file mode 100644 index 00000000..1d782b83 --- /dev/null +++ b/lib/review-apps.unit.test.js @@ -0,0 +1,798 @@ +require('isomorphic-fetch'); +const nock = require('nock'); + +jest.mock('./github-api'); +const reviewApps = require('./review-apps'); +const BASE_HEROKU_API_URL = 'https://api.heroku.com'; + +describe('review-apps', () => { + let nockScope; + let reviewAppsNockScope; + beforeAll(() => { + nockScope = nock(BASE_HEROKU_API_URL) + .matchHeader('accept', 'application/vnd.heroku+json; version=3'); + reviewAppsNockScope = nock(BASE_HEROKU_API_URL) + .matchHeader('accept', 'application/vnd.heroku+json; version=3.review-apps'); + jest.mock('shellpromise', () => a => a, { virtual: true }); + + // WARNING: Disable error logs, to clean up test output + // Re-enable here if need be + jest.spyOn(console, 'error').mockImplementation().mockName('console.error'); + }); + + afterAll(() => { + jest.unmock('shellpromise'); + jest.unmock('./github-api'); + }); + + afterEach(() => { + nock.cleanAll(); + jest.resetAllMocks(); + }); + + describe('createReviewApp', () => { + const { createReviewApp } = reviewApps; + + it('returns created review app', async () => { + const pipelineId = 'pipeline123'; + const branch = 'some-branch'; + const repoName = 'next-amazing'; + const commit = 'a00000000000'; + const githubToken = ''; + + reviewAppsNockScope.post('/review-apps') + .reply(200, {}); + + return createReviewApp({ + pipelineId, + branch, + repoName, + commit, + githubToken + }) + .then(async res => { + const { ok } = res; + expect(ok).toBeTruthy(); + + const reviewApp = await res.json(); + expect(reviewApp).toBeTruthy(); + }); + }); + + it('returns an error', async () => { + const pipelineId = 'pipeline123'; + const branch = 'some-branch'; + const repoName = 'next-amazing'; + const commit = 'a00000000000'; + const githubToken = ''; + + reviewAppsNockScope.post('/review-apps') + .reply(400, {}); + + return createReviewApp({ + pipelineId, + branch, + repoName, + commit, + githubToken + }) + .then(async res => { + const { ok } = res; + expect(ok).toBeFalsy(); + + const error = await res.json(); + expect(error).toBeTruthy(); + }); + }); + }); + + describe('findCreatedReviewApp', () => { + const { findCreatedReviewApp } = reviewApps; + + it('returns review app of branch', async () => { + const pipelineId = 'pipeline123'; + const branch = 'some-branch'; + reviewAppsNockScope.get('/pipelines/pipeline123/review-apps') + .reply(200, [ + { + branch + } + ]); + const reviewApp = await findCreatedReviewApp({ + pipelineId, + branch + }); + expect(reviewApp).toEqual({ + branch + }); + }); + + it('returns undefined if branch review app is not found', async () => { + const pipelineId = 'pipeline123'; + const branch = 'some-branch'; + reviewAppsNockScope.get('/pipelines/pipeline123/review-apps') + .reply(200, []); + const reviewApp = await findCreatedReviewApp({ + pipelineId, + branch + }); + expect(reviewApp).toBeUndefined(); + }); + + it('throws an error', async () => { + const pipelineId = 'pipeline123'; + reviewAppsNockScope.get('/pipelines/pipeline123/review-apps') + .reply(400, { + message: 'Some error occurred' + }); + + try { + await findCreatedReviewApp({ + pipelineId + }); + } catch (error) { + expect(error.message).toEqual('Some error occurred'); + } + }); + }); + + describe('getAppName', () => { + const { getAppName } = reviewApps; + + it('returns name', async () => { + const appId = 'app123'; + nockScope.get('/apps/app123') + .reply(200, { + name: 'the-app' + }); + const name = await getAppName(appId); + expect(name).toEqual('the-app'); + }); + + it('throws an error', async () => { + const appId = 'app123'; + nockScope.get('/apps/app123') + .reply(400, { + message: 'Some error occurred' + }); + + try { + await getAppName(appId); + } catch (error) { + expect(error.message).toEqual('Some error occurred'); + } + }); + }); + + describe('getBuilds', () => { + const { getBuilds } = reviewApps; + it('returns builds', async () => { + const appId = 'app123'; + nockScope.get('/apps/app123/builds') + .reply(200, []); + const builds = await getBuilds(appId); + expect(builds).toBeTruthy(); + }); + + it('throws an error', async () => { + const appId = 'app123'; + nockScope.get('/apps/app123/builds') + .reply(400, { + message: 'Some error occurred' + }); + + try { + await getBuilds(appId); + } catch (error) { + expect(error.message).toEqual('Some error occurred'); + } + }); + }); + + describe('getAppBuildWithCommit', () => { + const { getAppBuildWithCommit } = reviewApps; + it('returns build', async () => { + const appId = 'app123'; + const commit = 'ababa'; + nockScope.get('/apps/app123/builds') + .reply(200, [ + { + source_blob: { + version: commit + } + } + ]); + const build = await getAppBuildWithCommit({ appId, commit }); + expect(build).toBeTruthy(); + }); + + it('returns undefined if there is no build with the commit', async () => { + const appId = 'app123'; + const commit = 'ababa'; + nockScope.get('/apps/app123/builds') + .reply(200, [ + { + source_blob: { + version: 'wrong commit' + } + } + ]); + const build = await getAppBuildWithCommit({ appId, commit }); + expect(build).toBeFalsy(); + }); + + it('returns undefined if there is no builds', async () => { + const appId = 'app123'; + const commit = 'ababa'; + nockScope.get('/apps/app123/builds') + .reply(200, []); + const build = await getAppBuildWithCommit({ appId, commit }); + expect(build).toBeFalsy(); + }); + }); + + describe('waitForReviewAppBuild', () => { + const { waitForReviewAppBuild } = reviewApps; + + it('returns appId of succeeded review app build', async () => { + const commit = 'a00000000000'; + const appId = 'app123'; + const reviewAppBuild = { + app: { + id: appId + }, + source_blob: { + version: commit + }, + status: 'succeeded' + }; + nockScope.get('/apps/app123/builds') + .reply(200, [ reviewAppBuild ]); + + const waitedReviewAppBuild = await waitForReviewAppBuild({ + commit + })(appId); + + expect(waitedReviewAppBuild).toEqual(appId); + }); + + it('waits for succeeded review app build from no review apps', async () => { + const commit = 'a00000000000'; + const appId = 'app123'; + const succeededReviewAppBuild = { + app: { + id: appId + }, + source_blob: { + version: commit + }, + status: 'succeeded' + }; + nockScope + .get('/apps/app123/builds') + .reply(200, []) + .get('/apps/app123/builds') + .reply(200, [ succeededReviewAppBuild ]); + + const waitedReviewAppBuild = await waitForReviewAppBuild({ + commit, + minTimeout: 0 + })(appId); + + expect(waitedReviewAppBuild).toEqual(appId); + }); + + it('waits for succeeded review app build even with errors', async () => { + const commit = 'a00000000000'; + const appId = 'app123'; + const succeededReviewAppBuild = { + app: { + id: appId + }, + source_blob: { + version: commit + }, + status: 'succeeded' + }; + nockScope + .get('/apps/app123/builds') + .reply(400, []) + .get('/apps/app123/builds') + .reply(200, [ succeededReviewAppBuild ]); + + const waitedReviewAppBuild = await waitForReviewAppBuild({ + commit, + minTimeout: 0 + })(appId); + + expect(waitedReviewAppBuild).toEqual(appId); + }); + + it('waits for succeeded review app build from pending', async () => { + const commit = 'a00000000000'; + const appId = 'app123'; + const pendingReviewAppBuild = { + source_blob: { + version: commit + }, + status: 'pending' + }; + const succeededReviewAppBuild = { + app: { + id: appId + }, + source_blob: { + version: commit + }, + status: 'succeeded' + }; + nockScope + .get('/apps/app123/builds') + .reply(200, [ pendingReviewAppBuild ]) + .get('/apps/app123/builds') + .reply(200, [ succeededReviewAppBuild ]); + + const waitedReviewAppBuild = await waitForReviewAppBuild({ + commit, + minTimeout: 0 + })(appId); + + expect(waitedReviewAppBuild).toEqual(appId); + }); + + it('fails for failed review app build with output stream link', async () => { + const commit = 'a00000000000'; + const appId = 'app123'; + const failedReviewAppBuild = { + source_blob: { + version: commit + }, + status: 'failed', + output_stream_url: 'https://heroku.com/builds/output/app123' + }; + nockScope + .get('/apps/app123/builds') + .reply(200, [ failedReviewAppBuild ]); + + try { + await waitForReviewAppBuild({ + commit, + minTimeout: 0 + })(appId); + } catch (e) { + const { message } = e; + expect(message).toMatch('Review app build failed'); + expect(message).toMatch('https://heroku.com/builds/output/app123'); + } + }); + + it('times out', async () => { + const commit = 'a00000000000'; + const appId = 'app123'; + nockScope + .get('/apps/app123/builds') + .times(60) + .reply(200, []); + + return waitForReviewAppBuild({ + commit, + minTimeout: 0 + })(appId) + .catch((error) => { + const { message } = error; + expect(message).toMatch('No review app build found'); + }); + }); + }); + + describe('waitTillReviewAppCreated', () => { + const { waitTillReviewAppCreated } = reviewApps; + + it('returns review app id', async () => { + const appId = 'app123'; + const reviewAppId = 'reviewApp123'; + const reviewApp = { + id: reviewAppId + }; + reviewAppsNockScope.get('/review-apps/reviewApp123') + .reply(200, { + app: { + id: appId + }, + status: 'created' + }); + + const waitedReviewAppBuild = await waitTillReviewAppCreated()(reviewApp); + + expect(waitedReviewAppBuild).toEqual(appId); + }); + + it('waits for review app id until created', async () => { + const appId = 'app123'; + const reviewAppId = 'reviewApp123'; + const reviewApp = { + id: reviewAppId + }; + reviewAppsNockScope + .get('/review-apps/reviewApp123') + .reply(200, { + app: { + id: appId + }, + status: 'pending' + }) + .get('/review-apps/reviewApp123') + .reply(200, { + app: { + id: appId + }, + status: 'created' + }); + + const waitedReviewAppBuild = await waitTillReviewAppCreated({ + minTimeout: 0 + })(reviewApp); + + expect(waitedReviewAppBuild).toEqual(appId); + }); + + it('waits for review app id even if there is a http error', async () => { + const appId = 'app123'; + const reviewAppId = 'reviewApp123'; + const reviewApp = { + id: reviewAppId + }; + reviewAppsNockScope + .get('/review-apps/reviewApp123') + .reply(400, {}) + .get('/review-apps/reviewApp123') + .reply(200, { + app: { + id: appId + }, + status: 'created' + }); + + const waitedReviewAppBuild = await waitTillReviewAppCreated({ + minTimeout: 0 + })(reviewApp); + + expect(waitedReviewAppBuild).toEqual(appId); + }); + + it('throws error if review app status is deleted', async () => { + const reviewAppId = 'reviewApp123'; + const reviewApp = { + id: reviewAppId + }; + reviewAppsNockScope.get('/review-apps/reviewApp123') + .reply(200, { + status: 'deleted' + }); + + return waitTillReviewAppCreated()(reviewApp) + .catch(error => { + const { message } = error; + expect(message).toMatch('Review app was deleted'); + }); + }); + + it('throws error if review app status is errored', async () => { + const reviewAppId = 'reviewApp123'; + const reviewApp = { + id: reviewAppId + }; + reviewAppsNockScope.get('/review-apps/reviewApp123') + .reply(200, { + status: 'errored' + }); + + return waitTillReviewAppCreated()(reviewApp) + .catch(error => { + const { message } = error; + expect(message).toMatch('Review app errored'); + }); + }); + + it('can error if app is null', async () => { + const reviewAppId = 'reviewApp123'; + const reviewApp = { + id: reviewAppId + }; + reviewAppsNockScope.get('/review-apps/reviewApp123') + .reply(200, { + app: null, + status: 'errored' + }); + + return waitTillReviewAppCreated()(reviewApp) + .catch(error => { + const { message } = error; + expect(message).toMatch('Review app errored'); + }); + }); + + it('logs output stream url if review app status is errored', async () => { + const appId = 'app123'; + const reviewAppId = 'reviewApp123'; + const reviewApp = { + id: reviewAppId + }; + const commit = 'ababab'; + reviewAppsNockScope.get('/review-apps/reviewApp123') + .reply(200, { + app: { + id: appId + }, + status: 'errored' + }); + nockScope.get('/apps/app123/builds') + .reply(200, [ + { + app: { + id: appId + }, + source_blob: { + version: commit + }, + output_stream_url: 'https://heroku.com/builds/output/app123' + } + ]); + + return waitTillReviewAppCreated({ commit })(reviewApp) + .catch(() => { + expect(console.error.mock.calls[0][0]).toMatch('https://heroku.com/builds/output/app123'); // eslint-disable-line no-console + }); + }); + + it('logs error if review app status is errored and build endpoint errors', async () => { + const appId = 'app123'; + const reviewAppId = 'reviewApp123'; + const reviewApp = { + app: { + id: appId + }, + id: reviewAppId, + status: 'errored' + }; + reviewAppsNockScope.get('/review-apps/reviewApp123') + .reply(200, reviewApp); + nockScope.get('/apps/app123/builds') + .reply(400); + + return waitTillReviewAppCreated()(reviewApp) + .catch(() => { + expect(console.error.mock.calls[0][0]).toMatch('Could not get app build'); // eslint-disable-line no-console + }); + }); + + it('times out', async () => { + const reviewAppId = 'reviewApp123'; + const reviewApp = { + id: reviewAppId + }; + reviewAppsNockScope + .get('/review-apps/reviewApp123') + .times(60) + .reply(200, []); + + return waitTillReviewAppCreated({ minTimeout: 0 })(reviewApp) + .catch((error) => { + const { message } = error; + expect(message).toMatch('Review app not created yet'); + }); + }); + }); + + describe('getReviewAppName', () => { + const { getReviewAppName } = reviewApps; + + it('creates a new review app build if a review app already exists', async () => { + const appName = 'next-app'; + const repoName = 'next-app-repo'; + const branch = 'new-idea-branch'; + const commit = 'a123'; + const githubToken = 'github-token-123'; + + const pipelineId = 'pipelineId'; + const reviewAppId = 'reviewAppId'; + const appId = 'app123'; + + nockScope + .get('/pipelines/next-app') + .reply(200, { + id: pipelineId + }) + .get('/apps/app123/builds') + .reply(200, [ + { + status: 'succeeded', + app: { + id: appId + }, + source_blob: { + version: commit + } + } + ]) + .get('/apps/app123') + .reply(200, { + name: appName + }); + reviewAppsNockScope + .post('/review-apps') + .reply(409, { + id: 'conflict', + message: 'A review app already exists for the post-build branch' + }) + .get('/pipelines/pipelineId/review-apps') + .reply(200, [ + { + id: reviewAppId, + branch + } + ]) + .get('/review-apps/reviewAppId') + .reply(200, { + status: 'created', + app: { + id: appId + } + }); + + const name = await getReviewAppName({ + appName, + repoName, + branch, + commit, + githubToken + }); + + expect(name).toEqual(appName); + }); + + it('throws an error if review app is deleted', async () => { + const appName = 'next-app'; + const repoName = 'next-app-repo'; + const branch = 'new-idea-branch'; + const commit = 'a123'; + const githubToken = 'github-token-123'; + + const pipelineId = 'pipelineId'; + const reviewAppId = 'reviewAppId'; + const appId = 'app123'; + + nockScope.get('/pipelines/next-app') + .reply(200, { + id: pipelineId + }); + reviewAppsNockScope + .post('/review-apps') + .reply(409, { + id: 'conflict', + message: 'A review app already exists for the post-build branch' + }) + .get('/pipelines/pipelineId/review-apps') + .reply(200, [ + { + id: reviewAppId, + branch + } + ]) + .get('/review-apps/reviewAppId') + .reply(200, { + status: 'deleted', + app: { + id: appId + } + }); + + try { + await getReviewAppName({ + appName, + repoName, + branch, + commit, + githubToken + }); + } catch ({ message }) { + expect(message).toMatch('Review app was deleted'); + } + }); + + it('throws an error if review app errors', async () => { + const appName = 'next-app'; + const repoName = 'next-app-repo'; + const branch = 'new-idea-branch'; + const commit = 'a123'; + const githubToken = 'github-token-123'; + + const pipelineId = 'pipelineId'; + const reviewAppId = 'reviewAppId'; + const appId = 'app123'; + + nockScope.get('/pipelines/next-app') + .reply(200, { + id: pipelineId + }); + reviewAppsNockScope + .post('/review-apps') + .reply(409, { + id: 'conflict', + message: 'A review app already exists for the post-build branch' + }) + .get('/pipelines/pipelineId/review-apps') + .reply(200, [ + { + id: reviewAppId, + branch + } + ]) + .get('/review-apps/reviewAppId') + .reply(200, { + status: 'errored', + app: { + id: appId + } + }); + + try { + await getReviewAppName({ + appName, + repoName, + branch, + commit, + githubToken + }); + } catch ({ message }) { + expect(message).toMatch('Review app errored'); + expect(message).toMatch(appId); + } + }); + + it('gets review app name', async () => { + const appName = 'next-app'; + const repoName = 'next-app-repo'; + const branch = 'new-idea-branch'; + const commit = 'a123'; + const githubToken = 'github-token-123'; + + const pipelineId = 'pipelineId'; + const reviewAppId = 'reviewAppId'; + const appId = 'app123'; + + nockScope + .get('/pipelines/next-app') + .reply(200, { + id: pipelineId + }) + .get('/apps/app123') + .reply(200, { + name: appName + }); + reviewAppsNockScope + .post('/review-apps') + .reply(200, { + id: reviewAppId, + status: 'created' + }) + .get('/review-apps/reviewAppId') + .reply(200, { + status: 'created', + app: { + id: appId + } + }); + + const name = await getReviewAppName({ + appName, + repoName, + branch, + commit, + githubToken + }); + + expect(name).toEqual(appName); + }); + }); +}); diff --git a/lib/verify-cache-headers.js b/lib/verify-cache-headers.js deleted file mode 100644 index d7a8ecd5..00000000 --- a/lib/verify-cache-headers.js +++ /dev/null @@ -1,60 +0,0 @@ -const verifyCacheHeaders = (headers, path) => { - - const cacheErrors = []; - - if (!headers['surrogate-control'] && !headers['cache-control']) { - cacheErrors.push(`Each ${path} should specify a Cache-Control and/or a Surrogate-Control header`); - } - if (headers['cache-control'] && headers['cache-control'].includes('private')) { - if (headers['surrogate-control'] && !headers['surrogate-control'].includes('max-age=0')) { - cacheErrors.push(`${path} has a private cache-control, which will mean surrogate-control gets ignored by fastly`); - } - } else { - if (headers['surrogate-control'] && !(headers['surrogate-control'].includes('stale-while-revalidate')|| headers['surrogate-control'].includes('stale-if-error'))) { - cacheErrors.push(`${path} should specify stale-while-revalidate and stale-if-error cache headers`); - } - - if (!headers['surrogate-control'] || !headers['cache-control']) { - cacheErrors.push(`Cachable path ${path} should specify both a Cache-Control and a Surrogate-Control header`); - } - - if (headers['cache-control'] && headers['cache-control'].includes('public') && /max-age=[^0]/.test(headers['cache-control'])) { - cacheErrors.push(`${path} should not have a public Cache-Control header of max-age greater than 0`); - } - - if (headers['surrogate-control'] && !headers['cache-control']) { - cacheErrors.push(`As ${path} uses surrogate-control, you should set an aoutbound cache-control header too, usually res.set('Cache-Control', res.FT_NO_CACHE)`); - } - } - - if (cacheErrors.length) { - console.error(cacheErrors.join('\n')); // eslint-disable-line no-console - // eslint-disable-next-line no-console - console.error(`\ -n-express contains a few helpful cache constants you can use to rectify these issues: -res.FT_NO_CACHE = 'max-age=0, no-cache, no-store, must-revalidate'; -res.FT_SHORT_CACHE = 'max-age=600, stale-while-revalidate=60, stale-if-error=86400'; -res.FT_HOUR_CACHE = 'max-age=3600, stale-while-revalidate=60, stale-if-error=86400'; -res.FT_DAY_CACHE = 'max-age=86400, stale-while-revalidate=60, stale-if-error=86400'; -res.FT_LONG_CACHE = 'max-age=86400, stale-while-revalidate=60, stale-if-error=259200'; -e.g. res.set('Cache-Control', res.FT_NO_CACHE).set('Surrogate-Control', res.FT_HOUR_CACHE); -`); - throw new Error('Unwise Cache headers'); - } -}; - -module.exports = (testPage) => { - let okay = true; - let problems; - try { - verifyCacheHeaders(testPage.response.headers(), testPage.url); - } catch(errors) { - okay = false; - problems = errors; - } - return { - expected: 'Cache-Control headers should be sensible', - actual: okay || problems, - result: okay - }; -}; diff --git a/main.js b/main.js index c35ba602..4b92af68 100644 --- a/main.js +++ b/main.js @@ -3,9 +3,5 @@ module.exports = { configure: require('./tasks/configure'), deployHashedAssets: require('./tasks/deploy-hashed-assets'), - deployStatic: require('./tasks/deploy-static'), - deploy: require('./tasks/deploy'), - destroy: require('./tasks/destroy'), - provision: require('./tasks/provision'), - scale: require('./tasks/scale') + deployStatic: require('./tasks/deploy-static') }; diff --git a/package.json b/package.json index 5282fb1f..aed0bdf1 100644 --- a/package.json +++ b/package.json @@ -11,20 +11,19 @@ }, "main": "main.js", "dependencies": { - "@financial-times/n-test": "^1.1.3", "aws-sdk": "^2.1.19", "co": "^4.6.0", - "colors": "^1.1.2", "commander": "^2.6.0", "denodeify": "^1.2.0", "dotenv": "^1.2.0", "fetchres": "^1.0.4", "foreman": "^3.0.0", - "haikro": "^2.2.0", "is-image": "^1.0.1", "isomorphic-fetch": "^2.0.0", + "lodash.merge": "^4.6.1", "md5-file": "^3.1.0", "mime": "^1.3.4", + "nock": "^10.0.2", "node-vault": "^0.5.6", "nodemon": "^1.11.0", "p-retry": "^2.0.0", @@ -34,19 +33,12 @@ "devDependencies": { "@financial-times/n-gage": "^2.0.4", "body-parser": "^1.14.1", - "chai": "^3.3.0", "eslint": "^2.8.0", - "express": "^4.13.3", - "fetch-mock": "^3.0.0", "lintspaces-cli": "^0.1.1", - "mocha": "^5.0.0", + "jest": "^23.6.0", "mockery": "^1.4.0", - "npm-prepublish": "^1.2.1", "sinon": "^4.1.3" }, - "babel": { - "optional": "es7.classProperties" - }, "scripts": { "precommit": "node_modules/.bin/secret-squirrel", "prepush": "make verify -j3", diff --git a/scripts/generate-docs.sh b/scripts/generate-docs.sh index 98e1ff76..7f10eabb 100755 --- a/scripts/generate-docs.sh +++ b/scripts/generate-docs.sh @@ -1,2 +1,2 @@ #!/bin/sh -printf "# n-heroku-tools\nThis library is a command line tool that orchestrates [Heroku](https://www.heroku.com/) and [Amazon S3](https://aws.amazon.com/s3/) deployments for [Next](https://github.com/Financial-Times/next/wiki), based on configuration in the [Next service registry](https://next-registry.ft.com/v2/) and [Vault](https://www.vaultproject.io/).\n### Installation\nIn order to use this tool, run\n\`\`\`\nnpm install @financial-times/n-heroku-tools --save-dev\n\`\`\`\n\n ### Usage\nIn order to use \`n-heroku-tools\` the following commands are available in your command line:\n`./bin/n-heroku-tools.js`\n\n*Note*: The README.md is automatically generated. Run \`make docs\` to update it.\n" +printf "# n-heroku-tools\n\nThis library is a command line tool that orchestrates [Heroku](https://www.heroku.com/) and [Amazon S3](https://aws.amazon.com/s3/) deployments for [Next](https://github.com/Financial-Times/next/wiki), based on configuration in the [Next service registry](https://next-registry.ft.com/v2/) and [Vault](https://www.vaultproject.io/).\n\n### Installation\n\nIn order to use this tool, run\n\n\`\`\`\nnpm install @financial-times/n-heroku-tools --save-dev\n\`\`\`\n\n### Usage\n\nIn order to use \`n-heroku-tools\` the following commands are available in your command line:\n\n\`\`\`\n`./bin/n-heroku-tools.js`\n\`\`\`\n\n*Note*: The README.md is automatically generated. Run \`make docs\` to update it.\n" diff --git a/secret-squirrel.js b/secret-squirrel.js index dbdb4d93..d8112ce7 100644 --- a/secret-squirrel.js +++ b/secret-squirrel.js @@ -1,9 +1,6 @@ module.exports = { files: { allow: [ - 'ascii/canoe.ascii', - 'ascii/ship-in-bottle.ascii', - 'ascii/yacht.ascii' ], allowOverrides: [] }, diff --git a/tasks/configure.js b/tasks/configure.js index aa933012..03bbe6e9 100644 --- a/tasks/configure.js +++ b/tasks/configure.js @@ -1,11 +1,12 @@ 'use strict'; -let packageJson = require(process.cwd() + '/package.json'); -let findService = require('../lib/find-service'); -let herokuAuthToken = require('../lib/heroku-auth-token'); -let normalizeName = require('../lib/normalize-name'); -let vault = require('../lib/vault'); -let fetchres = require('fetchres'); +const packageJson = require(process.cwd() + '/package.json'); +const findService = require('../lib/find-service'); +const herokuAuthToken = require('../lib/heroku-auth-token'); +const normalizeName = require('../lib/normalize-name'); +const vault = require('../lib/vault'); +const pipelines = require('../lib/pipelines'); +const HerokuConfigVars = require('../lib/heroku-config-vars'); const FORBIDDEN_ATTACHMENT_VARIABLES = [ 'DATABASE_URL' @@ -14,20 +15,20 @@ const FORBIDDEN_ATTACHMENT_VARIABLES = [ const DEFAULT_REGISTRY_URI = 'https://next-registry.ft.com/v2/'; const getServiceData = (source, registry) => fetch(registry) - .then(response => response.json()) - .then(json => { - const serviceData = findService(json, normalizeName(source)); - if (!serviceData) { - throw new Error('Could not find a service in the registry, with `name` or `systemCode`, matching ' + source + '. Please check the service registry.'); - return false; - } - else { - return serviceData; - } - }); + .then(response => response.json()) + .then(json => { + const serviceData = findService(json, normalizeName(source)); + if (!serviceData) { + throw new Error('Could not find a service in the registry, with `name` or `systemCode`, matching ' + source + '. Please check the service registry.'); + return false; + } + else { + return serviceData; + } + }); -function fetchFromVault (source, target, serviceData) { - const path = serviceData.config.replace('https://vault.in.ft.com/v1/',''); +const fetchFromVault = (serviceData) => { + const path = serviceData.config.replace('https://vault.in.ft.com/v1/', ''); return Promise.all([path, vault.get()]) .then(([path, vault]) => { @@ -68,7 +69,7 @@ function fetchFromVault (source, target, serviceData) { throw e; }); }); -} +}; const fetchSessionToken = (userType, url, apiKey) => { return fetch(`${url}/${userType}?api_key=${apiKey}`) @@ -85,7 +86,12 @@ const fetchSessionToken = (userType, url, apiKey) => { }); }; -function task (opts) { +const getPipelineId = async (pipelineName) => { + const pipeline = await pipelines.info(pipelineName); + return pipeline.id; +}; + +async function task (opts) { let source = opts.source || 'ft-next-' + normalizeName(packageJson.name); let target = opts.target || source; let overrides = {}; @@ -97,76 +103,59 @@ function task (opts) { }); } - let authorizedPostHeaders = { - 'Accept': 'application/vnd.heroku+json; version=3', - 'Content-Type': 'application/json' - }; - - return herokuAuthToken() - .then(function (key) { - authorizedPostHeaders.Authorization = 'Bearer ' + key; - - return getServiceData(source, opts.registry).then(serviceData => Promise.all([ - fetchFromVault(source, target, serviceData), - fetch('https://api.heroku.com/apps/' + target + '/config-vars', { headers: authorizedPostHeaders }) - .then(fetchres.json) - .catch(function (err) { - if (err instanceof fetchres.BadServerResponseError && err.message === 404) { - throw new Error(source + ' app needs to be manually added to heroku before it, or any branches, can be deployed'); - } else { - throw err; - } - }) - ]) - .then(function (data) { - let desired = data[0]; - let current = data[1]; - - desired['SYSTEM_CODE'] = serviceData.code; - - desired['___WARNING___'] = 'Don\'t edit config vars manually. Use the Vault UI.'; - let patch = {}; - - Object.keys(current).forEach(function (key) { - if (!key.startsWith('HEROKU_') && !FORBIDDEN_ATTACHMENT_VARIABLES.includes(key)) { - patch[key] = null; - } - }); - - Object.keys(desired).forEach(key => { - if (FORBIDDEN_ATTACHMENT_VARIABLES.includes(key)) { - throw new Error(`\nCannot set environment variable '${key}' as this variable name is used for an attachment variable by Heroku, ` - + 'if this is for an external service, please use a different environment variable name in your app\n'); - } else { - patch[key] = desired[key]; - } - }); - - Object.keys(overrides).forEach(function (key) { - patch[key] = overrides[key]; - }); - - Object.keys(patch).forEach(function (key) { - if (patch[key] === null) { - console.log('Deleting config var: ' + key); // eslint-disable-line no-console - } else if (patch[key] !== current[key]) { - console.log('Setting config var: ' + key); // eslint-disable-line no-console - } - }); - - console.log('Setting environment keys', Object.keys(patch)); // eslint-disable-line no-console - - return fetch('https://api.heroku.com/apps/' + target + '/config-vars', { - headers: authorizedPostHeaders, - method: 'patch', - body: JSON.stringify(patch) - }); - }) - .then(response => { - if (response.status !== 200) return response.json().then(({id, message}) => Promise.reject(new Error(`Heroku Error - id: ${id}, message: ${message}`))); - console.log(target + ' config vars are set'); // eslint-disable-line no-console - })); - }); + console.log('Retrieving pipeline details from Heroku...'); // eslint-disable-line no-console + + const authToken = await herokuAuthToken(); + const pipelineId = await getPipelineId(source); + + const herokuConfigVars = new HerokuConfigVars({ target, pipelineId, authToken }); + + const serviceData = await getServiceData(source, opts.registry); + + console.log('Retrieving current and desired config vars...'); // eslint-disable-line no-console + + const [ desired, current ] = await Promise.all([ + fetchFromVault(serviceData), + herokuConfigVars.get() + ]); + + desired['SYSTEM_CODE'] = serviceData.code; + + desired['___WARNING___'] = 'Don\'t edit config vars manually. Use the Vault UI.'; + let patch = {}; + + Object.keys(current).forEach(function (key) { + if (!key.startsWith('HEROKU_') && !FORBIDDEN_ATTACHMENT_VARIABLES.includes(key)) { + patch[key] = null; + } + }); + + Object.keys(desired).forEach(key => { + if (FORBIDDEN_ATTACHMENT_VARIABLES.includes(key)) { + throw new Error(`\nCannot set environment variable '${key}' as this variable name is used for an attachment variable by Heroku, ` + + 'if this is for an external service, please use a different environment variable name in your app\n'); + } else { + patch[key] = desired[key]; + } + }); + + Object.keys(overrides).forEach(function (key) { + patch[key] = overrides[key]; + }); + + Object.keys(patch).forEach(function (key) { + if (patch[key] === null) { + console.log(`Deleting config var: ${key}`); // eslint-disable-line no-console + } else if (patch[key] !== current[key]) { + console.log(`Adding or updating config var: ${key}`); // eslint-disable-line no-console + } + }); + + console.log('Setting config vars', Object.keys(patch)); // eslint-disable-line no-console + + await herokuConfigVars.set(patch); + + console.log(`${target} config vars are set`); // eslint-disable-line no-console }; module.exports = function (program, utils) { diff --git a/tasks/deploy.js b/tasks/deploy.js deleted file mode 100644 index ef6905e5..00000000 --- a/tasks/deploy.js +++ /dev/null @@ -1,122 +0,0 @@ -'use strict'; - -const packageJson = require(process.cwd() + '/package.json'); -const herokuAuthToken = require('../lib/heroku-auth-token'); -const deploy = require('haikro/lib/deploy'); -const normalizeName = require('../lib/normalize-name'); -const host = require('../lib/host'); -const waitForOk = require('../lib/wait-for-ok'); -const denodeify = require('denodeify'); -const fs = require('fs'); -const exists = denodeify(fs.exists, function (exists) { return [undefined, exists]; }); -const commit = require('../lib/commit'); -const SmokeTest = require('@financial-times/n-test').SmokeTest; -const shell = require('shellpromise'); - -function task (opts) { - let token; - let hash; - const name = (opts.app) ? opts.app : 'ft-next-' + normalizeName(packageJson.name); - - return Promise.all([ - herokuAuthToken(), - commit(), - exists(process.cwd() + '/.haikro-cache/slug.tgz') - ]) - .then(function (results) { - token = results[0]; - hash = results[1].trim(); - const hasAbout = results[2]; - if (!hasAbout) { - throw new Error('/.haikro-cache/slug.tgz must be generated during the build step.'); - } - }) - .then(function () { - console.log('Next Build Tools going to deploy to ' + name); // eslint-disable-line no-console - return deploy({ - app: name, - token: token, - project: process.cwd(), - commit: hash - }); - }) - - // Start polling - .then(() => { - // Always skip gtg if preboot enabled as heroku's implementation of preboot means - // we are most likely hitting the last successful deploy, not the current one - if (!opts.skipGtg) { - // Smoke test are now compulsory - return exists(process.cwd() + '/test/smoke.js') - .then(hasSmokeConfig => { - if (!hasSmokeConfig) { - throw new Error(`Smoke tests, configured using a ./test/smoke.js file, must exist for all apps. -See https://github.com/Financial-Times/n-heroku-tools/blob/master/docs/smoke.md for docs. -If this app has no web process use the --skip-gtg option`); - } - const smokeOpts = { - host: host.url(name), - headers: opts.authenticatedSmokeTests ? { 'FT-NEXT-BACKEND-KEY': process.env.FT_NEXT_BACKEND_KEY } : null - }; - - //don't run browser tests against authenticated URLs - if(opts.authenticatedSmokeTests) { - smokeOpts.browsers = ['chrome']; - } - - const smokeTest = new SmokeTest(smokeOpts); - - smokeTest.addCheck('cacheHeaders', require('../lib/verify-cache-headers')); - - return waitForOk(`https://${name}.herokuapp.com/__gtg`) - .then(() => smokeTest.run()) - .catch(err => { - console.log('/**************** heroku app logs start ****************/'); // eslint-disable-line no-console - return shell('heroku logs -a ' + name, { verbose: true }) - .then(() => { - console.log('/**************** heroku app logs end ****************/'); // eslint-disable-line no-console - - // eslint-disable-next-line no-console - console.log(`\ -TIP: To recreate the deployed app locally run the following: -make clean install build-production -tar xf .haikro-cache/slug.tgz -C ../my-app-slug -cp .env ../my-app-slug/app/.env -cd ../my-app-slug/app -npm install @financial-times/n-heroku-tools -nht run -`); - if(err.urlsTested) { - throw 'Smoke Tests failed ಠ_ಠ'; - } else { - throw err; - } - }); - }); - }); - - } else { - console.log('Skipping gtg check.'); // eslint-disable-line no-console - } - }); -}; - -module.exports = function (program, utils) { - program - .command('deploy [app]') - .description('runs haikro deployment scripts with sensible defaults for Next projects') - .option('-s, --skip-gtg', 'skip the good-to-go HTTP check') - .option('-a, --authenticated-smoke-tests', 'authenticate smoke tests with a backend authorization key') - .action(function (app, options) { - task({ - app: app, - - // Skip GTG check if ‘--skip-gtg’ specified or if doing a production (i.e. no ‘app’ specified) - // deploy (because preboot will mean that the gtg checks are meaningless) - skipGtg: options.skipGtg || !app, - authenticatedSmokeTests: options.authenticatedSmokeTests, - }).catch(utils.exit); - }); -}; - -module.exports.task = task; diff --git a/tasks/destroy.js b/tasks/destroy.js deleted file mode 100644 index 1b7f6ea2..00000000 --- a/tasks/destroy.js +++ /dev/null @@ -1,43 +0,0 @@ -'use strict'; - -let spawn = require('shellpromise'); - -function task (options) { - let app = options.app; - let verbose = options.verbose; - - return spawn('heroku info ' + app).then(function () { - let promise = Promise.resolve(); - if (verbose) { - promise = promise.then(function () { - // `|| echo` to stop this failing failing builds - return spawn('heroku logs -a ' + app + ' || echo', { verbose: true }); - }); - } - promise = promise.then(function () { - return spawn('heroku destroy -a ' + app + ' --confirm ' + app, { verbose: true }); - }); - return promise; - }).catch(function () { - console.log(app + ' does not exist'); // eslint-disable-line no-console - }); -}; - -module.exports = function (program, utils) { - program - .command('destroy [app]') - .option('--skip-logs', 'skips trying to output the logs before destroying the app') - .description('deletes the app from heroku') - .action(function (app, options) { - if (app) { - task({ - app: app, - verbose: !options.skipLogs - }).catch(utils.exit); - } else { - utils.exit('Please provide an app name'); - } - }); -}; - -module.exports.task = task; diff --git a/tasks/drydock.js b/tasks/drydock.js deleted file mode 100644 index 7d8a35db..00000000 --- a/tasks/drydock.js +++ /dev/null @@ -1,76 +0,0 @@ -const log = require('../lib/logger'); -const pipelines = require('../lib/pipelines'); - -const provision = require('./provision'); -const destroy = require('./destroy'); - -const DEFAULT_ORG = 'ft-customer-products'; - -async function task (pipelineName, { multiregion, organisation = DEFAULT_ORG } = {}){ - - let stagingApp = pipelineName + '-staging'; - let euApp = pipelineName + '-eu'; - let usApp = pipelineName + '-us'; - - let apps = [stagingApp, euApp]; - if(multiregion){ - apps.push(usApp); - } - - try { - - log.info(`Creating apps for pipeline (organisation: ${organisation})...`); - let provisionPromises = [ - provision.task(stagingApp, { organisation }), - provision.task(euApp, { region: 'eu', organisation }) - ]; - - if(multiregion){ - provisionPromises.push(provision.task(usApp, { region: 'us', organisation })); - } - - await Promise.all(provisionPromises); - log.success('Created apps: %s', apps.join(', ')); - - await pipelines.create(pipelineName, { stagingApp, organisation }); - log.success('Created pipeline %s', pipelineName); - - let addAppToPipelinePromises = [ - pipelines.addAppToPipeline(pipelineName, euApp, 'production') - ]; - if(multiregion){ - addAppToPipelinePromises.push(pipelines.addAppToPipeline(pipelineName, usApp, 'production')); - } - - log.info('Add non-staging apps to pipeline'); - await Promise.all(addAppToPipelinePromises); - - log.success('DRY-DOCK COMPLETE'); - log.art.yacht(); - - } catch (error) { - log.error('Man overboard!', error, error.stack); - await Promise.all(apps.map(a => destroy.task({app:a}))); - - // Log and rethrow - log.error(error.message); - throw error; - } -}; - -module.exports = function (program, utils) { - program - .command('drydock [name]') - .description('Creates a new pipeline with a staging and EU production app') - .option('-m, --multiregion', 'Will create an additional app in the US') - .option('-o, --organisation [org]', 'Specify the organisation to own the created assets', DEFAULT_ORG) - .action(function (name, options){ - if(!name){ - throw new Error('Please specify a name for the pipeline'); - } - log.info(`Running drydock task with name: ${name}, org: ${options.organisation}, multiregion: ${options.multiregion}`); - task(name, options).catch(utils.exit); - }); -}; - -module.exports.task = task; diff --git a/tasks/float.js b/tasks/float.js deleted file mode 100644 index e79989d1..00000000 --- a/tasks/float.js +++ /dev/null @@ -1,92 +0,0 @@ - -'use strict'; -const co = require('co'); -const provision = require('./provision').task; -const configure = require('./configure').task; -const deploy = require('./deploy').task; -const destroy = require('./destroy').task; -const host = require('../lib/host'); -const packageJson = require(process.cwd() + '/package.json'); -const log = require('../lib/logger'); - -const DEFAULT_REGISTRY_URI = 'https://next-registry.ft.com/v2/'; - -function task (opts) { - let testAppName; - return co(function* (){ - let isMaster = host.isMasterBranch(); - if(isMaster){ - log.warn('On master branch'); - } - - if(isMaster && !opts.master){ - log.info('On master branch - don\'t run float. Use --master option if you want to do this'); - return; - } - - - let appName = opts.app || packageJson.name; - testAppName = opts.testapp || appName + '-' + host.buildNumber(); - - - log.info('Creating test app %s', testAppName); - yield provision(testAppName); - log.success('Created app %s', testAppName); - - if (opts.configure) { - const REGISTRY_URI = opts.registry || DEFAULT_REGISTRY_URI; - - log.info('Configure test app using registry: ', REGISTRY_URI); - - yield configure({ - source:appName, - target:testAppName, - overrides:['NODE_ENV=branch', `TEST_APP=${testAppName}`, 'WEB_CONCURRENCY=1'], - registry: REGISTRY_URI - }); - log.success('App configured'); - } - - log.info('Deploy to test app and run __gtg checks'); - yield deploy({ app: testAppName, skipGtg: opts.skipGtg }); - log.success('Deployed successfully'); - - if(opts.destroy){ - log.info('Destroy test app'); - yield destroy({app:testAppName}); - log.success('Removed Test App'); - } - - log.art.canoe(); - log.success('IT FLOATS!'); - }).catch(function (err) { - if (opts.destroy) { - return destroy({ app: testAppName, verbose: true }) - .then(function () { - throw err; - }); - } else { - throw err; - } - - }); -}; - -module.exports = function (program, utils) { - program - .command('float') - .description('Deploys code to a test app and checks it doesn\'t die') - .option('-a --app', 'Name of the app') - .option('-c --no-configure', 'Skip the configure step') - .option('-t --testapp [value]', 'Name of the app to be created') - .option('-m --master', 'Run even if on master branch (not required if using nbt ship).') - .option('-d --no-destroy', 'Don\'t automatically destroy new apps') - .option('-r, --registry [registry-uri]', `use this registry, instead of the default: ${DEFAULT_REGISTRY_URI}`, DEFAULT_REGISTRY_URI) - .option('-s --skip-gtg', 'skip the good-to-go HTTP check') - .option('--vault', 'no-op, please remove this option from your Makefile') - .action(function (options){ - task(options).catch(utils.exit); - }); -}; - -module.exports.task = task; diff --git a/tasks/provision.js b/tasks/provision.js deleted file mode 100644 index 7a506f2f..00000000 --- a/tasks/provision.js +++ /dev/null @@ -1,25 +0,0 @@ -const spawn = require('shellpromise'); - -const DEFAULT_REGION = 'us'; -const DEFAULT_ORG = 'ft-customer-products'; - -function task (name, { region = DEFAULT_REGION, organisation = DEFAULT_ORG } = {}) { - return spawn(`heroku create -a ${name} --region ${region} --org ${organisation} --no-remote`, { verbose: true }); -}; - -module.exports = function (program, utils) { - program - .command('provision [app]') - .description('provisions a new instance of an application server') - .option('-r --region [region]', 'Region to create app in (default: us)', DEFAULT_REGION) - .option('-o --organisation [org]', 'Specify the organisation to own the created assets', DEFAULT_ORG) - .action(function (appName, options) { - if (appName) { - task(appName, options).catch(utils.exit); - } else { - utils.exit('Please provide an app name'); - } - }); -}; - -module.exports.task = task; diff --git a/tasks/review-app.js b/tasks/review-app.js index 983b4974..d2c49464 100644 --- a/tasks/review-app.js +++ b/tasks/review-app.js @@ -1,201 +1,37 @@ -const pRetry = require('p-retry'); +const { + getReviewAppName +} = require('../lib/review-apps'); -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) { +async function task (appName, 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 => { + return getReviewAppName({ + appName, + repoName, + branch, + commit, + githubToken + }).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`) -*/ +const description = 'Create or find an existing heroku review app and print out the app name. [appName] is the package.json name (which is also the value of VAULT_NAME). On the first build of a branch, Heroku will create a review app with a build. On subsequent builds, Heroku will automatically generate a new build, which this task looks for. See https://devcenter.heroku.com/articles/review-apps-beta for more details of the internals'; + module.exports = function (program) { program - .command('review-app [app]') - .description('Create a heroku review app and print out the app name created') + .command('review-app [appName]') + .description(description) .option('-r, --repo-name ', 'github repository name') .option('-b, --branch ', 'branch of the review app') .option('-c, --commit ', 'commit SHA-1') .option('-g, --github-token ', 'github personal token to access source code (generate from https://github.com/settings/tokens)') - .action(async function (app, options) { + .action(async function (appName, options) { try { - await task(app, options); + await task(appName, options); } catch (error) { - console.error(error); // eslint-disable-line no-console + const { message } = error || {}; + console.error(`${message}\n\n`, error); // eslint-disable-line no-console process.exit(1); return; } diff --git a/tasks/scale.js b/tasks/scale.js deleted file mode 100644 index ff17bfb8..00000000 --- a/tasks/scale.js +++ /dev/null @@ -1,95 +0,0 @@ -'use strict'; - -const packageJson = require(process.cwd() + '/package.json'); -const findService = require('../lib/find-service'); -const herokuAuthToken = require('../lib/heroku-auth-token'); -const normalizeName = require('../lib/normalize-name'); -const fetchres = require('fetchres'); -const shellpromise = require('shellpromise'); - -const DEFAULT_REGISTRY_URI = 'https://next-registry.ft.com/v2/'; - -function task (opts) { - - let source = opts.source || normalizeName(packageJson.name, { version: false }); - let target = opts.target || packageJson.name; - let registry = opts.registry || DEFAULT_REGISTRY_URI; - let overrides = {}; - - if(opts.scale){ - return shellpromise('heroku ps:scale ' + opts.scale + ' --app ' + target, { verbose: true }); - } - - if (opts.overrides) { - opts.overrides.map(function (o) { - let t = o.split('='); - overrides[t[0]] = t[1]; - }); - } - - console.log('Using registry: ', registry); // eslint-disable-line no-console - - console.log('Scaling ' + target + ' using service registry information for ' + source); // eslint-disable-line no-console - return herokuAuthToken() - .then(() => fetch(registry)) - .then(fetchres.json) - .then(data => { - const serviceData = findService(data, source); - - if (!serviceData) { - throw new Error('Could not find a service in the registry, with `name` or `systemCode`, matching ' + source + '. Please check the service registry.'); - } - - const processInfo = serviceData.processes; - - if (!processInfo) { - throw new Error('Could not get process info for ' + serviceData.name + '. Please check the service registry.'); - } - - const processProfiles = Object.keys(processInfo).map(process => { - let scale = processInfo[process].scale; - let size = processInfo[process].size; - - if (opts.minimal) { - scale = 1; - } - - if (opts.inhibit) { - scale = 0; - } - - return `${process}=${scale}:${size}`; - }); - - return shellpromise('heroku ps:scale ' + processProfiles.join(' ') + ' --app ' + target, { verbose: true }); - - }) - .then(function (processProfiles) { - console.log(target + ' config vars are set to', processProfiles); // eslint-disable-line no-console - }) - .catch(function (err) { - console.log('Error scaling processes - ' + err); // eslint-disable-line no-console - console.log('Pro tip: Check that your process names haven\'t changed'); // eslint-disable-line no-console - throw err; - }); -}; - -module.exports = function (program, utils) { - program - .command('scale [source] [target]') - .description('downloads process information from next-service-registry and scales/sizes the application servers') - .option('-m, --minimal', 'scales each dyno to a single instance') - .option('-i, --inhibit', 'scales each dyno down to 0') - .option('-r, --registry [registry-uri]', `use this registry, instead of the default: ${DEFAULT_REGISTRY_URI}`, DEFAULT_REGISTRY_URI) - .action(function (source, target, options) { - task({ - source: source, - target: target, - minimal: options.minimal, - inhibit: options.inhibit, - registry: options.registry - }).catch(utils.exit); - }); -}; - -module.exports.task = task; diff --git a/tasks/ship.js b/tasks/ship.js deleted file mode 100644 index 33093c66..00000000 --- a/tasks/ship.js +++ /dev/null @@ -1,147 +0,0 @@ -'use strict'; - -const co = require('co'); -const scale = require('./scale').task; -const configure = require('./configure').task; -const packageJson = require(process.cwd() + '/package.json'); -const pipelines = require('../lib/pipelines'); -const deploy = require('./deploy').task; -const log = require('../lib/logger'); -const normalizeName = require('../lib/normalize-name'); - -const DEFAULT_REGISTRY_URI = 'https://next-registry.ft.com/v2/'; - -function task (opts) { - - return co(function* (){ - - let appName = normalizeName(packageJson.name); - let pipelineName = opts.pipeline || packageJson.name; - log.info('Deploy to ' + pipelineName); - let apps = yield pipelines.getApps(pipelineName); - if(!apps.staging){ - throw new Error('No staging app found'); - } - - if(!apps.production.eu){ - throw new Error('No EU production app found'); - } - - if(opts.multiregion && !apps.production.us){ - throw new Error('No US App Found - add --no-multiregion if it does not exist yet'); - } - - log.info('Found apps %j', apps); - - const REGISTRY_URI = opts.registry || DEFAULT_REGISTRY_URI; - - log.log('Using registry: ', REGISTRY_URI); - - if (opts.configure) { - log.log('Configure enabled'); - let source = pipelineName; - let configureTasks = [ - configure({ - source: source, - target: apps.staging, - registry: REGISTRY_URI - }), - configure({ - source: source, - target: apps.production.eu, - overrides: ['REGION=EU'], - registry: REGISTRY_URI - }) - ]; - if (opts.multiregion) { - configureTasks.push(configure({ - source: source, - target: apps.production.us, - overrides: ['REGION=US'], - registry: REGISTRY_URI - })); - } - - log.log('Configure all apps'); - yield Promise.all(configureTasks); - log.success('configure complete'); - } - - log.info('Scale staging app to 1 dyno'); - yield scale({ - source: appName, - target: apps.staging, - minimal: true, - registry: REGISTRY_URI - }).catch(function (){ - log.info('Failed to scale up staging app - is this the first run?'); - }); - - log.info('Deploy to staging app and run gtg checks'); - yield deploy({ app: apps.staging, authenticatedSmokeTests: true}); - log.success('Deploy successful'); - - log.warn('Enabling of preboot is deprecated because Heroku have changed the API and we had already decided to change the approach'); - - log.info('Promote slug to production'); - yield pipelines.promote(apps.staging); - log.success('Slug promoted'); - if(opts.scale){ - log.log('scale enabled'); - let source = appName; - let scaleTasks = [ - scale({ - source:source, - target:apps.staging, - registry: REGISTRY_URI - }), - scale({ - source:source, - target:apps.production.eu, - registry: REGISTRY_URI - }) - ]; - if(opts.multiregion){ - scaleTasks.push(scale({ - source:source, - target:apps.production.us, - registry: REGISTRY_URI - })); - } - - log.info('scale production apps'); - yield Promise.all(scaleTasks); - log.success('scale complete'); - } - - log.info('scale staging app back to 0'); - yield scale({ - source: appName, - target: apps.staging, - inhibit: true, - registry: REGISTRY_URI - }).catch(() => { - log.warn('Failed to scale down staging app'); - }); - - log.success('Shipped!'); - log.art.ship(appName); - }); -}; - -module.exports = function (program, utils) { - program - .command('ship') - .description('Ships code. Deploys using pipelines, also running the configure and scale steps automatically') - .option('-c --no-configure', 'Skip the configure step') - .option('-s --no-scale', 'Skip the scale step') - .option('-p --pipeline [name]', 'The name of the pipeline to deploy to. Defaults to the app name') - .option('-r, --registry [registry-uri]', `use this registry, instead of the default: ${DEFAULT_REGISTRY_URI}`, DEFAULT_REGISTRY_URI) - .option('-m --multiregion', 'Will expect a US app as well as an EU one') - .option('--vault', 'no-op, please remove this option from your Makefile') - .action(function (options){ - task(options).catch(utils.exit); - }); -}; - -module.exports.task = task; diff --git a/tasks/smoke.js b/tasks/smoke.js deleted file mode 100644 index d6cd0f11..00000000 --- a/tasks/smoke.js +++ /dev/null @@ -1,16 +0,0 @@ -const host = require('../lib/host'); -const SmokeTest = require('@financial-times/n-test').SmokeTest; - -module.exports = function (program) { - program - .command('smoke [app]') - .option('--auth', 'Authenticate with FT_NEXT_BACKEND_KEY') - .description('[DEPRECATED - Use n-test directly]. Tests that a given set of urls for an app respond as expected. Expects the config file ./test/smoke.js to exist') - .action(function (app, opts) { - const smoke = new SmokeTest({ - host: host.url(app), - headers: opts.auth ? { 'FT-NEXT-BACKEND-KEY': process.env.FT_NEXT_BACKEND_KEY } : null - }); - smoke.run(); - }); -}; diff --git a/tasks/test-urls.js b/tasks/test-urls.js deleted file mode 100644 index b961ec75..00000000 --- a/tasks/test-urls.js +++ /dev/null @@ -1,15 +0,0 @@ -module.exports = function (program) { - program - .command('test-urls [app]') - .description('Tests that a given set of urls for an app respond as expected. Expects the config file ./test/smoke.js to exist') - .option('-t, --throttle ', 'The maximum number of tests to run concurrently. default: 5') - .option('-c, --config', 'Path to config file, relative to cwd [default test/smoke]') - .action(function (app, options) { - console.warn('test-urls is deprecated. Smoke tests are now carried out as part of the deploy, ship and float tasks. \n To run smoke tests against a local app use e.g. `nht smoke local.ft.com:5050`'); // eslint-disable-line no-console - if (options.configPath) { - console.warn('Custom config path for test-urls is deprecated. Config must now be held in ./test/smoke.js'); // eslint-disable-line no-console - } - }); -}; - -module.exports.task = function () {}; diff --git a/test/drydock.test.js b/test/drydock.test.js deleted file mode 100644 index 4fbf6ce7..00000000 --- a/test/drydock.test.js +++ /dev/null @@ -1,96 +0,0 @@ -const expect = require('chai').expect; -const sinon = require('sinon'); - -const drydockTask = require('../tasks/drydock').task; -const pipelines = require('../lib/pipelines'); -const provision = require('../tasks/provision'); -const destroy = require('../tasks/destroy'); - -describe('Drydock', () => { - - const mockAppName = 'ft-next-testing'; - const mockOrganisation = 'unit-test-ft'; - let sandbox; - let provisionStub; - let destroyStub; - - beforeEach(() => { - sandbox = sinon.sandbox.create(); - sandbox.stub(pipelines, 'create').resolves({}); - sandbox.stub(pipelines, 'addAppToPipeline').resolves({}); - provisionStub = sandbox.stub(provision, 'task'); - destroyStub = sandbox.stub(destroy, 'task'); - }); - - afterEach(() => { - sandbox.restore(); - }); - - it('should always provision a staging app', () => { - return drydockTask(mockAppName, { organisation: mockOrganisation }) - .then(() => { - expect(provisionStub.calledWith(`${mockAppName}-staging`)).to.equal(true); - }); - }); - - it('should only provision an eu production stage app if not multiregion', () => { - return drydockTask(mockAppName, { organisation: mockOrganisation }) - .then(() => { - expect(provisionStub.calledWith(`${mockAppName}-eu`, { region: 'eu', organisation: mockOrganisation })).to.equal(true); - expect(provisionStub.calledWith(`${mockAppName}-us`)).to.equal(false); - }); - }); - - it('should create the pipeline properly', () => { - return drydockTask(mockAppName, { organisation: mockOrganisation }) - .then(() => { - expect(pipelines.create.calledWith(mockAppName, { stagingApp: `${mockAppName}-staging`, organisation: mockOrganisation })).to.equal(true); - }); - }); - - it('should only add the eu app to the pipeline', () => { - return drydockTask(mockAppName, { organisation: mockOrganisation }) - .then(() => { - expect(pipelines.addAppToPipeline.calledWith(mockAppName, `${mockAppName}-eu`, 'production')).to.equal(true); - expect(pipelines.addAppToPipeline.calledWith(mockAppName, `${mockAppName}-us`, 'production')).to.equal(false); - }); - }); - - context('when multiregion', () => { - - it('should provision an app in both EU and the US regions', () => { - return drydockTask(mockAppName, { multiregion: true, organisation: mockOrganisation }) - .then(() => { - expect(provisionStub.calledWith(`${mockAppName}-eu`, { region: 'eu', organisation: mockOrganisation })).to.equal(true); - expect(provisionStub.calledWith(`${mockAppName}-us`, { region: 'us', organisation: mockOrganisation })).to.equal(true); - }); - }); - - it('should add both region apps to the pipeline', () => { - return drydockTask(mockAppName, { multiregion: true, organisation: mockOrganisation }) - .then(() => { - expect(pipelines.addAppToPipeline.calledWith(mockAppName, `${mockAppName}-eu`, 'production')).to.equal(true); - expect(pipelines.addAppToPipeline.calledWith(mockAppName, `${mockAppName}-us`, 'production')).to.equal(true); - }); - }); - - }); - - context('if an error occurs', () => { - - beforeEach(() => { - pipelines.create.rejects(new Error('Some test pipeline creation error')); - }); - - it('should cleanup', () => { - return drydockTask(mockAppName, { multiregion: true, organisation: mockOrganisation }) - .catch(() => { - expect(destroyStub.calledWith({ app: `${mockAppName}-staging` }), 'staging app destroyed').to.equal(true); - expect(destroyStub.calledWith({ app: `${mockAppName}-eu` }), 'eu app destroyed').to.equal(true); - expect(destroyStub.calledWith({ app: `${mockAppName}-us` }), 'us app destroyed').to.equal(true); - }); - }); - - }); - -}); diff --git a/test/fixtures/smoke.js b/test/fixtures/smoke.js deleted file mode 100644 index 965e4589..00000000 --- a/test/fixtures/smoke.js +++ /dev/null @@ -1,47 +0,0 @@ -module.exports = [ - { - urls: { - '/get-200': 200, - '/get-404': 404, - '/get-302': 302, - '/get-302': 'http://www.thing.com/' - } - }, { - headers: { - 'test-header': 'header-value' - }, - urls: { - '/get-header': 200 - } - }, { - method: 'POST', - urls: { - '/post-200': 200, - '/post-404': 404, - '/post-302': 302, - '/post-302': 'http://www.thing.com/' - } - }, { - method: 'POST', - body: {foo: 'bar'}, - headers: { - 'Content-Type': 'application/json' - }, - urls: { - '/post-json': 200, - '/post-json-302': 302, - '/post-json-302': 'http://www.thing.com/' - } - }, { - method: 'POST', - body: 'foo=bar', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - urls: { - '/post-form': 200, - '/post-form-302': 302, - '/post-form-302': 'http://www.thing.com/' - } - } -]; diff --git a/test/fixtures/vcl/main.vcl b/test/fixtures/vcl/main.vcl deleted file mode 100644 index 0a807460..00000000 --- a/test/fixtures/vcl/main.vcl +++ /dev/null @@ -1 +0,0 @@ -set req.http.Auhorisation = "${AUTH_KEY}"; diff --git a/test/mocks/fastly.mock.js b/test/mocks/fastly.mock.js deleted file mode 100644 index e2cd1045..00000000 --- a/test/mocks/fastly.mock.js +++ /dev/null @@ -1,39 +0,0 @@ -'use strict'; -let sinon = require('sinon'); - -let fakeServiceId = '1234567'; - -function mockPromiseMethod (obj, name, value){ - obj[name] = sinon.stub().returns(Promise.resolve(value)); -} - -let methods = { - 'getServices': [{id:fakeServiceId}], - 'cloneVersion': {number:1}, - 'getVcl': [{name:'blah.vcl'}], - 'deleteVcl' : null, - 'updateVcl' : null, - 'setVclAsMain': null, - 'validateVersion': {status:'ok'}, - 'activateVersion': null -}; - -let mock = {}; -let called = false; - -module.exports = function (){ - if(called){ - return mock; - } - - mock = {}; - let func = mockPromiseMethod.bind(null, mock); - Object.keys(methods).forEach(function (key){ - func(key, methods[key]); - }); - - called = true; - return mock; -}; - -module.exports.fakeServiceId = fakeServiceId; diff --git a/test/ship.alternateRegistry.test.js b/test/ship.alternateRegistry.test.js deleted file mode 100644 index 33a315ff..00000000 --- a/test/ship.alternateRegistry.test.js +++ /dev/null @@ -1,174 +0,0 @@ -'use strict'; - -const mockery = require('mockery'); -const sinon = require('sinon'); -const co = require('co'); - -const OVERRIDE_REGISTRY_URI = 'https://next-registry.ft.com/v2/services.kat.json'; - -describe('tasks/ship (using alternate registry)', function (){ - - function stubbedPromise (value, property){ - const stub = sinon.stub().returns(Promise.resolve(value)); - if (property) { - const obj = {}; - obj[property] = stub; - return obj; - } else { - return stub; - } - } - - let mockApps = { - staging: 'ft-kat-app-staging', - production: { - eu: 'ft-kat-app-eu', - us: 'ft-kat-app-us' - } - }; - - let ship; - let mockScale = stubbedPromise(null, 'task'); - let mockConfigure = stubbedPromise(null, 'task'); - let mockDeploy = stubbedPromise(null, 'task'); - let mockPipelines = {getApps: stubbedPromise(mockApps), supported:stubbedPromise(true), promote:stubbedPromise(null)}; - - before(function (){ - mockery.registerMock('./configure', mockConfigure); - mockery.registerMock('./deploy', mockDeploy); - mockery.registerMock('./scale', mockScale); - mockery.registerMock('../lib/pipelines', mockPipelines); - mockery.registerMock(process.cwd() + '/package.json', { name: 'ft-kat-app' }); - mockery.enable({warnOnUnregistered:false,useCleanCache:true}); - ship = require('../tasks/ship').task; - }); - - after(function (){ - mockery.deregisterMock('./configure'); - mockery.deregisterMock('./deploy'); - mockery.deregisterMock('./scale'); - mockery.deregisterMock('../lib/pipelines'); - mockery.deregisterMock(process.cwd() + '/package.json'); - mockery.disable(); - }); - - it('Should be able to run the configure task on all apps, using the pipeline name as the source', function (){ - let pipelineName = 'test'; - return co(function* (){ - yield ship({ - pipeline:pipelineName, - configure:true, - multiregion:true, - registry: OVERRIDE_REGISTRY_URI - }); - - sinon.assert.calledWith(mockConfigure.task, { - source: pipelineName, - target: mockApps.staging, - registry: OVERRIDE_REGISTRY_URI, - }); - sinon.assert.calledWith(mockConfigure.task, { - source: pipelineName, - target: mockApps.production.eu, - overrides: ['REGION=EU'], - registry: OVERRIDE_REGISTRY_URI, - }); - sinon.assert.calledWith(mockConfigure.task, { - source: pipelineName, - target: mockApps.production.us, - overrides: ['REGION=US'], - registry: OVERRIDE_REGISTRY_URI, - }); - }); - }); - - it('Should scale to staging app up to 1 web dyno before deploying', function (){ - let pipelineName = 'test'; - - return co(function* (){ - yield ship({ - pipeline:pipelineName, - registry: OVERRIDE_REGISTRY_URI - }); - - sinon.assert.calledWith(mockScale.task, { - source: 'kat-app', - target:mockApps.staging, - minimal:true, - registry: OVERRIDE_REGISTRY_URI - }); - }); - }); - - it('Should be able to deploy to the staging app', function (){ - let pipelineName = 'test'; - return co(function* (){ - yield ship({ - pipeline:pipelineName, - registry: OVERRIDE_REGISTRY_URI - }); - - sinon.assert.calledWith(mockDeploy.task, { app:mockApps.staging, authenticatedSmokeTests: true }); - }); - }); - - it('Should be able to promote the slug to production', function (){ - let pipelineName = 'test'; - return co(function* (){ - yield ship({ - pipeline:pipelineName, - registry: OVERRIDE_REGISTRY_URI - }); - - sinon.assert.calledWith(mockPipelines.promote, mockApps.staging); - }); - }); - - it('Should be able to run the scale task on the production apps', function (){ - let pipelineName = 'test'; - return co(function* (){ - yield ship({ - pipeline:pipelineName, - scale:true, - multiregion:true, - registry: OVERRIDE_REGISTRY_URI - }); - - sinon.assert.calledWith(mockScale.task, { - source:'kat-app', - target:mockApps.staging, - registry: OVERRIDE_REGISTRY_URI - }); - sinon.assert.calledWith(mockScale.task, { - source:'kat-app', - target:mockApps.production.eu, - registry: OVERRIDE_REGISTRY_URI - }); - sinon.assert.calledWith(mockScale.task, { - source:'kat-app', - target:mockApps.production.us, - registry: OVERRIDE_REGISTRY_URI - }); - - }); - }); - - it('Should scale the staging app down to 0 when complete', function (){ - let pipelineName = 'test'; - - return co(function* (){ - yield ship({ - pipeline:pipelineName, - registry: OVERRIDE_REGISTRY_URI - }); - - sinon.assert.calledWith(mockScale.task, { - source:'kat-app', - target:mockApps.staging, - inhibit:true, - registry: OVERRIDE_REGISTRY_URI - }); - }); - }); - -}); diff --git a/test/ship.test.js b/test/ship.test.js deleted file mode 100644 index 3c29cb63..00000000 --- a/test/ship.test.js +++ /dev/null @@ -1,155 +0,0 @@ -'use strict'; - -const mockery = require('mockery'); -const sinon = require('sinon'); -const co = require('co'); - -const DEFAULT_REGISTRY_URI = 'https://next-registry.ft.com/v2/'; - -describe('tasks/ship', function (){ - - function stubbedPromise (value, property){ - const stub = sinon.stub().returns(Promise.resolve(value)); - if (property) { - const obj = {}; - obj[property] = stub; - return obj; - } else { - return stub; - } - } - - let mockApps = { - staging: 'ft-next-app-staging', - production: { - eu: 'ft-next-app-eu', - us: 'ft-next-app-us' - } - }; - - let ship; - let mockScale = stubbedPromise(null, 'task'); - let mockConfigure = stubbedPromise(null, 'task'); - let mockDeploy = stubbedPromise(null, 'task'); - let mockPipelines = {getApps: stubbedPromise(mockApps), supported:stubbedPromise(true), promote:stubbedPromise(null)}; - - before(function (){ - mockery.registerMock('./configure', mockConfigure); - mockery.registerMock('./deploy', mockDeploy); - mockery.registerMock('./scale', mockScale); - mockery.registerMock('../lib/pipelines', mockPipelines); - mockery.registerMock(process.cwd() + '/package.json', { name: 'ft-next-sample-app' }); - mockery.enable({warnOnUnregistered:false,useCleanCache:true}); - ship = require('../tasks/ship').task; - }); - - after(function (){ - mockery.deregisterMock('./configure'); - mockery.deregisterMock('./deploy'); - mockery.deregisterMock('./scale'); - mockery.deregisterMock('../lib/pipelines'); - mockery.deregisterMock(process.cwd() + '/package.json'); - mockery.disable(); - }); - - it('Should be able to run the configure task on all apps, using the pipeline name as the source', function (){ - let pipelineName = 'test'; - return co(function* (){ - yield ship({pipeline:pipelineName, configure:true, multiregion:true}); - - sinon.assert.calledWith(mockConfigure.task, { - source: pipelineName, - target: mockApps.staging, - registry: DEFAULT_REGISTRY_URI - }); - sinon.assert.calledWith(mockConfigure.task, { - source: pipelineName, - target: mockApps.production.eu, - overrides: ['REGION=EU'], - registry: DEFAULT_REGISTRY_URI - }); - sinon.assert.calledWith(mockConfigure.task, { - source: pipelineName, - target: mockApps.production.us, - overrides: ['REGION=US'], - registry: DEFAULT_REGISTRY_URI - }); - }); - }); - - it('Should scale to staging app up to 1 web dyno before deploying', function (){ - let pipelineName = 'test'; - let appName = 'sample-app'; - - return co(function* (){ - yield ship({pipeline:pipelineName}); - - sinon.assert.calledWith(mockScale.task, { - source:appName, - target:mockApps.staging, - minimal:true, - registry: DEFAULT_REGISTRY_URI - }); - }); - }); - - it('Should be able to deploy to the staging app', function (){ - let pipelineName = 'test'; - return co(function* (){ - yield ship({pipeline:pipelineName}); - - sinon.assert.calledWith(mockDeploy.task, { app:mockApps.staging, authenticatedSmokeTests: true }); - }); - }); - - it('Should be able to promote the slug to production', function (){ - let pipelineName = 'test'; - return co(function* (){ - yield ship({pipeline:pipelineName}); - - sinon.assert.calledWith(mockPipelines.promote, mockApps.staging); - }); - }); - - it('Should be able to run the scale task on the production apps', function (){ - let pipelineName = 'test'; - let appName = 'sample-app'; - return co(function* (){ - yield ship({pipeline:pipelineName,scale:true,multiregion:true}); - - sinon.assert.calledWith(mockScale.task, { - source:appName, - target:mockApps.staging, - registry: DEFAULT_REGISTRY_URI - }); - sinon.assert.calledWith(mockScale.task, { - source:appName, - target:mockApps.production.eu, - registry: DEFAULT_REGISTRY_URI - }); - sinon.assert.calledWith(mockScale.task, { - source:appName, - target:mockApps.production.us, - registry: DEFAULT_REGISTRY_URI - }); - - }); - }); - - it('Should scale the staging app down to 0 when complete', function (){ - let pipelineName = 'test'; - let appName = 'sample-app'; - - return co(function* (){ - yield ship({pipeline:pipelineName}); - - sinon.assert.calledWith(mockScale.task, { - source:appName, - target:mockApps.staging, - inhibit:true, - registry: DEFAULT_REGISTRY_URI - }); - }); - }); - -});