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
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# If there are abnormal line endings in any file, run "git add --renormalize <file_name>",
# review the changes, and commit them to fix the line endings.
* text=auto
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ on:

permissions:
security-events: write
id-token: write

jobs:
build:
Expand All @@ -40,8 +41,17 @@ jobs:
- name: Dotnet Pack
run: pwsh pack.ps1

- name: Azure Login with OIDC
uses: azure/login@v1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

- name: Dotnet Test
run: pwsh test.ps1
env:
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

- name: Publish Test Results
uses: actions/upload-artifact@v4
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
# Azure Functions localsettings file
local.settings.json

# Integration test secrets
appsettings.Secrets.json

# User-specific files
*.suo
*.user
Expand Down
452 changes: 226 additions & 226 deletions NOTICE

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ public ConfigurationClient CreateClient(string endpoint)
string connectionString = _connectionStrings.FirstOrDefault(cs => ConnectionStringUtils.Parse(cs, ConnectionStringUtils.EndpointSection) == endpoint);

//
// falback to the first connection string
// fallback to the first connection string
if (connectionString == null)
{
string id = ConnectionStringUtils.Parse(_connectionStrings.First(), ConnectionStringUtils.IdSection);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//
using Microsoft.Extensions.Diagnostics.HealthChecks;
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Threading.Tasks;
using System.Threading;
using System.Linq;

namespace Microsoft.Extensions.Configuration.AzureAppConfiguration
{
internal class AzureAppConfigurationHealthCheck : IHealthCheck
{
private static readonly PropertyInfo _propertyInfo = typeof(ChainedConfigurationProvider).GetProperty("Configuration", BindingFlags.Public | BindingFlags.Instance);
private readonly IEnumerable<IHealthCheck> _healthChecks;

public AzureAppConfigurationHealthCheck(IConfiguration configuration)
{
if (configuration == null)
{
throw new ArgumentNullException(nameof(configuration));
}

var healthChecks = new List<IHealthCheck>();
var configurationRoot = configuration as IConfigurationRoot;
FindHealthChecks(configurationRoot, healthChecks);

_healthChecks = healthChecks;
}

public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
if (!_healthChecks.Any())
{
return HealthCheckResult.Unhealthy(HealthCheckConstants.NoProviderFoundMessage);
}

foreach (IHealthCheck healthCheck in _healthChecks)
{
var result = await healthCheck.CheckHealthAsync(context, cancellationToken).ConfigureAwait(false);

if (result.Status == HealthStatus.Unhealthy)
{
return result;
}
}

return HealthCheckResult.Healthy();
}

private void FindHealthChecks(IConfigurationRoot configurationRoot, List<IHealthCheck> healthChecks)
{
if (configurationRoot != null)
{
foreach (IConfigurationProvider provider in configurationRoot.Providers)
{
if (provider is AzureAppConfigurationProvider appConfigurationProvider)
{
healthChecks.Add(appConfigurationProvider);
}
else if (provider is ChainedConfigurationProvider chainedProvider)
{
if (_propertyInfo != null)
{
var chainedProviderConfigurationRoot = _propertyInfo.GetValue(chainedProvider) as IConfigurationRoot;
FindHealthChecks(chainedProviderConfigurationRoot, healthChecks);
}
}
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration.AzureAppConfiguration;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using System;
using System.Collections.Generic;

namespace Microsoft.Extensions.DependencyInjection
{
/// <summary>
/// Extension methods to configure <see cref="AzureAppConfigurationHealthCheck"/>.
/// </summary>
public static class AzureAppConfigurationHealthChecksBuilderExtensions
{
/// <summary>
/// Add a health check for Azure App Configuration to given <paramref name="builder"/>.
/// </summary>
/// <param name="builder">The <see cref="IHealthChecksBuilder"/> to add <see cref="HealthCheckRegistration"/> to.</param>
/// <param name="factory"> A factory to obtain <see cref="IConfiguration"/> instance.</param>
/// <param name="name">The health check name.</param>
/// <param name="failureStatus">The <see cref="HealthStatus"/> that should be reported when the health check fails.</param>
/// <param name="tags">A list of tags that can be used to filter sets of health checks.</param>
/// <param name="timeout">A <see cref="TimeSpan"/> representing the timeout of the check.</param>
/// <returns>The provided health checks builder.</returns>
public static IHealthChecksBuilder AddAzureAppConfiguration(
this IHealthChecksBuilder builder,
Func<IServiceProvider, IConfiguration> factory = default,
string name = HealthCheckConstants.HealthCheckRegistrationName,
HealthStatus failureStatus = default,
IEnumerable<string> tags = default,
TimeSpan? timeout = default)
{
return builder.Add(new HealthCheckRegistration(
name ?? HealthCheckConstants.HealthCheckRegistrationName,
sp => new AzureAppConfigurationHealthCheck(
factory?.Invoke(sp) ?? sp.GetRequiredService<IConfiguration>()),
failureStatus,
tags,
timeout));
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// Licensed under the MIT license.
//
using Azure.Core;
using Azure.Core.Pipeline;
using Azure.Data.AppConfiguration;
using Microsoft.Extensions.Azure;
using Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault;
Expand All @@ -12,7 +11,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;

namespace Microsoft.Extensions.Configuration.AzureAppConfiguration
Expand Down Expand Up @@ -205,7 +203,14 @@ public AzureAppConfigurationOptions SetClientFactory(IAzureClientFactory<Configu
/// The label filter to apply when querying Azure App Configuration for key-values. By default the null label will be used. Built-in label filter options: <see cref="LabelFilter"/>
/// The characters asterisk (*) and comma (,) are not supported. Backslash (\) character is reserved and must be escaped using another backslash (\).
/// </param>
public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter = LabelFilter.Null)
/// <param name="tagFilters">
/// In addition to key and label filters, key-values from Azure App Configuration can be filtered based on their tag names and values.
/// 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 values: <see cref="TagValue"/>. For example, $"tagName={<see cref="TagValue.Null"/>}".
/// The characters asterisk (*), comma (,) and backslash (\) are reserved and must be escaped using a backslash (\).
/// Up to 5 tag filters can be provided. If no tag filters are provided, key-values will not be filtered based on tags.
/// </param>
public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter = LabelFilter.Null, IEnumerable<string> tagFilters = null)
{
if (string.IsNullOrEmpty(keyFilter))
{
Expand All @@ -223,6 +228,17 @@ public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter
labelFilter = LabelFilter.Null;
}

if (tagFilters != null)
{
foreach (string tag in tagFilters)
{
if (string.IsNullOrEmpty(tag) || !tag.Contains('=') || tag.IndexOf('=') == 0)
{
throw new ArgumentException($"Tag filter '{tag}' does not follow the format \"tagName=tagValue\".", nameof(tagFilters));
}
}
}

if (!_selectCalled)
{
_selectors.Remove(DefaultQuery);
Expand All @@ -233,7 +249,8 @@ public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter
_selectors.AppendUnique(new KeyValueSelector
{
KeyFilter = keyFilter,
LabelFilter = labelFilter
LabelFilter = labelFilter,
TagFilters = tagFilters
});

return this;
Expand Down Expand Up @@ -307,6 +324,7 @@ public AzureAppConfigurationOptions UseFeatureFlags(Action<FeatureFlagOptions> c
{
Key = featureFlagSelector.KeyFilter,
Label = featureFlagSelector.LabelFilter,
Tags = featureFlagSelector.TagFilters,
// If UseFeatureFlags is called multiple times for the same key and label filters, last refresh interval wins
RefreshInterval = options.RefreshInterval
});
Expand Down Expand Up @@ -528,15 +546,12 @@ public AzureAppConfigurationOptions ConfigureStartupOptions(Action<StartupOption

private static ConfigurationClientOptions GetDefaultClientOptions()
{
var clientOptions = new ConfigurationClientOptions(ConfigurationClientOptions.ServiceVersion.V2023_10_01);
var clientOptions = new ConfigurationClientOptions(ConfigurationClientOptions.ServiceVersion.V2023_11_01);
clientOptions.Retry.MaxRetries = MaxRetries;
clientOptions.Retry.MaxDelay = MaxRetryDelay;
clientOptions.Retry.Mode = RetryMode.Exponential;
clientOptions.Retry.NetworkTimeout = NetworkTimeout;
clientOptions.AddPolicy(new UserAgentHeaderPolicy(), HttpPipelinePosition.PerCall);
clientOptions.Transport = new HttpClientTransport(new HttpClient()
{
Timeout = NetworkTimeout
});

return clientOptions;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Azure.Data.AppConfiguration;
using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions;
using Microsoft.Extensions.Configuration.AzureAppConfiguration.Models;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
Expand All @@ -21,8 +22,9 @@

namespace Microsoft.Extensions.Configuration.AzureAppConfiguration
{
internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigurationRefresher, IDisposable
internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigurationRefresher, IHealthCheck, IDisposable
{
private readonly ActivitySource _activitySource = new ActivitySource(ActivityNames.AzureAppConfigurationActivitySource);
private bool _optional;
private bool _isInitialLoadComplete = false;
private bool _isAssemblyInspected;
Expand Down Expand Up @@ -52,6 +54,10 @@ internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigura
private Logger _logger = new Logger();
private ILoggerFactory _loggerFactory;

// For health check
private DateTimeOffset? _lastSuccessfulAttempt = null;
private DateTimeOffset? _lastFailedAttempt = null;

private class ConfigurationClientBackoffStatus
{
public int FailedAttempts { get; set; }
Expand Down Expand Up @@ -158,7 +164,7 @@ public AzureAppConfigurationProvider(IConfigurationClientManager configClientMan
public override void Load()
{
var watch = Stopwatch.StartNew();

using Activity activity = _activitySource.StartActivity(ActivityNames.Load);
try
{
using var startupCancellationTokenSource = new CancellationTokenSource(_options.Startup.Timeout);
Expand Down Expand Up @@ -255,9 +261,12 @@ public async Task RefreshAsync(CancellationToken cancellationToken)

_logger.LogDebug(LogHelper.BuildRefreshSkippedNoClientAvailableMessage());

_lastFailedAttempt = DateTime.UtcNow;

return;
}

using Activity activity = _activitySource.StartActivity(ActivityNames.Refresh);
// Check if initial configuration load had failed
if (_mappedData == null)
{
Expand Down Expand Up @@ -344,6 +353,7 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) =>
{
KeyFilter = watcher.Key,
LabelFilter = watcher.Label,
TagFilters = watcher.Tags,
IsFeatureFlagSelector = true
}),
_ffEtags,
Expand Down Expand Up @@ -568,6 +578,22 @@ public void ProcessPushNotification(PushNotification pushNotification, TimeSpan?
}
}

public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
if (!_lastSuccessfulAttempt.HasValue)
{
return HealthCheckResult.Unhealthy(HealthCheckConstants.LoadNotCompletedMessage);
}

if (_lastFailedAttempt.HasValue &&
_lastSuccessfulAttempt.Value < _lastFailedAttempt.Value)
{
return HealthCheckResult.Unhealthy(HealthCheckConstants.RefreshFailedMessage);
}

return HealthCheckResult.Healthy();
}

private void SetDirty(TimeSpan? maxDelay)
{
DateTimeOffset nextRefreshTime = AddRandomDelay(DateTimeOffset.UtcNow, maxDelay ?? DefaultMaxSetDirtyDelay);
Expand Down Expand Up @@ -826,6 +852,14 @@ private async Task<Dictionary<string, ConfigurationSetting>> LoadSelected(
LabelFilter = loadOption.LabelFilter
};

if (loadOption.TagFilters != null)
{
foreach (string tagFilter in loadOption.TagFilters)
{
selector.TagsFilter.Add(tagFilter);
}
}

var matchConditions = new List<MatchConditions>();

await CallWithRequestTracing(async () =>
Expand Down Expand Up @@ -1147,6 +1181,7 @@ private async Task<T> ExecuteWithFailOverPolicyAsync<T>(
success = true;

_lastSuccessfulEndpoint = _configClientManager.GetEndpointForClient(currentClient);
_lastSuccessfulAttempt = DateTime.UtcNow;

return result;
}
Expand All @@ -1172,6 +1207,7 @@ private async Task<T> ExecuteWithFailOverPolicyAsync<T>(
{
if (!success && backoffAllClients)
{
_lastFailedAttempt = DateTime.UtcNow;
_logger.LogWarning(LogHelper.BuildLastEndpointFailedMessage(previousEndpoint?.ToString()));

do
Expand Down Expand Up @@ -1221,9 +1257,7 @@ await ExecuteWithFailOverPolicyAsync<object>(clients, async (client) =>

private bool IsFailOverable(AggregateException ex)
{
TaskCanceledException tce = ex.InnerExceptions?.LastOrDefault(e => e is TaskCanceledException) as TaskCanceledException;

if (tce != null && tce.InnerException is TimeoutException)
if (ex.InnerExceptions?.Any(e => e is TaskCanceledException) == true)
{
return true;
}
Expand Down Expand Up @@ -1413,6 +1447,7 @@ private async Task ProcessKeyValueChangesAsync(
public void Dispose()
{
(_configClientManager as ConfigurationClientManager)?.Dispose();
_activitySource.Dispose();
}
}
}
Loading