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
-
-
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
- });
- });
- });
-
-});