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
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 @@ -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,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;
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -256,6 +261,8 @@ public async Task RefreshAsync(CancellationToken cancellationToken)

_logger.LogDebug(LogHelper.BuildRefreshSkippedNoClientAvailableMessage());

_lastFailedAttempt = DateTime.UtcNow;

return;
}

Expand Down Expand Up @@ -571,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 @@ -1158,6 +1181,7 @@ private async Task<T> ExecuteWithFailOverPolicyAsync<T>(
success = true;

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

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

do
Expand Down
Original file line number Diff line number Diff line change
@@ -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.";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.7.0" />
<PackageReference Include="Azure.Security.KeyVault.Secrets" Version="4.6.0" />
<PackageReference Include="DnsClient" Version="1.7.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="6.0.36" />
<PackageReference Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Azure" Version="1.7.6" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" />
Expand Down
140 changes: 140 additions & 0 deletions tests/Tests.AzureAppConfiguration/HealthCheckTest.cs
Original file line number Diff line number Diff line change
@@ -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<ConfigurationSetting> kvCollection = new List<ConfigurationSetting>
{
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<Response>();
var mockClient = new Mock<ConfigurationClient>(MockBehavior.Strict);

mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny<SettingSelector>(), It.IsAny<CancellationToken>()))
.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<Response>();
var mockClient = new Mock<ConfigurationClient>(MockBehavior.Strict);

mockClient.SetupSequence(c => c.GetConfigurationSettingsAsync(It.IsAny<SettingSelector>(), It.IsAny<CancellationToken>()))
.Returns(new MockAsyncPageable(kvCollection))
.Throws(new RequestFailedException(503, "Request failed."))
.Returns(new MockAsyncPageable(Enumerable.Empty<ConfigurationSetting>().ToList()))
.Returns(new MockAsyncPageable(Enumerable.Empty<ConfigurationSetting>().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<Response>();
var mockClient = new Mock<ConfigurationClient>(MockBehavior.Strict);

mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny<SettingSelector>(), It.IsAny<CancellationToken>()))
.Returns(new MockAsyncPageable(kvCollection));

var config = new ConfigurationBuilder()
.AddAzureAppConfiguration(options =>
{
options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object);
})
.Build();

var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(config);
services.AddLogging(); // add logging for health check service
services.AddHealthChecks()
.AddAzureAppConfiguration();
var provider = services.BuildServiceProvider();
var healthCheckService = provider.GetRequiredService<HealthCheckService>();

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);
}
}
}