@creditkarma/dynamic-config
Version:
Dynamic Config for Node.js backed by Consul and Vault
425 lines • 19.9 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.DynamicConfig = void 0;
const consul_client_1 = require("@creditkarma/consul-client");
const ConfigLoader_1 = require("./ConfigLoader");
const constants_1 = require("./constants");
const utils_1 = require("./utils");
const errors = require("./errors");
const resolvers_1 = require("./resolvers");
const loaders_1 = require("./loaders");
const logger_1 = require("./logger");
const SyncConfig_1 = require("./SyncConfig");
const translators_1 = require("./translators");
class DynamicConfig {
constructor({ configPath, configEnv = utils_1.Utils.readFirstMatch(constants_1.CONFIG_ENV, 'NODE_ENV'), remoteOptions = {}, resolvers = {}, loaders = [loaders_1.jsonLoader], translators = [translators_1.envTranslator], schemas = {}, } = {}) {
this.errorMap = {};
this.promisedConfig = null;
this.schemas = schemas;
this.translator = utils_1.ConfigUtils.makeTranslator(translators);
this.configLoader = new ConfigLoader_1.ConfigLoader({
loaders,
configPath,
configEnv,
});
this.remoteOptions = remoteOptions;
this.observerMap = new Map();
this.resolversByName = {
env: (0, resolvers_1.envResolver)(),
process: (0, resolvers_1.processResolver)(),
package: (0, resolvers_1.packageResolver)(),
};
this.initializedResolvers = Object.keys(this.resolversByName);
this.resolvers = {
env: this.resolversByName.env,
process: this.resolversByName.process,
};
if (resolvers.remote !== undefined) {
this.register(resolvers.remote);
}
if (resolvers.secret !== undefined) {
this.register(resolvers.secret);
}
}
/**
* Gets a given key from the config. There are not guarantees that the config is already
* loaded, so we must return a Promise.
*
* @param key The key to look up. Dot notation may be used to access nested properties.
*/
async get(key) {
return this.getConfig().then((resolvedConfig) => {
const error = utils_1.ConfigUtils.getErrorForKey(key, this.errorMap);
if (error) {
throw error;
}
else {
// If the key is not set we return the entire structure
if (key === undefined) {
return Promise.resolve(utils_1.ConfigUtils.readConfigValue(resolvedConfig));
// If the key is set we try to find it in the structure
}
else {
const normalizedKey = utils_1.Utils.normalizePath(key);
const value = utils_1.ConfigUtils.getConfigForKey(normalizedKey, resolvedConfig);
if (value !== null) {
const baseValue = utils_1.ConfigUtils.readConfigValue(value);
if (baseValue !== null) {
const schema = this.schemas[key];
if (schema !== undefined &&
!utils_1.JSONUtils.objectMatchesSchema(schema, baseValue)) {
throw new errors.DynamicConfigInvalidObject(key);
}
else {
return Promise.resolve(baseValue);
}
}
else if (value.nullable) {
return Promise.resolve(null);
}
else {
throw new errors.DynamicConfigMissingKey(key);
}
}
else {
throw new errors.DynamicConfigMissingKey(key);
}
}
}
});
}
watch(key) {
const normalizedKey = utils_1.Utils.normalizePath(key);
if (this.observerMap.has(key)) {
return this.observerMap.get(key);
}
else {
const observer = new consul_client_1.Observer((sink) => {
this.getConfig().then((resolvedConfig) => {
try {
const initialRawValue = utils_1.ConfigUtils.getConfigForKey(normalizedKey, resolvedConfig);
if (initialRawValue !== null) {
// Read initial value
const initialValue = utils_1.ConfigUtils.readConfigValue(initialRawValue);
// Set initial value
sink(undefined, initialValue);
// Defined watcher for config value
initialRawValue.watcher = (err, val) => {
sink(err, val);
};
const resolver = this.getResolverForValue(initialRawValue);
if (resolver !== undefined &&
resolver.type === 'remote' &&
initialRawValue.source.key !== undefined) {
resolver.watch(initialRawValue.source.key, (err, val) => {
if (err !== undefined) {
sink(err);
}
else {
const updatedRawValue = utils_1.ConfigBuilder.buildBaseConfigValue(initialRawValue.source, val);
this.replaceConfigPlaceholders(updatedRawValue).then((updatedResolvedValue) => {
if (initialRawValue.type !==
updatedResolvedValue.type) {
logger_1.defaultLogger.warn(`The watcher for key[${key}] updated with a value of type[${updatedResolvedValue.type}] the initial value was of type[${initialRawValue.type}]`);
}
this.setConfig(utils_1.ConfigUtils.setValueForKey(normalizedKey, updatedResolvedValue, resolvedConfig, true));
}, (placeholderError) => {
sink(placeholderError);
});
}
}, undefined, initialRawValue.source.altKey);
}
else {
logger_1.defaultLogger.log(`DynamicConfig.watch called on key[${key}] whose value is static.`);
}
}
else {
sink(new errors.DynamicConfigMissingKey(key));
}
}
catch (err) {
sink(err instanceof Error
? err
: new Error(`Non Error Thrown ${err}`));
}
}, (err) => {
sink(new Error(`Unable to load config. ${err.message}`));
});
});
this.observerMap.set(key, observer);
return observer;
}
}
/**
* Get n number of keys from the config and return a Promise of an Array of those values.
*/
async getAll(...args) {
return Promise.all(args.map((key) => this.get(key)));
}
/**
* Looks up a key in the config. If the key cannot be found the default is returned.
*
* @param key The key to look up. Dot notation may be used to access nested properties.
* @param defaultVal The value to return if the get fails.
*/
async getWithDefault(key, defaultVal) {
return this.get(key).catch(() => defaultVal);
}
async getRemoteValue(key, type) {
// get source for key
const source = await this.source(key);
// throw if key is undefined
if (!source.key) {
throw new errors.ResolverUnavailable(key);
}
// get the current config (the cached version, that is)
const currentConfig = await this.getConfig();
// get new remote value for key
const remoteValue = await this.getValueFromResolver(source.key, 'remote', type, source.altKey);
// Find any placeholders in the remote value. This is important for resolving `consul!` pointers.
const translatedValue = this.translator(remoteValue);
// normalize/format key path
const normalizedKey = utils_1.Utils.normalizePath(key);
/*
build the normalized key path for the refreshed value to live at.
*note*: this is **required** to format the new value into a shape consumable by `setConfig()`.
*/
const builtValue = utils_1.ConfigBuilder.buildBaseConfigValue(source, translatedValue);
// Resolve any new placeholders in updated config
const resolvedValue = await this.replaceConfigPlaceholders(builtValue);
// create shape of new config
const newConfig = utils_1.ConfigUtils.setValueForKey(normalizedKey, resolvedValue, currentConfig, true);
await this.setConfig(newConfig); // keyValue the formatted value to be set in the config
return this.get(key); //re-fetch the value from the updated config to be sure it successfully updated the val.
}
async getSecretValue(key, type) {
return this.source(key).then((source) => {
if (source.key !== undefined) {
return this.getValueFromResolver(source.key, 'secret', type, source.altKey);
}
else {
throw new errors.ResolverUnavailable(key);
}
});
}
async source(key) {
const error = utils_1.ConfigUtils.getErrorForKey(key, this.errorMap);
if (error) {
throw error;
}
else {
const normalizedKey = utils_1.Utils.normalizePath(key);
return this.getConfig().then((resolvedConfig) => {
const value = utils_1.ConfigUtils.getConfigForKey(normalizedKey, resolvedConfig);
if (value !== null) {
return value.source;
}
else {
throw new errors.DynamicConfigMissingKey(key);
}
});
}
}
buildDefaultForPlaceholder(placeholder, err) {
if (placeholder.default !== undefined) {
if (err !== undefined) {
logger_1.defaultLogger.warn(`Unable to read value. Returning default value. ${err.message}`);
}
return utils_1.ConfigBuilder.buildBaseConfigValue({
type: placeholder.resolver.type,
name: placeholder.resolver.name,
key: placeholder.key,
altKey: placeholder.altKey,
}, this.translator(placeholder.default));
}
else if (placeholder.nullable) {
if (err !== undefined) {
logger_1.defaultLogger.warn(`Unable to read value. Returning null value. ${err.message}`);
}
return utils_1.ConfigBuilder.nullValueForPlaceholder(placeholder);
}
else if (err !== undefined) {
logger_1.defaultLogger.error(err.message);
this.errorMap = utils_1.ConfigUtils.setErrorForKey(placeholder.path, err, this.errorMap);
return utils_1.ConfigBuilder.invalidValueForPlaceholder(placeholder);
}
else {
const missingError = new errors.MissingConfigPlaceholder(placeholder.path);
logger_1.defaultLogger.error(missingError.message);
this.errorMap = utils_1.ConfigUtils.setErrorForKey(placeholder.path, missingError, this.errorMap);
return utils_1.ConfigBuilder.invalidValueForPlaceholder(placeholder);
}
}
async getRemotePlaceholder(placeholder) {
const resolver = this.resolversByName[placeholder.resolver.name];
if (resolver === undefined) {
return this.buildDefaultForPlaceholder(placeholder);
}
else {
return resolver
.get(placeholder.key, placeholder.type, placeholder.altKey)
.then((remoteValue) => {
return utils_1.ConfigBuilder.buildBaseConfigValue({
type: placeholder.resolver.type,
name: placeholder.resolver.name,
key: placeholder.key,
altKey: placeholder.altKey,
}, this.translator(remoteValue));
}, (err) => {
return this.buildDefaultForPlaceholder(placeholder, err);
});
}
}
/**
* I personally think this is gross, a function that exists only to mutate one
* of its arguments. Shh, it's a private function. We'll keep it a secret.
*/
appendUpdatesForObject(configValue, path, updates, whitelist) {
if (configValue.type === 'placeholder' &&
(whitelist === undefined ||
whitelist.indexOf(configValue.value._source) > -1)) {
const resolvedPlaceholder = utils_1.ConfigUtils.normalizeConfigPlaceholder(path, configValue.value, this.resolversByName);
updates.push([
path,
this.getRemotePlaceholder(resolvedPlaceholder).then((val) => {
return this.replaceConfigPlaceholders(val, whitelist);
}),
]);
}
else if (configValue.type === 'object' ||
configValue.type === 'array') {
this.collectConfigPlaceholders(configValue, path, updates, whitelist);
}
}
collectConfigPlaceholders(configValue, path, updates, whitelist) {
if (configValue.type === 'array') {
configValue.items.forEach((oldValue, index) => {
const newPath = [...path, `${index}`];
this.appendUpdatesForObject(oldValue, newPath, updates, whitelist);
});
return updates;
}
else if (configValue.type === 'object' ||
configValue.type === 'root') {
for (const key of Object.keys(configValue.properties)) {
const objValue = configValue.properties[key];
const newPath = [...path, key];
this.appendUpdatesForObject(objValue, newPath, updates, whitelist);
}
return updates;
}
else {
return [];
}
}
/**
* When a config value is requested there is a chance that the value currently in the
* resolved config is a placeholder, or, in the more complex case, the requested value
* is an object that contains placeholders within nested keys. We need to find and resolve
* any placeholders that remain in the config
*/
async replaceConfigPlaceholders(rootConfig, whitelist) {
const unresolved = this.collectConfigPlaceholders(rootConfig, [], [], whitelist);
const paths = unresolved.map((next) => next[0].join('.'));
const promises = unresolved.map((next) => next[1]);
const resolvedPromises = await Promise.all(promises);
const newObj = resolvedPromises.reduce((acc, next, currentIndex) => {
return utils_1.ConfigUtils.setValueForKey(paths[currentIndex], next, acc);
}, rootConfig);
return utils_1.ConfigPromises.resolveConfigPromises(newObj);
}
async loadConfigs() {
const defaultConfigFile = await this.configLoader.loadDefault();
const defaultConfig = utils_1.ConfigBuilder.createConfigObject({
type: 'local',
name: 'default',
}, this.translator(defaultConfigFile.config));
const envConfigFile = await this.configLoader.loadEnvironment();
const envConfig = utils_1.ConfigBuilder.createConfigObject({
type: 'local',
name: envConfigFile.name,
}, this.translator(envConfigFile.config));
const localConfig = utils_1.ObjectUtils.overlayObjects(defaultConfig, envConfig);
return await this.initializeResolvers(localConfig);
}
setConfig(resolvedConfig) {
this.promisedConfig = Promise.resolve(resolvedConfig);
}
getConfig() {
if (this.promisedConfig === null) {
this.promisedConfig = this.loadConfigs().then(async (loadedConfigs) => {
const resolvedConfig = (await this.replaceConfigPlaceholders(loadedConfigs));
this.setConfig(resolvedConfig);
return resolvedConfig;
});
}
return this.promisedConfig;
}
async initializeResolvers(currentConfig) {
const allResolvers = [
this.resolvers.remote,
this.resolvers.secret,
].filter((next) => next !== undefined);
const numResolvers = allResolvers.length;
let index = 0;
return this.replaceConfigPlaceholders(currentConfig, this.initializedResolvers).then((initialConfig) => {
const loadNextConfig = async () => {
if (index < numResolvers) {
const nextResolver = allResolvers[index];
const configStore = new SyncConfig_1.SyncConfig(initialConfig);
const remoteConfig = await nextResolver.init(configStore, this.remoteOptions[nextResolver.name]);
const mergedConfig = utils_1.ConfigBuilder.createConfigObject({
type: nextResolver.type,
name: nextResolver.name,
}, this.translator(remoteConfig));
this.initializedResolvers.push(nextResolver.name);
const resolvedConfig = (await this.replaceConfigPlaceholders(mergedConfig, this.initializedResolvers));
initialConfig = utils_1.ObjectUtils.overlayObjects(initialConfig, resolvedConfig);
// Increment index for next resolver
index += 1;
return loadNextConfig();
}
else {
return initialConfig;
}
};
return loadNextConfig();
});
}
getValueFromResolver(key, resolverType, valueType, altKey) {
const resolver = this.resolvers[resolverType];
if (resolver !== undefined) {
return resolver.get(key, valueType, altKey).then((remoteValue) => {
if (remoteValue !== null) {
return Promise.resolve(remoteValue);
}
else {
return Promise.reject(new errors.DynamicConfigMissingKey(key));
}
}, () => {
return Promise.reject(new errors.DynamicConfigMissingKey(key));
});
}
else {
return Promise.reject(new errors.ResolverUnavailable(key));
}
}
getResolverForValue(value) {
return this.resolversByName[value.source.name];
}
register(resolver) {
this.resolversByName[resolver.name] = resolver;
switch (resolver.type) {
case 'remote':
this.resolvers.remote = resolver;
break;
case 'secret':
this.resolvers.secret = resolver;
break;
default:
throw new Error(`Unknown resolver type: ${resolver.type}`);
}
}
}
exports.DynamicConfig = DynamicConfig;
//# sourceMappingURL=DynamicConfig.js.map