diff --git a/README.md b/README.md index 6f8a61b76..ee5bd9d6a 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ updates in a FIFO order. - [Site Level CRR](/docs/site-level-crr.md) - [Transient Source](/docs/transient-crr-source.md) - [Out of Band updates from RING](/docs/oob-s3-ring.md) +- [Configuration overrides](/docs/config-overrides.md) ## QUICKSTART diff --git a/docs/config-overrides.md b/docs/config-overrides.md new file mode 100644 index 000000000..cc125e76c --- /dev/null +++ b/docs/config-overrides.md @@ -0,0 +1,60 @@ +# Configuration Overrides + +`BACKBEAT_CONFIG_OVERRIDES` holds a JSON document applied on top of the +configuration file at startup, so that any field can be changed per process +without a new image or release. + +This is a troubleshooting escape hatch, meant for support: the environment +variables named after the configuration fields, described in +[Configuration](/docs/configuration.md), remain the supported way to configure +backbeat, and should be preferred whenever one exists for the field at hand. + +## Semantics + +The document is applied as a [JSON Merge Patch](https://www.rfc-editor.org/rfc/rfc7386), +which is to say: + +- objects are merged recursively, so only the fields mentioned are changed; +- arrays and scalars replace the value they override — an array is never merged + element-wise, so overriding a list with a shorter one drops the extra entries; +- `null` deletes a field, restoring the default the schema defines for it. + +The overrides are applied last, over the configuration file and any other +setting, so that nothing silently overrides them. + +The result is validated as a whole, exactly like the configuration file: an +unknown field, a wrong type or a deleted mandatory field fails at startup, +rather than leaving a setting silently ignored. Values are coerced by the +schema, so `"250"` is accepted for a numeric field. + +## Examples + +Raise the log level of a single process: + +```sh +BACKBEAT_CONFIG_OVERRIDES='{"log":{"logLevel":"debug"}}' +``` + +Set librdkafka producer parameters, whose dotted keys need no escaping, being +plain JSON object keys: + +```sh +BACKBEAT_CONFIG_OVERRIDES='{"kafka":{"producerParams":{"linger.ms":10}}}' +``` + +Change a field of an extension, and restore another to its default: + +```sh +BACKBEAT_CONFIG_OVERRIDES='{"extensions":{"lifecycle":{"conductor":{"concurrency":20}}}}' +BACKBEAT_CONFIG_OVERRIDES='{"queuePopulator":{"batchMaxRead":null}}' +``` + +Several changes are applied in a single document: + +```sh +BACKBEAT_CONFIG_OVERRIDES='{ + "log": { "logLevel": "debug" }, + "queuePopulator": { "batchMaxRead": 250 }, + "extensions": { "gc": { "consumer": { "concurrency": 5 } } } +}' +``` diff --git a/docs/configuration.md b/docs/configuration.md index 322998328..2dbab5dc0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -88,6 +88,10 @@ setting a field of the configuration file. No schema declares them, so no name is derived for them, and their value is not validated. - `BACKBEAT_CONFIG_FILE`: path of the configuration file. +- `BACKBEAT_CONFIG_OVERRIDES`: JSON merge patch applied over the whole + configuration, for the fields no name above reaches. The result is validated, + unlike the other variables of this section — see + [Configuration overrides](/docs/config-overrides.md). - `BACKBEAT_QUEUEPOPULATOR_EXTENSIONS`: extensions run by this queue populator, comma separated. - `BOOTSTRAP_SITE_NAME`: restricts the replication bootstrap list to one site. diff --git a/lib/Config.js b/lib/Config.js index 2a67c9398..09416610f 100644 --- a/lib/Config.js +++ b/lib/Config.js @@ -66,9 +66,8 @@ class Config extends EventEmitter { * @returns {undefined} */ _parseConfig(config) { - applyEnvOverrides(config, backbeatConfigJoi); - - const parsedConfig = joi.attempt(config, backbeatConfigJoi); + const parsedConfig = joi.attempt( + applyEnvOverrides(config, backbeatConfigJoi), backbeatConfigJoi); // Strip config to ensure extensions cannot use another extension's config const globalConfig = { ...parsedConfig }; @@ -93,7 +92,7 @@ class Config extends EventEmitter { const lifecycleConfig = parsedConfig.extensions?.lifecycle; const backbeatSupportsTransition = lifecycleConfig?.supportedLifecycleRules?.includes('Transition'); const replicationConfig = parsedConfig.extensions?.replication; - if (backbeatSupportsTransition && !replicationConfig.dataMoverTopic) { + if (backbeatSupportsTransition && !replicationConfig?.dataMoverTopic) { throw new Error('dataMoverTopic is required when lifecycle transitions is supported'); } diff --git a/lib/config/configOverrides.js b/lib/config/configOverrides.js new file mode 100644 index 000000000..db22fdfb4 --- /dev/null +++ b/lib/config/configOverrides.js @@ -0,0 +1,131 @@ +'use strict'; + +/** + * Generic configuration overrides, from the BACKBEAT_CONFIG_OVERRIDES + * environment variable. + * + * The variable holds a JSON document applied to the configuration as a JSON + * Merge Patch (RFC 7386): objects are merged recursively, arrays and scalars + * replace the value they override, and `null` deletes a field, restoring the + * default the schema defines for it. + * + * This is a troubleshooting escape hatch, for the fields no named setting + * reaches — e.g. the librdkafka parameters of `kafka.producerParams`, whose + * dotted keys need no escaping here, being plain JSON object keys: + * + * BACKBEAT_CONFIG_OVERRIDES='{"kafka":{"producerParams":{"linger.ms":10}}}' + * + * It is applied last, over the configuration file and any named setting, so + * that nothing silently overrides the hatch someone reached for precisely + * because the usual path did not work. The result is still validated against + * the schema, so a typo or a wrong type fails at startup rather than leaving a + * setting silently ignored. + */ + +const { getField } = require('./fields'); + +const CONFIG_OVERRIDES = 'BACKBEAT_CONFIG_OVERRIDES'; + +/** + * @param {*} value - value to test + * @returns {boolean} true for a JSON object, excluding arrays and null + */ +function isObject(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Applies a JSON Merge Patch to a target value, transcribed from the reference + * pseudocode of RFC 7386, section 2. + * + * lodash's `merge()` is deliberately not used: it merges arrays element-wise + * instead of replacing them, which would leave stale entries behind when + * overriding a list with a shorter one, and it assigns `null` instead of + * deleting the field. + * + * The target is updated in place when both sides are objects, so that the + * caller keeps its reference; any other patch replaces it, and the new value is + * returned. + * + * A `__proto__` member is ignored, whatever the patch holds for it: no + * configuration field is named that, and JS objects cannot hold such a member + * anyway, only reach through it to Object.prototype. + * + * @param {*} target - value to patch + * @param {*} patch - merge patch to apply + * @returns {*} patched value + */ +function mergePatch(target, patch) { + if (!isObject(patch)) { + return patch; + } + if (!isObject(target)) { + // the patch describes an object: whatever the target held is replaced + target = {}; // eslint-disable-line no-param-reassign + } + Object.entries(patch).forEach(([key, value]) => { + if (key === '__proto__') { + // whatever the caller passes, this is not a member to write: it + // would update Object.prototype instead of the target + return; + } + + if (value === null) { + delete target[key]; // eslint-disable-line no-param-reassign + } else { + target[key] = mergePatch(target[key], value); // eslint-disable-line no-param-reassign + } + }); + return target; +} + +/** + * Applies the fraction of the BACKBEAT_CONFIG_OVERRIDES merge patch covering + * the fields of one schema, in place. The patch mirrors the configuration, so + * that fraction sits at the config path of the schema root. + * + * A fraction that is not an object replaces the whole section rather than + * updating it, and cannot be applied in place: the caller has to use the value + * returned, and leave the schema to report it if the section is mandatory. + * + * @param {Object} config - configuration to update, matching the schema + * @param {string[]} [prefix] - config path of the schema root + * @param {Object} [env] - environment to read the overrides from + * @returns {*} updated configuration + */ +function applyConfigOverrides(config, prefix = [], env = process.env) { + if (!env[CONFIG_OVERRIDES]) { + return config; + } + + let patch; + try { + // JSON.parse sets a `__proto__` key as a plain member: the reviver drops + // it at any depth, so that the document handed over holds only the + // configuration fields it is meant to describe + patch = JSON.parse(env[CONFIG_OVERRIDES], + (key, value) => (key === '__proto__' ? undefined : value)); + } catch (err) { + throw new Error(`invalid JSON value for ${CONFIG_OVERRIDES}: ${err.message}`); + } + + if (!isObject(patch)) { + // any other document would replace the whole configuration instead of + // updating it in place, which the caller would silently drop + throw new Error(`${CONFIG_OVERRIDES} must hold a JSON object`); + } + + // the patch may cover none of the fields of the schema, and then leaves the + // configuration alone + const fraction = getField(patch, prefix); + if (fraction === undefined) { + return config; + } + + return mergePatch(config, fraction); +} + +module.exports = { + applyConfigOverrides, + mergePatch, +}; diff --git a/lib/config/envOverrides.js b/lib/config/envOverrides.js index 856ccafa0..e321b03f4 100644 --- a/lib/config/envOverrides.js +++ b/lib/config/envOverrides.js @@ -4,6 +4,7 @@ * variables derived from the joi schemas. */ +const { applyConfigOverrides } = require('./configOverrides'); const { getField, setField } = require('./fields'); // A container exposes a single probe endpoint, shared by all the probe servers @@ -221,13 +222,15 @@ function applyLivenessProbePort(config, mappings, port) { } /** - * Applies the env vars mapped to the fields of a schema, in place. + * Applies the env vars mapped to the fields of a schema, in place. A generic override + * replacing the whole section cannot be applied in place, so the caller has to use the + * value returned rather than its own reference. * * @param {Object} config - configuration to update, matching the schema * @param {joi.Schema} schema - configuration schema * @param {string[]} [prefix] - config path of the schema root * @param {Object} [env] - environment to read the overrides from - * @returns {Object} updated configuration. Invalid config returned untouched for joi to report + * @returns {*} updated configuration. Invalid config returned untouched for joi to report */ function applyEnvOverrides(config, schema, prefix = [], env = process.env) { if (config === null || typeof config !== 'object') { @@ -248,7 +251,8 @@ function applyEnvOverrides(config, schema, prefix = [], env = process.env) { } }); - return config; + // the generic overrides come last, and win over every name derived above + return applyConfigOverrides(config, prefix, env); } module.exports = { diff --git a/tests/unit/lib/config/Config.spec.js b/tests/unit/lib/config/Config.spec.js index 67d6ba047..5bc281985 100644 --- a/tests/unit/lib/config/Config.spec.js +++ b/tests/unit/lib/config/Config.spec.js @@ -8,6 +8,8 @@ const { Config } = require('../../../../lib/Config'); const { getField } = require('../../../../lib/config/fields'); const backbeatConfig = require('./config.json'); +const CONFIG_OVERRIDES = 'BACKBEAT_CONFIG_OVERRIDES'; + describe('Config', () => { let config; let testConfig; @@ -52,6 +54,124 @@ describe('Config', () => { ]; assert.doesNotThrow(() => config._parseConfig(testConfig)); }); + + describe('configuration overrides', () => { + afterEach(() => { + delete process.env[CONFIG_OVERRIDES]; + delete process.env.BACKBEAT_CONFIG_FILE; + delete process.env.KAFKA_HOSTS; + delete process.env.EXTENSIONS_GC_TOPIC; + delete process.env.MONGODB_DATABASE; + delete process.env.MONGODB_HOSTS; + }); + + it('should win over an env var of the global config', () => { + process.env.KAFKA_HOSTS = 'from-env:9092'; + process.env[CONFIG_OVERRIDES] = '{"kafka":{"hosts":"from-override:9092"}}'; + config._parseConfig(testConfig); + assert.strictEqual(config.kafka.hosts, 'from-override:9092'); + }); + + it('should win over an env var of an extension', () => { + process.env.EXTENSIONS_GC_TOPIC = 'from-env-gc'; + process.env[CONFIG_OVERRIDES] = '{"extensions":{"gc":{"topic":"from-override-gc"}}}'; + config._parseConfig(testConfig); + assert.strictEqual(config.extensions.gc.topic, 'from-override-gc'); + }); + + it('should win over an env var setting several fields at once', () => { + process.env.MONGODB_HOSTS = 'from-env:27017'; + process.env[CONFIG_OVERRIDES] = + '{"queuePopulator":{"mongo":{"replicaSetHosts":"from-override:27017"}}}'; + config._parseConfig(testConfig); + assert.strictEqual(config.queuePopulator.mongo.replicaSetHosts, + 'from-override:27017'); + }); + + it('should override a value of the config file', () => { + process.env[CONFIG_OVERRIDES] = '{"kafka":{"hosts":"patched:9092"}}'; + config._parseConfig(testConfig); + assert.strictEqual(config.kafka.hosts, 'patched:9092'); + }); + + it('should leave the fields it does not mention alone', () => { + process.env[CONFIG_OVERRIDES] = '{"kafka":{"hosts":"patched:9092"}}'; + config._parseConfig(testConfig); + assert.strictEqual(config.kafka.maxRequestSize, + backbeatConfig.kafka.maxRequestSize); + assert.strictEqual(config.server.port, backbeatConfig.server.port); + }); + + it('should override a field of an extension', () => { + process.env[CONFIG_OVERRIDES] = '{"extensions":{"gc":{"topic":"patched-gc"}}}'; + config._parseConfig(testConfig); + assert.strictEqual(config.extensions.gc.topic, 'patched-gc'); + // the rest of the extension config is untouched + assert.strictEqual(config.extensions.gc.consumer.concurrency, + backbeatConfig.extensions.gc.consumer.concurrency); + }); + + it('should set a field the config file does not define', () => { + process.env[CONFIG_OVERRIDES] = + '{"kafka":{"producerParams":{"linger.ms":10,"socket.timeout.ms":5000}}}'; + config._parseConfig(testConfig); + assert.deepStrictEqual(config.kafka.producerParams, + { 'linger.ms': 10, 'socket.timeout.ms': 5000 }); + }); + + it('should replace an array rather than merge it', () => { + process.env[CONFIG_OVERRIDES] = + '{"server":{"healthChecks":{"allowFrom":["10.0.0.0/8"]}}}'; + config._parseConfig(testConfig); + // _parseConfig appends the default health checks to the configured ones + assert.deepStrictEqual(config.server.healthChecks.allowFrom, + ['10.0.0.0/8', '127.0.0.1/8', '::1']); + }); + + it('should restore the schema default when a field is deleted', () => { + // a value the schema default differs from, so that the assertion + // tells the field was deleted from the value it held + testConfig.kafka.backlogMetrics.intervalS = 120; + process.env[CONFIG_OVERRIDES] = '{"kafka":{"backlogMetrics":{"intervalS":null}}}'; + config._parseConfig(testConfig); + // the joi default of the field, not the value of the config file + assert.strictEqual(config.kafka.backlogMetrics.intervalS, 60); + }); + + it('should coerce the types joi converts', () => { + process.env[CONFIG_OVERRIDES] = '{"queuePopulator":{"batchMaxRead":"250"}}'; + config._parseConfig(testConfig); + assert.strictEqual(config.queuePopulator.batchMaxRead, 250); + }); + + it('should validate the merged config, rejecting a wrong type', () => { + process.env[CONFIG_OVERRIDES] = '{"server":{"port":"not-a-number"}}'; + assert.throws(() => config._parseConfig(testConfig), /port/); + }); + + it('should validate the merged config, rejecting an unknown field', () => { + process.env[CONFIG_OVERRIDES] = '{"kafka":{"notAKafkaSetting":1}}'; + assert.throws(() => config._parseConfig(testConfig), /notAKafkaSetting/); + }); + + it('should validate the merged config, rejecting a deleted required field', () => { + process.env[CONFIG_OVERRIDES] = '{"kafka":{"hosts":null}}'; + assert.throws(() => config._parseConfig(testConfig), /hosts/); + }); + + it('should reject an invalid overrides document', () => { + process.env[CONFIG_OVERRIDES] = '{oops'; + assert.throws(() => config._parseConfig(testConfig), + /invalid JSON value for BACKBEAT_CONFIG_OVERRIDES/); + }); + + it('should apply the overrides when loading the configuration file', () => { + process.env.BACKBEAT_CONFIG_FILE = require.resolve('./config.json'); + process.env[CONFIG_OVERRIDES] = '{"kafka":{"hosts":"patched:9092"}}'; + const loaded = new Config(); + assert.strictEqual(loaded.kafka.hosts, 'patched:9092'); + }); + }); }); describe('backbeat config singleton', () => { diff --git a/tests/unit/lib/config/configOverrides.spec.js b/tests/unit/lib/config/configOverrides.spec.js new file mode 100644 index 000000000..d51ca1e12 --- /dev/null +++ b/tests/unit/lib/config/configOverrides.spec.js @@ -0,0 +1,207 @@ +'use strict'; + +const assert = require('assert'); + +const { applyConfigOverrides, mergePatch } = require('../../../../lib/config/configOverrides'); + +const CONFIG_OVERRIDES = 'BACKBEAT_CONFIG_OVERRIDES'; + +describe('config overrides', () => { + describe('mergePatch', () => { + // the test cases of RFC 7386, appendix A + [ + [{ a: 'b' }, { a: 'c' }, { a: 'c' }], + [{ a: 'b' }, { b: 'c' }, { a: 'b', b: 'c' }], + [{ a: 'b' }, { a: null }, {}], + [{ a: 'b', b: 'c' }, { a: null }, { b: 'c' }], + [{ a: ['b'] }, { a: 'c' }, { a: 'c' }], + [{ a: 'c' }, { a: ['b'] }, { a: ['b'] }], + [{ a: { b: 'c' } }, { a: { b: 'd', c: null } }, { a: { b: 'd' } }], + [{ a: [{ b: 'c' }] }, { a: [1] }, { a: [1] }], + [['a', 'b'], ['c', 'd'], ['c', 'd']], + [{ a: 'b' }, ['c'], ['c']], + [{ a: 'foo' }, null, null], + [{ a: 'foo' }, 'bar', 'bar'], + [{ e: null }, { a: 1 }, { e: null, a: 1 }], + [[1, 2], { a: 'b', c: null }, { a: 'b' }], + [{}, { a: { bb: { ccc: null } } }, { a: { bb: {} } }], + ].forEach(([target, patch, expected]) => { + const title = `${JSON.stringify(target)} + ${JSON.stringify(patch)} ` + + `= ${JSON.stringify(expected)}`; + it(`should merge ${title}`, () => { + assert.deepStrictEqual(mergePatch(target, patch), expected); + }); + }); + + it('should merge nested objects recursively', () => { + assert.deepStrictEqual( + mergePatch({ a: { b: 1, c: { d: 2, e: 3 } } }, + { a: { c: { e: 4 } } }), + { a: { b: 1, c: { d: 2, e: 4 } } }); + }); + + it('should replace an array instead of merging it element-wise', () => { + assert.deepStrictEqual( + mergePatch({ list: [1, 2, 3] }, { list: [9] }), + { list: [9] }); + }); + + it('should replace a scalar with an object', () => { + assert.deepStrictEqual( + mergePatch({ a: 'scalar' }, { a: { b: 1 } }), + { a: { b: 1 } }); + }); + + it('should create the missing intermediate nodes', () => { + assert.deepStrictEqual( + mergePatch({}, { a: { b: { c: 1 } } }), + { a: { b: { c: 1 } } }); + }); + + it('should ignore the deletion of a field that is not set', () => { + assert.deepStrictEqual(mergePatch({ a: 1 }, { b: null }), { a: 1 }); + }); + + it('should update the target in place, so callers keep their reference', () => { + const target = { a: { b: 1 } }; + const { a } = target; + mergePatch(target, { a: { c: 2 } }); + assert.strictEqual(target.a, a); + assert.deepStrictEqual(target, { a: { b: 1, c: 2 } }); + }); + + it('should ignore a `__proto__` key instead of reaching the prototype', () => { + // an object literal would set the prototype rather than a member, + // so the patch has to be spelled out as JSON + const patch = JSON.parse('{"a":1,"__proto__":{"polluted":"yes"}}'); + try { + assert.deepStrictEqual(mergePatch({}, patch), { a: 1 }); + assert.strictEqual({}.polluted, undefined); + } finally { + delete Object.prototype.polluted; + } + }); + + it('should not share the patch structure with the merged result', () => { + const patch = { a: { b: 1 } }; + const target = mergePatch({}, patch); + patch.a.b = 2; + assert.strictEqual(target.a.b, 1); + }); + }); + + describe('applyConfigOverrides', () => { + const overrides = patch => ({ [CONFIG_OVERRIDES]: JSON.stringify(patch) }); + + it('should leave the config alone when the env var is not set', () => { + const config = { kafka: { hosts: 'localhost:9092' } }; + assert.deepStrictEqual(applyConfigOverrides(config, [], {}), + { kafka: { hosts: 'localhost:9092' } }); + }); + + it('should leave the config alone when the env var is empty', () => { + const config = { kafka: { hosts: 'localhost:9092' } }; + assert.deepStrictEqual( + applyConfigOverrides(config, [], { [CONFIG_OVERRIDES]: '' }), + { kafka: { hosts: 'localhost:9092' } }); + }); + + it('should reject an invalid JSON document', () => { + assert.throws( + () => applyConfigOverrides({}, [], { [CONFIG_OVERRIDES]: '{oops' }), + /invalid JSON value for BACKBEAT_CONFIG_OVERRIDES/); + }); + + it('should reject a patch that is not an object', () => { + ['"a string"', '42', 'null', '["a", "list"]'].forEach(patch => { + assert.throws( + () => applyConfigOverrides({}, [], { [CONFIG_OVERRIDES]: patch }), + /BACKBEAT_CONFIG_OVERRIDES must hold a JSON object/); + }); + }); + + it('should ignore a `__proto__` key instead of polluting the prototype', () => { + // an object literal would set the prototype rather than a member, + // so the patch has to be spelled out as JSON + const patch = '{"kafka":{"hosts":"other:9092","__proto__":{"nested":"pollution"}},' + + '"__proto__":{"polluted":"yes"}}'; + const config = { kafka: { hosts: 'localhost:9092' } }; + try { + applyConfigOverrides(config, [], { [CONFIG_OVERRIDES]: patch }); + assert.deepStrictEqual(config, { kafka: { hosts: 'other:9092' } }); + assert.strictEqual({}.polluted, undefined); + assert.strictEqual({}.nested, undefined); + } finally { + delete Object.prototype.polluted; + delete Object.prototype.nested; + } + }); + + it('should apply the whole patch without a prefix', () => { + const config = { kafka: { hosts: 'localhost:9092', site: 'here' } }; + applyConfigOverrides(config, [], overrides({ kafka: { hosts: 'other:9092' } })); + assert.deepStrictEqual(config, { kafka: { hosts: 'other:9092', site: 'here' } }); + }); + + it('should apply only the fraction covered by the prefix', () => { + const extConfig = { topic: 'gc', concurrency: 10 }; + applyConfigOverrides(extConfig, ['extensions', 'gc'], overrides({ + kafka: { hosts: 'other:9092' }, + extensions: { + gc: { topic: 'patched-gc' }, + lifecycle: { zookeeperPath: '/patched' }, + }, + })); + assert.deepStrictEqual(extConfig, { topic: 'patched-gc', concurrency: 10 }); + }); + + it('should leave the config alone when the prefix is not covered', () => { + const extConfig = { topic: 'gc' }; + applyConfigOverrides(extConfig, ['extensions', 'gc'], + overrides({ extensions: { lifecycle: { zookeeperPath: '/p' } } })); + assert.deepStrictEqual(extConfig, { topic: 'gc' }); + }); + + it('should return the config when the prefix is not covered', () => { + const extConfig = { topic: 'gc' }; + const returned = applyConfigOverrides(extConfig, ['extensions', 'gc'], + overrides({ kafka: { hosts: 'other:9092' } })); + assert.strictEqual(returned, extConfig); + }); + + it('should leave the config alone when the prefix breaks early', () => { + const extConfig = { topic: 'gc' }; + applyConfigOverrides(extConfig, ['extensions', 'gc'], + overrides({ kafka: { hosts: 'other:9092' } })); + assert.deepStrictEqual(extConfig, { topic: 'gc' }); + }); + + it('should return the replacement when the fraction is not an object', () => { + // such a fraction replaces the section instead of updating it, so it + // cannot be applied in place: the schema reports whatever it holds + ['nonsense', 42, null, ['a', 'list']].forEach(gc => { + assert.deepStrictEqual( + applyConfigOverrides({ topic: 'gc' }, ['extensions', 'gc'], + overrides({ extensions: { gc } })), + gc); + }); + }); + + it('should update the config in place', () => { + const config = { kafka: { hosts: 'localhost:9092' } }; + applyConfigOverrides(config, [], overrides({ kafka: { hosts: 'other:9092' } })); + assert.strictEqual(config.kafka.hosts, 'other:9092'); + }); + + it('should read the overrides from the process environment by default', () => { + process.env[CONFIG_OVERRIDES] = '{"kafka":{"hosts":"from-process-env"}}'; + try { + const config = { kafka: { hosts: 'localhost:9092' } }; + applyConfigOverrides(config); + assert.strictEqual(config.kafka.hosts, 'from-process-env'); + } finally { + delete process.env[CONFIG_OVERRIDES]; + } + }); + }); +}); diff --git a/tests/unit/lib/config/extensionConfigValidator.spec.js b/tests/unit/lib/config/extensionConfigValidator.spec.js index 8ccecbb77..d5086e35f 100644 --- a/tests/unit/lib/config/extensionConfigValidator.spec.js +++ b/tests/unit/lib/config/extensionConfigValidator.spec.js @@ -79,6 +79,13 @@ describe('extension config validator', () => { /"unknown" is not allowed/); }); + it('should leave the schema to report a generic override replacing the config', () => { + assert.throws( + () => withEnv({ BACKBEAT_CONFIG_OVERRIDES: '{"extensions":{"demo":"nonsense"}}' }, + () => validator(globalConfig, extConfig())), + /"value" must be of type object/); + }); + /** * Every extension is validated through the same factory: the env var of one * of its fields is checked here, so that a schema losing the annotations,