Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 99 additions & 44 deletions src/AzureAppConfigurationImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -91,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;

/**
Expand Down Expand Up @@ -129,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
Expand All @@ -156,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,
Expand Down Expand Up @@ -337,8 +340,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
* Refreshes the configuration.
*/
async refresh(): Promise<void> {
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) {
Expand All @@ -356,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);
Expand Down Expand Up @@ -425,8 +428,20 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {

async #refreshTasks(): Promise<void> {
const refreshTasks: Promise<boolean>[] = [];
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());
Expand Down Expand Up @@ -530,35 +545,32 @@ 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) {
await this.#updateWatchedKeyValuesEtag(loadedSettings);
}

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<void>[] = [];
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
Expand Down Expand Up @@ -626,7 +638,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
*/
async #refreshKeyValues(): Promise<boolean> {
// if still within refresh interval/backoff, return
if (!this.#kvRefreshTimer.canRefresh()) {
if (this.#kvRefreshTimer === undefined || !this.#kvRefreshTimer.canRefresh()) {
return Promise.resolve(false);
}

Expand All @@ -650,6 +662,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
}

if (needRefresh) {
for (const adapter of this.#adapters) {
await adapter.onChangeDetected();
}
await this.#loadSelectedAndWatchedKeyValues();
}

Expand All @@ -663,7 +678,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
*/
async #refreshFeatureFlags(): Promise<boolean> {
// if still within refresh interval/backoff, return
if (!this.#ffRefreshTimer.canRefresh()) {
if (this.#ffRefreshInterval === undefined || !this.#ffRefreshTimer.canRefresh()) {
return Promise.resolve(false);
}

Expand All @@ -676,6 +691,25 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
return Promise.resolve(needRefresh);
}

async #refreshSecrets(): Promise<boolean> {
// 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.
Expand Down Expand Up @@ -804,6 +838,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<void> {
if (this.#resolveSecretsInParallel) {
const secretResolutionPromises: Promise<void>[] = [];
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<string>): Promise<[string, unknown]> {
this.#setAIConfigurationTracing(setting);

Expand Down
18 changes: 9 additions & 9 deletions src/ConfigurationClientManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;

Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down
5 changes: 5 additions & 0 deletions src/IKeyValueAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
}
4 changes: 4 additions & 0 deletions src/JsonKeyValueAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,8 @@ export class JsonKeyValueAdapter implements IKeyValueAdapter {
}
return [setting.key, parsedValue];
}

async onChangeDetected(): Promise<void> {
return;
}
}
2 changes: 1 addition & 1 deletion src/StartupOptions.ts
Original file line number Diff line number Diff line change
@@ -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 {
/**
Expand Down
Loading