consulea
Version:
Load Consul keys, environment vars, and command line arguments in a predictable, standardized way.
357 lines (319 loc) • 10.3 kB
JavaScript
const EventEmitter = require('events').EventEmitter;
const util = require('util');
const consul = require('consul');
const camelCase = require('camelcase');
/**
* Read module config, returning config for Consul client
* @param {object} configIn Module config to parse
*/
function makeConsulConfig (configIn, envObj) {
// Consul client config can be passed in whole
const consulConfig = configIn.consulClientConfig || {};
// Consul host/port can be sourced from env CONSUL_HTTP_ADDR
if (envObj.CONSUL_HTTP_ADDR) {
let httpAddr = envObj.CONSUL_HTTP_ADDR;
consulConfig.secure = (httpAddr.substr(0, 5) === 'https');
httpAddr = httpAddr.replace('http://', '').replace('https://', '');
httpAddr = httpAddr.split(':');
if (httpAddr[0]) consulConfig.host = httpAddr[0];
if (httpAddr[1]) consulConfig.port = httpAddr[1];
}
if (configIn.consulToken) {
consulConfig.defaults = { token: configIn.consulToken };
}
return consulConfig;
}
exports.makeConsulConfig = makeConsulConfig;
/**
* Add KV data to object sourced from raw Consul watch response data
* @param {object} kvData Discovered key/value data input
* @param {object} dataIn Consul response data
* @param {string} prefix Consul prefix for to strip from discovered keys
* @returns {object} Discovered key/value data
*/
function parseConsul (kvData, dataIn, prefix) {
if (dataIn) {
for (let i = 0; i < dataIn.length; i++) {
if (dataIn[i].Key !== undefined && dataIn[i].Value !== undefined) {
// Standardize the key
let kvKey = dataIn[i].Key;
kvKey = kvKey.replace(prefix, '');
kvKey = camelCase(kvKey);
// Filter out directories
if (kvKey !== '') {
kvData[kvKey] = dataIn[i].Value;
}
}
}
}
return kvData;
}
exports.parseConsul = parseConsul;
/**
* Add KV data to object sourced from environment variables with specific prefix
* @param {object} kvData Discovered key/value data input
* @param {string} prefix Environment prefix for to strip from discovered keys
* @returns {object} Discovered key/value data output
*/
function parseEnv (kvData, envObj, prefix) {
if (prefix) {
prefix += '_';
Object.keys(envObj).forEach((envKey) => {
// Skip env keys which don't start with prefix
if (envKey.substring(0, prefix.length) === prefix) {
// Standardize the key
let kvKey = envKey.replace(prefix, '');
kvKey = camelCase(kvKey);
kvData[kvKey] = envObj[envKey];
}
});
}
return kvData;
}
exports.parseEnv = parseEnv;
/**
* Add KV data to object sourced from command line arguments
* @param {object} kvData Discovered key/value data input
* @returns {object} Discovered key/value data output
*/
function parseArgs (kvData, args) {
args.forEach((val) => {
// Skip args that don't start with "--"
if (val.substring(0, 2) === '--') {
// Break apart key/value
const argParts = val.substring(2).split('=', 2);
if (argParts.length > 1) {
// Standardize the key
const newKey = camelCase(argParts[0]);
kvData[newKey] = argParts[1];
}
}
});
return kvData;
}
exports.parseArgs = parseArgs;
/**
* Verify the discovered KV data contains all required keys. Missing keys are returned in an array.
* @param {object} kvData Discovered key/value data input
* @param {object} requiredKeys List of camelCased keys which are required
* @returns {array} List of missing keys
*/
function findMissingKeys (kvData, requiredKeys) {
const missingKeys = [];
if (requiredKeys) {
for (let i = 0; i < requiredKeys.length; i++) {
const requiredKey = requiredKeys[i];
if (kvData[requiredKey] === undefined) {
missingKeys.push(requiredKey);
}
}
}
return missingKeys;
}
exports.findMissingKeys = findMissingKeys;
/**
* Compare previous and current findings for changed keys. Changed keys are returned in an array.
* @param {object} self Self object
* @returns {array} List of added/removed/changed keys
*/
function findChangedKeys (self) {
const changedKeys = [];
const temp = JSON.parse(JSON.stringify(self.lastGoodKvData));
// Loop on previous findings, check for existence of and compare Key=>ModifyIndex map
const newKeys = Object.keys(self.kvData);
for (let j = 0; j < newKeys.length; j++) {
const newKey = newKeys[j];
const newVal = self.kvData[newKey];
if (temp[newKey] && temp[newKey] === newVal) {
delete temp[newKey];
} else {
temp[newKey] = newVal;
}
}
// Standardize the key
Object.keys(temp).forEach((kvKey) => {
kvKey = kvKey.replace(self.config.consulPrefix, '');
changedKeys.push(camelCase(kvKey));
});
return changedKeys;
}
exports.findChangedKeys = findChangedKeys;
/**
* JavaScript Pseudo-class that is compatible all the way back to Node 0.10
*/
const Consulea = (function () {
// JavaScript Pseudo-class constructor
function Consulea (configIn) {
// Verify required things are set
if (!configIn.consulPrefix) {
throw new Error('consulPrefix not defined');
}
const self = this;
this.config = configIn;
this.kvDataDefault = configIn.defaultData || {};
this.kvData = {};
this.initialLoad = true;
this.isReady = false;
this.consulConfig = makeConsulConfig(this.config, process.env);
this.consulClient = consul(this.consulConfig);
this.lastGoodKvData = {};
// Set defaults
this.config.suppressErrors = this.config.suppressErrors || false;
this.config.ifMissingKeysOnStartUp = this.config.ifMissingKeysOnStartUp || 'exit';
this.config.ifMissingKeysOnUpdate = this.config.ifMissingKeysOnUpdate || 'exit';
this.handleError = function (errObj) {
if (!this.config.suppressErrors) {
console.error(errObj.message);
}
// Catch possible 'TypeError: Uncaught, unspecified "error" event.'
// if error event is not registered
try {
this.emit('error', errObj);
} catch (e) {
// no-op
}
// Exit if the error was fatal
if (errObj.level === 'FATAL') {
process.exit(1);
}
};
// This will loop until ready, then will callback. Useful if using run-series or async.series
this.callbackWhenReady = function (callback) {
if (this.isReady) {
callback();
} else {
setTimeout(() => {
self.callbackWhenReady(callback);
}, 50);
}
}
// Start the watcher
this.watchStart();
}
// Extend constructor function to be an EventEmitter
util.inherits(Consulea, EventEmitter);
return Consulea;
}());
/**
* Start the Consul watcher
*/
Consulea.prototype.watchStart = function () {
const self = this;
// Start a watcher
this._watcher = this.consulClient.watch({
method: this.consulClient.kv.get,
options: {
key: this.config.consulPrefix,
recurse: true,
}
});
// When Consul namespace changes or is first loaded...
this._watcher.on('change', (response, res) => {
// Try to catch errors using http.IncomingMessage
if (res.statusCode !== 200) {
self.handleError({
code: 'NON_HTTP_200',
level: 'WARN',
message: 'Consul error: HTTP/' + res.statusCode + ' ' + res.statusMessage + '. ' +
'Possible bad Token, missing or unauthorized prefix: ' + self.config.consulPrefix
});
return;
}
// Build a new kvData from Consul, then Env, then Arguments
let kvData = self.kvDataDefault;
kvData = parseConsul(kvData, response, self.config.consulPrefix);
kvData = parseEnv(kvData, process.env, self.config.envPrefix);
kvData = parseArgs(kvData, process.argv);
self.kvData = kvData;
const changedKeys = findChangedKeys(self);
// Verify require keys
const missingKeys = findMissingKeys(kvData, self.config.requiredKeys);
// Something is missing
if (missingKeys.length > 0) {
const missingKeyList = missingKeys.join(', ');
// This is handled differently, depending on if this is the first time or not
const whichRule = (self.initialLoad ? 'ifMissingKeysOnStartUp' : 'ifMissingKeysOnUpdate');
const ruleValue = self.config[whichRule];
switch (ruleValue) {
case 'exit':
self.handleError({
code: 'MISSING_KEY_EXIT',
level: 'FATAL',
message: 'Exiting, Consulea found keys missing: ' + missingKeyList
});
break;
case 'warn':
self.handleError({
code: 'MISSING_KEY_WARN',
level: 'WARN',
message: 'Warning, Consulea found keys missing: ' + missingKeyList
});
break;
case 'skip':
self.handleError({
code: 'MISSING_KEY_SKIP',
level: 'WARN',
message: 'Warning, Consulea found keys missing, skipping event call: ' + missingKeyList
});
return;
case 'lastGoodValue':
for (let i = 0; i < missingKeys.length; i++) {
const missingKey = missingKeys[i];
if (self.lastGoodKvData[missingKey] !== undefined) {
self.handleError({
code: 'MISSING_KEY_USED_PREV_VAL',
level: 'WARN',
message: 'Warning, Consulea found key missing, using old value: ' + missingKey
});
kvData[missingKey] = self.lastGoodKvData[missingKey];
} else {
self.handleError({
code: 'MISSING_KEY_NO_PREV_VAL',
level: 'FATAL',
message: 'Exiting, Consulea found key missing with no previous value: ' + missingKey
});
}
}
break;
default:
self.handleError({
code: 'UNKNOWN_CONFIG',
level: 'FATAL',
message: 'Exiting, Consulea has unknown config: ' + whichRule + '=' + ruleValue
});
}
} else {
// console.log('No keys missing.');
self.lastGoodKvData = JSON.parse(JSON.stringify(kvData));
}
// Make a copy so the code using this module can not modify kvData by accident
const kvDataCopy = JSON.parse(JSON.stringify(self.kvData));
// Emit "update" event every time there is a change, and include some extra metadata
self.emit('update', null, kvDataCopy, {
changedKeys: changedKeys,
initialLoad: self.initialLoad
});
// Emit "ready" event only once
if (self.initialLoad) {
self.initialLoad = false;
self.isReady = true;
self.emit('ready', null, kvDataCopy);
}
});
// Emit "error" when Consul connection emits an error
this._watcher.on('error', (err) => {
self.handleError({
code: 'CLIENT_ERR',
level: 'WARN',
message: 'Consul error:' + err
});
});
};
/**
* Stop the Consul watcher
*/
Consulea.prototype.watchStop = function () {
this._watcher.end();
};
// module.exports = Consulea;
exports.Consulea = Consulea;