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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@azure/app-configuration-provider",
"version": "2.0.2",
"version": "2.1.0",
"description": "The JavaScript configuration provider for Azure App Configuration",
"main": "dist/index.js",
"module": "./dist-esm/index.js",
Expand Down
170 changes: 134 additions & 36 deletions src/AzureAppConfigurationImpl.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { AppConfigurationClient, ConfigurationSetting, ConfigurationSettingId, GetConfigurationSettingOptions, GetConfigurationSettingResponse, ListConfigurationSettingsOptions, featureFlagPrefix, isFeatureFlag } from "@azure/app-configuration";
import {
AppConfigurationClient,
ConfigurationSetting,
ConfigurationSettingId,
GetConfigurationSettingOptions,
GetConfigurationSettingResponse,
ListConfigurationSettingsOptions,
featureFlagPrefix,
isFeatureFlag,
isSecretReference,
GetSnapshotOptions,
GetSnapshotResponse,
KnownSnapshotComposition
} from "@azure/app-configuration";
import { isRestError } from "@azure/core-rest-pipeline";
import { AzureAppConfiguration, ConfigurationObjectConstructionOptions } from "./AzureAppConfiguration.js";
import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions.js";
Expand Down Expand Up @@ -37,7 +50,14 @@ import { FM_PACKAGE_NAME, AI_MIME_PROFILE, AI_CHAT_COMPLETION_MIME_PROFILE } fro
import { parseContentType, isJsonContentType, isFeatureFlagContentType, isSecretReferenceContentType } from "./common/contentType.js";
import { AzureKeyVaultKeyValueAdapter } from "./keyvault/AzureKeyVaultKeyValueAdapter.js";
import { RefreshTimer } from "./refresh/RefreshTimer.js";
import { RequestTracingOptions, getConfigurationSettingWithTrace, listConfigurationSettingsWithTrace, requestTracingEnabled } from "./requestTracing/utils.js";
import {
RequestTracingOptions,
getConfigurationSettingWithTrace,
listConfigurationSettingsWithTrace,
getSnapshotWithTrace,
listConfigurationSettingsForSnapshotWithTrace,
requestTracingEnabled
} from "./requestTracing/utils.js";
import { FeatureFlagTracingOptions } from "./requestTracing/FeatureFlagTracingOptions.js";
import { AIConfigurationTracingOptions } from "./requestTracing/AIConfigurationTracingOptions.js";
import { KeyFilter, LabelFilter, SettingSelector } from "./types.js";
Expand Down Expand Up @@ -91,6 +111,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
#ffRefreshInterval: number = DEFAULT_REFRESH_INTERVAL_IN_MS;
#ffRefreshTimer: RefreshTimer;

// Key Vault references
#resolveSecretsInParallel: boolean = false;

/**
* Selectors of key-values obtained from @see AzureAppConfigurationOptions.selectors
*/
Expand Down Expand Up @@ -171,6 +194,10 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
}
}

if (options?.keyVaultOptions?.parallelSecretResolutionEnabled) {
this.#resolveSecretsInParallel = options.keyVaultOptions.parallelSecretResolutionEnabled;
}

this.#adapters.push(new AzureKeyVaultKeyValueAdapter(options?.keyVaultOptions));
this.#adapters.push(new JsonKeyValueAdapter());
}
Expand Down Expand Up @@ -454,26 +481,49 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
);

for (const selector of selectorsToUpdate) {
const listOptions: ListConfigurationSettingsOptions = {
keyFilter: selector.keyFilter,
labelFilter: selector.labelFilter
};

const pageEtags: string[] = [];
const pageIterator = listConfigurationSettingsWithTrace(
this.#requestTraceOptions,
client,
listOptions
).byPage();
for await (const page of pageIterator) {
pageEtags.push(page.etag ?? "");
for (const setting of page.items) {
if (loadFeatureFlag === isFeatureFlag(setting)) {
loadedSettings.push(setting);
if (selector.snapshotName === undefined) {
const listOptions: ListConfigurationSettingsOptions = {
keyFilter: selector.keyFilter,
labelFilter: selector.labelFilter
};
const pageEtags: string[] = [];
const pageIterator = listConfigurationSettingsWithTrace(
this.#requestTraceOptions,
client,
listOptions
).byPage();

for await (const page of pageIterator) {
pageEtags.push(page.etag ?? "");
for (const setting of page.items) {
if (loadFeatureFlag === isFeatureFlag(setting)) {
loadedSettings.push(setting);
}
}
}
selector.pageEtags = pageEtags;
} else { // snapshot selector
const snapshot = await this.#getSnapshot(selector.snapshotName);
if (snapshot === undefined) {
throw new InvalidOperationError(`Could not find snapshot with name ${selector.snapshotName}.`);
}
if (snapshot.compositionType != KnownSnapshotComposition.Key) {
throw new InvalidOperationError(`Composition type for the selected snapshot with name ${selector.snapshotName} must be 'key'.`);
}
const pageIterator = listConfigurationSettingsForSnapshotWithTrace(
this.#requestTraceOptions,
client,
selector.snapshotName
).byPage();

for await (const page of pageIterator) {
for (const setting of page.items) {
if (loadFeatureFlag === isFeatureFlag(setting)) {
loadedSettings.push(setting);
}
}
}
}
selector.pageEtags = pageEtags;
}

if (loadFeatureFlag) {
Expand All @@ -492,7 +542,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
*/
async #loadSelectedAndWatchedKeyValues() {
const keyValues: [key: string, value: unknown][] = [];
const loadedSettings = await this.#loadConfigurationSettings();
const loadedSettings: ConfigurationSetting[] = await this.#loadConfigurationSettings();
if (this.#refreshEnabled && !this.#watchAll) {
await this.#updateWatchedKeyValuesEtag(loadedSettings);
}
Expand All @@ -502,11 +552,25 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
this.#aiConfigurationTracing.reset();
}

// adapt configuration settings to key-values
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);
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);
}

this.#clearLoadedKeyValues(); // clear existing key-values in case of configuration setting deletion
for (const [k, v] of keyValues) {
Expand Down Expand Up @@ -551,7 +615,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
*/
async #loadFeatureFlags() {
const loadFeatureFlag = true;
const featureFlagSettings = await this.#loadConfigurationSettings(loadFeatureFlag);
const featureFlagSettings: ConfigurationSetting[] = await this.#loadConfigurationSettings(loadFeatureFlag);

if (this.#requestTracingEnabled && this.#featureFlagTracing !== undefined) {
// Reset old feature flag tracing in order to track the information present in the current response from server.
Expand Down Expand Up @@ -631,6 +695,9 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
async #checkConfigurationSettingsChange(selectors: PagedSettingSelector[]): Promise<boolean> {
const funcToExecute = async (client) => {
for (const selector of selectors) {
if (selector.snapshotName) { // skip snapshot selector
continue;
}
const listOptions: ListConfigurationSettingsOptions = {
keyFilter: selector.keyFilter,
labelFilter: selector.labelFilter,
Expand Down Expand Up @@ -682,6 +749,29 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
return response;
}

async #getSnapshot(snapshotName: string, customOptions?: GetSnapshotOptions): Promise<GetSnapshotResponse | undefined> {
const funcToExecute = async (client) => {
return getSnapshotWithTrace(
this.#requestTraceOptions,
client,
snapshotName,
customOptions
);
};

let response: GetSnapshotResponse | undefined;
try {
response = await this.#executeWithFailoverPolicy(funcToExecute);
} catch (error) {
if (isRestError(error) && error.statusCode === 404) {
response = undefined;
} else {
throw error;
}
}
return response;
}

// Only operations related to Azure App Configuration should be executed with failover policy.
async #executeWithFailoverPolicy(funcToExecute: (client: AppConfigurationClient) => Promise<any>): Promise<any> {
let clientWrappers = await this.#clientManager.getClients();
Expand Down Expand Up @@ -940,11 +1030,11 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration {
}
}

function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] {
// below code deduplicates selectors by keyFilter and labelFilter, the latter selector wins
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);
const existingSelectorIndex = uniqueSelectors.findIndex(s => s.keyFilter === selector.keyFilter && s.labelFilter === selector.labelFilter && s.snapshotName === selector.snapshotName);
if (existingSelectorIndex >= 0) {
uniqueSelectors.splice(existingSelectorIndex, 1);
}
Expand All @@ -953,14 +1043,20 @@ function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] {

return uniqueSelectors.map(selectorCandidate => {
const selector = { ...selectorCandidate };
if (!selector.keyFilter) {
throw new ArgumentError("Key filter cannot be null or empty.");
}
if (!selector.labelFilter) {
selector.labelFilter = LabelFilter.Null;
}
if (selector.labelFilter.includes("*") || selector.labelFilter.includes(",")) {
throw new ArgumentError("The characters '*' and ',' are not supported in label filters.");
if (selector.snapshotName) {
if (selector.keyFilter || selector.labelFilter) {
throw new ArgumentError("Key or label filter should not be used for a snapshot.");
}
} else {
if (!selector.keyFilter) {
throw new ArgumentError("Key filter cannot be null or empty.");
}
if (!selector.labelFilter) {
selector.labelFilter = LabelFilter.Null;
}
if (selector.labelFilter.includes("*") || selector.labelFilter.includes(",")) {
throw new ArgumentError("The characters '*' and ',' are not supported in label filters.");
}
}
return selector;
});
Expand All @@ -971,7 +1067,7 @@ function getValidKeyValueSelectors(selectors?: SettingSelector[]): SettingSelect
// Default selector: key: *, label: \0
return [{ keyFilter: KeyFilter.Any, labelFilter: LabelFilter.Null }];
}
return getValidSelectors(selectors);
return getValidSettingSelectors(selectors);
}

function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSelector[] {
Expand All @@ -980,7 +1076,9 @@ function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSel
return [{ keyFilter: `${featureFlagPrefix}${KeyFilter.Any}`, labelFilter: LabelFilter.Null }];
}
selectors.forEach(selector => {
selector.keyFilter = `${featureFlagPrefix}${selector.keyFilter}`;
if (selector.keyFilter) {
selector.keyFilter = `${featureFlagPrefix}${selector.keyFilter}`;
}
});
return getValidSelectors(selectors);
return getValidSettingSelectors(selectors);
}
8 changes: 8 additions & 0 deletions src/keyvault/KeyVaultOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,12 @@ export interface KeyVaultOptions {
* @returns The secret value.
*/
secretResolver?: (keyVaultReference: URL) => string | Promise<string>;

/**
* Specifies whether to resolve the secret value in parallel.
*
* @remarks
* If not specified, the default value is false.
*/
parallelSecretResolutionEnabled?: boolean;
}
47 changes: 31 additions & 16 deletions src/requestTracing/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { AppConfigurationClient, ConfigurationSettingId, GetConfigurationSettingOptions, ListConfigurationSettingsOptions } from "@azure/app-configuration";
import { OperationOptions } from "@azure/core-client";
import { AppConfigurationClient, ConfigurationSettingId, GetConfigurationSettingOptions, ListConfigurationSettingsOptions, GetSnapshotOptions, ListConfigurationSettingsForSnapshotOptions } from "@azure/app-configuration";
import { AzureAppConfigurationOptions } from "../AzureAppConfigurationOptions.js";
import { FeatureFlagTracingOptions } from "./FeatureFlagTracingOptions.js";
import { AIConfigurationTracingOptions } from "./AIConfigurationTracingOptions.js";
Expand Down Expand Up @@ -52,15 +53,7 @@ export function listConfigurationSettingsWithTrace(
client: AppConfigurationClient,
listOptions: ListConfigurationSettingsOptions
) {
const actualListOptions = { ...listOptions };
if (requestTracingOptions.enabled) {
actualListOptions.requestOptions = {
customHeaders: {
[CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(requestTracingOptions)
}
};
}

const actualListOptions = applyRequestTracing(requestTracingOptions, listOptions);
return client.listConfigurationSettings(actualListOptions);
}

Expand All @@ -70,20 +63,43 @@ export function getConfigurationSettingWithTrace(
configurationSettingId: ConfigurationSettingId,
getOptions?: GetConfigurationSettingOptions,
) {
const actualGetOptions = { ...getOptions };
const actualGetOptions = applyRequestTracing(requestTracingOptions, getOptions);
return client.getConfigurationSetting(configurationSettingId, actualGetOptions);
}

export function getSnapshotWithTrace(
requestTracingOptions: RequestTracingOptions,
client: AppConfigurationClient,
snapshotName: string,
getOptions?: GetSnapshotOptions
) {
const actualGetOptions = applyRequestTracing(requestTracingOptions, getOptions);
return client.getSnapshot(snapshotName, actualGetOptions);
}

export function listConfigurationSettingsForSnapshotWithTrace(
requestTracingOptions: RequestTracingOptions,
client: AppConfigurationClient,
snapshotName: string,
listOptions?: ListConfigurationSettingsForSnapshotOptions
) {
const actualListOptions = applyRequestTracing(requestTracingOptions, listOptions);
return client.listConfigurationSettingsForSnapshot(snapshotName, actualListOptions);
}

function applyRequestTracing<T extends OperationOptions>(requestTracingOptions: RequestTracingOptions, operationOptions?: T) {
const actualOptions = { ...operationOptions };
if (requestTracingOptions.enabled) {
actualGetOptions.requestOptions = {
actualOptions.requestOptions = {
customHeaders: {
[CORRELATION_CONTEXT_HEADER_NAME]: createCorrelationContextHeader(requestTracingOptions)
}
};
}

return client.getConfigurationSetting(configurationSettingId, actualGetOptions);
return actualOptions;
}

export function createCorrelationContextHeader(requestTracingOptions: RequestTracingOptions): string {
function createCorrelationContextHeader(requestTracingOptions: RequestTracingOptions): string {
/*
RequestType: 'Startup' during application starting up, 'Watch' after startup completed.
Host: identify with defined envs
Expand Down Expand Up @@ -227,4 +243,3 @@ export function isWebWorker() {

return workerGlobalScopeDefined && importScriptsAsGlobalFunction && isNavigatorDefinedAsExpected;
}

Loading