diff --git a/packages/dd-trace/src/appsec/remote_config.js b/packages/dd-trace/src/appsec/remote_config.js index 7e4ee6020d5..457cebe0290 100644 --- a/packages/dd-trace/src/appsec/remote_config.js +++ b/packages/dd-trace/src/appsec/remote_config.js @@ -11,8 +11,8 @@ let rc /** * Configures remote config handlers for appsec features - * @param {Object} rcInstance - RemoteConfigManager instance * + * @param {Object} rcInstance - RemoteConfig instance * @param {Object} config - Tracer config * @param {Object} appsec - Appsec module */ diff --git a/packages/dd-trace/src/config/remote_config.js b/packages/dd-trace/src/config/remote_config.js new file mode 100644 index 00000000000..7839a46f3b8 --- /dev/null +++ b/packages/dd-trace/src/config/remote_config.js @@ -0,0 +1,34 @@ +'use strict' + +const RemoteConfigCapabilities = require('../remote_config/capabilities') + +/** + * Configures remote config for core APM tracing functionality + * + * @param {Object} rc - RemoteConfig instance + * @param {Object} config - Tracer config + * @param {Function} enableOrDisableTracing - Function to enable/disable tracing based on config + */ +function enable (rc, config, enableOrDisableTracing) { + // Register core APM tracing capabilities + rc.updateCapabilities(RemoteConfigCapabilities.APM_TRACING_CUSTOM_TAGS, true) + rc.updateCapabilities(RemoteConfigCapabilities.APM_TRACING_HTTP_HEADER_TAGS, true) + rc.updateCapabilities(RemoteConfigCapabilities.APM_TRACING_LOGS_INJECTION, true) + rc.updateCapabilities(RemoteConfigCapabilities.APM_TRACING_SAMPLE_RATE, true) + rc.updateCapabilities(RemoteConfigCapabilities.APM_TRACING_ENABLED, true) + rc.updateCapabilities(RemoteConfigCapabilities.APM_TRACING_SAMPLE_RULES, true) + + // APM_TRACING product handler - manages tracer configuration + rc.setProductHandler('APM_TRACING', (action, conf) => { + if (action === 'unapply') { + config.configure({}, true) + } else { + config.configure(conf.lib_config, true) + } + enableOrDisableTracing(config) + }) +} + +module.exports = { + enable +} diff --git a/packages/dd-trace/src/openfeature/remote_config.js b/packages/dd-trace/src/openfeature/remote_config.js new file mode 100644 index 00000000000..4ea9e9c5959 --- /dev/null +++ b/packages/dd-trace/src/openfeature/remote_config.js @@ -0,0 +1,31 @@ +'use strict' + +const RemoteConfigCapabilities = require('../remote_config/capabilities') + +/** + * Configures remote config handlers for openfeature feature flagging + * + * @param {Object} rc - RemoteConfig instance + * @param {Object} config - Tracer config + * @param {Function} getOpenfeatureProxy - Function that returns the OpenFeature proxy from tracer + */ +function enable (rc, config, getOpenfeatureProxy) { + // Always enable capability for feature flag configuration + // This indicates the library supports this capability via remote config + rc.updateCapabilities(RemoteConfigCapabilities.FFE_FLAG_CONFIGURATION_RULES, true) + + // Only register product handler if the experimental feature is enabled + if (!config.experimental.flaggingProvider.enabled) return + + // Set product handler for FFE_FLAGS + rc.setProductHandler('FFE_FLAGS', (action, conf) => { + // Feed UFC config directly to OpenFeature provider + if (action === 'apply' || action === 'modify') { + getOpenfeatureProxy()._setConfiguration(conf) + } + }) +} + +module.exports = { + enable +} diff --git a/packages/dd-trace/src/proxy.js b/packages/dd-trace/src/proxy.js index 3984eb099fa..199282d86e6 100644 --- a/packages/dd-trace/src/proxy.js +++ b/packages/dd-trace/src/proxy.js @@ -126,16 +126,11 @@ class Tracer extends NoopProxy { } if (config.remoteConfig.enabled && !config.isCiVisibility) { - const rc = require('./remote_config').enable(config) + const RemoteConfig = require('./remote_config') + const rc = new RemoteConfig(config) - rc.setProductHandler('APM_TRACING', (action, conf) => { - if (action === 'unapply') { - config.configure({}, true) - } else { - config.configure(conf.lib_config, true) - } - this._enableOrDisableTracing(config) - }) + const tracingRemoteConfig = require('./config/remote_config') + tracingRemoteConfig.enable(rc, config, this._enableOrDisableTracing.bind(this)) rc.setProductHandler('AGENT_CONFIG', (action, conf) => { if (!conf?.name?.startsWith('flare-log-level.')) return @@ -165,14 +160,8 @@ class Tracer extends NoopProxy { DynamicInstrumentation.start(config, rc) } - if (config.experimental.flaggingProvider.enabled) { - rc.setProductHandler('FFE_FLAGS', (action, conf) => { - // Feed UFC config directly to OpenFeature provider - if (action === 'apply' || action === 'modify') { - this.openfeature._setConfiguration(conf) - } - }) - } + const openfeatureRemoteConfig = require('./openfeature/remote_config') + openfeatureRemoteConfig.enable(rc, config, () => this.openfeature) } if (config.profiling.enabled === 'true') { diff --git a/packages/dd-trace/src/remote_config/index.js b/packages/dd-trace/src/remote_config/index.js index 7a82ab40e2d..d16cf211d68 100644 --- a/packages/dd-trace/src/remote_config/index.js +++ b/packages/dd-trace/src/remote_config/index.js @@ -1,28 +1,610 @@ 'use strict' -const RemoteConfigManager = require('./manager') -const RemoteConfigCapabilities = require('./capabilities') +const { URL, format } = require('url') +const uuid = require('../../../../vendor/dist/crypto-randomuuid') +const tracerVersion = require('../../../../package.json').version +const request = require('../exporters/common/request') +const log = require('../log') +const { getExtraServices } = require('../service-naming/extra-services') +const { UNACKNOWLEDGED, ACKNOWLEDGED, ERROR } = require('./apply_states') +const Scheduler = require('./scheduler') +const { GIT_REPOSITORY_URL, GIT_COMMIT_SHA } = require('../plugins/util/tags') +const tagger = require('../tagger') +const defaults = require('../config_defaults') + +const clientId = uuid() + +const DEFAULT_CAPABILITY = Buffer.alloc(1).toString('base64') // 0x00 + +const kSupportsAckCallback = Symbol('kSupportsAckCallback') + +// There MUST NOT exist separate instances of RC clients in a tracer making separate ClientGetConfigsRequest +// with their own separated Client.ClientState. +class RemoteConfig { + constructor (config) { + const pollInterval = Math.floor(config.remoteConfig.pollInterval * 1000) + + this.url = config.url || new URL(format({ + protocol: 'http:', + hostname: config.hostname || defaults.hostname, + port: config.port + })) + + tagger.add(config.tags, { + '_dd.rc.client_id': clientId + }) + + const tags = config.repositoryUrl + ? { + ...config.tags, + [GIT_REPOSITORY_URL]: config.repositoryUrl, + [GIT_COMMIT_SHA]: config.commitSHA + } + : config.tags + + this._handlers = new Map() + this._products = new Set() + this._batchHandlers = new Map() + const appliedConfigs = this.appliedConfigs = new Map() + + this.scheduler = new Scheduler((cb) => this.poll(cb), pollInterval) + + this.state = { + client: { + state: { // updated by `parseConfig()` and `poll()` + root_version: 1, + targets_version: 0, + // Use getter so `apply_*` can be updated async and still affect the content of `config_states` + get config_states () { + const configs = [] + for (const conf of appliedConfigs.values()) { + configs.push({ + id: conf.id, + version: conf.version, + product: conf.product, + apply_state: conf.apply_state, + apply_error: conf.apply_error + }) + } + return configs + }, + has_error: false, + error: '', + backend_client_state: '' + }, + id: clientId, + products: /** @type {string[]} */ ([]), // updated by `updateProducts()` + is_tracer: true, + client_tracer: { + runtime_id: config.tags['runtime-id'], + language: 'node', + tracer_version: tracerVersion, + service: config.service, + env: config.env, + app_version: config.version, + extra_services: /** @type {string[]} */ ([]), + tags: Object.entries(tags).map((pair) => pair.join(':')) + }, + capabilities: DEFAULT_CAPABILITY // updated by `updateCapabilities()` + }, + cached_target_files: /** @type {RcCachedTargetFile[]} */ ([]) // updated by `parseConfig()` + } + } + + /** + * @param {bigint} mask + * @param {boolean} value + */ + updateCapabilities (mask, value) { + const hex = Buffer.from(this.state.client.capabilities, 'base64').toString('hex') + + let num = BigInt(`0x${hex}`) + + if (value) { + num |= mask + } else { + num &= ~mask + } + + let str = num.toString(16) + + if (str.length % 2) str = `0${str}` + + this.state.client.capabilities = Buffer.from(str, 'hex').toString('base64') + } + + /** + * Subscribe to a product and register a per-config handler. + * + * This is the common API for products that can be handled one config at a time. + * It **implies subscription** (equivalent to calling `subscribeProducts(product)`). + * + * @param {string} product + * @param {Function} handler + */ + setProductHandler (product, handler) { + this._handlers.set(product, handler) + this.subscribeProducts(product) + } + + /** + * Remove the per-config handler for a product and unsubscribe from it. + * + * If you only want to stop receiving configs (but keep the handler attached for later), + * call `unsubscribeProducts(product)` instead. + * + * @param {string} product + */ + removeProductHandler (product) { + this._handlers.delete(product) + this.unsubscribeProducts(product) + } + + /** + * Subscribe to one or more products with Remote Config (receive configs for them). + * + * This only affects subscription/polling and does **not** register any handler. + * + * @param {...string} products + */ + subscribeProducts (...products) { + const hadProducts = this._products.size > 0 + for (const product of products) { + this._products.add(product) + } + this.updateProducts() + if (!hadProducts && this._products.size > 0) { + this.scheduler.start() + } + } + + /** + * Unsubscribe from one or more products (stop receiving configs for them). + * + * This does **not** remove registered handlers; use `removeProductHandler(product)` + * if you want to detach a handler as well. + * + * @param {...string} products + */ + unsubscribeProducts (...products) { + const hadProducts = this._products.size > 0 + for (const product of products) { + this._products.delete(product) + } + this.updateProducts() + if (hadProducts && this._products.size === 0) { + this.scheduler.stop() + } + } + + updateProducts () { + this.state.client.products = [...this._products] + } + + /** + * Register a handler that will be invoked once per RC update, with the update batch filtered + * down to the specified products. This is useful for consumers that need to process multiple + * configs at once (e.g. WAF updates spanning ASM/ASM_DD/ASM_DATA) and then do one-time reconciliation. + * + * This does **not** implicitly subscribe to the products; call `subscribeProducts()` separately. + * + * @param {string[]} products + * @param {(tx: RcBatchUpdateTx) => void} handler + */ + setBatchHandler (products, handler) { + this._batchHandlers.set(handler, new Set(products)) + } + + /** + * Remove a previously-registered batch handler. + * + * @param {Function} handler + */ + removeBatchHandler (handler) { + this._batchHandlers.delete(handler) + } + + getPayload () { + this.state.client.client_tracer.extra_services = getExtraServices() + + return JSON.stringify(this.state) + } + + poll (cb) { + const options = { + url: this.url, + method: 'POST', + path: '/v0.7/config', + headers: { + 'Content-Type': 'application/json; charset=utf-8' + } + } + + request(this.getPayload(), options, (err, data, statusCode) => { + // 404 means RC is disabled, ignore it + if (statusCode === 404) return cb() + + if (err) { + log.errorWithoutTelemetry('[RC] Error in request', err) + return cb() + } + + // if error was just sent, reset the state + if (this.state.client.state.has_error) { + this.state.client.state.has_error = false + this.state.client.state.error = '' + } + + if (data && data !== '{}') { // '{}' means the tracer is up to date + try { + this.parseConfig(JSON.parse(data)) + } catch (err) { + log.error('[RC] Could not parse remote config response', err) + + this.state.client.state.has_error = true + this.state.client.state.error = err.toString() + } + } + + cb() + }) + } + + // `client_configs` is the list of config paths to have applied + // `targets` is the signed index with metadata for config files + // `target_files` is the list of config files containing the actual config data + parseConfig ({ + client_configs: clientConfigs = [], + targets, + target_files: targetFiles = [] + }) { + const toUnapply = /** @type {RcConfigState[]} */ ([]) + const toApply = /** @type {RcConfigState[]} */ ([]) + const toModify = /** @type {RcConfigState[]} */ ([]) + const txByPath = new Map() + const txHandledPaths = new Set() + const txOutcomes = new Map() + + for (const appliedConfig of this.appliedConfigs.values()) { + if (!clientConfigs.includes(appliedConfig.path)) { + toUnapply.push(appliedConfig) + txByPath.set(appliedConfig.path, appliedConfig) + } + } + + targets = fromBase64JSON(targets) + + if (targets) { + for (const path of clientConfigs) { + const meta = targets.signed.targets[path] + if (!meta) throw new Error(`Unable to find target for path ${path}`) + + const current = this.appliedConfigs.get(path) + + const newConf = /** @type {RcConfigState} */ ({}) + + if (current) { + if (current.hashes.sha256 === meta.hashes.sha256) continue + + toModify.push(newConf) + } else { + toApply.push(newConf) + } + + const file = targetFiles.find(file => file.path === path) + if (!file) throw new Error(`Unable to find file for path ${path}`) + + // TODO: verify signatures + // verify length + // verify hash + // verify _type + // TODO: new Date(meta.signed.expires) ignore the Targets data if it has expired ? + + const { product, id } = parseConfigPath(path) + + Object.assign(newConf, { + path, + product, + id, + version: meta.custom.v, + apply_state: UNACKNOWLEDGED, + apply_error: '', + length: meta.length, + hashes: meta.hashes, + file: fromBase64JSON(file.raw) + }) + txByPath.set(path, newConf) + } + + this.state.client.state.targets_version = targets.signed.version + this.state.client.state.backend_client_state = targets.signed.custom.opaque_backend_state + } + + if (toUnapply.length || toApply.length || toModify.length) { + const tx = createUpdateTransaction({ toUnapply, toApply, toModify }, txHandledPaths, txOutcomes) + + if (this._batchHandlers.size) { + for (const [handler, products] of this._batchHandlers) { + const txView = filterTransactionByProducts(tx, products) + if (txView.toUnapply.length || txView.toApply.length || txView.toModify.length) { + handler(txView) + } + } + } + + applyOutcomes(txByPath, txOutcomes) + + this.dispatch(toUnapply, 'unapply', txHandledPaths) + this.dispatch(toApply, 'apply', txHandledPaths) + this.dispatch(toModify, 'modify', txHandledPaths) + + this.state.cached_target_files = /** @type {RcCachedTargetFile[]} */ ([]) + + for (const conf of this.appliedConfigs.values()) { + const hashes = [] + for (const hash of Object.entries(conf.hashes)) { + hashes.push({ algorithm: hash[0], hash: hash[1] }) + } + this.state.cached_target_files.push({ + path: conf.path, + length: conf.length, + hashes + }) + } + } + } + + /** + * Dispatch a list of config changes to per-product handlers, skipping any paths + * marked as handled by a batch handler. + * + * @param {RcConfigState[]} list + * @param {'apply' | 'modify' | 'unapply'} action + * @param {Set} handledPaths + */ + dispatch (list, action, handledPaths) { + for (const item of list) { + if (!handledPaths.has(item.path)) { + this._callHandlerFor(action, item) + } + + if (action === 'unapply') { + this.appliedConfigs.delete(item.path) + } else { + this.appliedConfigs.set(item.path, item) + } + } + } + + /** + * @param {'apply' | 'modify' | 'unapply'} action + * @param {RcConfigState} item + */ + _callHandlerFor (action, item) { + // in case the item was already handled by a batch hook + if (item.apply_state !== UNACKNOWLEDGED && action !== 'unapply') return + + const handler = this._handlers.get(item.product) + + if (!handler) return + + try { + if (supportsAckCallback(handler)) { + // If the handler accepts an `ack` callback, expect that to be called and set `apply_state` accordingly + // TODO: do we want to pass old and new config ? + handler(action, item.file, item.id, (err) => { + if (err) { + item.apply_state = ERROR + item.apply_error = err.toString() + } else if (item.apply_state !== ERROR) { + item.apply_state = ACKNOWLEDGED + } + }) + } else { + // If the handler doesn't accept an `ack` callback, assume `apply_state` is `ACKNOWLEDGED`, + // unless it returns a promise, in which case we wait for the promise to be resolved or rejected. + // TODO: do we want to pass old and new config ? + const result = handler(action, item.file, item.id) + if (result instanceof Promise) { + result.then( + () => { item.apply_state = ACKNOWLEDGED }, + (err) => { + item.apply_state = ERROR + item.apply_error = err.toString() + } + ) + } else { + item.apply_state = ACKNOWLEDGED + } + } + } catch (err) { + item.apply_state = ERROR + item.apply_error = err.toString() + } + } +} + +/** + * Remote Config “applied config” state tracked by the RC manager. + * This is the mutable shape stored in `this.appliedConfigs` and passed to per-product handlers. + * + * @typedef {Object} RcConfigState + * @property {string} path + * @property {string} product + * @property {string} id + * @property {number} version + * @property {unknown} file + * @property {number} apply_state + * @property {string} apply_error + * @property {number} length + * @property {Record} hashes + */ + +/** + * Target file metadata cached in `state.cached_target_files` and sent back to the agent. + * + * @typedef {Object} RcCachedTargetFile + * @property {string} path + * @property {number} length + * @property {Array<{algorithm: string, hash: string}>} hashes + */ + +/** + * @typedef {Object} RcConfigDescriptor + * @property {string} path + * @property {string} product + * @property {string} id + * @property {number} version + * @property {unknown} file + */ /** - * Enables remote configuration by creating and configuring a RemoteConfigManager instance. - * Sets up core APM tracing capabilities for remote configuration. + * Remote Config batch update transaction passed to batch handlers registered via + * `RemoteConfig.setBatchHandler()`. * - * @param {import('../config')} config - The tracer configuration object - * @returns {RemoteConfigManager} The configured remote config manager instance + * @typedef {Object} RcBatchUpdateTx + * @property {RcConfigDescriptor[]} toUnapply + * @property {RcConfigDescriptor[]} toApply + * @property {RcConfigDescriptor[]} toModify + * @property {{toUnapply: RcConfigDescriptor[], toApply: RcConfigDescriptor[], toModify: RcConfigDescriptor[]}} changes + * @property {(path: string) => void} markHandled + * @property {(path: string) => void} ack + * @property {(path: string, err: unknown) => void} error */ -function enable (config) { - const rc = new RemoteConfigManager(config) - rc.updateCapabilities(RemoteConfigCapabilities.APM_TRACING_CUSTOM_TAGS, true) - rc.updateCapabilities(RemoteConfigCapabilities.APM_TRACING_HTTP_HEADER_TAGS, true) - rc.updateCapabilities(RemoteConfigCapabilities.APM_TRACING_LOGS_INJECTION, true) - rc.updateCapabilities(RemoteConfigCapabilities.APM_TRACING_SAMPLE_RATE, true) - rc.updateCapabilities(RemoteConfigCapabilities.APM_TRACING_ENABLED, true) - rc.updateCapabilities(RemoteConfigCapabilities.APM_TRACING_SAMPLE_RULES, true) - rc.updateCapabilities(RemoteConfigCapabilities.FFE_FLAG_CONFIGURATION_RULES, true) - return rc +/** + * Create an immutable “view” of the batch changes and attach explicit outcome reporting. + * + * @param {{toUnapply: RcConfigState[], toApply: RcConfigState[], toModify: RcConfigState[]}} changes + * @param {Set} handledPaths + * @param {Map} outcomes + * @returns {RcBatchUpdateTx} + */ +function createUpdateTransaction ({ toUnapply, toApply, toModify }, handledPaths, outcomes) { + const descriptors = { + toUnapply: toUnapply.map(toDescriptor), + toApply: toApply.map(toDescriptor), + toModify: toModify.map(toDescriptor) + } + + // Expose descriptors directly for ease-of-use, and also under `changes` for clarity. + const tx = { + ...descriptors, + changes: descriptors, + markHandled (path) { + if (typeof path === 'string') handledPaths.add(path) + }, + ack (path) { + if (typeof path === 'string') outcomes.set(path, { state: ACKNOWLEDGED, error: '' }) + }, + error (path, err) { + if (typeof path !== 'string') return + outcomes.set(path, { state: ERROR, error: err ? err.toString() : 'Error' }) + } + } + + return tx } -module.exports = { - enable +/** + * Create a filtered “view” of the transaction for a given product set, while preserving + * the outcome methods (ack/error/markHandled). + * + * @param {RcBatchUpdateTx} tx + * @param {Set} products + * @returns {RcBatchUpdateTx} + */ +function filterTransactionByProducts (tx, products) { + const toUnapply = [] + const toApply = [] + const toModify = [] + + for (const item of tx.toUnapply) { + if (products.has(item.product)) toUnapply.push(item) + } + + for (const item of tx.toApply) { + if (products.has(item.product)) toApply.push(item) + } + + for (const item of tx.toModify) { + if (products.has(item.product)) toModify.push(item) + } + + const changes = { toUnapply, toApply, toModify } + + return { + toUnapply, + toApply, + toModify, + changes, + markHandled: tx.markHandled, + ack: tx.ack, + error: tx.error + } +} + +/** + * @param {RcConfigState} conf + * @returns {RcConfigDescriptor} + */ +function toDescriptor (conf) { + const desc = { + path: conf.path, + product: conf.product, + id: conf.id, + version: conf.version, + file: conf.file + } + return Object.freeze(desc) +} + +function applyOutcomes (byPath, outcomes) { + for (const [path, outcome] of outcomes) { + const item = byPath.get(path) + if (!item) continue + item.apply_state = outcome.state + item.apply_error = outcome.error + } +} + +function fromBase64JSON (str) { + if (!str) return null + + return JSON.parse(Buffer.from(str, 'base64').toString()) } + +const configPathRegex = /^(?:datadog\/\d+|employee)\/([^/]+)\/([^/]+)\/[^/]+$/ + +function parseConfigPath (configPath) { + const match = configPathRegex.exec(configPath) + + if (!match || !match[1] || !match[2]) { + throw new Error(`Unable to parse path ${configPath}`) + } + + return { + product: match[1], + id: match[2] + } +} + +function supportsAckCallback (handler) { + if (kSupportsAckCallback in handler) return handler[kSupportsAckCallback] + + const numOfArgs = handler.length + let result = false + + if (numOfArgs >= 4) { + result = true + } else if (numOfArgs !== 0) { + const source = handler.toString() + result = source.slice(0, source.indexOf(')')).includes('...') + } + + handler[kSupportsAckCallback] = result + + return result +} + +module.exports = RemoteConfig diff --git a/packages/dd-trace/src/remote_config/manager.js b/packages/dd-trace/src/remote_config/manager.js deleted file mode 100644 index 053762e4a73..00000000000 --- a/packages/dd-trace/src/remote_config/manager.js +++ /dev/null @@ -1,610 +0,0 @@ -'use strict' - -const { URL, format } = require('url') -const uuid = require('../../../../vendor/dist/crypto-randomuuid') -const tracerVersion = require('../../../../package.json').version -const request = require('../exporters/common/request') -const log = require('../log') -const { getExtraServices } = require('../service-naming/extra-services') -const { UNACKNOWLEDGED, ACKNOWLEDGED, ERROR } = require('./apply_states') -const Scheduler = require('./scheduler') -const { GIT_REPOSITORY_URL, GIT_COMMIT_SHA } = require('../plugins/util/tags') -const tagger = require('../tagger') -const defaults = require('../config_defaults') - -const clientId = uuid() - -const DEFAULT_CAPABILITY = Buffer.alloc(1).toString('base64') // 0x00 - -const kSupportsAckCallback = Symbol('kSupportsAckCallback') - -// There MUST NOT exist separate instances of RC clients in a tracer making separate ClientGetConfigsRequest -// with their own separated Client.ClientState. -class RemoteConfigManager { - constructor (config) { - const pollInterval = Math.floor(config.remoteConfig.pollInterval * 1000) - - this.url = config.url || new URL(format({ - protocol: 'http:', - hostname: config.hostname || defaults.hostname, - port: config.port - })) - - tagger.add(config.tags, { - '_dd.rc.client_id': clientId - }) - - const tags = config.repositoryUrl - ? { - ...config.tags, - [GIT_REPOSITORY_URL]: config.repositoryUrl, - [GIT_COMMIT_SHA]: config.commitSHA - } - : config.tags - - this._handlers = new Map() - this._products = new Set() - this._batchHandlers = new Map() - const appliedConfigs = this.appliedConfigs = new Map() - - this.scheduler = new Scheduler((cb) => this.poll(cb), pollInterval) - - this.state = { - client: { - state: { // updated by `parseConfig()` and `poll()` - root_version: 1, - targets_version: 0, - // Use getter so `apply_*` can be updated async and still affect the content of `config_states` - get config_states () { - const configs = [] - for (const conf of appliedConfigs.values()) { - configs.push({ - id: conf.id, - version: conf.version, - product: conf.product, - apply_state: conf.apply_state, - apply_error: conf.apply_error - }) - } - return configs - }, - has_error: false, - error: '', - backend_client_state: '' - }, - id: clientId, - products: /** @type {string[]} */ ([]), // updated by `updateProducts()` - is_tracer: true, - client_tracer: { - runtime_id: config.tags['runtime-id'], - language: 'node', - tracer_version: tracerVersion, - service: config.service, - env: config.env, - app_version: config.version, - extra_services: /** @type {string[]} */ ([]), - tags: Object.entries(tags).map((pair) => pair.join(':')) - }, - capabilities: DEFAULT_CAPABILITY // updated by `updateCapabilities()` - }, - cached_target_files: /** @type {RcCachedTargetFile[]} */ ([]) // updated by `parseConfig()` - } - } - - /** - * @param {bigint} mask - * @param {boolean} value - */ - updateCapabilities (mask, value) { - const hex = Buffer.from(this.state.client.capabilities, 'base64').toString('hex') - - let num = BigInt(`0x${hex}`) - - if (value) { - num |= mask - } else { - num &= ~mask - } - - let str = num.toString(16) - - if (str.length % 2) str = `0${str}` - - this.state.client.capabilities = Buffer.from(str, 'hex').toString('base64') - } - - /** - * Subscribe to a product and register a per-config handler. - * - * This is the common API for products that can be handled one config at a time. - * It **implies subscription** (equivalent to calling `subscribeProducts(product)`). - * - * @param {string} product - * @param {Function} handler - */ - setProductHandler (product, handler) { - this._handlers.set(product, handler) - this.subscribeProducts(product) - } - - /** - * Remove the per-config handler for a product and unsubscribe from it. - * - * If you only want to stop receiving configs (but keep the handler attached for later), - * call `unsubscribeProducts(product)` instead. - * - * @param {string} product - */ - removeProductHandler (product) { - this._handlers.delete(product) - this.unsubscribeProducts(product) - } - - /** - * Subscribe to one or more products with Remote Config (receive configs for them). - * - * This only affects subscription/polling and does **not** register any handler. - * - * @param {...string} products - */ - subscribeProducts (...products) { - const hadProducts = this._products.size > 0 - for (const product of products) { - this._products.add(product) - } - this.updateProducts() - if (!hadProducts && this._products.size > 0) { - this.scheduler.start() - } - } - - /** - * Unsubscribe from one or more products (stop receiving configs for them). - * - * This does **not** remove registered handlers; use `removeProductHandler(product)` - * if you want to detach a handler as well. - * - * @param {...string} products - */ - unsubscribeProducts (...products) { - const hadProducts = this._products.size > 0 - for (const product of products) { - this._products.delete(product) - } - this.updateProducts() - if (hadProducts && this._products.size === 0) { - this.scheduler.stop() - } - } - - updateProducts () { - this.state.client.products = [...this._products] - } - - /** - * Register a handler that will be invoked once per RC update, with the update batch filtered - * down to the specified products. This is useful for consumers that need to process multiple - * configs at once (e.g. WAF updates spanning ASM/ASM_DD/ASM_DATA) and then do one-time reconciliation. - * - * This does **not** implicitly subscribe to the products; call `subscribeProducts()` separately. - * - * @param {string[]} products - * @param {(tx: RcBatchUpdateTx) => void} handler - */ - setBatchHandler (products, handler) { - this._batchHandlers.set(handler, new Set(products)) - } - - /** - * Remove a previously-registered batch handler. - * - * @param {Function} handler - */ - removeBatchHandler (handler) { - this._batchHandlers.delete(handler) - } - - getPayload () { - this.state.client.client_tracer.extra_services = getExtraServices() - - return JSON.stringify(this.state) - } - - poll (cb) { - const options = { - url: this.url, - method: 'POST', - path: '/v0.7/config', - headers: { - 'Content-Type': 'application/json; charset=utf-8' - } - } - - request(this.getPayload(), options, (err, data, statusCode) => { - // 404 means RC is disabled, ignore it - if (statusCode === 404) return cb() - - if (err) { - log.errorWithoutTelemetry('[RC] Error in request', err) - return cb() - } - - // if error was just sent, reset the state - if (this.state.client.state.has_error) { - this.state.client.state.has_error = false - this.state.client.state.error = '' - } - - if (data && data !== '{}') { // '{}' means the tracer is up to date - try { - this.parseConfig(JSON.parse(data)) - } catch (err) { - log.error('[RC] Could not parse remote config response', err) - - this.state.client.state.has_error = true - this.state.client.state.error = err.toString() - } - } - - cb() - }) - } - - // `client_configs` is the list of config paths to have applied - // `targets` is the signed index with metadata for config files - // `target_files` is the list of config files containing the actual config data - parseConfig ({ - client_configs: clientConfigs = [], - targets, - target_files: targetFiles = [] - }) { - const toUnapply = /** @type {RcConfigState[]} */ ([]) - const toApply = /** @type {RcConfigState[]} */ ([]) - const toModify = /** @type {RcConfigState[]} */ ([]) - const txByPath = new Map() - const txHandledPaths = new Set() - const txOutcomes = new Map() - - for (const appliedConfig of this.appliedConfigs.values()) { - if (!clientConfigs.includes(appliedConfig.path)) { - toUnapply.push(appliedConfig) - txByPath.set(appliedConfig.path, appliedConfig) - } - } - - targets = fromBase64JSON(targets) - - if (targets) { - for (const path of clientConfigs) { - const meta = targets.signed.targets[path] - if (!meta) throw new Error(`Unable to find target for path ${path}`) - - const current = this.appliedConfigs.get(path) - - const newConf = /** @type {RcConfigState} */ ({}) - - if (current) { - if (current.hashes.sha256 === meta.hashes.sha256) continue - - toModify.push(newConf) - } else { - toApply.push(newConf) - } - - const file = targetFiles.find(file => file.path === path) - if (!file) throw new Error(`Unable to find file for path ${path}`) - - // TODO: verify signatures - // verify length - // verify hash - // verify _type - // TODO: new Date(meta.signed.expires) ignore the Targets data if it has expired ? - - const { product, id } = parseConfigPath(path) - - Object.assign(newConf, { - path, - product, - id, - version: meta.custom.v, - apply_state: UNACKNOWLEDGED, - apply_error: '', - length: meta.length, - hashes: meta.hashes, - file: fromBase64JSON(file.raw) - }) - txByPath.set(path, newConf) - } - - this.state.client.state.targets_version = targets.signed.version - this.state.client.state.backend_client_state = targets.signed.custom.opaque_backend_state - } - - if (toUnapply.length || toApply.length || toModify.length) { - const tx = createUpdateTransaction({ toUnapply, toApply, toModify }, txHandledPaths, txOutcomes) - - if (this._batchHandlers.size) { - for (const [handler, products] of this._batchHandlers) { - const txView = filterTransactionByProducts(tx, products) - if (txView.toUnapply.length || txView.toApply.length || txView.toModify.length) { - handler(txView) - } - } - } - - applyOutcomes(txByPath, txOutcomes) - - this.dispatch(toUnapply, 'unapply', txHandledPaths) - this.dispatch(toApply, 'apply', txHandledPaths) - this.dispatch(toModify, 'modify', txHandledPaths) - - this.state.cached_target_files = /** @type {RcCachedTargetFile[]} */ ([]) - - for (const conf of this.appliedConfigs.values()) { - const hashes = [] - for (const hash of Object.entries(conf.hashes)) { - hashes.push({ algorithm: hash[0], hash: hash[1] }) - } - this.state.cached_target_files.push({ - path: conf.path, - length: conf.length, - hashes - }) - } - } - } - - /** - * Dispatch a list of config changes to per-product handlers, skipping any paths - * marked as handled by a batch handler. - * - * @param {RcConfigState[]} list - * @param {'apply' | 'modify' | 'unapply'} action - * @param {Set} handledPaths - */ - dispatch (list, action, handledPaths) { - for (const item of list) { - if (!handledPaths.has(item.path)) { - this._callHandlerFor(action, item) - } - - if (action === 'unapply') { - this.appliedConfigs.delete(item.path) - } else { - this.appliedConfigs.set(item.path, item) - } - } - } - - /** - * @param {'apply' | 'modify' | 'unapply'} action - * @param {RcConfigState} item - */ - _callHandlerFor (action, item) { - // in case the item was already handled by a batch hook - if (item.apply_state !== UNACKNOWLEDGED && action !== 'unapply') return - - const handler = this._handlers.get(item.product) - - if (!handler) return - - try { - if (supportsAckCallback(handler)) { - // If the handler accepts an `ack` callback, expect that to be called and set `apply_state` accordingly - // TODO: do we want to pass old and new config ? - handler(action, item.file, item.id, (err) => { - if (err) { - item.apply_state = ERROR - item.apply_error = err.toString() - } else if (item.apply_state !== ERROR) { - item.apply_state = ACKNOWLEDGED - } - }) - } else { - // If the handler doesn't accept an `ack` callback, assume `apply_state` is `ACKNOWLEDGED`, - // unless it returns a promise, in which case we wait for the promise to be resolved or rejected. - // TODO: do we want to pass old and new config ? - const result = handler(action, item.file, item.id) - if (result instanceof Promise) { - result.then( - () => { item.apply_state = ACKNOWLEDGED }, - (err) => { - item.apply_state = ERROR - item.apply_error = err.toString() - } - ) - } else { - item.apply_state = ACKNOWLEDGED - } - } - } catch (err) { - item.apply_state = ERROR - item.apply_error = err.toString() - } - } -} - -/** - * Remote Config “applied config” state tracked by the RC manager. - * This is the mutable shape stored in `this.appliedConfigs` and passed to per-product handlers. - * - * @typedef {Object} RcConfigState - * @property {string} path - * @property {string} product - * @property {string} id - * @property {number} version - * @property {unknown} file - * @property {number} apply_state - * @property {string} apply_error - * @property {number} length - * @property {Record} hashes - */ - -/** - * Target file metadata cached in `state.cached_target_files` and sent back to the agent. - * - * @typedef {Object} RcCachedTargetFile - * @property {string} path - * @property {number} length - * @property {Array<{algorithm: string, hash: string}>} hashes - */ - -/** - * @typedef {Object} RcConfigDescriptor - * @property {string} path - * @property {string} product - * @property {string} id - * @property {number} version - * @property {unknown} file - */ - -/** - * Remote Config batch update transaction passed to batch handlers registered via - * `RemoteConfigManager.setBatchHandler()`. - * - * @typedef {Object} RcBatchUpdateTx - * @property {RcConfigDescriptor[]} toUnapply - * @property {RcConfigDescriptor[]} toApply - * @property {RcConfigDescriptor[]} toModify - * @property {{toUnapply: RcConfigDescriptor[], toApply: RcConfigDescriptor[], toModify: RcConfigDescriptor[]}} changes - * @property {(path: string) => void} markHandled - * @property {(path: string) => void} ack - * @property {(path: string, err: unknown) => void} error - */ - -/** - * Create an immutable “view” of the batch changes and attach explicit outcome reporting. - * - * @param {{toUnapply: RcConfigState[], toApply: RcConfigState[], toModify: RcConfigState[]}} changes - * @param {Set} handledPaths - * @param {Map} outcomes - * @returns {RcBatchUpdateTx} - */ -function createUpdateTransaction ({ toUnapply, toApply, toModify }, handledPaths, outcomes) { - const descriptors = { - toUnapply: toUnapply.map(toDescriptor), - toApply: toApply.map(toDescriptor), - toModify: toModify.map(toDescriptor) - } - - // Expose descriptors directly for ease-of-use, and also under `changes` for clarity. - const tx = { - ...descriptors, - changes: descriptors, - markHandled (path) { - if (typeof path === 'string') handledPaths.add(path) - }, - ack (path) { - if (typeof path === 'string') outcomes.set(path, { state: ACKNOWLEDGED, error: '' }) - }, - error (path, err) { - if (typeof path !== 'string') return - outcomes.set(path, { state: ERROR, error: err ? err.toString() : 'Error' }) - } - } - - return tx -} - -/** - * Create a filtered “view” of the transaction for a given product set, while preserving - * the outcome methods (ack/error/markHandled). - * - * @param {RcBatchUpdateTx} tx - * @param {Set} products - * @returns {RcBatchUpdateTx} - */ -function filterTransactionByProducts (tx, products) { - const toUnapply = [] - const toApply = [] - const toModify = [] - - for (const item of tx.toUnapply) { - if (products.has(item.product)) toUnapply.push(item) - } - - for (const item of tx.toApply) { - if (products.has(item.product)) toApply.push(item) - } - - for (const item of tx.toModify) { - if (products.has(item.product)) toModify.push(item) - } - - const changes = { toUnapply, toApply, toModify } - - return { - toUnapply, - toApply, - toModify, - changes, - markHandled: tx.markHandled, - ack: tx.ack, - error: tx.error - } -} - -/** - * @param {RcConfigState} conf - * @returns {RcConfigDescriptor} - */ -function toDescriptor (conf) { - const desc = { - path: conf.path, - product: conf.product, - id: conf.id, - version: conf.version, - file: conf.file - } - return Object.freeze(desc) -} - -function applyOutcomes (byPath, outcomes) { - for (const [path, outcome] of outcomes) { - const item = byPath.get(path) - if (!item) continue - item.apply_state = outcome.state - item.apply_error = outcome.error - } -} - -function fromBase64JSON (str) { - if (!str) return null - - return JSON.parse(Buffer.from(str, 'base64').toString()) -} - -const configPathRegex = /^(?:datadog\/\d+|employee)\/([^/]+)\/([^/]+)\/[^/]+$/ - -function parseConfigPath (configPath) { - const match = configPathRegex.exec(configPath) - - if (!match || !match[1] || !match[2]) { - throw new Error(`Unable to parse path ${configPath}`) - } - - return { - product: match[1], - id: match[2] - } -} - -function supportsAckCallback (handler) { - if (kSupportsAckCallback in handler) return handler[kSupportsAckCallback] - - const numOfArgs = handler.length - let result = false - - if (numOfArgs >= 4) { - result = true - } else if (numOfArgs !== 0) { - const source = handler.toString() - result = source.slice(0, source.indexOf(')')).includes('...') - } - - handler[kSupportsAckCallback] = result - - return result -} - -module.exports = RemoteConfigManager diff --git a/packages/dd-trace/test/config/remote_config.spec.js b/packages/dd-trace/test/config/remote_config.spec.js new file mode 100644 index 00000000000..2d17e9e5313 --- /dev/null +++ b/packages/dd-trace/test/config/remote_config.spec.js @@ -0,0 +1,77 @@ +'use strict' + +const { describe, it, beforeEach } = require('tap').mocha +const sinon = require('sinon') + +const RemoteConfigCapabilities = require('../../src/remote_config/capabilities') +const { enable } = require('../../src/config/remote_config') + +require('../setup/core') + +describe('Tracing Remote Config', () => { + let rc + let config + let enableOrDisableTracing + let handlers + + beforeEach(() => { + handlers = new Map() + + rc = { + updateCapabilities: sinon.spy(), + setProductHandler: sinon.spy((product, handler) => { + handlers.set(product, handler) + }) + } + + config = { + configure: sinon.spy() + } + + enableOrDisableTracing = sinon.spy() + }) + + describe('enable', () => { + it('should register all APM tracing capabilities', () => { + enable(rc, config, enableOrDisableTracing) + + sinon.assert.calledWithExactly(rc.updateCapabilities, RemoteConfigCapabilities.APM_TRACING_CUSTOM_TAGS, true) + sinon.assert.calledWithExactly(rc.updateCapabilities, RemoteConfigCapabilities.APM_TRACING_HTTP_HEADER_TAGS, true) + sinon.assert.calledWithExactly(rc.updateCapabilities, RemoteConfigCapabilities.APM_TRACING_LOGS_INJECTION, true) + sinon.assert.calledWithExactly(rc.updateCapabilities, RemoteConfigCapabilities.APM_TRACING_SAMPLE_RATE, true) + sinon.assert.calledWithExactly(rc.updateCapabilities, RemoteConfigCapabilities.APM_TRACING_ENABLED, true) + sinon.assert.calledWithExactly(rc.updateCapabilities, RemoteConfigCapabilities.APM_TRACING_SAMPLE_RULES, true) + }) + + it('should register APM_TRACING product handler', () => { + enable(rc, config, enableOrDisableTracing) + + sinon.assert.calledOnceWithExactly(rc.setProductHandler, 'APM_TRACING', sinon.match.func) + }) + + describe('APM_TRACING handler', () => { + it('should configure tracer on apply action', () => { + enable(rc, config, enableOrDisableTracing) + + const handler = handlers.get('APM_TRACING') + const libConfig = { service: 'test-service' } + + handler('apply', { lib_config: libConfig }) + + sinon.assert.calledOnceWithExactly(config.configure, libConfig, true) + sinon.assert.calledOnceWithExactly(enableOrDisableTracing, config) + }) + + it('should reset config on unapply action', () => { + enable(rc, config, enableOrDisableTracing) + + const handler = handlers.get('APM_TRACING') + + handler('unapply', {}) + + sinon.assert.calledOnceWithExactly(config.configure, {}, true) + sinon.assert.calledOnceWithExactly(enableOrDisableTracing, config) + }) + }) + }) +}) diff --git a/packages/dd-trace/test/openfeature/remote_config.spec.js b/packages/dd-trace/test/openfeature/remote_config.spec.js new file mode 100644 index 00000000000..2cdc50c3e6d --- /dev/null +++ b/packages/dd-trace/test/openfeature/remote_config.spec.js @@ -0,0 +1,122 @@ +'use strict' + +const { describe, it, beforeEach } = require('mocha') +const sinon = require('sinon') + +const RemoteConfigCapabilities = require('../../src/remote_config/capabilities') +const { enable } = require('../../src/openfeature/remote_config') + +require('../setup/mocha') + +describe('OpenFeature Remote Config', () => { + let rc + let config + let openfeatureProxy + let getOpenfeatureProxy + let handlers + + beforeEach(() => { + handlers = new Map() + + rc = { + updateCapabilities: sinon.spy(), + setProductHandler: sinon.spy((product, handler) => { + handlers.set(product, handler) + }) + } + + config = { + experimental: { + flaggingProvider: { + enabled: true + } + } + } + + openfeatureProxy = { + _setConfiguration: sinon.spy() + } + + getOpenfeatureProxy = sinon.stub().returns(openfeatureProxy) + }) + + describe('enable', () => { + it('should enable FFE_FLAG_CONFIGURATION_RULES capability', () => { + enable(rc, config, getOpenfeatureProxy) + + sinon.assert.calledOnceWithExactly( + rc.updateCapabilities, + RemoteConfigCapabilities.FFE_FLAG_CONFIGURATION_RULES, + true + ) + }) + + it('should register FFE_FLAGS product handler', () => { + enable(rc, config, getOpenfeatureProxy) + + sinon.assert.calledOnceWithExactly(rc.setProductHandler, 'FFE_FLAGS', sinon.match.func) + }) + + it('should call _setConfiguration on apply action when feature is enabled', () => { + enable(rc, config, getOpenfeatureProxy) + + const flagConfig = { flags: { 'test-flag': {} } } + const handler = handlers.get('FFE_FLAGS') + + handler('apply', flagConfig) + + sinon.assert.calledOnceWithExactly(openfeatureProxy._setConfiguration, flagConfig) + }) + + it('should call _setConfiguration on modify action when feature is enabled', () => { + enable(rc, config, getOpenfeatureProxy) + + const flagConfig = { flags: { 'modified-flag': {} } } + const handler = handlers.get('FFE_FLAGS') + + handler('modify', flagConfig) + + sinon.assert.calledOnceWithExactly(openfeatureProxy._setConfiguration, flagConfig) + }) + + it('should not call _setConfiguration on unapply action', () => { + enable(rc, config, getOpenfeatureProxy) + + const flagConfig = { flags: { 'test-flag': {} } } + const handler = handlers.get('FFE_FLAGS') + + handler('unapply', flagConfig) + + sinon.assert.notCalled(openfeatureProxy._setConfiguration) + }) + + it('should not call _setConfiguration on unknown action', () => { + enable(rc, config, getOpenfeatureProxy) + + const flagConfig = { flags: { 'test-flag': {} } } + const handler = handlers.get('FFE_FLAGS') + + handler('unknown', flagConfig) + + sinon.assert.notCalled(openfeatureProxy._setConfiguration) + }) + + it('should not register product handler when experimental feature is disabled', () => { + config.experimental.flaggingProvider.enabled = false + enable(rc, config, getOpenfeatureProxy) + + sinon.assert.notCalled(rc.setProductHandler) + }) + + it('should still enable capability even when experimental feature is disabled', () => { + config.experimental.flaggingProvider.enabled = false + enable(rc, config, getOpenfeatureProxy) + + sinon.assert.calledOnceWithExactly( + rc.updateCapabilities, + RemoteConfigCapabilities.FFE_FLAG_CONFIGURATION_RULES, + true + ) + }) + }) +}) diff --git a/packages/dd-trace/test/proxy.spec.js b/packages/dd-trace/test/proxy.spec.js index e649e2981e4..eeee31d6ae3 100644 --- a/packages/dd-trace/test/proxy.spec.js +++ b/packages/dd-trace/test/proxy.spec.js @@ -35,7 +35,7 @@ describe('TracerProxy', () => { let PluginManager let pluginManager let flare - let remoteConfig + let RemoteConfig let handlers let rc let dogStatsD @@ -45,7 +45,7 @@ describe('TracerProxy', () => { let openfeatureProvider beforeEach(() => { - process.env.DD_TRACE_MOCHA_ENABLED = false + process.env.DD_TRACE_MOCHA_ENABLED = 'false' aiguardSdk = { evaluate: sinon.stub(), @@ -212,13 +212,7 @@ describe('TracerProxy', () => { cleanup: sinon.spy() } - remoteConfig = { - enable: sinon.stub() - } - - const appsecRemoteConfig = { - configureAppsec: sinon.stub() - } + RemoteConfig = sinon.stub().returns(rc) handlers = new Map() rc = { @@ -231,8 +225,6 @@ describe('TracerProxy', () => { unsubscribeProducts: sinon.spy() } - remoteConfig.enable.returns(rc) - NoopProxy = proxyquire('../src/noop/proxy', { './tracer': NoopTracer, '../aiguard/noop': NoopAIGuardSdk, @@ -250,9 +242,8 @@ describe('TracerProxy', () => { './profiler': profiler, './appsec': appsec, './appsec/iast': iast, - './appsec/remote_config': appsecRemoteConfig, './telemetry': telemetry, - './remote_config': remoteConfig, + './remote_config': RemoteConfig, './aiguard/sdk': AIGuardSdk, './appsec/sdk': AppsecSdk, './dogstatsd': dogStatsD, @@ -278,7 +269,7 @@ describe('TracerProxy', () => { sinon.assert.calledWith(Config, options) sinon.assert.calledWith(DatadogTracer, config) - sinon.assert.calledOnceWithMatch(remoteConfig.enable, config) + sinon.assert.calledOnceWithExactly(RemoteConfig, config) }) it('should not initialize twice', () => { @@ -286,7 +277,7 @@ describe('TracerProxy', () => { proxy.init() sinon.assert.calledOnce(DatadogTracer) - sinon.assert.calledOnce(remoteConfig.enable) + sinon.assert.calledOnce(RemoteConfig) }) it('should not enable remote config when disabled', () => { @@ -295,7 +286,7 @@ describe('TracerProxy', () => { proxy.init() sinon.assert.calledOnce(DatadogTracer) - sinon.assert.notCalled(remoteConfig.enable) + sinon.assert.notCalled(RemoteConfig) }) it('should not initialize when disabled', () => { @@ -404,7 +395,7 @@ describe('TracerProxy', () => { './tracer': DatadogTracer, './appsec': appsec, './appsec/iast': iast, - './remote_config': remoteConfig, + './remote_config': RemoteConfig, './appsec/sdk': AppsecSdk }) @@ -435,7 +426,7 @@ describe('TracerProxy', () => { './config': Config, './appsec': appsec, './appsec/iast': iast, - './remote_config': remoteConfig, + './remote_config': RemoteConfig, './appsec/sdk': AppsecSdk }) @@ -576,7 +567,7 @@ describe('TracerProxy', () => { './profiler': null, // this will cause the import failure error './appsec': appsec, './telemetry': telemetry, - './remote_config': remoteConfig + './remote_config': RemoteConfig }) const profilerImportFailureProxy = new ProfilerImportFailureProxy() @@ -604,7 +595,7 @@ describe('TracerProxy', () => { './config': Config, './appsec': appsec, './appsec/iast': iast, - './remote_config': remoteConfig, + './remote_config': RemoteConfig, './appsec/sdk': AppsecSdk, './standalone': standalone, './telemetry': telemetry diff --git a/packages/dd-trace/test/remote_config/index.spec.js b/packages/dd-trace/test/remote_config/index.spec.js index 87e1328106a..fed31c3ade9 100644 --- a/packages/dd-trace/test/remote_config/index.spec.js +++ b/packages/dd-trace/test/remote_config/index.spec.js @@ -8,73 +8,785 @@ const proxyquire = require('proxyquire') require('../setup/core') -const getConfig = require('../../src/config') -const RemoteConfigCapabilities = require('../../src/remote_config/capabilities') +const Capabilities = require('../../src/remote_config/capabilities') +const { UNACKNOWLEDGED, ACKNOWLEDGED, ERROR } = require('../../src/remote_config/apply_states') -let config -let rc -let RemoteConfigManager -let remoteConfig +const noop = () => {} + +describe('RemoteConfig', () => { + let uuid + let scheduler + let Scheduler + let request + let log + let extraServices + let RemoteConfig + let config + let rc + let tagger -describe('Remote Config index', () => { beforeEach(() => { - config = getConfig({ - appsec: { - enabled: undefined, - eventTracking: { - mode: 'identification' - } + uuid = sinon.stub().returns('1234-5678') + + scheduler = { + start: sinon.spy(), + stop: sinon.spy() + } + + Scheduler = sinon.stub().returns(scheduler) + + request = sinon.stub() + + log = { + error: sinon.spy() + } + + tagger = { + add: sinon.stub() + } + + extraServices = [] + + RemoteConfig = proxyquire('../../src/remote_config', { + '../../../../vendor/dist/crypto-randomuuid': uuid, + './scheduler': Scheduler, + '../../../../package.json': { version: '3.0.0' }, + '../exporters/common/request': request, + '../log': log, + '../tagger': tagger, + '../service-naming/extra-services': { + getExtraServices: () => extraServices } }) - rc = { - updateCapabilities: sinon.spy(), - setBatchHandler: sinon.spy(), - removeBatchHandler: sinon.spy(), - setProductHandler: sinon.spy(), - removeProductHandler: sinon.spy(), - subscribeProducts: sinon.spy(), - unsubscribeProducts: sinon.spy() + config = { + url: 'http://127.0.0.1:1337', + hostname: '127.0.0.1', + port: '1337', + tags: { + 'runtime-id': 'runtimeId' + }, + service: 'serviceName', + env: 'serviceEnv', + version: 'appVersion', + remoteConfig: { + pollInterval: 5 + } + } + + rc = new RemoteConfig(config) + }) + + it('should instantiate RemoteConfig', () => { + sinon.stub(rc, 'poll') + + sinon.assert.calledOnce(Scheduler) + const [firstArg, secondArg] = Scheduler.firstCall.args + assert.strictEqual(typeof firstArg, 'function') + assert.strictEqual(secondArg, 5e3) + + firstArg(noop) + sinon.assert.calledOnceWithExactly(rc.poll, noop) + + assert.strictEqual(rc.scheduler, scheduler) + + assert.deepStrictEqual(rc.url, config.url) + + sinon.assert.calledOnceWithExactly(tagger.add, config.tags, { + '_dd.rc.client_id': '1234-5678' + }) + + assert.deepStrictEqual(rc.state, { + client: { + state: { + root_version: 1, + targets_version: 0, + config_states: [], + has_error: false, + error: '', + backend_client_state: '' + }, + id: '1234-5678', + products: [], + is_tracer: true, + client_tracer: { + runtime_id: config.tags['runtime-id'], + language: 'node', + tracer_version: '3.0.0', + service: config.service, + env: config.env, + app_version: config.version, + extra_services: [], + tags: ['runtime-id:runtimeId'] + }, + capabilities: 'AA==' + }, + cached_target_files: [] + }) + + assert.ok(rc.appliedConfigs instanceof Map) + }) + + it('should add git metadata to tags if present', () => { + const configWithGit = { + ...config, + repositoryUrl: 'https://github.com/DataDog/dd-trace-js', + commitSHA: '1234567890' } + const rc = new RemoteConfig(configWithGit) + assert.deepStrictEqual(rc.state.client.client_tracer.tags, [ + 'runtime-id:runtimeId', + 'git.repository_url:https://github.com/DataDog/dd-trace-js', + 'git.commit.sha:1234567890' + ]) + }) + + describe('updateCapabilities', () => { + it('should set multiple capabilities to true', () => { + rc.updateCapabilities(Capabilities.ASM_ACTIVATION, true) + assert.strictEqual(rc.state.client.capabilities, 'Ag==') + + rc.updateCapabilities(Capabilities.ASM_IP_BLOCKING, true) + assert.strictEqual(rc.state.client.capabilities, 'Bg==') + + rc.updateCapabilities(Capabilities.ASM_DD_RULES, true) + assert.strictEqual(rc.state.client.capabilities, 'Dg==') + + rc.updateCapabilities(Capabilities.ASM_USER_BLOCKING, true) + assert.strictEqual(rc.state.client.capabilities, 'jg==') + }) + + it('should set multiple capabilities to false', () => { + rc.state.client.capabilities = 'jg==' + + rc.updateCapabilities(Capabilities.ASM_USER_BLOCKING, false) + assert.strictEqual(rc.state.client.capabilities, 'Dg==') + + rc.updateCapabilities(Capabilities.ASM_ACTIVATION, false) + assert.strictEqual(rc.state.client.capabilities, 'DA==') + + rc.updateCapabilities(Capabilities.ASM_IP_BLOCKING, false) + assert.strictEqual(rc.state.client.capabilities, 'CA==') + + rc.updateCapabilities(Capabilities.ASM_DD_RULES, false) + assert.strictEqual(rc.state.client.capabilities, 'AA==') + }) + + it('should set an arbitrary amount of capabilities', () => { + rc.updateCapabilities(1n << 1n, true) + rc.updateCapabilities(1n << 200n, true) + assert.strictEqual(rc.state.client.capabilities, 'AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAI=') + + rc.updateCapabilities(1n << 200n, false) + assert.strictEqual(rc.state.client.capabilities, 'Ag==') + }) + }) + + describe('setProductHandler/removeProductHandler', () => { + it('should update the product list and autostart or autostop', () => { + sinon.assert.notCalled(rc.scheduler.start) + + rc.setProductHandler('ASM_FEATURES', noop) + + assert.deepStrictEqual(rc.state.client.products, ['ASM_FEATURES']) + sinon.assert.called(rc.scheduler.start) + + rc.setProductHandler('ASM_DATA', noop) + rc.setProductHandler('ASM_DD', noop) + + assert.deepStrictEqual(rc.state.client.products, ['ASM_FEATURES', 'ASM_DATA', 'ASM_DD']) + + rc.removeProductHandler('ASM_FEATURES') + + assert.deepStrictEqual(rc.state.client.products, ['ASM_DATA', 'ASM_DD']) + + rc.removeProductHandler('ASM_DATA') + + sinon.assert.notCalled(rc.scheduler.stop) + + rc.removeProductHandler('ASM_DD') + + sinon.assert.called(rc.scheduler.stop) + assert.strictEqual(rc.state.client.products.length, 0) + }) + }) + + describe('poll', () => { + let expectedPayload + + beforeEach(() => { + sinon.stub(rc, 'parseConfig') + expectedPayload = { + url: rc.url, + method: 'POST', + path: '/v0.7/config', + headers: { 'Content-Type': 'application/json; charset=utf-8' } + } + }) + + it('should request and do nothing when received status 404', (cb) => { + request.yieldsRight(new Error('Response received 404'), '{"a":"b"}', 404) + + const payload = JSON.stringify(rc.state) + + rc.poll(() => { + sinon.assert.calledOnceWithMatch(request, payload, expectedPayload) + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(rc.parseConfig) + cb() + }) + }) + + it('should request when received error', (cb) => { + const err = new Error('Response received 500') + request.yieldsRight(err, '{"a":"b"}', 500) + + const payload = JSON.stringify(rc.state) + + rc.poll(() => { + sinon.assert.calledOnceWithMatch(request, payload, expectedPayload) + sinon.assert.notCalled(rc.parseConfig) + cb() + }) + }) + + it('should request and call parseConfig when payload is not empty', (cb) => { + request.yieldsRight(null, '{"a":"b"}', 200) + + const payload = JSON.stringify(rc.state) + + rc.poll(() => { + sinon.assert.calledOnceWithMatch(request, payload, expectedPayload) + sinon.assert.notCalled(log.error) + sinon.assert.calledOnceWithExactly(rc.parseConfig, { a: 'b' }) + cb() + }) + }) + + it('should catch exceptions, update the error state, and clear the error state at next request', (cb) => { + const error = new Error('Unable to parse config') + request + .onFirstCall().yieldsRight(null, '{"a":"b"}', 200) + .onSecondCall().yieldsRight(null, null, 200) + rc.parseConfig.onFirstCall().throws(error) + + const payload = JSON.stringify(rc.state) + + rc.poll(() => { + sinon.assert.calledOnceWithMatch(request, payload, expectedPayload) + sinon.assert.calledOnceWithExactly(rc.parseConfig, { a: 'b' }) + sinon.assert.calledOnceWithExactly(log.error, '[RC] Could not parse remote config response', error) + assert.strictEqual(rc.state.client.state.has_error, true) + assert.strictEqual(rc.state.client.state.error, 'Error: Unable to parse config') + + const payload2 = JSON.stringify(rc.state) + + rc.poll(() => { + sinon.assert.calledTwice(request) + sinon.assert.calledWith(request.secondCall, payload2, expectedPayload) + sinon.assert.calledOnce(rc.parseConfig) + sinon.assert.calledOnce(log.error) + assert.strictEqual(rc.state.client.state.has_error, false) + assert.strictEqual(rc.state.client.state.error.length, 0) + cb() + }) + }) + }) + + it('should request and do nothing when payload is empty JSON object', (cb) => { + request.yieldsRight(null, '{}', 200) + + const payload = JSON.stringify(rc.state) - RemoteConfigManager = sinon.stub().returns(rc) + rc.poll(() => { + sinon.assert.calledOnceWithMatch(request, payload, expectedPayload) + sinon.assert.notCalled(log.error) + sinon.assert.notCalled(rc.parseConfig) + cb() + }) + }) + + it('should include extra_services in the payload', (cb) => { + request.yieldsRight(null, '{}', 200) + + extraServices = ['test-service'] - remoteConfig = proxyquire('../../src/remote_config', { - './manager': RemoteConfigManager + // getPayload includes the new extraServices that might be available + const payload = rc.getPayload() + assert.deepStrictEqual(JSON.parse(payload).client.client_tracer.extra_services, extraServices) + + rc.poll(() => { + sinon.assert.calledOnceWithMatch(request, payload, expectedPayload) + cb() + }) }) }) - describe('enable', () => { - it('should initialize remote config manager', () => { - const result = remoteConfig.enable(config) + describe('parseConfig', () => { + let payload + const parsePayload = () => rc.parseConfig(payload) + let previousState + + beforeEach(() => { + sinon.stub(rc, 'dispatch').callsFake((list, action) => { + const items = /** @type {Array<{path: string, apply_state: number}>} */ (list) + for (const item of items) { + item.apply_state = ACKNOWLEDGED + + if (action === 'unapply') rc.appliedConfigs.delete(item.path) + else rc.appliedConfigs.set(item.path, item) + } + }) + + previousState = JSON.parse(JSON.stringify(rc.state)) + }) + + it('should do nothing if passed an empty payload', () => { + payload = {} + + assert.doesNotThrow(parsePayload) + sinon.assert.notCalled(rc.dispatch) + assert.deepStrictEqual(rc.state, previousState) + }) + + it('should throw when target is not found', () => { + payload = { + client_configs: ['datadog/42/PRODUCT/confId/config'], + targets: toBase64({ + signed: { + targets: { + 'datadog/42/OTHERPRODUCT/confId/config': {} + } + } + }) + } + + assert.throws(parsePayload, { message: 'Unable to find target for path datadog/42/PRODUCT/confId/config' }) + sinon.assert.notCalled(rc.dispatch) + assert.deepStrictEqual(rc.state, previousState) + }) + + it('should throw when target file is not found', () => { + payload = { + client_configs: ['datadog/42/PRODUCT/confId/config'], + targets: toBase64({ + signed: { + targets: { + 'datadog/42/PRODUCT/confId/config': { + hashes: { + sha256: 'haaaxx' + } + } + } + } + }) + } + + assert.throws(parsePayload, { message: 'Unable to find file for path datadog/42/PRODUCT/confId/config' }) + sinon.assert.notCalled(rc.dispatch) + assert.deepStrictEqual(rc.state, previousState) + }) + + it('should throw when config path cannot be parsed', () => { + payload = { + client_configs: ['datadog/42/confId/config'], + targets: toBase64({ + signed: { + targets: { + 'datadog/42/confId/config': { + hashes: { + sha256: 'haaaxx' + } + } + } + } + }), + target_files: [{ + path: 'datadog/42/confId/config', + raw: toBase64({}) + }] + } + + assert.throws(parsePayload, { message: 'Unable to parse path datadog/42/confId/config' }) + sinon.assert.notCalled(rc.dispatch) + assert.deepStrictEqual(rc.state, previousState) + }) + + it('should parse the config, call dispatch, and update the state', () => { + rc.appliedConfigs.set('datadog/42/UNAPPLY/confId/config', { + path: 'datadog/42/UNAPPLY/confId/config', + product: 'UNAPPLY', + id: 'confId', + version: 69, + apply_state: ACKNOWLEDGED, + apply_error: '', + length: 147, + hashes: { sha256: 'anotherHash' }, + file: { asm: { enabled: true } } + }) + rc.appliedConfigs.set('datadog/42/IGNORE/confId/config', { + path: 'datadog/42/IGNORE/confId/config', + product: 'IGNORE', + id: 'confId', + version: 43, + apply_state: ACKNOWLEDGED, + apply_error: '', + length: 420, + hashes: { sha256: 'sameHash' }, + file: {} + }) + rc.appliedConfigs.set('datadog/42/MODIFY/confId/config', { + path: 'datadog/42/MODIFY/confId/config', + product: 'MODIFY', + id: 'confId', + version: 11, + apply_state: ACKNOWLEDGED, + apply_error: '', + length: 147, + hashes: { sha256: 'oldHash' }, + file: { config: 'oldConf' } + }) + + payload = { + client_configs: [ + 'datadog/42/IGNORE/confId/config', + 'datadog/42/MODIFY/confId/config', + 'datadog/42/APPLY/confId/config' + ], + targets: toBase64({ + signed: { + custom: { + opaque_backend_state: 'opaquestateinbase64' + }, + targets: { + 'datadog/42/IGNORE/confId/config': { + custom: { + v: 43 + }, + hashes: { + sha256: 'sameHash' + }, + length: 420 + }, + 'datadog/42/MODIFY/confId/config': { + custom: { + v: 12 + }, + hashes: { + sha256: 'newHash' + }, + length: 147 + }, + 'datadog/42/APPLY/confId/config': { + custom: { + v: 1 + }, + hashes: { + sha256: 'haaaxx' + }, + length: 0 + } + }, + version: 12345 + } + }), + target_files: [ + { + path: 'datadog/42/MODIFY/confId/config', + raw: toBase64({ config: 'newConf' }) + }, + { + path: 'datadog/42/APPLY/confId/config', + raw: '' + } + ] + } + + // Calling parsePayload should not throw. + parsePayload() + + assert.strictEqual(rc.state.client.state.targets_version, 12345) + assert.strictEqual(rc.state.client.state.backend_client_state, 'opaquestateinbase64') + + sinon.assert.calledThrice(rc.dispatch) + sinon.assert.calledWithMatch(rc.dispatch.firstCall, [{ + path: 'datadog/42/UNAPPLY/confId/config', + product: 'UNAPPLY', + id: 'confId', + version: 69, + apply_state: ACKNOWLEDGED, + apply_error: '', + length: 147, + hashes: { sha256: 'anotherHash' }, + file: { asm: { enabled: true } } + }], 'unapply', sinon.match.instanceOf(Set)) + sinon.assert.calledWithMatch(rc.dispatch.secondCall, [{ + path: 'datadog/42/APPLY/confId/config', + product: 'APPLY', + id: 'confId', + version: 1, + apply_state: ACKNOWLEDGED, + apply_error: '', + length: 0, + hashes: { sha256: 'haaaxx' }, + file: null + }], 'apply', sinon.match.instanceOf(Set)) + sinon.assert.calledWithMatch(rc.dispatch.thirdCall, [{ + path: 'datadog/42/MODIFY/confId/config', + product: 'MODIFY', + id: 'confId', + version: 12, + apply_state: ACKNOWLEDGED, + apply_error: '', + length: 147, + hashes: { sha256: 'newHash' }, + file: { config: 'newConf' } + }], 'modify', sinon.match.instanceOf(Set)) + + assert.deepStrictEqual(rc.state.client.state.config_states, [ + { + id: 'confId', + version: 43, + product: 'IGNORE', + apply_state: ACKNOWLEDGED, + apply_error: '' + }, + { + id: 'confId', + version: 12, + product: 'MODIFY', + apply_state: ACKNOWLEDGED, + apply_error: '' + }, + { + id: 'confId', + version: 1, + product: 'APPLY', + apply_state: ACKNOWLEDGED, + apply_error: '' + } + ]) + assert.deepStrictEqual(rc.state.cached_target_files, [ + { + path: 'datadog/42/IGNORE/confId/config', + length: 420, + hashes: [{ algorithm: 'sha256', hash: 'sameHash' }] + }, + { + path: 'datadog/42/MODIFY/confId/config', + length: 147, + hashes: [{ algorithm: 'sha256', hash: 'newHash' }] + }, + { + path: 'datadog/42/APPLY/confId/config', + length: 0, + hashes: [{ algorithm: 'sha256', hash: 'haaaxx' }] + } + ]) + }) + + it('should allow batch handlers to ack + handle items and skip per-product handlers (including unapply)', () => { + // Arrange: two configs already applied, one will be unapplied. + const unapplyPath = 'datadog/42/ASM/confId/config' + rc.appliedConfigs.set(unapplyPath, { + path: unapplyPath, + product: 'ASM', + id: 'confId', + version: 1, + apply_state: ACKNOWLEDGED, + apply_error: '', + length: 1, + hashes: { sha256: 'oldHash' }, + file: { a: 1 } + }) + + const handler = sinon.spy() + rc.setProductHandler('ASM', handler) - sinon.assert.calledOnceWithExactly(RemoteConfigManager, config) - assert.strictEqual(result, rc) + // Batch hook will handle the unapply and report success. + rc.setBatchHandler(['ASM'], (tx) => { + for (const item of tx.toUnapply) { + tx.ack(item.path) + tx.markHandled(item.path) + } + }) + + payload = { + client_configs: [], + targets: toBase64({ + signed: { + custom: { opaque_backend_state: 'state' }, + targets: {}, + version: 2 + } + }), + target_files: [] + } + + // Act + parsePayload() + + // Assert: handler should not be invoked, but state should be updated (unapplied). + sinon.assert.notCalled(handler) + assert.strictEqual(rc.appliedConfigs.has(unapplyPath), false) }) - it('should enable APM tracing capabilities', () => { - remoteConfig.enable(config) + it('should call per-product handlers when batch handlers do not markHandled (including unapply)', () => { + const unapplyPath = 'datadog/42/ASM/confId/config' + const conf = { + path: unapplyPath, + product: 'ASM', + id: 'confId', + version: 1, + apply_state: ACKNOWLEDGED, + apply_error: '', + length: 1, + hashes: { sha256: 'oldHash' }, + file: { a: 1 } + } + rc.appliedConfigs.set(unapplyPath, conf) + + const handler = sinon.spy() + rc.setProductHandler('ASM', handler) + + // Batch hook does nothing (does not markHandled), so per-product handler should run. + rc.setBatchHandler(['ASM'], () => {}) + + // This test needs the real dispatch path in order to verify handler invocation. + rc.dispatch.restore() - sinon.assert.calledWithExactly(rc.updateCapabilities, RemoteConfigCapabilities.APM_TRACING_CUSTOM_TAGS, true) - sinon.assert.calledWithExactly(rc.updateCapabilities, RemoteConfigCapabilities.APM_TRACING_HTTP_HEADER_TAGS, true) - sinon.assert.calledWithExactly(rc.updateCapabilities, RemoteConfigCapabilities.APM_TRACING_LOGS_INJECTION, true) - sinon.assert.calledWithExactly(rc.updateCapabilities, RemoteConfigCapabilities.APM_TRACING_SAMPLE_RATE, true) - sinon.assert.calledWithExactly(rc.updateCapabilities, RemoteConfigCapabilities.APM_TRACING_ENABLED, true) - sinon.assert.calledWithExactly(rc.updateCapabilities, RemoteConfigCapabilities.APM_TRACING_SAMPLE_RULES, true) + payload = { + client_configs: [], + targets: toBase64({ + signed: { + custom: { opaque_backend_state: 'state' }, + targets: {}, + version: 2 + } + }), + target_files: [] + } + + parsePayload() + + sinon.assert.calledOnceWithExactly(handler, 'unapply', conf.file, conf.id) + assert.strictEqual(rc.appliedConfigs.has(unapplyPath), false) }) + }) + + describe('dispatch', () => { + it('should call registered handler for each config, catch errors, and update the state', (done) => { + const syncGoodNonAckHandler = sinon.spy() + const syncBadNonAckHandler = sinon.spy((action, conf, id) => { throw new Error('sync fn') }) + const asyncGoodHandler = sinon.spy(async (action, conf, id) => {}) + const asyncBadHandler = sinon.spy(async (action, conf, id) => { throw new Error('async fn') }) + const syncGoodAckHandler = sinon.spy((action, conf, id, ack) => { ack() }) + const syncBadAckHandler = sinon.spy((action, conf, id, ack) => { ack(new Error('sync ack fn')) }) + const asyncGoodAckHandler = sinon.spy((action, conf, id, ack) => { setImmediate(ack) }) + const asyncBadAckHandler = sinon.spy((action, conf, id, ack) => { + setImmediate(ack.bind(null, new Error('async ack fn'))) + }) + const unackHandler = sinon.spy((action, conf, id, ack) => {}) + + rc.setProductHandler('PRODUCT_0', syncGoodNonAckHandler) + rc.setProductHandler('PRODUCT_1', syncBadNonAckHandler) + rc.setProductHandler('PRODUCT_2', asyncGoodHandler) + rc.setProductHandler('PRODUCT_3', asyncBadHandler) + rc.setProductHandler('PRODUCT_4', syncGoodAckHandler) + rc.setProductHandler('PRODUCT_5', syncBadAckHandler) + rc.setProductHandler('PRODUCT_6', asyncGoodAckHandler) + rc.setProductHandler('PRODUCT_7', asyncBadAckHandler) + rc.setProductHandler('PRODUCT_8', unackHandler) + + const list = [] + for (let i = 0; i < 9; i++) { + list[i] = { + id: `id_${i}`, + path: `datadog/42/PRODUCT_${i}/confId/config`, + product: `PRODUCT_${i}`, + apply_state: UNACKNOWLEDGED, + apply_error: '', + file: { index: i } + } + } - it('should enable FFE_FLAG_CONFIGURATION_RULES capability', () => { - remoteConfig.enable(config) + rc.dispatch(list, 'apply', new Set()) - sinon.assert.calledWithExactly(rc.updateCapabilities, RemoteConfigCapabilities.FFE_FLAG_CONFIGURATION_RULES, true) + sinon.assert.calledOnceWithExactly(syncGoodNonAckHandler, 'apply', list[0].file, list[0].id) + sinon.assert.calledOnceWithExactly(syncBadNonAckHandler, 'apply', list[1].file, list[1].id) + sinon.assert.calledOnceWithExactly(asyncGoodHandler, 'apply', list[2].file, list[2].id) + sinon.assert.calledOnceWithExactly(asyncBadHandler, 'apply', list[3].file, list[3].id) + assertAsyncHandlerCallArguments(syncGoodAckHandler, 'apply', list[4].file, list[4].id) + assertAsyncHandlerCallArguments(syncBadAckHandler, 'apply', list[5].file, list[5].id) + assertAsyncHandlerCallArguments(asyncGoodAckHandler, 'apply', list[6].file, list[6].id) + assertAsyncHandlerCallArguments(asyncBadAckHandler, 'apply', list[7].file, list[7].id) + assertAsyncHandlerCallArguments(unackHandler, 'apply', list[8].file, list[8].id) + + assert.strictEqual(list[0].apply_state, ACKNOWLEDGED) + assert.strictEqual(list[0].apply_error, '') + assert.strictEqual(list[1].apply_state, ERROR) + assert.strictEqual(list[1].apply_error, 'Error: sync fn') + assert.strictEqual(list[2].apply_state, UNACKNOWLEDGED) + assert.strictEqual(list[2].apply_error, '') + assert.strictEqual(list[3].apply_state, UNACKNOWLEDGED) + assert.strictEqual(list[3].apply_error, '') + assert.strictEqual(list[4].apply_state, ACKNOWLEDGED) + assert.strictEqual(list[4].apply_error, '') + assert.strictEqual(list[5].apply_state, ERROR) + assert.strictEqual(list[5].apply_error, 'Error: sync ack fn') + assert.strictEqual(list[6].apply_state, UNACKNOWLEDGED) + assert.strictEqual(list[6].apply_error, '') + assert.strictEqual(list[7].apply_state, UNACKNOWLEDGED) + assert.strictEqual(list[7].apply_error, '') + assert.strictEqual(list[8].apply_state, UNACKNOWLEDGED) + assert.strictEqual(list[8].apply_error, '') + + for (let i = 0; i < list.length; i++) { + assert.strictEqual(rc.appliedConfigs.get(`datadog/42/PRODUCT_${i}/confId/config`), list[i]) + } + + setImmediate(() => { + assert.strictEqual(list[2].apply_state, ACKNOWLEDGED) + assert.strictEqual(list[2].apply_error, '') + assert.strictEqual(list[3].apply_state, ERROR) + assert.strictEqual(list[3].apply_error, 'Error: async fn') + assert.strictEqual(list[6].apply_state, ACKNOWLEDGED) + assert.strictEqual(list[6].apply_error, '') + assert.strictEqual(list[7].apply_state, ERROR) + assert.strictEqual(list[7].apply_error, 'Error: async ack fn') + assert.strictEqual(list[8].apply_state, UNACKNOWLEDGED) + assert.strictEqual(list[8].apply_error, '') + done() + }) + + function assertAsyncHandlerCallArguments (handler, ...expectedArgs) { + sinon.assert.calledOnceWithMatch(handler, ...expectedArgs) + assert.strictEqual(handler.args[0].length, expectedArgs.length + 1) + assert.strictEqual(typeof handler.args[0][handler.args[0].length - 1], 'function') + } }) - it('should not configure appsec handlers', () => { - remoteConfig.enable(config) + it('should delete config from state when action is unapply', () => { + const handler = sinon.spy() + rc.setProductHandler('ASM_FEATURES', handler) + + rc.appliedConfigs.set('datadog/42/ASM_FEATURES/confId/config', { + id: 'asm_data', + path: 'datadog/42/ASM_FEATURES/confId/config', + product: 'ASM_FEATURES', + apply_state: ACKNOWLEDGED, + apply_error: '', + file: { asm: { enabled: true } } + }) - sinon.assert.neverCalledWith(rc.updateCapabilities, RemoteConfigCapabilities.ASM_ACTIVATION) - sinon.assert.neverCalledWith(rc.updateCapabilities, RemoteConfigCapabilities.ASM_AUTO_USER_INSTRUM_MODE) - sinon.assert.notCalled(rc.setProductHandler) + rc.dispatch([rc.appliedConfigs.get('datadog/42/ASM_FEATURES/confId/config')], 'unapply', new Set()) + + sinon.assert.calledOnceWithExactly(handler, 'unapply', { asm: { enabled: true } }, 'asm_data') + assert.strictEqual(rc.appliedConfigs.size, 0) }) }) }) + +function toBase64 (data) { + return Buffer.from(JSON.stringify(data), 'utf8').toString('base64') +} diff --git a/packages/dd-trace/test/remote_config/manager.spec.js b/packages/dd-trace/test/remote_config/manager.spec.js deleted file mode 100644 index 9a045691b2c..00000000000 --- a/packages/dd-trace/test/remote_config/manager.spec.js +++ /dev/null @@ -1,792 +0,0 @@ -'use strict' - -const assert = require('node:assert/strict') - -const { describe, it, beforeEach } = require('tap').mocha -const sinon = require('sinon') -const proxyquire = require('proxyquire') - -require('../setup/core') - -const Capabilities = require('../../src/remote_config/capabilities') -const { UNACKNOWLEDGED, ACKNOWLEDGED, ERROR } = require('../../src/remote_config/apply_states') - -const noop = () => {} - -describe('RemoteConfigManager', () => { - let uuid - let scheduler - let Scheduler - let request - let log - let extraServices - let RemoteConfigManager - let config - let rc - let tagger - - beforeEach(() => { - uuid = sinon.stub().returns('1234-5678') - - scheduler = { - start: sinon.spy(), - stop: sinon.spy() - } - - Scheduler = sinon.stub().returns(scheduler) - - request = sinon.stub() - - log = { - error: sinon.spy() - } - - tagger = { - add: sinon.stub() - } - - extraServices = [] - - RemoteConfigManager = proxyquire('../../src/remote_config/manager', { - '../../../../vendor/dist/crypto-randomuuid': uuid, - './scheduler': Scheduler, - '../../../../package.json': { version: '3.0.0' }, - '../exporters/common/request': request, - '../log': log, - '../tagger': tagger, - '../service-naming/extra-services': { - getExtraServices: () => extraServices - } - }) - - config = { - url: 'http://127.0.0.1:1337', - hostname: '127.0.0.1', - port: '1337', - tags: { - 'runtime-id': 'runtimeId' - }, - service: 'serviceName', - env: 'serviceEnv', - version: 'appVersion', - remoteConfig: { - pollInterval: 5 - } - } - - rc = new RemoteConfigManager(config) - }) - - it('should instantiate RemoteConfigManager', () => { - sinon.stub(rc, 'poll') - - sinon.assert.calledOnce(Scheduler) - const [firstArg, secondArg] = Scheduler.firstCall.args - assert.strictEqual(typeof firstArg, 'function') - assert.strictEqual(secondArg, 5e3) - - firstArg(noop) - sinon.assert.calledOnceWithExactly(rc.poll, noop) - - assert.strictEqual(rc.scheduler, scheduler) - - assert.deepStrictEqual(rc.url, config.url) - - sinon.assert.calledOnceWithExactly(tagger.add, config.tags, { - '_dd.rc.client_id': '1234-5678' - }) - - assert.deepStrictEqual(rc.state, { - client: { - state: { - root_version: 1, - targets_version: 0, - config_states: [], - has_error: false, - error: '', - backend_client_state: '' - }, - id: '1234-5678', - products: [], - is_tracer: true, - client_tracer: { - runtime_id: config.tags['runtime-id'], - language: 'node', - tracer_version: '3.0.0', - service: config.service, - env: config.env, - app_version: config.version, - extra_services: [], - tags: ['runtime-id:runtimeId'] - }, - capabilities: 'AA==' - }, - cached_target_files: [] - }) - - assert.ok(rc.appliedConfigs instanceof Map) - }) - - it('should add git metadata to tags if present', () => { - const configWithGit = { - ...config, - repositoryUrl: 'https://github.com/DataDog/dd-trace-js', - commitSHA: '1234567890' - } - const rc = new RemoteConfigManager(configWithGit) - assert.deepStrictEqual(rc.state.client.client_tracer.tags, [ - 'runtime-id:runtimeId', - 'git.repository_url:https://github.com/DataDog/dd-trace-js', - 'git.commit.sha:1234567890' - ]) - }) - - describe('updateCapabilities', () => { - it('should set multiple capabilities to true', () => { - rc.updateCapabilities(Capabilities.ASM_ACTIVATION, true) - assert.strictEqual(rc.state.client.capabilities, 'Ag==') - - rc.updateCapabilities(Capabilities.ASM_IP_BLOCKING, true) - assert.strictEqual(rc.state.client.capabilities, 'Bg==') - - rc.updateCapabilities(Capabilities.ASM_DD_RULES, true) - assert.strictEqual(rc.state.client.capabilities, 'Dg==') - - rc.updateCapabilities(Capabilities.ASM_USER_BLOCKING, true) - assert.strictEqual(rc.state.client.capabilities, 'jg==') - }) - - it('should set multiple capabilities to false', () => { - rc.state.client.capabilities = 'jg==' - - rc.updateCapabilities(Capabilities.ASM_USER_BLOCKING, false) - assert.strictEqual(rc.state.client.capabilities, 'Dg==') - - rc.updateCapabilities(Capabilities.ASM_ACTIVATION, false) - assert.strictEqual(rc.state.client.capabilities, 'DA==') - - rc.updateCapabilities(Capabilities.ASM_IP_BLOCKING, false) - assert.strictEqual(rc.state.client.capabilities, 'CA==') - - rc.updateCapabilities(Capabilities.ASM_DD_RULES, false) - assert.strictEqual(rc.state.client.capabilities, 'AA==') - }) - - it('should set an arbitrary amount of capabilities', () => { - rc.updateCapabilities(1n << 1n, true) - rc.updateCapabilities(1n << 200n, true) - assert.strictEqual(rc.state.client.capabilities, 'AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAI=') - - rc.updateCapabilities(1n << 200n, false) - assert.strictEqual(rc.state.client.capabilities, 'Ag==') - }) - }) - - describe('setProductHandler/removeProductHandler', () => { - it('should update the product list and autostart or autostop', () => { - sinon.assert.notCalled(rc.scheduler.start) - - rc.setProductHandler('ASM_FEATURES', noop) - - assert.deepStrictEqual(rc.state.client.products, ['ASM_FEATURES']) - sinon.assert.called(rc.scheduler.start) - - rc.setProductHandler('ASM_DATA', noop) - rc.setProductHandler('ASM_DD', noop) - - assert.deepStrictEqual(rc.state.client.products, ['ASM_FEATURES', 'ASM_DATA', 'ASM_DD']) - - rc.removeProductHandler('ASM_FEATURES') - - assert.deepStrictEqual(rc.state.client.products, ['ASM_DATA', 'ASM_DD']) - - rc.removeProductHandler('ASM_DATA') - - sinon.assert.notCalled(rc.scheduler.stop) - - rc.removeProductHandler('ASM_DD') - - sinon.assert.called(rc.scheduler.stop) - assert.strictEqual(rc.state.client.products.length, 0) - }) - }) - - describe('poll', () => { - let expectedPayload - - beforeEach(() => { - sinon.stub(rc, 'parseConfig') - expectedPayload = { - url: rc.url, - method: 'POST', - path: '/v0.7/config', - headers: { 'Content-Type': 'application/json; charset=utf-8' } - } - }) - - it('should request and do nothing when received status 404', (cb) => { - request.yieldsRight(new Error('Response received 404'), '{"a":"b"}', 404) - - const payload = JSON.stringify(rc.state) - - rc.poll(() => { - sinon.assert.calledOnceWithMatch(request, payload, expectedPayload) - sinon.assert.notCalled(log.error) - sinon.assert.notCalled(rc.parseConfig) - cb() - }) - }) - - it('should request when received error', (cb) => { - const err = new Error('Response received 500') - request.yieldsRight(err, '{"a":"b"}', 500) - - const payload = JSON.stringify(rc.state) - - rc.poll(() => { - sinon.assert.calledOnceWithMatch(request, payload, expectedPayload) - sinon.assert.notCalled(rc.parseConfig) - cb() - }) - }) - - it('should request and call parseConfig when payload is not empty', (cb) => { - request.yieldsRight(null, '{"a":"b"}', 200) - - const payload = JSON.stringify(rc.state) - - rc.poll(() => { - sinon.assert.calledOnceWithMatch(request, payload, expectedPayload) - sinon.assert.notCalled(log.error) - sinon.assert.calledOnceWithExactly(rc.parseConfig, { a: 'b' }) - cb() - }) - }) - - it('should catch exceptions, update the error state, and clear the error state at next request', (cb) => { - const error = new Error('Unable to parse config') - request - .onFirstCall().yieldsRight(null, '{"a":"b"}', 200) - .onSecondCall().yieldsRight(null, null, 200) - rc.parseConfig.onFirstCall().throws(error) - - const payload = JSON.stringify(rc.state) - - rc.poll(() => { - sinon.assert.calledOnceWithMatch(request, payload, expectedPayload) - sinon.assert.calledOnceWithExactly(rc.parseConfig, { a: 'b' }) - sinon.assert.calledOnceWithExactly(log.error, '[RC] Could not parse remote config response', error) - assert.strictEqual(rc.state.client.state.has_error, true) - assert.strictEqual(rc.state.client.state.error, 'Error: Unable to parse config') - - const payload2 = JSON.stringify(rc.state) - - rc.poll(() => { - sinon.assert.calledTwice(request) - sinon.assert.calledWith(request.secondCall, payload2, expectedPayload) - sinon.assert.calledOnce(rc.parseConfig) - sinon.assert.calledOnce(log.error) - assert.strictEqual(rc.state.client.state.has_error, false) - assert.strictEqual(rc.state.client.state.error.length, 0) - cb() - }) - }) - }) - - it('should request and do nothing when payload is empty JSON object', (cb) => { - request.yieldsRight(null, '{}', 200) - - const payload = JSON.stringify(rc.state) - - rc.poll(() => { - sinon.assert.calledOnceWithMatch(request, payload, expectedPayload) - sinon.assert.notCalled(log.error) - sinon.assert.notCalled(rc.parseConfig) - cb() - }) - }) - - it('should include extra_services in the payload', (cb) => { - request.yieldsRight(null, '{}', 200) - - extraServices = ['test-service'] - - // getPayload includes the new extraServices that might be available - const payload = rc.getPayload() - assert.deepStrictEqual(JSON.parse(payload).client.client_tracer.extra_services, extraServices) - - rc.poll(() => { - sinon.assert.calledOnceWithMatch(request, payload, expectedPayload) - cb() - }) - }) - }) - - describe('parseConfig', () => { - let payload - const parsePayload = () => rc.parseConfig(payload) - let previousState - - beforeEach(() => { - sinon.stub(rc, 'dispatch').callsFake((list, action) => { - const items = /** @type {Array<{path: string, apply_state: number}>} */ (list) - for (const item of items) { - item.apply_state = ACKNOWLEDGED - - if (action === 'unapply') rc.appliedConfigs.delete(item.path) - else rc.appliedConfigs.set(item.path, item) - } - }) - - previousState = JSON.parse(JSON.stringify(rc.state)) - }) - - it('should do nothing if passed an empty payload', () => { - payload = {} - - assert.doesNotThrow(parsePayload) - sinon.assert.notCalled(rc.dispatch) - assert.deepStrictEqual(rc.state, previousState) - }) - - it('should throw when target is not found', () => { - payload = { - client_configs: ['datadog/42/PRODUCT/confId/config'], - targets: toBase64({ - signed: { - targets: { - 'datadog/42/OTHERPRODUCT/confId/config': {} - } - } - }) - } - - assert.throws(parsePayload, { message: 'Unable to find target for path datadog/42/PRODUCT/confId/config' }) - sinon.assert.notCalled(rc.dispatch) - assert.deepStrictEqual(rc.state, previousState) - }) - - it('should throw when target file is not found', () => { - payload = { - client_configs: ['datadog/42/PRODUCT/confId/config'], - targets: toBase64({ - signed: { - targets: { - 'datadog/42/PRODUCT/confId/config': { - hashes: { - sha256: 'haaaxx' - } - } - } - } - }) - } - - assert.throws(parsePayload, { message: 'Unable to find file for path datadog/42/PRODUCT/confId/config' }) - sinon.assert.notCalled(rc.dispatch) - assert.deepStrictEqual(rc.state, previousState) - }) - - it('should throw when config path cannot be parsed', () => { - payload = { - client_configs: ['datadog/42/confId/config'], - targets: toBase64({ - signed: { - targets: { - 'datadog/42/confId/config': { - hashes: { - sha256: 'haaaxx' - } - } - } - } - }), - target_files: [{ - path: 'datadog/42/confId/config', - raw: toBase64({}) - }] - } - - assert.throws(parsePayload, { message: 'Unable to parse path datadog/42/confId/config' }) - sinon.assert.notCalled(rc.dispatch) - assert.deepStrictEqual(rc.state, previousState) - }) - - it('should parse the config, call dispatch, and update the state', () => { - rc.appliedConfigs.set('datadog/42/UNAPPLY/confId/config', { - path: 'datadog/42/UNAPPLY/confId/config', - product: 'UNAPPLY', - id: 'confId', - version: 69, - apply_state: ACKNOWLEDGED, - apply_error: '', - length: 147, - hashes: { sha256: 'anotherHash' }, - file: { asm: { enabled: true } } - }) - rc.appliedConfigs.set('datadog/42/IGNORE/confId/config', { - path: 'datadog/42/IGNORE/confId/config', - product: 'IGNORE', - id: 'confId', - version: 43, - apply_state: ACKNOWLEDGED, - apply_error: '', - length: 420, - hashes: { sha256: 'sameHash' }, - file: {} - }) - rc.appliedConfigs.set('datadog/42/MODIFY/confId/config', { - path: 'datadog/42/MODIFY/confId/config', - product: 'MODIFY', - id: 'confId', - version: 11, - apply_state: ACKNOWLEDGED, - apply_error: '', - length: 147, - hashes: { sha256: 'oldHash' }, - file: { config: 'oldConf' } - }) - - payload = { - client_configs: [ - 'datadog/42/IGNORE/confId/config', - 'datadog/42/MODIFY/confId/config', - 'datadog/42/APPLY/confId/config' - ], - targets: toBase64({ - signed: { - custom: { - opaque_backend_state: 'opaquestateinbase64' - }, - targets: { - 'datadog/42/IGNORE/confId/config': { - custom: { - v: 43 - }, - hashes: { - sha256: 'sameHash' - }, - length: 420 - }, - 'datadog/42/MODIFY/confId/config': { - custom: { - v: 12 - }, - hashes: { - sha256: 'newHash' - }, - length: 147 - }, - 'datadog/42/APPLY/confId/config': { - custom: { - v: 1 - }, - hashes: { - sha256: 'haaaxx' - }, - length: 0 - } - }, - version: 12345 - } - }), - target_files: [ - { - path: 'datadog/42/MODIFY/confId/config', - raw: toBase64({ config: 'newConf' }) - }, - { - path: 'datadog/42/APPLY/confId/config', - raw: '' - } - ] - } - - // Calling parsePayload should not throw. - parsePayload() - - assert.strictEqual(rc.state.client.state.targets_version, 12345) - assert.strictEqual(rc.state.client.state.backend_client_state, 'opaquestateinbase64') - - sinon.assert.calledThrice(rc.dispatch) - sinon.assert.calledWithMatch(rc.dispatch.firstCall, [{ - path: 'datadog/42/UNAPPLY/confId/config', - product: 'UNAPPLY', - id: 'confId', - version: 69, - apply_state: ACKNOWLEDGED, - apply_error: '', - length: 147, - hashes: { sha256: 'anotherHash' }, - file: { asm: { enabled: true } } - }], 'unapply', sinon.match.instanceOf(Set)) - sinon.assert.calledWithMatch(rc.dispatch.secondCall, [{ - path: 'datadog/42/APPLY/confId/config', - product: 'APPLY', - id: 'confId', - version: 1, - apply_state: ACKNOWLEDGED, - apply_error: '', - length: 0, - hashes: { sha256: 'haaaxx' }, - file: null - }], 'apply', sinon.match.instanceOf(Set)) - sinon.assert.calledWithMatch(rc.dispatch.thirdCall, [{ - path: 'datadog/42/MODIFY/confId/config', - product: 'MODIFY', - id: 'confId', - version: 12, - apply_state: ACKNOWLEDGED, - apply_error: '', - length: 147, - hashes: { sha256: 'newHash' }, - file: { config: 'newConf' } - }], 'modify', sinon.match.instanceOf(Set)) - - assert.deepStrictEqual(rc.state.client.state.config_states, [ - { - id: 'confId', - version: 43, - product: 'IGNORE', - apply_state: ACKNOWLEDGED, - apply_error: '' - }, - { - id: 'confId', - version: 12, - product: 'MODIFY', - apply_state: ACKNOWLEDGED, - apply_error: '' - }, - { - id: 'confId', - version: 1, - product: 'APPLY', - apply_state: ACKNOWLEDGED, - apply_error: '' - } - ]) - assert.deepStrictEqual(rc.state.cached_target_files, [ - { - path: 'datadog/42/IGNORE/confId/config', - length: 420, - hashes: [{ algorithm: 'sha256', hash: 'sameHash' }] - }, - { - path: 'datadog/42/MODIFY/confId/config', - length: 147, - hashes: [{ algorithm: 'sha256', hash: 'newHash' }] - }, - { - path: 'datadog/42/APPLY/confId/config', - length: 0, - hashes: [{ algorithm: 'sha256', hash: 'haaaxx' }] - } - ]) - }) - - it('should allow batch handlers to ack + handle items and skip per-product handlers (including unapply)', () => { - // Arrange: two configs already applied, one will be unapplied. - const unapplyPath = 'datadog/42/ASM/confId/config' - rc.appliedConfigs.set(unapplyPath, { - path: unapplyPath, - product: 'ASM', - id: 'confId', - version: 1, - apply_state: ACKNOWLEDGED, - apply_error: '', - length: 1, - hashes: { sha256: 'oldHash' }, - file: { a: 1 } - }) - - const handler = sinon.spy() - rc.setProductHandler('ASM', handler) - - // Batch hook will handle the unapply and report success. - rc.setBatchHandler(['ASM'], (tx) => { - for (const item of tx.toUnapply) { - tx.ack(item.path) - tx.markHandled(item.path) - } - }) - - payload = { - client_configs: [], - targets: toBase64({ - signed: { - custom: { opaque_backend_state: 'state' }, - targets: {}, - version: 2 - } - }), - target_files: [] - } - - // Act - parsePayload() - - // Assert: handler should not be invoked, but state should be updated (unapplied). - sinon.assert.notCalled(handler) - assert.strictEqual(rc.appliedConfigs.has(unapplyPath), false) - }) - - it('should call per-product handlers when batch handlers do not markHandled (including unapply)', () => { - const unapplyPath = 'datadog/42/ASM/confId/config' - const conf = { - path: unapplyPath, - product: 'ASM', - id: 'confId', - version: 1, - apply_state: ACKNOWLEDGED, - apply_error: '', - length: 1, - hashes: { sha256: 'oldHash' }, - file: { a: 1 } - } - rc.appliedConfigs.set(unapplyPath, conf) - - const handler = sinon.spy() - rc.setProductHandler('ASM', handler) - - // Batch hook does nothing (does not markHandled), so per-product handler should run. - rc.setBatchHandler(['ASM'], () => {}) - - // This test needs the real dispatch path in order to verify handler invocation. - rc.dispatch.restore() - - payload = { - client_configs: [], - targets: toBase64({ - signed: { - custom: { opaque_backend_state: 'state' }, - targets: {}, - version: 2 - } - }), - target_files: [] - } - - parsePayload() - - sinon.assert.calledOnceWithExactly(handler, 'unapply', conf.file, conf.id) - assert.strictEqual(rc.appliedConfigs.has(unapplyPath), false) - }) - }) - - describe('dispatch', () => { - it('should call registered handler for each config, catch errors, and update the state', (done) => { - const syncGoodNonAckHandler = sinon.spy() - const syncBadNonAckHandler = sinon.spy((action, conf, id) => { throw new Error('sync fn') }) - const asyncGoodHandler = sinon.spy(async (action, conf, id) => {}) - const asyncBadHandler = sinon.spy(async (action, conf, id) => { throw new Error('async fn') }) - const syncGoodAckHandler = sinon.spy((action, conf, id, ack) => { ack() }) - const syncBadAckHandler = sinon.spy((action, conf, id, ack) => { ack(new Error('sync ack fn')) }) - const asyncGoodAckHandler = sinon.spy((action, conf, id, ack) => { setImmediate(ack) }) - const asyncBadAckHandler = sinon.spy((action, conf, id, ack) => { - setImmediate(ack.bind(null, new Error('async ack fn'))) - }) - const unackHandler = sinon.spy((action, conf, id, ack) => {}) - - rc.setProductHandler('PRODUCT_0', syncGoodNonAckHandler) - rc.setProductHandler('PRODUCT_1', syncBadNonAckHandler) - rc.setProductHandler('PRODUCT_2', asyncGoodHandler) - rc.setProductHandler('PRODUCT_3', asyncBadHandler) - rc.setProductHandler('PRODUCT_4', syncGoodAckHandler) - rc.setProductHandler('PRODUCT_5', syncBadAckHandler) - rc.setProductHandler('PRODUCT_6', asyncGoodAckHandler) - rc.setProductHandler('PRODUCT_7', asyncBadAckHandler) - rc.setProductHandler('PRODUCT_8', unackHandler) - - const list = [] - for (let i = 0; i < 9; i++) { - list[i] = { - id: `id_${i}`, - path: `datadog/42/PRODUCT_${i}/confId/config`, - product: `PRODUCT_${i}`, - apply_state: UNACKNOWLEDGED, - apply_error: '', - file: { index: i } - } - } - - rc.dispatch(list, 'apply', new Set()) - - sinon.assert.calledOnceWithExactly(syncGoodNonAckHandler, 'apply', list[0].file, list[0].id) - sinon.assert.calledOnceWithExactly(syncBadNonAckHandler, 'apply', list[1].file, list[1].id) - sinon.assert.calledOnceWithExactly(asyncGoodHandler, 'apply', list[2].file, list[2].id) - sinon.assert.calledOnceWithExactly(asyncBadHandler, 'apply', list[3].file, list[3].id) - assertAsyncHandlerCallArguments(syncGoodAckHandler, 'apply', list[4].file, list[4].id) - assertAsyncHandlerCallArguments(syncBadAckHandler, 'apply', list[5].file, list[5].id) - assertAsyncHandlerCallArguments(asyncGoodAckHandler, 'apply', list[6].file, list[6].id) - assertAsyncHandlerCallArguments(asyncBadAckHandler, 'apply', list[7].file, list[7].id) - assertAsyncHandlerCallArguments(unackHandler, 'apply', list[8].file, list[8].id) - - assert.strictEqual(list[0].apply_state, ACKNOWLEDGED) - assert.strictEqual(list[0].apply_error, '') - assert.strictEqual(list[1].apply_state, ERROR) - assert.strictEqual(list[1].apply_error, 'Error: sync fn') - assert.strictEqual(list[2].apply_state, UNACKNOWLEDGED) - assert.strictEqual(list[2].apply_error, '') - assert.strictEqual(list[3].apply_state, UNACKNOWLEDGED) - assert.strictEqual(list[3].apply_error, '') - assert.strictEqual(list[4].apply_state, ACKNOWLEDGED) - assert.strictEqual(list[4].apply_error, '') - assert.strictEqual(list[5].apply_state, ERROR) - assert.strictEqual(list[5].apply_error, 'Error: sync ack fn') - assert.strictEqual(list[6].apply_state, UNACKNOWLEDGED) - assert.strictEqual(list[6].apply_error, '') - assert.strictEqual(list[7].apply_state, UNACKNOWLEDGED) - assert.strictEqual(list[7].apply_error, '') - assert.strictEqual(list[8].apply_state, UNACKNOWLEDGED) - assert.strictEqual(list[8].apply_error, '') - - for (let i = 0; i < list.length; i++) { - assert.strictEqual(rc.appliedConfigs.get(`datadog/42/PRODUCT_${i}/confId/config`), list[i]) - } - - setImmediate(() => { - assert.strictEqual(list[2].apply_state, ACKNOWLEDGED) - assert.strictEqual(list[2].apply_error, '') - assert.strictEqual(list[3].apply_state, ERROR) - assert.strictEqual(list[3].apply_error, 'Error: async fn') - assert.strictEqual(list[6].apply_state, ACKNOWLEDGED) - assert.strictEqual(list[6].apply_error, '') - assert.strictEqual(list[7].apply_state, ERROR) - assert.strictEqual(list[7].apply_error, 'Error: async ack fn') - assert.strictEqual(list[8].apply_state, UNACKNOWLEDGED) - assert.strictEqual(list[8].apply_error, '') - done() - }) - - function assertAsyncHandlerCallArguments (handler, ...expectedArgs) { - sinon.assert.calledOnceWithMatch(handler, ...expectedArgs) - assert.strictEqual(handler.args[0].length, expectedArgs.length + 1) - assert.strictEqual(typeof handler.args[0][handler.args[0].length - 1], 'function') - } - }) - - it('should delete config from state when action is unapply', () => { - const handler = sinon.spy() - rc.setProductHandler('ASM_FEATURES', handler) - - rc.appliedConfigs.set('datadog/42/ASM_FEATURES/confId/config', { - id: 'asm_data', - path: 'datadog/42/ASM_FEATURES/confId/config', - product: 'ASM_FEATURES', - apply_state: ACKNOWLEDGED, - apply_error: '', - file: { asm: { enabled: true } } - }) - - rc.dispatch([rc.appliedConfigs.get('datadog/42/ASM_FEATURES/confId/config')], 'unapply', new Set()) - - sinon.assert.calledOnceWithExactly(handler, 'unapply', { asm: { enabled: true } }, 'asm_data') - assert.strictEqual(rc.appliedConfigs.size, 0) - }) - }) -}) - -function toBase64 (data) { - return Buffer.from(JSON.stringify(data), 'utf8').toString('base64') -}