UNPKG

@creditkarma/dynamic-config

Version:

Dynamic Config for Node.js backed by Consul and Vault

425 lines 19.9 kB
"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