diff --git a/package-lock.json b/package-lock.json index 550e7f3d..e30a06e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { "name": "@azure/app-configuration-provider", - "version": "2.1.0", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@azure/app-configuration-provider", - "version": "2.1.0", + "version": "2.2.0", "license": "MIT", "dependencies": { - "@azure/app-configuration": "^1.6.1", + "@azure/app-configuration": "^1.8.0", "@azure/identity": "^4.2.1", - "@azure/keyvault-secrets": "^4.7.0" + "@azure/keyvault-secrets": "^4.7.0", + "jsonc-parser": "^3.3.1" }, "devDependencies": { "@rollup/plugin-typescript": "^11.1.2", @@ -57,11 +58,11 @@ } }, "node_modules/@azure/app-configuration": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@azure/app-configuration/-/app-configuration-1.6.1.tgz", - "integrity": "sha512-pk8zyG/8Nc6VN7uDA9QY19UFhTXneUbnB+5IcW9uuPyVDXU17TcXBI4xY1ZBm7hmhn0yh3CeZK4kOxa/tjsMqQ==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@azure/app-configuration/-/app-configuration-1.9.0.tgz", + "integrity": "sha512-X0AVDQygL4AGLtplLYW+W0QakJpJ417sQldOacqwcBQ882tAPdUVs6V3mZ4jUjwVsgr+dV1v9zMmijvsp6XBxA==", "dependencies": { - "@azure/abort-controller": "^1.0.0", + "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.3.0", "@azure/core-client": "^1.5.0", "@azure/core-http-compat": "^2.0.0", @@ -77,6 +78,17 @@ "node": ">=18.0.0" } }, + "node_modules/@azure/app-configuration/node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@azure/app-configuration/node_modules/@azure/core-http-compat": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.0.1.tgz", @@ -90,6 +102,17 @@ "node": ">=14.0.0" } }, + "node_modules/@azure/app-configuration/node_modules/@azure/core-http-compat/node_modules/@azure/abort-controller": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.1.0.tgz", + "integrity": "sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw==", + "dependencies": { + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/@azure/core-auth": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.5.0.tgz", @@ -1229,10 +1252,11 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1261,6 +1285,19 @@ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1548,6 +1585,20 @@ "url": "https://github.com/motdotla/dotenv?sponsor=1" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -1568,6 +1619,51 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -1900,12 +1996,15 @@ } }, "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -1932,6 +2031,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -1950,6 +2058,43 @@ "node": "*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", @@ -2017,6 +2162,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -2041,6 +2198,45 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -2316,6 +2512,11 @@ "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", "dev": true }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==" + }, "node_modules/jsonwebtoken": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", @@ -2513,6 +2714,15 @@ "node": ">=12" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2613,11 +2823,10 @@ } }, "node_modules/mocha/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -3041,10 +3250,11 @@ } }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } diff --git a/package.json b/package.json index 4ac941b2..d5934885 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azure/app-configuration-provider", - "version": "2.1.0", + "version": "2.2.0", "description": "The JavaScript configuration provider for Azure App Configuration", "main": "dist/index.js", "module": "./dist-esm/index.js", @@ -55,8 +55,9 @@ "uuid": "^9.0.1" }, "dependencies": { - "@azure/app-configuration": "^1.6.1", + "@azure/app-configuration": "^1.8.0", "@azure/identity": "^4.2.1", - "@azure/keyvault-secrets": "^4.7.0" + "@azure/keyvault-secrets": "^4.7.0", + "jsonc-parser": "^3.3.1" } } diff --git a/rollup.config.mjs b/rollup.config.mjs index 16224a2f..0df0e168 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -11,6 +11,7 @@ export default [ "@azure/identity", "crypto", "dns/promises", + "jsonc-parser", "@microsoft/feature-management" ], input: "src/index.ts", diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index fc8759b4..011c2017 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -22,6 +22,7 @@ import { IKeyValueAdapter } from "./IKeyValueAdapter.js"; import { JsonKeyValueAdapter } from "./JsonKeyValueAdapter.js"; import { DEFAULT_STARTUP_TIMEOUT_IN_MS } from "./StartupOptions.js"; import { DEFAULT_REFRESH_INTERVAL_IN_MS, MIN_REFRESH_INTERVAL_IN_MS } from "./refresh/refreshOptions.js"; +import { MIN_SECRET_REFRESH_INTERVAL_IN_MS } from "./keyvault/KeyVaultOptions.js"; import { Disposable } from "./common/disposable.js"; import { FEATURE_FLAGS_KEY_NAME, @@ -60,9 +61,6 @@ import { InvalidOperationError, ArgumentError, isFailoverableError, isInputError const MIN_DELAY_FOR_UNHANDLED_FAILURE = 5_000; // 5 seconds type PagedSettingSelector = SettingSelector & { - /** - * Key: page eTag, Value: feature flag configurations - */ pageEtags?: string[]; }; @@ -94,16 +92,22 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { /** * Aka watched settings. */ + #refreshEnabled: boolean = false; #sentinels: ConfigurationSettingId[] = []; #watchAll: boolean = false; #kvRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; #kvRefreshTimer: RefreshTimer; // Feature flags + #featureFlagEnabled: boolean = false; + #featureFlagRefreshEnabled: boolean = false; #ffRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS; #ffRefreshTimer: RefreshTimer; // Key Vault references + #secretRefreshEnabled: boolean = false; + #secretReferences: ConfigurationSetting[] = []; // cached key vault references + #secretRefreshTimer: RefreshTimer; #resolveSecretsInParallel: boolean = false; /** @@ -132,14 +136,15 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { this.#featureFlagTracing = new FeatureFlagTracingOptions(); } - if (options?.trimKeyPrefixes) { + if (options?.trimKeyPrefixes !== undefined) { 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) { + if (options?.refreshOptions?.enabled === true) { + this.#refreshEnabled = true; const { refreshIntervalInMs, watchedSettings } = options.refreshOptions; if (watchedSettings === undefined || watchedSettings.length === 0) { this.#watchAll = true; // if no watched settings is specified, then watch all @@ -159,53 +164,48 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (refreshIntervalInMs !== undefined) { if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) { throw new RangeError(`The refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`); - } else { - this.#kvRefreshInterval = refreshIntervalInMs; } + this.#kvRefreshInterval = refreshIntervalInMs; } this.#kvRefreshTimer = new RefreshTimer(this.#kvRefreshInterval); } // feature flag options - if (options?.featureFlagOptions?.enabled) { + if (options?.featureFlagOptions?.enabled === true) { + this.#featureFlagEnabled = true; // validate feature flag selectors, only load feature flags when enabled this.#ffSelectors = getValidFeatureFlagSelectors(options.featureFlagOptions.selectors); - if (options.featureFlagOptions.refresh?.enabled) { + if (options.featureFlagOptions.refresh?.enabled === true) { + this.#featureFlagRefreshEnabled = true; const { refreshIntervalInMs } = options.featureFlagOptions.refresh; // custom refresh interval if (refreshIntervalInMs !== undefined) { if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) { throw new RangeError(`The feature flag refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`); - } else { - this.#ffRefreshInterval = refreshIntervalInMs; } + this.#ffRefreshInterval = refreshIntervalInMs; } this.#ffRefreshTimer = new RefreshTimer(this.#ffRefreshInterval); } } - if (options?.keyVaultOptions?.parallelSecretResolutionEnabled) { - this.#resolveSecretsInParallel = options.keyVaultOptions.parallelSecretResolutionEnabled; + if (options?.keyVaultOptions !== undefined) { + const { secretRefreshIntervalInMs } = options.keyVaultOptions; + if (secretRefreshIntervalInMs !== undefined) { + if (secretRefreshIntervalInMs < MIN_SECRET_REFRESH_INTERVAL_IN_MS) { + throw new RangeError(`The Key Vault secret refresh interval cannot be less than ${MIN_SECRET_REFRESH_INTERVAL_IN_MS} milliseconds.`); + } + this.#secretRefreshEnabled = true; + this.#secretRefreshTimer = new RefreshTimer(secretRefreshIntervalInMs); + } + this.#resolveSecretsInParallel = options.keyVaultOptions.parallelSecretResolutionEnabled ?? false; } - - this.#adapters.push(new AzureKeyVaultKeyValueAdapter(options?.keyVaultOptions)); + this.#adapters.push(new AzureKeyVaultKeyValueAdapter(options?.keyVaultOptions, this.#secretRefreshTimer)); 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, @@ -340,8 +340,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { * Refreshes the configuration. */ async refresh(): Promise { - if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) { - throw new InvalidOperationError("Refresh is not enabled for key-values or feature flags."); + if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled && !this.#secretRefreshEnabled) { + throw new InvalidOperationError("Refresh is not enabled for key-values, feature flags or Key Vault secrets."); } if (this.#refreshInProgress) { @@ -359,8 +359,8 @@ 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 InvalidOperationError("Refresh is not enabled for key-values or feature flags."); + if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled && !this.#secretRefreshEnabled) { + throw new InvalidOperationError("Refresh is not enabled for key-values, feature flags or Key Vault secrets."); } const boundedListener = listener.bind(thisArg); @@ -395,6 +395,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (isInputError(error)) { throw error; } + if (isRestError(error) && !isFailoverableError(error)) { + throw error; + } if (abortSignal.aborted) { return; } @@ -428,8 +431,20 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { async #refreshTasks(): Promise { const refreshTasks: Promise[] = []; - if (this.#refreshEnabled) { - refreshTasks.push(this.#refreshKeyValues()); + if (this.#refreshEnabled || this.#secretRefreshEnabled) { + refreshTasks.push( + this.#refreshKeyValues() + .then(keyValueRefreshed => { + // Only refresh secrets if key values didn't change and secret refresh is enabled + // If key values are refreshed, all secret references will be refreshed as well. + if (!keyValueRefreshed && this.#secretRefreshEnabled) { + // Returns the refreshSecrets promise directly. + // in a Promise chain, this automatically flattens nested Promises without requiring await. + return this.#refreshSecrets(); + } + return keyValueRefreshed; + }) + ); } if (this.#featureFlagRefreshEnabled) { refreshTasks.push(this.#refreshFeatureFlags()); @@ -476,7 +491,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (selector.snapshotName === undefined) { const listOptions: ListConfigurationSettingsOptions = { keyFilter: selector.keyFilter, - labelFilter: selector.labelFilter + labelFilter: selector.labelFilter, + tagsFilter: selector.tagFilters }; const pageEtags: string[] = []; const pageIterator = listConfigurationSettingsWithTrace( @@ -533,6 +549,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { * Loads selected key-values and watched settings (sentinels) for refresh from App Configuration to the local configuration. */ async #loadSelectedAndWatchedKeyValues() { + this.#secretReferences = []; // clear all cached key vault reference configuration settings const keyValues: [key: string, value: unknown][] = []; const loadedSettings: ConfigurationSetting[] = await this.#loadConfigurationSettings(); if (this.#refreshEnabled && !this.#watchAll) { @@ -540,28 +557,24 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } if (this.#requestTracingEnabled && this.#aiConfigurationTracing !== undefined) { - // Reset old AI configuration tracing in order to track the information present in the current response from server. + // reset old AI configuration tracing in order to track the information present in the current response from server this.#aiConfigurationTracing.reset(); } - const secretResolutionPromises: Promise[] = []; for (const setting of loadedSettings) { - if (this.#resolveSecretsInParallel && isSecretReference(setting)) { - // secret references are resolved asynchronously to improve performance - const secretResolutionPromise = this.#processKeyValue(setting) - .then(([key, value]) => { - keyValues.push([key, value]); - }); - secretResolutionPromises.push(secretResolutionPromise); + if (isSecretReference(setting)) { + this.#secretReferences.push(setting); // cache secret references for resolve/refresh secret separately continue; } // adapt configuration settings to key-values const [key, value] = await this.#processKeyValue(setting); keyValues.push([key, value]); } - if (secretResolutionPromises.length > 0) { - // wait for all secret resolution promises to be resolved - await Promise.all(secretResolutionPromises); + + if (this.#secretReferences.length > 0) { + await this.#resolveSecretReferences(this.#secretReferences, (key, value) => { + keyValues.push([key, value]); + }); } this.#clearLoadedKeyValues(); // clear existing key-values in case of configuration setting deletion @@ -629,7 +642,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { */ async #refreshKeyValues(): Promise { // if still within refresh interval/backoff, return - if (!this.#kvRefreshTimer.canRefresh()) { + if (this.#kvRefreshTimer === undefined || !this.#kvRefreshTimer.canRefresh()) { return Promise.resolve(false); } @@ -653,6 +666,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } if (needRefresh) { + for (const adapter of this.#adapters) { + await adapter.onChangeDetected(); + } await this.#loadSelectedAndWatchedKeyValues(); } @@ -666,7 +682,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { */ async #refreshFeatureFlags(): Promise { // if still within refresh interval/backoff, return - if (!this.#ffRefreshTimer.canRefresh()) { + if (this.#ffRefreshInterval === undefined || !this.#ffRefreshTimer.canRefresh()) { return Promise.resolve(false); } @@ -679,6 +695,25 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { return Promise.resolve(needRefresh); } + async #refreshSecrets(): Promise { + // if still within refresh interval/backoff, return + if (this.#secretRefreshTimer === undefined || !this.#secretRefreshTimer.canRefresh()) { + return Promise.resolve(false); + } + + // if no cached key vault references, return + if (this.#secretReferences.length === 0) { + return Promise.resolve(false); + } + + await this.#resolveSecretReferences(this.#secretReferences, (key, value) => { + this.#configMap.set(key, value); + }); + + this.#secretRefreshTimer.reset(); + return Promise.resolve(true); + } + /** * Checks whether the key-value collection has changed. * @param selectors - The @see PagedSettingSelector of the kev-value collection. @@ -693,6 +728,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { const listOptions: ListConfigurationSettingsOptions = { keyFilter: selector.keyFilter, labelFilter: selector.labelFilter, + tagsFilter: selector.tagFilters, pageEtags: selector.pageEtags }; @@ -807,6 +843,27 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { throw new Error("All fallback clients failed to get configuration settings."); } + async #resolveSecretReferences(secretReferences: ConfigurationSetting[], resultHandler: (key: string, value: unknown) => void): Promise { + if (this.#resolveSecretsInParallel) { + const secretResolutionPromises: Promise[] = []; + for (const setting of secretReferences) { + const secretResolutionPromise = this.#processKeyValue(setting) + .then(([key, value]) => { + resultHandler(key, value); + }); + secretResolutionPromises.push(secretResolutionPromise); + } + + // Wait for all secret resolution promises to be resolved + await Promise.all(secretResolutionPromises); + } else { + for (const setting of secretReferences) { + const [key, value] = await this.#processKeyValue(setting); + resultHandler(key, value); + } + } + } + async #processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]> { this.#setAIConfigurationTracing(setting); @@ -911,7 +968,11 @@ function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector // below code deduplicates selectors, the latter selector wins const uniqueSelectors: SettingSelector[] = []; for (const selector of selectors) { - const existingSelectorIndex = uniqueSelectors.findIndex(s => s.keyFilter === selector.keyFilter && s.labelFilter === selector.labelFilter && s.snapshotName === selector.snapshotName); + const existingSelectorIndex = uniqueSelectors.findIndex( + s => s.keyFilter === selector.keyFilter && + s.labelFilter === selector.labelFilter && + s.snapshotName === selector.snapshotName && + areTagFiltersEqual(s.tagFilters, selector.tagFilters)); if (existingSelectorIndex >= 0) { uniqueSelectors.splice(existingSelectorIndex, 1); } @@ -921,8 +982,8 @@ function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector return uniqueSelectors.map(selectorCandidate => { const selector = { ...selectorCandidate }; if (selector.snapshotName) { - if (selector.keyFilter || selector.labelFilter) { - throw new ArgumentError("Key or label filter should not be used for a snapshot."); + if (selector.keyFilter || selector.labelFilter || selector.tagFilters) { + throw new ArgumentError("Key, label or tag filters should not be specified while selecting a snapshot."); } } else { if (!selector.keyFilter) { @@ -934,11 +995,31 @@ function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector if (selector.labelFilter.includes("*") || selector.labelFilter.includes(",")) { throw new ArgumentError("The characters '*' and ',' are not supported in label filters."); } + if (selector.tagFilters) { + validateTagFilters(selector.tagFilters); + } } return selector; }); } +function areTagFiltersEqual(tagsA?: string[], tagsB?: string[]): boolean { + if (!tagsA && !tagsB) { + return true; + } + if (!tagsA || !tagsB) { + return false; + } + if (tagsA.length !== tagsB.length) { + return false; + } + + const sortedStringA = [...tagsA].sort().join("\n"); + const sortedStringB = [...tagsB].sort().join("\n"); + + return sortedStringA === sortedStringB; +} + function getValidKeyValueSelectors(selectors?: SettingSelector[]): SettingSelector[] { if (selectors === undefined || selectors.length === 0) { // Default selector: key: *, label: \0 @@ -959,3 +1040,12 @@ function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSel }); return getValidSettingSelectors(selectors); } + +function validateTagFilters(tagFilters: string[]): void { + for (const tagFilter of tagFilters) { + const res = tagFilter.split("="); + if (res[0] === "" || res.length !== 2) { + throw new Error(`Invalid tag filter: ${tagFilter}. Tag filter must follow the format "tagName=tagValue".`); + } + } +} diff --git a/src/ConfigurationClientManager.ts b/src/ConfigurationClientManager.ts index 72a3bfeb..9fc2331b 100644 --- a/src/ConfigurationClientManager.ts +++ b/src/ConfigurationClientManager.ts @@ -12,7 +12,7 @@ import { ArgumentError } from "./common/error.js"; // Configuration client retry options const CLIENT_MAX_RETRIES = 2; -const CLIENT_MAX_RETRY_DELAY = 60_000; // 1 minute in milliseconds +const CLIENT_MAX_RETRY_DELAY_IN_MS = 60_000; const TCP_ORIGIN_KEY_NAME = "_origin._tcp"; const ALT_KEY_NAME = "_alt"; @@ -21,9 +21,9 @@ const ENDPOINT_KEY_NAME = "Endpoint"; const ID_KEY_NAME = "Id"; const SECRET_KEY_NAME = "Secret"; const TRUSTED_DOMAIN_LABELS = [".azconfig.", ".appconfig."]; -const FALLBACK_CLIENT_EXPIRE_INTERVAL = 60 * 60 * 1000; // 1 hour in milliseconds -const MINIMAL_CLIENT_REFRESH_INTERVAL = 30_000; // 30 seconds in milliseconds -const DNS_RESOLVER_TIMEOUT = 3_000; // 3 seconds in milliseconds, in most cases, dns resolution should be within 200 milliseconds +const FALLBACK_CLIENT_EXPIRE_INTERVAL_IN_MS = 60 * 60 * 1000; +const MINIMAL_CLIENT_REFRESH_INTERVAL_IN_MS = 30_000; +const DNS_RESOLVER_TIMEOUT_IN_MS = 3_000; const DNS_RESOLVER_TRIES = 2; const MAX_ALTNATIVE_SRV_COUNT = 10; @@ -120,11 +120,11 @@ export class ConfigurationClientManager { 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 && + if (currentTime >= this.#lastFallbackClientRefreshAttempt + MINIMAL_CLIENT_REFRESH_INTERVAL_IN_MS && (!this.#dynamicClients || // All dynamic clients are in backoff means no client is available this.#dynamicClients.every(client => currentTime < client.backoffEndTime) || - currentTime >= this.#lastFallbackClientUpdateTime + FALLBACK_CLIENT_EXPIRE_INTERVAL)) { + currentTime >= this.#lastFallbackClientUpdateTime + FALLBACK_CLIENT_EXPIRE_INTERVAL_IN_MS)) { await this.#discoverFallbackClients(this.endpoint.hostname); return availableClients.concat(this.#dynamicClients); } @@ -142,7 +142,7 @@ export class ConfigurationClientManager { async refreshClients() { const currentTime = Date.now(); if (this.#isFailoverable && - currentTime >= this.#lastFallbackClientRefreshAttempt + MINIMAL_CLIENT_REFRESH_INTERVAL) { + currentTime >= this.#lastFallbackClientRefreshAttempt + MINIMAL_CLIENT_REFRESH_INTERVAL_IN_MS) { await this.#discoverFallbackClients(this.endpoint.hostname); } } @@ -185,7 +185,7 @@ export class ConfigurationClientManager { try { // https://nodejs.org/api/dns.html#dnspromisesresolvesrvhostname - const resolver = new this.#dns.Resolver({timeout: DNS_RESOLVER_TIMEOUT, tries: DNS_RESOLVER_TRIES}); + const resolver = new this.#dns.Resolver({timeout: DNS_RESOLVER_TIMEOUT_IN_MS, tries: DNS_RESOLVER_TRIES}); // On success, resolveSrv() returns an array of SrvRecord // On failure, resolveSrv() throws an error with code 'ENOTFOUND'. const originRecords = await resolver.resolveSrv(`${TCP_ORIGIN_KEY_NAME}.${host}`); // look up SRV records for the origin host @@ -266,7 +266,7 @@ function getClientOptions(options?: AzureAppConfigurationOptions): AppConfigurat // retry options const defaultRetryOptions = { maxRetries: CLIENT_MAX_RETRIES, - maxRetryDelayInMs: CLIENT_MAX_RETRY_DELAY, + maxRetryDelayInMs: CLIENT_MAX_RETRY_DELAY_IN_MS, }; const retryOptions = Object.assign({}, defaultRetryOptions, options?.clientOptions?.retryOptions); diff --git a/src/IKeyValueAdapter.ts b/src/IKeyValueAdapter.ts index 1f5042d6..222461dd 100644 --- a/src/IKeyValueAdapter.ts +++ b/src/IKeyValueAdapter.ts @@ -13,4 +13,9 @@ export interface IKeyValueAdapter { * This method process the original configuration setting, and returns processed key and value in an array. */ processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]>; + + /** + * This method is called when a change is detected in the configuration setting. + */ + onChangeDetected(): Promise; } diff --git a/src/JsonKeyValueAdapter.ts b/src/JsonKeyValueAdapter.ts index 92f52f53..75ecaffc 100644 --- a/src/JsonKeyValueAdapter.ts +++ b/src/JsonKeyValueAdapter.ts @@ -2,6 +2,7 @@ // Licensed under the MIT license. import { ConfigurationSetting, featureFlagContentType, secretReferenceContentType } from "@azure/app-configuration"; +import { stripComments } from "jsonc-parser"; import { parseContentType, isJsonContentType } from "./common/contentType.js"; import { IKeyValueAdapter } from "./IKeyValueAdapter.js"; @@ -25,14 +26,35 @@ export class JsonKeyValueAdapter implements IKeyValueAdapter { async processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]> { let parsedValue: unknown; if (setting.value !== undefined) { - try { - parsedValue = JSON.parse(setting.value); - } catch (error) { - parsedValue = setting.value; + const parseResult = this.#tryParseJson(setting.value); + if (parseResult.success) { + parsedValue = parseResult.result; + } else { + // Try parsing with comments stripped + const parseWithoutCommentsResult = this.#tryParseJson(stripComments(setting.value)); + if (parseWithoutCommentsResult.success) { + parsedValue = parseWithoutCommentsResult.result; + } else { + // If still not valid JSON, return the original value + parsedValue = setting.value; + } } - } else { - parsedValue = setting.value; } return [setting.key, parsedValue]; } + + async onChangeDetected(): Promise { + return; + } + + #tryParseJson(value: string): { success: true; result: unknown } | { success: false } { + try { + return { success: true, result: JSON.parse(value) }; + } catch (error) { + if (error instanceof SyntaxError) { + return { success: false }; + } + throw error; + } + } } diff --git a/src/StartupOptions.ts b/src/StartupOptions.ts index f80644bb..5ab4f1a2 100644 --- a/src/StartupOptions.ts +++ b/src/StartupOptions.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -export const DEFAULT_STARTUP_TIMEOUT_IN_MS = 100 * 1000; // 100 seconds in milliseconds +export const DEFAULT_STARTUP_TIMEOUT_IN_MS = 100_000; export interface StartupOptions { /** diff --git a/src/common/backoffUtils.ts b/src/common/backoffUtils.ts index 2bebf5c4..d0b78f39 100644 --- a/src/common/backoffUtils.ts +++ b/src/common/backoffUtils.ts @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -const MIN_BACKOFF_DURATION = 30_000; // 30 seconds in milliseconds -const MAX_BACKOFF_DURATION = 10 * 60 * 1000; // 10 minutes in milliseconds +const MIN_BACKOFF_DURATION_IN_MS = 30_000; +const MAX_BACKOFF_DURATION_IN_MS = 10 * 60 * 1000; const JITTER_RATIO = 0.25; export function getFixedBackoffDuration(timeElapsedInMs: number): number | undefined { @@ -13,21 +13,21 @@ export function getFixedBackoffDuration(timeElapsedInMs: number): number | undef return 10_000; } if (timeElapsedInMs < 10 * 60 * 1000) { - return MIN_BACKOFF_DURATION; + return MIN_BACKOFF_DURATION_IN_MS; } return undefined; } export function getExponentialBackoffDuration(failedAttempts: number): number { if (failedAttempts <= 1) { - return MIN_BACKOFF_DURATION; + return MIN_BACKOFF_DURATION_IN_MS; } // exponential: minBackoff * 2 ^ (failedAttempts - 1) // The right shift operator is not used in order to avoid potential overflow. Bitwise operations in JavaScript are limited to 32 bits. - let calculatedBackoffDuration = MIN_BACKOFF_DURATION * Math.pow(2, failedAttempts - 1); - if (calculatedBackoffDuration > MAX_BACKOFF_DURATION) { - calculatedBackoffDuration = MAX_BACKOFF_DURATION; + let calculatedBackoffDuration = MIN_BACKOFF_DURATION_IN_MS * Math.pow(2, failedAttempts - 1); + if (calculatedBackoffDuration > MAX_BACKOFF_DURATION_IN_MS) { + calculatedBackoffDuration = MAX_BACKOFF_DURATION_IN_MS; } // jitter: random value between [-1, 1) * jitterRatio * calculatedBackoffMs diff --git a/src/keyvault/AzureKeyVaultKeyValueAdapter.ts b/src/keyvault/AzureKeyVaultKeyValueAdapter.ts index d67fee34..8f5cc0f1 100644 --- a/src/keyvault/AzureKeyVaultKeyValueAdapter.ts +++ b/src/keyvault/AzureKeyVaultKeyValueAdapter.ts @@ -3,21 +3,21 @@ import { ConfigurationSetting, isSecretReference, parseSecretReference } from "@azure/app-configuration"; import { IKeyValueAdapter } from "../IKeyValueAdapter.js"; +import { AzureKeyVaultSecretProvider } from "./AzureKeyVaultSecretProvider.js"; import { KeyVaultOptions } from "./KeyVaultOptions.js"; +import { RefreshTimer } from "../refresh/RefreshTimer.js"; import { ArgumentError, KeyVaultReferenceError } from "../common/error.js"; -import { KeyVaultSecretIdentifier, SecretClient, parseKeyVaultSecretIdentifier } from "@azure/keyvault-secrets"; +import { KeyVaultSecretIdentifier, parseKeyVaultSecretIdentifier } from "@azure/keyvault-secrets"; import { isRestError } from "@azure/core-rest-pipeline"; import { AuthenticationError } from "@azure/identity"; export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter { - /** - * Map vault hostname to corresponding secret client. - */ - #secretClients: Map; #keyVaultOptions: KeyVaultOptions | undefined; + #keyVaultSecretProvider: AzureKeyVaultSecretProvider; - constructor(keyVaultOptions: KeyVaultOptions | undefined) { + constructor(keyVaultOptions: KeyVaultOptions | undefined, refreshTimer?: RefreshTimer) { this.#keyVaultOptions = keyVaultOptions; + this.#keyVaultSecretProvider = new AzureKeyVaultSecretProvider(keyVaultOptions, refreshTimer); } canProcess(setting: ConfigurationSetting): boolean { @@ -25,7 +25,6 @@ export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter { } async processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]> { - // TODO: cache results to save requests. if (!this.#keyVaultOptions) { throw new ArgumentError("Failed to process the Key Vault reference because Key Vault options are not configured."); } @@ -39,53 +38,19 @@ export class AzureKeyVaultKeyValueAdapter implements IKeyValueAdapter { } try { - // precedence: secret clients > credential > secret resolver - const client = this.#getSecretClient(new URL(secretIdentifier.vaultUrl)); - if (client) { - const secret = await client.getSecret(secretIdentifier.name, { version: secretIdentifier.version }); - return [setting.key, secret.value]; - } - if (this.#keyVaultOptions.secretResolver) { - return [setting.key, await this.#keyVaultOptions.secretResolver(new URL(secretIdentifier.sourceId))]; - } + const secretValue = await this.#keyVaultSecretProvider.getSecretValue(secretIdentifier); + return [setting.key, secretValue]; } catch (error) { if (isRestError(error) || error instanceof AuthenticationError) { throw new KeyVaultReferenceError(buildKeyVaultReferenceErrorMessage("Failed to resolve Key Vault reference.", setting, secretIdentifier.sourceId), { cause: error }); } throw error; } - - // When code reaches here, it means that the key vault reference cannot be resolved in all possible ways. - throw new ArgumentError("Failed to process the key vault reference. No key vault secret client, credential or secret resolver callback is available to resolve the secret."); } - /** - * - * @param vaultUrl - The url of the key vault. - * @returns - */ - #getSecretClient(vaultUrl: URL): SecretClient | undefined { - if (this.#secretClients === undefined) { - this.#secretClients = new Map(); - for (const client of this.#keyVaultOptions?.secretClients ?? []) { - const clientUrl = new URL(client.vaultUrl); - this.#secretClients.set(clientUrl.host, client); - } - } - - let client: SecretClient | undefined; - client = this.#secretClients.get(vaultUrl.host); - if (client !== undefined) { - return client; - } - - if (this.#keyVaultOptions?.credential) { - client = new SecretClient(vaultUrl.toString(), this.#keyVaultOptions.credential, this.#keyVaultOptions.clientOptions); - this.#secretClients.set(vaultUrl.host, client); - return client; - } - - return undefined; + async onChangeDetected(): Promise { + this.#keyVaultSecretProvider.clearCache(); + return; } } diff --git a/src/keyvault/AzureKeyVaultSecretProvider.ts b/src/keyvault/AzureKeyVaultSecretProvider.ts new file mode 100644 index 00000000..546b4be5 --- /dev/null +++ b/src/keyvault/AzureKeyVaultSecretProvider.ts @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { KeyVaultOptions } from "./KeyVaultOptions.js"; +import { RefreshTimer } from "../refresh/RefreshTimer.js"; +import { ArgumentError } from "../common/error.js"; +import { SecretClient, KeyVaultSecretIdentifier } from "@azure/keyvault-secrets"; + +export class AzureKeyVaultSecretProvider { + #keyVaultOptions: KeyVaultOptions | undefined; + #secretRefreshTimer: RefreshTimer | undefined; + #secretClients: Map; // map key vault hostname to corresponding secret client + #cachedSecretValues: Map = new Map(); // map secret identifier to secret value + + constructor(keyVaultOptions: KeyVaultOptions | undefined, refreshTimer?: RefreshTimer) { + if (keyVaultOptions?.secretRefreshIntervalInMs !== undefined) { + if (refreshTimer === undefined) { + throw new ArgumentError("Refresh timer must be specified when Key Vault secret refresh is enabled."); + } + if (refreshTimer.interval !== keyVaultOptions.secretRefreshIntervalInMs) { + throw new ArgumentError("Refresh timer does not match the secret refresh interval."); + } + } + this.#keyVaultOptions = keyVaultOptions; + this.#secretRefreshTimer = refreshTimer; + this.#secretClients = new Map(); + for (const client of this.#keyVaultOptions?.secretClients ?? []) { + const clientUrl = new URL(client.vaultUrl); + this.#secretClients.set(clientUrl.host, client); + } + } + + async getSecretValue(secretIdentifier: KeyVaultSecretIdentifier): Promise { + const identifierKey = secretIdentifier.sourceId; + + // If the refresh interval is not expired, return the cached value if available. + if (this.#cachedSecretValues.has(identifierKey) && + (!this.#secretRefreshTimer || !this.#secretRefreshTimer.canRefresh())) { + return this.#cachedSecretValues.get(identifierKey); + } + + // Fallback to fetching the secret value from Key Vault. + const secretValue = await this.#getSecretValueFromKeyVault(secretIdentifier); + this.#cachedSecretValues.set(identifierKey, secretValue); + return secretValue; + } + + clearCache(): void { + this.#cachedSecretValues.clear(); + } + + async #getSecretValueFromKeyVault(secretIdentifier: KeyVaultSecretIdentifier): Promise { + if (!this.#keyVaultOptions) { + throw new ArgumentError("Failed to get secret value. The keyVaultOptions is not configured."); + } + const { name: secretName, vaultUrl, sourceId, version } = secretIdentifier; + // precedence: secret clients > custom secret resolver + const client = this.#getSecretClient(new URL(vaultUrl)); + if (client) { + const secret = await client.getSecret(secretName, { version }); + return secret.value; + } + if (this.#keyVaultOptions.secretResolver) { + return await this.#keyVaultOptions.secretResolver(new URL(sourceId)); + } + // When code reaches here, it means that the key vault reference cannot be resolved in all possible ways. + throw new ArgumentError("Failed to process the key vault reference. No key vault secret client, credential or secret resolver callback is available to resolve the secret."); + } + + #getSecretClient(vaultUrl: URL): SecretClient | undefined { + let client = this.#secretClients.get(vaultUrl.host); + if (client !== undefined) { + return client; + } + if (this.#keyVaultOptions?.credential) { + client = new SecretClient(vaultUrl.toString(), this.#keyVaultOptions.credential, this.#keyVaultOptions.clientOptions); + this.#secretClients.set(vaultUrl.host, client); + return client; + } + return undefined; + } +} diff --git a/src/keyvault/KeyVaultOptions.ts b/src/keyvault/KeyVaultOptions.ts index 3cf4bad0..7f960872 100644 --- a/src/keyvault/KeyVaultOptions.ts +++ b/src/keyvault/KeyVaultOptions.ts @@ -4,6 +4,8 @@ import { TokenCredential } from "@azure/identity"; import { SecretClient, SecretClientOptions } from "@azure/keyvault-secrets"; +export const MIN_SECRET_REFRESH_INTERVAL_IN_MS = 60_000; + /** * Options used to resolve Key Vault references. */ @@ -19,7 +21,7 @@ export interface KeyVaultOptions { credential?: TokenCredential; /** - * Configures the client options used when connecting to key vaults that have no registered SecretClient. + * * Configures the client options used when connecting to key vaults that have no registered SecretClient. * * @remarks * The client options will not affect the registered SecretClient instances. @@ -40,4 +42,12 @@ export interface KeyVaultOptions { * If not specified, the default value is false. */ parallelSecretResolutionEnabled?: boolean; + + /** + * Specifies the refresh interval in milliseconds for periodically reloading all secrets from Key Vault. + * + * @remarks + * If specified, the value must be greater than 60 seconds. + */ + secretRefreshIntervalInMs?: number; } diff --git a/src/load.ts b/src/load.ts index 25fd9594..15f88218 100644 --- a/src/load.ts +++ b/src/load.ts @@ -8,7 +8,7 @@ import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js" import { ConfigurationClientManager } from "./ConfigurationClientManager.js"; import { instanceOfTokenCredential } from "./common/utils.js"; -const MIN_DELAY_FOR_UNHANDLED_ERROR: number = 5_000; // 5 seconds +const MIN_DELAY_FOR_UNHANDLED_ERROR_IN_MS: number = 5_000; /** * Loads the data from Azure App Configuration service and returns an instance of AzureAppConfiguration. @@ -49,7 +49,7 @@ export async function load( // load() method is called in the application's startup code path. // Unhandled exceptions cause application crash which can result in crash loops as orchestrators attempt to restart the application. // Knowing the intended usage of the provider in startup code path, we mitigate back-to-back crash loops from overloading the server with requests by waiting a minimum time to propagate fatal errors. - const delay = MIN_DELAY_FOR_UNHANDLED_ERROR - (Date.now() - startTimestamp); + const delay = MIN_DELAY_FOR_UNHANDLED_ERROR_IN_MS - (Date.now() - startTimestamp); if (delay > 0) { await new Promise((resolve) => setTimeout(resolve, delay)); } diff --git a/src/refresh/RefreshTimer.ts b/src/refresh/RefreshTimer.ts index cf4deca5..2c77df53 100644 --- a/src/refresh/RefreshTimer.ts +++ b/src/refresh/RefreshTimer.ts @@ -3,15 +3,15 @@ export class RefreshTimer { #backoffEnd: number; // Timestamp - #interval: number; + readonly interval: number; constructor(interval: number) { if (interval <= 0) { throw new RangeError(`Refresh interval must be greater than 0. Given: ${interval}`); } - this.#interval = interval; - this.#backoffEnd = Date.now() + this.#interval; + this.interval = interval; + this.#backoffEnd = Date.now() + this.interval; } canRefresh(): boolean { @@ -19,6 +19,6 @@ export class RefreshTimer { } reset(): void { - this.#backoffEnd = Date.now() + this.#interval; + this.#backoffEnd = Date.now() + this.interval; } } diff --git a/src/refresh/refreshOptions.ts b/src/refresh/refreshOptions.ts index 202c7340..9b82b6d9 100644 --- a/src/refresh/refreshOptions.ts +++ b/src/refresh/refreshOptions.ts @@ -3,8 +3,8 @@ import { WatchedSetting } from "../WatchedSetting.js"; -export const DEFAULT_REFRESH_INTERVAL_IN_MS = 30 * 1000; -export const MIN_REFRESH_INTERVAL_IN_MS = 1 * 1000; +export const DEFAULT_REFRESH_INTERVAL_IN_MS = 30_000; +export const MIN_REFRESH_INTERVAL_IN_MS = 1_000; export interface RefreshOptions { /** diff --git a/src/requestTracing/constants.ts b/src/requestTracing/constants.ts index cfed8317..6f9311b4 100644 --- a/src/requestTracing/constants.ts +++ b/src/requestTracing/constants.ts @@ -49,6 +49,7 @@ export const REPLICA_COUNT_KEY = "ReplicaCount"; // Tag names export const KEY_VAULT_CONFIGURED_TAG = "UsesKeyVault"; +export const KEY_VAULT_REFRESH_CONFIGURED_TAG = "RefreshesKeyVault"; export const FAILOVER_REQUEST_TAG = "Failover"; // Compact feature tags diff --git a/src/requestTracing/utils.ts b/src/requestTracing/utils.ts index af6ef0b8..ada90382 100644 --- a/src/requestTracing/utils.ts +++ b/src/requestTracing/utils.ts @@ -19,6 +19,7 @@ import { HOST_TYPE_KEY, HostType, KEY_VAULT_CONFIGURED_TAG, + KEY_VAULT_REFRESH_CONFIGURED_TAG, KUBERNETES_ENV_VAR, NODEJS_DEV_ENV_VAL, NODEJS_ENV_VAR, @@ -121,10 +122,13 @@ function createCorrelationContextHeader(requestTracingOptions: RequestTracingOpt const appConfigOptions = requestTracingOptions.appConfigOptions; if (appConfigOptions?.keyVaultOptions) { - const { credential, secretClients, secretResolver } = appConfigOptions.keyVaultOptions; + const { credential, secretClients, secretRefreshIntervalInMs, secretResolver } = appConfigOptions.keyVaultOptions; if (credential !== undefined || secretClients?.length || secretResolver !== undefined) { tags.push(KEY_VAULT_CONFIGURED_TAG); } + if (secretRefreshIntervalInMs !== undefined) { + tags.push(KEY_VAULT_REFRESH_CONFIGURED_TAG); + } } const featureFlagTracing = requestTracingOptions.featureFlagTracing; diff --git a/src/types.ts b/src/types.ts index bef8b6b9..21ce23f0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,6 +30,16 @@ export type SettingSelector = { */ labelFilter?: string + /** + * The tag filter to apply when querying Azure App Configuration for key-values. + * + * @remarks + * Each tag filter must follow the format "tagName=tagValue". Only those key-values will be loaded whose tags match all the tags provided here. + * Built in tag filter value is `TagFilter.Null`, which indicates the tag has no value. For example, `tagName=${TagFilter.Null}` will match all key-values with the tag "tagName" that has no value. + * Up to 5 tag filters can be provided. If no tag filters are provided, key-values will not be filtered based on tags. + */ + tagFilters?: string[] + /** * The name of snapshot to load from App Configuration. * @@ -59,3 +69,13 @@ export enum LabelFilter { */ Null = "\0" } + +/** + * TagFilter is used to filter key-values based on tags. + */ +export enum TagFilter { + /** + * Represents empty tag value. + */ + Null = "" +} diff --git a/src/version.ts b/src/version.ts index 0200538f..f61789c4 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 = "2.1.0"; +export const VERSION = "2.2.0"; diff --git a/test/featureFlag.test.ts b/test/featureFlag.test.ts index 14586cf7..3c42007c 100644 --- a/test/featureFlag.test.ts +++ b/test/featureFlag.test.ts @@ -55,6 +55,9 @@ const mockedKVs = [{ createMockedFeatureFlag("FlagWithTestLabel", { enabled: true }, {label: "Test"}), createMockedFeatureFlag("Alpha_1", { enabled: true }), createMockedFeatureFlag("Alpha_2", { enabled: false }), + createMockedFeatureFlag("DevFeatureFlag", { enabled: true }, { tags: { "environment": "dev" } }), + createMockedFeatureFlag("ProdFeatureFlag", { enabled: false }, { tags: { "environment": "prod" } }), + createMockedFeatureFlag("TaggedFeature", { enabled: true }, { tags: { "team": "backend", "priority": "high" } }), createMockedFeatureFlag("Telemetry_1", { enabled: true, telemetry: { enabled: true } }, { etag: "ETag"}), createMockedFeatureFlag("Telemetry_2", { enabled: true, telemetry: { enabled: true } }, { etag: "ETag", label: "Test"}), createMockedFeatureFlag("NoPercentileAndSeed", { @@ -338,6 +341,78 @@ describe("feature flags", function () { expect(featureFlag.telemetry.metadata.FeatureFlagReference).equals(`${createMockedEndpoint()}/kv/.appconfig.featureflag/Telemetry_2?label=Test`); }); + it("should load feature flags using tag filters", async () => { + const connectionString = createMockedConnectionString(); + + // Test filtering by environment=dev tag + const settingsWithDevTag = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [{ + keyFilter: "*", + tagFilters: ["environment=dev"] + }] + } + }); + + expect(settingsWithDevTag).not.undefined; + expect(settingsWithDevTag.get("feature_management")).not.undefined; + let featureFlags = settingsWithDevTag.get("feature_management").feature_flags; + expect(featureFlags).not.undefined; + expect((featureFlags as []).length).equals(1); + expect(featureFlags[0].id).equals("DevFeatureFlag"); + expect(featureFlags[0].enabled).equals(true); + + // Test filtering by environment=prod tag + const settingsWithProdTag = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [{ + keyFilter: "*", + tagFilters: ["environment=prod"] + }] + } + }); + + featureFlags = settingsWithProdTag.get("feature_management").feature_flags; + expect(featureFlags).not.undefined; + expect((featureFlags as []).length).equals(1); + expect(featureFlags[0].id).equals("ProdFeatureFlag"); + expect(featureFlags[0].enabled).equals(false); + + // Test filtering by multiple tags (team=backend AND priority=high) + const settingsWithMultipleTags = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [{ + keyFilter: "*", + tagFilters: ["team=backend", "priority=high"] + }] + } + }); + + featureFlags = settingsWithMultipleTags.get("feature_management").feature_flags; + expect(featureFlags).not.undefined; + expect((featureFlags as []).length).equals(1); + expect(featureFlags[0].id).equals("TaggedFeature"); + expect(featureFlags[0].enabled).equals(true); + + // Test filtering by non-existent tag + const settingsWithNonExistentTag = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [{ + keyFilter: "*", + tagFilters: ["nonexistent=tag"] + }] + } + }); + + featureFlags = settingsWithNonExistentTag.get("feature_management").feature_flags; + expect(featureFlags).not.undefined; + expect((featureFlags as []).length).equals(0); + }); + it("should load feature flags from snapshot", async () => { const snapshotName = "Test"; mockAppConfigurationClientGetSnapshot(snapshotName, {compositionType: "key"}); diff --git a/test/json.test.ts b/test/json.test.ts index a2b57907..fbb5f03e 100644 --- a/test/json.test.ts +++ b/test/json.test.ts @@ -60,7 +60,7 @@ describe("json", function () { createMockedJsonKeyValue("json.settings.object", "{}"), createMockedJsonKeyValue("json.settings.array", "[]"), createMockedJsonKeyValue("json.settings.number", "8"), - createMockedJsonKeyValue("json.settings.string", "string"), + createMockedJsonKeyValue("json.settings.string", "\"string\""), createMockedJsonKeyValue("json.settings.false", "false"), createMockedJsonKeyValue("json.settings.true", "true"), createMockedJsonKeyValue("json.settings.null", "null"), @@ -88,4 +88,87 @@ describe("json", function () { expect(settings.get("json.settings.emptyString")).eq("", "is empty string"); expect(settings.get("json.settings.illegalString")).eq("[unclosed", "is illegal string"); }); + + it("should load json values with comments", async () => { + // Test various comment styles and positions + const mixedCommentStylesValue = `{ + // Single line comment at start + "ApiSettings": { + "BaseUrl": "https://api.example.com", // Inline single line + /* Multi-line comment + spanning multiple lines */ + "ApiKey": "secret-key", + "Endpoints": [ + // Comment before array element + "/users", + /* Comment between elements */ + "/orders", + "/products" // Comment after element + ] + }, + // Test edge cases + "StringWithSlashes": "This is not a // comment", + "StringWithStars": "This is not a /* comment */", + "UrlValue": "https://example.com/path", // This is a real comment + "EmptyComment": "value", // + /**/ + "AfterEmptyComment": "value2" + /* Final multi-line comment */ + }`; + + // Test invalid JSON with comments + const invalidJsonWithCommentsValue = `// This is a comment + { invalid json structure + // Another comment + missing quotes and braces`; + + // Test only comments (should be invalid JSON) + const onlyCommentsValue = ` + // Just comments + /* No actual content */ + `; + + const keyValues = [ + createMockedJsonKeyValue("MixedCommentStyles", mixedCommentStylesValue), + createMockedJsonKeyValue("InvalidJsonWithComments", invalidJsonWithCommentsValue), + createMockedJsonKeyValue("OnlyComments", onlyCommentsValue) + ]; + + mockAppConfigurationClientListConfigurationSettings([keyValues]); + + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString); + expect(settings).not.undefined; + + // Verify mixed comment styles are properly parsed + const mixedConfig = settings.get("MixedCommentStyles"); + expect(mixedConfig).not.undefined; + expect(mixedConfig.ApiSettings).not.undefined; + expect(mixedConfig.ApiSettings.BaseUrl).eq("https://api.example.com"); + expect(mixedConfig.ApiSettings.ApiKey).eq("secret-key"); + expect(mixedConfig.ApiSettings.Endpoints).not.undefined; + expect(Array.isArray(mixedConfig.ApiSettings.Endpoints)).eq(true); + expect(mixedConfig.ApiSettings.Endpoints[0]).eq("/users"); + expect(mixedConfig.ApiSettings.Endpoints[1]).eq("/orders"); + expect(mixedConfig.ApiSettings.Endpoints[2]).eq("/products"); + + // Verify edge cases where comment-like text appears in strings + expect(mixedConfig.StringWithSlashes).eq("This is not a // comment"); + expect(mixedConfig.StringWithStars).eq("This is not a /* comment */"); + expect(mixedConfig.UrlValue).eq("https://example.com/path"); + expect(mixedConfig.EmptyComment).eq("value"); + expect(mixedConfig.AfterEmptyComment).eq("value2"); + + // Invalid JSON should fall back to string value + const invalidConfig = settings.get("InvalidJsonWithComments"); + expect(invalidConfig).not.undefined; + expect(typeof invalidConfig).eq("string"); + expect(invalidConfig).eq(invalidJsonWithCommentsValue); + + // Only comments should be treated as string value (invalid JSON) + const onlyCommentsConfig = settings.get("OnlyComments"); + expect(onlyCommentsConfig).not.undefined; + expect(typeof onlyCommentsConfig).eq("string"); + expect(onlyCommentsConfig).eq(onlyCommentsValue); + }); }); diff --git a/test/keyvault.test.ts b/test/keyvault.test.ts index 8fd15a19..a48d633e 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 { MAX_TIME_OUT, sinon, createMockedConnectionString, createMockedTokenCredential, mockAppConfigurationClientListConfigurationSettings, mockSecretClientGetSecret, restoreMocks, createMockedKeyVaultReference } from "./utils/testHelper.js"; +import { MAX_TIME_OUT, sinon, createMockedConnectionString, createMockedTokenCredential, mockAppConfigurationClientListConfigurationSettings, mockSecretClientGetSecret, restoreMocks, createMockedKeyVaultReference, sleepInMs } from "./utils/testHelper.js"; import { KeyVaultSecret, SecretClient } from "@azure/keyvault-secrets"; const mockedData = [ @@ -140,3 +140,62 @@ describe("key vault reference", function () { expect(settings.get("TestKeyFixedVersion")).eq("OldSecretValue"); }); }); + +describe("key vault secret refresh", function () { + this.timeout(MAX_TIME_OUT); + + beforeEach(() => { + const data = [ + ["TestKey", "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName", "SecretValue"] + ]; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const kvs = data.map(([key, vaultUri, _value]) => createMockedKeyVaultReference(key, vaultUri)); + mockAppConfigurationClientListConfigurationSettings([kvs]); + }); + + afterEach(() => { + restoreMocks(); + }); + + it("should not allow secret refresh interval less than 1 minute", async () => { + const connectionString = createMockedConnectionString(); + const loadWithInvalidSecretRefreshInterval = load(connectionString, { + keyVaultOptions: { + secretClients: [ + new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()), + ], + secretRefreshIntervalInMs: 59999 // less than 60_000 milliseconds + } + }); + return expect(loadWithInvalidSecretRefreshInterval).eventually.rejectedWith("The Key Vault secret refresh interval cannot be less than 60000 milliseconds."); + }); + + it("should reload key vault secret when there is no change to key-values", async () => { + const client = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()); + const stub = sinon.stub(client, "getSecret"); + stub.onCall(0).resolves({ value: "SecretValue" } as KeyVaultSecret); + stub.onCall(1).resolves({ value: "SecretValue - Updated" } as KeyVaultSecret); + + const settings = await load(createMockedConnectionString(), { + keyVaultOptions: { + secretClients: [ + client + ], + credential: createMockedTokenCredential(), + secretRefreshIntervalInMs: 60_000 + } + }); + expect(settings).not.undefined; + expect(settings.get("TestKey")).eq("SecretValue"); + + await sleepInMs(30_000); + await settings.refresh(); + // use cached value + expect(settings.get("TestKey")).eq("SecretValue"); + + await sleepInMs(30_000); + await settings.refresh(); + // secret refresh interval expires, reload secret value + expect(settings.get("TestKey")).eq("SecretValue - Updated"); + }); +}); diff --git a/test/load.test.ts b/test/load.test.ts index 7806789d..244782e9 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -29,10 +29,12 @@ const mockedKVs = [{ }, { key: "TestKey", label: "Test", + tags: {"testTag": ""}, value: "TestValue", }, { key: "TestKey", label: "Prod", + tags: {"testTag": ""}, value: "TestValueForProd", }, { key: "KeyForNullValue", @@ -73,6 +75,30 @@ const mockedKVs = [{ } }), contentType: "application/vnd.microsoft.appconfig.ff+json;charset=utf-8" +}, { + key: "keyWithMultipleTags", + value: "someValue", + tags: {"tag1": "someValue", "tag2": "someValue"} +}, { + key: "keyWithTag1", + value: "someValue1", + tags: {"tag1": "someValue"} +}, { + key: "keyWithTag2", + value: "someValue2", + tags: {"tag2": "someValue"} +}, { + key: "keyWithNullTag", + value: "valueWithNullTag", + tags: {"nullTag": null} +}, { + key: "keyWithEscapedComma", + value: "valueWithEscapedComma", + tags: {"tag": "value\\,with\\,commas"} +}, { + key: "keyWithEmptyTag", + value: "valueWithEmptyTag", + tags: {"emptyTag": ""} } ].map(createMockedKeyValue); @@ -138,7 +164,7 @@ describe("load", function () { snapshotName: "Test", labelFilter: "\0" }] - })).eventually.rejectedWith("Key or label filter should not be used for a snapshot."); + })).eventually.rejectedWith("Key, label or tag filters should not be specified while selecting a snapshot."); }); it("should not include feature flags directly in the settings", async () => { @@ -165,6 +191,79 @@ describe("load", function () { expect(settings.get("app.settings.fontFamily")).undefined; }); + it("should filter by tags, has(key) and get(key) should work", async () => { + const connectionString = createMockedConnectionString(); + const loadWithTag1 = await load(connectionString, { + selectors: [{ + keyFilter: "*", + tagFilters: ["tag1=someValue"] + }] + }); + expect(loadWithTag1.has("keyWithTag1")).true; + expect(loadWithTag1.get("keyWithTag1")).eq("someValue1"); + expect(loadWithTag1.has("keyWithTag2")).false; + expect(loadWithTag1.has("keyWithMultipleTags")).true; + expect(loadWithTag1.get("keyWithMultipleTags")).eq("someValue"); + + const loadWithMultipleTags = await load(connectionString, { + selectors: [{ + keyFilter: "*", + tagFilters: ["tag1=someValue", "tag2=someValue"] + }] + }); + expect(loadWithMultipleTags.has("keyWithTag1")).false; + expect(loadWithMultipleTags.has("keyWithTag2")).false; + expect(loadWithMultipleTags.has("keyWithMultipleTags")).true; + expect(loadWithMultipleTags.get("keyWithMultipleTags")).eq("someValue"); + }); + + it("should filter by nullTag to load key values with null tag", async () => { + const connectionString = createMockedConnectionString(); + const loadWithNullTag = await load(connectionString, { + selectors: [{ + keyFilter: "*", + tagFilters: ["nullTag=\0"] + }] + }); + + // Should include only key values with nullTag=\0 + expect(loadWithNullTag.has("keyWithNullTag")).true; + expect(loadWithNullTag.get("keyWithNullTag")).eq("valueWithNullTag"); + + // Should exclude key values with other tags + expect(loadWithNullTag.has("keyWithEmptyTag")).false; + }); + + it("should filter by tags with escaped comma characters", async () => { + const connectionString = createMockedConnectionString(); + const loadWithEscapedComma = await load(connectionString, { + selectors: [{ + keyFilter: "*", + tagFilters: ["tag=value\\,with\\,commas"] + }] + }); + + expect(loadWithEscapedComma.has("keyWithEscapedComma")).true; + expect(loadWithEscapedComma.get("keyWithEscapedComma")).eq("valueWithEscapedComma"); + }); + + it("should filter by empty tag value to load key values with empty tag", async () => { + const connectionString = createMockedConnectionString(); + const loadWithEmptyTag = await load(connectionString, { + selectors: [{ + keyFilter: "*", + tagFilters: ["emptyTag="] + }] + }); + + // Should include key values with emptyTag="" + expect(loadWithEmptyTag.has("keyWithEmptyTag")).true; + expect(loadWithEmptyTag.get("keyWithEmptyTag")).eq("valueWithEmptyTag"); + + // Should exclude key values with other tags + expect(loadWithEmptyTag.has("keyWithNullTag")).false; + }); + it("should also work with other ReadonlyMap APIs", async () => { const connectionString = createMockedConnectionString(); const settings = await load(connectionString, { @@ -274,6 +373,45 @@ describe("load", function () { return expect(loadWithMultipleLabelFilter).to.eventually.rejectedWith("The characters '*' and ',' are not supported in label filters."); }); + it("should throw exception when there is any invalid tag filter", async () => { + const connectionString = createMockedConnectionString(); + const loadWithInvalidTagFilter = load(connectionString, { + selectors: [{ + keyFilter: "*", + tagFilters: ["emptyTag"] + }] + }); + return expect(loadWithInvalidTagFilter).to.eventually.rejectedWith("Tag filter must follow the format \"tagName=tagValue\""); + }); + + it("should throw exception when too many tag filters are provided", async () => { + const connectionString = createMockedConnectionString(); + + // Create a list with more than the maximum allowed tag filters (assuming max is 5) + const tooManyTagFilters = [ + "Environment=Development", + "Team=Backend", + "Priority=High", + "Version=1.0", + "Stage=Testing", + "Region=EastUS" // This should exceed the limit + ]; + try { + await load(connectionString, { + selectors: [{ + keyFilter: "*", + tagFilters: tooManyTagFilters + }] + }); + } catch (error) { + expect(error.message).eq("Failed to load."); + expect(error.cause.message).eq("Invalid request parameter 'tags'. Maximum number of tag filters is 5."); + return; + } + // we should never reach here, load should throw an error + throw new Error("Expected load to throw."); + }); + it("should override config settings with same key but different label", async () => { const connectionString = createMockedConnectionString(); const settings = await load(connectionString, { @@ -294,13 +432,15 @@ describe("load", function () { const settings = await load(connectionString, { selectors: [{ keyFilter: "Test*", - labelFilter: "Prod" + labelFilter: "Prod", + tagFilters: ["testTag="] }, { keyFilter: "Test*", labelFilter: "Test" }, { keyFilter: "Test*", - labelFilter: "Prod" + labelFilter: "Prod", + tagFilters: ["testTag="] }] }); expect(settings).not.undefined; diff --git a/test/refresh.test.ts b/test/refresh.test.ts index d03d9436..0468475d 100644 --- a/test/refresh.test.ts +++ b/test/refresh.test.ts @@ -39,7 +39,8 @@ describe("dynamic refresh", function () { mockedKVs = [ { value: "red", key: "app.settings.fontColor" }, { value: "40", key: "app.settings.fontSize" }, - { value: "30", key: "app.settings.fontSize", label: "prod" } + { value: "30", key: "app.settings.fontSize", label: "prod" }, + { value: "someValue", key: "TestTagKey", tags: { "env": "dev" } } ].map(createMockedKeyValue); mockAppConfigurationClientListConfigurationSettings([mockedKVs], listKvCallback); mockAppConfigurationClientGetConfigurationSetting(mockedKVs, getKvCallback); @@ -55,7 +56,7 @@ describe("dynamic refresh", function () { const connectionString = createMockedConnectionString(); const settings = await load(connectionString); const refreshCall = settings.refresh(); - return expect(refreshCall).eventually.rejectedWith("Refresh is not enabled for key-values or feature flags."); + return expect(refreshCall).eventually.rejectedWith("Refresh is not enabled for key-values, feature flags or Key Vault secrets."); }); it("should not allow refresh interval less than 1 second", async () => { @@ -117,7 +118,7 @@ describe("dynamic refresh", function () { it("should throw error when calling onRefresh when refresh is not enabled", async () => { const connectionString = createMockedConnectionString(); const settings = await load(connectionString); - expect(() => settings.onRefresh(() => { })).throws("Refresh is not enabled for key-values or feature flags."); + expect(() => settings.onRefresh(() => { })).throws("Refresh is not enabled for key-values, feature flags or Key Vault secrets."); }); it("should only update values after refreshInterval", async () => { @@ -435,10 +436,38 @@ describe("dynamic refresh", function () { expect(getKvRequestCount).eq(1); expect(settings.get("app.settings.fontColor")).eq("blue"); }); + + it("should refresh key values using tag filters", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + selectors: [{ + keyFilter: "*", + tagFilters: ["env=dev"] + }], + refreshOptions: { + enabled: true, + refreshIntervalInMs: 2000 + } + }); + + expect(settings).not.undefined; + + // Verify only dev-tagged items are loaded + expect(settings.get("TestTagKey")).eq("someValue"); + + // Change the dev-tagged key value + updateSetting("TestTagKey", "newValue"); + + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + + // Verify changes are reflected + expect(settings.get("TestTagKey")).eq("newValue"); + }); }); describe("dynamic refresh feature flags", function () { - this.timeout(10000); + this.timeout(MAX_TIME_OUT); beforeEach(() => { }); @@ -549,4 +578,50 @@ describe("dynamic refresh feature flags", function () { expect(getKvRequestCount).eq(0); expect(refreshSuccessfulCount).eq(1); // change in feature flags, because page etags are different. }); + + it("should refresh feature flags using tag filters", async () => { + mockedKVs = [ + createMockedFeatureFlag("DevFeature", { enabled: true }, { tags: { "env": "dev" } }), + createMockedFeatureFlag("ProdFeature", { enabled: false }, { tags: { "env": "prod" } }) + ]; + mockAppConfigurationClientListConfigurationSettings([mockedKVs], listKvCallback); + mockAppConfigurationClientGetConfigurationSetting(mockedKVs, getKvCallback); + + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [{ + keyFilter: "*", + tagFilters: ["env=dev"] + }], + refresh: { + enabled: true, + refreshIntervalInMs: 2000 + } + } + }); + + expect(settings).not.undefined; + + const featureManagement = settings.get("feature_management"); + expect(featureManagement).not.undefined; + expect(featureManagement.feature_flags).not.undefined; + expect(featureManagement.feature_flags.length).eq(1); + expect(featureManagement.feature_flags[0].id).eq("DevFeature"); + expect(featureManagement.feature_flags[0].enabled).eq(true); + + // Change the dev-tagged feature flag + updateSetting(".appconfig.featureflag/DevFeature", JSON.stringify({ + "id": "DevFeature", + "enabled": false + })); + + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + + const updatedFeatureManagement = settings.get("feature_management"); + expect(updatedFeatureManagement.feature_flags[0].id).eq("DevFeature"); + expect(updatedFeatureManagement.feature_flags[0].enabled).eq(false); + }); }); diff --git a/test/utils/testHelper.ts b/test/utils/testHelper.ts index 6b1baca4..de0d2470 100644 --- a/test/utils/testHelper.ts +++ b/test/utils/testHelper.ts @@ -13,7 +13,7 @@ import * as crypto from "crypto"; import { ConfigurationClientManager } from "../../src/ConfigurationClientManager.js"; import { ConfigurationClientWrapper } from "../../src/ConfigurationClientWrapper.js"; -const MAX_TIME_OUT = 20000; +const MAX_TIME_OUT = 100_000; const TEST_CLIENT_ID = "00000000-0000-0000-0000-000000000000"; const TEST_TENANT_ID = "00000000-0000-0000-0000-000000000000"; @@ -26,6 +26,12 @@ function _sha256(input) { function _filterKVs(unfilteredKvs: ConfigurationSetting[], listOptions: any) { const keyFilter = listOptions?.keyFilter ?? "*"; const labelFilter = listOptions?.labelFilter ?? "*"; + const tagsFilter = listOptions?.tagsFilter ?? []; + + if (tagsFilter.length > 5) { + throw new RestError("Invalid request parameter 'tags'. Maximum number of tag filters is 5.", { statusCode: 400 }); + } + return unfilteredKvs.filter(kv => { const keyMatched = keyFilter.endsWith("*") ? kv.key.startsWith(keyFilter.slice(0, -1)) : kv.key === keyFilter; let labelMatched = false; @@ -38,7 +44,17 @@ function _filterKVs(unfilteredKvs: ConfigurationSetting[], listOptions: any) { } else { labelMatched = kv.label === labelFilter; } - return keyMatched && labelMatched; + let tagsMatched = true; + if (tagsFilter.length > 0) { + tagsMatched = tagsFilter.every(tag => { + const [tagName, tagValue] = tag.split("="); + if (tagValue === "\0") { + return kv.tags && kv.tags[tagName] === null; + } + return kv.tags && kv.tags[tagName] === tagValue; + }); + } + return keyMatched && labelMatched && tagsMatched; }); } @@ -233,8 +249,7 @@ const createMockedKeyVaultReference = (key: string, vaultUri: string): Configura key, contentType: "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8", lastModified: new Date(), - tags: { - }, + tags: {}, etag: uuid.v4(), isReadOnly: false, });