From 7791617833db54c3c514132523dcbf5d2a1e451e Mon Sep 17 00:00:00 2001 From: nulltoken Date: Thu, 19 May 2022 17:24:49 +0200 Subject: [PATCH] Expose IConfigChangeListener callback service Fix #1619 --- .../KubernetesConfigProvider.cs | 8 + .../Configuration/IConfigChangeListener.cs | 39 ++ .../Configuration/IProxyConfig.cs | 9 + .../Configuration/InMemoryConfigProvider.cs | 32 +- .../Management/ProxyConfigManager.cs | 55 ++- .../Management/ProxyConfigManagerTests.cs | 377 +++++++++++++++++- 6 files changed, 510 insertions(+), 10 deletions(-) create mode 100644 src/ReverseProxy/Configuration/IConfigChangeListener.cs diff --git a/src/Kubernetes.Controller/ConfigProvider/KubernetesConfigProvider.cs b/src/Kubernetes.Controller/ConfigProvider/KubernetesConfigProvider.cs index d468f060c..240aa6a49 100644 --- a/src/Kubernetes.Controller/ConfigProvider/KubernetesConfigProvider.cs +++ b/src/Kubernetes.Controller/ConfigProvider/KubernetesConfigProvider.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -36,12 +37,19 @@ private class MessageConfig : IProxyConfig private readonly CancellationTokenSource _cts = new CancellationTokenSource(); public MessageConfig(IReadOnlyList routes, IReadOnlyList clusters) + : this(routes, clusters, Guid.NewGuid().ToString()) + { } + + public MessageConfig(IReadOnlyList routes, IReadOnlyList clusters, string revisionId) { + RevisionId = revisionId ?? throw new ArgumentNullException(nameof(revisionId)); Routes = routes; Clusters = clusters; ChangeToken = new CancellationChangeToken(_cts.Token); } + public string RevisionId { get; } + public IReadOnlyList Routes { get; } public IReadOnlyList Clusters { get; } diff --git a/src/ReverseProxy/Configuration/IConfigChangeListener.cs b/src/ReverseProxy/Configuration/IConfigChangeListener.cs new file mode 100644 index 000000000..2ec544db5 --- /dev/null +++ b/src/ReverseProxy/Configuration/IConfigChangeListener.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; + +namespace Yarp.ReverseProxy.Configuration; + +/// +/// Allows subscribing to events notifying you when the configuration is loaded and applied, or when those actions fail. +/// +public interface IConfigChangeListener +{ + /// + /// Invoked when an error occurs while loading the configuration. + /// + /// The instance of the configuration provider that failed to provide the configuration. + /// The thrown exception. + void ConfigurationLoadingFailed(IProxyConfigProvider configProvider, Exception exception); + + /// + /// Invoked once the configuration have been successfully loaded. + /// + /// The list of instances that have been loaded. + void ConfigurationLoaded(IReadOnlyList proxyConfigs); + + /// + /// Invoked when an error occurs while applying the configuration. + /// + /// The list of instances that were being processed. + /// The thrown exception. + void ConfigurationApplyingFailed(IReadOnlyList proxyConfigs, Exception exception); + + /// + /// Invoked once the configuration has been successfully applied. + /// + /// The list of instances that have been applied. + void ConfigurationApplied(IReadOnlyList proxyConfigs); +} diff --git a/src/ReverseProxy/Configuration/IProxyConfig.cs b/src/ReverseProxy/Configuration/IProxyConfig.cs index 66d751b77..dcf2c47d9 100644 --- a/src/ReverseProxy/Configuration/IProxyConfig.cs +++ b/src/ReverseProxy/Configuration/IProxyConfig.cs @@ -1,7 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; using System.Collections.Generic; +using System.Runtime.CompilerServices; using Microsoft.Extensions.Primitives; namespace Yarp.ReverseProxy.Configuration; @@ -11,6 +13,13 @@ namespace Yarp.ReverseProxy.Configuration; /// public interface IProxyConfig { + private static readonly ConditionalWeakTable _revisionIdsTable = new(); + + /// + /// A unique identifier for this revision of the configuration. + /// + string RevisionId => _revisionIdsTable.GetValue(this, static _ => Guid.NewGuid().ToString()); + /// /// Routes matching requests to clusters. /// diff --git a/src/ReverseProxy/Configuration/InMemoryConfigProvider.cs b/src/ReverseProxy/Configuration/InMemoryConfigProvider.cs index d0868afea..23abd5335 100644 --- a/src/ReverseProxy/Configuration/InMemoryConfigProvider.cs +++ b/src/ReverseProxy/Configuration/InMemoryConfigProvider.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; using System.Collections.Generic; using System.Threading; using Microsoft.Extensions.Primitives; @@ -19,8 +20,15 @@ public sealed class InMemoryConfigProvider : IProxyConfigProvider /// Creates a new instance. /// public InMemoryConfigProvider(IReadOnlyList routes, IReadOnlyList clusters) + : this(routes, clusters, Guid.NewGuid().ToString()) + { } + + /// + /// Creates a new instance, specifying a revision id of the configuration. + /// + public InMemoryConfigProvider(IReadOnlyList routes, IReadOnlyList clusters, string revisionId) { - _config = new InMemoryConfig(routes, clusters); + _config = new InMemoryConfig(routes, clusters, revisionId); } /// @@ -35,6 +43,20 @@ public InMemoryConfigProvider(IReadOnlyList routes, IReadOnlyList routes, IReadOnlyList clusters) { var newConfig = new InMemoryConfig(routes, clusters); + UpdateInternal(newConfig); + } + + /// + /// Swaps the config state with a new snapshot of the configuration, then signals that the old one is outdated. + /// + public void Update(IReadOnlyList routes, IReadOnlyList clusters, string revisionId) + { + var newConfig = new InMemoryConfig(routes, clusters, revisionId); + UpdateInternal(newConfig); + } + + private void UpdateInternal(InMemoryConfig newConfig) + { var oldConfig = Interlocked.Exchange(ref _config, newConfig); oldConfig.SignalChange(); } @@ -48,12 +70,20 @@ private class InMemoryConfig : IProxyConfig private readonly CancellationTokenSource _cts = new CancellationTokenSource(); public InMemoryConfig(IReadOnlyList routes, IReadOnlyList clusters) + : this(routes, clusters, Guid.NewGuid().ToString()) + { } + + public InMemoryConfig(IReadOnlyList routes, IReadOnlyList clusters, string revisionId) { + RevisionId = revisionId ?? throw new ArgumentNullException(nameof(revisionId)); Routes = routes; Clusters = clusters; ChangeToken = new CancellationChangeToken(_cts.Token); } + /// + public string RevisionId { get; } + /// /// A snapshot of the list of routes for the proxy /// diff --git a/src/ReverseProxy/Management/ProxyConfigManager.cs b/src/ReverseProxy/Management/ProxyConfigManager.cs index 4d88537da..40a29b3e0 100644 --- a/src/ReverseProxy/Management/ProxyConfigManager.cs +++ b/src/ReverseProxy/Management/ProxyConfigManager.cs @@ -49,7 +49,7 @@ internal sealed class ProxyConfigManager : EndpointDataSource, IProxyStateLookup private readonly List> _conventions; private readonly IActiveHealthCheckMonitor _activeHealthCheckMonitor; private readonly IClusterDestinationsUpdater _clusterDestinationsUpdater; - + private readonly IConfigChangeListener[] _configChangeListeners; private List? _endpoints; private CancellationTokenSource _endpointsChangeSource = new(); private IChangeToken _endpointsChangeToken; @@ -66,7 +66,8 @@ public ProxyConfigManager( ITransformBuilder transformBuilder, IForwarderHttpClientFactory httpClientFactory, IActiveHealthCheckMonitor activeHealthCheckMonitor, - IClusterDestinationsUpdater clusterDestinationsUpdater) + IClusterDestinationsUpdater clusterDestinationsUpdater, + IEnumerable configChangeListeners) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _providers = providers?.ToArray() ?? throw new ArgumentNullException(nameof(providers)); @@ -80,6 +81,8 @@ public ProxyConfigManager( _activeHealthCheckMonitor = activeHealthCheckMonitor ?? throw new ArgumentNullException(nameof(activeHealthCheckMonitor)); _clusterDestinationsUpdater = clusterDestinationsUpdater ?? throw new ArgumentNullException(nameof(clusterDestinationsUpdater)); + _configChangeListeners = configChangeListeners?.ToArray() ?? Array.Empty(); + if (_providers.Length == 0) { throw new ArgumentException($"At least one {nameof(IProxyConfigProvider)} is required.", nameof(providers)); @@ -141,6 +144,11 @@ private void CreateEndpoints() /// public override IChangeToken GetChangeToken() => Volatile.Read(ref _endpointsChangeToken); + private static IReadOnlyList ExtractListOfProxyConfigs(IEnumerable configStates) + { + return configStates.Select(state => state.LatestConfig).ToList().AsReadOnly(); + } + internal async Task InitialLoadAsync() { // Trigger the first load immediately and throw if it fails. @@ -160,8 +168,20 @@ internal async Task InitialLoadAsync() clusters.AddRange(config.Clusters ?? Array.Empty()); } + var proxyConfigs = ExtractListOfProxyConfigs(_configs); + + foreach (var configChangeListener in _configChangeListeners) + { + configChangeListener.ConfigurationLoaded(proxyConfigs); + } + await ApplyConfigAsync(routes, clusters); + foreach (var configChangeListener in _configChangeListeners) + { + configChangeListener.ConfigurationApplied(proxyConfigs); + } + ListenForConfigChanges(); } catch (Exception ex) @@ -199,6 +219,11 @@ private async Task ReloadConfigAsync() { instance.LoadFailed = true; Log.ErrorReloadingConfig(_logger, ex); + + foreach (var configChangeListener in _configChangeListeners) + { + configChangeListener.ConfigurationLoadingFailed(instance.Provider, ex); + } } // If we didn't/couldn't get a new config then re-use the last one. @@ -206,10 +231,16 @@ private async Task ReloadConfigAsync() clusters.AddRange(instance.LatestConfig.Clusters ?? Array.Empty()); } - // Only reload if at least one provider changed. - if (sourcesChanged) + var proxyConfigs = ExtractListOfProxyConfigs(_configs); + foreach (var configChangeListener in _configChangeListeners) { - try + configChangeListener.ConfigurationLoaded(proxyConfigs); + } + + try + { + // Only reload if at least one provider changed. + if (sourcesChanged) { var hasChanged = await ApplyConfigAsync(routes, clusters); lock (_syncRoot) @@ -222,9 +253,19 @@ private async Task ReloadConfigAsync() } } } - catch (Exception ex) + + foreach (var configChangeListener in _configChangeListeners) + { + configChangeListener.ConfigurationApplied(proxyConfigs); + } + } + catch (Exception ex) + { + Log.ErrorApplyingConfig(_logger, ex); + + foreach (var configChangeListener in _configChangeListeners) { - Log.ErrorApplyingConfig(_logger, ex); + configChangeListener.ConfigurationApplyingFailed(proxyConfigs, ex); } } diff --git a/test/ReverseProxy.Tests/Management/ProxyConfigManagerTests.cs b/test/ReverseProxy.Tests/Management/ProxyConfigManagerTests.cs index f2870a1c5..0f4a3bfd3 100644 --- a/test/ReverseProxy.Tests/Management/ProxyConfigManagerTests.cs +++ b/test/ReverseProxy.Tests/Management/ProxyConfigManagerTests.cs @@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Primitives; using Moq; using Xunit; using Yarp.ReverseProxy.Configuration; @@ -29,7 +30,11 @@ namespace Yarp.ReverseProxy.Management.Tests; public class ProxyConfigManagerTests { - private static IServiceProvider CreateServices(List routes, List clusters, Action configureProxy = null) + private static IServiceProvider CreateServices( + List routes, + List clusters, + Action configureProxy = null, + IEnumerable configListeners = null) { var serviceCollection = new ServiceCollection(); serviceCollection.AddLogging(); @@ -41,13 +46,23 @@ private static IServiceProvider CreateServices(List routes, List p.Name).Returns("activePolicyA"); serviceCollection.AddSingleton(activeHealthPolicy.Object); configureProxy?.Invoke(proxyBuilder); + if (configListeners is not null) + { + foreach (var configListener in configListeners) + { + serviceCollection.AddSingleton(configListener); + } + } var services = serviceCollection.BuildServiceProvider(); var routeBuilder = services.GetRequiredService(); routeBuilder.SetProxyPipeline(context => Task.CompletedTask); return services; } - private static IServiceProvider CreateServices(IEnumerable configProviders, Action configureProxy = null) + private static IServiceProvider CreateServices( + IEnumerable configProviders, + Action configureProxy = null, + IEnumerable configListeners = null) { var serviceCollection = new ServiceCollection(); serviceCollection.AddLogging(); @@ -63,6 +78,13 @@ private static IServiceProvider CreateServices(IEnumerable activeHealthPolicy.SetupGet(p => p.Name).Returns("activePolicyA"); serviceCollection.AddSingleton(activeHealthPolicy.Object); configureProxy?.Invoke(proxyBuilder); + if (configListeners is not null) + { + foreach (var configListener in configListeners) + { + serviceCollection.AddSingleton(configListener); + } + } var services = serviceCollection.BuildServiceProvider(); var routeBuilder = services.GetRequiredService(); routeBuilder.SetProxyPipeline(context => Task.CompletedTask); @@ -407,6 +429,357 @@ public async Task BuildConfig_TwoOverlappingConfigs_Works() Assert.Equal(TestAddress, destination.Model.Config.Address); } + private class FakeConfigChangeListener : IConfigChangeListener + { + public bool? HasApplyingSucceeded { get; private set; } + public bool DidAtLeastOneErrorOccurWhileLoading { get; private set; } + public string[] EventuallyLoaded; + public string[] SuccessfullyApplied; + public string[] FailedApplied; + + public FakeConfigChangeListener() + { + Reset(); + } + + public void Reset() + { + DidAtLeastOneErrorOccurWhileLoading = false; + HasApplyingSucceeded = null; + EventuallyLoaded = Array.Empty(); + SuccessfullyApplied = Array.Empty(); + FailedApplied = Array.Empty(); + } + + public void ConfigurationLoadingFailed(IProxyConfigProvider configProvider, Exception ex) + { + DidAtLeastOneErrorOccurWhileLoading = true; + } + + public void ConfigurationLoaded(IReadOnlyList proxyConfigs) + { + EventuallyLoaded = proxyConfigs.Select(c => c.RevisionId).ToArray(); + } + + public void ConfigurationApplyingFailed(IReadOnlyList proxyConfigs, Exception ex) + { + HasApplyingSucceeded = false; + FailedApplied = proxyConfigs.Select(c => c.RevisionId).ToArray(); + } + + public void ConfigurationApplied(IReadOnlyList proxyConfigs) + { + HasApplyingSucceeded = true; + SuccessfullyApplied = proxyConfigs.Select(c => c.RevisionId).ToArray(); + } + } + + private class ConfigChangeListenerCounter : IConfigChangeListener + { + public int NumberOfLoadedConfigurations { get; private set; } + public int NumberOfFailedConfigurationLoads { get; private set; } + public int NumberOfAppliedConfigurations { get; private set; } + public int NumberOfFailedConfigurationApplications { get; private set; } + + public ConfigChangeListenerCounter() + { + Reset(); + } + + public void Reset() + { + NumberOfLoadedConfigurations = 0; + NumberOfFailedConfigurationLoads = 0; + NumberOfAppliedConfigurations = 0; + NumberOfFailedConfigurationApplications = 0; + } + + public void ConfigurationLoadingFailed(IProxyConfigProvider configProvider, Exception ex) + { + NumberOfFailedConfigurationLoads++; + } + + public void ConfigurationLoaded(IReadOnlyList proxyConfigs) + { + NumberOfLoadedConfigurations += proxyConfigs.Count; + } + + public void ConfigurationApplyingFailed(IReadOnlyList proxyConfigs, Exception ex) + { + NumberOfFailedConfigurationApplications += proxyConfigs.Count; + } + + public void ConfigurationApplied(IReadOnlyList proxyConfigs) + { + NumberOfAppliedConfigurations += proxyConfigs.Count; + } + } + + private class InMemoryConfig : IProxyConfig + { + private readonly CancellationTokenSource _cts = new CancellationTokenSource(); + + public InMemoryConfig(IReadOnlyList routes, IReadOnlyList clusters, string revisionId) + { + RevisionId = revisionId; + Routes = routes; + Clusters = clusters; + ChangeToken = new CancellationChangeToken(_cts.Token); + } + + public string RevisionId { get; } + + public IReadOnlyList Routes { get; } + + public IReadOnlyList Clusters { get; } + + public IChangeToken ChangeToken { get; } + + internal void SignalChange() + { + _cts.Cancel(); + } + } + + private class OnDemandFailingInMemoryConfigProvider : IProxyConfigProvider + { + private volatile InMemoryConfig _config; + + public OnDemandFailingInMemoryConfigProvider( + InMemoryConfig config) + { + _config = config; + } + + public OnDemandFailingInMemoryConfigProvider( + IReadOnlyList routes, + IReadOnlyList clusters, + string revisionId) : this(new InMemoryConfig(routes, clusters, revisionId)) + { + } + + public IProxyConfig GetConfig() + { + if (ShouldConfigLoadingFail) + { + return null; + } + + return _config; + } + + public void Update(IReadOnlyList routes, IReadOnlyList clusters, string revisionId) + { + Update(new InMemoryConfig(routes, clusters, revisionId)); + } + + public void Update(InMemoryConfig config) + { + var oldConfig = Interlocked.Exchange(ref _config, config); + oldConfig.SignalChange(); + } + + public bool ShouldConfigLoadingFail { get; set; } + } + + [Fact] + public async Task BuildConfig_CanBeNotifiedOfProxyConfigSuccessfulAndFailedLoading() + { + var configProviderA = new OnDemandFailingInMemoryConfigProvider(new List() { }, new List() { }, "A1"); + var configProviderB = new OnDemandFailingInMemoryConfigProvider(new List() { }, new List() { }, "B1"); + + var configChangeListenerCounter = new ConfigChangeListenerCounter(); + var fakeConfigChangeListener = new FakeConfigChangeListener(); + + var services = CreateServices(new[] { configProviderA, configProviderB }, null, new IConfigChangeListener[] { fakeConfigChangeListener, configChangeListenerCounter }); + + var manager = services.GetRequiredService(); + await manager.InitialLoadAsync(); + + Assert.Equal(2, configChangeListenerCounter.NumberOfLoadedConfigurations); + Assert.Equal(0, configChangeListenerCounter.NumberOfFailedConfigurationLoads); + Assert.Equal(2, configChangeListenerCounter.NumberOfAppliedConfigurations); + Assert.Equal(0, configChangeListenerCounter.NumberOfFailedConfigurationApplications); + + Assert.False(fakeConfigChangeListener.DidAtLeastOneErrorOccurWhileLoading); + Assert.Equal(new[] { "A1", "B1" }, fakeConfigChangeListener.EventuallyLoaded); + Assert.True(fakeConfigChangeListener.HasApplyingSucceeded); + Assert.Equal(new[] { "A1", "B1" }, fakeConfigChangeListener.SuccessfullyApplied); + Assert.Empty(fakeConfigChangeListener.FailedApplied); + + const string TestAddress = "https://localhost:123/"; + + var cluster1 = new ClusterConfig + { + ClusterId = "cluster1", + Destinations = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "d1", new DestinationConfig { Address = TestAddress } } + } + }; + var cluster2 = new ClusterConfig + { + ClusterId = "cluster2", + Destinations = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "d2", new DestinationConfig { Address = TestAddress } } + } + }; + + var route1 = new RouteConfig + { + RouteId = "route1", + ClusterId = "cluster1", + Match = new RouteMatch { Path = "/" } + }; + var route2 = new RouteConfig + { + RouteId = "route2", + ClusterId = "cluster2", + Match = new RouteMatch { Path = "/" } + }; + + fakeConfigChangeListener.Reset(); + configChangeListenerCounter.Reset(); + + configProviderA.Update(new List() { route1 }, new List() { cluster1 }, "A2"); + + Assert.Equal(2, configChangeListenerCounter.NumberOfLoadedConfigurations); + Assert.Equal(0, configChangeListenerCounter.NumberOfFailedConfigurationLoads); + Assert.Equal(2, configChangeListenerCounter.NumberOfAppliedConfigurations); + Assert.Equal(0, configChangeListenerCounter.NumberOfFailedConfigurationApplications); + + Assert.False(fakeConfigChangeListener.DidAtLeastOneErrorOccurWhileLoading); + Assert.Equal(new[] { "A2", "B1" }, fakeConfigChangeListener.EventuallyLoaded); + Assert.True(fakeConfigChangeListener.HasApplyingSucceeded); + Assert.Equal(new[] { "A2", "B1" }, fakeConfigChangeListener.SuccessfullyApplied); + Assert.Empty(fakeConfigChangeListener.FailedApplied); + + configProviderB.ShouldConfigLoadingFail = true; + + fakeConfigChangeListener.Reset(); + configChangeListenerCounter.Reset(); + + configProviderB.Update(new List() { route2 }, new List() { cluster2 }, "B2"); + + Assert.Equal(2, configChangeListenerCounter.NumberOfLoadedConfigurations); + Assert.Equal(1, configChangeListenerCounter.NumberOfFailedConfigurationLoads); + Assert.Equal(2, configChangeListenerCounter.NumberOfAppliedConfigurations); + Assert.Equal(0, configChangeListenerCounter.NumberOfFailedConfigurationApplications); + + Assert.True(fakeConfigChangeListener.DidAtLeastOneErrorOccurWhileLoading); + Assert.Equal(new[] { "A2", "B1" }, fakeConfigChangeListener.EventuallyLoaded); + Assert.True(fakeConfigChangeListener.HasApplyingSucceeded); + Assert.Equal(new[] { "A2", "B1" }, fakeConfigChangeListener.SuccessfullyApplied); + Assert.Empty(fakeConfigChangeListener.FailedApplied); + } + + [Fact] + public async Task BuildConfig_CanBeNotifiedOfProxyConfigSuccessfulAndFailedUpdating() + { + var configProviderA = new InMemoryConfigProvider(new List() { }, new List() { }, "A1"); + var configProviderB = new InMemoryConfigProvider(new List() { }, new List() { }, "B1"); + + var configChangeListenerCounter = new ConfigChangeListenerCounter(); + var fakeConfigChangeListener = new FakeConfigChangeListener(); + + var services = CreateServices(new[] { configProviderA, configProviderB }, null, new IConfigChangeListener[] { fakeConfigChangeListener, configChangeListenerCounter }); + + var manager = services.GetRequiredService(); + await manager.InitialLoadAsync(); + + Assert.Equal(2, configChangeListenerCounter.NumberOfLoadedConfigurations); + Assert.Equal(0, configChangeListenerCounter.NumberOfFailedConfigurationLoads); + Assert.Equal(2, configChangeListenerCounter.NumberOfAppliedConfigurations); + Assert.Equal(0, configChangeListenerCounter.NumberOfFailedConfigurationApplications); + + Assert.False(fakeConfigChangeListener.DidAtLeastOneErrorOccurWhileLoading); + Assert.Equal(new[] { "A1", "B1" }, fakeConfigChangeListener.EventuallyLoaded); + Assert.True(fakeConfigChangeListener.HasApplyingSucceeded); + Assert.Equal(new[] { "A1", "B1" }, fakeConfigChangeListener.SuccessfullyApplied); + Assert.Empty(fakeConfigChangeListener.FailedApplied); + + const string TestAddress = "https://localhost:123/"; + + var cluster1 = new ClusterConfig + { + ClusterId = "cluster1", + Destinations = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "d1", new DestinationConfig { Address = TestAddress } } + } + }; + var cluster2 = new ClusterConfig + { + ClusterId = "cluster2", + Destinations = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "d2", new DestinationConfig { Address = TestAddress } } + } + }; + + var route1 = new RouteConfig + { + RouteId = "route1", + ClusterId = "cluster1", + Match = new RouteMatch { Path = "/" } + }; + var route2 = new RouteConfig + { + RouteId = "route2", + ClusterId = "cluster2", + // Missing Match here will be caught by the analysis + }; + + fakeConfigChangeListener.Reset(); + configChangeListenerCounter.Reset(); + + configProviderA.Update(new List() { route1 }, new List() { cluster1 }, "A2"); + + Assert.Equal(2, configChangeListenerCounter.NumberOfLoadedConfigurations); + Assert.Equal(0, configChangeListenerCounter.NumberOfFailedConfigurationLoads); + Assert.Equal(2, configChangeListenerCounter.NumberOfAppliedConfigurations); + Assert.Equal(0, configChangeListenerCounter.NumberOfFailedConfigurationApplications); + + Assert.False(fakeConfigChangeListener.DidAtLeastOneErrorOccurWhileLoading); + Assert.Equal(new[] { "A2", "B1" }, fakeConfigChangeListener.EventuallyLoaded); + Assert.True(fakeConfigChangeListener.HasApplyingSucceeded); + Assert.Equal(new[] { "A2", "B1" }, fakeConfigChangeListener.SuccessfullyApplied); + Assert.Empty(fakeConfigChangeListener.FailedApplied); + + fakeConfigChangeListener.Reset(); + configChangeListenerCounter.Reset(); + + configProviderB.Update(new List() { route2 }, new List() { cluster2 }, "B2"); + + Assert.Equal(2, configChangeListenerCounter.NumberOfLoadedConfigurations); + Assert.Equal(0, configChangeListenerCounter.NumberOfFailedConfigurationLoads); + Assert.Equal(0, configChangeListenerCounter.NumberOfAppliedConfigurations); + Assert.Equal(2, configChangeListenerCounter.NumberOfFailedConfigurationApplications); + + Assert.False(fakeConfigChangeListener.DidAtLeastOneErrorOccurWhileLoading); + Assert.Equal(new[] { "A2", "B2" }, fakeConfigChangeListener.EventuallyLoaded); + Assert.False(fakeConfigChangeListener.HasApplyingSucceeded); + Assert.Empty(fakeConfigChangeListener.SuccessfullyApplied); + Assert.Equal(new[] { "A2", "B2" }, fakeConfigChangeListener.FailedApplied); + } + + public class DummyProxyConfig : IProxyConfig + { + public IReadOnlyList Routes => throw new NotImplementedException(); + public IReadOnlyList Clusters => throw new NotImplementedException(); + public IChangeToken ChangeToken => throw new NotImplementedException(); + } + + [Fact] + public void IProxyConfigDerivedTypes_RevisionIdIsAutomaticallySet() + { + IProxyConfig config = new DummyProxyConfig(); + Assert.NotNull(config.RevisionId); + Assert.NotEmpty(config.RevisionId); + Assert.Same(config.RevisionId, config.RevisionId); + } + [Fact] public async Task InitialLoadAsync_ProxyHttpClientOptionsSet_CreateAndSetHttpClient() {