diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4b9e1af..997a794a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: AppConfiguration-JavaScriptProvider CI on: push: - branches: [ "main" ] + branches: [ "main", "preview" ] pull_request: - branches: [ "main" ] + branches: [ "main", "preview" ] jobs: build: diff --git a/package-lock.json b/package-lock.json index e2c6af23..a7374571 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@azure/app-configuration-provider", - "version": "1.1.3", + "version": "2.0.0-preview.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@azure/app-configuration-provider", - "version": "1.1.3", + "version": "2.0.0-preview.2", "license": "MIT", "dependencies": { "@azure/app-configuration": "^1.6.1", @@ -1142,6 +1142,7 @@ "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -1429,6 +1430,7 @@ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -1505,6 +1507,7 @@ "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" } @@ -2524,6 +2527,7 @@ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, + "license": "MIT", "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -2577,6 +2581,7 @@ "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz", "integrity": "sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==", "dev": true, + "license": "MIT", "dependencies": { "ansi-colors": "^4.1.3", "browser-stdout": "^1.3.1", @@ -2612,6 +2617,7 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -2641,6 +2647,7 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -2866,6 +2873,7 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz", "integrity": "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==", "dev": true, + "license": "MIT", "dependencies": { "isarray": "0.0.1" } @@ -2952,6 +2960,7 @@ "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", "dev": true, + "license": "MIT", "dependencies": { "safe-buffer": "^5.1.0" } @@ -3082,6 +3091,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz", "integrity": "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==", "dev": true, + "license": "MIT", "bin": { "rollup": "dist/bin/rollup" }, @@ -3176,6 +3186,7 @@ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "randombytes": "^2.1.0" } @@ -3548,6 +3559,7 @@ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", "dev": true, + "license": "ISC", "engines": { "node": ">=10" } diff --git a/package.json b/package.json index 7ec6f6b6..16148a76 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azure/app-configuration-provider", - "version": "1.1.3", + "version": "2.0.0-preview.2", "description": "The JavaScript configuration provider for Azure App Configuration", "main": "dist/index.js", "module": "./dist-esm/index.js", diff --git a/rollup.config.mjs b/rollup.config.mjs index b2e87c64..1fa9626f 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -4,7 +4,7 @@ import dts from "rollup-plugin-dts"; export default [ { - external: ["@azure/app-configuration", "@azure/keyvault-secrets", "@azure/core-rest-pipeline"], + external: ["@azure/app-configuration", "@azure/keyvault-secrets", "@azure/core-rest-pipeline", "crypto", "dns/promises", "@microsoft/feature-management"], input: "src/index.ts", output: [ { diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 4523642d..90e283f0 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -9,12 +9,37 @@ import { IKeyValueAdapter } from "./IKeyValueAdapter.js"; import { JsonKeyValueAdapter } from "./JsonKeyValueAdapter.js"; import { DEFAULT_REFRESH_INTERVAL_IN_MS, MIN_REFRESH_INTERVAL_IN_MS } from "./RefreshOptions.js"; import { Disposable } from "./common/disposable.js"; -import { FEATURE_FLAGS_KEY_NAME, FEATURE_MANAGEMENT_KEY_NAME, CONDITIONS_KEY_NAME, CLIENT_FILTERS_KEY_NAME, TELEMETRY_KEY_NAME, VARIANTS_KEY_NAME, ALLOCATION_KEY_NAME, SEED_KEY_NAME, NAME_KEY_NAME, ENABLED_KEY_NAME } from "./featureManagement/constants.js"; +import { base64Helper, jsonSorter } from "./common/utils.js"; +import { + FEATURE_FLAGS_KEY_NAME, + FEATURE_MANAGEMENT_KEY_NAME, + NAME_KEY_NAME, + TELEMETRY_KEY_NAME, + ENABLED_KEY_NAME, + METADATA_KEY_NAME, + ETAG_KEY_NAME, + FEATURE_FLAG_ID_KEY_NAME, + FEATURE_FLAG_REFERENCE_KEY_NAME, + ALLOCATION_ID_KEY_NAME, + ALLOCATION_KEY_NAME, + DEFAULT_WHEN_ENABLED_KEY_NAME, + PERCENTILE_KEY_NAME, + FROM_KEY_NAME, + TO_KEY_NAME, + SEED_KEY_NAME, + VARIANT_KEY_NAME, + VARIANTS_KEY_NAME, + CONFIGURATION_VALUE_KEY_NAME, + CONDITIONS_KEY_NAME, + CLIENT_FILTERS_KEY_NAME +} from "./featureManagement/constants.js"; +import { FM_PACKAGE_NAME } from "./requestTracing/constants.js"; import { AzureKeyVaultKeyValueAdapter } from "./keyvault/AzureKeyVaultKeyValueAdapter.js"; import { RefreshTimer } from "./refresh/RefreshTimer.js"; -import { getConfigurationSettingWithTrace, listConfigurationSettingsWithTrace, requestTracingEnabled } from "./requestTracing/utils.js"; +import { RequestTracingOptions, getConfigurationSettingWithTrace, listConfigurationSettingsWithTrace, requestTracingEnabled } from "./requestTracing/utils.js"; import { FeatureFlagTracingOptions } from "./requestTracing/FeatureFlagTracingOptions.js"; import { KeyFilter, LabelFilter, SettingSelector } from "./types.js"; +import { ConfigurationClientManager } from "./ConfigurationClientManager.js"; type PagedSettingSelector = SettingSelector & { /** @@ -36,37 +61,49 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { */ #sortedTrimKeyPrefixes: string[] | undefined; readonly #requestTracingEnabled: boolean; - #client: AppConfigurationClient; + #clientManager: ConfigurationClientManager; #options: AzureAppConfigurationOptions | undefined; #isInitialLoadCompleted: boolean = false; + #isFailoverRequest: boolean = false; #featureFlagTracing: FeatureFlagTracingOptions | undefined; + #fmVersion: string | undefined; // Refresh #refreshInProgress: boolean = false; - #refreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; #onRefreshListeners: Array<() => any> = []; /** * Aka watched settings. */ #sentinels: ConfigurationSettingId[] = []; - #refreshTimer: RefreshTimer; + #watchAll: boolean = false; + #kvRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; + #kvRefreshTimer: RefreshTimer; // Feature flags - #featureFlagRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; - #featureFlagRefreshTimer: RefreshTimer; + #ffRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; + #ffRefreshTimer: RefreshTimer; - // selectors - #featureFlagSelectors: PagedSettingSelector[] = []; + /** + * Selectors of key-values obtained from @see AzureAppConfigurationOptions.selectors + */ + #kvSelectors: PagedSettingSelector[] = []; + /** + * Selectors of feature flags obtained from @see AzureAppConfigurationOptions.featureFlagOptions.selectors + */ + #ffSelectors: PagedSettingSelector[] = []; + + // Load balancing + #lastSuccessfulEndpoint: string = ""; constructor( - client: AppConfigurationClient, - options: AzureAppConfigurationOptions | undefined + clientManager: ConfigurationClientManager, + options: AzureAppConfigurationOptions | undefined, ) { - this.#client = client; this.#options = options; + this.#clientManager = clientManager; - // Enable request tracing if not opt-out + // enable request tracing if not opt-out this.#requestTracingEnabled = requestTracingEnabled(); if (this.#requestTracingEnabled) { this.#featureFlagTracing = new FeatureFlagTracingOptions(); @@ -76,40 +113,40 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { this.#sortedTrimKeyPrefixes = [...options.trimKeyPrefixes].sort((a, b) => b.localeCompare(a)); } + // if no selector is specified, always load key values using the default selector: key="*" and label="\0" + this.#kvSelectors = getValidKeyValueSelectors(options?.selectors); + if (options?.refreshOptions?.enabled) { - const { watchedSettings, refreshIntervalInMs } = options.refreshOptions; - // validate watched settings + const { refreshIntervalInMs, watchedSettings } = options.refreshOptions; if (watchedSettings === undefined || watchedSettings.length === 0) { - throw new Error("Refresh is enabled but no watched settings are specified."); + this.#watchAll = true; // if no watched settings is specified, then watch all + } else { + for (const setting of watchedSettings) { + if (setting.key.includes("*") || setting.key.includes(",")) { + throw new Error("The characters '*' and ',' are not supported in key of watched settings."); + } + if (setting.label?.includes("*") || setting.label?.includes(",")) { + throw new Error("The characters '*' and ',' are not supported in label of watched settings."); + } + this.#sentinels.push(setting); + } } // custom refresh interval if (refreshIntervalInMs !== undefined) { if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) { throw new Error(`The refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`); - } else { - this.#refreshInterval = refreshIntervalInMs; - } - } - - for (const setting of watchedSettings) { - if (setting.key.includes("*") || setting.key.includes(",")) { - throw new Error("The characters '*' and ',' are not supported in key of watched settings."); - } - if (setting.label?.includes("*") || setting.label?.includes(",")) { - throw new Error("The characters '*' and ',' are not supported in label of watched settings."); + this.#kvRefreshInterval = refreshIntervalInMs; } - this.#sentinels.push(setting); } - - this.#refreshTimer = new RefreshTimer(this.#refreshInterval); + this.#kvRefreshTimer = new RefreshTimer(this.#kvRefreshInterval); } // feature flag options if (options?.featureFlagOptions?.enabled) { - // validate feature flag selectors - this.#featureFlagSelectors = getValidFeatureFlagSelectors(options.featureFlagOptions.selectors); + // validate feature flag selectors, only load feature flags when enabled + this.#ffSelectors = getValidFeatureFlagSelectors(options.featureFlagOptions.selectors); if (options.featureFlagOptions.refresh?.enabled) { const { refreshIntervalInMs } = options.featureFlagOptions.refresh; @@ -118,11 +155,11 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) { throw new Error(`The feature flag refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`); } else { - this.#featureFlagRefreshInterval = refreshIntervalInMs; + this.#ffRefreshInterval = refreshIntervalInMs; } } - this.#featureFlagRefreshTimer = new RefreshTimer(this.#featureFlagRefreshInterval); + this.#ffRefreshTimer = new RefreshTimer(this.#ffRefreshInterval); } } @@ -130,6 +167,30 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { this.#adapters.push(new JsonKeyValueAdapter()); } + get #refreshEnabled(): boolean { + return !!this.#options?.refreshOptions?.enabled; + } + + get #featureFlagEnabled(): boolean { + return !!this.#options?.featureFlagOptions?.enabled; + } + + get #featureFlagRefreshEnabled(): boolean { + return this.#featureFlagEnabled && !!this.#options?.featureFlagOptions?.refresh?.enabled; + } + + get #requestTraceOptions(): RequestTracingOptions { + return { + enabled: this.#requestTracingEnabled, + appConfigOptions: this.#options, + initialLoadCompleted: this.#isInitialLoadCompleted, + replicaCount: this.#clientManager.getReplicaCount(), + isFailoverRequest: this.#isFailoverRequest, + featureFlagTracing: this.#featureFlagTracing, + fmVersion: this.#fmVersion + }; + } + // #region ReadonlyMap APIs get(key: string): T | undefined { return this.#configMap.get(key); @@ -164,146 +225,11 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } // #endregion - get #refreshEnabled(): boolean { - return !!this.#options?.refreshOptions?.enabled; - } - - get #featureFlagEnabled(): boolean { - return !!this.#options?.featureFlagOptions?.enabled; - } - - get #featureFlagRefreshEnabled(): boolean { - return this.#featureFlagEnabled && !!this.#options?.featureFlagOptions?.refresh?.enabled; - } - - get #requestTraceOptions() { - return { - requestTracingEnabled: this.#requestTracingEnabled, - initialLoadCompleted: this.#isInitialLoadCompleted, - appConfigOptions: this.#options, - featureFlagTracingOptions: this.#featureFlagTracing - }; - } - - async #loadSelectedKeyValues(): Promise { - const loadedSettings: ConfigurationSetting[] = []; - - // validate selectors - const selectors = getValidKeyValueSelectors(this.#options?.selectors); - - for (const selector of selectors) { - const listOptions: ListConfigurationSettingsOptions = { - keyFilter: selector.keyFilter, - labelFilter: selector.labelFilter - }; - - const settings = listConfigurationSettingsWithTrace( - this.#requestTraceOptions, - this.#client, - listOptions - ); - - for await (const setting of settings) { - if (!isFeatureFlag(setting)) { // exclude feature flags - loadedSettings.push(setting); - } - } - } - return loadedSettings; - } - - /** - * Update etag of watched settings from loaded data. If a watched setting is not covered by any selector, a request will be sent to retrieve it. - */ - async #updateWatchedKeyValuesEtag(existingSettings: ConfigurationSetting[]): Promise { - if (!this.#refreshEnabled) { - return; - } - - for (const sentinel of this.#sentinels) { - const matchedSetting = existingSettings.find(s => s.key === sentinel.key && s.label === sentinel.label); - if (matchedSetting) { - sentinel.etag = matchedSetting.etag; - } else { - // Send a request to retrieve key-value since it may be either not loaded or loaded with a different label or different casing - const { key, label } = sentinel; - const response = await this.#getConfigurationSetting({ key, label }); - if (response) { - sentinel.etag = response.etag; - } else { - sentinel.etag = undefined; - } - } - } - } - - async #loadSelectedAndWatchedKeyValues() { - const keyValues: [key: string, value: unknown][] = []; - const loadedSettings = await this.#loadSelectedKeyValues(); - await this.#updateWatchedKeyValuesEtag(loadedSettings); - - // process key-values, watched settings have higher priority - for (const setting of loadedSettings) { - const [key, value] = await this.#processKeyValues(setting); - keyValues.push([key, value]); - } - - this.#clearLoadedKeyValues(); // clear existing key-values in case of configuration setting deletion - for (const [k, v] of keyValues) { - this.#configMap.set(k, v); - } - } - - async #clearLoadedKeyValues() { - for (const key of this.#configMap.keys()) { - if (key !== FEATURE_MANAGEMENT_KEY_NAME) { - this.#configMap.delete(key); - } - } - } - - async #loadFeatureFlags() { - const featureFlagSettings: ConfigurationSetting[] = []; - for (const selector of this.#featureFlagSelectors) { - const listOptions: ListConfigurationSettingsOptions = { - keyFilter: `${featureFlagPrefix}${selector.keyFilter}`, - labelFilter: selector.labelFilter - }; - - const pageEtags: string[] = []; - const pageIterator = listConfigurationSettingsWithTrace( - this.#requestTraceOptions, - this.#client, - listOptions - ).byPage(); - for await (const page of pageIterator) { - pageEtags.push(page.etag ?? ""); - for (const setting of page.items) { - if (isFeatureFlag(setting)) { - featureFlagSettings.push(setting); - } - } - } - selector.pageEtags = pageEtags; - } - - if (this.#requestTracingEnabled && this.#featureFlagTracing !== undefined) { - this.#featureFlagTracing.resetFeatureFlagTracing(); - } - - // parse feature flags - const featureFlags = await Promise.all( - featureFlagSettings.map(setting => this.#parseFeatureFlag(setting)) - ); - - // feature_management is a reserved key, and feature_flags is an array of feature flags - this.#configMap.set(FEATURE_MANAGEMENT_KEY_NAME, { [FEATURE_FLAGS_KEY_NAME]: featureFlags }); - } - /** - * Load the configuration store for the first time. + * Loads the configuration store for the first time. */ async load() { + await this.#inspectFmPackage(); await this.#loadSelectedAndWatchedKeyValues(); if (this.#featureFlagEnabled) { await this.#loadFeatureFlags(); @@ -313,7 +239,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } /** - * Construct hierarchical data object from map. + * Constructs hierarchical data object from map. */ constructConfigurationObject(options?: ConfigurationObjectConstructionOptions): Record { const separator = options?.separator ?? "."; @@ -356,7 +282,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } /** - * Refresh the configuration store. + * Refreshes the configuration. */ async refresh(): Promise { if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) { @@ -374,6 +300,41 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } } + /** + * Registers a callback function to be called when the configuration is refreshed. + */ + onRefresh(listener: () => any, thisArg?: any): Disposable { + if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) { + throw new Error("Refresh is not enabled for key-values or feature flags."); + } + + const boundedListener = listener.bind(thisArg); + this.#onRefreshListeners.push(boundedListener); + + const remove = () => { + const index = this.#onRefreshListeners.indexOf(boundedListener); + if (index >= 0) { + this.#onRefreshListeners.splice(index, 1); + } + }; + return new Disposable(remove); + } + + /** + * Inspects the feature management package version. + */ + async #inspectFmPackage() { + if (this.#requestTracingEnabled && !this.#fmVersion) { + try { + // get feature management package version + const fmPackage = await import(FM_PACKAGE_NAME); + this.#fmVersion = fmPackage?.VERSION; + } catch (error) { + // ignore the error + } + } + } + async #refreshTasks(): Promise { const refreshTasks: Promise[] = []; if (this.#refreshEnabled) { @@ -389,7 +350,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { // check if any refresh task failed for (const result of results) { if (result.status === "rejected") { - throw result.reason; + console.warn("Refresh failed:", result.reason); } } @@ -404,17 +365,141 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } /** - * Refresh key-values. + * Loads configuration settings from App Configuration, either key-value settings or feature flag settings. + * Additionally, updates the `pageEtags` property of the corresponding @see PagedSettingSelector after loading. + * + * @param loadFeatureFlag - Determines which type of configurationsettings to load: + * If true, loads feature flag using the feature flag selectors; + * If false, loads key-value using the key-value selectors. Defaults to false. + */ + async #loadConfigurationSettings(loadFeatureFlag: boolean = false): Promise { + const selectors = loadFeatureFlag ? this.#ffSelectors : this.#kvSelectors; + const funcToExecute = async (client) => { + const loadedSettings: ConfigurationSetting[] = []; + // deep copy selectors to avoid modification if current client fails + const selectorsToUpdate = JSON.parse( + JSON.stringify(selectors) + ); + + for (const selector of selectorsToUpdate) { + const listOptions: ListConfigurationSettingsOptions = { + keyFilter: selector.keyFilter, + labelFilter: selector.labelFilter + }; + + const pageEtags: string[] = []; + const pageIterator = listConfigurationSettingsWithTrace( + this.#requestTraceOptions, + client, + listOptions + ).byPage(); + for await (const page of pageIterator) { + pageEtags.push(page.etag ?? ""); + for (const setting of page.items) { + if (loadFeatureFlag === isFeatureFlag(setting)) { + loadedSettings.push(setting); + } + } + } + selector.pageEtags = pageEtags; + } + + if (loadFeatureFlag) { + this.#ffSelectors = selectorsToUpdate; + } else { + this.#kvSelectors = selectorsToUpdate; + } + return loadedSettings; + }; + + return await this.#executeWithFailoverPolicy(funcToExecute) as ConfigurationSetting[]; + } + + /** + * Loads selected key-values and watched settings (sentinels) for refresh from App Configuration to the local configuration. + */ + async #loadSelectedAndWatchedKeyValues() { + const keyValues: [key: string, value: unknown][] = []; + const loadedSettings = await this.#loadConfigurationSettings(); + if (this.#refreshEnabled && !this.#watchAll) { + await this.#updateWatchedKeyValuesEtag(loadedSettings); + } + + // process key-values, watched settings have higher priority + for (const setting of loadedSettings) { + const [key, value] = await this.#processKeyValues(setting); + keyValues.push([key, value]); + } + + this.#clearLoadedKeyValues(); // clear existing key-values in case of configuration setting deletion + for (const [k, v] of keyValues) { + this.#configMap.set(k, v); // reset the configuration + } + } + + /** + * Updates etag of watched settings from loaded data. If a watched setting is not covered by any selector, a request will be sent to retrieve it. + */ + async #updateWatchedKeyValuesEtag(existingSettings: ConfigurationSetting[]): Promise { + for (const sentinel of this.#sentinels) { + const matchedSetting = existingSettings.find(s => s.key === sentinel.key && s.label === sentinel.label); + if (matchedSetting) { + sentinel.etag = matchedSetting.etag; + } else { + // Send a request to retrieve key-value since it may be either not loaded or loaded with a different label or different casing + const { key, label } = sentinel; + const response = await this.#getConfigurationSetting({ key, label }); + if (response) { + sentinel.etag = response.etag; + } else { + sentinel.etag = undefined; + } + } + } + } + + /** + * Clears all existing key-values in the local configuration except feature flags. + */ + async #clearLoadedKeyValues() { + for (const key of this.#configMap.keys()) { + if (key !== FEATURE_MANAGEMENT_KEY_NAME) { + this.#configMap.delete(key); + } + } + } + + /** + * Loads feature flags from App Configuration to the local configuration. + */ + async #loadFeatureFlags() { + const loadFeatureFlag = true; + const featureFlagSettings = await this.#loadConfigurationSettings(loadFeatureFlag); + + // parse feature flags + const featureFlags = await Promise.all( + featureFlagSettings.map(setting => this.#parseFeatureFlag(setting)) + ); + + // feature_management is a reserved key, and feature_flags is an array of feature flags + this.#configMap.set(FEATURE_MANAGEMENT_KEY_NAME, { [FEATURE_FLAGS_KEY_NAME]: featureFlags }); + } + + /** + * Refreshes key-values. * @returns true if key-values are refreshed, false otherwise. */ async #refreshKeyValues(): Promise { // if still within refresh interval/backoff, return - if (!this.#refreshTimer.canRefresh()) { + if (!this.#kvRefreshTimer.canRefresh()) { return Promise.resolve(false); } // try refresh if any of watched settings is changed. let needRefresh = false; + if (this.#watchAll) { + needRefresh = await this.#checkConfigurationSettingsChange(this.#kvSelectors); + } for (const sentinel of this.#sentinels.values()) { const response = await this.#getConfigurationSetting(sentinel, { onlyIfChanged: true @@ -430,84 +515,131 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } if (needRefresh) { - try { - await this.#loadSelectedAndWatchedKeyValues(); - } catch (error) { - // if refresh failed, backoff - this.#refreshTimer.backoff(); - throw error; - } + await this.#loadSelectedAndWatchedKeyValues(); } - this.#refreshTimer.reset(); + this.#kvRefreshTimer.reset(); return Promise.resolve(needRefresh); } /** - * Refresh feature flags. + * Refreshes feature flags. * @returns true if feature flags are refreshed, false otherwise. */ async #refreshFeatureFlags(): Promise { // if still within refresh interval/backoff, return - if (!this.#featureFlagRefreshTimer.canRefresh()) { + if (!this.#ffRefreshTimer.canRefresh()) { return Promise.resolve(false); } - // check if any feature flag is changed - let needRefresh = false; - for (const selector of this.#featureFlagSelectors) { - const listOptions: ListConfigurationSettingsOptions = { - keyFilter: `${featureFlagPrefix}${selector.keyFilter}`, - labelFilter: selector.labelFilter, - pageEtags: selector.pageEtags - }; - const pageIterator = listConfigurationSettingsWithTrace( - this.#requestTraceOptions, - this.#client, - listOptions - ).byPage(); + const needRefresh = await this.#checkConfigurationSettingsChange(this.#ffSelectors); + if (needRefresh) { + await this.#loadFeatureFlags(); + } - for await (const page of pageIterator) { - if (page._response.status === 200) { // created or changed - needRefresh = true; - break; + this.#ffRefreshTimer.reset(); + return Promise.resolve(needRefresh); + } + + /** + * Checks whether the key-value collection has changed. + * @param selectors - The @see PagedSettingSelector of the kev-value collection. + * @returns true if key-value collection has changed, false otherwise. + */ + async #checkConfigurationSettingsChange(selectors: PagedSettingSelector[]): Promise { + const funcToExecute = async (client) => { + for (const selector of selectors) { + const listOptions: ListConfigurationSettingsOptions = { + keyFilter: selector.keyFilter, + labelFilter: selector.labelFilter, + pageEtags: selector.pageEtags + }; + + const pageIterator = listConfigurationSettingsWithTrace( + this.#requestTraceOptions, + client, + listOptions + ).byPage(); + + for await (const page of pageIterator) { + if (page._response.status === 200) { // created or changed + return true; + } } } + return false; + }; - if (needRefresh) { - break; // short-circuit if result from any of the selectors is changed - } - } + const isChanged = await this.#executeWithFailoverPolicy(funcToExecute); + return isChanged; + } - if (needRefresh) { - try { - await this.#loadFeatureFlags(); - } catch (error) { - // if refresh failed, backoff - this.#featureFlagRefreshTimer.backoff(); + /** + * Gets a configuration setting by key and label.If the setting is not found, return undefine instead of throwing an error. + */ + async #getConfigurationSetting(configurationSettingId: ConfigurationSettingId, customOptions?: GetConfigurationSettingOptions): Promise { + const funcToExecute = async (client) => { + return getConfigurationSettingWithTrace( + this.#requestTraceOptions, + client, + configurationSettingId, + customOptions + ); + }; + + let response: GetConfigurationSettingResponse | undefined; + try { + response = await this.#executeWithFailoverPolicy(funcToExecute); + } catch (error) { + if (isRestError(error) && error.statusCode === 404) { + response = undefined; + } else { throw error; } } - - this.#featureFlagRefreshTimer.reset(); - return Promise.resolve(needRefresh); + return response; } - onRefresh(listener: () => any, thisArg?: any): Disposable { - if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) { - throw new Error("Refresh is not enabled for key-values or feature flags."); + async #executeWithFailoverPolicy(funcToExecute: (client: AppConfigurationClient) => Promise): Promise { + let clientWrappers = await this.#clientManager.getClients(); + if (this.#options?.loadBalancingEnabled && this.#lastSuccessfulEndpoint !== "" && clientWrappers.length > 1) { + let nextClientIndex = 0; + // Iterate through clients to find the index of the client with the last successful endpoint + for (const clientWrapper of clientWrappers) { + nextClientIndex++; + if (clientWrapper.endpoint === this.#lastSuccessfulEndpoint) { + break; + } + } + // If we found the last successful client, rotate the list so that the next client is at the beginning + if (nextClientIndex < clientWrappers.length) { + clientWrappers = [...clientWrappers.slice(nextClientIndex), ...clientWrappers.slice(0, nextClientIndex)]; + } } - const boundedListener = listener.bind(thisArg); - this.#onRefreshListeners.push(boundedListener); + let successful: boolean; + for (const clientWrapper of clientWrappers) { + successful = false; + try { + const result = await funcToExecute(clientWrapper.client); + this.#isFailoverRequest = false; + this.#lastSuccessfulEndpoint = clientWrapper.endpoint; + successful = true; + clientWrapper.updateBackoffStatus(successful); + return result; + } catch (error) { + if (isFailoverableError(error)) { + clientWrapper.updateBackoffStatus(successful); + this.#isFailoverRequest = true; + continue; + } - const remove = () => { - const index = this.#onRefreshListeners.indexOf(boundedListener); - if (index >= 0) { - this.#onRefreshListeners.splice(index, 1); + throw error; } - }; - return new Disposable(remove); + } + + this.#clientManager.refreshClients(); + throw new Error("All clients failed to get configuration settings."); } async #processKeyValues(setting: ConfigurationSetting): Promise<[string, unknown]> { @@ -536,34 +668,28 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return key; } - /** - * Get a configuration setting by key and label. If the setting is not found, return undefine instead of throwing an error. - */ - async #getConfigurationSetting(configurationSettingId: ConfigurationSettingId, customOptions?: GetConfigurationSettingOptions): Promise { - let response: GetConfigurationSettingResponse | undefined; - try { - response = await getConfigurationSettingWithTrace( - this.#requestTraceOptions, - this.#client, - configurationSettingId, - customOptions - ); - } catch (error) { - if (isRestError(error) && error.statusCode === 404) { - response = undefined; - } else { - throw error; - } - } - return response; - } - async #parseFeatureFlag(setting: ConfigurationSetting): Promise { const rawFlag = setting.value; if (rawFlag === undefined) { throw new Error("The value of configuration setting cannot be undefined."); } const featureFlag = JSON.parse(rawFlag); + + if (featureFlag[TELEMETRY_KEY_NAME] && featureFlag[TELEMETRY_KEY_NAME][ENABLED_KEY_NAME] === true) { + const metadata = featureFlag[TELEMETRY_KEY_NAME][METADATA_KEY_NAME]; + let allocationId = ""; + if (featureFlag[ALLOCATION_KEY_NAME] !== undefined) { + allocationId = await this.#generateAllocationId(featureFlag); + } + featureFlag[TELEMETRY_KEY_NAME][METADATA_KEY_NAME] = { + [ETAG_KEY_NAME]: setting.etag, + [FEATURE_FLAG_ID_KEY_NAME]: await this.#calculateFeatureFlagId(setting), + [FEATURE_FLAG_REFERENCE_KEY_NAME]: this.#createFeatureFlagReference(setting), + ...(allocationId !== "" && { [ALLOCATION_ID_KEY_NAME]: allocationId }), + ...(metadata || {}) + }; + } + if (this.#requestTracingEnabled && this.#featureFlagTracing !== undefined) { if (featureFlag[CONDITIONS_KEY_NAME] && featureFlag[CONDITIONS_KEY_NAME][CLIENT_FILTERS_KEY_NAME] && @@ -582,8 +708,177 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { this.#featureFlagTracing.usesSeed = true; } } + return featureFlag; } + + async #calculateFeatureFlagId(setting: ConfigurationSetting): Promise { + let crypto; + + // Check for browser environment + if (typeof window !== "undefined" && window.crypto && window.crypto.subtle) { + crypto = window.crypto; + } + // Check for Node.js environment + else if (typeof global !== "undefined" && global.crypto) { + crypto = global.crypto; + } + // Fallback to native Node.js crypto module + else { + try { + if (typeof module !== "undefined" && module.exports) { + crypto = require("crypto"); + } + else { + crypto = await import("crypto"); + } + } catch (error) { + console.error("Failed to load the crypto module:", error.message); + throw error; + } + } + + let baseString = `${setting.key}\n`; + if (setting.label && setting.label.trim().length !== 0) { + baseString += `${setting.label}`; + } + + // Convert to UTF-8 encoded bytes + const data = new TextEncoder().encode(baseString); + + // In the browser, use crypto.subtle.digest + if (crypto.subtle) { + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = new Uint8Array(hashBuffer); + // btoa/atob is also available in Node.js 18+ + const base64String = btoa(String.fromCharCode(...hashArray)); + const base64urlString = base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + return base64urlString; + } + // In Node.js, use the crypto module's hash function + else { + const hash = crypto.createHash("sha256").update(data).digest(); + return hash.toString("base64url"); + } + } + + #createFeatureFlagReference(setting: ConfigurationSetting): string { + let featureFlagReference = `${this.#clientManager.endpoint.origin}/kv/${setting.key}`; + if (setting.label && setting.label.trim().length !== 0) { + featureFlagReference += `?label=${setting.label}`; + } + return featureFlagReference; + } + + async #generateAllocationId(featureFlag: any): Promise { + let rawAllocationId = ""; + // Only default variant when enabled and variants allocated by percentile involve in the experimentation + // The allocation id is genearted from default variant when enabled and percentile allocation + const variantsForExperimentation: string[] = []; + + rawAllocationId += `seed=${featureFlag[ALLOCATION_KEY_NAME][SEED_KEY_NAME] ?? ""}\ndefault_when_enabled=`; + + if (featureFlag[ALLOCATION_KEY_NAME][DEFAULT_WHEN_ENABLED_KEY_NAME]) { + variantsForExperimentation.push(featureFlag[ALLOCATION_KEY_NAME][DEFAULT_WHEN_ENABLED_KEY_NAME]); + rawAllocationId += `${featureFlag[ALLOCATION_KEY_NAME][DEFAULT_WHEN_ENABLED_KEY_NAME]}`; + } + + rawAllocationId += "\npercentiles="; + + const percentileList = featureFlag[ALLOCATION_KEY_NAME][PERCENTILE_KEY_NAME]; + if (percentileList) { + const sortedPercentileList = percentileList + .filter(p => + (p[FROM_KEY_NAME] !== undefined) && + (p[TO_KEY_NAME] !== undefined) && + (p[VARIANT_KEY_NAME] !== undefined) && + (p[FROM_KEY_NAME] !== p[TO_KEY_NAME])) + .sort((a, b) => a[FROM_KEY_NAME] - b[FROM_KEY_NAME]); + + const percentileAllocation: string[] = []; + for (const percentile of sortedPercentileList) { + variantsForExperimentation.push(percentile[VARIANT_KEY_NAME]); + percentileAllocation.push(`${percentile[FROM_KEY_NAME]},${base64Helper(percentile[VARIANT_KEY_NAME])},${percentile[TO_KEY_NAME]}`); + } + rawAllocationId += percentileAllocation.join(";"); + } + + if (variantsForExperimentation.length === 0 && featureFlag[ALLOCATION_KEY_NAME][SEED_KEY_NAME] === undefined) { + // All fields required for generating allocation id are missing, short-circuit and return empty string + return ""; + } + + rawAllocationId += "\nvariants="; + + if (variantsForExperimentation.length !== 0) { + const variantsList = featureFlag[VARIANTS_KEY_NAME]; + if (variantsList) { + const sortedVariantsList = variantsList + .filter(v => + (v[NAME_KEY_NAME] !== undefined) && + variantsForExperimentation.includes(v[NAME_KEY_NAME])) + .sort((a, b) => (a.name > b.name ? 1 : -1)); + + const variantConfiguration: string[] = []; + for (const variant of sortedVariantsList) { + const configurationValue = JSON.stringify(variant[CONFIGURATION_VALUE_KEY_NAME], jsonSorter) ?? ""; + variantConfiguration.push(`${base64Helper(variant[NAME_KEY_NAME])},${configurationValue}`); + } + rawAllocationId += variantConfiguration.join(";"); + } + } + + let crypto; + + // Check for browser environment + if (typeof window !== "undefined" && window.crypto && window.crypto.subtle) { + crypto = window.crypto; + } + // Check for Node.js environment + else if (typeof global !== "undefined" && global.crypto) { + crypto = global.crypto; + } + // Fallback to native Node.js crypto module + else { + try { + if (typeof module !== "undefined" && module.exports) { + crypto = require("crypto"); + } + else { + crypto = await import("crypto"); + } + } catch (error) { + console.error("Failed to load the crypto module:", error.message); + throw error; + } + } + + // Convert to UTF-8 encoded bytes + const data = new TextEncoder().encode(rawAllocationId); + + // In the browser, use crypto.subtle.digest + if (crypto.subtle) { + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = new Uint8Array(hashBuffer); + + // Only use the first 15 bytes + const first15Bytes = hashArray.slice(0, 15); + + // btoa/atob is also available in Node.js 18+ + const base64String = btoa(String.fromCharCode(...first15Bytes)); + const base64urlString = base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + return base64urlString; + } + // In Node.js, use the crypto module's hash function + else { + const hash = crypto.createHash("sha256").update(data).digest(); + + // Only use the first 15 bytes + const first15Bytes = hash.slice(0, 15); + + return first15Bytes.toString("base64url"); + } + } } function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] { @@ -613,7 +908,7 @@ function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] { } function getValidKeyValueSelectors(selectors?: SettingSelector[]): SettingSelector[] { - if (!selectors || selectors.length === 0) { + if (selectors === undefined || selectors.length === 0) { // Default selector: key: *, label: \0 return [{ keyFilter: KeyFilter.Any, labelFilter: LabelFilter.Null }]; } @@ -621,10 +916,18 @@ function getValidKeyValueSelectors(selectors?: SettingSelector[]): SettingSelect } function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSelector[] { - if (!selectors || selectors.length === 0) { - // selectors must be explicitly provided. - throw new Error("Feature flag selectors must be provided."); - } else { - return getValidSelectors(selectors); + if (selectors === undefined || selectors.length === 0) { + // Default selector: key: *, label: \0 + return [{ keyFilter: `${featureFlagPrefix}${KeyFilter.Any}`, labelFilter: LabelFilter.Null }]; } + selectors.forEach(selector => { + selector.keyFilter = `${featureFlagPrefix}${selector.keyFilter}`; + }); + return getValidSelectors(selectors); +} + +function isFailoverableError(error: any): boolean { + // ENOTFOUND: DNS lookup failed, ENOENT: no such file or directory + return isRestError(error) && (error.code === "ENOTFOUND" || error.code === "ENOENT" || + (error.statusCode !== undefined && (error.statusCode === 401 || error.statusCode === 403 || error.statusCode === 408 || error.statusCode === 429 || error.statusCode >= 500))); } diff --git a/src/AzureAppConfigurationOptions.ts b/src/AzureAppConfigurationOptions.ts index f88ad67c..56b47b50 100644 --- a/src/AzureAppConfigurationOptions.ts +++ b/src/AzureAppConfigurationOptions.ts @@ -12,7 +12,7 @@ export const MaxRetryDelayInMs = 60000; export interface AzureAppConfigurationOptions { /** - * Specify what key-values to include in the configuration provider. + * Specifies what key-values to include in the configuration provider. * * @remarks * If no selectors are specified then all key-values with no label will be included. @@ -47,4 +47,20 @@ export interface AzureAppConfigurationOptions { * Specifies options used to configure feature flags. */ featureFlagOptions?: FeatureFlagOptions; + + /** + * Specifies whether to enable replica discovery or not. + * + * @remarks + * If not specified, the default value is true. + */ + replicaDiscoveryEnabled?: boolean; + + /** + * Specifies whether to enable load balance or not. + * + * @remarks + * If not specified, the default value is false. + */ + loadBalancingEnabled?: boolean; } diff --git a/src/ConfigurationClientManager.ts b/src/ConfigurationClientManager.ts new file mode 100644 index 00000000..7ad8e597 --- /dev/null +++ b/src/ConfigurationClientManager.ts @@ -0,0 +1,300 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { AppConfigurationClient, AppConfigurationClientOptions } from "@azure/app-configuration"; +import { ConfigurationClientWrapper } from "./ConfigurationClientWrapper.js"; +import { TokenCredential } from "@azure/identity"; +import { AzureAppConfigurationOptions, MaxRetries, MaxRetryDelayInMs } from "./AzureAppConfigurationOptions.js"; +import { isBrowser, isWebWorker } from "./requestTracing/utils.js"; +import * as RequestTracing from "./requestTracing/constants.js"; +import { shuffleList } from "./common/utils.js"; + +const TCP_ORIGIN_KEY_NAME = "_origin._tcp"; +const ALT_KEY_NAME = "_alt"; +const TCP_KEY_NAME = "_tcp"; +const ENDPOINT_KEY_NAME = "Endpoint"; +const ID_KEY_NAME = "Id"; +const SECRET_KEY_NAME = "Secret"; +const TRUSTED_DOMAIN_LABELS = [".azconfig.", ".appconfig."]; +const FALLBACK_CLIENT_REFRESH_EXPIRE_INTERVAL = 60 * 60 * 1000; // 1 hour in milliseconds +const MINIMAL_CLIENT_REFRESH_INTERVAL = 30 * 1000; // 30 seconds in milliseconds +const SRV_QUERY_TIMEOUT = 30 * 1000; // 30 seconds in milliseconds + +export class ConfigurationClientManager { + #isFailoverable: boolean; + #dns: any; + endpoint: URL; + #secret : string; + #id : string; + #credential: TokenCredential; + #clientOptions: AppConfigurationClientOptions | undefined; + #appConfigOptions: AzureAppConfigurationOptions | undefined; + #validDomain: string; + #staticClients: ConfigurationClientWrapper[]; // there should always be only one static client + #dynamicClients: ConfigurationClientWrapper[]; + #replicaCount: number = 0; + #lastFallbackClientRefreshTime: number = 0; + #lastFallbackClientRefreshAttempt: number = 0; + + constructor ( + connectionStringOrEndpoint?: string | URL, + credentialOrOptions?: TokenCredential | AzureAppConfigurationOptions, + appConfigOptions?: AzureAppConfigurationOptions + ) { + let staticClient: AppConfigurationClient; + const credentialPassed = instanceOfTokenCredential(credentialOrOptions); + + if (typeof connectionStringOrEndpoint === "string" && !credentialPassed) { + const connectionString = connectionStringOrEndpoint; + this.#appConfigOptions = credentialOrOptions as AzureAppConfigurationOptions; + this.#clientOptions = getClientOptions(this.#appConfigOptions); + const ConnectionStringRegex = /Endpoint=(.*);Id=(.*);Secret=(.*)/; + const regexMatch = connectionString.match(ConnectionStringRegex); + if (regexMatch) { + const endpointFromConnectionStr = regexMatch[1]; + this.endpoint = getValidUrl(endpointFromConnectionStr); + this.#id = regexMatch[2]; + this.#secret = regexMatch[3]; + } else { + throw new Error(`Invalid connection string. Valid connection strings should match the regex '${ConnectionStringRegex.source}'.`); + } + staticClient = new AppConfigurationClient(connectionString, this.#clientOptions); + } else if ((connectionStringOrEndpoint instanceof URL || typeof connectionStringOrEndpoint === "string") && credentialPassed) { + let endpoint = connectionStringOrEndpoint; + // ensure string is a valid URL. + if (typeof endpoint === "string") { + endpoint = getValidUrl(endpoint); + } + + const credential = credentialOrOptions as TokenCredential; + this.#appConfigOptions = appConfigOptions as AzureAppConfigurationOptions; + this.#clientOptions = getClientOptions(this.#appConfigOptions); + this.endpoint = endpoint; + this.#credential = credential; + staticClient = new AppConfigurationClient(this.endpoint.origin, this.#credential, this.#clientOptions); + } else { + throw new Error("A connection string or an endpoint with credential must be specified to create a client."); + } + + this.#staticClients = [new ConfigurationClientWrapper(this.endpoint.origin, staticClient)]; + this.#validDomain = getValidDomain(this.endpoint.hostname.toLowerCase()); + } + + async init() { + if (this.#appConfigOptions?.replicaDiscoveryEnabled === false || isBrowser() || isWebWorker()) { + this.#isFailoverable = false; + return; + } + if (this.#dns) { + return; + } + + try { + this.#dns = await import("dns/promises"); + } catch (error) { + this.#isFailoverable = false; + console.warn("Failed to load the dns module:", error.message); + return; + } + + this.#isFailoverable = true; + } + + getReplicaCount(): number { + return this.#replicaCount; + } + + async getClients(): Promise { + if (!this.#isFailoverable) { + return this.#staticClients; + } + + const currentTime = Date.now(); + // Filter static clients whose backoff time has ended + let availableClients = this.#staticClients.filter(client => client.backoffEndTime <= currentTime); + if (currentTime >= this.#lastFallbackClientRefreshAttempt + MINIMAL_CLIENT_REFRESH_INTERVAL && + (!this.#dynamicClients || + // All dynamic clients are in backoff means no client is available + this.#dynamicClients.every(client => currentTime < client.backoffEndTime) || + currentTime >= this.#lastFallbackClientRefreshTime + FALLBACK_CLIENT_REFRESH_EXPIRE_INTERVAL)) { + this.#lastFallbackClientRefreshAttempt = currentTime; + await this.#discoverFallbackClients(this.endpoint.hostname); + return availableClients.concat(this.#dynamicClients); + } + + // If there are dynamic clients, filter and concatenate them + if (this.#dynamicClients && this.#dynamicClients.length > 0) { + availableClients = availableClients.concat( + this.#dynamicClients + .filter(client => client.backoffEndTime <= currentTime)); + } + + return availableClients; + } + + async refreshClients() { + const currentTime = Date.now(); + if (this.#isFailoverable && + currentTime >= new Date(this.#lastFallbackClientRefreshAttempt + MINIMAL_CLIENT_REFRESH_INTERVAL).getTime()) { + this.#lastFallbackClientRefreshAttempt = currentTime; + await this.#discoverFallbackClients(this.endpoint.hostname); + } + } + + async #discoverFallbackClients(host: string) { + let result; + let timeout; + try { + result = await Promise.race([ + new Promise((_, reject) => timeout = setTimeout(() => reject(new Error("SRV record query timed out.")), SRV_QUERY_TIMEOUT)), + this.#querySrvTargetHost(host) + ]); + } catch (error) { + throw new Error(`Failed to build fallback clients, ${error.message}`); + } finally { + clearTimeout(timeout); + } + + const srvTargetHosts = shuffleList(result) as string[]; + const newDynamicClients: ConfigurationClientWrapper[] = []; + for (const host of srvTargetHosts) { + if (isValidEndpoint(host, this.#validDomain)) { + const targetEndpoint = `https://${host}`; + if (host.toLowerCase() === this.endpoint.hostname.toLowerCase()) { + continue; + } + const client = this.#credential ? + new AppConfigurationClient(targetEndpoint, this.#credential, this.#clientOptions) : + new AppConfigurationClient(buildConnectionString(targetEndpoint, this.#secret, this.#id), this.#clientOptions); + newDynamicClients.push(new ConfigurationClientWrapper(targetEndpoint, client)); + } + } + + this.#dynamicClients = newDynamicClients; + this.#lastFallbackClientRefreshTime = Date.now(); + this.#replicaCount = this.#dynamicClients.length; + } + + /** + * Query SRV records and return target hosts. + */ + async #querySrvTargetHost(host: string): Promise { + const results: string[] = []; + + try { + // Look up SRV records for the origin host + const originRecords = await this.#dns.resolveSrv(`${TCP_ORIGIN_KEY_NAME}.${host}`); + if (originRecords.length === 0) { + return results; + } + + // Add the first origin record to results + const originHost = originRecords[0].name; + results.push(originHost); + + // Look up SRV records for alternate hosts + let index = 0; + // eslint-disable-next-line no-constant-condition + while (true) { + const currentAlt = `${ALT_KEY_NAME}${index}`; + const altRecords = await this.#dns.resolveSrv(`${currentAlt}.${TCP_KEY_NAME}.${originHost}`); + if (altRecords.length === 0) { + break; // No more alternate records, exit loop + } + + altRecords.forEach(record => { + const altHost = record.name; + if (altHost) { + results.push(altHost); + } + }); + index++; + } + } catch (err) { + if (err.code === "ENOTFOUND") { + return results; // No more SRV records found, return results + } else { + throw new Error(`Failed to lookup SRV records: ${err.message}`); + } + } + + return results; + } +} + +/** + * Builds a connection string from the given endpoint, secret, and id. + * Returns an empty string if either secret or id is empty. + */ +function buildConnectionString(endpoint, secret, id: string): string { + if (!secret || !id) { + return ""; + } + + return `${ENDPOINT_KEY_NAME}=${endpoint};${ID_KEY_NAME}=${id};${SECRET_KEY_NAME}=${secret}`; +} + +/** + * Extracts a valid domain from the given endpoint URL based on trusted domain labels. + */ +export function getValidDomain(host: string): string { + for (const label of TRUSTED_DOMAIN_LABELS) { + const index = host.lastIndexOf(label); + if (index !== -1) { + return host.substring(index); + } + } + + return ""; +} + +/** + * Checks if the given host ends with the valid domain. + */ +export function isValidEndpoint(host: string, validDomain: string): boolean { + if (!validDomain) { + return false; + } + + return host.toLowerCase().endsWith(validDomain.toLowerCase()); +} + +function getClientOptions(options?: AzureAppConfigurationOptions): AppConfigurationClientOptions | undefined { + // user-agent + let userAgentPrefix = RequestTracing.USER_AGENT_PREFIX; // Default UA for JavaScript Provider + const userAgentOptions = options?.clientOptions?.userAgentOptions; + if (userAgentOptions?.userAgentPrefix) { + userAgentPrefix = `${userAgentOptions.userAgentPrefix} ${userAgentPrefix}`; // Prepend if UA prefix specified by user + } + + // retry options + const defaultRetryOptions = { + maxRetries: MaxRetries, + maxRetryDelayInMs: MaxRetryDelayInMs, + }; + const retryOptions = Object.assign({}, defaultRetryOptions, options?.clientOptions?.retryOptions); + + return Object.assign({}, options?.clientOptions, { + retryOptions, + userAgentOptions: { + userAgentPrefix + } + }); +} + +function getValidUrl(endpoint: string): URL { + try { + return new URL(endpoint); + } catch (error) { + if (error.code === "ERR_INVALID_URL") { + throw new Error("Invalid endpoint URL.", { cause: error }); + } else { + throw error; + } + } +} + +export function instanceOfTokenCredential(obj: unknown) { + return obj && typeof obj === "object" && "getToken" in obj && typeof obj.getToken === "function"; +} + diff --git a/src/ConfigurationClientWrapper.ts b/src/ConfigurationClientWrapper.ts new file mode 100644 index 00000000..7dd6f418 --- /dev/null +++ b/src/ConfigurationClientWrapper.ts @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { AppConfigurationClient } from "@azure/app-configuration"; + +const MaxBackoffDuration = 10 * 60 * 1000; // 10 minutes in milliseconds +const MinBackoffDuration = 30 * 1000; // 30 seconds in milliseconds +const MAX_SAFE_EXPONENTIAL = 30; // Used to avoid overflow. bitwise operations in JavaScript are limited to 32 bits. It overflows at 2^31 - 1. +const JITTER_RATIO = 0.25; + +export class ConfigurationClientWrapper { + endpoint: string; + client: AppConfigurationClient; + backoffEndTime: number = 0; // Timestamp + #failedAttempts: number = 0; + + constructor(endpoint: string, client: AppConfigurationClient) { + this.endpoint = endpoint; + this.client = client; + } + + updateBackoffStatus(successfull: boolean) { + if (successfull) { + this.#failedAttempts = 0; + this.backoffEndTime = Date.now(); + } else { + this.#failedAttempts += 1; + this.backoffEndTime = Date.now() + calculateBackoffDuration(this.#failedAttempts); + } + } +} + +export function calculateBackoffDuration(failedAttempts: number) { + if (failedAttempts <= 1) { + return MinBackoffDuration; + } + + // exponential: minBackoff * 2 ^ (failedAttempts - 1) + const exponential = Math.min(failedAttempts - 1, MAX_SAFE_EXPONENTIAL); + let calculatedBackoffDuration = MinBackoffDuration * (1 << exponential); + if (calculatedBackoffDuration > MaxBackoffDuration) { + calculatedBackoffDuration = MaxBackoffDuration; + } + + // jitter: random value between [-1, 1) * jitterRatio * calculatedBackoffMs + const jitter = JITTER_RATIO * (Math.random() * 2 - 1); + + return calculatedBackoffDuration * (1 + jitter); +} diff --git a/src/RefreshOptions.ts b/src/RefreshOptions.ts index 37425112..d5e4da5f 100644 --- a/src/RefreshOptions.ts +++ b/src/RefreshOptions.ts @@ -22,6 +22,9 @@ export interface RefreshOptions { /** * One or more configuration settings to be watched for changes on the server. * Any modifications to watched settings will refresh all settings loaded by the configuration provider when refresh() is called. + * + * @remarks + * If no watched setting is specified, all configuration settings will be watched. */ watchedSettings?: WatchedSetting[]; } diff --git a/src/common/utils.ts b/src/common/utils.ts new file mode 100644 index 00000000..8682484b --- /dev/null +++ b/src/common/utils.ts @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export function base64Helper(str: string): string { + const bytes = new TextEncoder().encode(str); // UTF-8 encoding + let chars = ""; + for (let i = 0; i < bytes.length; i++) { + chars += String.fromCharCode(bytes[i]); + } + return btoa(chars); +} + +export function jsonSorter(key, value) { + if (value === null) { + return null; + } + if (Array.isArray(value)) { + return value; + } + if (typeof value === "object") { + return Object.fromEntries(Object.entries(value).sort()); + } + return value; +} + +export function shuffleList(array: T[]): T[] { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + return array; +} diff --git a/src/featureManagement/FeatureFlagOptions.ts b/src/featureManagement/FeatureFlagOptions.ts index eedf9ec7..55ceda4d 100644 --- a/src/featureManagement/FeatureFlagOptions.ts +++ b/src/featureManagement/FeatureFlagOptions.ts @@ -10,16 +10,15 @@ import { SettingSelector } from "../types.js"; export interface FeatureFlagOptions { /** * Specifies whether feature flags will be loaded from Azure App Configuration. - */ enabled: boolean; /** - * Specifies the selectors used to filter feature flags. + * Specifies what feature flags to include in the configuration provider. * * @remarks * keyFilter of selector will be prefixed with "appconfig.featureflag/" when request is sent. - * If no selectors are specified then no feature flags will be retrieved. + * If no selectors are specified then all feature flags with no label will be included. */ selectors?: SettingSelector[]; diff --git a/src/featureManagement/constants.ts b/src/featureManagement/constants.ts index 67afa554..564bfbd9 100644 --- a/src/featureManagement/constants.ts +++ b/src/featureManagement/constants.ts @@ -3,14 +3,25 @@ export const FEATURE_MANAGEMENT_KEY_NAME = "feature_management"; export const FEATURE_FLAGS_KEY_NAME = "feature_flags"; -export const CONDITIONS_KEY_NAME = "conditions"; -export const CLIENT_FILTERS_KEY_NAME = "client_filters"; +export const NAME_KEY_NAME = "name"; export const TELEMETRY_KEY_NAME = "telemetry"; -export const VARIANTS_KEY_NAME = "variants"; +export const ENABLED_KEY_NAME = "enabled"; +export const METADATA_KEY_NAME = "metadata"; +export const ETAG_KEY_NAME = "ETag"; +export const FEATURE_FLAG_ID_KEY_NAME = "FeatureFlagId"; +export const FEATURE_FLAG_REFERENCE_KEY_NAME = "FeatureFlagReference"; export const ALLOCATION_KEY_NAME = "allocation"; +export const DEFAULT_WHEN_ENABLED_KEY_NAME = "default_when_enabled"; +export const PERCENTILE_KEY_NAME = "percentile"; +export const FROM_KEY_NAME = "from"; +export const TO_KEY_NAME = "to"; export const SEED_KEY_NAME = "seed"; -export const NAME_KEY_NAME = "name"; -export const ENABLED_KEY_NAME = "enabled"; +export const VARIANT_KEY_NAME = "variant"; +export const VARIANTS_KEY_NAME = "variants"; +export const CONFIGURATION_VALUE_KEY_NAME = "configuration_value"; +export const ALLOCATION_ID_KEY_NAME = "AllocationId"; +export const CONDITIONS_KEY_NAME = "conditions"; +export const CLIENT_FILTERS_KEY_NAME = "client_filters"; export const TIME_WINDOW_FILTER_NAMES = ["TimeWindow", "Microsoft.TimeWindow", "TimeWindowFilter", "Microsoft.TimeWindowFilter"]; export const TARGETING_FILTER_NAMES = ["Targeting", "Microsoft.Targeting", "TargetingFilter", "Microsoft.TargetingFilter"]; diff --git a/src/load.ts b/src/load.ts index cb78f4d3..4d24174e 100644 --- a/src/load.ts +++ b/src/load.ts @@ -1,12 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { AppConfigurationClient, AppConfigurationClientOptions } from "@azure/app-configuration"; import { TokenCredential } from "@azure/identity"; import { AzureAppConfiguration } from "./AzureAppConfiguration.js"; import { AzureAppConfigurationImpl } from "./AzureAppConfigurationImpl.js"; -import { AzureAppConfigurationOptions, MaxRetries, MaxRetryDelayInMs } from "./AzureAppConfigurationOptions.js"; -import * as RequestTracing from "./requestTracing/constants.js"; +import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js"; +import { ConfigurationClientManager, instanceOfTokenCredential } from "./ConfigurationClientManager.js"; const MIN_DELAY_FOR_UNHANDLED_ERROR: number = 5000; // 5 seconds @@ -31,39 +30,18 @@ export async function load( appConfigOptions?: AzureAppConfigurationOptions ): Promise { const startTimestamp = Date.now(); - let client: AppConfigurationClient; let options: AzureAppConfigurationOptions | undefined; + const clientManager = new ConfigurationClientManager(connectionStringOrEndpoint, credentialOrOptions, appConfigOptions); + await clientManager.init(); - // input validation - if (typeof connectionStringOrEndpoint === "string" && !instanceOfTokenCredential(credentialOrOptions)) { - const connectionString = connectionStringOrEndpoint; + if (!instanceOfTokenCredential(credentialOrOptions)) { options = credentialOrOptions as AzureAppConfigurationOptions; - const clientOptions = getClientOptions(options); - client = new AppConfigurationClient(connectionString, clientOptions); - } else if ((connectionStringOrEndpoint instanceof URL || typeof connectionStringOrEndpoint === "string") && instanceOfTokenCredential(credentialOrOptions)) { - let endpoint = connectionStringOrEndpoint; - // ensure string is a valid URL. - if (typeof endpoint === "string") { - try { - endpoint = new URL(endpoint); - } catch (error) { - if (error.code === "ERR_INVALID_URL") { - throw new Error("Invalid endpoint URL.", { cause: error }); - } else { - throw error; - } - } - } - const credential = credentialOrOptions as TokenCredential; - options = appConfigOptions; - const clientOptions = getClientOptions(options); - client = new AppConfigurationClient(endpoint.toString(), credential, clientOptions); } else { - throw new Error("A connection string or an endpoint with credential must be specified to create a client."); + options = appConfigOptions; } try { - const appConfiguration = new AzureAppConfigurationImpl(client, options); + const appConfiguration = new AzureAppConfigurationImpl(clientManager, options); await appConfiguration.load(); return appConfiguration; } catch (error) { @@ -77,30 +55,3 @@ export async function load( throw error; } } - -function instanceOfTokenCredential(obj: unknown) { - return obj && typeof obj === "object" && "getToken" in obj && typeof obj.getToken === "function"; -} - -function getClientOptions(options?: AzureAppConfigurationOptions): AppConfigurationClientOptions | undefined { - // user-agent - let userAgentPrefix = RequestTracing.USER_AGENT_PREFIX; // Default UA for JavaScript Provider - const userAgentOptions = options?.clientOptions?.userAgentOptions; - if (userAgentOptions?.userAgentPrefix) { - userAgentPrefix = `${userAgentOptions.userAgentPrefix} ${userAgentPrefix}`; // Prepend if UA prefix specified by user - } - - // retry options - const defaultRetryOptions = { - maxRetries: MaxRetries, - maxRetryDelayInMs: MaxRetryDelayInMs, - }; - const retryOptions = Object.assign({}, defaultRetryOptions, options?.clientOptions?.retryOptions); - - return Object.assign({}, options?.clientOptions, { - retryOptions, - userAgentOptions: { - userAgentPrefix - } - }); -} diff --git a/src/refresh/RefreshTimer.ts b/src/refresh/RefreshTimer.ts index ce485947..45fdf0b3 100644 --- a/src/refresh/RefreshTimer.ts +++ b/src/refresh/RefreshTimer.ts @@ -1,30 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -/** - * The backoff time is between the minimum and maximum backoff time, based on the number of attempts. - * An exponential backoff strategy is used, with a jitter factor to prevent clients from retrying at the same time. - * - * The backoff time is calculated as follows: - * - `basic backoff time` = `MinimumBackoffInMs` * 2 ^ `attempts`, and it is no larger than the `MaximumBackoffInMs`. - * - based on jitter ratio, the jittered time is between [-1, 1) * `JitterRatio` * basic backoff time. - * - the final backoff time is the basic backoff time plus the jittered time. - * - * Note: the backoff time usually is no larger than the refresh interval, which is specified by the user. - * - If the interval is less than the minimum backoff, the interval is used. - * - If the interval is between the minimum and maximum backoff, the interval is used as the maximum backoff. - * - Because of the jitter, the maximum backoff time is actually `MaximumBackoffInMs` * (1 + `JitterRatio`). - */ - -const MIN_BACKOFF_IN_MS = 30 * 1000; // 30s -const MAX_BACKOFF_IN_MS = 10 * 60 * 1000; // 10min -const MAX_SAFE_EXPONENTIAL = 30; // Used to avoid overflow. bitwise operations in JavaScript are limited to 32 bits. It overflows at 2^31 - 1. -const JITTER_RATIO = 0.25; - export class RefreshTimer { - #minBackoff: number = MIN_BACKOFF_IN_MS; - #maxBackoff: number = MAX_BACKOFF_IN_MS; - #failedAttempts: number = 0; #backoffEnd: number; // Timestamp #interval: number; @@ -43,43 +20,7 @@ export class RefreshTimer { return Date.now() >= this.#backoffEnd; } - backoff(): void { - this.#failedAttempts += 1; - this.#backoffEnd = Date.now() + this.#calculateBackoffTime(); - } - reset(): void { - this.#failedAttempts = 0; this.#backoffEnd = Date.now() + this.#interval; } - - #calculateBackoffTime(): number { - let minBackoffMs: number; - let maxBackoffMs: number; - if (this.#interval <= this.#minBackoff) { - return this.#interval; - } - - // _minBackoff <= _interval - if (this.#interval <= this.#maxBackoff) { - minBackoffMs = this.#minBackoff; - maxBackoffMs = this.#interval; - } else { - minBackoffMs = this.#minBackoff; - maxBackoffMs = this.#maxBackoff; - } - - // exponential: minBackoffMs * 2^(failedAttempts-1) - const exponential = Math.min(this.#failedAttempts - 1, MAX_SAFE_EXPONENTIAL); - let calculatedBackoffMs = minBackoffMs * (1 << exponential); - if (calculatedBackoffMs > maxBackoffMs) { - calculatedBackoffMs = maxBackoffMs; - } - - // jitter: random value between [-1, 1) * jitterRatio * calculatedBackoffMs - const jitter = JITTER_RATIO * (Math.random() * 2 - 1); - - return calculatedBackoffMs * (1 + jitter); - } - } diff --git a/src/requestTracing/constants.ts b/src/requestTracing/constants.ts index f32996b9..74ca58bb 100644 --- a/src/requestTracing/constants.ts +++ b/src/requestTracing/constants.ts @@ -44,8 +44,20 @@ export enum RequestType { WATCH = "Watch" } +// Replica count +export const REPLICA_COUNT_KEY = "ReplicaCount"; + // Tag names export const KEY_VAULT_CONFIGURED_TAG = "UsesKeyVault"; +export const FAILOVER_REQUEST_TAG = "Failover"; + +// Compact feature tags +export const FEATURES_KEY = "Features"; +export const LOAD_BALANCE_CONFIGURED_TAG = "LB"; + +// Feature management package +export const FM_PACKAGE_NAME = "@microsoft/feature-management"; +export const FM_VERSION_KEY = "FMJsVer"; // Feature flag usage tracing export const FEATURE_FILTER_TYPE_KEY = "Filter"; diff --git a/src/requestTracing/utils.ts b/src/requestTracing/utils.ts index e9d0b0df..2e8b1124 100644 --- a/src/requestTracing/utils.ts +++ b/src/requestTracing/utils.ts @@ -23,27 +23,35 @@ import { REQUEST_TYPE_KEY, RequestType, SERVICE_FABRIC_ENV_VAR, - CORRELATION_CONTEXT_HEADER_NAME + CORRELATION_CONTEXT_HEADER_NAME, + REPLICA_COUNT_KEY, + FAILOVER_REQUEST_TAG, + FEATURES_KEY, + LOAD_BALANCE_CONFIGURED_TAG, + FM_VERSION_KEY } from "./constants"; +export interface RequestTracingOptions { + enabled: boolean; + appConfigOptions: AzureAppConfigurationOptions | undefined; + initialLoadCompleted: boolean; + replicaCount: number; + isFailoverRequest: boolean; + featureFlagTracing: FeatureFlagTracingOptions | undefined; + fmVersion: string | undefined; +} + // Utils export function listConfigurationSettingsWithTrace( - requestTracingOptions: { - requestTracingEnabled: boolean; - initialLoadCompleted: boolean; - appConfigOptions: AzureAppConfigurationOptions | undefined; - featureFlagTracingOptions: FeatureFlagTracingOptions | undefined; - }, + requestTracingOptions: RequestTracingOptions, client: AppConfigurationClient, listOptions: ListConfigurationSettingsOptions ) { - const { requestTracingEnabled, initialLoadCompleted, appConfigOptions, featureFlagTracingOptions } = requestTracingOptions; - const actualListOptions = { ...listOptions }; - if (requestTracingEnabled) { + if (requestTracingOptions.enabled) { actualListOptions.requestOptions = { customHeaders: { - [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(appConfigOptions, featureFlagTracingOptions, initialLoadCompleted) + [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(requestTracingOptions) } }; } @@ -52,23 +60,17 @@ export function listConfigurationSettingsWithTrace( } export function getConfigurationSettingWithTrace( - requestTracingOptions: { - requestTracingEnabled: boolean; - initialLoadCompleted: boolean; - appConfigOptions: AzureAppConfigurationOptions | undefined; - featureFlagTracingOptions: FeatureFlagTracingOptions | undefined; - }, + requestTracingOptions: RequestTracingOptions, client: AppConfigurationClient, configurationSettingId: ConfigurationSettingId, getOptions?: GetConfigurationSettingOptions, ) { - const { requestTracingEnabled, initialLoadCompleted, appConfigOptions, featureFlagTracingOptions } = requestTracingOptions; const actualGetOptions = { ...getOptions }; - if (requestTracingEnabled) { + if (requestTracingOptions.enabled) { actualGetOptions.requestOptions = { customHeaders: { - [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(appConfigOptions, featureFlagTracingOptions, initialLoadCompleted) + [CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(requestTracingOptions) } }; } @@ -76,18 +78,35 @@ export function getConfigurationSettingWithTrace( return client.getConfigurationSetting(configurationSettingId, actualGetOptions); } -export function createCorrelationContextHeader(options: AzureAppConfigurationOptions | undefined, featureFlagTracing: FeatureFlagTracingOptions | undefined, isInitialLoadCompleted: boolean): string { +export function createCorrelationContextHeader(requestTracingOptions: RequestTracingOptions): string { /* RequestType: 'Startup' during application starting up, 'Watch' after startup completed. Host: identify with defined envs - Env: identify by env `NODE_ENV` which is a popular but not standard.usually the value can be "development", "production". + Env: identify by env `NODE_ENV` which is a popular but not standard. Usually, the value can be "development", "production". + ReplicaCount: identify how many replicas are found + Features: LB + Filter: CSTM+TIME+TRGT + MaxVariants: identify the max number of variants feature flag uses + FFFeatures: Seed+Telemetry UsersKeyVault + Failover */ const keyValues = new Map(); - keyValues.set(REQUEST_TYPE_KEY, isInitialLoadCompleted ? RequestType.WATCH : RequestType.STARTUP); + const tags: string[] = []; + + keyValues.set(REQUEST_TYPE_KEY, requestTracingOptions.initialLoadCompleted ? RequestType.WATCH : RequestType.STARTUP); keyValues.set(HOST_TYPE_KEY, getHostType()); keyValues.set(ENV_KEY, isDevEnvironment() ? DEV_ENV_VAL : undefined); + const appConfigOptions = requestTracingOptions.appConfigOptions; + if (appConfigOptions?.keyVaultOptions) { + const { credential, secretClients, secretResolver } = appConfigOptions.keyVaultOptions; + if (credential !== undefined || secretClients?.length || secretResolver !== undefined) { + tags.push(KEY_VAULT_CONFIGURED_TAG); + } + } + + const featureFlagTracing = requestTracingOptions.featureFlagTracing; if (featureFlagTracing) { keyValues.set(FEATURE_FILTER_TYPE_KEY, featureFlagTracing.usesAnyFeatureFilter() ? featureFlagTracing.createFeatureFiltersString() : undefined); keyValues.set(FF_FEATURES_KEY, featureFlagTracing.usesAnyTracingFeature() ? featureFlagTracing.createFeaturesString() : undefined); @@ -96,12 +115,19 @@ export function createCorrelationContextHeader(options: AzureAppConfigurationOpt } } - const tags: string[] = []; - if (options?.keyVaultOptions) { - const { credential, secretClients, secretResolver } = options.keyVaultOptions; - if (credential !== undefined || secretClients?.length || secretResolver !== undefined) { - tags.push(KEY_VAULT_CONFIGURED_TAG); - } + if (requestTracingOptions.isFailoverRequest) { + tags.push(FAILOVER_REQUEST_TAG); + } + if (requestTracingOptions.replicaCount > 0) { + keyValues.set(REPLICA_COUNT_KEY, requestTracingOptions.replicaCount.toString()); + } + if (requestTracingOptions.fmVersion) { + keyValues.set(FM_VERSION_KEY, requestTracingOptions.fmVersion); + } + + // Compact tags: Features=LB+... + if (appConfigOptions?.loadBalancingEnabled) { + keyValues.set(FEATURES_KEY, LOAD_BALANCE_CONFIGURED_TAG); } const contextParts: string[] = []; @@ -160,7 +186,7 @@ function isDevEnvironment(): boolean { return false; } -function isBrowser() { +export function isBrowser() { // https://developer.mozilla.org/en-US/docs/Web/API/Window const isWindowDefinedAsExpected = typeof window === "object" && typeof Window === "function" && window instanceof Window; // https://developer.mozilla.org/en-US/docs/Web/API/Document @@ -169,7 +195,7 @@ function isBrowser() { return isWindowDefinedAsExpected && isDocumentDefinedAsExpected; } -function isWebWorker() { +export function isWebWorker() { // https://developer.mozilla.org/en-US/docs/Web/API/WorkerGlobalScope const workerGlobalScopeDefined = typeof WorkerGlobalScope !== "undefined"; // https://developer.mozilla.org/en-US/docs/Web/API/WorkerNavigator @@ -179,3 +205,4 @@ function isWebWorker() { return workerGlobalScopeDefined && importScriptsAsGlobalFunction && isNavigatorDefinedAsExpected; } + diff --git a/src/version.ts b/src/version.ts index 91c099f6..bb9c7aa8 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,4 +1,4 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -export const VERSION = "1.1.3"; +export const VERSION = "2.0.0-preview.2"; diff --git a/test/clientOptions.test.ts b/test/clientOptions.test.ts index 62e1b21c..2e9417e9 100644 --- a/test/clientOptions.test.ts +++ b/test/clientOptions.test.ts @@ -6,7 +6,7 @@ import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; import { load } from "./exportedApi.js"; -import { createMockedConnectionString } from "./utils/testHelper.js"; +import { MAX_TIME_OUT, createMockedConnectionString } from "./utils/testHelper.js"; import * as nock from "nock"; class HttpRequestCountPolicy { @@ -27,7 +27,7 @@ class HttpRequestCountPolicy { } describe("custom client options", function () { - this.timeout(15000); + this.timeout(MAX_TIME_OUT); const fakeEndpoint = "https://azure.azconfig.io"; beforeEach(() => { diff --git a/test/failover.test.ts b/test/failover.test.ts new file mode 100644 index 00000000..e1f2f043 --- /dev/null +++ b/test/failover.test.ts @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; +chai.use(chaiAsPromised); +const expect = chai.expect; +import { load } from "./exportedApi"; +import { MAX_TIME_OUT, createMockedConnectionString, createMockedFeatureFlag, createMockedKeyValue, mockConfigurationManagerGetClients, restoreMocks } from "./utils/testHelper"; +import { getValidDomain, isValidEndpoint } from "../src/ConfigurationClientManager"; + +const mockedKVs = [{ + key: "app.settings.fontColor", + value: "red", +}, { + key: "app.settings.fontSize", + value: "40", +}].map(createMockedKeyValue); + +const mockedFeatureFlags = [{ + key: "app.settings.fontColor", + value: "red", +}].map(createMockedKeyValue).concat([ + createMockedFeatureFlag("Beta", { enabled: true }), + createMockedFeatureFlag("Alpha_1", { enabled: true }), + createMockedFeatureFlag("Alpha_2", { enabled: false }), +]); + +describe("failover", function () { + this.timeout(MAX_TIME_OUT); + + afterEach(() => { + restoreMocks(); + }); + + it("should failover to replica and load key values from config store", async () => { + const isFailoverable = true; + mockConfigurationManagerGetClients([], isFailoverable, mockedKVs); + + const connectionString = createMockedConnectionString(); + // replicaDiscoveryEnabled is default to true + const settings = await load(connectionString); + expect(settings).not.undefined; + expect(settings.get("app.settings.fontColor")).eq("red"); + expect(settings.get("app.settings.fontSize")).eq("40"); + }); + + it("should failover to replica and load feature flags from config store", async () => { + const isFailoverable = true; + mockConfigurationManagerGetClients([], isFailoverable, mockedFeatureFlags); + + const connectionString = createMockedConnectionString(); + // replicaDiscoveryEnabled is default to true + const settings = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [{ + keyFilter: "*" + }] + } + }); + expect(settings).not.undefined; + expect(settings.get("feature_management")).not.undefined; + expect(settings.get("feature_management").feature_flags).not.undefined; + }); + + it("should throw error when all clients failed", async () => { + const isFailoverable = false; + mockConfigurationManagerGetClients([], isFailoverable); + + const connectionString = createMockedConnectionString(); + return expect(load(connectionString)).eventually.rejectedWith("All clients failed to get configuration settings."); + }); + + it("should validate endpoint", () => { + const fakeHost = "fake.azconfig.io"; + const validDomain = getValidDomain(fakeHost); + + expect(isValidEndpoint("azure.azconfig.io", validDomain)).to.be.true; + expect(isValidEndpoint("azure.privatelink.azconfig.io", validDomain)).to.be.true; + expect(isValidEndpoint("azure-replica.azconfig.io", validDomain)).to.be.true; + expect(isValidEndpoint("azure.badazconfig.io", validDomain)).to.be.false; + expect(isValidEndpoint("azure.azconfigbad.io", validDomain)).to.be.false; + expect(isValidEndpoint("azure.appconfig.azure.com", validDomain)).to.be.false; + expect(isValidEndpoint("azure.azconfig.bad.io", validDomain)).to.be.false; + + const fakeHost2 = "foobar.appconfig.azure.com"; + const validDomain2 = getValidDomain(fakeHost2); + + expect(isValidEndpoint("azure.appconfig.azure.com", validDomain2)).to.be.true; + expect(isValidEndpoint("azure.z1.appconfig.azure.com", validDomain2)).to.be.true; + expect(isValidEndpoint("azure-replia.z1.appconfig.azure.com", validDomain2)).to.be.true; // Note: Typo "azure-replia" + expect(isValidEndpoint("azure.privatelink.appconfig.azure.com", validDomain2)).to.be.true; + expect(isValidEndpoint("azconfig.appconfig.azure.com", validDomain2)).to.be.true; + expect(isValidEndpoint("azure.azconfig.io", validDomain2)).to.be.false; + expect(isValidEndpoint("azure.badappconfig.azure.com", validDomain2)).to.be.false; + expect(isValidEndpoint("azure.appconfigbad.azure.com", validDomain2)).to.be.false; + + const fakeHost3 = "foobar.azconfig-test.io"; + const validDomain3 = getValidDomain(fakeHost3); + + expect(isValidEndpoint("azure.azconfig-test.io", validDomain3)).to.be.false; + expect(isValidEndpoint("azure.azconfig.io", validDomain3)).to.be.false; + + const fakeHost4 = "foobar.z1.appconfig-test.azure.com"; + const validDomain4 = getValidDomain(fakeHost4); + + expect(isValidEndpoint("foobar.z2.appconfig-test.azure.com", validDomain4)).to.be.false; + expect(isValidEndpoint("foobar.appconfig-test.azure.com", validDomain4)).to.be.false; + expect(isValidEndpoint("foobar.appconfig.azure.com", validDomain4)).to.be.false; + }); +}); diff --git a/test/featureFlag.test.ts b/test/featureFlag.test.ts index 897efe97..2d6a7e02 100644 --- a/test/featureFlag.test.ts +++ b/test/featureFlag.test.ts @@ -3,8 +3,9 @@ import * as chai from "chai"; import * as chaiAsPromised from "chai-as-promised"; +import { featureFlagContentType } from "@azure/app-configuration"; import { load } from "./exportedApi.js"; -import { createMockedConnectionString, createMockedFeatureFlag, createMockedKeyValue, mockAppConfigurationClientListConfigurationSettings, restoreMocks } from "./utils/testHelper.js"; +import { MAX_TIME_OUT, createMockedConnectionString, createMockedEndpoint, createMockedFeatureFlag, createMockedKeyValue, mockAppConfigurationClientListConfigurationSettings, restoreMocks } from "./utils/testHelper.js"; chai.use(chaiAsPromised); const expect = chai.expect; @@ -49,15 +50,157 @@ const mockedKVs = [{ }, { key: ".appconfig.featureflag/variant", value: sampleVariantValue, - contentType: "application/vnd.microsoft.appconfig.ff+json;charset=utf-8", + contentType: featureFlagContentType, }].map(createMockedKeyValue).concat([ - createMockedFeatureFlag("Beta", { enabled: true }), + createMockedFeatureFlag("FlagWithTestLabel", { enabled: true }, {label: "Test"}), createMockedFeatureFlag("Alpha_1", { enabled: true }), createMockedFeatureFlag("Alpha_2", { enabled: false }), + createMockedFeatureFlag("Telemetry_1", { enabled: true, telemetry: { enabled: true } }, { etag: "ETag"}), + createMockedFeatureFlag("Telemetry_2", { enabled: true, telemetry: { enabled: true } }, { etag: "ETag", label: "Test"}), + createMockedFeatureFlag("NoPercentileAndSeed", { + enabled: true, + telemetry: { enabled: true }, + variants: [ { name: "Control" }, { name: "Test" } ], + allocation: { + default_when_disabled: "Control", + user: [ {users: ["Jeff"], variant: "Test"} ] + } + }), + createMockedFeatureFlag("SeedOnly", { + enabled: true, + telemetry: { enabled: true }, + variants: [ { name: "Control" }, { name: "Test" } ], + allocation: { + default_when_disabled: "Control", + user: [ {users: ["Jeff"], variant: "Test"} ], + seed: "123" + } + }), + createMockedFeatureFlag("DefaultWhenEnabledOnly", { + enabled: true, + telemetry: { enabled: true }, + variants: [ { name: "Control" }, { name: "Test" } ], + allocation: { + default_when_enabled: "Control" + } + }), + createMockedFeatureFlag("PercentileOnly", { + enabled: true, + telemetry: { enabled: true }, + variants: [ ], + allocation: { + percentile: [ { from: 0, to: 50, variant: "Control" }, { from: 50, to: 100, variant: "Test" } ] + } + }), + createMockedFeatureFlag("SimpleConfigurationValue", { + enabled: true, + telemetry: { enabled: true }, + variants: [ { name: "Control", configuration_value: "standard" }, { name: "Test", configuration_value: "special" } ], + allocation: { + default_when_enabled: "Control", + percentile: [ { from: 0, to: 50, variant: "Control" }, { from: 50, to: 100, variant: "Test" } ], + seed: "123" + } + }), + createMockedFeatureFlag("ComplexConfigurationValue", { + enabled: true, + telemetry: { enabled: true }, + variants: [ { name: "Control", configuration_value: { title: { size: 100, color: "red" }, options: [ 1, 2, 3 ]} }, { name: "Test", configuration_value: { title: { size: 200, color: "blue" }, options: [ "1", "2", "3" ]} } ], + allocation: { + default_when_enabled: "Control", + percentile: [ { from: 0, to: 50, variant: "Control" }, { from: 50, to: 100, variant: "Test" } ], + seed: "123" + } + }), + createMockedFeatureFlag("TelemetryVariantPercentile", { + enabled: true, + telemetry: { enabled: true }, + variants: [ + { + name: "True_Override", + configuration_value: { + someOtherKey: { + someSubKey: "someSubValue" + }, + someKey4: [3, 1, 4, true], + someKey: "someValue", + someKey3: 3.14, + someKey2: 3 + } + } + ], + allocation: { + default_when_enabled: "True_Override", + percentile: [ + { + variant: "True_Override", + from: 0, + to: 100 + } + ] + } + }), + createMockedFeatureFlag("Complete", { + enabled: true, + telemetry: { enabled: true }, + variants: [ + { + name: "Large", + configuration_value: 100 + }, + { + name: "Medium", + configuration_value: 50 + }, + { + name: "Small", + configuration_value: 10 + } + ], + allocation: { + percentile: [ + { + variant: "Large", + from: 0, + to: 25 + }, + { + variant: "Medium", + from: 25, + to: 55 + }, + { + variant: "Small", + from: 55, + to: 95 + }, + { + variant: "Large", + from: 95, + to: 100 + } + ], + group: [ + { + variant: "Large", + groups: ["beta"] + } + ], + user: [ + { + variant: "Small", + users: ["Richel"] + } + ], + seed: "test-seed", + default_when_enabled: "Medium", + default_when_disabled: "Medium" + } + }) ]); describe("feature flags", function () { - this.timeout(10000); + this.timeout(MAX_TIME_OUT); before(() => { mockAppConfigurationClientListConfigurationSettings([mockedKVs]); @@ -71,15 +214,22 @@ describe("feature flags", function () { const connectionString = createMockedConnectionString(); const settings = await load(connectionString, { featureFlagOptions: { - enabled: true, - selectors: [{ - keyFilter: "*" - }] + enabled: true } }); expect(settings).not.undefined; expect(settings.get("feature_management")).not.undefined; expect(settings.get("feature_management").feature_flags).not.undefined; + // it should only load feature flags with no label by default + expect((settings.get("feature_management").feature_flags as any[]).find(ff => ff.id === "FlagWithTestLabel")).to.be.undefined; + + const settings2 = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [ { keyFilter: "*", labelFilter: "Test" } ] + } + }); + expect((settings2.get("feature_management").feature_flags as any[]).find(ff => ff.id === "FlagWithTestLabel")).not.undefined; }); it("should not load feature flags if disabled", async () => { @@ -100,15 +250,6 @@ describe("feature flags", function () { expect(settings.get("feature_management")).undefined; }); - it("should throw error if selectors not specified", async () => { - const connectionString = createMockedConnectionString(); - return expect(load(connectionString, { - featureFlagOptions: { - enabled: true - } - })).eventually.rejectedWith("Feature flag selectors must be provided."); - }); - it("should load feature flags with custom selector", async () => { const connectionString = createMockedConnectionString(); const settings = await load(connectionString, { @@ -158,4 +299,104 @@ describe("feature flags", function () { expect(variant.telemetry).not.undefined; }); + it("should populate telemetry metadata", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [ + { + keyFilter: "Telemetry_1" + }, + { + keyFilter: "Telemetry_2", + labelFilter: "Test" + } + ] + } + }); + expect(settings).not.undefined; + expect(settings.get("feature_management")).not.undefined; + const featureFlags = settings.get("feature_management").feature_flags; + expect(featureFlags).not.undefined; + expect((featureFlags as []).length).equals(2); + + let featureFlag = featureFlags[0]; + expect(featureFlag).not.undefined; + expect(featureFlag.id).equals("Telemetry_1"); + expect(featureFlag.telemetry).not.undefined; + expect(featureFlag.telemetry.enabled).equals(true); + expect(featureFlag.telemetry.metadata.ETag).equals("ETag"); + expect(featureFlag.telemetry.metadata.FeatureFlagId).equals("krkOsu9dVV9huwbQDPR6gkV_2T0buWxOCS-nNsj5-6g"); + expect(featureFlag.telemetry.metadata.FeatureFlagReference).equals(`${createMockedEndpoint()}/kv/.appconfig.featureflag/Telemetry_1`); + + featureFlag = featureFlags[1]; + expect(featureFlag).not.undefined; + expect(featureFlag.id).equals("Telemetry_2"); + expect(featureFlag.telemetry).not.undefined; + expect(featureFlag.telemetry.enabled).equals(true); + expect(featureFlag.telemetry.metadata.ETag).equals("ETag"); + expect(featureFlag.telemetry.metadata.FeatureFlagId).equals("Rc8Am7HIGDT7HC5Ovs3wKN_aGaaK_Uz1mH2e11gaK0o"); + expect(featureFlag.telemetry.metadata.FeatureFlagReference).equals(`${createMockedEndpoint()}/kv/.appconfig.featureflag/Telemetry_2?label=Test`); + }); + + it("should not populate allocation id", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [ { keyFilter: "*" } ] + } + }); + expect(settings).not.undefined; + expect(settings.get("feature_management")).not.undefined; + const featureFlags = settings.get("feature_management").feature_flags; + expect(featureFlags).not.undefined; + + const NoPercentileAndSeed = (featureFlags as any[]).find(item => item.id === "NoPercentileAndSeed"); + expect(NoPercentileAndSeed).not.undefined; + expect(NoPercentileAndSeed?.telemetry.metadata.AllocationId).to.be.undefined; + }); + + it("should populate allocation id", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [ { keyFilter: "*" } ] + } + }); + expect(settings).not.undefined; + expect(settings.get("feature_management")).not.undefined; + const featureFlags = settings.get("feature_management").feature_flags; + expect(featureFlags).not.undefined; + + const SeedOnly = (featureFlags as any[]).find(item => item.id === "SeedOnly"); + expect(SeedOnly).not.undefined; + expect(SeedOnly?.telemetry.metadata.AllocationId).equals("qZApcKdfXscxpgn_8CMf"); + + const DefaultWhenEnabledOnly = (featureFlags as any[]).find(item => item.id === "DefaultWhenEnabledOnly"); + expect(DefaultWhenEnabledOnly).not.undefined; + expect(DefaultWhenEnabledOnly?.telemetry.metadata.AllocationId).equals("k486zJjud_HkKaL1C4qB"); + + const PercentileOnly = (featureFlags as any[]).find(item => item.id === "PercentileOnly"); + expect(PercentileOnly).not.undefined; + expect(PercentileOnly?.telemetry.metadata.AllocationId).equals("5YUbmP0P5s47zagO_LvI"); + + const SimpleConfigurationValue = (featureFlags as any[]).find(item => item.id === "SimpleConfigurationValue"); + expect(SimpleConfigurationValue).not.undefined; + expect(SimpleConfigurationValue?.telemetry.metadata.AllocationId).equals("QIOEOTQJr2AXo4dkFFqy"); + + const ComplexConfigurationValue = (featureFlags as any[]).find(item => item.id === "ComplexConfigurationValue"); + expect(ComplexConfigurationValue).not.undefined; + expect(ComplexConfigurationValue?.telemetry.metadata.AllocationId).equals("4Bes0AlwuO8kYX-YkBWs"); + + const TelemetryVariantPercentile = (featureFlags as any[]).find(item => item.id === "TelemetryVariantPercentile"); + expect(TelemetryVariantPercentile).not.undefined; + expect(TelemetryVariantPercentile?.telemetry.metadata.AllocationId).equals("YsdJ4pQpmhYa8KEhRLUn"); + + const Complete = (featureFlags as any[]).find(item => item.id === "Complete"); + expect(Complete).not.undefined; + expect(Complete?.telemetry.metadata.AllocationId).equals("DER2rF-ZYog95c4CBZoi"); + }); }); diff --git a/test/json.test.ts b/test/json.test.ts index 47a3f670..cb937bd9 100644 --- a/test/json.test.ts +++ b/test/json.test.ts @@ -6,12 +6,14 @@ import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; import { load } from "./exportedApi.js"; -import { mockAppConfigurationClientListConfigurationSettings, restoreMocks, createMockedConnectionString, createMockedKeyVaultReference, createMockedJsonKeyValue } from "./utils/testHelper.js"; +import { MAX_TIME_OUT, mockAppConfigurationClientListConfigurationSettings, restoreMocks, createMockedConnectionString, createMockedKeyVaultReference, createMockedJsonKeyValue } from "./utils/testHelper.js"; const jsonKeyValue = createMockedJsonKeyValue("json.settings.logging", '{"Test":{"Level":"Debug"},"Prod":{"Level":"Warning"}}'); const keyVaultKeyValue = createMockedKeyVaultReference("TestKey", "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName"); describe("json", function () { + this.timeout(MAX_TIME_OUT); + beforeEach(() => { }); diff --git a/test/keyvault.test.ts b/test/keyvault.test.ts index 2877243b..e88044ea 100644 --- a/test/keyvault.test.ts +++ b/test/keyvault.test.ts @@ -6,7 +6,7 @@ import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; import { load } from "./exportedApi.js"; -import { sinon, createMockedConnectionString, createMockedTokenCredential, mockAppConfigurationClientListConfigurationSettings, mockSecretClientGetSecret, restoreMocks, createMockedKeyVaultReference } from "./utils/testHelper.js"; +import { MAX_TIME_OUT, sinon, createMockedConnectionString, createMockedTokenCredential, mockAppConfigurationClientListConfigurationSettings, mockSecretClientGetSecret, restoreMocks, createMockedKeyVaultReference } from "./utils/testHelper.js"; import { KeyVaultSecret, SecretClient } from "@azure/keyvault-secrets"; const mockedData = [ @@ -27,7 +27,7 @@ function mockNewlyCreatedKeyVaultSecretClients() { mockSecretClientGetSecret(mockedData.map(([_key, secretUri, value]) => [secretUri, value])); } describe("key vault reference", function () { - this.timeout(10000); + this.timeout(MAX_TIME_OUT); beforeEach(() => { mockAppConfigurationClient(); diff --git a/test/load.test.ts b/test/load.test.ts index 6d2c94b8..d36a3311 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -6,7 +6,7 @@ import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; import { load } from "./exportedApi.js"; -import { mockAppConfigurationClientListConfigurationSettings, restoreMocks, createMockedConnectionString, createMockedEndpoint, createMockedTokenCredential, createMockedKeyValue } from "./utils/testHelper.js"; +import { MAX_TIME_OUT, mockAppConfigurationClientListConfigurationSettings, restoreMocks, createMockedConnectionString, createMockedEndpoint, createMockedTokenCredential, createMockedKeyValue } from "./utils/testHelper.js"; const mockedKVs = [{ key: "app.settings.fontColor", @@ -77,7 +77,7 @@ const mockedKVs = [{ ].map(createMockedKeyValue); describe("load", function () { - this.timeout(10000); + this.timeout(MAX_TIME_OUT); before(() => { mockAppConfigurationClientListConfigurationSettings([mockedKVs]); diff --git a/test/loadBalance.test.ts b/test/loadBalance.test.ts new file mode 100644 index 00000000..59bdf0f8 --- /dev/null +++ b/test/loadBalance.test.ts @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; +chai.use(chaiAsPromised); +const expect = chai.expect; +import { load } from "./exportedApi.js"; +import { MAX_TIME_OUT, restoreMocks, createMockedConnectionString, sleepInMs, createMockedEndpoint, mockConfigurationManagerGetClients, mockAppConfigurationClientLoadBalanceMode } from "./utils/testHelper.js"; +import { AppConfigurationClient } from "@azure/app-configuration"; +import { ConfigurationClientWrapper } from "../src/ConfigurationClientWrapper.js"; + +const fakeEndpoint_1 = createMockedEndpoint("fake_1"); +const fakeEndpoint_2 = createMockedEndpoint("fake_2"); +const fakeClientWrapper_1 = new ConfigurationClientWrapper(fakeEndpoint_1, new AppConfigurationClient(createMockedConnectionString(fakeEndpoint_1))); +const fakeClientWrapper_2 = new ConfigurationClientWrapper(fakeEndpoint_2, new AppConfigurationClient(createMockedConnectionString(fakeEndpoint_2))); +const clientRequestCounter_1 = {count: 0}; +const clientRequestCounter_2 = {count: 0}; + +describe("load balance", function () { + this.timeout(MAX_TIME_OUT); + + beforeEach(() => { + }); + + afterEach(() => { + restoreMocks(); + }); + + it("should load balance the request when loadBalancingEnabled", async () => { + mockConfigurationManagerGetClients([fakeClientWrapper_1, fakeClientWrapper_2], false); + mockAppConfigurationClientLoadBalanceMode(fakeClientWrapper_1, clientRequestCounter_1); + mockAppConfigurationClientLoadBalanceMode(fakeClientWrapper_2, clientRequestCounter_2); + + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + loadBalancingEnabled: true, + featureFlagOptions: { + enabled: true, + selectors: [{ + keyFilter: "*" + }], + refresh: { + enabled: true, + refreshIntervalInMs: 2000 // 2 seconds for quick test. + } + } + }); + // one request for key values, one request for feature flags + expect(clientRequestCounter_1.count).eq(1); + expect(clientRequestCounter_2.count).eq(1); + + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + // refresh request for feature flags + expect(clientRequestCounter_1.count).eq(2); + expect(clientRequestCounter_2.count).eq(1); + + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + expect(clientRequestCounter_1.count).eq(2); + expect(clientRequestCounter_2.count).eq(2); + }); + + it("should not load balance the request when loadBalance disabled", async () => { + clientRequestCounter_1.count = 0; + clientRequestCounter_2.count = 0; + mockConfigurationManagerGetClients([fakeClientWrapper_1, fakeClientWrapper_2], false); + mockAppConfigurationClientLoadBalanceMode(fakeClientWrapper_1, clientRequestCounter_1); + mockAppConfigurationClientLoadBalanceMode(fakeClientWrapper_2, clientRequestCounter_2); + + const connectionString = createMockedConnectionString(); + // loadBalancingEnabled is default to false + const settings = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [{ + keyFilter: "*" + }], + refresh: { + enabled: true, + refreshIntervalInMs: 2000 // 2 seconds for quick test. + } + } + }); + // one request for key values, one request for feature flags + expect(clientRequestCounter_1.count).eq(2); + expect(clientRequestCounter_2.count).eq(0); + + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + // refresh request for feature flags + expect(clientRequestCounter_1.count).eq(3); + expect(clientRequestCounter_2.count).eq(0); + }); +}); diff --git a/test/refresh.test.ts b/test/refresh.test.ts index 5fbeb973..6457cb1a 100644 --- a/test/refresh.test.ts +++ b/test/refresh.test.ts @@ -6,7 +6,7 @@ import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; import { load } from "./exportedApi.js"; -import { mockAppConfigurationClientListConfigurationSettings, mockAppConfigurationClientGetConfigurationSetting, restoreMocks, createMockedConnectionString, createMockedKeyValue, sleepInMs, createMockedFeatureFlag } from "./utils/testHelper.js"; +import { MAX_TIME_OUT, mockAppConfigurationClientListConfigurationSettings, mockAppConfigurationClientGetConfigurationSetting, restoreMocks, createMockedConnectionString, createMockedKeyValue, sleepInMs, createMockedFeatureFlag } from "./utils/testHelper.js"; import * as uuid from "uuid"; let mockedKVs: any[] = []; @@ -33,7 +33,7 @@ const getKvCallback = () => { }; describe("dynamic refresh", function () { - this.timeout(10000); + this.timeout(MAX_TIME_OUT); beforeEach(() => { mockedKVs = [ @@ -58,25 +58,6 @@ describe("dynamic refresh", function () { return expect(refreshCall).eventually.rejectedWith("Refresh is not enabled for key-values or feature flags."); }); - it("should only allow non-empty list of watched settings when refresh is enabled", async () => { - const connectionString = createMockedConnectionString(); - const loadWithEmptyWatchedSettings = load(connectionString, { - refreshOptions: { - enabled: true, - watchedSettings: [] - } - }); - const loadWithUndefinedWatchedSettings = load(connectionString, { - refreshOptions: { - enabled: true - } - }); - return Promise.all([ - expect(loadWithEmptyWatchedSettings).eventually.rejectedWith("Refresh is enabled but no watched settings are specified."), - expect(loadWithUndefinedWatchedSettings).eventually.rejectedWith("Refresh is enabled but no watched settings are specified.") - ]); - }); - it("should not allow refresh interval less than 1 second", async () => { const connectionString = createMockedConnectionString(); const loadWithInvalidRefreshInterval = load(connectionString, { @@ -354,6 +335,73 @@ describe("dynamic refresh", function () { expect(settings.get("app.settings.fontColor")).eq("red"); }); + it("should refresh key value based on page eTag, if no watched setting is specified", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + refreshOptions: { + enabled: true, + refreshIntervalInMs: 2000 + } + }); + expect(listKvRequestCount).eq(1); + expect(getKvRequestCount).eq(0); + expect(settings).not.undefined; + expect(settings.get("app.settings.fontColor")).eq("red"); + expect(settings.get("app.settings.fontSize")).eq("40"); + + // change setting + updateSetting("app.settings.fontColor", "blue"); + + // after refreshInterval, should really refresh + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + expect(listKvRequestCount).eq(3); // 1 + 2 more requests: one conditional request to detect change and one request to reload all key values + expect(getKvRequestCount).eq(0); + expect(settings.get("app.settings.fontColor")).eq("blue"); + }); + + it("should refresh key value based on page Etag, only on change", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + refreshOptions: { + enabled: true, + refreshIntervalInMs: 2000 + } + }); + expect(listKvRequestCount).eq(1); + expect(getKvRequestCount).eq(0); + + let refreshSuccessfulCount = 0; + settings.onRefresh(() => { + refreshSuccessfulCount++; + }); + + expect(settings).not.undefined; + expect(settings.get("app.settings.fontColor")).eq("red"); + + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + expect(listKvRequestCount).eq(2); // one more conditional request to detect change + expect(getKvRequestCount).eq(0); + expect(refreshSuccessfulCount).eq(0); // no change in key values, because page etags are the same. + + // change key value + restoreMocks(); + const changedKVs = [ + { value: "blue", key: "app.settings.fontColor" }, + { value: "40", key: "app.settings.fontSize" } + ].map(createMockedKeyValue); + mockAppConfigurationClientListConfigurationSettings([changedKVs], listKvCallback); + mockAppConfigurationClientGetConfigurationSetting(changedKVs, getKvCallback); + + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + expect(listKvRequestCount).eq(4); // 2 + 2 more requests: one conditional request to detect change and one request to reload all key values + expect(getKvRequestCount).eq(0); + expect(refreshSuccessfulCount).eq(1); // change in key values, because page etags are different. + expect(settings.get("app.settings.fontColor")).eq("blue"); + }); + it("should not refresh any more when there is refresh in progress", async () => { const connectionString = createMockedConnectionString(); const settings = await load(connectionString, { @@ -449,7 +497,7 @@ describe("dynamic refresh feature flags", function () { }); - it("should refresh feature flags only on change, based on page etags", async () => { + it("should refresh feature flags based on page etags, only on change", async () => { // mock multiple pages of feature flags const page1 = [ createMockedFeatureFlag("Alpha_1", { enabled: true }), diff --git a/test/requestTracing.test.ts b/test/requestTracing.test.ts index 7bd73ce0..ed3fdaee 100644 --- a/test/requestTracing.test.ts +++ b/test/requestTracing.test.ts @@ -5,13 +5,14 @@ import * as chai from "chai"; import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; -import { createMockedConnectionString, createMockedKeyValue, createMockedFeatureFlag, createMockedTokenCredential, mockAppConfigurationClientListConfigurationSettings, restoreMocks, HttpRequestHeadersPolicy, sleepInMs } from "./utils/testHelper.js"; +import { MAX_TIME_OUT, HttpRequestHeadersPolicy, createMockedConnectionString, createMockedKeyValue, createMockedFeatureFlag, createMockedTokenCredential, mockAppConfigurationClientListConfigurationSettings, restoreMocks, sinon, sleepInMs } from "./utils/testHelper.js"; +import { ConfigurationClientManager } from "../src/ConfigurationClientManager.js"; import { load } from "./exportedApi.js"; const CORRELATION_CONTEXT_HEADER_NAME = "Correlation-Context"; describe("request tracing", function () { - this.timeout(15000); + this.timeout(MAX_TIME_OUT); const fakeEndpoint = "https://127.0.0.1"; // sufficient to test the request it sends out const headerPolicy = new HttpRequestHeadersPolicy(); @@ -42,9 +43,7 @@ describe("request tracing", function () { it("should have request type in correlation-context header", async () => { try { - await load(createMockedConnectionString(fakeEndpoint), { - clientOptions - }); + await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; expect(headerPolicy.headers.get("Correlation-Context")).eq("RequestType=Startup"); @@ -65,12 +64,23 @@ describe("request tracing", function () { expect(correlationContext.includes("UsesKeyVault")).eq(true); }); + it("should have replica count in correlation-context header", async () => { + const replicaCount = 2; + sinon.stub(ConfigurationClientManager.prototype, "getReplicaCount").returns(replicaCount); + try { + await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); + } catch (e) { /* empty */ } + expect(headerPolicy.headers).not.undefined; + const correlationContext = headerPolicy.headers.get("Correlation-Context"); + expect(correlationContext).not.undefined; + expect(correlationContext.includes(`ReplicaCount=${replicaCount}`)).eq(true); + sinon.restore(); + }); + it("should detect env in correlation-context header", async () => { process.env.NODE_ENV = "development"; try { - await load(createMockedConnectionString(fakeEndpoint), { - clientOptions - }); + await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -82,9 +92,7 @@ describe("request tracing", function () { it("should detect host type in correlation-context header", async () => { process.env.WEBSITE_SITE_NAME = "website-name"; try { - await load(createMockedConnectionString(fakeEndpoint), { - clientOptions - }); + await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); @@ -97,9 +105,7 @@ describe("request tracing", function () { for (const indicator of ["TRUE", "true"]) { process.env.AZURE_APP_CONFIGURATION_TRACING_DISABLED = indicator; try { - await load(createMockedConnectionString(fakeEndpoint), { - clientOptions - }); + await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; const correlationContext = headerPolicy.headers.get("Correlation-Context"); diff --git a/test/utils/testHelper.ts b/test/utils/testHelper.ts index c00c99b6..85f7ac80 100644 --- a/test/utils/testHelper.ts +++ b/test/utils/testHelper.ts @@ -2,7 +2,7 @@ // Licensed under the MIT license. import * as sinon from "sinon"; -import { AppConfigurationClient, ConfigurationSetting } from "@azure/app-configuration"; +import { AppConfigurationClient, ConfigurationSetting, featureFlagContentType } from "@azure/app-configuration"; import { ClientSecretCredential } from "@azure/identity"; import { KeyVaultSecret, SecretClient } from "@azure/keyvault-secrets"; import * as uuid from "uuid"; @@ -10,6 +10,10 @@ import { RestError } from "@azure/core-rest-pipeline"; import { promisify } from "util"; const sleepInMs = promisify(setTimeout); import * as crypto from "crypto"; +import { ConfigurationClientManager } from "../../src/ConfigurationClientManager.js"; +import { ConfigurationClientWrapper } from "../../src/ConfigurationClientWrapper.js"; + +const MAX_TIME_OUT = 20000; const TEST_CLIENT_ID = "00000000-0000-0000-0000-000000000000"; const TEST_TENANT_ID = "00000000-0000-0000-0000-000000000000"; @@ -38,6 +42,50 @@ function _filterKVs(unfilteredKvs: ConfigurationSetting[], listOptions: any) { }); } +function getMockedIterator(pages: ConfigurationSetting[][], kvs: ConfigurationSetting[], listOptions: any) { + const mockIterator: AsyncIterableIterator & { byPage(): AsyncIterableIterator } = { + [Symbol.asyncIterator](): AsyncIterableIterator { + kvs = _filterKVs(pages.flat(), listOptions); + return this; + }, + next() { + const value = kvs.shift(); + return Promise.resolve({ done: !value, value }); + }, + byPage(): AsyncIterableIterator { + let remainingPages; + const pageEtags = listOptions?.pageEtags ? [...listOptions.pageEtags] : undefined; // a copy of the original list + return { + [Symbol.asyncIterator](): AsyncIterableIterator { + remainingPages = [...pages]; + return this; + }, + next() { + const pageItems = remainingPages.shift(); + const pageEtag = pageEtags?.shift(); + if (pageItems === undefined) { + return Promise.resolve({ done: true, value: undefined }); + } else { + const items = _filterKVs(pageItems ?? [], listOptions); + const etag = _sha256(JSON.stringify(items)); + const statusCode = pageEtag === etag ? 304 : 200; + return Promise.resolve({ + done: false, + value: { + items, + etag, + _response: { status: statusCode } + } + }); + } + } + }; + } + }; + + return mockIterator as any; +} + /** * Mocks the listConfigurationSettings method of AppConfigurationClient to return the provided pages of ConfigurationSetting. * E.g. @@ -52,48 +100,46 @@ function mockAppConfigurationClientListConfigurationSettings(pages: Configuratio customCallback(listOptions); } - let kvs = _filterKVs(pages.flat(), listOptions); - const mockIterator: AsyncIterableIterator & { byPage(): AsyncIterableIterator } = { - [Symbol.asyncIterator](): AsyncIterableIterator { - kvs = _filterKVs(pages.flat(), listOptions); - return this; - }, - next() { - const value = kvs.shift(); - return Promise.resolve({ done: !value, value }); - }, - byPage(): AsyncIterableIterator { - let remainingPages; - const pageEtags = listOptions?.pageEtags ? [...listOptions.pageEtags] : undefined; // a copy of the original list - return { - [Symbol.asyncIterator](): AsyncIterableIterator { - remainingPages = [...pages]; - return this; - }, - next() { - const pageItems = remainingPages.shift(); - const pageEtag = pageEtags?.shift(); - if (pageItems === undefined) { - return Promise.resolve({ done: true, value: undefined }); - } else { - const items = _filterKVs(pageItems ?? [], listOptions); - const etag = _sha256(JSON.stringify(items)); - const statusCode = pageEtag === etag ? 304 : 200; - return Promise.resolve({ - done: false, - value: { - items, - etag, - _response: { status: statusCode } - } - }); - } - } - }; - } - }; + const kvs = _filterKVs(pages.flat(), listOptions); + return getMockedIterator(pages, kvs, listOptions); + }); +} - return mockIterator as any; +function mockAppConfigurationClientLoadBalanceMode(clientWrapper: ConfigurationClientWrapper, countObject: { count: number }) { + const emptyPages: ConfigurationSetting[][] = []; + sinon.stub(clientWrapper.client, "listConfigurationSettings").callsFake((listOptions) => { + countObject.count += 1; + const kvs = _filterKVs(emptyPages.flat(), listOptions); + return getMockedIterator(emptyPages, kvs, listOptions); + }); +} + +function mockConfigurationManagerGetClients(fakeClientWrappers: ConfigurationClientWrapper[], isFailoverable: boolean, ...pages: ConfigurationSetting[][]) { + // Stub the getClients method on the class prototype + sinon.stub(ConfigurationClientManager.prototype, "getClients").callsFake(async () => { + if (fakeClientWrappers?.length > 0) { + return fakeClientWrappers; + } + const clients: ConfigurationClientWrapper[] = []; + const fakeEndpoint = createMockedEndpoint("fake"); + const fakeStaticClientWrapper = new ConfigurationClientWrapper(fakeEndpoint, new AppConfigurationClient(createMockedConnectionString(fakeEndpoint))); + sinon.stub(fakeStaticClientWrapper.client, "listConfigurationSettings").callsFake(() => { + throw new RestError("Internal Server Error", { statusCode: 500 }); + }); + clients.push(fakeStaticClientWrapper); + + if (!isFailoverable) { + return clients; + } + + const fakeReplicaEndpoint = createMockedEndpoint("fake-replica"); + const fakeDynamicClientWrapper = new ConfigurationClientWrapper(fakeReplicaEndpoint, new AppConfigurationClient(createMockedConnectionString(fakeReplicaEndpoint))); + clients.push(fakeDynamicClientWrapper); + sinon.stub(fakeDynamicClientWrapper.client, "listConfigurationSettings").callsFake((listOptions) => { + const kvs = _filterKVs(pages.flat(), listOptions); + return getMockedIterator(pages, kvs, listOptions); + }); + return clients; }); } @@ -194,7 +240,7 @@ const createMockedFeatureFlag = (name: string, flagProps?: any, props?: any) => "client_filters": [] } }, flagProps)), - contentType: "application/vnd.microsoft.appconfig.ff+json;charset=utf-8", + contentType: featureFlagContentType, lastModified: new Date(), tags: {}, etag: uuid.v4(), @@ -219,6 +265,8 @@ export { sinon, mockAppConfigurationClientListConfigurationSettings, mockAppConfigurationClientGetConfigurationSetting, + mockAppConfigurationClientLoadBalanceMode, + mockConfigurationManagerGetClients, mockSecretClientGetSecret, restoreMocks, @@ -230,7 +278,7 @@ export { createMockedKeyValue, createMockedFeatureFlag, - HttpRequestHeadersPolicy, - - sleepInMs + sleepInMs, + MAX_TIME_OUT, + HttpRequestHeadersPolicy };