diff --git a/build/install-dotnet.ps1 b/build/install-dotnet.ps1 index 194dbf5a..eeb52b70 100644 --- a/build/install-dotnet.ps1 +++ b/build/install-dotnet.ps1 @@ -1,10 +1,8 @@ -# Installs .NET 6, .NET 7, and .NET 8 for CI/CD environment +# Installs .NET 6 and .NET 8 for CI/CD environment # see: https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-install-script#examples [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; &([scriptblock]::Create((Invoke-WebRequest -UseBasicParsing 'https://dot.net/v1/dotnet-install.ps1'))) -Channel 6.0 -&([scriptblock]::Create((Invoke-WebRequest -UseBasicParsing 'https://dot.net/v1/dotnet-install.ps1'))) -Channel 7.0 - &([scriptblock]::Create((Invoke-WebRequest -UseBasicParsing 'https://dot.net/v1/dotnet-install.ps1'))) -Channel 8.0 diff --git a/examples/ConfigStoreDemo/Program.cs b/examples/ConfigStoreDemo/Program.cs index 90838b59..40d0b9ed 100644 --- a/examples/ConfigStoreDemo/Program.cs +++ b/examples/ConfigStoreDemo/Program.cs @@ -30,7 +30,7 @@ public static IWebHost BuildWebHost(string[] args) .ConfigureRefresh(refresh => { refresh.Register("Settings:BackgroundColor") - .SetCacheExpiration(TimeSpan.FromSeconds(10)); + .SetRefreshInterval(TimeSpan.FromSeconds(10)); }); }); }) diff --git a/examples/ConsoleApplication/Program.cs b/examples/ConsoleApplication/Program.cs index a3e371b0..c3d52ac8 100644 --- a/examples/ConsoleApplication/Program.cs +++ b/examples/ConsoleApplication/Program.cs @@ -57,7 +57,7 @@ private static void Configure() { refresh.Register("AppName") .Register("Language", refreshAll: true) - .SetCacheExpiration(TimeSpan.FromSeconds(10)); + .SetRefreshInterval(TimeSpan.FromSeconds(10)); }); // Get an instance of the refresher that can be used to refresh data diff --git a/src/Microsoft.Azure.AppConfiguration.AspNetCore/Microsoft.Azure.AppConfiguration.AspNetCore.csproj b/src/Microsoft.Azure.AppConfiguration.AspNetCore/Microsoft.Azure.AppConfiguration.AspNetCore.csproj index f11142cc..5fd49e8a 100644 --- a/src/Microsoft.Azure.AppConfiguration.AspNetCore/Microsoft.Azure.AppConfiguration.AspNetCore.csproj +++ b/src/Microsoft.Azure.AppConfiguration.AspNetCore/Microsoft.Azure.AppConfiguration.AspNetCore.csproj @@ -2,7 +2,7 @@ - net6.0;net7.0;net8.0 + net6.0;net8.0 Microsoft.Azure.AppConfiguration.AspNetCore allows developers to use Microsoft Azure App Configuration service as a configuration source in their applications. This package adds additional features for ASP.NET Core applications to the existing package Microsoft.Extensions.Configuration.AzureAppConfiguration. true false diff --git a/src/Microsoft.Azure.AppConfiguration.Functions.Worker/Microsoft.Azure.AppConfiguration.Functions.Worker.csproj b/src/Microsoft.Azure.AppConfiguration.Functions.Worker/Microsoft.Azure.AppConfiguration.Functions.Worker.csproj index 6ae5ccb4..edb4bf28 100644 --- a/src/Microsoft.Azure.AppConfiguration.Functions.Worker/Microsoft.Azure.AppConfiguration.Functions.Worker.csproj +++ b/src/Microsoft.Azure.AppConfiguration.Functions.Worker/Microsoft.Azure.AppConfiguration.Functions.Worker.csproj @@ -2,7 +2,7 @@ - net6.0;net7.0;net8.0 + net6.0;net8.0 Microsoft.Azure.AppConfiguration.Functions.Worker allows developers to use the Microsoft Azure App Configuration service as a configuration source in their applications. This package adds additional features to the existing package Microsoft.Extensions.Configuration.AzureAppConfiguration for .NET Azure Functions running in an isolated process. true false diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index d79838da..7d9a9cad 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -35,10 +35,15 @@ public class AzureAppConfigurationOptions private SortedSet _keyPrefixes = new SortedSet(Comparer.Create((k1, k2) => -string.Compare(k1, k2, StringComparison.OrdinalIgnoreCase))); /// - /// Flag to indicate whether enable replica discovery. + /// Flag to indicate whether replica discovery is enabled. /// public bool ReplicaDiscoveryEnabled { get; set; } = true; + /// + /// Flag to indicate whether load balancing is enabled. + /// + public bool LoadBalancingEnabled { get; set; } + /// /// The list of connection strings used to connect to an Azure App Configuration store and its replicas. /// @@ -119,7 +124,7 @@ internal IEnumerable Adapters /// /// Indicates all types of feature filters used by the application. /// - internal FeatureFilterTracing FeatureFilterTracing { get; set; } = new FeatureFilterTracing(); + internal FeatureFlagTracing FeatureFlagTracing { get; set; } = new FeatureFlagTracing(); /// /// Options used to configure provider startup. @@ -135,7 +140,7 @@ public AzureAppConfigurationOptions() { new AzureKeyVaultKeyValueAdapter(new AzureKeyVaultSecretProvider()), new JsonKeyValueAdapter(), - new FeatureManagementKeyValueAdapter(FeatureFilterTracing) + new FeatureManagementKeyValueAdapter(FeatureFlagTracing) }; } @@ -215,10 +220,10 @@ public AzureAppConfigurationOptions UseFeatureFlags(Action c FeatureFlagOptions options = new FeatureFlagOptions(); configure?.Invoke(options); - if (options.CacheExpirationInterval < RefreshConstants.MinimumFeatureFlagsCacheExpirationInterval) + if (options.RefreshInterval < RefreshConstants.MinimumFeatureFlagRefreshInterval) { - throw new ArgumentOutOfRangeException(nameof(options.CacheExpirationInterval), options.CacheExpirationInterval.TotalMilliseconds, - string.Format(ErrorMessages.CacheExpirationTimeTooShort, RefreshConstants.MinimumFeatureFlagsCacheExpirationInterval.TotalMilliseconds)); + throw new ArgumentOutOfRangeException(nameof(options.RefreshInterval), options.RefreshInterval.TotalMilliseconds, + string.Format(ErrorMessages.RefreshIntervalTooShort, RefreshConstants.MinimumFeatureFlagRefreshInterval.TotalMilliseconds)); } if (options.FeatureFlagSelectors.Count() != 0 && options.Label != null) @@ -247,8 +252,8 @@ public AzureAppConfigurationOptions UseFeatureFlags(Action c { Key = featureFlagFilter, Label = labelFilter, - // If UseFeatureFlags is called multiple times for the same key and label filters, last cache expiration time wins - CacheExpirationInterval = options.CacheExpirationInterval + // If UseFeatureFlags is called multiple times for the same key and label filters, last refresh interval wins + RefreshInterval = options.RefreshInterval }); } @@ -381,7 +386,7 @@ public AzureAppConfigurationOptions ConfigureRefresh(Action _mappedData; private Dictionary _watchedSettings = new Dictionary(); private RequestTracingOptions _requestTracingOptions; private Dictionary _configClientBackoffs = new Dictionary(); - private readonly TimeSpan MinCacheExpirationInterval; + private readonly TimeSpan MinRefreshInterval; // The most-recent time when the refresh operation attempted to load the initial configuration private DateTimeOffset InitializationCacheExpires = default; @@ -111,11 +112,11 @@ public AzureAppConfigurationProvider(IConfigurationClientManager configClientMan if (watchers.Any()) { - MinCacheExpirationInterval = watchers.Min(w => w.CacheExpirationInterval); + MinRefreshInterval = watchers.Min(w => w.RefreshInterval); } else { - MinCacheExpirationInterval = RefreshConstants.DefaultCacheExpirationInterval; + MinRefreshInterval = RefreshConstants.DefaultRefreshInterval; } // Enable request tracing if not opt-out @@ -193,13 +194,13 @@ public async Task RefreshAsync(CancellationToken cancellationToken) EnsureAssemblyInspected(); var utcNow = DateTimeOffset.UtcNow; - IEnumerable cacheExpiredWatchers = _options.ChangeWatchers.Where(changeWatcher => utcNow >= changeWatcher.CacheExpires); - IEnumerable cacheExpiredMultiKeyWatchers = _options.MultiKeyWatchers.Where(changeWatcher => utcNow >= changeWatcher.CacheExpires); + IEnumerable refreshableWatchers = _options.ChangeWatchers.Where(changeWatcher => utcNow >= changeWatcher.NextRefreshTime); + IEnumerable refreshableMultiKeyWatchers = _options.MultiKeyWatchers.Where(changeWatcher => utcNow >= changeWatcher.NextRefreshTime); - // Skip refresh if mappedData is loaded, but none of the watchers or adapters cache is expired. + // Skip refresh if mappedData is loaded, but none of the watchers or adapters are refreshable. if (_mappedData != null && - !cacheExpiredWatchers.Any() && - !cacheExpiredMultiKeyWatchers.Any() && + !refreshableWatchers.Any() && + !refreshableMultiKeyWatchers.Any() && !_options.Adapters.Any(adapter => adapter.NeedsRefresh())) { return; @@ -238,7 +239,7 @@ public async Task RefreshAsync(CancellationToken cancellationToken) { if (InitializationCacheExpires < utcNow) { - InitializationCacheExpires = utcNow.Add(MinCacheExpirationInterval); + InitializationCacheExpires = utcNow.Add(MinRefreshInterval); await InitializeAsync(clients, cancellationToken).ConfigureAwait(false); } @@ -267,7 +268,7 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => logDebugBuilder.Clear(); logInfoBuilder.Clear(); - foreach (KeyValueWatcher changeWatcher in cacheExpiredWatchers) + foreach (KeyValueWatcher changeWatcher in refreshableWatchers) { string watchedKey = changeWatcher.Key; string watchedLabel = changeWatcher.Label; @@ -338,7 +339,7 @@ await CallWithRequestTracing( return; } - changedKeyValuesCollection = await GetRefreshedKeyValueCollections(cacheExpiredMultiKeyWatchers, client, logDebugBuilder, logInfoBuilder, endpoint, cancellationToken).ConfigureAwait(false); + changedKeyValuesCollection = await GetRefreshedKeyValueCollections(refreshableMultiKeyWatchers, client, logDebugBuilder, logInfoBuilder, endpoint, cancellationToken).ConfigureAwait(false); if (!changedKeyValuesCollection.Any()) { @@ -352,9 +353,9 @@ await CallWithRequestTracing( { watchedSettings = new Dictionary(_watchedSettings); - foreach (KeyValueWatcher changeWatcher in cacheExpiredWatchers.Concat(cacheExpiredMultiKeyWatchers)) + foreach (KeyValueWatcher changeWatcher in refreshableWatchers.Concat(refreshableMultiKeyWatchers)) { - UpdateCacheExpirationTime(changeWatcher); + UpdateNextRefreshTime(changeWatcher); } foreach (KeyValueChange change in keyValueChanges.Concat(changedKeyValuesCollection)) @@ -389,14 +390,14 @@ await CallWithRequestTracing( // Invalidate the cached Key Vault secret (if any) for this ConfigurationSetting foreach (IKeyValueAdapter adapter in _options.Adapters) { - // If the current setting is null, try to invalidate the previous setting instead + // If the current setting is null, try to pass the previous setting instead if (change.Current != null) { - adapter.InvalidateCache(change.Current); + adapter.OnChangeDetected(change.Current); } else if (change.Previous != null) { - adapter.InvalidateCache(change.Previous); + adapter.OnChangeDetected(change.Previous); } } } @@ -408,13 +409,13 @@ await CallWithRequestTracing( // Invalidate all the cached KeyVault secrets foreach (IKeyValueAdapter adapter in _options.Adapters) { - adapter.InvalidateCache(); + adapter.OnChangeDetected(); } - // Update the cache expiration time for all refresh registered settings and feature flags + // Update the next refresh time for all refresh registered settings and feature flags foreach (KeyValueWatcher changeWatcher in _options.ChangeWatchers.Concat(_options.MultiKeyWatchers)) { - UpdateCacheExpirationTime(changeWatcher); + UpdateNextRefreshTime(changeWatcher); } } @@ -552,16 +553,16 @@ public void ProcessPushNotification(PushNotification pushNotification, TimeSpan? private void SetDirty(TimeSpan? maxDelay) { - DateTimeOffset cacheExpires = AddRandomDelay(DateTimeOffset.UtcNow, maxDelay ?? DefaultMaxSetDirtyDelay); + DateTimeOffset nextRefreshTime = AddRandomDelay(DateTimeOffset.UtcNow, maxDelay ?? DefaultMaxSetDirtyDelay); foreach (KeyValueWatcher changeWatcher in _options.ChangeWatchers) { - changeWatcher.CacheExpires = cacheExpires; + changeWatcher.NextRefreshTime = nextRefreshTime; } foreach (KeyValueWatcher changeWatcher in _options.MultiKeyWatchers) { - changeWatcher.CacheExpires = cacheExpires; + changeWatcher.NextRefreshTime = nextRefreshTime; } } @@ -569,8 +570,8 @@ private async Task> PrepareData(Dictionary(StringComparer.OrdinalIgnoreCase); - // Reset old filter tracing in order to track the filter types present in the current response from server. - _options.FeatureFilterTracing.ResetFeatureFilterTracing(); + // Reset old feature flag tracing in order to track the information present in the current response from server. + _options.FeatureFlagTracing.ResetFeatureFlagTracing(); foreach (KeyValuePair kvp in data) { @@ -728,10 +729,10 @@ await ExecuteWithFailOverPolicyAsync( cancellationToken) .ConfigureAwait(false); - // Update the cache expiration time for all refresh registered settings and feature flags + // Update the next refresh time for all refresh registered settings and feature flags foreach (KeyValueWatcher changeWatcher in _options.ChangeWatchers.Concat(_options.MultiKeyWatchers)) { - UpdateCacheExpirationTime(changeWatcher); + UpdateNextRefreshTime(changeWatcher); } if (data != null) @@ -739,7 +740,7 @@ await ExecuteWithFailOverPolicyAsync( // Invalidate all the cached KeyVault secrets foreach (IKeyValueAdapter adapter in _options.Adapters) { - adapter.InvalidateCache(); + adapter.OnChangeDetected(); } Dictionary mappedData = await MapConfigurationSettings(data).ConfigureAwait(false); @@ -918,6 +919,11 @@ private void SetData(IDictionary data) // Set the application data for the configuration provider Data = data; + foreach (IKeyValueAdapter adapter in _options.Adapters) + { + adapter.OnConfigUpdated(); + } + // Notify that the configuration has been updated OnReload(); } @@ -933,7 +939,7 @@ private async Task>> ProcessAdapters(Co continue; } - IEnumerable> kvs = await adapter.ProcessKeyValue(setting, _logger, cancellationToken).ConfigureAwait(false); + IEnumerable> kvs = await adapter.ProcessKeyValue(setting, AppConfigurationEndpoint, _logger, cancellationToken).ConfigureAwait(false); if (kvs != null) { @@ -961,7 +967,8 @@ private void SetRequestTracingOptions() IsKeyVaultConfigured = _options.IsKeyVaultConfigured, IsKeyVaultRefreshConfigured = _options.IsKeyVaultRefreshConfigured, ReplicaCount = _options.Endpoints?.Count() - 1 ?? _options.ConnectionStrings?.Count() - 1 ?? 0, - FilterTracing = _options.FeatureFilterTracing + FeatureFlagTracing = _options.FeatureFlagTracing, + IsLoadBalancingEnabled = _options.LoadBalancingEnabled }; } @@ -986,10 +993,9 @@ private bool IsAuthenticationError(Exception ex) return false; } - private void UpdateCacheExpirationTime(KeyValueWatcher changeWatcher) + private void UpdateNextRefreshTime(KeyValueWatcher changeWatcher) { - TimeSpan cacheExpirationTime = changeWatcher.CacheExpirationInterval; - changeWatcher.CacheExpires = DateTimeOffset.UtcNow.Add(cacheExpirationTime); + changeWatcher.NextRefreshTime = DateTimeOffset.UtcNow.Add(changeWatcher.RefreshInterval); } private async Task ExecuteWithFailOverPolicyAsync( @@ -997,6 +1003,32 @@ private async Task ExecuteWithFailOverPolicyAsync( Func> funcToExecute, CancellationToken cancellationToken = default) { + if (_requestTracingEnabled && _requestTracingOptions != null) + { + _requestTracingOptions.IsFailoverRequest = false; + } + + if (_options.LoadBalancingEnabled && _lastSuccessfulEndpoint != null && clients.Count() > 1) + { + int nextClientIndex = 0; + + foreach (ConfigurationClient client in clients) + { + nextClientIndex++; + + if (_configClientManager.GetEndpointForClient(client) == _lastSuccessfulEndpoint) + { + break; + } + } + + // If we found the last successful client, we'll rotate the list so that the next client is at the beginning + if (nextClientIndex < clients.Count()) + { + clients = clients.Skip(nextClientIndex).Concat(clients.Take(nextClientIndex)); + } + } + using IEnumerator clientEnumerator = clients.GetEnumerator(); clientEnumerator.MoveNext(); @@ -1017,6 +1049,8 @@ private async Task ExecuteWithFailOverPolicyAsync( T result = await funcToExecute(currentClient).ConfigureAwait(false); success = true; + _lastSuccessfulEndpoint = _configClientManager.GetEndpointForClient(currentClient); + return result; } catch (RequestFailedException rfe) @@ -1067,6 +1101,11 @@ private async Task ExecuteWithFailOverPolicyAsync( } previousEndpoint = currentEndpoint; + + if (_requestTracingEnabled && _requestTracingOptions != null) + { + _requestTracingOptions.IsFailoverRequest = true; + } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefreshOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefreshOptions.cs index 7522896e..f3fb6c4a 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefreshOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationRefreshOptions.cs @@ -12,7 +12,7 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration /// public class AzureAppConfigurationRefreshOptions { - internal TimeSpan CacheExpirationInterval { get; private set; } = RefreshConstants.DefaultCacheExpirationInterval; + internal TimeSpan RefreshInterval { get; private set; } = RefreshConstants.DefaultRefreshInterval; internal ISet RefreshRegistrations = new HashSet(); /// @@ -55,15 +55,28 @@ public AzureAppConfigurationRefreshOptions Register(string key, string label = L /// Any refresh operation triggered using will not update the value for a key until the cached value for that key has expired. /// /// Minimum time that must elapse before the cache is expired. + [Obsolete("The " + nameof(SetCacheExpiration) + " method is deprecated and will be removed in a future release. " + + "Please use the " + nameof(SetRefreshInterval) + " method instead. " + + "Note that only the name of the method has changed, and the functionality remains the same.")] public AzureAppConfigurationRefreshOptions SetCacheExpiration(TimeSpan cacheExpiration) { - if (cacheExpiration < RefreshConstants.MinimumCacheExpirationInterval) + return SetRefreshInterval(cacheExpiration); + } + + /// + /// Sets the minimum time interval between consecutive refresh operations for the registered key-values. Default value is 30 seconds. Must be greater than 1 second. + /// Refresh operations triggered using will not make any server requests unless the refresh interval has elapsed since the key was last refreshed. + /// + /// Minimum time that must elapse between each refresh for a specific key. + public AzureAppConfigurationRefreshOptions SetRefreshInterval(TimeSpan refreshInterval) + { + if (refreshInterval < RefreshConstants.MinimumRefreshInterval) { - throw new ArgumentOutOfRangeException(nameof(cacheExpiration), cacheExpiration.TotalMilliseconds, - string.Format(ErrorMessages.CacheExpirationTimeTooShort, RefreshConstants.MinimumCacheExpirationInterval.TotalMilliseconds)); + throw new ArgumentOutOfRangeException(nameof(refreshInterval), refreshInterval.TotalMilliseconds, + string.Format(ErrorMessages.RefreshIntervalTooShort, RefreshConstants.MinimumRefreshInterval.TotalMilliseconds)); } - CacheExpirationInterval = cacheExpiration; + RefreshInterval = refreshInterval; return this; } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs index 09544029..dee62006 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs @@ -37,11 +37,20 @@ public IConfigurationProvider Build(IConfigurationBuilder builder) } else if (options.ConnectionStrings != null) { - clientManager = new ConfigurationClientManager(options.ConnectionStrings, options.ClientOptions, options.ReplicaDiscoveryEnabled); + clientManager = new ConfigurationClientManager( + options.ConnectionStrings, + options.ClientOptions, + options.ReplicaDiscoveryEnabled, + options.LoadBalancingEnabled); } else if (options.Endpoints != null && options.Credential != null) { - clientManager = new ConfigurationClientManager(options.Endpoints, options.Credential, options.ClientOptions, options.ReplicaDiscoveryEnabled); + clientManager = new ConfigurationClientManager( + options.Endpoints, + options.Credential, + options.ClientOptions, + options.ReplicaDiscoveryEnabled, + options.LoadBalancingEnabled); } else { diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs index f0880809..a272b413 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs @@ -27,7 +27,7 @@ public AzureKeyVaultKeyValueAdapter(AzureKeyVaultSecretProvider secretProvider) /// Uses the Azure Key Vault secret provider to resolve Key Vault references retrieved from Azure App Configuration. /// inputs the IKeyValue /// returns the keyname and actual value - public async Task>> ProcessKeyValue(ConfigurationSetting setting, Logger logger, CancellationToken cancellationToken) + public async Task>> ProcessKeyValue(ConfigurationSetting setting, Uri endpoint, Logger logger, CancellationToken cancellationToken) { string secretRefUri = ParseSecretReferenceUri(setting); @@ -76,7 +76,7 @@ public bool CanProcess(ConfigurationSetting setting) return string.Equals(contentType, KeyVaultConstants.ContentType); } - public void InvalidateCache(ConfigurationSetting setting = null) + public void OnChangeDetected(ConfigurationSetting setting = null) { if (setting == null) { @@ -96,6 +96,11 @@ public void InvalidateCache(ConfigurationSetting setting = null) } } + public void OnConfigUpdated() + { + return; + } + public bool NeedsRefresh() { return _secretProvider.ShouldRefreshKeyVaultSecrets(); diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientManager.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientManager.cs index 06f916b0..a0215ca3 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientManager.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientManager.cs @@ -54,7 +54,8 @@ internal class ConfigurationClientManager : IConfigurationClientManager, IDispos public ConfigurationClientManager( IEnumerable connectionStrings, ConfigurationClientOptions clientOptions, - bool replicaDiscoveryEnabled) + bool replicaDiscoveryEnabled, + bool loadBalancingEnabled) { if (connectionStrings == null || !connectionStrings.Any()) { @@ -68,6 +69,12 @@ public ConfigurationClientManager( _clientOptions = clientOptions; _replicaDiscoveryEnabled = replicaDiscoveryEnabled; + // If load balancing is enabled, shuffle the passed in connection strings to randomize the endpoint used on startup + if (loadBalancingEnabled) + { + connectionStrings = connectionStrings.ToList().Shuffle(); + } + _validDomain = GetValidDomain(_endpoint); _srvLookupClient = new SrvLookupClient(); @@ -84,7 +91,8 @@ public ConfigurationClientManager( IEnumerable endpoints, TokenCredential credential, ConfigurationClientOptions clientOptions, - bool replicaDiscoveryEnabled) + bool replicaDiscoveryEnabled, + bool loadBalancingEnabled) { if (endpoints == null || !endpoints.Any()) { @@ -101,6 +109,12 @@ public ConfigurationClientManager( _clientOptions = clientOptions; _replicaDiscoveryEnabled = replicaDiscoveryEnabled; + // If load balancing is enabled, shuffle the passed in endpoints to randomize the endpoint used on startup + if (loadBalancingEnabled) + { + endpoints = endpoints.ToList().Shuffle(); + } + _validDomain = GetValidDomain(_endpoint); _srvLookupClient = new SrvLookupClient(); @@ -132,6 +146,7 @@ public IEnumerable GetClients() _ = DiscoverFallbackClients(); } + // Treat the passed in endpoints as the highest priority clients IEnumerable clients = _clients.Select(c => c.Client); if (_dynamicClients != null && _dynamicClients.Any()) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs index 61b4bb3b..c7974736 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs @@ -5,10 +5,10 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration { internal class ErrorMessages { - public const string CacheExpirationTimeTooShort = "The cache expiration time cannot be less than {0} milliseconds."; + public const string RefreshIntervalTooShort = "The refresh interval cannot be less than {0} milliseconds."; public const string SecretRefreshIntervalTooShort = "The secret refresh interval cannot be less than {0} milliseconds."; public const string FeatureFlagInvalidJsonProperty = "Invalid property '{0}' for feature flag. Key: '{1}'. Found type: '{2}'. Expected type: '{3}'."; - public const string FeatureFlagInvalidFormat = "Invalid json format for feature flag. Key: '{0}'"; + public const string FeatureFlagInvalidFormat = "Invalid json format for feature flag. Key: '{0}'."; public const string InvalidKeyVaultReference = "Invalid Key Vault reference."; } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RefreshConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RefreshConstants.cs index 954e3775..2c1616fe 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RefreshConstants.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RefreshConstants.cs @@ -8,12 +8,12 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration internal class RefreshConstants { // Key-values - public static readonly TimeSpan DefaultCacheExpirationInterval = TimeSpan.FromSeconds(30); - public static readonly TimeSpan MinimumCacheExpirationInterval = TimeSpan.FromSeconds(1); + public static readonly TimeSpan DefaultRefreshInterval = TimeSpan.FromSeconds(30); + public static readonly TimeSpan MinimumRefreshInterval = TimeSpan.FromSeconds(1); // Feature flags - public static readonly TimeSpan DefaultFeatureFlagsCacheExpirationInterval = TimeSpan.FromSeconds(30); - public static readonly TimeSpan MinimumFeatureFlagsCacheExpirationInterval = TimeSpan.FromSeconds(1); + public static readonly TimeSpan DefaultFeatureFlagRefreshInterval = TimeSpan.FromSeconds(30); + public static readonly TimeSpan MinimumFeatureFlagRefreshInterval = TimeSpan.FromSeconds(1); // Key Vault secrets public static readonly TimeSpan MinimumSecretRefreshInterval = TimeSpan.FromMinutes(1); diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs index 6ff59fa1..15e862b6 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs @@ -22,7 +22,6 @@ internal class RequestTracingConstants public const string RequestTypeKey = "RequestType"; public const string HostTypeKey = "Host"; - public const string FilterTypeKey = "Filter"; public const string EnvironmentKey = "Env"; public const string FeatureManagementVersionKey = "FMVer"; public const string FeatureManagementAspNetCoreVersionKey = "FMANCVer"; @@ -30,7 +29,21 @@ internal class RequestTracingConstants public const string KeyVaultConfiguredTag = "UsesKeyVault"; public const string KeyVaultRefreshConfiguredTag = "RefreshesKeyVault"; public const string ReplicaCountKey = "ReplicaCount"; - public const string SignalRUsedTag = "UsesSignalR"; + public const string FeaturesKey = "Features"; + public const string LoadBalancingEnabledTag = "LB"; + public const string SignalRUsedTag = "SignalR"; + public const string FailoverRequestTag = "Failover"; + + public const string FeatureFlagFilterTypeKey = "Filter"; + public const string CustomFilter = "CSTM"; + public const string PercentageFilter = "PRCNT"; + public const string TimeWindowFilter = "TIME"; + public const string TargetingFilter = "TRGT"; + public const string FeatureFlagFeaturesKey = "FFFeatures"; + public const string FeatureFlagUsesTelemetryTag = "Telemetry"; + public const string FeatureFlagUsesSeedTag = "Seed"; + public const string FeatureFlagMaxVariantsKey = "MaxVariants"; + public const string FeatureFlagUsesVariantConfigurationReferenceTag = "ConfigRef"; public const string DiagnosticHeaderActivityName = "Azure.CustomDiagnosticHeaders"; public const string CorrelationContextHeader = "Correlation-Context"; @@ -38,7 +51,8 @@ internal class RequestTracingConstants public const string FeatureManagementAssemblyName = "Microsoft.FeatureManagement"; public const string FeatureManagementAspNetCoreAssemblyName = "Microsoft.FeatureManagement.AspNetCore"; - public const string SignalRAssemblyName = "Microsoft.AspNetCore.SignalR"; + + public const string Delimiter = "+"; } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/BytesExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/BytesExtensions.cs new file mode 100644 index 00000000..3c5266ec --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/BytesExtensions.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System; +using System.Text; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions +{ + internal static class BytesExtensions + { + /// + /// Converts a byte array to Base64URL string with optional padding ('=') characters removed. + /// Base64 description: https://datatracker.ietf.org/doc/html/rfc4648.html#section-4 + /// + public static string ToBase64Url(this byte[] bytes) + { + string bytesBase64 = Convert.ToBase64String(bytes); + + int indexOfEquals = bytesBase64.IndexOf("="); + + // Skip the optional padding of '=' characters based on the Base64Url spec if any are present from the Base64 conversion + int stringBuilderCapacity = indexOfEquals != -1 ? indexOfEquals : bytesBase64.Length; + + StringBuilder stringBuilder = new StringBuilder(stringBuilderCapacity); + + // Construct Base64URL string by replacing characters in Base64 conversion that are not URL safe + for (int i = 0; i < stringBuilderCapacity; i++) + { + if (bytesBase64[i] == '+') + { + stringBuilder.Append('-'); + } + else if (bytesBase64[i] == '/') + { + stringBuilder.Append('_'); + } + else + { + stringBuilder.Append(bytesBase64[i]); + } + } + + return stringBuilder.ToString(); + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ListExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ListExtensions.cs index 99024c3c..93722539 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ListExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ListExtensions.cs @@ -1,15 +1,15 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -// +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// using DnsClient.Protocol; using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; - -namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions -{ - internal static class ListExtensions - { + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions +{ + internal static class ListExtensions + { public static List Shuffle(this List values) { var rdm = new Random(); @@ -63,6 +63,6 @@ public static void AppendUnique(this List items, T item) // Append to the end, keeping precedence. items.Add(item); - } + } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureAllocation.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureAllocation.cs new file mode 100644 index 00000000..0b2877e6 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureAllocation.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System.Collections.Generic; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement +{ + internal class FeatureAllocation + { + public string DefaultWhenDisabled { get; set; } + + public string DefaultWhenEnabled { get; set; } + + public IEnumerable User { get; set; } + + public IEnumerable Group { get; set; } + + public IEnumerable Percentile { get; set; } + + public string Seed { get; set; } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlag.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlag.cs index aaf38540..31af50a6 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlag.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlag.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // +using System.Collections.Generic; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement { @@ -11,5 +12,11 @@ internal class FeatureFlag public bool Enabled { get; set; } public FeatureConditions Conditions { get; set; } + + public IEnumerable Variants { get; set; } + + public FeatureAllocation Allocation { get; set; } + + public FeatureTelemetry Telemetry { get; set; } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs index c63fddc1..1e8beae6 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs @@ -14,11 +14,22 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManage /// public class FeatureFlagOptions { + private TimeSpan _refreshInterval = RefreshConstants.DefaultFeatureFlagRefreshInterval; + /// /// A collection of . /// internal List FeatureFlagSelectors = new List(); + /// + /// The time after which feature flags can be refreshed. Must be greater than or equal to 1 second. + /// + internal TimeSpan RefreshInterval + { + get { return _refreshInterval; } + set { _refreshInterval = value; } + } + /// /// The label that feature flags will be selected from. /// @@ -27,7 +38,27 @@ public class FeatureFlagOptions /// /// The time after which the cached values of the feature flags expire. Must be greater than or equal to 1 second. /// - public TimeSpan CacheExpirationInterval { get; set; } = RefreshConstants.DefaultFeatureFlagsCacheExpirationInterval; + [Obsolete("The " + nameof(CacheExpirationInterval) + " property is deprecated and will be removed in a future release. " + + "Please use the new " + nameof(SetRefreshInterval) + " method instead. " + + "Note that the usage has changed, but the functionality remains the same.")] + public TimeSpan CacheExpirationInterval + { + get { return _refreshInterval; } + set { _refreshInterval = value; } + } + + /// + /// Sets the time after which feature flags can be refreshed. + /// + /// + /// Sets the minimum time interval between consecutive refresh operations for feature flags. Default value is 30 seconds. Must be greater than or equal to 1 second. + /// + public FeatureFlagOptions SetRefreshInterval(TimeSpan refreshInterval) + { + RefreshInterval = refreshInterval; + + return this; + } /// /// Specify what feature flags to include in the configuration provider. diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFilterTracing.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagTracing.cs similarity index 55% rename from src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFilterTracing.cs rename to src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagTracing.cs index deb6db49..f48b5220 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFilterTracing.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagTracing.cs @@ -12,14 +12,8 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManage /// /// Tracing for tracking built-in feature filter usage. /// - internal class FeatureFilterTracing + internal class FeatureFlagTracing { - private const string CustomFilter = "CSTM"; - private const string PercentageFilter = "PRCNT"; - private const string TimeWindowFilter = "TIME"; - private const string TargetingFilter = "TRGT"; - private const string FilterTypeDelimiter = "+"; - // Built-in Feature Filter Names private readonly List PercentageFilterNames = new List { "Percentage", "Microsoft.Percentage", "PercentageFilter", "Microsoft.PercentageFilter" }; private readonly List TimeWindowFilterNames = new List { "TimeWindow", "Microsoft.TimeWindow", "TimeWindowFilter", "Microsoft.TimeWindowFilter" }; @@ -29,18 +23,31 @@ internal class FeatureFilterTracing public bool UsesPercentageFilter { get; set; } = false; public bool UsesTimeWindowFilter { get; set; } = false; public bool UsesTargetingFilter { get; set; } = false; + public bool UsesSeed { get; set; } = false; + public bool UsesTelemetry { get; set; } = false; + public bool UsesVariantConfigurationReference { get; set; } = false; + public int MaxVariants { get; set; } public bool UsesAnyFeatureFilter() { return UsesCustomFilter || UsesPercentageFilter || UsesTimeWindowFilter || UsesTargetingFilter; } - public void ResetFeatureFilterTracing() + public bool UsesAnyTracingFeature() + { + return UsesSeed || UsesTelemetry || UsesVariantConfigurationReference; + } + + public void ResetFeatureFlagTracing() { UsesCustomFilter = false; UsesPercentageFilter = false; UsesTimeWindowFilter = false; UsesTargetingFilter = false; + UsesSeed = false; + UsesTelemetry = false; + UsesVariantConfigurationReference = false; + MaxVariants = 0; } public void UpdateFeatureFilterTracing(string filterName) @@ -63,11 +70,19 @@ public void UpdateFeatureFilterTracing(string filterName) } } + public void NotifyMaxVariants(int currentFlagTotalVariants) + { + if (currentFlagTotalVariants > MaxVariants) + { + MaxVariants = currentFlagTotalVariants; + } + } + /// /// Returns a formatted string containing code names, indicating which feature filters are used by the application. /// /// Formatted string like: "CSTM+PRCNT+TIME+TRGT", "PRCNT+TRGT", etc. If no filters are used, empty string will be returned. - public override string ToString() + public string CreateFiltersString() { if (!UsesAnyFeatureFilter()) { @@ -78,37 +93,78 @@ public override string ToString() if (UsesCustomFilter) { - sb.Append(CustomFilter); + sb.Append(RequestTracingConstants.CustomFilter); } if (UsesPercentageFilter) { if (sb.Length > 0) { - sb.Append(FilterTypeDelimiter); + sb.Append(RequestTracingConstants.Delimiter); } - sb.Append(PercentageFilter); + sb.Append(RequestTracingConstants.PercentageFilter); } if (UsesTimeWindowFilter) { if (sb.Length > 0) { - sb.Append(FilterTypeDelimiter); + sb.Append(RequestTracingConstants.Delimiter); } - sb.Append(TimeWindowFilter); + sb.Append(RequestTracingConstants.TimeWindowFilter); } if (UsesTargetingFilter) { if (sb.Length > 0) { - sb.Append(FilterTypeDelimiter); + sb.Append(RequestTracingConstants.Delimiter); + } + + sb.Append(RequestTracingConstants.TargetingFilter); + } + + return sb.ToString(); + } + + /// + /// Returns a formatted string containing code names, indicating which tracing features are used by feature flags. + /// + /// Formatted string like: "Seed+ConfigRef+Telemetry". If no tracing features are used, empty string will be returned. + public string CreateFeaturesString() + { + if (!UsesAnyTracingFeature()) + { + return string.Empty; + } + + var sb = new StringBuilder(); + + if (UsesSeed) + { + sb.Append(RequestTracingConstants.FeatureFlagUsesSeedTag); + } + + if (UsesVariantConfigurationReference) + { + if (sb.Length > 0) + { + sb.Append(RequestTracingConstants.Delimiter); + } + + sb.Append(RequestTracingConstants.FeatureFlagUsesVariantConfigurationReferenceTag); + } + + if (UsesTelemetry) + { + if (sb.Length > 0) + { + sb.Append(RequestTracingConstants.Delimiter); } - sb.Append(TargetingFilter); + sb.Append(RequestTracingConstants.FeatureFlagUsesTelemetryTag); } return sb.ToString(); diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureGroupAllocation.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureGroupAllocation.cs new file mode 100644 index 00000000..f39ca8cd --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureGroupAllocation.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System.Collections.Generic; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement +{ + internal class FeatureGroupAllocation + { + public string Variant { get; set; } + + public IEnumerable Groups { get; set; } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs index e4775950..c6d86d84 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs @@ -1,22 +1,53 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // + namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement { internal class FeatureManagementConstants { public const string FeatureFlagMarker = ".appconfig.featureflag/"; public const string ContentType = "application/vnd.microsoft.appconfig.ff+json"; - public const string SectionName = "FeatureManagement"; - public const string EnabledFor = "EnabledFor"; - public const string RequirementType = "RequirementType"; - public const string EnabledJsonPropertyName = "enabled"; - public const string IdJsonPropertyName = "id"; - public const string ConditionsJsonPropertyName = "conditions"; - public const string RequirementTypeJsonPropertyName = "requirement_type"; - public const string ClientFiltersJsonPropertyName = "client_filters"; - public const string NameJsonPropertyName = "name"; - public const string ParametersJsonPropertyName = "parameters"; + // Feature management section keys + public const string FeatureManagementSectionName = "feature_management"; + public const string FeatureFlagsSectionName = "feature_flags"; + + // Feature flag properties + public const string Id = "id"; + public const string Enabled = "enabled"; + public const string Conditions = "conditions"; + public const string ClientFilters = "client_filters"; + public const string Variants = "variants"; + public const string Allocation = "allocation"; + public const string UserAllocation = "user"; + public const string GroupAllocation = "group"; + public const string PercentileAllocation = "percentile"; + public const string Telemetry = "telemetry"; + public const string Metadata = "metadata"; + public const string RequirementType = "requirement_type"; + public const string Name = "name"; + public const string Parameters = "parameters"; + public const string Variant = "variant"; + public const string ConfigurationValue = "configuration_value"; + public const string ConfigurationReference = "configuration_reference"; + public const string StatusOverride = "status_override"; + public const string DefaultWhenDisabled = "default_when_disabled"; + public const string DefaultWhenEnabled = "default_when_enabled"; + public const string Users = "users"; + public const string Groups = "groups"; + public const string From = "from"; + public const string To = "to"; + public const string Seed = "seed"; + + // Telemetry metadata keys + public const string ETag = "ETag"; + public const string FeatureFlagId = "FeatureFlagId"; + public const string FeatureFlagReference = "FeatureFlagReference"; + + // Dotnet schema keys + public const string DotnetSchemaSectionName = "FeatureManagement"; + public const string DotnetSchemaEnabledFor = "EnabledFor"; + public const string DotnetSchemaRequirementType = "RequirementType"; } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs index 058f353c..2b0e0833 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs @@ -2,9 +2,12 @@ // Licensed under the MIT license. // using Azure.Data.AppConfiguration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; using System; using System.Collections.Generic; using System.Linq; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -13,78 +16,313 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManage { internal class FeatureManagementKeyValueAdapter : IKeyValueAdapter { - private FeatureFilterTracing _featureFilterTracing; + private FeatureFlagTracing _featureFlagTracing; + private int _featureFlagIndex = 0; - public FeatureManagementKeyValueAdapter(FeatureFilterTracing featureFilterTracing) + public FeatureManagementKeyValueAdapter(FeatureFlagTracing featureFlagTracing) { - _featureFilterTracing = featureFilterTracing ?? throw new ArgumentNullException(nameof(featureFilterTracing)); + _featureFlagTracing = featureFlagTracing ?? throw new ArgumentNullException(nameof(featureFlagTracing)); } - public Task>> ProcessKeyValue(ConfigurationSetting setting, Logger logger, CancellationToken cancellationToken) + public Task>> ProcessKeyValue(ConfigurationSetting setting, Uri endpoint, Logger logger, CancellationToken cancellationToken) { FeatureFlag featureFlag = ParseFeatureFlag(setting.Key, setting.Value); var keyValues = new List>(); - if (!string.IsNullOrEmpty(featureFlag.Id)) + // Check if we need to process the feature flag using the microsoft schema + if ((featureFlag.Variants != null && featureFlag.Variants.Any()) || featureFlag.Allocation != null || featureFlag.Telemetry != null) { - if (featureFlag.Enabled) + keyValues = ProcessMicrosoftSchemaFeatureFlag(featureFlag, setting, endpoint); + } + else + { + keyValues = ProcessDotnetSchemaFeatureFlag(featureFlag, setting, endpoint); + } + + return Task.FromResult>>(keyValues); + } + + public bool CanProcess(ConfigurationSetting setting) + { + string contentType = setting?.ContentType?.Split(';')[0].Trim(); + + return string.Equals(contentType, FeatureManagementConstants.ContentType) || + setting.Key.StartsWith(FeatureManagementConstants.FeatureFlagMarker); + } + + public bool NeedsRefresh() + { + return false; + } + + public void OnChangeDetected(ConfigurationSetting setting = null) + { + return; + } + + public void OnConfigUpdated() + { + _featureFlagIndex = 0; + + return; + } + + private List> ProcessDotnetSchemaFeatureFlag(FeatureFlag featureFlag, ConfigurationSetting setting, Uri endpoint) + { + var keyValues = new List>(); + + if (string.IsNullOrEmpty(featureFlag.Id)) + { + return keyValues; + } + + string featureFlagPath = $"{FeatureManagementConstants.DotnetSchemaSectionName}:{featureFlag.Id}"; + + if (featureFlag.Enabled) + { + if (featureFlag.Conditions?.ClientFilters == null || !featureFlag.Conditions.ClientFilters.Any()) + { + keyValues.Add(new KeyValuePair(featureFlagPath, true.ToString())); + } + else { - if (featureFlag.Conditions?.ClientFilters == null || !featureFlag.Conditions.ClientFilters.Any()) + for (int i = 0; i < featureFlag.Conditions.ClientFilters.Count; i++) + { + ClientFilter clientFilter = featureFlag.Conditions.ClientFilters[i]; + + _featureFlagTracing.UpdateFeatureFilterTracing(clientFilter.Name); + + string clientFiltersPath = $"{featureFlagPath}:{FeatureManagementConstants.DotnetSchemaEnabledFor}:{i}"; + + keyValues.Add(new KeyValuePair($"{clientFiltersPath}:Name", clientFilter.Name)); + + foreach (KeyValuePair kvp in new JsonFlattener().FlattenJson(clientFilter.Parameters)) + { + keyValues.Add(new KeyValuePair($"{clientFiltersPath}:Parameters:{kvp.Key}", kvp.Value)); + } + } + + // + // process RequirementType only when filters are not empty + if (featureFlag.Conditions.RequirementType != null) { - keyValues.Add(new KeyValuePair($"{FeatureManagementConstants.SectionName}:{featureFlag.Id}", true.ToString())); + keyValues.Add(new KeyValuePair( + $"{featureFlagPath}:{FeatureManagementConstants.DotnetSchemaRequirementType}", + featureFlag.Conditions.RequirementType)); } - else + } + } + else + { + keyValues.Add(new KeyValuePair($"{featureFlagPath}", false.ToString())); + } + + return keyValues; + } + + private List> ProcessMicrosoftSchemaFeatureFlag(FeatureFlag featureFlag, ConfigurationSetting setting, Uri endpoint) + { + var keyValues = new List>(); + + if (string.IsNullOrEmpty(featureFlag.Id)) + { + return keyValues; + } + + string featureFlagPath = $"{FeatureManagementConstants.FeatureManagementSectionName}:{FeatureManagementConstants.FeatureFlagsSectionName}:{_featureFlagIndex}"; + + _featureFlagIndex++; + + keyValues.Add(new KeyValuePair($"{featureFlagPath}:{FeatureManagementConstants.Id}", featureFlag.Id)); + + keyValues.Add(new KeyValuePair($"{featureFlagPath}:{FeatureManagementConstants.Enabled}", featureFlag.Enabled.ToString())); + + if (featureFlag.Enabled) + { + if (featureFlag.Conditions?.ClientFilters != null && featureFlag.Conditions.ClientFilters.Any()) // workaround since we are not yet setting client filters to null + { + // + // Conditionally based on feature filters + for (int i = 0; i < featureFlag.Conditions.ClientFilters.Count; i++) { - for (int i = 0; i < featureFlag.Conditions.ClientFilters.Count; i++) + ClientFilter clientFilter = featureFlag.Conditions.ClientFilters[i]; + + _featureFlagTracing.UpdateFeatureFilterTracing(clientFilter.Name); + + string clientFiltersPath = $"{featureFlagPath}:{FeatureManagementConstants.Conditions}:{FeatureManagementConstants.ClientFilters}:{i}"; + + keyValues.Add(new KeyValuePair($"{clientFiltersPath}:{FeatureManagementConstants.Name}", clientFilter.Name)); + + foreach (KeyValuePair kvp in new JsonFlattener().FlattenJson(clientFilter.Parameters)) { - ClientFilter clientFilter = featureFlag.Conditions.ClientFilters[i]; + keyValues.Add(new KeyValuePair($"{clientFiltersPath}:{FeatureManagementConstants.Parameters}:{kvp.Key}", kvp.Value)); + } + } - _featureFilterTracing.UpdateFeatureFilterTracing(clientFilter.Name); + // + // process RequirementType only when filters are not empty + if (featureFlag.Conditions.RequirementType != null) + { + keyValues.Add(new KeyValuePair( + $"{featureFlagPath}:{FeatureManagementConstants.Conditions}:{FeatureManagementConstants.RequirementType}", + featureFlag.Conditions.RequirementType)); + } + } + } - keyValues.Add(new KeyValuePair($"{FeatureManagementConstants.SectionName}:{featureFlag.Id}:{FeatureManagementConstants.EnabledFor}:{i}:Name", clientFilter.Name)); + if (featureFlag.Variants != null) + { + int i = 0; - foreach (KeyValuePair kvp in new JsonFlattener().FlattenJson(clientFilter.Parameters)) - { - keyValues.Add(new KeyValuePair($"{FeatureManagementConstants.SectionName}:{featureFlag.Id}:{FeatureManagementConstants.EnabledFor}:{i}:Parameters:{kvp.Key}", kvp.Value)); - } + foreach (FeatureVariant featureVariant in featureFlag.Variants) + { + string variantsPath = $"{featureFlagPath}:{FeatureManagementConstants.Variants}:{i}"; + + keyValues.Add(new KeyValuePair($"{variantsPath}:{FeatureManagementConstants.Name}", featureVariant.Name)); + + foreach (KeyValuePair kvp in new JsonFlattener().FlattenJson(featureVariant.ConfigurationValue)) + { + keyValues.Add(new KeyValuePair($"{variantsPath}:{FeatureManagementConstants.ConfigurationValue}" + + (string.IsNullOrEmpty(kvp.Key) ? "" : $":{kvp.Key}"), kvp.Value)); + } + + if (featureVariant.ConfigurationReference != null) + { + _featureFlagTracing.UsesVariantConfigurationReference = true; + + keyValues.Add(new KeyValuePair($"{variantsPath}:{FeatureManagementConstants.ConfigurationReference}", featureVariant.ConfigurationReference)); + } + + if (featureVariant.StatusOverride != null) + { + keyValues.Add(new KeyValuePair($"{variantsPath}:{FeatureManagementConstants.StatusOverride}", featureVariant.StatusOverride)); + } + + i++; + } + + _featureFlagTracing.NotifyMaxVariants(i); + } + + if (featureFlag.Allocation != null) + { + FeatureAllocation allocation = featureFlag.Allocation; + + string allocationPath = $"{featureFlagPath}:{FeatureManagementConstants.Allocation}"; + + if (allocation.DefaultWhenDisabled != null) + { + keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.DefaultWhenDisabled}", allocation.DefaultWhenDisabled)); + } + + if (allocation.DefaultWhenEnabled != null) + { + keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.DefaultWhenEnabled}", allocation.DefaultWhenEnabled)); + } + + if (allocation.User != null) + { + int i = 0; + + foreach (FeatureUserAllocation userAllocation in allocation.User) + { + keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.UserAllocation}:{i}:{FeatureManagementConstants.Variant}", userAllocation.Variant)); + + int j = 0; + + foreach (string user in userAllocation.Users) + { + keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.UserAllocation}:{i}:{FeatureManagementConstants.Users}:{j}", user)); + + j++; } - // - // process RequirementType only when filters are not empty - if (featureFlag.Conditions.RequirementType != null) + i++; + } + } + + if (allocation.Group != null) + { + int i = 0; + + foreach (FeatureGroupAllocation groupAllocation in allocation.Group) + { + keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.GroupAllocation}:{i}:{FeatureManagementConstants.Variant}", groupAllocation.Variant)); + + int j = 0; + + foreach (string group in groupAllocation.Groups) { - keyValues.Add(new KeyValuePair( - $"{FeatureManagementConstants.SectionName}:{featureFlag.Id}:{FeatureManagementConstants.RequirementType}", - featureFlag.Conditions.RequirementType)); + keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.GroupAllocation}:{i}:{FeatureManagementConstants.Groups}:{j}", group)); + + j++; } + + i++; } } - else + + if (allocation.Percentile != null) + { + int i = 0; + + foreach (FeaturePercentileAllocation percentileAllocation in allocation.Percentile) + { + keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.PercentileAllocation}:{i}:{FeatureManagementConstants.Variant}", percentileAllocation.Variant)); + + keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.PercentileAllocation}:{i}:{FeatureManagementConstants.From}", percentileAllocation.From.ToString())); + + keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.PercentileAllocation}:{i}:{FeatureManagementConstants.To}", percentileAllocation.To.ToString())); + + i++; + } + } + + if (allocation.Seed != null) { - keyValues.Add(new KeyValuePair($"{FeatureManagementConstants.SectionName}:{featureFlag.Id}", false.ToString())); + _featureFlagTracing.UsesSeed = true; + + keyValues.Add(new KeyValuePair($"{allocationPath}:{FeatureManagementConstants.Seed}", allocation.Seed)); } } - return Task.FromResult>>(keyValues); - } + if (featureFlag.Telemetry != null) + { + FeatureTelemetry telemetry = featureFlag.Telemetry; - public bool CanProcess(ConfigurationSetting setting) - { - string contentType = setting?.ContentType?.Split(';')[0].Trim(); + string telemetryPath = $"{featureFlagPath}:{FeatureManagementConstants.Telemetry}"; - return string.Equals(contentType, FeatureManagementConstants.ContentType) || - setting.Key.StartsWith(FeatureManagementConstants.FeatureFlagMarker); - } + if (telemetry.Enabled) + { + _featureFlagTracing.UsesTelemetry = true; - public void InvalidateCache(ConfigurationSetting setting = null) - { - return; - } + if (telemetry.Metadata != null) + { + foreach (KeyValuePair kvp in telemetry.Metadata) + { + keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Metadata}:{kvp.Key}", kvp.Value)); + } + } - public bool NeedsRefresh() - { - return false; + string featureFlagId = CalculateFeatureFlagId(setting.Key, setting.Label); + + keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Metadata}:{FeatureManagementConstants.FeatureFlagId}", featureFlagId)); + + if (endpoint != null) + { + string featureFlagReference = $"{endpoint.AbsoluteUri}kv/{setting.Key}{(!string.IsNullOrWhiteSpace(setting.Label) ? $"?label={setting.Label}" : "")}"; + + keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Metadata}:{FeatureManagementConstants.FeatureFlagReference}", featureFlagReference)); + } + + keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Metadata}:{FeatureManagementConstants.ETag}", setting.ETag.ToString())); + + keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Enabled}", telemetry.Enabled.ToString())); + } + } + + return keyValues; } private FormatException CreateFeatureFlagFormatException(string jsonPropertyName, string settingKey, string foundJsonValueKind, string expectedJsonValueKind) @@ -99,7 +337,7 @@ private FormatException CreateFeatureFlagFormatException(string jsonPropertyName private FeatureFlag ParseFeatureFlag(string settingKey, string settingValue) { - FeatureFlag featureFlag = new FeatureFlag(); + var featureFlag = new FeatureFlag(); var reader = new Utf8JsonReader(System.Text.Encoding.UTF8.GetBytes(settingValue)); @@ -121,7 +359,7 @@ private FeatureFlag ParseFeatureFlag(string settingKey, string settingValue) switch (propertyName) { - case FeatureManagementConstants.IdJsonPropertyName: + case FeatureManagementConstants.Id: { if (reader.Read() && reader.TokenType == JsonTokenType.String) { @@ -130,7 +368,7 @@ private FeatureFlag ParseFeatureFlag(string settingKey, string settingValue) else if (reader.TokenType != JsonTokenType.Null) { throw CreateFeatureFlagFormatException( - FeatureManagementConstants.IdJsonPropertyName, + FeatureManagementConstants.Id, settingKey, reader.TokenType.ToString(), JsonTokenType.String.ToString()); @@ -139,7 +377,7 @@ private FeatureFlag ParseFeatureFlag(string settingKey, string settingValue) break; } - case FeatureManagementConstants.EnabledJsonPropertyName: + case FeatureManagementConstants.Enabled: { if (reader.Read() && (reader.TokenType == JsonTokenType.False || reader.TokenType == JsonTokenType.True)) { @@ -152,7 +390,7 @@ private FeatureFlag ParseFeatureFlag(string settingKey, string settingValue) else { throw CreateFeatureFlagFormatException( - FeatureManagementConstants.EnabledJsonPropertyName, + FeatureManagementConstants.Enabled, settingKey, reader.TokenType.ToString(), $"{JsonTokenType.True}' or '{JsonTokenType.False}"); @@ -161,7 +399,7 @@ private FeatureFlag ParseFeatureFlag(string settingKey, string settingValue) break; } - case FeatureManagementConstants.ConditionsJsonPropertyName: + case FeatureManagementConstants.Conditions: { if (reader.Read() && reader.TokenType == JsonTokenType.StartObject) { @@ -170,7 +408,88 @@ private FeatureFlag ParseFeatureFlag(string settingKey, string settingValue) else if (reader.TokenType != JsonTokenType.Null) { throw CreateFeatureFlagFormatException( - FeatureManagementConstants.ConditionsJsonPropertyName, + FeatureManagementConstants.Conditions, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.StartObject.ToString()); + } + + break; + } + + case FeatureManagementConstants.Allocation: + { + if (reader.Read() && reader.TokenType == JsonTokenType.StartObject) + { + featureFlag.Allocation = ParseFeatureAllocation(ref reader, settingKey); + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.Allocation, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.StartObject.ToString()); + } + + break; + } + + case FeatureManagementConstants.Variants: + { + if (reader.Read() && reader.TokenType == JsonTokenType.StartArray) + { + List variants = new List(); + + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + int i = 0; + + if (reader.TokenType == JsonTokenType.StartObject) + { + FeatureVariant featureVariant = ParseFeatureVariant(ref reader, settingKey); + + if (featureVariant.Name != null) + { + variants.Add(featureVariant); + } + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + $"{FeatureManagementConstants.Variants}[{i}]", + settingKey, + reader.TokenType.ToString(), + JsonTokenType.StartObject.ToString()); + } + + i++; + } + + featureFlag.Variants = variants; + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.Variants, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.StartArray.ToString()); + } + + break; + } + + case FeatureManagementConstants.Telemetry: + { + if (reader.Read() && reader.TokenType == JsonTokenType.StartObject) + { + featureFlag.Telemetry = ParseFeatureTelemetry(ref reader, settingKey); + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.Telemetry, settingKey, reader.TokenType.ToString(), JsonTokenType.StartObject.ToString()); @@ -188,7 +507,7 @@ private FeatureFlag ParseFeatureFlag(string settingKey, string settingValue) } catch (JsonException e) { - throw new FormatException(string.Format(ErrorMessages.FeatureFlagInvalidFormat, settingKey), e); + throw new FormatException(settingKey, e); } return featureFlag; @@ -209,14 +528,12 @@ private FeatureConditions ParseFeatureConditions(ref Utf8JsonReader reader, stri switch (conditionsPropertyName) { - case FeatureManagementConstants.ClientFiltersJsonPropertyName: + case FeatureManagementConstants.ClientFilters: { - if (reader.Read() && reader.TokenType == JsonTokenType.Null) - { - break; - } - else if (reader.TokenType == JsonTokenType.StartArray) + if (reader.Read() && reader.TokenType == JsonTokenType.StartArray) { + int i = 0; + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) { if (reader.TokenType == JsonTokenType.StartObject) @@ -230,12 +547,22 @@ private FeatureConditions ParseFeatureConditions(ref Utf8JsonReader reader, stri featureConditions.ClientFilters.Add(clientFilter); } } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + $"{FeatureManagementConstants.ClientFilters}[{i}]", + settingKey, + reader.TokenType.ToString(), + JsonTokenType.StartObject.ToString()); + } + + i++; } } - else + else if (reader.TokenType != JsonTokenType.Null) { throw CreateFeatureFlagFormatException( - FeatureManagementConstants.ClientFiltersJsonPropertyName, + FeatureManagementConstants.ClientFilters, settingKey, reader.TokenType.ToString(), JsonTokenType.StartArray.ToString()); @@ -244,7 +571,7 @@ private FeatureConditions ParseFeatureConditions(ref Utf8JsonReader reader, stri break; } - case FeatureManagementConstants.RequirementTypeJsonPropertyName: + case FeatureManagementConstants.RequirementType: { if (reader.Read() && reader.TokenType == JsonTokenType.String) { @@ -253,7 +580,7 @@ private FeatureConditions ParseFeatureConditions(ref Utf8JsonReader reader, stri else if (reader.TokenType != JsonTokenType.Null) { throw CreateFeatureFlagFormatException( - FeatureManagementConstants.RequirementTypeJsonPropertyName, + FeatureManagementConstants.RequirementType, settingKey, reader.TokenType.ToString(), JsonTokenType.String.ToString()); @@ -287,7 +614,7 @@ private ClientFilter ParseClientFilter(ref Utf8JsonReader reader, string setting switch (clientFiltersPropertyName) { - case FeatureManagementConstants.NameJsonPropertyName: + case FeatureManagementConstants.Name: { if (reader.Read() && reader.TokenType == JsonTokenType.String) { @@ -296,7 +623,7 @@ private ClientFilter ParseClientFilter(ref Utf8JsonReader reader, string setting else if (reader.TokenType != JsonTokenType.Null) { throw CreateFeatureFlagFormatException( - FeatureManagementConstants.NameJsonPropertyName, + FeatureManagementConstants.Name, settingKey, reader.TokenType.ToString(), JsonTokenType.String.ToString()); @@ -305,7 +632,7 @@ private ClientFilter ParseClientFilter(ref Utf8JsonReader reader, string setting break; } - case FeatureManagementConstants.ParametersJsonPropertyName: + case FeatureManagementConstants.Parameters: { if (reader.Read() && reader.TokenType == JsonTokenType.StartObject) { @@ -314,7 +641,7 @@ private ClientFilter ParseClientFilter(ref Utf8JsonReader reader, string setting else if (reader.TokenType != JsonTokenType.Null) { throw CreateFeatureFlagFormatException( - FeatureManagementConstants.ParametersJsonPropertyName, + FeatureManagementConstants.Parameters, settingKey, reader.TokenType.ToString(), JsonTokenType.StartObject.ToString()); @@ -332,5 +659,660 @@ private ClientFilter ParseClientFilter(ref Utf8JsonReader reader, string setting return clientFilter; } + + private FeatureAllocation ParseFeatureAllocation(ref Utf8JsonReader reader, string settingKey) + { + var featureAllocation = new FeatureAllocation(); + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) + { + continue; + } + + string allocationPropertyName = reader.GetString(); + + switch (allocationPropertyName) + { + case FeatureManagementConstants.DefaultWhenDisabled: + { + if (reader.Read() && reader.TokenType == JsonTokenType.String) + { + featureAllocation.DefaultWhenDisabled = reader.GetString(); + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.DefaultWhenDisabled, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.String.ToString()); + } + + break; + } + + case FeatureManagementConstants.DefaultWhenEnabled: + { + if (reader.Read() && reader.TokenType == JsonTokenType.String) + { + featureAllocation.DefaultWhenEnabled = reader.GetString(); + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.DefaultWhenEnabled, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.String.ToString()); + } + + break; + } + + case FeatureManagementConstants.UserAllocation: + { + if (reader.Read() && reader.TokenType == JsonTokenType.StartArray) + { + List userAllocations = new List(); + + int i = 0; + + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + if (reader.TokenType == JsonTokenType.StartObject) + { + FeatureUserAllocation featureUserAllocation = ParseFeatureUserAllocation(ref reader, settingKey); + + if (featureUserAllocation.Variant != null || + (featureUserAllocation.Users != null && + featureUserAllocation.Users.Any())) + { + userAllocations.Add(featureUserAllocation); + } + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + $"{FeatureManagementConstants.UserAllocation}[{i}]", + settingKey, + reader.TokenType.ToString(), + JsonTokenType.StartObject.ToString()); + } + + i++; + } + + featureAllocation.User = userAllocations; + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.UserAllocation, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.StartArray.ToString()); + } + + break; + } + + case FeatureManagementConstants.GroupAllocation: + { + if (reader.Read() && reader.TokenType == JsonTokenType.StartArray) + { + List groupAllocations = new List(); + + int i = 0; + + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + if (reader.TokenType == JsonTokenType.StartObject) + { + FeatureGroupAllocation featureGroupAllocation = ParseFeatureGroupAllocation(ref reader, settingKey); + + if (featureGroupAllocation.Variant != null || + (featureGroupAllocation.Groups != null && + featureGroupAllocation.Groups.Any())) + { + groupAllocations.Add(featureGroupAllocation); + } + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + $"{FeatureManagementConstants.GroupAllocation}[{i}]", + settingKey, + reader.TokenType.ToString(), + JsonTokenType.StartObject.ToString()); + } + + i++; + } + + featureAllocation.Group = groupAllocations; + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.GroupAllocation, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.StartArray.ToString()); + } + + break; + } + + case FeatureManagementConstants.PercentileAllocation: + { + if (reader.Read() && reader.TokenType == JsonTokenType.StartArray) + { + List percentileAllocations = new List(); + + int i = 0; + + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + if (reader.TokenType == JsonTokenType.StartObject) + { + FeaturePercentileAllocation featurePercentileAllocation = ParseFeaturePercentileAllocation(ref reader, settingKey); + + percentileAllocations.Add(featurePercentileAllocation); + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + $"{FeatureManagementConstants.PercentileAllocation}[{i}]", + settingKey, + reader.TokenType.ToString(), + JsonTokenType.StartObject.ToString()); + } + + i++; + } + + featureAllocation.Percentile = percentileAllocations; + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.PercentileAllocation, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.StartArray.ToString()); + } + + break; + } + + case FeatureManagementConstants.Seed: + { + if (reader.Read() && reader.TokenType == JsonTokenType.String) + { + featureAllocation.Seed = reader.GetString(); + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.Seed, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.String.ToString()); + } + + break; + } + + default: + reader.Skip(); + + break; + } + } + + return featureAllocation; + } + + private FeatureUserAllocation ParseFeatureUserAllocation(ref Utf8JsonReader reader, string settingKey) + { + var featureUserAllocation = new FeatureUserAllocation(); + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) + { + continue; + } + + string userAllocationPropertyName = reader.GetString(); + + switch (userAllocationPropertyName) + { + case FeatureManagementConstants.Variant: + { + if (reader.Read() && reader.TokenType == JsonTokenType.String) + { + featureUserAllocation.Variant = reader.GetString(); + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.Variant, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.String.ToString()); + } + + break; + } + + case FeatureManagementConstants.Users: + { + if (reader.Read() && reader.TokenType == JsonTokenType.StartArray) + { + List users = new List(); + + int i = 0; + + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + if (reader.TokenType == JsonTokenType.String) + { + users.Add(reader.GetString()); + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + $"{FeatureManagementConstants.Users}[{i}]", + settingKey, + reader.TokenType.ToString(), + JsonTokenType.String.ToString()); + } + + i++; + } + + featureUserAllocation.Users = users; + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.Users, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.StartArray.ToString()); + } + + break; + } + + default: + reader.Skip(); + + break; + } + } + + return featureUserAllocation; + } + + private FeatureGroupAllocation ParseFeatureGroupAllocation(ref Utf8JsonReader reader, string settingKey) + { + var featureGroupAllocation = new FeatureGroupAllocation(); + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) + { + continue; + } + + string groupAllocationPropertyName = reader.GetString(); + + switch (groupAllocationPropertyName) + { + case FeatureManagementConstants.Variant: + { + if (reader.Read() && reader.TokenType == JsonTokenType.String) + { + featureGroupAllocation.Variant = reader.GetString(); + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.Variant, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.String.ToString()); + } + + break; + } + + case FeatureManagementConstants.Groups: + { + if (reader.Read() && reader.TokenType == JsonTokenType.StartArray) + { + List groups = new List(); + + int i = 0; + + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + if (reader.TokenType == JsonTokenType.String) + { + groups.Add(reader.GetString()); + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + $"{FeatureManagementConstants.Groups}[{i}]", + settingKey, + reader.TokenType.ToString(), + JsonTokenType.String.ToString()); + } + + i++; + } + + featureGroupAllocation.Groups = groups; + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.Groups, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.StartArray.ToString()); + } + + break; + } + + default: + reader.Skip(); + + break; + } + } + + return featureGroupAllocation; + } + + private FeaturePercentileAllocation ParseFeaturePercentileAllocation(ref Utf8JsonReader reader, string settingKey) + { + var featurePercentileAllocation = new FeaturePercentileAllocation(); + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) + { + continue; + } + + string percentileAllocationPropertyName = reader.GetString(); + + switch (percentileAllocationPropertyName) + { + case FeatureManagementConstants.Variant: + { + if (reader.Read() && reader.TokenType == JsonTokenType.String) + { + featurePercentileAllocation.Variant = reader.GetString(); + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.Variant, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.String.ToString()); + } + + break; + } + + case FeatureManagementConstants.From: + { + if (reader.Read() && + ((reader.TokenType == JsonTokenType.Number && reader.TryGetInt32(out int from)) || + (reader.TokenType == JsonTokenType.String && int.TryParse(reader.GetString(), out from)))) + { + featurePercentileAllocation.From = from; + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.From, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.Number.ToString()); + } + + break; + } + + case FeatureManagementConstants.To: + { + if (reader.Read() && + ((reader.TokenType == JsonTokenType.Number && reader.TryGetInt32(out int to)) || + (reader.TokenType == JsonTokenType.String && int.TryParse(reader.GetString(), out to)))) + { + featurePercentileAllocation.To = to; + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.To, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.Number.ToString()); + } + + break; + } + + default: + reader.Skip(); + + break; + } + } + + return featurePercentileAllocation; + } + + private FeatureVariant ParseFeatureVariant(ref Utf8JsonReader reader, string settingKey) + { + var featureVariant = new FeatureVariant(); + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) + { + continue; + } + + string variantPropertyName = reader.GetString(); + + switch (variantPropertyName) + { + case FeatureManagementConstants.Name: + { + if (reader.Read() && reader.TokenType == JsonTokenType.String) + { + featureVariant.Name = reader.GetString(); + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.Name, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.String.ToString()); + } + + break; + } + + case FeatureManagementConstants.ConfigurationReference: + { + if (reader.Read() && reader.TokenType == JsonTokenType.String) + { + featureVariant.ConfigurationReference = reader.GetString(); + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.ConfigurationReference, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.String.ToString()); + } + + break; + } + + case FeatureManagementConstants.ConfigurationValue: + { + if (reader.Read()) + { + featureVariant.ConfigurationValue = JsonDocument.ParseValue(ref reader).RootElement; + } + + break; + } + + case FeatureManagementConstants.StatusOverride: + { + if (reader.Read() && reader.TokenType == JsonTokenType.String) + { + featureVariant.StatusOverride = reader.GetString(); + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.StatusOverride, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.String.ToString()); + } + + break; + } + + default: + reader.Skip(); + + break; + } + } + + return featureVariant; + } + + private FeatureTelemetry ParseFeatureTelemetry(ref Utf8JsonReader reader, string settingKey) + { + var featureTelemetry = new FeatureTelemetry(); + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) + { + continue; + } + + string telemetryPropertyName = reader.GetString(); + + switch (telemetryPropertyName) + { + case FeatureManagementConstants.Enabled: + { + if (reader.Read() && (reader.TokenType == JsonTokenType.False || reader.TokenType == JsonTokenType.True)) + { + featureTelemetry.Enabled = reader.GetBoolean(); + } + else if (reader.TokenType == JsonTokenType.String && bool.TryParse(reader.GetString(), out bool enabled)) + { + featureTelemetry.Enabled = enabled; + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.Enabled, + settingKey, + reader.TokenType.ToString(), + $"{JsonTokenType.True}' or '{JsonTokenType.False}"); + } + + break; + } + + case FeatureManagementConstants.Metadata: + { + if (reader.Read() && reader.TokenType == JsonTokenType.StartObject) + { + featureTelemetry.Metadata = new Dictionary(); + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) + { + continue; + } + + string metadataPropertyName = reader.GetString(); + + if (reader.Read() && reader.TokenType == JsonTokenType.String) + { + featureTelemetry.Metadata[metadataPropertyName] = reader.GetString(); + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + metadataPropertyName, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.String.ToString()); + } + } + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw CreateFeatureFlagFormatException( + FeatureManagementConstants.Metadata, + settingKey, + reader.TokenType.ToString(), + JsonTokenType.StartObject.ToString()); + } + + break; + } + + default: + reader.Skip(); + + break; + } + } + + return featureTelemetry; + } + + private static string CalculateFeatureFlagId(string key, string label) + { + byte[] featureFlagIdHash; + + // Convert the value consisting of key, newline character, and label to a byte array using UTF8 encoding to hash it using SHA 256 + using (HashAlgorithm hashAlgorithm = SHA256.Create()) + { + featureFlagIdHash = hashAlgorithm.ComputeHash(Encoding.UTF8.GetBytes($"{key}\n{(string.IsNullOrWhiteSpace(label) ? null : label)}")); + } + + // Convert the hashed byte array to Base64Url + return featureFlagIdHash.ToBase64Url(); + } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeaturePercentileAllocation.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeaturePercentileAllocation.cs new file mode 100644 index 00000000..1d107dba --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeaturePercentileAllocation.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement +{ + internal class FeaturePercentileAllocation + { + public string Variant { get; set; } + + public double From { get; set; } + + public double To { get; set; } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureTelemetry.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureTelemetry.cs new file mode 100644 index 00000000..09d8dd71 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureTelemetry.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// + +using System.Collections.Generic; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement +{ + internal class FeatureTelemetry + { + public bool Enabled { get; set; } + + public IDictionary Metadata { get; set; } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureUserAllocation.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureUserAllocation.cs new file mode 100644 index 00000000..fc403d6e --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureUserAllocation.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System.Collections.Generic; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement +{ + internal class FeatureUserAllocation + { + public string Variant { get; set; } + + public IEnumerable Users { get; set; } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureVariant.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureVariant.cs new file mode 100644 index 00000000..87c5c0b1 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureVariant.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System.Text.Json; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement +{ + internal class FeatureVariant + { + public string Name { get; set; } + + public JsonElement ConfigurationValue { get; set; } + + public string ConfigurationReference { get; set; } + + public string StatusOverride { get; set; } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationRefresher.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationRefresher.cs index b47184a2..78df7125 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationRefresher.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationRefresher.cs @@ -2,7 +2,6 @@ // Licensed under the MIT license. // using Azure; -using Microsoft.Extensions.Logging; using System; using System.Threading; using System.Threading.Tasks; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IKeyValueAdapter.cs index 48fc85c5..de13314e 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IKeyValueAdapter.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. // using Azure.Data.AppConfiguration; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -10,11 +11,13 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration { internal interface IKeyValueAdapter { - Task>> ProcessKeyValue(ConfigurationSetting setting, Logger logger, CancellationToken cancellationToken); + Task>> ProcessKeyValue(ConfigurationSetting setting, Uri endpoint, Logger logger, CancellationToken cancellationToken); bool CanProcess(ConfigurationSetting setting); - void InvalidateCache(ConfigurationSetting setting = null); + void OnChangeDetected(ConfigurationSetting setting = null); + + void OnConfigUpdated(); bool NeedsRefresh(); } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs index 979811d7..b4448e32 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs @@ -22,7 +22,7 @@ internal class JsonKeyValueAdapter : IKeyValueAdapter KeyVaultConstants.ContentType }; - public Task>> ProcessKeyValue(ConfigurationSetting setting, Logger logger, CancellationToken cancellationToken) + public Task>> ProcessKeyValue(ConfigurationSetting setting, Uri endpoint, Logger logger, CancellationToken cancellationToken) { if (setting == null) { @@ -95,7 +95,12 @@ public bool CanProcess(ConfigurationSetting setting) return false; } - public void InvalidateCache(ConfigurationSetting setting = null) + public void OnChangeDetected(ConfigurationSetting setting = null) + { + return; + } + + public void OnConfigUpdated() { return; } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/LogHelper.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/LogHelper.cs index e7429a9e..11442dcb 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/LogHelper.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/LogHelper.cs @@ -88,8 +88,7 @@ public static string BuildLastEndpointFailedMessage(string endpoint) public static string BuildFallbackClientLookupFailMessage(string exceptionMessage) { return $"{LoggingConstants.FallbackClientLookupError}\n{exceptionMessage}"; - } - + } public static string BuildRefreshFailedDueToFormattingErrorMessage(string exceptionMessage) { return $"{LoggingConstants.RefreshFailedDueToFormattingError}\n{exceptionMessage}"; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueWatcher.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueWatcher.cs index 121a35be..616f8bcd 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueWatcher.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueWatcher.cs @@ -26,12 +26,12 @@ internal class KeyValueWatcher /// /// The minimum time that must elapse before the key-value is refreshed. /// - public TimeSpan CacheExpirationInterval { get; set; } + public TimeSpan RefreshInterval { get; set; } /// - /// The cache expiration time for the key-value. + /// The next time when this key-value can be refreshed. /// - public DateTimeOffset CacheExpires { get; set; } + public DateTimeOffset NextRefreshTime { get; set; } public override bool Equals(object obj) { diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs index b5769b9d..7b06535b 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. // using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; +using System.Text; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration { @@ -33,9 +34,9 @@ internal class RequestTracingOptions public int ReplicaCount { get; set; } = 0; /// - /// Type of feature filters used by the application. + /// Information about feature flags in the application, like filter and variant usage. /// - public FeatureFilterTracing FilterTracing { get; set; } = new FeatureFilterTracing(); + public FeatureFlagTracing FeatureFlagTracing { get; set; } = new FeatureFlagTracing(); /// /// Version of the Microsoft.FeatureManagement assembly, if present in the application. @@ -51,5 +52,55 @@ internal class RequestTracingOptions /// Flag to indicate whether Microsoft.AspNetCore.SignalR assembly is present in the application. /// public bool IsSignalRUsed { get; set; } = false; + + /// + /// Flag to indicate whether load balancing is enabled. + /// + public bool IsLoadBalancingEnabled { get; set; } = false; + + /// + /// Flag to indicate whether the request is triggered by a failover. + /// + public bool IsFailoverRequest { get; set; } = false; + + /// + /// Checks whether any tracing feature is used. + /// + /// True if any tracing feature is used, otherwise false. + public bool UsesAnyTracingFeature() + { + return IsLoadBalancingEnabled || IsSignalRUsed; + } + + /// + /// Returns a formatted string containing code names, indicating which tracing features are used by the application. + /// + /// Formatted string like: "LB+SignalR". If no tracing features are used, empty string will be returned. + public string CreateFeaturesString() + { + if (!UsesAnyTracingFeature()) + { + return string.Empty; + } + + var sb = new StringBuilder(); + + if (IsLoadBalancingEnabled) + { + sb.Append(RequestTracingConstants.LoadBalancingEnabledTag); + } + + if (IsSignalRUsed) + { + if (sb.Length > 0) + { + sb.Append(RequestTracingConstants.Delimiter); + } + + sb.Append(RequestTracingConstants.SignalRUsedTag); + } + + return sb.ToString(); + } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TracingUtils.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TracingUtils.cs index 2c8d9869..b1b2b196 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TracingUtils.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TracingUtils.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Reflection; using System.Security; using System.Text; using System.Threading.Tasks; @@ -79,8 +80,28 @@ public static string GetAssemblyVersion(string assemblyName) { if (!string.IsNullOrEmpty(assemblyName)) { - // Return the version using only the first 3 fields and remove additional characters - return AppDomain.CurrentDomain.GetAssemblies().SingleOrDefault(assembly => assembly.GetName().Name == assemblyName)?.GetName().Version?.ToString(3).Trim('{', '}'); + Assembly infoVersionAttribute = AppDomain.CurrentDomain.GetAssemblies().SingleOrDefault(assembly => assembly.GetName().Name == assemblyName); + + if (infoVersionAttribute != null) + { + string informationalVersion = infoVersionAttribute.GetCustomAttribute()?.InformationalVersion; + + if (string.IsNullOrEmpty(informationalVersion)) + { + return null; + } + + // Commit information is appended to the informational version starting with a '+', so we remove + // the commit information to get just the full name of the version. + int plusIndex = informationalVersion.IndexOf('+'); + + if (plusIndex != -1) + { + informationalVersion = informationalVersion.Substring(0, plusIndex); + } + + return informationalVersion; + } } return null; @@ -135,9 +156,19 @@ private static string CreateCorrelationContextHeader(RequestType requestType, Re correlationContextKeyValues.Add(new KeyValuePair(RequestTracingConstants.EnvironmentKey, RequestTracingConstants.DevEnvironmentValue)); } - if (requestTracingOptions.FilterTracing.UsesAnyFeatureFilter()) + if (requestTracingOptions.FeatureFlagTracing.UsesAnyFeatureFilter()) + { + correlationContextKeyValues.Add(new KeyValuePair(RequestTracingConstants.FeatureFlagFilterTypeKey, requestTracingOptions.FeatureFlagTracing.CreateFiltersString())); + } + + if (requestTracingOptions.FeatureFlagTracing.MaxVariants > 0) + { + correlationContextKeyValues.Add(new KeyValuePair(RequestTracingConstants.FeatureFlagMaxVariantsKey, requestTracingOptions.FeatureFlagTracing.MaxVariants.ToString())); + } + + if (requestTracingOptions.FeatureFlagTracing.UsesAnyTracingFeature()) { - correlationContextKeyValues.Add(new KeyValuePair(RequestTracingConstants.FilterTypeKey, requestTracingOptions.FilterTracing.ToString())); + correlationContextKeyValues.Add(new KeyValuePair(RequestTracingConstants.FeatureFlagFeaturesKey, requestTracingOptions.FeatureFlagTracing.CreateFeaturesString())); } if (requestTracingOptions.FeatureManagementVersion != null) @@ -150,6 +181,11 @@ private static string CreateCorrelationContextHeader(RequestType requestType, Re correlationContextKeyValues.Add(new KeyValuePair(RequestTracingConstants.FeatureManagementAspNetCoreVersionKey, requestTracingOptions.FeatureManagementAspNetCoreVersion)); } + if (requestTracingOptions.UsesAnyTracingFeature()) + { + correlationContextKeyValues.Add(new KeyValuePair(RequestTracingConstants.FeaturesKey, requestTracingOptions.CreateFeaturesString())); + } + if (requestTracingOptions.IsKeyVaultConfigured) { correlationContextTags.Add(RequestTracingConstants.KeyVaultConfiguredTag); @@ -160,9 +196,9 @@ private static string CreateCorrelationContextHeader(RequestType requestType, Re correlationContextTags.Add(RequestTracingConstants.KeyVaultRefreshConfiguredTag); } - if (requestTracingOptions.IsSignalRUsed) + if (requestTracingOptions.IsFailoverRequest) { - correlationContextTags.Add(RequestTracingConstants.SignalRUsedTag); + correlationContextTags.Add(RequestTracingConstants.FailoverRequestTag); } var sb = new StringBuilder(); diff --git a/tests/Tests.AzureAppConfiguration.AspNetCore/Tests.AzureAppConfiguration.AspNetCore.csproj b/tests/Tests.AzureAppConfiguration.AspNetCore/Tests.AzureAppConfiguration.AspNetCore.csproj index f15c5eb7..bdd1236b 100644 --- a/tests/Tests.AzureAppConfiguration.AspNetCore/Tests.AzureAppConfiguration.AspNetCore.csproj +++ b/tests/Tests.AzureAppConfiguration.AspNetCore/Tests.AzureAppConfiguration.AspNetCore.csproj @@ -1,7 +1,7 @@ - net6.0;net7.0;net8.0 + net6.0;net8.0 8.0 false true diff --git a/tests/Tests.AzureAppConfiguration.Functions.Worker/Tests.AzureAppConfiguration.Functions.Worker.csproj b/tests/Tests.AzureAppConfiguration.Functions.Worker/Tests.AzureAppConfiguration.Functions.Worker.csproj index 02f3f7e2..6f9f3b96 100644 --- a/tests/Tests.AzureAppConfiguration.Functions.Worker/Tests.AzureAppConfiguration.Functions.Worker.csproj +++ b/tests/Tests.AzureAppConfiguration.Functions.Worker/Tests.AzureAppConfiguration.Functions.Worker.csproj @@ -1,7 +1,7 @@ - net6.0;net7.0;net8.0 + net6.0;net8.0 8.0 false true diff --git a/tests/Tests.AzureAppConfiguration/FailoverTests.cs b/tests/Tests.AzureAppConfiguration/FailoverTests.cs index 03c7951d..86ea96b9 100644 --- a/tests/Tests.AzureAppConfiguration/FailoverTests.cs +++ b/tests/Tests.AzureAppConfiguration/FailoverTests.cs @@ -68,7 +68,7 @@ public async Task FailOverTests_ReturnsAllClientsIfAllBackedOff() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); options.ReplicaDiscoveryEnabled = false; @@ -133,7 +133,7 @@ public void FailOverTests_PropagatesNonFailOverableExceptions() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -193,7 +193,7 @@ public async Task FailOverTests_BackoffStateIsUpdatedOnSuccessfulRequest() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -258,7 +258,7 @@ public void FailOverTests_AutoFailover() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); }) @@ -272,7 +272,8 @@ public void FailOverTests_ValidateEndpoints() new[] { new Uri("https://foobar.azconfig.io") }, new DefaultAzureCredential(), new ConfigurationClientOptions(), - true); + true, + false); Assert.True(configClientManager.IsValidEndpoint("azure.azconfig.io")); Assert.True(configClientManager.IsValidEndpoint("appconfig.azconfig.io")); @@ -287,7 +288,8 @@ public void FailOverTests_ValidateEndpoints() new[] { new Uri("https://foobar.appconfig.azure.com") }, new DefaultAzureCredential(), new ConfigurationClientOptions(), - true); + true, + false); Assert.True(configClientManager2.IsValidEndpoint("azure.appconfig.azure.com")); Assert.True(configClientManager2.IsValidEndpoint("azure.z1.appconfig.azure.com")); @@ -302,7 +304,8 @@ public void FailOverTests_ValidateEndpoints() new[] { new Uri("https://foobar.azconfig-test.io") }, new DefaultAzureCredential(), new ConfigurationClientOptions(), - true); + true, + false); Assert.False(configClientManager3.IsValidEndpoint("azure.azconfig-test.io")); Assert.False(configClientManager3.IsValidEndpoint("azure.azconfig.io")); @@ -311,7 +314,8 @@ public void FailOverTests_ValidateEndpoints() new[] { new Uri("https://foobar.z1.appconfig-test.azure.com") }, new DefaultAzureCredential(), new ConfigurationClientOptions(), - true); + true, + false); Assert.False(configClientManager4.IsValidEndpoint("foobar.z2.appconfig-test.azure.com")); Assert.False(configClientManager4.IsValidEndpoint("foobar.appconfig-test.azure.com")); @@ -325,7 +329,8 @@ public void FailOverTests_GetNoDynamicClient() new[] { new Uri("https://azure.azconfig.io") }, new DefaultAzureCredential(), new ConfigurationClientOptions(), - true); + true, + false); var clients = configClientManager.GetClients(); diff --git a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs index 2b6a70a2..44c20b4d 100644 --- a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs +++ b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs @@ -6,6 +6,7 @@ using Azure.Core.Testing; using Azure.Data.AppConfiguration; using Azure.Data.AppConfiguration.Tests; +using Azure.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration.AzureAppConfiguration; using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; @@ -14,6 +15,9 @@ using System.Collections.Generic; using System.Diagnostics.Tracing; using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Xunit; @@ -416,7 +420,209 @@ public class FeatureManagementTests eTag: new ETag("0a76e3d7-7ec1-4e37-883c-9ea6d0d89e63"), contentType: "text"); - TimeSpan CacheExpirationTime = TimeSpan.FromSeconds(1); + List _variantFeatureFlagCollection = new List + { + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "VariantsFeature1", + value: @" + { + ""id"": ""VariantsFeature1"", + ""enabled"": true, + ""variants"": [ + { + ""name"": ""Big"", + ""configuration_value"": ""600px"" + }, + { + ""name"": ""Small"", + ""configuration_reference"": ""ShoppingCart:Small"", + ""status_override"": ""Disabled"" + } + ], + ""allocation"": { + ""seed"": ""13992821"", + ""default_when_disabled"": ""Small"", + ""default_when_enabled"": ""Small"", + ""user"": [ + { + ""variant"": ""Big"", + ""users"": [ + ""Marsha"", + ""John"" + ] + }, + { + ""variant"": ""Small"", + ""users"": [ + ""Alice"", + ""Bob"" + ] + } + ], + ""group"": [ + { + ""variant"": ""Big"", + ""groups"": [ + ""Ring1"" + ] + }, + { + ""variant"": ""Small"", + ""groups"": [ + ""Ring2"", + ""Ring3"" + ] + } + ], + ""percentile"": [ + { + ""variant"": ""Big"", + ""from"": 0, + ""to"": 50 + }, + { + ""variant"": ""Small"", + ""from"": 50, + ""to"": 100 + } + ] + } + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")), + + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "VariantsFeature2", + value: @" + { + ""id"": ""VariantsFeature2"", + ""enabled"": false, + ""variants"": [ + { + ""name"": ""ObjectVariant"", + ""configuration_value"": { + ""Key1"": ""Value1"", + ""Key2"": { + ""InsideKey2"": ""Value2"" + } + } + }, + { + ""name"": ""NumberVariant"", + ""configuration_value"": 100 + }, + { + ""name"": ""NullVariant"", + ""configuration_value"": null + }, + { + ""name"": ""MissingValueVariant"" + }, + { + ""name"": ""BooleanVariant"", + ""configuration_value"": true + } + ], + ""allocation"": { + ""default_when_disabled"": ""ObjectVariant"", + ""default_when_enabled"": ""ObjectVariant"" + } + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")), + + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "VariantsFeature3", + value: @" + { + ""id"": ""VariantsFeature3"", + ""enabled"": ""true"", + ""variants"": [ + { + ""name"": ""NumberVariant"", + ""configuration_value"": 1 + }, + { + ""name"": ""NumberVariant"", + ""configuration_value"": 2 + }, + { + ""name"": ""OtherVariant"", + ""configuration_value"": ""Other"" + } + ], + ""allocation"": { + ""default_when_enabled"": ""OtherVariant"", + ""default_when_enabled"": ""NumberVariant"" + } + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")), + + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "VariantsFeature4", + value: @" + { + ""id"": ""VariantsFeature4"", + ""enabled"": true, + ""variants"": null, + ""allocation"": null + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")) + }; + + List _telemetryFeatureFlagCollection = new List + { + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "TelemetryFeature1", + value: @" + { + ""id"": ""TelemetryFeature1"", + ""enabled"": true, + ""telemetry"": { + ""enabled"": ""true"", + ""metadata"": { + ""Tags.Tag1"": ""Tag1Value"", + ""Tags.Tag2"": ""Tag2Value"" + } + } + } + ", + label: "label", + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")), + + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "TelemetryFeature2", + value: @" + { + ""id"": ""TelemetryFeature2"", + ""enabled"": true, + ""telemetry"": { + ""enabled"": false, + ""enabled"": true, + ""metadata"": { + ""Tags.Tag1"": ""Tag1Value"", + ""Tags.Tag1"": ""Tag2Value"" + } + } + } + ", + label: "label", + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")) + }; + + TimeSpan RefreshInterval = TimeSpan.FromSeconds(1); [Fact] public void UsesFeatureFlags() @@ -463,12 +669,82 @@ public async Task WatchesFeatureFlags() .Returns(new MockAsyncPageable(featureFlags)); IConfigurationRefresher refresher = null; - var cacheExpirationTimeSpan = TimeSpan.FromSeconds(1); var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => { options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); - options.UseFeatureFlags(o => o.CacheExpirationInterval = cacheExpirationTimeSpan); + options.UseFeatureFlags(o => o.SetRefreshInterval(RefreshInterval)); + + refresher = options.GetRefresher(); + }) + .Build(); + + Assert.Equal("Browser", config["FeatureManagement:Beta:EnabledFor:0:Name"]); + Assert.Equal("Firefox", config["FeatureManagement:Beta:EnabledFor:0:Parameters:AllowedBrowsers:0"]); + Assert.Equal("Safari", config["FeatureManagement:Beta:EnabledFor:0:Parameters:AllowedBrowsers:1"]); + Assert.Equal("RollOut", config["FeatureManagement:Beta:EnabledFor:1:Name"]); + Assert.Equal("20", config["FeatureManagement:Beta:EnabledFor:1:Parameters:Percentage"]); + Assert.Equal("US", config["FeatureManagement:Beta:EnabledFor:1:Parameters:Region"]); + Assert.Equal("SuperUsers", config["FeatureManagement:Beta:EnabledFor:2:Name"]); + Assert.Equal("TimeWindow", config["FeatureManagement:Beta:EnabledFor:3:Name"]); + Assert.Equal("/Date(1578643200000)/", config["FeatureManagement:Beta:EnabledFor:3:Parameters:Start"]); + Assert.Equal("/Date(1578686400000)/", config["FeatureManagement:Beta:EnabledFor:3:Parameters:End"]); + + featureFlags[0] = ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "myFeature", + value: @" + { + ""id"": ""Beta"", + ""description"": ""The new beta version of our web site."", + ""display_name"": ""Beta Feature"", + ""enabled"": true, + ""conditions"": { + ""client_filters"": [ + { + ""name"": ""Browser"", + ""parameters"": { + ""AllowedBrowsers"": [ ""Chrome"", ""Edge"" ] + } + } + ] + } + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1" + "f")); + + featureFlags.Add(_kv2); + + // Sleep to let the refresh interval elapse + Thread.Sleep(RefreshInterval); + await refresher.RefreshAsync(); + + Assert.Equal("Browser", config["FeatureManagement:Beta:EnabledFor:0:Name"]); + Assert.Equal("Chrome", config["FeatureManagement:Beta:EnabledFor:0:Parameters:AllowedBrowsers:0"]); + Assert.Equal("Edge", config["FeatureManagement:Beta:EnabledFor:0:Parameters:AllowedBrowsers:1"]); + Assert.Equal("SuperUsers", config["FeatureManagement:MyFeature2:EnabledFor:0:Name"]); + } + + [Fact] + public async Task WatchesFeatureFlagsUsingCacheExpirationInterval() + { + var featureFlags = new List { _kv }; + + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(featureFlags)); + + var cacheExpirationInterval = TimeSpan.FromSeconds(1); + + IConfigurationRefresher refresher = null; + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.UseFeatureFlags(o => o.CacheExpirationInterval = cacheExpirationInterval); refresher = options.GetRefresher(); }) @@ -512,7 +788,7 @@ public async Task WatchesFeatureFlags() featureFlags.Add(_kv2); // Sleep to let the cache expire - Thread.Sleep(cacheExpirationTimeSpan); + Thread.Sleep(cacheExpirationInterval); await refresher.RefreshAsync(); Assert.Equal("Browser", config["FeatureManagement:Beta:EnabledFor:0:Name"]); @@ -521,6 +797,73 @@ public async Task WatchesFeatureFlags() Assert.Equal("SuperUsers", config["FeatureManagement:MyFeature2:EnabledFor:0:Name"]); } + [Fact] + public async Task SkipRefreshIfRefreshIntervalHasNotElapsed() + { + var featureFlags = new List { _kv }; + + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(featureFlags)); + + IConfigurationRefresher refresher = null; + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.UseFeatureFlags(o => o.SetRefreshInterval(TimeSpan.FromSeconds(10))); + + refresher = options.GetRefresher(); + }) + .Build(); + + Assert.Equal("Browser", config["FeatureManagement:Beta:EnabledFor:0:Name"]); + Assert.Equal("Firefox", config["FeatureManagement:Beta:EnabledFor:0:Parameters:AllowedBrowsers:0"]); + Assert.Equal("Safari", config["FeatureManagement:Beta:EnabledFor:0:Parameters:AllowedBrowsers:1"]); + Assert.Equal("RollOut", config["FeatureManagement:Beta:EnabledFor:1:Name"]); + Assert.Equal("20", config["FeatureManagement:Beta:EnabledFor:1:Parameters:Percentage"]); + Assert.Equal("US", config["FeatureManagement:Beta:EnabledFor:1:Parameters:Region"]); + Assert.Equal("SuperUsers", config["FeatureManagement:Beta:EnabledFor:2:Name"]); + Assert.Equal("TimeWindow", config["FeatureManagement:Beta:EnabledFor:3:Name"]); + Assert.Equal("/Date(1578643200000)/", config["FeatureManagement:Beta:EnabledFor:3:Parameters:Start"]); + Assert.Equal("/Date(1578686400000)/", config["FeatureManagement:Beta:EnabledFor:3:Parameters:End"]); + + featureFlags[0] = ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "myFeature", + value: @" + { + ""id"": ""Beta"", + ""description"": ""The new beta version of our web site."", + ""display_name"": ""Beta Feature"", + ""enabled"": true, + ""conditions"": { + ""client_filters"": [ + { + ""name"": ""Browser"", + ""parameters"": { + ""AllowedBrowsers"": [ ""Chrome"", ""Edge"" ] + } + } + ] + } + } + ", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1" + "f")); + + featureFlags.Add(_kv2); + + await refresher.RefreshAsync(); + + Assert.Equal("Browser", config["FeatureManagement:Beta:EnabledFor:0:Name"]); + Assert.Equal("Firefox", config["FeatureManagement:Beta:EnabledFor:0:Parameters:AllowedBrowsers:0"]); + Assert.Equal("Safari", config["FeatureManagement:Beta:EnabledFor:0:Parameters:AllowedBrowsers:1"]); + Assert.Null(config["FeatureManagement:MyFeature2:EnabledFor:0:Name"]); + } + [Fact] public async Task SkipRefreshIfCacheNotExpired() { @@ -652,19 +995,18 @@ public async Task UsesEtagForFeatureFlagRefresh() .Returns(new MockAsyncPageable(new List { _kv })); IConfigurationRefresher refresher = null; - var cacheExpirationTimeSpan = TimeSpan.FromSeconds(1); var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => { options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); - options.UseFeatureFlags(o => o.CacheExpirationInterval = cacheExpirationTimeSpan); + options.UseFeatureFlags(o => o.SetRefreshInterval(RefreshInterval)); refresher = options.GetRefresher(); }) .Build(); - // Sleep to let the cache expire - Thread.Sleep(cacheExpirationTimeSpan); + // Sleep to wait for refresh interval to elapse + Thread.Sleep(RefreshInterval); await refresher.TryRefreshAsync(); mockClient.Verify(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny()), Times.Exactly(3)); @@ -677,7 +1019,6 @@ public void SelectFeatureFlags() var mockClient = new Mock(MockBehavior.Strict); var featureFlagPrefix = "App1"; var labelFilter = "App1_Label"; - var cacheExpiration = TimeSpan.FromSeconds(1); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(_featureFlagCollection.Where(s => s.Key.StartsWith(FeatureManagementConstants.FeatureFlagMarker + featureFlagPrefix) && s.Label == labelFilter).ToList())); @@ -690,7 +1031,7 @@ public void SelectFeatureFlags() options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(testClient); options.UseFeatureFlags(ff => { - ff.CacheExpirationInterval = cacheExpiration; + ff.SetRefreshInterval(RefreshInterval); ff.Select(featureFlagPrefix + "*", labelFilter); }); }) @@ -712,7 +1053,7 @@ public void TestNullAndMissingValuesForConditions() { var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); - var cacheExpiration = TimeSpan.FromSeconds(1); + var refreshInterval = TimeSpan.FromSeconds(1); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(_nullOrMissingConditionsFeatureFlagCollection)); @@ -726,7 +1067,7 @@ public void TestNullAndMissingValuesForConditions() options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(testClient); options.UseFeatureFlags(ff => { - ff.CacheExpirationInterval = cacheExpiration; + ff.SetRefreshInterval(refreshInterval); ff.Select(KeyFilter.Any); }); }) @@ -746,7 +1087,7 @@ public void InvalidFeatureFlagFormatsThrowFormatException() { var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); - var cacheExpiration = TimeSpan.FromSeconds(1); + var refreshInterval = TimeSpan.FromSeconds(1); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns((Func)GetTestKeys); @@ -771,7 +1112,7 @@ MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(testClient); options.UseFeatureFlags(ff => { - ff.CacheExpirationInterval = cacheExpiration; + ff.SetRefreshInterval(refreshInterval); ff.Select(setting.Key.Substring(FeatureManagementConstants.FeatureFlagMarker.Length)); }); }) @@ -1032,8 +1373,8 @@ public async Task DifferentCacheExpirationsForMultipleFeatureFlagRegistrations() var prefix2 = "App2"; var label1 = "App1_Label"; var label2 = "App2_Label"; - var cacheExpiration1 = TimeSpan.FromSeconds(1); - var cacheExpiration2 = TimeSpan.FromSeconds(60); + var refreshInterval1 = TimeSpan.FromSeconds(1); + var refreshInterval2 = TimeSpan.FromSeconds(60); IConfigurationRefresher refresher = null; var featureFlagCollection = new List(_featureFlagCollection); @@ -1051,12 +1392,12 @@ public async Task DifferentCacheExpirationsForMultipleFeatureFlagRegistrations() options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); options.UseFeatureFlags(ff => { - ff.CacheExpirationInterval = cacheExpiration1; + ff.SetRefreshInterval(refreshInterval1); ff.Select(prefix1 + "*", label1); }); options.UseFeatureFlags(ff => { - ff.CacheExpirationInterval = cacheExpiration2; + ff.SetRefreshInterval(refreshInterval2); ff.Select(prefix2 + "*", label2); }); @@ -1108,8 +1449,8 @@ public async Task DifferentCacheExpirationsForMultipleFeatureFlagRegistrations() contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1" + "f"))); - // Sleep to let the cache for feature flag with label1 expire - Thread.Sleep(cacheExpiration1); + // Sleep to let the refresh interval for feature flag with label1 elapse + Thread.Sleep(refreshInterval1); await refresher.RefreshAsync(); Assert.Equal("Browser", config["FeatureManagement:App1_Feature1:EnabledFor:0:Name"]); @@ -1124,12 +1465,12 @@ public async Task DifferentCacheExpirationsForMultipleFeatureFlagRegistrations() } [Fact] - public async Task OverwrittenCacheExpirationForSameFeatureFlagRegistrations() + public async Task OverwrittenRefreshIntervalForSameFeatureFlagRegistrations() { var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); - var cacheExpiration1 = TimeSpan.FromSeconds(1); - var cacheExpiration2 = TimeSpan.FromSeconds(60); + var refreshInterval1 = TimeSpan.FromSeconds(1); + var refreshInterval2 = TimeSpan.FromSeconds(60); IConfigurationRefresher refresher = null; var featureFlagCollection = new List(_featureFlagCollection); @@ -1144,13 +1485,13 @@ public async Task OverwrittenCacheExpirationForSameFeatureFlagRegistrations() { ff.Select("*", "App1_Label"); ff.Select("*", "App2_Label"); - ff.CacheExpirationInterval = cacheExpiration1; + ff.SetRefreshInterval(refreshInterval1); }); options.UseFeatureFlags(ff => { ff.Select("*", "App1_Label"); ff.Select("*", "App2_Label"); - ff.CacheExpirationInterval = cacheExpiration2; + ff.SetRefreshInterval(refreshInterval2); }); refresher = options.GetRefresher(); @@ -1186,11 +1527,11 @@ public async Task OverwrittenCacheExpirationForSameFeatureFlagRegistrations() contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1" + "f")); - Thread.Sleep(cacheExpiration1); + Thread.Sleep(refreshInterval1); await refresher.RefreshAsync(); - // The cache expiration time for feature flags was overwritten by second call to UseFeatureFlags. - // Sleeping for cacheExpiration1 time should not update feature flags. + // The refresh interval time for feature flags was overwritten by second call to UseFeatureFlags. + // Sleeping for refreshInterval1 time should not update feature flags. Assert.Equal("True", config["FeatureManagement:App1_Feature1"]); Assert.Equal("False", config["FeatureManagement:App1_Feature2"]); Assert.Equal("False", config["FeatureManagement:App2_Feature1"]); @@ -1205,7 +1546,6 @@ public async Task SelectAndRefreshSingleFeatureFlag() var mockClient = new Mock(MockBehavior.Strict); var prefix1 = "Feature1"; var label1 = "App1_Label"; - var cacheExpiration = TimeSpan.FromSeconds(1); IConfigurationRefresher refresher = null; var featureFlagCollection = new List(_featureFlagCollection); @@ -1222,7 +1562,7 @@ public async Task SelectAndRefreshSingleFeatureFlag() options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); options.UseFeatureFlags(ff => { - ff.CacheExpirationInterval = cacheExpiration; + ff.SetRefreshInterval(RefreshInterval); ff.Select(prefix1, label1); }); @@ -1255,8 +1595,8 @@ public async Task SelectAndRefreshSingleFeatureFlag() contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1" + "f")); - // Sleep to let the cache for feature flag with label1 expire - Thread.Sleep(cacheExpiration); + // Sleep to let the refresh interval for feature flag with label1 elapse + Thread.Sleep(RefreshInterval); await refresher.RefreshAsync(); Assert.Equal("Browser", config["FeatureManagement:Feature1:EnabledFor:0:Name"]); @@ -1297,7 +1637,7 @@ public async Task ValidateCorrectFeatureFlagLoggedIfModifiedOrRemovedDuringRefre .AddAzureAppConfiguration(options => { options.ClientManager = mockClientManager; - options.UseFeatureFlags(o => o.CacheExpirationInterval = CacheExpirationTime); + options.UseFeatureFlags(o => o.SetRefreshInterval(RefreshInterval)); refresher = options.GetRefresher(); }) .Build(); @@ -1325,14 +1665,14 @@ public async Task ValidateCorrectFeatureFlagLoggedIfModifiedOrRemovedDuringRefre contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1" + "f")); - Thread.Sleep(CacheExpirationTime); + Thread.Sleep(RefreshInterval); await refresher.TryRefreshAsync(); Assert.Equal("AllUsers", config["FeatureManagement:MyFeature:EnabledFor:0:Name"]); Assert.Contains(LogHelper.BuildFeatureFlagReadMessage("myFeature1", null, TestHelpers.PrimaryConfigStoreEndpoint.ToString().TrimEnd('/')), verboseInvocation); Assert.Contains(LogHelper.BuildFeatureFlagUpdatedMessage("myFeature1"), informationalInvocation); featureFlags.RemoveAt(0); - Thread.Sleep(CacheExpirationTime); + Thread.Sleep(RefreshInterval); await refresher.TryRefreshAsync(); Assert.Null(config["FeatureManagement:MyFeature:EnabledFor:0:Name"]); @@ -1373,11 +1713,11 @@ public async Task ValidateFeatureFlagsUnchangedLogged() .AddAzureAppConfiguration(options => { options.ClientManager = mockClientManager; - options.UseFeatureFlags(o => o.CacheExpirationInterval = CacheExpirationTime); + options.UseFeatureFlags(o => o.SetRefreshInterval(RefreshInterval)); options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); refresher = options.GetRefresher(); }) @@ -1386,7 +1726,7 @@ public async Task ValidateFeatureFlagsUnchangedLogged() Assert.Equal("SuperUsers", config["FeatureManagement:MyFeature2:EnabledFor:0:Name"]); FirstKeyValue.Value = "newValue1"; - Thread.Sleep(CacheExpirationTime); + Thread.Sleep(RefreshInterval); await refresher.TryRefreshAsync(); Assert.Equal("SuperUsers", config["FeatureManagement:MyFeature2:EnabledFor:0:Name"]); Assert.Contains(LogHelper.BuildFeatureFlagsUnchangedMessage(TestHelpers.PrimaryConfigStoreEndpoint.ToString().TrimEnd('/')), verboseInvocation); @@ -1441,9 +1781,9 @@ public async Task MapTransformFeatureFlagWithRefresh() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label", true) - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); - options.UseFeatureFlags(o => o.CacheExpirationInterval = CacheExpirationTime); + options.UseFeatureFlags(o => o.SetRefreshInterval(RefreshInterval)); options.Map((setting) => { if (setting.ContentType == FeatureManagementConstants.ContentType + ";charset=utf-8") @@ -1499,13 +1839,139 @@ public async Task MapTransformFeatureFlagWithRefresh() contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1" + "f")); - Thread.Sleep(CacheExpirationTime); + Thread.Sleep(RefreshInterval); await refresher.TryRefreshAsync(); Assert.Equal("newValue1", config["TestKey1"]); Assert.Equal("NoUsers", config["FeatureManagement:MyFeature:EnabledFor:0:Name"]); } + [Fact] + public void WithVariants() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(_variantFeatureFlagCollection)); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.UseFeatureFlags(); + }) + .Build(); + + Assert.Equal("VariantsFeature1", config["feature_management:feature_flags:0:id"]); + Assert.Equal("True", config["feature_management:feature_flags:0:enabled"]); + Assert.Equal("Big", config["feature_management:feature_flags:0:variants:0:name"]); + Assert.Equal("600px", config["feature_management:feature_flags:0:variants:0:configuration_value"]); + Assert.Equal("Small", config["feature_management:feature_flags:0:variants:1:name"]); + Assert.Equal("ShoppingCart:Small", config["feature_management:feature_flags:0:variants:1:configuration_reference"]); + Assert.Equal("Disabled", config["feature_management:feature_flags:0:variants:1:status_override"]); + Assert.Equal("Small", config["feature_management:feature_flags:0:allocation:default_when_disabled"]); + Assert.Equal("Small", config["feature_management:feature_flags:0:allocation:default_when_enabled"]); + Assert.Equal("Big", config["feature_management:feature_flags:0:allocation:user:0:variant"]); + Assert.Equal("Marsha", config["feature_management:feature_flags:0:allocation:user:0:users:0"]); + Assert.Equal("John", config["feature_management:feature_flags:0:allocation:user:0:users:1"]); + Assert.Equal("Small", config["feature_management:feature_flags:0:allocation:user:1:variant"]); + Assert.Equal("Alice", config["feature_management:feature_flags:0:allocation:user:1:users:0"]); + Assert.Equal("Bob", config["feature_management:feature_flags:0:allocation:user:1:users:1"]); + Assert.Equal("Big", config["feature_management:feature_flags:0:allocation:group:0:variant"]); + Assert.Equal("Ring1", config["feature_management:feature_flags:0:allocation:group:0:groups:0"]); + Assert.Equal("Small", config["feature_management:feature_flags:0:allocation:group:1:variant"]); + Assert.Equal("Ring2", config["feature_management:feature_flags:0:allocation:group:1:groups:0"]); + Assert.Equal("Ring3", config["feature_management:feature_flags:0:allocation:group:1:groups:1"]); + Assert.Equal("Big", config["feature_management:feature_flags:0:allocation:percentile:0:variant"]); + Assert.Equal("0", config["feature_management:feature_flags:0:allocation:percentile:0:from"]); + Assert.Equal("50", config["feature_management:feature_flags:0:allocation:percentile:0:to"]); + Assert.Equal("Small", config["feature_management:feature_flags:0:allocation:percentile:1:variant"]); + Assert.Equal("50", config["feature_management:feature_flags:0:allocation:percentile:1:from"]); + Assert.Equal("100", config["feature_management:feature_flags:0:allocation:percentile:1:to"]); + Assert.Equal("13992821", config["feature_management:feature_flags:0:allocation:seed"]); + + Assert.Equal("VariantsFeature2", config["feature_management:feature_flags:1:id"]); + Assert.Equal("False", config["feature_management:feature_flags:1:enabled"]); + Assert.Equal("ObjectVariant", config["feature_management:feature_flags:1:variants:0:name"]); + Assert.Equal("Value1", config["feature_management:feature_flags:1:variants:0:configuration_value:Key1"]); + Assert.Equal("Value2", config["feature_management:feature_flags:1:variants:0:configuration_value:Key2:InsideKey2"]); + Assert.Equal("NumberVariant", config["feature_management:feature_flags:1:variants:1:name"]); + Assert.Equal("100", config["feature_management:feature_flags:1:variants:1:configuration_value"]); + Assert.Equal("NullVariant", config["feature_management:feature_flags:1:variants:2:name"]); + Assert.Equal("", config["feature_management:feature_flags:1:variants:2:configuration_value"]); + Assert.True(config + .GetSection("feature_management:feature_flags:1:variants:2") + .AsEnumerable() + .ToDictionary(x => x.Key, x => x.Value) + .ContainsKey("feature_management:feature_flags:1:variants:2:configuration_value")); + Assert.Equal("MissingValueVariant", config["feature_management:feature_flags:1:variants:3:name"]); + Assert.Null(config["feature_management:feature_flags:1:variants:3:configuration_value"]); + Assert.False(config + .GetSection("feature_management:feature_flags:1:variants:3") + .AsEnumerable() + .ToDictionary(x => x.Key, x => x.Value) + .ContainsKey("feature_management:feature_flags:1:variants:3:configuration_value")); + Assert.Equal("BooleanVariant", config["feature_management:feature_flags:1:variants:4:name"]); + Assert.Equal("True", config["feature_management:feature_flags:1:variants:4:configuration_value"]); + Assert.Equal("ObjectVariant", config["feature_management:feature_flags:1:allocation:default_when_disabled"]); + Assert.Equal("ObjectVariant", config["feature_management:feature_flags:1:allocation:default_when_enabled"]); + + Assert.Equal("VariantsFeature3", config["feature_management:feature_flags:2:id"]); + Assert.Equal("True", config["feature_management:feature_flags:2:enabled"]); + Assert.Equal("NumberVariant", config["feature_management:feature_flags:2:allocation:default_when_enabled"]); + Assert.Equal("1", config["feature_management:feature_flags:2:variants:0:configuration_value"]); + Assert.Equal("2", config["feature_management:feature_flags:2:variants:1:configuration_value"]); + Assert.Equal("Other", config["feature_management:feature_flags:2:variants:2:configuration_value"]); + Assert.Equal("NumberVariant", config["feature_management:feature_flags:2:allocation:default_when_enabled"]); + + Assert.Equal("True", config["FeatureManagement:VariantsFeature4"]); + } + + [Fact] + public void WithTelemetry() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(_telemetryFeatureFlagCollection)); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Connect(TestHelpers.PrimaryConfigStoreEndpoint, new DefaultAzureCredential()); + options.UseFeatureFlags(); + }) + .Build(); + + Assert.Equal("True", config["feature_management:feature_flags:0:telemetry:enabled"]); + Assert.Equal("TelemetryFeature1", config["feature_management:feature_flags:0:id"]); + Assert.Equal("Tag1Value", config["feature_management:feature_flags:0:telemetry:metadata:Tags.Tag1"]); + Assert.Equal("Tag2Value", config["feature_management:feature_flags:0:telemetry:metadata:Tags.Tag2"]); + Assert.Equal("c3c231fd-39a0-4cb6-3237-4614474b92c1", config["feature_management:feature_flags:0:telemetry:metadata:ETag"]); + + byte[] featureFlagIdHash; + + using (HashAlgorithm hashAlgorithm = SHA256.Create()) + { + featureFlagIdHash = hashAlgorithm.ComputeHash(Encoding.UTF8.GetBytes($"{FeatureManagementConstants.FeatureFlagMarker}TelemetryFeature1\nlabel")); + } + + string featureFlagId = Convert.ToBase64String(featureFlagIdHash) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + Assert.Equal(featureFlagId, config["feature_management:feature_flags:0:telemetry:metadata:FeatureFlagId"]); + Assert.Equal($"{TestHelpers.PrimaryConfigStoreEndpoint}kv/{FeatureManagementConstants.FeatureFlagMarker}TelemetryFeature1?label=label", config["feature_management:feature_flags:0:telemetry:metadata:FeatureFlagReference"]); + + Assert.Equal("True", config["feature_management:feature_flags:1:telemetry:enabled"]); + Assert.Equal("TelemetryFeature2", config["feature_management:feature_flags:1:id"]); + Assert.Equal("Tag2Value", config["feature_management:feature_flags:1:telemetry:metadata:Tags.Tag1"]); + } + [Fact] public void WithRequirementType() { @@ -1524,9 +1990,9 @@ public void WithRequirementType() var featureFlags = new List() { _kv2, - FeatureWithRequirementType("Feature_NoFilters", "All", emptyFilters), - FeatureWithRequirementType("Feature_RequireAll", "All", nonEmptyFilters), - FeatureWithRequirementType("Feature_RequireAny", "Any", nonEmptyFilters) + CreateFeatureFlag("Feature_NoFilters", requirementType: "\"All\"", clientFiltersJsonString: emptyFilters), + CreateFeatureFlag("Feature_RequireAll", requirementType: "\"All\"", clientFiltersJsonString: nonEmptyFilters), + CreateFeatureFlag("Feature_RequireAny", requirementType: "\"Any\"", clientFiltersJsonString: nonEmptyFilters) }; var mockResponse = new Mock(); @@ -1543,10 +2009,60 @@ public void WithRequirementType() }) .Build(); - Assert.Null(config["FeatureManagement:MyFeature2:RequirementType"]); - Assert.Null(config["FeatureManagement:Feature_NoFilters:RequirementType"]); - Assert.Equal("All", config["FeatureManagement:Feature_RequireAll:RequirementType"]); - Assert.Equal("Any", config["FeatureManagement:Feature_RequireAny:RequirementType"]); + Assert.Null(config["feature_management:feature_flags:0:requirement_type"]); + Assert.Equal("Feature_NoFilters", config["feature_management:feature_flags:0:id"]); + Assert.Equal("All", config["feature_management:feature_flags:1:conditions:requirement_type"]); + Assert.Equal("Feature_RequireAll", config["feature_management:feature_flags:1:id"]); + Assert.Equal("Any", config["feature_management:feature_flags:2:conditions:requirement_type"]); + Assert.Equal("Feature_RequireAny", config["feature_management:feature_flags:2:id"]); + } + + [Fact] + public void ThrowsOnIncorrectJsonTypes() + { + var settings = new List() + { + CreateFeatureFlag("Feature1", variantsJsonString: @"[{""name"": 1}]"), + CreateFeatureFlag("Feature2", variantsJsonString: @"[{""configuration_reference"": true}]"), + CreateFeatureFlag("Feature3", variantsJsonString: @"[{""status_override"": []}]"), + CreateFeatureFlag("Feature4", seed: "{}"), + CreateFeatureFlag("Feature5", defaultWhenDisabled: "5"), + CreateFeatureFlag("Feature6", defaultWhenEnabled: "6"), + CreateFeatureFlag("Feature7", userJsonString: @"[{""variant"": []}]"), + CreateFeatureFlag("Feature8", userJsonString: @"[{""users"": [ {""name"": ""8""} ]}]"), + CreateFeatureFlag("Feature9", groupJsonString: @"[{""variant"": false}]"), + CreateFeatureFlag("Feature10", groupJsonString: @"[{""groups"": 10}]"), + CreateFeatureFlag("Feature11", percentileJsonString: @"[{""variant"": []}]"), + CreateFeatureFlag("Feature12", percentileJsonString: @"[{""from"": true}]"), + CreateFeatureFlag("Feature13", percentileJsonString: @"[{""to"": {}}]"), + CreateFeatureFlag("Feature14", telemetryEnabled: "14"), + CreateFeatureFlag("Feature15", telemetryMetadataJsonString: @"{""key"": 15}"), + CreateFeatureFlag("Feature16", clientFiltersJsonString: @"[{""name"": 16}]"), + CreateFeatureFlag("Feature17", clientFiltersJsonString: @"{""key"": [{""name"": ""name"", ""parameters"": 17}]}"), + CreateFeatureFlag("Feature18", requirementType: "18") + }; + + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + foreach (ConfigurationSetting setting in settings) + { + var featureFlags = new List { setting }; + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(featureFlags)); + + void action() => new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.UseFeatureFlags(); + }).Build(); + + var exception = Assert.Throws(action); + + Assert.False(exception.InnerException is JsonException); + } } Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) @@ -1559,18 +2075,42 @@ Response GetTestKey(string key, string label, Cancellation return Response.FromValue(TestHelpers.CloneSetting(FirstKeyValue), new Mock().Object); } - private ConfigurationSetting FeatureWithRequirementType(string featureId, string requirementType, string clientFiltersJsonString) + private ConfigurationSetting CreateFeatureFlag(string featureId, + string requirementType = "null", + string clientFiltersJsonString = "null", + string variantsJsonString = "null", + string seed = "null", + string defaultWhenDisabled = "null", + string defaultWhenEnabled = "null", + string userJsonString = "null", + string groupJsonString = "null", + string percentileJsonString = "null", + string telemetryEnabled = "null", + string telemetryMetadataJsonString = "null") { return ConfigurationModelFactory.ConfigurationSetting( key: FeatureManagementConstants.FeatureFlagMarker + featureId, value: $@" {{ - ""id"": ""{featureId}"", - ""enabled"": true, - ""conditions"": {{ - ""requirement_type"": ""{requirementType}"", - ""client_filters"": {clientFiltersJsonString} - }} + ""id"": ""{featureId}"", + ""enabled"": true, + ""conditions"": {{ + ""requirement_type"": {requirementType}, + ""client_filters"": {clientFiltersJsonString} + }}, + ""variants"": {variantsJsonString}, + ""allocation"": {{ + ""seed"": {seed}, + ""default_when_disabled"": {defaultWhenDisabled}, + ""default_when_enabled"": {defaultWhenEnabled}, + ""user"": {userJsonString}, + ""group"": {groupJsonString}, + ""percentile"": {percentileJsonString} + }}, + ""telemetry"": {{ + ""enabled"": {telemetryEnabled}, + ""metadata"": {telemetryMetadataJsonString} + }} }} ", contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", diff --git a/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs b/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs index 9517b709..06c88040 100644 --- a/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs +++ b/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs @@ -507,9 +507,10 @@ public void DoesNotThrowKeyVaultExceptionWhenProviderIsOptional() var mockKeyValueAdapter = new Mock(MockBehavior.Strict); mockKeyValueAdapter.Setup(adapter => adapter.CanProcess(_kv)) .Returns(true); - mockKeyValueAdapter.Setup(adapter => adapter.ProcessKeyValue(_kv, It.IsAny(), It.IsAny())) + mockKeyValueAdapter.Setup(adapter => adapter.ProcessKeyValue(_kv, It.IsAny(), It.IsAny(), It.IsAny())) .Throws(new KeyVaultReferenceException("Key vault error", null)); - mockKeyValueAdapter.Setup(adapter => adapter.InvalidateCache(null)); + mockKeyValueAdapter.Setup(adapter => adapter.OnChangeDetected(null)); + mockKeyValueAdapter.Setup(adapter => adapter.OnConfigUpdated()); new ConfigurationBuilder() .AddAzureAppConfiguration(options => @@ -685,7 +686,7 @@ public void ThrowsWhenSecretRefreshIntervalIsTooShort() public async Task SecretIsReturnedFromCacheIfSecretCacheHasNotExpired() { IConfigurationRefresher refresher = null; - TimeSpan cacheExpirationTime = TimeSpan.FromSeconds(1); + TimeSpan refreshInterval = TimeSpan.FromSeconds(1); var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); @@ -731,7 +732,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("Sentinel") - .SetCacheExpiration(cacheExpirationTime); + .SetRefreshInterval(refreshInterval); }); refresher = options.GetRefresher(); @@ -743,7 +744,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o // Update sentinel key-value sentinelKv.Value = "Value2"; - Thread.Sleep(cacheExpirationTime); + Thread.Sleep(refreshInterval); await refresher.RefreshAsync(); Assert.Equal("Value2", config["Sentinel"]); @@ -758,7 +759,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o public async Task CachedSecretIsInvalidatedWhenRefreshAllIsTrue() { IConfigurationRefresher refresher = null; - TimeSpan cacheExpirationTime = TimeSpan.FromSeconds(60); + TimeSpan refreshInterval = TimeSpan.FromSeconds(60); var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); @@ -803,7 +804,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("Sentinel", refreshAll: true) - .SetCacheExpiration(cacheExpirationTime); + .SetRefreshInterval(refreshInterval); }); refresher = options.GetRefresher(); @@ -815,7 +816,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o // Update sentinel key-value to trigger refresh operation sentinelKv.Value = "Value2"; - Thread.Sleep(cacheExpirationTime); + Thread.Sleep(refreshInterval); await refresher.RefreshAsync(); Assert.Equal("Value2", config["Sentinel"]); @@ -830,7 +831,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o public async Task SecretIsReloadedFromKeyVaultWhenCacheExpires() { IConfigurationRefresher refresher = null; - TimeSpan cacheExpirationTime = TimeSpan.FromSeconds(60); + TimeSpan refreshInterval = TimeSpan.FromSeconds(60); var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); @@ -850,7 +851,7 @@ public async Task SecretIsReloadedFromKeyVaultWhenCacheExpires() options.ConfigureKeyVault(kv => { kv.Register(mockSecretClient.Object); - kv.SetSecretRefreshInterval(_kv.Key, cacheExpirationTime); + kv.SetSecretRefreshInterval(_kv.Key, refreshInterval); }); refresher = options.GetRefresher(); @@ -860,7 +861,7 @@ public async Task SecretIsReloadedFromKeyVaultWhenCacheExpires() Assert.Equal(_secretValue, config[_kv.Key]); // Sleep to let the secret cache expire - Thread.Sleep(cacheExpirationTime); + Thread.Sleep(refreshInterval); await refresher.RefreshAsync(); Assert.Equal(_secretValue, config[_kv.Key]); @@ -873,7 +874,7 @@ public async Task SecretIsReloadedFromKeyVaultWhenCacheExpires() public async Task SecretsWithDefaultRefreshInterval() { IConfigurationRefresher refresher = null; - TimeSpan shortCacheExpirationTime = TimeSpan.FromSeconds(60); + TimeSpan shortRefreshInterval = TimeSpan.FromSeconds(60); var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); @@ -893,7 +894,7 @@ public async Task SecretsWithDefaultRefreshInterval() options.ConfigureKeyVault(kv => { kv.Register(mockSecretClient.Object); - kv.SetSecretRefreshInterval(shortCacheExpirationTime); + kv.SetSecretRefreshInterval(shortRefreshInterval); }); refresher = options.GetRefresher(); @@ -904,7 +905,7 @@ public async Task SecretsWithDefaultRefreshInterval() Assert.Equal(_secretValue, config["TK2"]); // Sleep to let the secret cache expire for both secrets - Thread.Sleep(shortCacheExpirationTime); + Thread.Sleep(shortRefreshInterval); await refresher.RefreshAsync(); Assert.Equal(_secretValue, config["TK1"]); @@ -918,8 +919,8 @@ public async Task SecretsWithDefaultRefreshInterval() public async Task SecretsWithDifferentRefreshIntervals() { IConfigurationRefresher refresher = null; - TimeSpan shortCacheExpirationTime = TimeSpan.FromSeconds(60); - TimeSpan longCacheExpirationTime = TimeSpan.FromDays(1); + TimeSpan shortRefreshInterval = TimeSpan.FromSeconds(60); + TimeSpan longRefreshInterval = TimeSpan.FromDays(1); var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); @@ -939,8 +940,8 @@ public async Task SecretsWithDifferentRefreshIntervals() options.ConfigureKeyVault(kv => { kv.Register(mockSecretClient.Object); - kv.SetSecretRefreshInterval("TK1", shortCacheExpirationTime); - kv.SetSecretRefreshInterval(longCacheExpirationTime); + kv.SetSecretRefreshInterval("TK1", shortRefreshInterval); + kv.SetSecretRefreshInterval(longRefreshInterval); }); refresher = options.GetRefresher(); @@ -951,7 +952,7 @@ public async Task SecretsWithDifferentRefreshIntervals() Assert.Equal(_secretValue, config["TK2"]); // Sleep to let the secret cache expire for one secret - Thread.Sleep(shortCacheExpirationTime); + Thread.Sleep(shortRefreshInterval); await refresher.RefreshAsync(); Assert.Equal(_secretValue, config["TK1"]); diff --git a/tests/Tests.AzureAppConfiguration/LoadBalancingTests.cs b/tests/Tests.AzureAppConfiguration/LoadBalancingTests.cs new file mode 100644 index 00000000..4429c7be --- /dev/null +++ b/tests/Tests.AzureAppConfiguration/LoadBalancingTests.cs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure; +using Azure.Core.Testing; +using Azure.Data.AppConfiguration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration; +using Moq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Tests.AzureAppConfiguration +{ + public class LoadBalancingTests + { + readonly ConfigurationSetting kv = ConfigurationModelFactory.ConfigurationSetting(key: "TestKey1", label: "label", value: "TestValue1", + eTag: new ETag("0a76e3d7-7ec1-4e37-883c-9ea6d0d89e63"), + contentType: "text"); + + TimeSpan RefreshInterval = TimeSpan.FromSeconds(1); + + [Fact] + public async Task LoadBalancingTests_UsesAllEndpoints() + { + IConfigurationRefresher refresher = null; + var mockResponse = new MockResponse(200); + + var mockClient1 = new Mock(MockBehavior.Strict); + mockClient1.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(Enumerable.Empty().ToList())); + mockClient1.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(kv, mockResponse)); + mockClient1.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(kv, mockResponse)); + mockClient1.Setup(c => c.Equals(mockClient1)).Returns(true); + + var mockClient2 = new Mock(MockBehavior.Strict); + mockClient2.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(Enumerable.Empty().ToList())); + mockClient2.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(kv, mockResponse)); + mockClient2.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(kv, mockResponse)); + mockClient2.Setup(c => c.Equals(mockClient2)).Returns(true); + + ConfigurationClientWrapper cw1 = new ConfigurationClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object); + ConfigurationClientWrapper cw2 = new ConfigurationClientWrapper(TestHelpers.SecondaryConfigStoreEndpoint, mockClient2.Object); + + var clientList = new List() { cw1, cw2 }; + var configClientManager = new ConfigurationClientManager(clientList); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = configClientManager; + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.Register("TestKey1", "label") + .SetRefreshInterval(RefreshInterval); + }); + options.ReplicaDiscoveryEnabled = false; + options.LoadBalancingEnabled = true; + + refresher = options.GetRefresher(); + }).Build(); + + // Ensure client 1 was used for startup + mockClient1.Verify(mc => mc.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny()), Times.Exactly(1)); + + Thread.Sleep(RefreshInterval); + await refresher.RefreshAsync(); + + // Ensure client 2 was used for refresh + mockClient1.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(0)); + + mockClient2.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(1)); + + Thread.Sleep(RefreshInterval); + await refresher.RefreshAsync(); + + // Ensure client 1 was now used for refresh + mockClient1.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(1)); + } + + [Fact] + public async Task LoadBalancingTests_UsesClientAfterBackoffEnds() + { + IConfigurationRefresher refresher = null; + var mockResponse = new MockResponse(200); + + var mockClient1 = new Mock(MockBehavior.Strict); + mockClient1.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Throws(new RequestFailedException(503, "Request failed.")); + mockClient1.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(kv, mockResponse)); + mockClient1.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(kv, mockResponse)); + mockClient1.Setup(c => c.Equals(mockClient1)).Returns(true); + + var mockClient2 = new Mock(MockBehavior.Strict); + mockClient2.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(Enumerable.Empty().ToList())); + mockClient2.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(kv, mockResponse)); + mockClient2.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(kv, mockResponse)); + mockClient2.Setup(c => c.Equals(mockClient2)).Returns(true); + + ConfigurationClientWrapper cw1 = new ConfigurationClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object); + ConfigurationClientWrapper cw2 = new ConfigurationClientWrapper(TestHelpers.SecondaryConfigStoreEndpoint, mockClient2.Object); + + var clientList = new List() { cw1, cw2 }; + var configClientManager = new ConfigurationClientManager(clientList); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.MinBackoffDuration = TimeSpan.FromSeconds(2); + options.ClientManager = configClientManager; + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.Register("TestKey1", "label") + .SetRefreshInterval(RefreshInterval); + }); + options.ReplicaDiscoveryEnabled = false; + options.LoadBalancingEnabled = true; + + refresher = options.GetRefresher(); + }).Build(); + + // Ensure client 2 was used for startup + mockClient2.Verify(mc => mc.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny()), Times.Exactly(1)); + + Thread.Sleep(TimeSpan.FromSeconds(2)); + await refresher.RefreshAsync(); + + // Ensure client 1 has recovered and is used for refresh + mockClient2.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(0)); + + mockClient1.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(1)); + + Thread.Sleep(RefreshInterval); + await refresher.RefreshAsync(); + + mockClient2.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(1)); + } + } +} diff --git a/tests/Tests.AzureAppConfiguration/LoggingTests.cs b/tests/Tests.AzureAppConfiguration/LoggingTests.cs index e4392e89..547c65bd 100644 --- a/tests/Tests.AzureAppConfiguration/LoggingTests.cs +++ b/tests/Tests.AzureAppConfiguration/LoggingTests.cs @@ -51,7 +51,7 @@ public class LoggingTests eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1"), contentType: KeyVaultConstants.ContentType + "; charset=utf-8"); - TimeSpan CacheExpirationTime = TimeSpan.FromSeconds(1); + TimeSpan RefreshInterval = TimeSpan.FromSeconds(1); [Fact] public async Task ValidateExceptionLoggedDuringRefresh() @@ -80,7 +80,7 @@ public async Task ValidateExceptionLoggedDuringRefresh() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); refresher = options.GetRefresher(); @@ -90,7 +90,7 @@ public async Task ValidateExceptionLoggedDuringRefresh() Assert.Equal("TestValue1", config["TestKey1"]); FirstKeyValue.Value = "newValue1"; - Thread.Sleep(CacheExpirationTime); + Thread.Sleep(RefreshInterval); await refresher.TryRefreshAsync(); Assert.NotEqual("newValue1", config["TestKey1"]); @@ -122,7 +122,7 @@ public async Task ValidateUnauthorizedExceptionLoggedDuringRefresh() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); refresher = options.GetRefresher(); @@ -132,7 +132,7 @@ public async Task ValidateUnauthorizedExceptionLoggedDuringRefresh() Assert.Equal("TestValue1", config["TestKey1"]); FirstKeyValue.Value = "newValue1"; - Thread.Sleep(CacheExpirationTime); + Thread.Sleep(RefreshInterval); await refresher.TryRefreshAsync(); Assert.NotEqual("newValue1", config["TestKey1"]); @@ -164,7 +164,7 @@ public async Task ValidateInvalidOperationExceptionLoggedDuringRefresh() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); refresher = options.GetRefresher(); @@ -174,7 +174,7 @@ public async Task ValidateInvalidOperationExceptionLoggedDuringRefresh() Assert.Equal("TestValue1", config["TestKey1"]); FirstKeyValue.Value = "newValue1"; - Thread.Sleep(CacheExpirationTime); + Thread.Sleep(RefreshInterval); await refresher.TryRefreshAsync(); Assert.NotEqual("newValue1", config["TestKey1"]); @@ -230,7 +230,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("SentinelKey", refreshAll: true) - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); refresher = options.GetRefresher(); }) @@ -240,7 +240,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o // Update sentinel key-value to trigger refreshAll operation sentinelKv.Value = "UpdatedSentinelValue"; - Thread.Sleep(CacheExpirationTime); + Thread.Sleep(RefreshInterval); await refresher.TryRefreshAsync(); Assert.Contains(LoggingConstants.RefreshFailedDueToKeyVaultError + "\nNo key vault credential or secret resolver callback configured, and no matching secret client could be found.", warningInvocation); @@ -271,7 +271,7 @@ public async Task ValidateAggregateExceptionWithInnerOperationCanceledExceptionL options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); refresher = options.GetRefresher(); @@ -281,7 +281,7 @@ public async Task ValidateAggregateExceptionWithInnerOperationCanceledExceptionL Assert.Equal("TestValue1", config["TestKey1"]); FirstKeyValue.Value = "newValue1"; - Thread.Sleep(CacheExpirationTime); + Thread.Sleep(RefreshInterval); await refresher.TryRefreshAsync(); Assert.NotEqual("newValue1", config["TestKey1"]); @@ -311,7 +311,7 @@ public async Task ValidateOperationCanceledExceptionLoggedDuringRefresh() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); refresher = options.GetRefresher(); @@ -321,7 +321,7 @@ public async Task ValidateOperationCanceledExceptionLoggedDuringRefresh() Assert.Equal("TestValue1", config["TestKey1"]); FirstKeyValue.Value = "newValue1"; - Thread.Sleep(CacheExpirationTime); + Thread.Sleep(RefreshInterval); using var cancellationSource = new CancellationTokenSource(); cancellationSource.Cancel(); @@ -367,7 +367,7 @@ public async Task ValidateFailoverToDifferentEndpointMessageLoggedAfterFailover( options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); refresher = options.GetRefresher(); @@ -379,7 +379,7 @@ public async Task ValidateFailoverToDifferentEndpointMessageLoggedAfterFailover( Assert.Equal("TestValue1", config["TestKey1"]); FirstKeyValue.Value = "newValue1"; - Thread.Sleep(CacheExpirationTime); + Thread.Sleep(RefreshInterval); await refresher.TryRefreshAsync(); Assert.Equal("newValue1", config["TestKey1"]); @@ -391,7 +391,7 @@ public async Task ValidateFailoverToDifferentEndpointMessageLoggedAfterFailover( FirstKeyValue.Value = "TestValue1"; - Thread.Sleep(CacheExpirationTime); + Thread.Sleep(RefreshInterval); await refresher.TryRefreshAsync(); Assert.Equal("newValue1", config["TestKey1"]); @@ -423,7 +423,7 @@ public async Task ValidateConfigurationUpdatedSuccessLoggedDuringRefresh() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label", true) - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); refresher = options.GetRefresher(); @@ -433,7 +433,7 @@ public async Task ValidateConfigurationUpdatedSuccessLoggedDuringRefresh() Assert.Equal("TestValue1", config["TestKey1"]); FirstKeyValue.Value = "newValue1"; - Thread.Sleep(CacheExpirationTime); + Thread.Sleep(RefreshInterval); await refresher.TryRefreshAsync(); Assert.Equal("newValue1", config["TestKey1"]); @@ -473,7 +473,7 @@ public async Task ValidateCorrectEndpointLoggedOnConfigurationUpdate() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label", true) - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); refresher = options.GetRefresher(); @@ -482,7 +482,7 @@ public async Task ValidateCorrectEndpointLoggedOnConfigurationUpdate() FirstKeyValue.Value = "newValue1"; - Thread.Sleep(CacheExpirationTime); + Thread.Sleep(RefreshInterval); await refresher.TryRefreshAsync(); // We should see the second client's endpoint logged since the first client is backed off @@ -520,7 +520,7 @@ public async Task ValidateCorrectKeyValueLoggedDuringRefresh() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label", false).Register("TestKey2", false) - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); refresher = options.GetRefresher(); @@ -530,7 +530,7 @@ public async Task ValidateCorrectKeyValueLoggedDuringRefresh() Assert.Equal("TestValue1", config["TestKey1"]); FirstKeyValue.Value = "newValue1"; - Thread.Sleep(CacheExpirationTime); + Thread.Sleep(RefreshInterval); await refresher.TryRefreshAsync(); Assert.Equal("newValue1", config["TestKey1"]); @@ -580,7 +580,7 @@ public async Task ValidateCorrectKeyVaultSecretLoggedDuringRefresh() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label", true) - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); options.ConfigureKeyVault(kv => kv.Register(mockSecretClient.Object).SetSecretRefreshInterval(TimeSpan.FromSeconds(60))); refresher = options.GetRefresher(); @@ -594,7 +594,7 @@ public async Task ValidateCorrectKeyVaultSecretLoggedDuringRefresh() ""uri"":""https://keyvault-theclassics.vault.azure.net/secrets/Password3/6db5a48680104dda9097b1e6d859e553"" } "; - Thread.Sleep(CacheExpirationTime); + Thread.Sleep(RefreshInterval); await refresher.TryRefreshAsync(); Assert.Contains(LogHelper.BuildKeyVaultSecretReadMessage(_kvr.Key, _kvr.Label), verboseInvocation); Assert.Contains(LogHelper.BuildKeyVaultSettingUpdatedMessage(_kvr.Key), informationalInvocation); diff --git a/tests/Tests.AzureAppConfiguration/MapTests.cs b/tests/Tests.AzureAppConfiguration/MapTests.cs index 56e9fc7a..623ae477 100644 --- a/tests/Tests.AzureAppConfiguration/MapTests.cs +++ b/tests/Tests.AzureAppConfiguration/MapTests.cs @@ -51,7 +51,7 @@ public class MapTests ConfigurationSetting FirstKeyValue => _kvCollection.First(); ConfigurationSetting sentinelKv = new ConfigurationSetting("SentinelKey", "SentinelValue"); - TimeSpan CacheExpirationTime = TimeSpan.FromSeconds(1); + TimeSpan RefreshInterval = TimeSpan.FromSeconds(1); string _certValue = "Certificate Value from KeyVault"; string _secretValue = "SecretValue from KeyVault"; @@ -150,7 +150,7 @@ public async Task MapTransformWithRefresh() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label", true) - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); options.Map((setting) => { @@ -183,7 +183,7 @@ public async Task MapTransformWithRefresh() FirstKeyValue.Value = "newValue1"; - Thread.Sleep(CacheExpirationTime); + Thread.Sleep(RefreshInterval); await refresher.TryRefreshAsync(); Assert.Equal("newValue1 mapped first", config["TestKey1"]); @@ -205,7 +205,7 @@ public async Task MapTransformSettingKeyWithRefresh() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label", true) - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); options.Map((setting) => { @@ -235,7 +235,7 @@ public async Task MapTransformSettingKeyWithRefresh() FirstKeyValue.Value = "newValue1"; _kvCollection.Last().Value = "newValue2"; - Thread.Sleep(CacheExpirationTime); + Thread.Sleep(RefreshInterval); await refresher.TryRefreshAsync(); Assert.Equal("newValue1 changed", config["newTestKey1"]); @@ -257,7 +257,7 @@ public async Task MapTransformSettingLabelWithRefresh() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label", true) - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); options.Map((setting) => { @@ -285,7 +285,7 @@ public async Task MapTransformSettingLabelWithRefresh() FirstKeyValue.Value = "newValue1"; _kvCollection.Last().Value = "newValue2"; - Thread.Sleep(CacheExpirationTime); + Thread.Sleep(RefreshInterval); await refresher.TryRefreshAsync(); Assert.Equal("newValue1 changed", config["TestKey1"]); @@ -307,7 +307,7 @@ public async Task MapTransformSettingCreateDuplicateKeyWithRefresh() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label", true) - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); options.Map((setting) => { @@ -335,7 +335,7 @@ public async Task MapTransformSettingCreateDuplicateKeyWithRefresh() FirstKeyValue.Value = "newValue1"; - Thread.Sleep(CacheExpirationTime); + Thread.Sleep(RefreshInterval); await refresher.TryRefreshAsync(); Assert.Equal("TestValue2 changed", config["TestKey2"]); @@ -357,7 +357,7 @@ public async Task MapCreateNewSettingWithRefresh() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label", true) - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); options.Map((setting) => { @@ -381,7 +381,7 @@ public async Task MapCreateNewSettingWithRefresh() Assert.Equal("TestValue2", config["TestKey2"]); FirstKeyValue.Value = "newValue1"; - Thread.Sleep(CacheExpirationTime); + Thread.Sleep(RefreshInterval); await refresher.TryRefreshAsync(); Assert.Equal("mappedValue1", config["TestKey1"]); @@ -496,7 +496,7 @@ public async Task MapTransformSettingKeyWithLogAndRefresh() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label", true) - .SetCacheExpiration(CacheExpirationTime); + .SetRefreshInterval(RefreshInterval); }); options.Map((setting) => { @@ -526,7 +526,7 @@ public async Task MapTransformSettingKeyWithLogAndRefresh() FirstKeyValue.Value = "newValue1"; _kvCollection.Last().Value = "newValue2"; - Thread.Sleep(CacheExpirationTime); + Thread.Sleep(RefreshInterval); await refresher.TryRefreshAsync(); Assert.Equal("newValue1 changed", config["newTestKey1"]); diff --git a/tests/Tests.AzureAppConfiguration/PushRefreshTests.cs b/tests/Tests.AzureAppConfiguration/PushRefreshTests.cs index 38b8c70f..c4c7c38c 100644 --- a/tests/Tests.AzureAppConfiguration/PushRefreshTests.cs +++ b/tests/Tests.AzureAppConfiguration/PushRefreshTests.cs @@ -267,7 +267,7 @@ public void ProcessPushNotificationThrowsArgumentExceptions() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromDays(30)); + .SetRefreshInterval(TimeSpan.FromDays(30)); }); refresher = options.GetRefresher(); }) @@ -303,7 +303,7 @@ public async Task SyncTokenUpdatesCorrectNumberOfTimes() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromDays(30)); + .SetRefreshInterval(TimeSpan.FromDays(30)); }); refresher = options.GetRefresher(); }) @@ -335,12 +335,12 @@ public async Task RefreshAsyncUpdatesConfig() var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => { - options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); ; options.Select("*"); options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromDays(30)); + .SetRefreshInterval(TimeSpan.FromDays(30)); }); refresher = options.GetRefresher(); }) diff --git a/tests/Tests.AzureAppConfiguration/RefreshTests.cs b/tests/Tests.AzureAppConfiguration/RefreshTests.cs index 25c88f99..6edc1a9a 100644 --- a/tests/Tests.AzureAppConfiguration/RefreshTests.cs +++ b/tests/Tests.AzureAppConfiguration/RefreshTests.cs @@ -97,7 +97,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o options.ConfigureRefresh(refresh => { refresh.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(60)); + .SetRefreshInterval(TimeSpan.FromSeconds(60)); }); }) .Build(); @@ -119,7 +119,7 @@ public void RefreshTests_RefreshRegisteredKeysAreLoadedOnStartup_CustomUseQuery( { refreshOptions.Register("TestKey2", "label") .Register("TestKey3", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(60)); + .SetRefreshInterval(TimeSpan.FromSeconds(60)); }); }) .Build(); @@ -142,7 +142,7 @@ public async Task RefreshTests_RefreshIsSkippedIfCacheIsNotExpired() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(10)); + .SetRefreshInterval(TimeSpan.FromSeconds(10)); }); refresher = options.GetRefresher(); @@ -171,7 +171,7 @@ public async Task RefreshTests_RefreshIsSkippedIfKvNotInSelectAndCacheIsNotExpir options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(10)); + .SetRefreshInterval(TimeSpan.FromSeconds(10)); }); refresher = options.GetRefresher(); @@ -200,7 +200,7 @@ public async Task RefreshTests_RefreshIsNotSkippedIfCacheIsExpired() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -233,7 +233,7 @@ public async Task RefreshTests_RefreshAllFalseDoesNotUpdateEntireConfiguration() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") // refreshAll: false - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -271,7 +271,7 @@ public async Task RefreshTests_RefreshAllTrueUpdatesEntireConfiguration() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label", refreshAll: true) - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -342,7 +342,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label", refreshAll: true) - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -415,7 +415,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o { refreshOptions.Register("TestKey1", "label") .Register("NonExistentKey", refreshAll: true) - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -489,7 +489,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -526,7 +526,7 @@ public void RefreshTests_RefreshAsyncThrowsOnRequestFailedException() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -562,7 +562,7 @@ public async Task RefreshTests_TryRefreshAsyncReturnsFalseOnRequestFailedExcepti options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -598,7 +598,7 @@ public async Task RefreshTests_TryRefreshAsyncUpdatesConfigurationAndReturnsTrue options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -641,7 +641,7 @@ public async Task RefreshTests_TryRefreshAsyncReturnsFalseForAuthenticationFaile options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -694,7 +694,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label", refreshAll: true) - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -736,7 +736,7 @@ public async Task RefreshTests_UpdatesAllSettingsIfInitialLoadFails() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -812,7 +812,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label", refreshAll: true) - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -862,7 +862,7 @@ public async Task RefreshTests_RefreshAllTrueForOverwrittenSentinelUpdatesEntire { refreshOptions.Register("TestKeyWithMultipleLabels", "label1", refreshAll: true) .Register("TestKeyWithMultipleLabels", "label2") - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -904,7 +904,7 @@ public async Task RefreshTests_RefreshAllFalseForOverwrittenSentinelUpdatesConfi { refreshOptions.Register("TestKeyWithMultipleLabels", "label1") .Register("TestKeyWithMultipleLabels", "label2") - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -946,7 +946,7 @@ public async Task RefreshTests_RefreshRegisteredKvOverwritesSelectedKv() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKeyWithMultipleLabels", "label1") - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -1014,7 +1014,7 @@ public void RefreshTests_ConfigureRefreshThrowsOnNoRegistration() { options.ConfigureRefresh(refreshOptions => { - refreshOptions.SetCacheExpiration(TimeSpan.FromSeconds(1)); + refreshOptions.SetRefreshInterval(TimeSpan.FromSeconds(1)); }); }) .Build(); @@ -1035,7 +1035,7 @@ public void RefreshTests_RefreshIsCancelled() options.ConfigureRefresh(refreshOptions => { refreshOptions.Register("TestKey1", "label") - .SetCacheExpiration(TimeSpan.FromSeconds(1)); + .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); @@ -1056,7 +1056,7 @@ public void RefreshTests_RefreshIsCancelled() Assert.Equal("TestValue1", config["TestKey1"]); } -#if NET7_0 +#if NET8_0 [Fact] public void RefreshTests_ChainedConfigurationProviderUsedAsRootForRefresherProvider() { diff --git a/tests/Tests.AzureAppConfiguration/Tests.AzureAppConfiguration.csproj b/tests/Tests.AzureAppConfiguration/Tests.AzureAppConfiguration.csproj index 8849645f..49c58ec6 100644 --- a/tests/Tests.AzureAppConfiguration/Tests.AzureAppConfiguration.csproj +++ b/tests/Tests.AzureAppConfiguration/Tests.AzureAppConfiguration.csproj @@ -1,7 +1,7 @@ - net48;net6.0;net7.0;net8.0 + net48;net6.0;net8.0 8.0 false true