Commit b6f35f61 authored by Dan Allen's avatar Dan Allen

merge !40

resolves #54 isolate playbook builder from process environment
parents 351fba54 a1ebe6cf
Pipeline #14717460 passed with stages
in 4 minutes and 14 seconds
......@@ -3,7 +3,7 @@ const buildPlaybook = require('../packages/playbook-builder/lib/index')
// ...
// run the pipeline
const playbook = buildPlaybook()
const playbook = buildPlaybook(process.argv.slice(2), process.env)
// test
console.log(playbook)
......@@ -33,11 +33,13 @@ All the details of loading the configuration, including the schema, the validati
The playbook builder component should:
* Load the built-in playbook schema and assign default option values
* Accept two parameters, an array of arguments (populated from or simulating process arguments) and a hash of variables (populated from or simulating environment variables)
* Accept two parameters, an array of arguments (populated from or simulating process arguments) and a map of variables (populated from or simulating environment variables)
** By accepting these two parameters, the playbook builder can be used and tested independently of the CLI runtime environment.
** Within those parameters, a playbook spec file may be specified, which is a third (and the bulk) method of user input.
* Look for the specified playbook spec file composed in YAML, JSON, or CSON format
** When the data in these formats are read in, they become plain JavaScript objects.
** If the playbook spec file is a relative path, assume it's relative to the process working directory
** If the path specified for the playbook spec file does not have a file extension, assume the file extension is `.yml`
* Apply audibles in the form of process arguments and environment variables
** The order of precedence for an option value is as follows (highest to lowest): process argument, environment variable, spec, default.
* Validate the aggregate playbook spec values
......@@ -57,8 +59,6 @@ The playbook builder component should:
The playbook builder component is implemented as a dedicated node package (i.e., module).
Its API exports the `buildPlaybook()` function, which reads environment variables, commandline flags and switches, and a playbook spec file to produce the playbook data model.
#FIXME# the buildPlaybook function should accept an array of process arguments and a hash of environment variables.
The playbook builder should:
* Be the main coordinator, though it may delegate work to subordinate objects
......@@ -72,6 +72,7 @@ The playbook builder should:
The API for the playbook builder should be used as follows:
////
[source,js]
----
const buildPlaybook = require ('../packages/playbook/lib/index')
......@@ -79,6 +80,20 @@ const buildPlaybook = require ('../packages/playbook/lib/index')
const playbook = buildPlaybook()
----
By default, the process arguments (i.e., `process.argv` and environment variables `process.env` are used as configuration input).
// Q: should args and env be assumed to be empty if not specified?
It should be possible to isolate the API call from the process environment by passing an array of arguments and map of environment variables:
////
[source,js]
----
const buildPlaybook = require ('../packages/playbook/lib/index')
const playbook = buildPlaybook(process.argv.slice(2), process.env)
----
The first argument is an array of process arguments (e.g., `+['--playbook', 'site.yml']+`) and the second is a map of environment variables (e.g., `+{ URL: 'http://example.com' }+`)
The properties of the playbook can be accessed as follows:
[source,js]
......
'use strict'
const convict = require('./solitary-convict')
const cson = require('cson-parser')
const freeze = require('deep-freeze')
const fs = require('fs')
const path = require('path')
const convict = require('convict')
const cson = require('cson-parser')
const deepFreeze = require('deep-freeze')
const defaultSchema = require('./config/schema')
const yaml = require('js-yaml')
function getConvictConfig (customSchema) {
if (customSchema != null) {
return convict(customSchema)
const loadConvictConfig = (args, env, customSchema) =>
convict(customSchema || require('./config/schema'), { args: args, env: env })
const parseSpecFile = (specFilePath) => {
const data = fs.readFileSync(specFilePath, 'utf8')
switch (path.extname(specFilePath)) {
case '.yml':
return yaml.safeLoad(data)
case '.json':
return JSON.parse(data)
case '.cson':
return cson.parse(data)
default:
throw new Error('Unsupported file type')
}
return convict(defaultSchema)
}
function loadSpecFile (specPath) {
const specExtname = path.extname(specPath)
const fileContents = fs.readFileSync(specPath, 'utf8')
module.exports = (args, env, schema) => {
const config = loadConvictConfig(args, env, schema)
if (specExtname === '.yml') {
return yaml.safeLoad(fileContents)
const specFileRelPath = config.get('playbook')
if (!specFileRelPath) {
throw new Error('Spec file for playbook not specified')
}
if (specExtname === '.json') {
return JSON.parse(fileContents)
}
if (specExtname === '.cson') {
return cson.parse(fileContents)
}
let specFileAbsPath = path.resolve(process.cwd(), specFileRelPath)
if (!path.extname(specFileAbsPath)) specFileAbsPath += '.yml'
throw new Error('Unknown file type')
}
module.exports = (customSchema) => {
const config = getConvictConfig(customSchema)
const specRelativePath = config.get('playbook')
if (specRelativePath == null) {
throw new Error('Playbook spec file cannot be found')
}
let specPath = path.resolve(process.cwd(), specRelativePath)
// assume implicit .yml extension
if (path.extname(specPath) === '') {
specPath += '.yml'
}
const spec = loadSpecFile(specPath)
config.load(spec)
config.load(parseSpecFile(specFileAbsPath))
config.validate({ allowed: 'strict' })
const playbook = config.getProperties()
// playbook path property should not leak
// playbook property is private; should not leak
delete playbook.playbook
const frozenPlaybook = deepFreeze(playbook)
return frozenPlaybook
return freeze(playbook)
}
const convict = require('convict')
/**
* A convict function wrapper that decouples it from the process environment.
* This wrapper allows the args array and env map to be specified as options.
*/
module.exports = (schema, opts = {}) => {
let processArgv
let args = opts.args || []
processArgv = process.argv
// NOTE convict expects first two arguments to be node command and script filename
let argv = processArgv.slice(0, 2).concat(args)
process.argv = argv
let processEnv
let env = opts.env || {}
processEnv = process.env
process.env = env
const config = convict(schema)
process.argv = processArgv
process.env = processEnv
const originalLoad = config.load
config.load = function (configOverlay) {
process.argv = argv
process.env = env
const combinedConfig = originalLoad.apply(this, [configOverlay])
process.argv = processArgv
process.env = processEnv
return combinedConfig
}
return config
}
......@@ -7,17 +7,9 @@ const buildPlaybook = require('../lib/index')
const path = require('path')
describe('buildPlaybook()', () => {
let originalEnv
let originalArgv
let schema
let expectedPlaybook
let schema, expectedPlaybook
beforeEach(() => {
originalArgv = process.argv
originalEnv = process.env
process.argv = ['/path/to/node', '/path/to/script.js']
process.env = {}
schema = {
playbook: {
format: String,
......@@ -28,8 +20,8 @@ describe('buildPlaybook()', () => {
one: {
format: String,
default: null,
arg: 'oneone',
env: 'ANTORA_ONEONE',
arg: 'one-one',
env: 'ANTORA_ONE_ONE',
},
two: {
format: String,
......@@ -64,13 +56,8 @@ describe('buildPlaybook()', () => {
}
})
afterEach(() => {
process.argv = originalArgv
process.env = originalEnv
})
const ymlSpec = path.resolve(__dirname, 'fixtures', 'spec-sample.yml')
const extensionLessSpec = path.resolve(__dirname, 'fixtures', 'spec-sample')
const extensionlessSpec = path.resolve(__dirname, 'fixtures', 'spec-sample')
const jsonSpec = path.resolve(__dirname, 'fixtures', 'spec-sample.json')
const csonSpec = path.resolve(__dirname, 'fixtures', 'spec-sample.cson')
const iniSpec = path.resolve(__dirname, 'fixtures', 'spec-sample.ini')
......@@ -79,139 +66,137 @@ describe('buildPlaybook()', () => {
const defaultSchemaSpec = path.resolve(__dirname, 'fixtures', 'default-schema-spec-sample.yml')
it('should throw error if no playbook spec file can be loaded', () => {
expect(() => buildPlaybook(schema)).to.throw()
expect(() => buildPlaybook([], {}, schema)).to.throw()
})
it('should load YML playbook spec file', () => {
process.env.PLAYBOOK = ymlSpec
const playbook = buildPlaybook(schema)
const playbook = buildPlaybook([], { PLAYBOOK: ymlSpec }, schema)
expectedPlaybook.one.one = 'yml-spec-value-one'
expect(playbook).to.eql(expectedPlaybook)
})
it('should load YML playbook spec file when no file extension is given', () => {
process.env.PLAYBOOK = extensionLessSpec
const playbook = buildPlaybook(schema)
const playbook = buildPlaybook([], { PLAYBOOK: extensionlessSpec }, schema)
expectedPlaybook.one.one = 'yml-spec-value-one'
expect(playbook).to.eql(expectedPlaybook)
})
it('should load JSON playbook spec file', () => {
process.env.PLAYBOOK = jsonSpec
const playbook = buildPlaybook(schema)
const playbook = buildPlaybook([], { PLAYBOOK: jsonSpec }, schema)
expectedPlaybook.one.one = 'json-spec-value-one'
expect(playbook).to.eql(expectedPlaybook)
})
it('should load CSON playbook spec file', () => {
process.env.PLAYBOOK = csonSpec
const playbook = buildPlaybook(schema)
const playbook = buildPlaybook([], { PLAYBOOK: csonSpec }, schema)
expectedPlaybook.one.one = 'cson-spec-value-one'
expect(playbook).to.eql(expectedPlaybook)
})
it('should throw error when loading unknown type file', () => {
process.env.PLAYBOOK = iniSpec
expect(() => buildPlaybook(schema)).to.throw()
expect(() => buildPlaybook([], { PLAYBOOK: iniSpec }, schema)).to.throw()
})
it('should throw error if spec file is specified but cannot be found', () => {
process.env.PLAYBOOK = 'file/not/found.yml'
expect(() => buildPlaybook(schema)).to.throw()
expect(() => buildPlaybook([], { PLAYBOOK: 'non-existent/file.yml' }, schema)).to.throw()
})
it('should use default value if spec file is not specified', () => {
process.env.PLAYBOOK = ymlSpec
const playbook = buildPlaybook(schema)
const playbook = buildPlaybook([], { PLAYBOOK: ymlSpec }, schema)
expect(playbook.one.two).to.equal('default-value')
})
it('should use env value over spec file value', () => {
process.env.PLAYBOOK = ymlSpec
process.env.ANTORA_ONEONE = 'the-env-value'
const playbook = buildPlaybook(schema)
const env = { PLAYBOOK: ymlSpec, ANTORA_ONE_ONE: 'the-env-value' }
const playbook = buildPlaybook([], env, schema)
expect(playbook.one.one).to.equal('the-env-value')
})
it('should use argv value over spec file value or env value', () => {
process.env.PLAYBOOK = ymlSpec
process.argv.push('--oneone', 'the-argv-value')
process.env.ANTORA_ONEONE = 'the-env-value'
const playbook = buildPlaybook(schema)
expect(playbook.one.one).to.equal('the-argv-value')
it('should use args value over spec file value or env value', () => {
const args = ['--one-one', 'the-args-value']
const env = { PLAYBOOK: ymlSpec, ANTORA_ONE_ONE: 'the-env-value' }
const playbook = buildPlaybook(args, env, schema)
expect(playbook.one.one).to.equal('the-args-value')
})
it('should coerce Number values', () => {
process.env.PLAYBOOK = ymlSpec
const playbook = buildPlaybook(schema)
it('should coerce Number values in spec file', () => {
const playbook = buildPlaybook([], { PLAYBOOK: ymlSpec }, schema)
expect(playbook.two).to.equal(42)
})
it('should coerce Number values (via env)', () => {
process.env.PLAYBOOK = ymlSpec
process.env.ANTORA_TWO = '777'
const playbook = buildPlaybook(schema)
it('should coerce Number values in env', () => {
const env = { PLAYBOOK: ymlSpec, ANTORA_TWO: '777' }
const playbook = buildPlaybook([], env, schema)
expect(playbook.two).to.equal(777)
})
it('should coerce Number values (via argv)', () => {
process.env.PLAYBOOK = ymlSpec
process.argv.push('--two', '777')
const playbook = buildPlaybook(schema)
it('should coerce Number values in args', () => {
const playbook = buildPlaybook(['--two', '777'], { PLAYBOOK: ymlSpec }, schema)
expect(playbook.two).to.equal(777)
})
it('should coerce Boolean values', () => {
process.env.PLAYBOOK = ymlSpec
const playbook = buildPlaybook(schema)
it('should coerce Boolean values in spec file', () => {
const playbook = buildPlaybook([], { PLAYBOOK: ymlSpec }, schema)
expect(playbook.three).to.be.false()
})
it('should coerce Boolean values (via env)', () => {
process.env.PLAYBOOK = ymlSpec
process.env.ANTORA_THREE = 'true'
const playbook = buildPlaybook(schema)
it('should coerce Boolean values in env', () => {
const env = { PLAYBOOK: ymlSpec, ANTORA_THREE: 'true' }
const playbook = buildPlaybook([], env, schema)
expect(playbook.three).to.be.true()
})
it('should coerce Boolean values (via argv)', () => {
process.env.PLAYBOOK = ymlSpec
process.argv.push('--three')
const playbook = buildPlaybook(schema)
it('should coerce Boolean values in args', () => {
const playbook = buildPlaybook(['--three'], { PLAYBOOK: ymlSpec }, schema)
expect(playbook.three).to.be.true()
})
it('should coerce a String value to an Array', () => {
const playbook = buildPlaybook([], { PLAYBOOK: coerceValueSpec }, schema)
expectedPlaybook.one.one = 'one'
expectedPlaybook.four = ['John']
expect(playbook).to.eql(expectedPlaybook)
})
it('should throw error when trying to load values not declared in the schema', () => {
process.env.PLAYBOOK = badSpec
expect(() => buildPlaybook(schema)).to.throw()
expect(() => buildPlaybook([], { PLAYBOOK: badSpec }, schema)).to.throw()
})
it('should throw error when spec file used values of the wrong format', () => {
process.env.PLAYBOOK = ymlSpec
schema.two.format = String
expect(() => buildPlaybook(schema)).to.throw()
expect(() => buildPlaybook([], { PLAYBOOK: ymlSpec }, schema)).to.throw()
})
it('should return an immutable playbook', () => {
process.env.PLAYBOOK = ymlSpec
const playbook = buildPlaybook(schema)
const playbook = buildPlaybook([], { PLAYBOOK: ymlSpec }, schema)
expect(() => {
playbook.one.two = 'override'
}).to.throw()
})
it('should use default schema if none is specified', () => {
process.env.PLAYBOOK = defaultSchemaSpec
const playbook = buildPlaybook()
const playbook = buildPlaybook([], { PLAYBOOK: defaultSchemaSpec })
expect(playbook.site.url).to.equal('https://example.com')
expect(playbook.site.title).to.equal('Example site')
})
it('should coerce a String value to an Array', () => {
process.env.PLAYBOOK = coerceValueSpec
const playbook = buildPlaybook(schema)
expectedPlaybook.one.one = 'one'
expectedPlaybook.four = ['John']
expect(playbook).to.eql(expectedPlaybook)
it('is decoupled from the process environment', () => {
const originalEnv = process.env
process.env = { PLAYBOOK: defaultSchemaSpec }
expect(() => buildPlaybook().to.throw())
process.env = originalEnv
})
it('leaves the process environment unchanged', () => {
const processArgv = process.argv
const processEnv = process.env
const args = ['--one-one', 'the-args-value']
const env = { PLAYBOOK: ymlSpec, ANTORA_TWO: 99 }
const playbook = buildPlaybook(args, env, schema)
expect(playbook.one.one).to.equal('the-args-value')
expect(playbook.two).to.equal(99)
expect(playbook.three).to.equal(false)
expect(process.argv).to.equal(processArgv)
expect(process.env).to.equal(processEnv)
})
})
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment