diff --git a/README.md b/README.md index 7ff5d41..cc4e030 100644 --- a/README.md +++ b/README.md @@ -21,14 +21,12 @@ then create a new `Makefile` file with the following: ```make # n-gage bootstrapping logic -node_modules/@financial-times/n-gage/index.mk: - npm install --no-save --no-package-lock @financial-times/n-gage - touch $@ - --include node_modules/@financial-times/n-gage/index.mk +include $(shell npx -p @financial-times/n-gage ngage bootstrap) ``` -See [here](#bootstrapping) for more explanation of the bootstrapping logic. You will want to add `unit-test`, `test`, `provision`, `smoke` and `deploy` tasks to the `Makefile`. See other, similar Next projects for ideas. +See [the bootstrap command documentation](#bootstrap) for more explanation of this logic. You will want to add `unit-test`, `test`, `provision`, `smoke` and `deploy` tasks to the `Makefile`. See other, similar Next projects for ideas. + +If your Makefile is using the [old bootstrap code](https://github.com/Financial-Times/n-gage/blob/v2.0.4/README.md#getting-started), you should update to the new bootstrap by running `make update-bootstrap` (or [`ngage update-bootstrap Makefile`](#update-bootstrap)). The new bootstrapping code is backwards compatible, and old-bootstrap makefiles will continue to work, but future improvements to the bootstrap are far easier to distribute with the new method. ## Make tasks @@ -38,14 +36,25 @@ See in [index.mk](index.mk) for all the different tasks you can use in your `Mak This includes a CLI for you to use to do some things. -### get-config +```sh +$ ngage --help +ngage -This tool helps you to obtain configuration for your project. +Commands: + ngage bootstrap called by makefiles to include n-gage + ngage get-config get environment variables from Vault + ngage update-bootstrap migrate a makefile from bootstrap v1 to v2 -```sh -$ ngage -ngage get-config --help +Options: + --version Show version number [boolean] + --help Show help [boolean] +``` + +### `get-config` +This command helps you to obtain configuration for your project. + +```sh $ ngage get-config --help Options: --help Show help [boolean] @@ -66,13 +75,13 @@ $ cat .env-ci } ``` +The `--team` option lets you specify a team if not `next` (must match Vault path). + ```sh $ ngage get-config --team myteam ``` -The `--team` option lets you specify a team if not `next` (must match Vault path). - -### FT User Sessions +#### FT User Sessions To get `FTSession` and `FTSession_s` environment variables to be populated with up-to-date session tokens from test users, add the following environment variables to your `development` and/or `continuous-integration` configs in the Vault: @@ -86,27 +95,17 @@ As a result of this, `{USER_TYPE}_FTSession` and `{USER_TYPE}_FTSession_s` envir Multiple user types can be specified in the TEST_USER_TYPES variable. -*Example* +##### Example If you set `TEST_USER_TYPES` environment variable to `premium,standard`, these variables will be populated in the `.env` file: `PREMIUM_FTSession`, `PREMIUM_FTSession_s`, `STANDARD_FTSession`, `STANDARD_FTSession_s` -## Bootstrapping +### `bootstrap` -Curious how the bootstrapping bit at top of the `Makefile` works? Here's the annotated code: +The `bootstrap` command is the main entry point to `n-gage` for makefiles. On its own, it outputs the path to `index.mk`. It's intended to be run using `make`'s `$(shell)` function, passing the result to `include`. -```make -# This task tells make how to 'build' n-gage. It npm installs n-gage, and -# Once that's done it overwrites the file with its own contents - this -# ensures the timestamp on the file is recent, so make won't think the file -# is out of date and try to rebuild it every time -node_modules/@financial-times/n-gage/index.mk: - npm install --no-save @financial-times/n-gage - touch $@ - -# If, by the end of parsing your `Makefile`, `make` finds that any files -# referenced with `-include` don't exist or are out of date, it will run any -# tasks it finds that match the missing file. So if n-gage *is* installed -# it will just be included; if not, it will look for a task to run --include node_modules/@financial-times/n-gage/index.mk -``` +Running this command using [`npx`](https://www.npmjs.com/package/npx) (which is included in `npm` v5 and above) will use the `n-gage` installed in `node_modules`, if it's there; if not, it'll install it. This lets you run `make` without first running `npm install`, and subsequent runs of `make install` won't be interfered with because the automatically-installed `n-gage` is stored in `npm`'s cache, not `node_modules`. + +### `update-bootstrap` + +Updates the makefile passed in from v1 bootstrap to v2. See [this Pull Request](https://github.com/Financial-Times/n-gage/pull/132#issue-219628923) for context. If the original bootstrap has been modified in your makefile, this command won't do anything, but print out what it expected to see and what to replace it with. diff --git a/index.mk b/index.mk index e597636..52924e0 100644 --- a/index.mk +++ b/index.mk @@ -46,6 +46,7 @@ DONE = echo ✓ $@ done IS_USER_FACING = `find . -type d \( -path ./bower_components -o -path ./node_modules -o -path ./coverage \) -prune -o -name '*.html' -print` MAKEFILE_HAS_A11Y = `grep -rli "a11y" Makefile` REPLACE_IN_GITIGNORE = sed -i -e 's/$1/$2/g' .gitignore && rm -f .gitignore-e ||: +ENTRY_MAKEFILE = $(firstword $(MAKEFILE_LIST)) # Show help when run without any target .DEFAULT_GOAL := help @@ -283,6 +284,9 @@ else fi; endif +update-bootstrap: ## update-bootstrap: update makefile bootstrap v1 to v2 + ngage update-bootstrap $(ENTRY_MAKEFILE) + # some aliases css: nui build --sass-only diff --git a/scripts/commands/bootstrap.js b/scripts/commands/bootstrap.js new file mode 100644 index 0000000..88b7135 --- /dev/null +++ b/scripts/commands/bootstrap.js @@ -0,0 +1,5 @@ +exports.command = 'bootstrap'; +exports.describe = 'called by makefiles to include n-gage'; +exports.handler = () => { + console.log(require.resolve('../index.mk')); +}; diff --git a/scripts/get-config.js b/scripts/commands/get-config.js similarity index 75% rename from scripts/get-config.js rename to scripts/commands/get-config.js index 67768b2..6b8f5a8 100755 --- a/scripts/get-config.js +++ b/scripts/commands/get-config.js @@ -2,10 +2,13 @@ const fetch = require('@financial-times/n-fetch'); const fs = require('fs'); const path = require('path'); const os = require('os'); -const appendSessionTokens = require('./append-session-tokens'); +const appendSessionTokens = require('../append-session-tokens'); -const opts = require('yargs') - .option('app', (() => { +exports.command = 'get-config'; +exports.describe = 'get environment variables from Vault'; + +exports.builder = yargs => (yargs + .option('app', (() => { // try get app name from the package.json try { return { @@ -19,24 +22,24 @@ const opts = require('yargs') } } })()) - .option('env', { + .option('env', { choices: ['dev', 'prod', 'ci', 'test'], default: 'dev' }) - .option('filename', { + .option('filename', { coerce: value => typeof value === 'string' ? value : '.env', default: '.env' }) - .option('format', { + .option('format', { + choices: ['simple', 'json'], default: 'simple' }) - .option('team', { + .option('team', { coerce: value => typeof value === 'string' ? value : 'next', default: 'next' - }) - .help() - .argv; + }) +); const getToken = () => { if (process.env.CIRCLECI) { @@ -64,6 +67,7 @@ const getVaultPaths = (ftApp, env, team) => { const app = ftApp.replace(/^ft-/, ''); const vaultEnvs = { dev: 'development', prod: 'production', ci: 'continuous-integration', test: 'test' }; const vaultEnv = vaultEnvs[env]; + return [ `secret/teams/${team}/${app}/${vaultEnv}`, `secret/teams/${team}/${app}/shared`, @@ -71,8 +75,8 @@ const getVaultPaths = (ftApp, env, team) => { ]; }; -const parseKeys = (app, appShared, envShared) => { - if (opts.env === 'ci') { +const parseKeys = (env, app, appShared, envShared) => { + if (env === 'ci') { return Object.assign({}, app.env, envShared); } else { const shared = appShared.env.reduce((keys, key) => { @@ -97,16 +101,17 @@ const format = (keys, mode) => { // LET'S GO! -module.exports = () => { +exports.handler = argv => { getToken() .then(token => { - return Promise.all(getVaultPaths(opts.app, opts.env, opts.team).map(path => { + return Promise.all(getVaultPaths(argv.app, argv.env, argv.team).map(path => { const url = 'https://vault.in.ft.com/v1/' + path; + console.log(url); const vaultFetch = fetch(url, { headers: { 'X-Vault-Token': token } }) - .then(json => json.data || {}); + .then(json => json.data || {}); - if (opts.env === 'dev') { + if (argv.env === 'dev') { return vaultFetch.catch(err => { console.warn(`Couldn't get config at ${url}.`); }); @@ -114,17 +119,17 @@ module.exports = () => { return vaultFetch; } })) - .then(([app, appShared, envShared]) => parseKeys(app, appShared, envShared)) + .then(([app, appShared, envShared]) => parseKeys(argv.env, app, appShared, envShared)) .then((keys) => appendSessionTokens(keys)) .then((keys) => { - const content = format(keys, opts.format); - const file = path.join(process.cwd(), opts.filename || '.env'); + const content = format(keys, argv.format); + const file = path.join(process.cwd(), argv.filename || '.env'); fs.writeFileSync(file, content); - console.log(`Written ${opts.app}'s ${opts.env} config to ${file}`); + console.log(`Written ${argv.app}'s ${argv.env} config to ${file}`); }); }) - .catch(error => { - console.error(error); - process.exit(14); - }); + .catch(error => { + console.error(error); + process.exit(14); + }); }; diff --git a/scripts/commands/update-bootstrap.js b/scripts/commands/update-bootstrap.js new file mode 100644 index 0000000..56baf23 --- /dev/null +++ b/scripts/commands/update-bootstrap.js @@ -0,0 +1,55 @@ +const fs = require('fs'); + +exports.command = 'update-bootstrap '; +exports.describe = 'migrate a makefile from bootstrap v1 to v2'; + +const oldBootstrap = `node_modules/@financial-times/n-gage/index.mk: + npm install --no-save --no-package-lock @financial-times/n-gage + touch $@ + +-include node_modules/@financial-times/n-gage/index.mk`; + +const newBootstrap = 'include $(shell npx -p @financial-times/n-gage ngage bootstrap)'; + +const twee = 'have a nice day! xoxoxox'; + +exports.handler = argv => { + let content; + + try { + content = fs.readFileSync(argv.makefile, 'utf8'); + } catch(e) { + // probably the file doesn't exist. this shouldn't happen unless someone runs `ngage update-bootstrap nonexistent/Makefile` + console.log(`yeah we couldn't read from ${argv.makefile}, make sure that's a real thing`); + throw e; + } + + const replaced = content.replace(oldBootstrap, newBootstrap); + + if(replaced === content) { + console.log( +`looks like your makefile isn't using the standard n-gage v1 bootstrap, or it's already been migrated to v2. have a look at ${argv.makefile}, and if there's something that looks like: + +${oldBootstrap} + +please replace it with: + +${newBootstrap} + +${twee}`); + + return; + } + + try { + fs.writeFileSync(argv.makefile, replaced, 'utf8'); + } catch(e) { + console.log(`yeah we couldn't write to ${argv.makefile}, dunno what's up with that, sorry,`); + throw e; + } + + console.log( +`bootstrap updated to v2! check that ${argv.makefile} looks good and commit it plz + +${twee}`); +}; diff --git a/scripts/ngage.js b/scripts/ngage.js index 4b78357..5a0cff4 100755 --- a/scripts/ngage.js +++ b/scripts/ngage.js @@ -1,7 +1,7 @@ #!/usr/bin/env node -if (process.argv.length > 1 && process.argv[2] === 'get-config') { - require('./get-config')(); -} else { - console.log('ngage get-config --help'); -} \ No newline at end of file +require('yargs') + .commandDir('commands') + .demandCommand() + .help() + .argv; diff --git a/test/get-config.test.js b/test/get-config.test.js index 74c9aea..7dd4598 100644 --- a/test/get-config.test.js +++ b/test/get-config.test.js @@ -1,6 +1,7 @@ const proxyquire = require('proxyquire'); const sinon = require('sinon'); const expect = require('chai').expect; +const yargs = require('yargs'); require('chai').use(require('sinon-chai')); describe('get-config', () => { @@ -17,11 +18,16 @@ describe('get-config', () => { fetch.withArgs('https://vault.in.ft.com/v1/secret/teams/next/myapp/production', sinon.match.object).returns(Promise.resolve({ data: { a: 'z' }})); fetch.withArgs('https://vault.in.ft.com/v1/secret/teams/next/myapp/shared', sinon.match.object).returns(Promise.resolve({ data: { env: [ 'b' ]}})); fetch.withArgs('https://vault.in.ft.com/v1/secret/teams/next/shared/production', sinon.match.object).returns(Promise.resolve({ data: { b: 'y' }})); - proxyquire('../scripts/get-config', { - 'yargs': { argv: require('yargs')(['', '', '--app', 'myapp', '--env', 'prod', '--format', 'simple']).argv }, + + const {builder, handler} = proxyquire('../scripts/commands/get-config', { '@financial-times/n-fetch': fetch, 'fs': { writeFileSync } - })(); + }); + + const args = builder(yargs(['', '', '--app', 'myapp', '--env', 'prod', '--format', 'simple'])).argv; + + handler(args); + setTimeout(() => { expect(writeFileSync).to.have.been.called; expect(writeFileSync).to.have.been.calledWith(sinon.match.string, 'a=z\nb=y\n'); @@ -37,11 +43,16 @@ describe('get-config', () => { fetch.withArgs('https://vault.in.ft.com/v1/secret/teams/next/myapp/development', { headers: { 'X-Vault-Token': 'mytoken' }}).returns(Promise.resolve({ data: { a: 'z' }})); fetch.withArgs('https://vault.in.ft.com/v1/secret/teams/next/myapp/shared', { headers: { 'X-Vault-Token': 'mytoken' }}).returns(Promise.resolve({ data: { env: [ 'b' ]}})); fetch.withArgs('https://vault.in.ft.com/v1/secret/teams/next/shared/development', { headers: { 'X-Vault-Token': 'mytoken' }}).returns(Promise.resolve({ data: { b: 'y' }})); - proxyquire('../scripts/get-config', { - 'yargs': { argv: require('yargs')(['', '', '--app', 'myapp', '--env', 'dev', '--format', 'json']).argv }, + + const {builder, handler} = proxyquire('../scripts/commands/get-config', { '@financial-times/n-fetch': fetch, 'fs': { writeFileSync } - })(); + }) + + const args = builder(yargs(['', '', '--app', 'myapp', '--env', 'dev', '--format', 'json'])).argv; + + handler(args); + setTimeout(() => { expect(writeFileSync).to.have.been.called; expect(writeFileSync).to.have.been.calledWith(sinon.match.string, '{\n "b": "y",\n "a": "z"\n}'); @@ -63,12 +74,17 @@ describe('get-config', () => { fetch.withArgs('https://vault.in.ft.com/v1/secret/teams/next/myapp/production', { headers: { 'X-Vault-Token': 'my-token' }}).returns(Promise.resolve({ data: { a: 'z' }})); fetch.withArgs('https://vault.in.ft.com/v1/secret/teams/next/myapp/shared', { headers: { 'X-Vault-Token': 'my-token' }}).returns(Promise.resolve({ data: { env: [ 'b' ]}})); fetch.withArgs('https://vault.in.ft.com/v1/secret/teams/next/shared/production', { headers: { 'X-Vault-Token': 'my-token' }}).returns(Promise.resolve({ data: { b: 'y' }})); - proxyquire('../scripts/get-config', { - 'yargs': { argv: require('yargs')(['', '', '--app', 'myapp', '--env', 'prod', '--format', 'simple']).argv }, + + const {builder, handler} = proxyquire('../scripts/commands/get-config', { '@financial-times/n-fetch': fetch, 'fs': { readFile, writeFileSync }, 'os': { homedir } - })(); + }); + + const args = builder(yargs(['', '', '--app', 'myapp', '--env', 'prod', '--format', 'simple'])).argv; + + handler(args); + setTimeout(() => { expect(writeFileSync).to.have.been.called; expect(writeFileSync).to.have.been.calledWith(sinon.match.string, 'a=z\nb=y\n'); @@ -84,11 +100,16 @@ describe('get-config', () => { fetch.withArgs('https://vault.in.ft.com/v1/secret/teams/next/myapp/continuous-integration', sinon.match.object).returns(Promise.resolve({ data: { env: { a: 'z' }}})); fetch.withArgs('https://vault.in.ft.com/v1/secret/teams/next/myapp/shared', sinon.match.object).returns(Promise.resolve({ data: {}})); fetch.withArgs('https://vault.in.ft.com/v1/secret/teams/next/shared/continuous-integration', sinon.match.object).returns(Promise.resolve({ data: { b: 'y' }})); - proxyquire('../scripts/get-config', { - 'yargs': { argv: require('yargs')(['', '', '--app', 'myapp', '--env', 'ci', '--format', 'simple']).argv }, + + const {builder, handler} = proxyquire('../scripts/commands/get-config', { '@financial-times/n-fetch': fetch, 'fs': { writeFileSync } - })(); + }); + + const args = builder(yargs(['', '', '--app', 'myapp', '--env', 'ci', '--format', 'simple'])).argv + + handler(args); + setTimeout(() => { expect(writeFileSync).to.have.been.called; expect(writeFileSync).to.have.been.calledWith(sinon.match.string, 'a=z\nb=y\n'); @@ -104,11 +125,16 @@ describe('get-config', () => { fetch.withArgs('https://vault.in.ft.com/v1/secret/teams/myteam/myapp/continuous-integration', sinon.match.object).returns(Promise.resolve({ data: { env: { a: 'z' }}})); fetch.withArgs('https://vault.in.ft.com/v1/secret/teams/myteam/myapp/shared', sinon.match.object).returns(Promise.resolve({ data: {}})); fetch.withArgs('https://vault.in.ft.com/v1/secret/teams/myteam/shared/continuous-integration', sinon.match.object).returns(Promise.resolve({ data: { b: 'y' }})); - proxyquire('../scripts/get-config', { - 'yargs': { argv: require('yargs')(['', '', '--app', 'myapp','--team', 'myteam', '--env', 'ci', '--format', 'simple']).argv }, + + const {builder, handler} = proxyquire('../scripts/commands/get-config', { '@financial-times/n-fetch': fetch, 'fs': { writeFileSync } - })(); + }); + + const args = builder(yargs(['', '', '--app', 'myapp','--team', 'myteam', '--env', 'ci', '--format', 'simple'])).argv; + + handler(args); + setTimeout(() => { expect(writeFileSync).to.have.been.called; expect(writeFileSync).to.have.been.calledWith(sinon.match.string, 'a=z\nb=y\n'); @@ -135,13 +161,18 @@ describe('get-config', () => { const appendSessionTokens = proxyquire('../scripts/append-session-tokens', { '@financial-times/n-fetch': fetch }); - proxyquire('../scripts/get-config', { - 'yargs': { argv: require('yargs')(['', '', '--app', 'myapp', '--env', 'dev', '--format', 'simple']).argv }, + + const {builder, handler} = proxyquire('../scripts/commands/get-config', { '@financial-times/n-fetch': fetch, 'fs': { readFile, writeFileSync }, 'os': { homedir }, - './append-session-tokens': appendSessionTokens - })(); + '../append-session-tokens': appendSessionTokens + }) + + const args = builder(yargs(['', '', '--app', 'myapp', '--env', 'dev', '--format', 'simple'])).argv; + + handler(args); + setTimeout(() => { expect(writeFileSync).to.have.been.called; expect(writeFileSync).to.have.been.calledWith(sinon.match.string, @@ -155,4 +186,4 @@ describe('get-config', () => { done(); }, 0); }); -}); \ No newline at end of file +});