Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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,41 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//
using Microsoft.Extensions.Diagnostics.HealthChecks;
using System;
using System.Threading.Tasks;
using System.Threading;

namespace Microsoft.Extensions.Configuration.AzureAppConfiguration
{
internal class AzureAppConfigurationHealthCheck : IConfigurationHealthCheck
{
private AzureAppConfigurationProvider _provider = null;

internal void SetProvider(AzureAppConfigurationProvider provider)
{
_provider = provider ?? throw new ArgumentNullException(nameof(provider));
}

public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
if (_provider == null)
{
return HealthCheckResult.Unhealthy("Configuration provider is not set.");
}

if (!_provider.LastSuccessfulAttempt.HasValue)
{
return HealthCheckResult.Unhealthy("The initial load is not completed.");
}

if (_provider.LastFailedAttempt.HasValue &&
_provider.LastSuccessfulAttempt.Value < _provider.LastFailedAttempt.Value)
{
return HealthCheckResult.Unhealthy("The last refresh attempt failed.");
}

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

namespace Microsoft.Extensions.Configuration.AzureAppConfiguration
Expand All @@ -25,14 +25,15 @@ public class AzureAppConfigurationOptions
private const int MaxRetries = 2;
private static readonly TimeSpan MaxRetryDelay = TimeSpan.FromMinutes(1);
private static readonly TimeSpan NetworkTimeout = TimeSpan.FromSeconds(10);
private static readonly KeyValueSelector DefaultQuery = new KeyValueSelector { KeyFilter = KeyFilter.Any, LabelFilter = LabelFilter.Null };
private static readonly KeyValueSelector DefaultQuery = new KeyValueSelector { KeyFilter = KeyFilter.Any, LabelFilter = LabelFilter.Null };

private List<KeyValueWatcher> _individualKvWatchers = new List<KeyValueWatcher>();
private List<KeyValueWatcher> _ffWatchers = new List<KeyValueWatcher>();
private List<IKeyValueAdapter> _adapters;
private List<Func<ConfigurationSetting, ValueTask<ConfigurationSetting>>> _mappers = new List<Func<ConfigurationSetting, ValueTask<ConfigurationSetting>>>();
private List<KeyValueSelector> _selectors;
private IConfigurationRefresher _refresher = new AzureAppConfigurationRefresher();
private IConfigurationHealthCheck _healthCheck = new AzureAppConfigurationHealthCheck();
private bool _selectCalled = false;

// The following set is sorted in descending order.
Expand Down Expand Up @@ -459,6 +460,15 @@ public IConfigurationRefresher GetRefresher()
return _refresher;
}

/// <summary>
/// Get an instance of <see cref="IConfigurationHealthCheck"/> that can be used to do health checks for the configuration provider.
/// </summary>
/// <returns>An instance of <see cref="IConfigurationHealthCheck"/>.</returns>
public IConfigurationHealthCheck GetHealthCheck()
{
return _healthCheck;
}

/// <summary>
/// Configures the Azure App Configuration provider to use the provided Key Vault configuration to resolve key vault references.
/// </summary>
Expand Down Expand Up @@ -514,9 +524,9 @@ private static ConfigurationClientOptions GetDefaultClientOptions()
clientOptions.Retry.Mode = RetryMode.Exponential;
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 @@ -58,6 +58,9 @@ private class ConfigurationClientBackoffStatus
public DateTimeOffset BackoffEndTime { get; set; }
}

public DateTimeOffset? LastSuccessfulAttempt { get; private set; } = null;
public DateTimeOffset? LastFailedAttempt { get; private set; } = null;

public Uri AppConfigurationEndpoint
{
get
Expand Down Expand Up @@ -116,6 +119,9 @@ public AzureAppConfigurationProvider(IConfigurationClientManager configClientMan
bool hasWatchers = watchers.Any();
TimeSpan minWatcherRefreshInterval = hasWatchers ? watchers.Min(w => w.RefreshInterval) : TimeSpan.MaxValue;

var healthCheck = (AzureAppConfigurationHealthCheck)_options.GetHealthCheck();
healthCheck.SetProvider(this);

if (options.RegisterAllEnabled)
{
if (options.KvCollectionRefreshInterval <= TimeSpan.Zero)
Expand Down Expand Up @@ -255,6 +261,8 @@ public async Task RefreshAsync(CancellationToken cancellationToken)

_logger.LogDebug(LogHelper.BuildRefreshSkippedNoClientAvailableMessage());

LastFailedAttempt = DateTime.UtcNow;

return;
}

Expand Down Expand Up @@ -1135,6 +1143,7 @@ private async Task<T> ExecuteWithFailOverPolicyAsync<T>(
success = true;

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

return result;
}
Expand All @@ -1143,6 +1152,7 @@ private async Task<T> ExecuteWithFailOverPolicyAsync<T>(
if (!IsFailOverable(rfe) || !clientEnumerator.MoveNext())
{
backoffAllClients = true;
LastFailedAttempt = DateTime.UtcNow;

throw;
}
Expand All @@ -1152,6 +1162,7 @@ private async Task<T> ExecuteWithFailOverPolicyAsync<T>(
if (!IsFailOverable(ae) || !clientEnumerator.MoveNext())
{
backoffAllClients = true;
LastFailedAttempt = DateTime.UtcNow;

throw;
}
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.
//
using Microsoft.Extensions.Diagnostics.HealthChecks;

namespace Microsoft.Extensions.Configuration.AzureAppConfiguration
{
/// <summary>
/// An interface for Azure App Configuration health check.
/// </summary>
public interface IConfigurationHealthCheck : IHealthCheck
{
}
}
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.Extensions.DependencyInjection.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="6.0.1" />
Expand Down
110 changes: 110 additions & 0 deletions tests/Tests.AzureAppConfiguration/HealthCheckTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// 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;

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()
{
IHealthCheck healthCheck = null;

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);
healthCheck = options.GetHealthCheck();
})
.Build();

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;
IHealthCheck healthCheck = 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();
healthCheck = options.GetHealthCheck();
})
.Build();

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);
}
}
}
6 changes: 3 additions & 3 deletions tests/Tests.AzureAppConfiguration/RefreshTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ Response<ConfigurationSetting> GetIfChanged(ConfigurationSetting setting, bool o
foreach (var setting in keyValueCollection)
{
copy.Add(TestHelpers.CloneSetting(setting));
};
}

return new MockAsyncPageable(copy);
});
Expand Down Expand Up @@ -392,7 +392,7 @@ Response<ConfigurationSetting> GetIfChanged(ConfigurationSetting setting, bool o
foreach (var setting in keyValueCollection)
{
copy.Add(TestHelpers.CloneSetting(setting));
};
}

return new MockAsyncPageable(copy);
});
Expand Down Expand Up @@ -461,7 +461,7 @@ public async Task RefreshTests_SingleServerCallOnSimultaneousMultipleRefresh()
foreach (var setting in keyValueCollection)
{
copy.Add(TestHelpers.CloneSetting(setting));
};
}

return new MockAsyncPageable(copy, operationDelay);
});
Expand Down