diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs new file mode 100644 index 00000000..76b706c0 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs @@ -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 _healthChecks; + + public AzureAppConfigurationHealthCheck(IConfiguration configuration) + { + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + var healthChecks = new List(); + var configurationRoot = configuration as IConfigurationRoot; + FindHealthChecks(configurationRoot, healthChecks); + + _healthChecks = healthChecks; + } + + public async Task 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 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); + } + } + } + } + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthChecksBuilderExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthChecksBuilderExtensions.cs new file mode 100644 index 00000000..f006b746 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthChecksBuilderExtensions.cs @@ -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 +{ + /// + /// Extension methods to configure . + /// + public static class AzureAppConfigurationHealthChecksBuilderExtensions + { + /// + /// Add a health check for Azure App Configuration to given . + /// + /// The to add to. + /// A factory to obtain instance. + /// The health check name. + /// The that should be reported when the health check fails. + /// A list of tags that can be used to filter sets of health checks. + /// A representing the timeout of the check. + /// The provided health checks builder. + public static IHealthChecksBuilder AddAzureAppConfiguration( + this IHealthChecksBuilder builder, + Func factory = default, + string name = HealthCheckConstants.HealthCheckRegistrationName, + HealthStatus failureStatus = default, + IEnumerable tags = default, + TimeSpan? timeout = default) + { + return builder.Add(new HealthCheckRegistration( + name ?? HealthCheckConstants.HealthCheckRegistrationName, + sp => new AzureAppConfigurationHealthCheck( + factory?.Invoke(sp) ?? sp.GetRequiredService()), + failureStatus, + tags, + timeout)); + } + } +} + diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index a83c7413..6b100f8d 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -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; @@ -21,7 +22,7 @@ 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; @@ -53,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; } @@ -256,6 +261,8 @@ public async Task RefreshAsync(CancellationToken cancellationToken) _logger.LogDebug(LogHelper.BuildRefreshSkippedNoClientAvailableMessage()); + _lastFailedAttempt = DateTime.UtcNow; + return; } @@ -571,6 +578,22 @@ public void ProcessPushNotification(PushNotification pushNotification, TimeSpan? } } + public async Task 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); @@ -1158,6 +1181,7 @@ private async Task ExecuteWithFailOverPolicyAsync( success = true; _lastSuccessfulEndpoint = _configClientManager.GetEndpointForClient(currentClient); + _lastSuccessfulAttempt = DateTime.UtcNow; return result; } @@ -1183,6 +1207,7 @@ private async Task ExecuteWithFailOverPolicyAsync( { if (!success && backoffAllClients) { + _lastFailedAttempt = DateTime.UtcNow; _logger.LogWarning(LogHelper.BuildLastEndpointFailedMessage(previousEndpoint?.ToString())); do diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/HealthCheckConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/HealthCheckConstants.cs new file mode 100644 index 00000000..06939815 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/HealthCheckConstants.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration +{ + internal class HealthCheckConstants + { + public const string HealthCheckRegistrationName = "Microsoft.Extensions.Configuration.AzureAppConfiguration"; + public const string NoProviderFoundMessage = "No configuration provider is found."; + public const string LoadNotCompletedMessage = "The initial load is not completed."; + public const string RefreshFailedMessage = "The last refresh attempt failed."; + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj index 87c4251d..7934e5e4 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj @@ -19,6 +19,7 @@ + diff --git a/tests/Tests.AzureAppConfiguration/HealthCheckTest.cs b/tests/Tests.AzureAppConfiguration/HealthCheckTest.cs new file mode 100644 index 00000000..9dce8e82 --- /dev/null +++ b/tests/Tests.AzureAppConfiguration/HealthCheckTest.cs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure; +using Azure.Data.AppConfiguration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Moq; +using System.Threading; +using System.Collections.Generic; +using System.Threading.Tasks; +using Xunit; +using System; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; + +namespace Tests.AzureAppConfiguration +{ + public class HealthCheckTest + { + readonly List kvCollection = new List + { + ConfigurationModelFactory.ConfigurationSetting("TestKey1", "TestValue1", "label", + eTag: new ETag("0a76e3d7-7ec1-4e37-883c-9ea6d0d89e63"), + contentType:"text"), + ConfigurationModelFactory.ConfigurationSetting("TestKey2", "TestValue2", "label", + eTag: new ETag("31c38369-831f-4bf1-b9ad-79db56c8b989"), + contentType: "text"), + ConfigurationModelFactory.ConfigurationSetting("TestKey3", "TestValue3", "label", + + eTag: new ETag("bb203f2b-c113-44fc-995d-b933c2143339"), + contentType: "text"), + ConfigurationModelFactory.ConfigurationSetting("TestKey4", "TestValue4", "label", + eTag: new ETag("3ca43b3e-d544-4b0c-b3a2-e7a7284217a2"), + contentType: "text"), + }; + + [Fact] + public async Task HealthCheckTests_ReturnsHealthyWhenInitialLoadIsCompleted() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(kvCollection)); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + }) + .Build(); + + IHealthCheck healthCheck = new AzureAppConfigurationHealthCheck(config); + + Assert.True(config["TestKey1"] == "TestValue1"); + var result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); + Assert.Equal(HealthStatus.Healthy, result.Status); + } + + [Fact] + public async Task HealthCheckTests_ReturnsUnhealthyWhenRefreshFailed() + { + IConfigurationRefresher refresher = null; + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.SetupSequence(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(kvCollection)) + .Throws(new RequestFailedException(503, "Request failed.")) + .Returns(new MockAsyncPageable(Enumerable.Empty().ToList())) + .Returns(new MockAsyncPageable(Enumerable.Empty().ToList())); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.MinBackoffDuration = TimeSpan.FromSeconds(2); + options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.RegisterAll() + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + refresher = options.GetRefresher(); + }) + .Build(); + + IHealthCheck healthCheck = new AzureAppConfigurationHealthCheck(config); + + var result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); + Assert.Equal(HealthStatus.Healthy, result.Status); + + // Wait for the refresh interval to expire + Thread.Sleep(1000); + + await refresher.TryRefreshAsync(); + result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); + Assert.Equal(HealthStatus.Unhealthy, result.Status); + + // Wait for client backoff to end + Thread.Sleep(3000); + + await refresher.RefreshAsync(); + result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); + Assert.Equal(HealthStatus.Healthy, result.Status); + } + + [Fact] + public async Task HealthCheckTests_RegisterAzureAppConfigurationHealthCheck() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(kvCollection)); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + }) + .Build(); + + var services = new ServiceCollection(); + services.AddSingleton(config); + services.AddLogging(); // add logging for health check service + services.AddHealthChecks() + .AddAzureAppConfiguration(); + var provider = services.BuildServiceProvider(); + var healthCheckService = provider.GetRequiredService(); + + var result = await healthCheckService.CheckHealthAsync(); + Assert.Equal(HealthStatus.Healthy, result.Status); + Assert.Contains(HealthCheckConstants.HealthCheckRegistrationName, result.Entries.Keys); + Assert.Equal(HealthStatus.Healthy, result.Entries[HealthCheckConstants.HealthCheckRegistrationName].Status); + } + } +}