Skip to content

Commit 6d1b6a1

Browse files
Merge pull request #204 from Azure/main
Merge main to preview
2 parents f413119 + d59f8cf commit 6d1b6a1

17 files changed

+307
-122
lines changed

src/AzureAppConfigurationImpl.ts

Lines changed: 99 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { IKeyValueAdapter } from "./IKeyValueAdapter.js";
2222
import { JsonKeyValueAdapter } from "./JsonKeyValueAdapter.js";
2323
import { DEFAULT_STARTUP_TIMEOUT_IN_MS } from "./StartupOptions.js";
2424
import { DEFAULT_REFRESH_INTERVAL_IN_MS, MIN_REFRESH_INTERVAL_IN_MS } from "./refresh/refreshOptions.js";
25+
import { MIN_SECRET_REFRESH_INTERVAL_IN_MS } from "./keyvault/KeyVaultOptions.js";
2526
import { Disposable } from "./common/disposable.js";
2627
import { base64Helper, jsonSorter } from "./common/utils.js";
2728
import {
@@ -99,16 +100,22 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
99100
/**
100101
* Aka watched settings.
101102
*/
103+
#refreshEnabled: boolean = false;
102104
#sentinels: ConfigurationSettingId[] = [];
103105
#watchAll: boolean = false;
104106
#kvRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS;
105107
#kvRefreshTimer: RefreshTimer;
106108

107109
// Feature flags
110+
#featureFlagEnabled: boolean = false;
111+
#featureFlagRefreshEnabled: boolean = false;
108112
#ffRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS;
109113
#ffRefreshTimer: RefreshTimer;
110114

111115
// Key Vault references
116+
#secretRefreshEnabled: boolean = false;
117+
#secretReferences: ConfigurationSetting[] = []; // cached key vault references
118+
#secretRefreshTimer: RefreshTimer;
112119
#resolveSecretsInParallel: boolean = false;
113120

114121
/**
@@ -137,14 +144,15 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
137144
this.#featureFlagTracing = new FeatureFlagTracingOptions();
138145
}
139146

140-
if (options?.trimKeyPrefixes) {
147+
if (options?.trimKeyPrefixes !== undefined) {
141148
this.#sortedTrimKeyPrefixes = [...options.trimKeyPrefixes].sort((a, b) => b.localeCompare(a));
142149
}
143150

144151
// if no selector is specified, always load key values using the default selector: key="*" and label="\0"
145152
this.#kvSelectors = getValidKeyValueSelectors(options?.selectors);
146153

147-
if (options?.refreshOptions?.enabled) {
154+
if (options?.refreshOptions?.enabled === true) {
155+
this.#refreshEnabled = true;
148156
const { refreshIntervalInMs, watchedSettings } = options.refreshOptions;
149157
if (watchedSettings === undefined || watchedSettings.length === 0) {
150158
this.#watchAll = true; // if no watched settings is specified, then watch all
@@ -164,53 +172,48 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
164172
if (refreshIntervalInMs !== undefined) {
165173
if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) {
166174
throw new RangeError(`The refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`);
167-
} else {
168-
this.#kvRefreshInterval = refreshIntervalInMs;
169175
}
176+
this.#kvRefreshInterval = refreshIntervalInMs;
170177
}
171178
this.#kvRefreshTimer = new RefreshTimer(this.#kvRefreshInterval);
172179
}
173180

174181
// feature flag options
175-
if (options?.featureFlagOptions?.enabled) {
182+
if (options?.featureFlagOptions?.enabled === true) {
183+
this.#featureFlagEnabled = true;
176184
// validate feature flag selectors, only load feature flags when enabled
177185
this.#ffSelectors = getValidFeatureFlagSelectors(options.featureFlagOptions.selectors);
178186

179-
if (options.featureFlagOptions.refresh?.enabled) {
187+
if (options.featureFlagOptions.refresh?.enabled === true) {
188+
this.#featureFlagRefreshEnabled = true;
180189
const { refreshIntervalInMs } = options.featureFlagOptions.refresh;
181190
// custom refresh interval
182191
if (refreshIntervalInMs !== undefined) {
183192
if (refreshIntervalInMs < MIN_REFRESH_INTERVAL_IN_MS) {
184193
throw new RangeError(`The feature flag refresh interval cannot be less than ${MIN_REFRESH_INTERVAL_IN_MS} milliseconds.`);
185-
} else {
186-
this.#ffRefreshInterval = refreshIntervalInMs;
187194
}
195+
this.#ffRefreshInterval = refreshIntervalInMs;
188196
}
189197

190198
this.#ffRefreshTimer = new RefreshTimer(this.#ffRefreshInterval);
191199
}
192200
}
193201

194-
if (options?.keyVaultOptions?.parallelSecretResolutionEnabled) {
195-
this.#resolveSecretsInParallel = options.keyVaultOptions.parallelSecretResolutionEnabled;
202+
if (options?.keyVaultOptions !== undefined) {
203+
const { secretRefreshIntervalInMs } = options.keyVaultOptions;
204+
if (secretRefreshIntervalInMs !== undefined) {
205+
if (secretRefreshIntervalInMs < MIN_SECRET_REFRESH_INTERVAL_IN_MS) {
206+
throw new RangeError(`The Key Vault secret refresh interval cannot be less than ${MIN_SECRET_REFRESH_INTERVAL_IN_MS} milliseconds.`);
207+
}
208+
this.#secretRefreshEnabled = true;
209+
this.#secretRefreshTimer = new RefreshTimer(secretRefreshIntervalInMs);
210+
}
211+
this.#resolveSecretsInParallel = options.keyVaultOptions.parallelSecretResolutionEnabled ?? false;
196212
}
197-
198-
this.#adapters.push(new AzureKeyVaultKeyValueAdapter(options?.keyVaultOptions));
213+
this.#adapters.push(new AzureKeyVaultKeyValueAdapter(options?.keyVaultOptions, this.#secretRefreshTimer));
199214
this.#adapters.push(new JsonKeyValueAdapter());
200215
}
201216

202-
get #refreshEnabled(): boolean {
203-
return !!this.#options?.refreshOptions?.enabled;
204-
}
205-
206-
get #featureFlagEnabled(): boolean {
207-
return !!this.#options?.featureFlagOptions?.enabled;
208-
}
209-
210-
get #featureFlagRefreshEnabled(): boolean {
211-
return this.#featureFlagEnabled && !!this.#options?.featureFlagOptions?.refresh?.enabled;
212-
}
213-
214217
get #requestTraceOptions(): RequestTracingOptions {
215218
return {
216219
enabled: this.#requestTracingEnabled,
@@ -345,8 +348,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
345348
* Refreshes the configuration.
346349
*/
347350
async refresh(): Promise<void> {
348-
if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) {
349-
throw new InvalidOperationError("Refresh is not enabled for key-values or feature flags.");
351+
if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled && !this.#secretRefreshEnabled) {
352+
throw new InvalidOperationError("Refresh is not enabled for key-values, feature flags or Key Vault secrets.");
350353
}
351354

352355
if (this.#refreshInProgress) {
@@ -364,8 +367,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
364367
* Registers a callback function to be called when the configuration is refreshed.
365368
*/
366369
onRefresh(listener: () => any, thisArg?: any): Disposable {
367-
if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled) {
368-
throw new InvalidOperationError("Refresh is not enabled for key-values or feature flags.");
370+
if (!this.#refreshEnabled && !this.#featureFlagRefreshEnabled && !this.#secretRefreshEnabled) {
371+
throw new InvalidOperationError("Refresh is not enabled for key-values, feature flags or Key Vault secrets.");
369372
}
370373

371374
const boundedListener = listener.bind(thisArg);
@@ -433,8 +436,20 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
433436

434437
async #refreshTasks(): Promise<void> {
435438
const refreshTasks: Promise<boolean>[] = [];
436-
if (this.#refreshEnabled) {
437-
refreshTasks.push(this.#refreshKeyValues());
439+
if (this.#refreshEnabled || this.#secretRefreshEnabled) {
440+
refreshTasks.push(
441+
this.#refreshKeyValues()
442+
.then(keyValueRefreshed => {
443+
// Only refresh secrets if key values didn't change and secret refresh is enabled
444+
// If key values are refreshed, all secret references will be refreshed as well.
445+
if (!keyValueRefreshed && this.#secretRefreshEnabled) {
446+
// Returns the refreshSecrets promise directly.
447+
// in a Promise chain, this automatically flattens nested Promises without requiring await.
448+
return this.#refreshSecrets();
449+
}
450+
return keyValueRefreshed;
451+
})
452+
);
438453
}
439454
if (this.#featureFlagRefreshEnabled) {
440455
refreshTasks.push(this.#refreshFeatureFlags());
@@ -538,35 +553,32 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
538553
* Loads selected key-values and watched settings (sentinels) for refresh from App Configuration to the local configuration.
539554
*/
540555
async #loadSelectedAndWatchedKeyValues() {
556+
this.#secretReferences = []; // clear all cached key vault reference configuration settings
541557
const keyValues: [key: string, value: unknown][] = [];
542558
const loadedSettings: ConfigurationSetting[] = await this.#loadConfigurationSettings();
543559
if (this.#refreshEnabled && !this.#watchAll) {
544560
await this.#updateWatchedKeyValuesEtag(loadedSettings);
545561
}
546562

547563
if (this.#requestTracingEnabled && this.#aiConfigurationTracing !== undefined) {
548-
// Reset old AI configuration tracing in order to track the information present in the current response from server.
564+
// reset old AI configuration tracing in order to track the information present in the current response from server
549565
this.#aiConfigurationTracing.reset();
550566
}
551567

552-
const secretResolutionPromises: Promise<void>[] = [];
553568
for (const setting of loadedSettings) {
554-
if (this.#resolveSecretsInParallel && isSecretReference(setting)) {
555-
// secret references are resolved asynchronously to improve performance
556-
const secretResolutionPromise = this.#processKeyValue(setting)
557-
.then(([key, value]) => {
558-
keyValues.push([key, value]);
559-
});
560-
secretResolutionPromises.push(secretResolutionPromise);
569+
if (isSecretReference(setting)) {
570+
this.#secretReferences.push(setting); // cache secret references for resolve/refresh secret separately
561571
continue;
562572
}
563573
// adapt configuration settings to key-values
564574
const [key, value] = await this.#processKeyValue(setting);
565575
keyValues.push([key, value]);
566576
}
567-
if (secretResolutionPromises.length > 0) {
568-
// wait for all secret resolution promises to be resolved
569-
await Promise.all(secretResolutionPromises);
577+
578+
if (this.#secretReferences.length > 0) {
579+
await this.#resolveSecretReferences(this.#secretReferences, (key, value) => {
580+
keyValues.push([key, value]);
581+
});
570582
}
571583

572584
this.#clearLoadedKeyValues(); // clear existing key-values in case of configuration setting deletion
@@ -634,7 +646,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
634646
*/
635647
async #refreshKeyValues(): Promise<boolean> {
636648
// if still within refresh interval/backoff, return
637-
if (!this.#kvRefreshTimer.canRefresh()) {
649+
if (this.#kvRefreshTimer === undefined || !this.#kvRefreshTimer.canRefresh()) {
638650
return Promise.resolve(false);
639651
}
640652

@@ -658,6 +670,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
658670
}
659671

660672
if (needRefresh) {
673+
for (const adapter of this.#adapters) {
674+
await adapter.onChangeDetected();
675+
}
661676
await this.#loadSelectedAndWatchedKeyValues();
662677
}
663678

@@ -671,7 +686,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
671686
*/
672687
async #refreshFeatureFlags(): Promise<boolean> {
673688
// if still within refresh interval/backoff, return
674-
if (!this.#ffRefreshTimer.canRefresh()) {
689+
if (this.#ffRefreshInterval === undefined || !this.#ffRefreshTimer.canRefresh()) {
675690
return Promise.resolve(false);
676691
}
677692

@@ -684,6 +699,25 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
684699
return Promise.resolve(needRefresh);
685700
}
686701

702+
async #refreshSecrets(): Promise<boolean> {
703+
// if still within refresh interval/backoff, return
704+
if (this.#secretRefreshTimer === undefined || !this.#secretRefreshTimer.canRefresh()) {
705+
return Promise.resolve(false);
706+
}
707+
708+
// if no cached key vault references, return
709+
if (this.#secretReferences.length === 0) {
710+
return Promise.resolve(false);
711+
}
712+
713+
await this.#resolveSecretReferences(this.#secretReferences, (key, value) => {
714+
this.#configMap.set(key, value);
715+
});
716+
717+
this.#secretRefreshTimer.reset();
718+
return Promise.resolve(true);
719+
}
720+
687721
/**
688722
* Checks whether the key-value collection has changed.
689723
* @param selectors - The @see PagedSettingSelector of the kev-value collection.
@@ -812,6 +846,27 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
812846
throw new Error("All fallback clients failed to get configuration settings.");
813847
}
814848

849+
async #resolveSecretReferences(secretReferences: ConfigurationSetting[], resultHandler: (key: string, value: unknown) => void): Promise<void> {
850+
if (this.#resolveSecretsInParallel) {
851+
const secretResolutionPromises: Promise<void>[] = [];
852+
for (const setting of secretReferences) {
853+
const secretResolutionPromise = this.#processKeyValue(setting)
854+
.then(([key, value]) => {
855+
resultHandler(key, value);
856+
});
857+
secretResolutionPromises.push(secretResolutionPromise);
858+
}
859+
860+
// Wait for all secret resolution promises to be resolved
861+
await Promise.all(secretResolutionPromises);
862+
} else {
863+
for (const setting of secretReferences) {
864+
const [key, value] = await this.#processKeyValue(setting);
865+
resultHandler(key, value);
866+
}
867+
}
868+
}
869+
815870
async #processKeyValue(setting: ConfigurationSetting<string>): Promise<[string, unknown]> {
816871
this.#setAIConfigurationTracing(setting);
817872

src/ConfigurationClientManager.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { ArgumentError } from "./common/error.js";
1212

1313
// Configuration client retry options
1414
const CLIENT_MAX_RETRIES = 2;
15-
const CLIENT_MAX_RETRY_DELAY = 60_000; // 1 minute in milliseconds
15+
const CLIENT_MAX_RETRY_DELAY_IN_MS = 60_000;
1616

1717
const TCP_ORIGIN_KEY_NAME = "_origin._tcp";
1818
const ALT_KEY_NAME = "_alt";
@@ -21,9 +21,9 @@ const ENDPOINT_KEY_NAME = "Endpoint";
2121
const ID_KEY_NAME = "Id";
2222
const SECRET_KEY_NAME = "Secret";
2323
const TRUSTED_DOMAIN_LABELS = [".azconfig.", ".appconfig."];
24-
const FALLBACK_CLIENT_EXPIRE_INTERVAL = 60 * 60 * 1000; // 1 hour in milliseconds
25-
const MINIMAL_CLIENT_REFRESH_INTERVAL = 30_000; // 30 seconds in milliseconds
26-
const DNS_RESOLVER_TIMEOUT = 3_000; // 3 seconds in milliseconds, in most cases, dns resolution should be within 200 milliseconds
24+
const FALLBACK_CLIENT_EXPIRE_INTERVAL_IN_MS = 60 * 60 * 1000;
25+
const MINIMAL_CLIENT_REFRESH_INTERVAL_IN_MS = 30_000;
26+
const DNS_RESOLVER_TIMEOUT_IN_MS = 3_000;
2727
const DNS_RESOLVER_TRIES = 2;
2828
const MAX_ALTNATIVE_SRV_COUNT = 10;
2929

@@ -120,11 +120,11 @@ export class ConfigurationClientManager {
120120
const currentTime = Date.now();
121121
// Filter static clients whose backoff time has ended
122122
let availableClients = this.#staticClients.filter(client => client.backoffEndTime <= currentTime);
123-
if (currentTime >= this.#lastFallbackClientRefreshAttempt + MINIMAL_CLIENT_REFRESH_INTERVAL &&
123+
if (currentTime >= this.#lastFallbackClientRefreshAttempt + MINIMAL_CLIENT_REFRESH_INTERVAL_IN_MS &&
124124
(!this.#dynamicClients ||
125125
// All dynamic clients are in backoff means no client is available
126126
this.#dynamicClients.every(client => currentTime < client.backoffEndTime) ||
127-
currentTime >= this.#lastFallbackClientUpdateTime + FALLBACK_CLIENT_EXPIRE_INTERVAL)) {
127+
currentTime >= this.#lastFallbackClientUpdateTime + FALLBACK_CLIENT_EXPIRE_INTERVAL_IN_MS)) {
128128
await this.#discoverFallbackClients(this.endpoint.hostname);
129129
return availableClients.concat(this.#dynamicClients);
130130
}
@@ -142,7 +142,7 @@ export class ConfigurationClientManager {
142142
async refreshClients() {
143143
const currentTime = Date.now();
144144
if (this.#isFailoverable &&
145-
currentTime >= this.#lastFallbackClientRefreshAttempt + MINIMAL_CLIENT_REFRESH_INTERVAL) {
145+
currentTime >= this.#lastFallbackClientRefreshAttempt + MINIMAL_CLIENT_REFRESH_INTERVAL_IN_MS) {
146146
await this.#discoverFallbackClients(this.endpoint.hostname);
147147
}
148148
}
@@ -185,7 +185,7 @@ export class ConfigurationClientManager {
185185

186186
try {
187187
// https://nodejs.org/api/dns.html#dnspromisesresolvesrvhostname
188-
const resolver = new this.#dns.Resolver({timeout: DNS_RESOLVER_TIMEOUT, tries: DNS_RESOLVER_TRIES});
188+
const resolver = new this.#dns.Resolver({timeout: DNS_RESOLVER_TIMEOUT_IN_MS, tries: DNS_RESOLVER_TRIES});
189189
// On success, resolveSrv() returns an array of SrvRecord
190190
// On failure, resolveSrv() throws an error with code 'ENOTFOUND'.
191191
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
266266
// retry options
267267
const defaultRetryOptions = {
268268
maxRetries: CLIENT_MAX_RETRIES,
269-
maxRetryDelayInMs: CLIENT_MAX_RETRY_DELAY,
269+
maxRetryDelayInMs: CLIENT_MAX_RETRY_DELAY_IN_MS,
270270
};
271271
const retryOptions = Object.assign({}, defaultRetryOptions, options?.clientOptions?.retryOptions);
272272

src/IKeyValueAdapter.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,9 @@ export interface IKeyValueAdapter {
1313
* This method process the original configuration setting, and returns processed key and value in an array.
1414
*/
1515
processKeyValue(setting: ConfigurationSetting): Promise<[string, unknown]>;
16+
17+
/**
18+
* This method is called when a change is detected in the configuration setting.
19+
*/
20+
onChangeDetected(): Promise<void>;
1621
}

src/JsonKeyValueAdapter.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,8 @@ export class JsonKeyValueAdapter implements IKeyValueAdapter {
3535
}
3636
return [setting.key, parsedValue];
3737
}
38+
39+
async onChangeDetected(): Promise<void> {
40+
return;
41+
}
3842
}

src/StartupOptions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT license.
33

4-
export const DEFAULT_STARTUP_TIMEOUT_IN_MS = 100 * 1000; // 100 seconds in milliseconds
4+
export const DEFAULT_STARTUP_TIMEOUT_IN_MS = 100_000;
55

66
export interface StartupOptions {
77
/**

0 commit comments

Comments
 (0)