diff --git a/all.sln b/all.sln index cd6213beb..5f3dded71 100644 --- a/all.sln +++ b/all.sln @@ -1,5 +1,4 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.3.32929.385 MinimumVisualStudioVersion = 10.0.40219.1 @@ -273,6 +272,7 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SecretManagement", "SecretManagement", "{929A8AD2-DB45-B92A-7930-EBDD2DBAF802}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SecretManagementSample", "examples\SecretManagement\SecretManagementSample\SecretManagementSample.csproj", "{ED74B33F-3CE7-42EB-BAA1-623F43900B15}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.AI.Microsoft.Extensions.Test", "test\Dapr.AI.Microsoft.Extensions.Test\Dapr.AI.Microsoft.Extensions.Test.csproj", "{86CBB08F-601A-4B0B-87BF-383D391A961C}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.DistributedLock.Test", "test\Dapr.DistributedLock.Test\Dapr.DistributedLock.Test.csproj", "{2B8E9CAD-F9A2-43B9-BB1C-619CF64476A0}" @@ -297,6 +297,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.Common.Generators.Test EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.IntegrationTest.DaprClient", "test\Dapr.IntegrationTest.DaprClient\Dapr.IntegrationTest.DaprClient.csproj", "{7DB95C53-19C1-49D2-B0BA-116DAEFC83DB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.IntegrationTest.Configuration", "test\Dapr.IntegrationTest.Configuration\Dapr.IntegrationTest.Configuration.csproj", "{193BBE09-E261-4D65-B4CC-18B88DB049D7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1495,6 +1497,22 @@ Global {97CAEE0B-4020-4A86-97DA-9900FDF4DFC6}.Release|x64.Build.0 = Release|Any CPU {97CAEE0B-4020-4A86-97DA-9900FDF4DFC6}.Release|x86.ActiveCfg = Release|Any CPU {97CAEE0B-4020-4A86-97DA-9900FDF4DFC6}.Release|x86.Build.0 = Release|Any CPU + {193BBE09-E261-4D65-B4CC-18B88DB049D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {193BBE09-E261-4D65-B4CC-18B88DB049D7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {193BBE09-E261-4D65-B4CC-18B88DB049D7}.Debug|x64.ActiveCfg = Debug|Any CPU + {193BBE09-E261-4D65-B4CC-18B88DB049D7}.Debug|x64.Build.0 = Debug|Any CPU + {193BBE09-E261-4D65-B4CC-18B88DB049D7}.Debug|x86.ActiveCfg = Debug|Any CPU + {193BBE09-E261-4D65-B4CC-18B88DB049D7}.Debug|x86.Build.0 = Debug|Any CPU + {193BBE09-E261-4D65-B4CC-18B88DB049D7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {193BBE09-E261-4D65-B4CC-18B88DB049D7}.Release|Any CPU.Build.0 = Release|Any CPU + {193BBE09-E261-4D65-B4CC-18B88DB049D7}.Release|x64.ActiveCfg = Release|Any CPU + {193BBE09-E261-4D65-B4CC-18B88DB049D7}.Release|x64.Build.0 = Release|Any CPU + {193BBE09-E261-4D65-B4CC-18B88DB049D7}.Release|x86.ActiveCfg = Release|Any CPU + {193BBE09-E261-4D65-B4CC-18B88DB049D7}.Release|x86.Build.0 = Release|Any CPU + {97CAEE0B-4020-4A86-97DA-9900FDF4DFC6}.Release|x64.ActiveCfg = Release|Any CPU + {97CAEE0B-4020-4A86-97DA-9900FDF4DFC6}.Release|x64.Build.0 = Release|Any CPU + {97CAEE0B-4020-4A86-97DA-9900FDF4DFC6}.Release|x86.ActiveCfg = Release|Any CPU + {97CAEE0B-4020-4A86-97DA-9900FDF4DFC6}.Release|x86.Build.0 = Release|Any CPU {01A20A89-53A1-4D5B-B563-89E157718474}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {01A20A89-53A1-4D5B-B563-89E157718474}.Debug|Any CPU.Build.0 = Debug|Any CPU {01A20A89-53A1-4D5B-B563-89E157718474}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -1886,7 +1904,6 @@ Global {F99CE5A1-FDA1-415C-B1E6-C8787734ACD2} = {27C5D71D-0721-4221-9286-B94AB07B58CF} {B42DD6AA-255C-4606-8A1B-263B26650DED} = {27C5D71D-0721-4221-9286-B94AB07B58CF} {1BECAC48-1C83-43D2-A91F-9AFA634A68C7} = {27C5D71D-0721-4221-9286-B94AB07B58CF} - {87842296-C78B-42B9-8E96-5054E79CD700} = {DD020B34-460F-455F-8D17-CF4A949F100B} {929A8AD2-DB45-B92A-7930-EBDD2DBAF802} = {D687DDC4-66C5-4667-9E3A-FD8B78ECAA78} {ED74B33F-3CE7-42EB-BAA1-623F43900B15} = {929A8AD2-DB45-B92A-7930-EBDD2DBAF802} {8777EAD2-419B-4683-826A-82B7C1F4F69F} = {8462B106-175A-423A-BA94-BE0D39D0BD8E} @@ -1905,6 +1922,9 @@ Global {DA1F9FE7-6041-4581-B9AD-685034869CE8} = {27C5D71D-0721-4221-9286-B94AB07B58CF} {E015C5ED-F93F-4DB6-BA01-BC59B7859A60} = {0AF0FE8D-C234-4F04-8514-32206ACE01BD} {7DB95C53-19C1-49D2-B0BA-116DAEFC83DB} = {8462B106-175A-423A-BA94-BE0D39D0BD8E} + {193BBE09-E261-4D65-B4CC-18B88DB049D7} = {8462B106-175A-423A-BA94-BE0D39D0BD8E} + {87842296-C78B-42B9-8E96-5054E79CD700} = {0AF0FE8D-C234-4F04-8514-32206ACE01BD} + {A3F7C8B2-1D45-4E92-B5F6-3C8D9E0A2F71} = {27C5D71D-0721-4221-9286-B94AB07B58CF} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {65220BF2-EAE1-4CB2-AA58-EBE80768CB40} diff --git a/src/Dapr.Extensions.Configuration/DaprConfigurationStoreExtension.cs b/src/Dapr.Extensions.Configuration/DaprConfigurationStoreExtension.cs index f0910ad0b..428419710 100644 --- a/src/Dapr.Extensions.Configuration/DaprConfigurationStoreExtension.cs +++ b/src/Dapr.Extensions.Configuration/DaprConfigurationStoreExtension.cs @@ -34,6 +34,7 @@ public static class DaprConfigurationStoreExtension /// The used for the request. /// The used to configure the timeout waiting for Dapr. /// Optional metadata sent to the configuration store. + /// When true, does not block startup waiting for the sidecar. Configuration is loaded in the background once the sidecar becomes available. /// The . public static IConfigurationBuilder AddDaprConfigurationStore( this IConfigurationBuilder configurationBuilder, @@ -41,7 +42,8 @@ public static IConfigurationBuilder AddDaprConfigurationStore( IReadOnlyList keys, DaprClient client, TimeSpan sidecarWaitTimeout, - IReadOnlyDictionary? metadata = default) + IReadOnlyDictionary? metadata = default, + bool optional = false) { ArgumentVerifier.ThrowIfNullOrEmpty(store, nameof(store)); ArgumentVerifier.ThrowIfNull(keys, nameof(keys)); @@ -54,7 +56,8 @@ public static IConfigurationBuilder AddDaprConfigurationStore( Client = client, SidecarWaitTimeout = sidecarWaitTimeout, IsStreaming = false, - Metadata = metadata + Metadata = metadata, + IsOptional = optional }); return configurationBuilder; @@ -71,6 +74,7 @@ public static IConfigurationBuilder AddDaprConfigurationStore( /// The used for the request. /// The used to configure the timeout waiting for Dapr. /// Optional metadata sent to the configuration store. + /// When true, does not block startup waiting for the sidecar. Configuration is loaded in the background once the sidecar becomes available. /// The . public static IConfigurationBuilder AddStreamingDaprConfigurationStore( this IConfigurationBuilder configurationBuilder, @@ -78,7 +82,8 @@ public static IConfigurationBuilder AddStreamingDaprConfigurationStore( IReadOnlyList keys, DaprClient client, TimeSpan sidecarWaitTimeout, - IReadOnlyDictionary? metadata = default) + IReadOnlyDictionary? metadata = default, + bool optional = false) { ArgumentVerifier.ThrowIfNullOrEmpty(store, nameof(store)); ArgumentVerifier.ThrowIfNull(keys, nameof(keys)); @@ -91,7 +96,8 @@ public static IConfigurationBuilder AddStreamingDaprConfigurationStore( Client = client, SidecarWaitTimeout = sidecarWaitTimeout, IsStreaming = true, - Metadata = metadata + Metadata = metadata, + IsOptional = optional }); return configurationBuilder; diff --git a/src/Dapr.Extensions.Configuration/DaprConfigurationStoreProvider.cs b/src/Dapr.Extensions.Configuration/DaprConfigurationStoreProvider.cs index 3e859b8d0..2fc9df9c2 100644 --- a/src/Dapr.Extensions.Configuration/DaprConfigurationStoreProvider.cs +++ b/src/Dapr.Extensions.Configuration/DaprConfigurationStoreProvider.cs @@ -16,6 +16,7 @@ using System.Threading; using System.Threading.Tasks; using Dapr.Client; +using Grpc.Core; using Microsoft.Extensions.Configuration; namespace Dapr.Extensions.Configuration; @@ -26,14 +27,19 @@ namespace Dapr.Extensions.Configuration; /// internal class DaprConfigurationStoreProvider : ConfigurationProvider, IDisposable { - private string store; - private IReadOnlyList keys; - private DaprClient daprClient; - private TimeSpan sidecarWaitTimeout; - private bool isStreaming; - private IReadOnlyDictionary? metadata; - private CancellationTokenSource cts; + private static readonly TimeSpan DisposeWaitTimeout = TimeSpan.FromSeconds(1); + + private readonly string store; + private readonly IReadOnlyList keys; + private readonly DaprClient daprClient; + private readonly TimeSpan sidecarWaitTimeout; + private readonly bool isStreaming; + private readonly bool isOptional; + private readonly IReadOnlyDictionary? metadata; + private readonly CancellationTokenSource cts; + private Task loadTask = Task.CompletedTask; private Task subscribeTask = Task.CompletedTask; + private int disposed; /// /// Constructor. @@ -44,30 +50,108 @@ internal class DaprConfigurationStoreProvider : ConfigurationProvider, IDisposab /// The used to configure the timeout waiting for Dapr. /// Determines if the source is streaming or not. /// Optional metadata sent to the configuration store. + /// When true, does not block startup waiting for the sidecar. public DaprConfigurationStoreProvider( string store, IReadOnlyList keys, DaprClient daprClient, TimeSpan sidecarWaitTimeout, bool isStreaming = false, - IReadOnlyDictionary? metadata = default) + IReadOnlyDictionary? metadata = default, + bool isOptional = false) { this.store = store; this.keys = keys; this.daprClient = daprClient; this.sidecarWaitTimeout = sidecarWaitTimeout; this.isStreaming = isStreaming; + this.isOptional = isOptional; this.metadata = metadata ?? new Dictionary(); this.cts = new CancellationTokenSource(); } public void Dispose() { + if (Interlocked.Exchange(ref disposed, 1) != 0) + { + return; + } + cts.Cancel(); + + var loadTaskCompleted = WaitForBackgroundTask(loadTask); + var subscribeTaskCompleted = WaitForBackgroundTask(subscribeTask); + if (loadTaskCompleted && subscribeTaskCompleted) + { + // Only dispose the CTS after tracked tasks have stopped using cts.Token. + cts.Dispose(); + } } /// - public override void Load() => LoadAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + public override void Load() + { + if (isOptional) + { + Data = new Dictionary(StringComparer.OrdinalIgnoreCase); + loadTask = Task.Run(() => LoadInBackgroundAsync()); + } + else + { + LoadAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + } + } + + private static bool WaitForBackgroundTask(Task task) + { + try + { + return task.Wait(DisposeWaitTimeout); + } + catch + { + // Observe background task exceptions during disposal. + return true; + } + } + + private async Task LoadInBackgroundAsync() + { + while (!cts.Token.IsCancellationRequested) + { + try + { + using var tokenSource = new CancellationTokenSource(sidecarWaitTimeout); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(tokenSource.Token, cts.Token); + await daprClient.WaitForSidecarAsync(linked.Token); + + await FetchDataAsync(); + OnReload(); + return; + } + catch (OperationCanceledException) when (cts.Token.IsCancellationRequested) + { + return; + } + catch (OperationCanceledException) + { + // Sidecar wait timed out — retry after delay. + } + catch (DaprException) + { + // Transient Dapr error — retry after delay. + } + + try + { + await Task.Delay(sidecarWaitTimeout, cts.Token); + } + catch (OperationCanceledException) + { + return; + } + } + } private async Task LoadAsync() { @@ -77,6 +161,11 @@ private async Task LoadAsync() await daprClient.WaitForSidecarAsync(tokenSource.Token); } + await FetchDataAsync(); + } + + private async Task FetchDataAsync() + { if (isStreaming) { subscribeTask = Task.Run(async () => @@ -100,12 +189,43 @@ private async Task LoadAsync() OnReload(); } } - catch (Exception) + catch (OperationCanceledException) when (cts.Token.IsCancellationRequested) + { + return; + } + catch (RpcException ex) when (cts.Token.IsCancellationRequested && ex.StatusCode == StatusCode.Cancelled) + { + return; + } + catch (Exception ex) when (ex is DaprException or RpcException) { - // If we catch an exception, try and cancel the subscription so we can connect again. if (!string.IsNullOrEmpty(id)) { - await daprClient.UnsubscribeConfiguration(store, id); + try + { + await daprClient.UnsubscribeConfiguration(store, id, cts.Token); + } + catch (OperationCanceledException) when (cts.Token.IsCancellationRequested) + { + return; + } + catch (RpcException unsubscribeException) when (cts.Token.IsCancellationRequested && unsubscribeException.StatusCode == StatusCode.Cancelled) + { + return; + } + catch (Exception unsubscribeException) when (unsubscribeException is DaprException or RpcException) + { + // Ignore transient unsubscribe failures and reconnect after the retry delay. + } + } + + try + { + await Task.Delay(sidecarWaitTimeout, cts.Token); + } + catch (OperationCanceledException) when (cts.Token.IsCancellationRequested) + { + return; } } } @@ -115,10 +235,13 @@ private async Task LoadAsync() { // We don't need to worry about ReloadTokens here because it is a constant response. var getConfigurationResponse = await daprClient.GetConfiguration(store, keys, metadata, cts.Token); + var data = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var item in getConfigurationResponse.Items) { - Set(item.Key, item.Value.Value); + data[item.Key] = item.Value.Value; } + + Data = data; } } } diff --git a/src/Dapr.Extensions.Configuration/DaprConfigurationStoreSource.cs b/src/Dapr.Extensions.Configuration/DaprConfigurationStoreSource.cs index 513e156b0..86012af5e 100644 --- a/src/Dapr.Extensions.Configuration/DaprConfigurationStoreSource.cs +++ b/src/Dapr.Extensions.Configuration/DaprConfigurationStoreSource.cs @@ -53,9 +53,16 @@ public class DaprConfigurationStoreSource : IConfigurationSource /// public IReadOnlyDictionary? Metadata { get; set; } = default; + /// + /// Gets or sets a value indicating whether this configuration source is optional. + /// When true, the provider will not block startup waiting for the Dapr sidecar and will + /// instead load configuration in the background once the sidecar becomes available. + /// + public bool IsOptional { get; set; } + /// public IConfigurationProvider Build(IConfigurationBuilder builder) { - return new DaprConfigurationStoreProvider(Store, Keys, Client, SidecarWaitTimeout, IsStreaming, Metadata); + return new DaprConfigurationStoreProvider(Store, Keys, Client, SidecarWaitTimeout, IsStreaming, Metadata, IsOptional); } -} \ No newline at end of file +} diff --git a/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationExtensions.cs b/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationExtensions.cs index 7df531e3e..0662ebe3e 100644 --- a/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationExtensions.cs +++ b/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationExtensions.cs @@ -32,12 +32,14 @@ public static class DaprSecretStoreConfigurationExtensions /// Dapr secret store name. /// The secrets to retrieve. /// The Dapr client + /// When true, does not block startup waiting for the sidecar. Secrets are loaded in the background once the sidecar becomes available. /// The . public static IConfigurationBuilder AddDaprSecretStore( this IConfigurationBuilder configurationBuilder, string store, IEnumerable secretDescriptors, - DaprClient client) + DaprClient client, + bool optional = false) { ArgumentVerifier.ThrowIfNullOrEmpty(store, nameof(store)); ArgumentVerifier.ThrowIfNull(secretDescriptors, nameof(secretDescriptors)); @@ -47,7 +49,8 @@ public static IConfigurationBuilder AddDaprSecretStore( { Store = store, SecretDescriptors = secretDescriptors, - Client = client + Client = client, + IsOptional = optional }); return configurationBuilder; @@ -61,13 +64,15 @@ public static IConfigurationBuilder AddDaprSecretStore( /// The secrets to retrieve. /// The Dapr client. /// The used to configure the timeout waiting for Dapr. + /// When true, does not block startup waiting for the sidecar. Secrets are loaded in the background once the sidecar becomes available. /// The . public static IConfigurationBuilder AddDaprSecretStore( this IConfigurationBuilder configurationBuilder, string store, IEnumerable secretDescriptors, DaprClient client, - TimeSpan sidecarWaitTimeout) + TimeSpan sidecarWaitTimeout, + bool optional = false) { ArgumentVerifier.ThrowIfNullOrEmpty(store, nameof(store)); ArgumentVerifier.ThrowIfNull(secretDescriptors, nameof(secretDescriptors)); @@ -78,7 +83,8 @@ public static IConfigurationBuilder AddDaprSecretStore( Store = store, SecretDescriptors = secretDescriptors, Client = client, - SidecarWaitTimeout = sidecarWaitTimeout + SidecarWaitTimeout = sidecarWaitTimeout, + IsOptional = optional }); return configurationBuilder; @@ -89,14 +95,16 @@ public static IConfigurationBuilder AddDaprSecretStore( /// /// The to add to. /// Dapr secret store name. - /// A collection of metadata key-value pairs that will be provided to the secret store. The valid metadata keys and values are determined by the type of secret store used. /// The Dapr client + /// A collection of metadata key-value pairs that will be provided to the secret store. The valid metadata keys and values are determined by the type of secret store used. + /// When true, does not block startup waiting for the sidecar. Secrets are loaded in the background once the sidecar becomes available. /// The . public static IConfigurationBuilder AddDaprSecretStore( this IConfigurationBuilder configurationBuilder, string store, DaprClient client, - IReadOnlyDictionary? metadata = null) + IReadOnlyDictionary? metadata = null, + bool optional = false) { ArgumentVerifier.ThrowIfNullOrEmpty(store, nameof(store)); ArgumentVerifier.ThrowIfNull(client, nameof(client)); @@ -105,7 +113,8 @@ public static IConfigurationBuilder AddDaprSecretStore( { Store = store, Metadata = metadata, - Client = client + Client = client, + IsOptional = optional }); return configurationBuilder; @@ -116,16 +125,18 @@ public static IConfigurationBuilder AddDaprSecretStore( /// /// The to add to. /// Dapr secret store name. - /// A collection of metadata key-value pairs that will be provided to the secret store. The valid metadata keys and values are determined by the type of secret store used. /// The Dapr client /// The used to configure the timeout waiting for Dapr. + /// A collection of metadata key-value pairs that will be provided to the secret store. The valid metadata keys and values are determined by the type of secret store used. + /// When true, does not block startup waiting for the sidecar. Secrets are loaded in the background once the sidecar becomes available. /// The . public static IConfigurationBuilder AddDaprSecretStore( this IConfigurationBuilder configurationBuilder, string store, DaprClient client, TimeSpan sidecarWaitTimeout, - IReadOnlyDictionary? metadata = null) + IReadOnlyDictionary? metadata = null, + bool optional = false) { ArgumentVerifier.ThrowIfNullOrEmpty(store, nameof(store)); ArgumentVerifier.ThrowIfNull(client, nameof(client)); @@ -135,7 +146,8 @@ public static IConfigurationBuilder AddDaprSecretStore( Store = store, Metadata = metadata, Client = client, - SidecarWaitTimeout = sidecarWaitTimeout + SidecarWaitTimeout = sidecarWaitTimeout, + IsOptional = optional }); return configurationBuilder; @@ -146,14 +158,16 @@ public static IConfigurationBuilder AddDaprSecretStore( /// /// The to add to. /// Dapr secret store name. - /// A collection of delimiters that will be replaced by ':' in the key of every secret. /// The Dapr client + /// A collection of delimiters that will be replaced by ':' in the key of every secret. + /// When true, does not block startup waiting for the sidecar. Secrets are loaded in the background once the sidecar becomes available. /// The . public static IConfigurationBuilder AddDaprSecretStore( this IConfigurationBuilder configurationBuilder, string store, DaprClient client, - IEnumerable? keyDelimiters) + IEnumerable? keyDelimiters, + bool optional = false) { ArgumentVerifier.ThrowIfNullOrEmpty(store, nameof(store)); ArgumentVerifier.ThrowIfNull(client, nameof(client)); @@ -161,7 +175,8 @@ public static IConfigurationBuilder AddDaprSecretStore( var source = new DaprSecretStoreConfigurationSource { Store = store, - Client = client + Client = client, + IsOptional = optional }; if (keyDelimiters != null) @@ -179,16 +194,18 @@ public static IConfigurationBuilder AddDaprSecretStore( /// /// The to add to. /// Dapr secret store name. - /// A collection of delimiters that will be replaced by ':' in the key of every secret. /// The Dapr client + /// A collection of delimiters that will be replaced by ':' in the key of every secret. /// The used to configure the timeout waiting for Dapr. + /// When true, does not block startup waiting for the sidecar. Secrets are loaded in the background once the sidecar becomes available. /// The . public static IConfigurationBuilder AddDaprSecretStore( this IConfigurationBuilder configurationBuilder, string store, DaprClient client, IEnumerable? keyDelimiters, - TimeSpan sidecarWaitTimeout) + TimeSpan sidecarWaitTimeout, + bool optional = false) { ArgumentVerifier.ThrowIfNullOrEmpty(store, nameof(store)); ArgumentVerifier.ThrowIfNull(client, nameof(client)); @@ -197,7 +214,8 @@ public static IConfigurationBuilder AddDaprSecretStore( { Store = store, Client = client, - SidecarWaitTimeout = sidecarWaitTimeout + SidecarWaitTimeout = sidecarWaitTimeout, + IsOptional = optional }; if (keyDelimiters != null) @@ -218,4 +236,4 @@ public static IConfigurationBuilder AddDaprSecretStore( /// The . public static IConfigurationBuilder AddDaprSecretStore(this IConfigurationBuilder configurationBuilder, Action configureSource) => configurationBuilder.Add(configureSource); -} \ No newline at end of file +} diff --git a/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationProvider.cs b/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationProvider.cs index 31aa5b1fc..fe2b25ce4 100644 --- a/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationProvider.cs +++ b/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationProvider.cs @@ -24,9 +24,10 @@ namespace Dapr.Extensions.Configuration.DaprSecretStore; /// /// A Dapr Secret Store based . /// -internal class DaprSecretStoreConfigurationProvider : ConfigurationProvider +internal class DaprSecretStoreConfigurationProvider : ConfigurationProvider, IDisposable { internal static readonly TimeSpan DefaultSidecarWaitTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan DisposeWaitTimeout = TimeSpan.FromSeconds(1); private readonly string store; @@ -42,6 +43,12 @@ internal class DaprSecretStoreConfigurationProvider : ConfigurationProvider private readonly TimeSpan sidecarWaitTimeout; + private readonly bool isOptional; + + private readonly CancellationTokenSource cts = new(); + private Task loadTask = Task.CompletedTask; + private int disposed; + /// /// Creates a new instance of . /// @@ -83,19 +90,22 @@ public DaprSecretStoreConfigurationProvider( /// The secrets to retrieve. /// Dapr client used to retrieve Secrets /// The used to configure the timeout waiting for Dapr. + /// When true, does not block startup waiting for the sidecar. public DaprSecretStoreConfigurationProvider( string store, bool normalizeKey, IList? keyDelimiters, IEnumerable secretDescriptors, DaprClient client, - TimeSpan sidecarWaitTimeout) + TimeSpan sidecarWaitTimeout, + bool isOptional = false) { ArgumentVerifier.ThrowIfNullOrEmpty(store, nameof(store)); ArgumentVerifier.ThrowIfNull(secretDescriptors, nameof(secretDescriptors)); ArgumentVerifier.ThrowIfNull(client, nameof(client)); - if (secretDescriptors.Count() == 0) + var daprSecretDescriptors = secretDescriptors.ToList(); + if (daprSecretDescriptors.Count == 0) { throw new ArgumentException("No secret descriptor was provided", nameof(secretDescriptors)); } @@ -103,9 +113,10 @@ public DaprSecretStoreConfigurationProvider( this.store = store; this.normalizeKey = normalizeKey; this.keyDelimiters = keyDelimiters; - this.secretDescriptors = secretDescriptors; + this.secretDescriptors = daprSecretDescriptors; this.client = client; this.sidecarWaitTimeout = sidecarWaitTimeout; + this.isOptional = isOptional; } /// @@ -149,13 +160,15 @@ public DaprSecretStoreConfigurationProvider( /// A collection of metadata key-value pairs that will be provided to the secret store. The valid metadata keys and values are determined by the type of secret store used. /// Dapr client used to retrieve Secrets /// The used to configure the timeout waiting for Dapr. + /// When true, does not block startup waiting for the sidecar. public DaprSecretStoreConfigurationProvider( string store, bool normalizeKey, IList? keyDelimiters, IReadOnlyDictionary? metadata, DaprClient client, - TimeSpan sidecarWaitTimeout) + TimeSpan sidecarWaitTimeout, + bool isOptional = false) { ArgumentVerifier.ThrowIfNullOrEmpty(store, nameof(store)); ArgumentVerifier.ThrowIfNull(client, nameof(client)); @@ -166,16 +179,31 @@ public DaprSecretStoreConfigurationProvider( this.metadata = metadata; this.client = client; this.sidecarWaitTimeout = sidecarWaitTimeout; + this.isOptional = isOptional; + } + + /// + public void Dispose() + { + if (Interlocked.Exchange(ref disposed, 1) != 0) + { + return; + } + + cts.Cancel(); + + if (WaitForBackgroundTask(loadTask)) + { + // Only dispose the CTS after tracked tasks have stopped using cts.Token. + cts.Dispose(); + } } private string NormalizeKey(string key) { if (this.keyDelimiters?.Count > 0) { - foreach (var keyDelimiter in this.keyDelimiters) - { - key = key.Replace(keyDelimiter, ConfigurationPath.KeyDelimiter); - } + key = this.keyDelimiters.Aggregate(key, (current, keyDelimiter) => current.Replace(keyDelimiter, ConfigurationPath.KeyDelimiter)); } return key; @@ -185,18 +213,85 @@ private string NormalizeKey(string key) /// Loads the configuration by calling the asynchronous LoadAsync method and blocking the calling /// thread until the operation is completed. /// - public override void Load() => LoadAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + public override void Load() + { + if (isOptional) + { + Data = new Dictionary(StringComparer.OrdinalIgnoreCase); + loadTask = Task.Run(LoadInBackgroundAsync); + } + else + { + LoadAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + } + } - private async Task LoadAsync() + private static bool WaitForBackgroundTask(Task task) { - var data = new Dictionary(StringComparer.InvariantCultureIgnoreCase); + try + { + return task.Wait(DisposeWaitTimeout); + } + catch + { + // Observe background task exceptions during disposal. + return true; + } + } + private async Task LoadInBackgroundAsync() + { + while (!cts.Token.IsCancellationRequested) + { + try + { + using var tokenSource = new CancellationTokenSource(sidecarWaitTimeout); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(tokenSource.Token, cts.Token); + await client.WaitForSidecarAsync(linked.Token); + + await FetchSecretsAsync(linked.Token); + OnReload(); + return; + } + catch (OperationCanceledException) when (cts.Token.IsCancellationRequested) + { + return; + } + catch (OperationCanceledException) + { + // Sidecar wait timed out — retry after delay. + } + catch (DaprException) + { + // Transient Dapr error — retry after delay. + } + + try + { + await Task.Delay(sidecarWaitTimeout, cts.Token); + } + catch (OperationCanceledException) + { + return; + } + } + } + + private async Task LoadAsync() + { // Wait for the Dapr Sidecar to report healthy before attempting to fetch secrets. using (var tokenSource = new CancellationTokenSource(sidecarWaitTimeout)) { await client.WaitForSidecarAsync(tokenSource.Token); } + await FetchSecretsAsync(); + } + + private async Task FetchSecretsAsync(CancellationToken cancellationToken = default) + { + var data = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (secretDescriptors != null) { foreach (var secretDescriptor in secretDescriptors) @@ -207,7 +302,7 @@ private async Task LoadAsync() try { result = await client - .GetSecretAsync(store, secretDescriptor.SecretKey, secretDescriptor.Metadata) + .GetSecretAsync(store, secretDescriptor.SecretKey, secretDescriptor.Metadata, cancellationToken) .ConfigureAwait(false); } catch (DaprException) @@ -238,25 +333,21 @@ private async Task LoadAsync() data.Add(normalizedKey, result[key]); } } - - Data = data; } else { - var result = await client.GetBulkSecretAsync(store, metadata).ConfigureAwait(false); - foreach (var key in result.Keys) + var result = await client.GetBulkSecretAsync(store, metadata, cancellationToken).ConfigureAwait(false); + foreach (KeyValuePair secret in result.Keys.SelectMany(key => result[key])) { - foreach (var secret in result[key]) + if (data.ContainsKey(secret.Key)) { - if (data.ContainsKey(secret.Key)) - { - throw new InvalidOperationException($"A duplicate key '{secret.Key}' was found in the secret store '{store}'. Please remove any duplicates from your secret store."); - } - - data.Add(normalizeKey ? NormalizeKey(secret.Key) : secret.Key, secret.Value); + throw new InvalidOperationException($"A duplicate key '{secret.Key}' was found in the secret store '{store}'. Please remove any duplicates from your secret store."); } + + data.Add(normalizeKey ? NormalizeKey(secret.Key) : secret.Key, secret.Value); } - Data = data; } + + Data = data; } } diff --git a/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationSource.cs b/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationSource.cs index de73c8518..d21dd4158 100644 --- a/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationSource.cs +++ b/src/Dapr.Extensions.Configuration/DaprSecretStoreConfigurationSource.cs @@ -59,6 +59,13 @@ public class DaprSecretStoreConfigurationSource : IConfigurationSource /// public TimeSpan? SidecarWaitTimeout { get; set; } + /// + /// Gets or sets a value indicating whether this configuration source is optional. + /// When true, the provider will not block startup waiting for the Dapr sidecar and will + /// instead load secrets in the background once the sidecar becomes available. + /// + public bool IsOptional { get; set; } + /// public IConfigurationProvider Build(IConfigurationBuilder builder) { @@ -69,11 +76,11 @@ public IConfigurationProvider Build(IConfigurationBuilder builder) throw new ArgumentException($"{nameof(Metadata)} must be null when {nameof(SecretDescriptors)} is set", nameof(Metadata)); } - return new DaprSecretStoreConfigurationProvider(Store, NormalizeKey, KeyDelimiters, SecretDescriptors, Client, SidecarWaitTimeout ?? DaprSecretStoreConfigurationProvider.DefaultSidecarWaitTimeout); + return new DaprSecretStoreConfigurationProvider(Store, NormalizeKey, KeyDelimiters, SecretDescriptors, Client, SidecarWaitTimeout ?? DaprSecretStoreConfigurationProvider.DefaultSidecarWaitTimeout, IsOptional); } else { - return new DaprSecretStoreConfigurationProvider(Store, NormalizeKey, KeyDelimiters, Metadata, Client, SidecarWaitTimeout ?? DaprSecretStoreConfigurationProvider.DefaultSidecarWaitTimeout); + return new DaprSecretStoreConfigurationProvider(Store, NormalizeKey, KeyDelimiters, Metadata, Client, SidecarWaitTimeout ?? DaprSecretStoreConfigurationProvider.DefaultSidecarWaitTimeout, IsOptional); } } } \ No newline at end of file diff --git a/src/Dapr.Testcontainers/Common/DaprHarnessBuilder.cs b/src/Dapr.Testcontainers/Common/DaprHarnessBuilder.cs index 139cd23c9..9a912832f 100644 --- a/src/Dapr.Testcontainers/Common/DaprHarnessBuilder.cs +++ b/src/Dapr.Testcontainers/Common/DaprHarnessBuilder.cs @@ -27,7 +27,7 @@ public sealed class DaprHarnessBuilder /// /// The Dapr container runtime options. /// - private DaprRuntimeOptions _options { get; set; } = new("1.17.0"); + private DaprRuntimeOptions _options { get; set; } = new("1.17.0-rc.3"); /// /// The isolated test environment to use with the harness, if any. /// @@ -92,6 +92,11 @@ public DaprHarnessBuilder WithEnvironment(DaprTestEnvironment environment) /// Builds a distributed lock harness. /// public DistributedLockHarness BuildDistributedLock() => new(_componentsDirectory, _startApp, _options, _environment); + + /// + /// Builds a secret store harness. + /// + public SecretStoreHarness BuildSecretStore() => new(_componentsDirectory, _startApp, _options, _environment); /// /// Builds a conversation harness. diff --git a/src/Dapr.Testcontainers/Harnesses/SecretStoreHarness.cs b/src/Dapr.Testcontainers/Harnesses/SecretStoreHarness.cs new file mode 100644 index 000000000..9afd20d48 --- /dev/null +++ b/src/Dapr.Testcontainers/Harnesses/SecretStoreHarness.cs @@ -0,0 +1,88 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Dapr.Testcontainers.Common.Options; + +namespace Dapr.Testcontainers.Harnesses; + +/// +/// Provides an implementation harness for Dapr's secret store building block using a local file-based secret store. +/// +public sealed class SecretStoreHarness : BaseHarness +{ + private readonly string componentsDir; + + /// + /// The name of the secret store component. + /// + public const string SecretStoreComponentName = "localsecretstore"; + + /// + /// Provides an implementation harness for Dapr's secret store building block. + /// + /// The directory to Dapr components. + /// The test app to validate in the harness. + /// The Dapr runtime options. + /// The isolated environment instance. + public SecretStoreHarness(string componentsDir, System.Func? startApp, DaprRuntimeOptions options, DaprTestEnvironment? environment = null) : base(componentsDir, startApp, options, environment) + { + this.componentsDir = componentsDir; + } + + /// + protected override Task OnInitializeAsync(CancellationToken cancellationToken) + { + WriteSecretsFile(componentsDir); + WriteComponentYaml(componentsDir); + return Task.CompletedTask; + } + + /// + /// Writes the secrets JSON file to the specified directory. + /// + public static void WriteSecretsFile(string folderPath, string fileName = "secrets.json") + { + Directory.CreateDirectory(folderPath); + var fullPath = Path.Combine(folderPath, fileName); + File.WriteAllText(fullPath, @"{ + ""secret1"": ""value1"", + ""secret2"": ""value2"" +}"); + } + + /// + /// Writes the component YAML file to the specified directory. + /// + public static void WriteComponentYaml(string folderPath, string fileName = "secretstore.yaml") + { + Directory.CreateDirectory(folderPath); + var fullPath = Path.Combine(folderPath, fileName); + File.WriteAllText(fullPath, $@"apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: {SecretStoreComponentName} + namespace: default +spec: + type: secretstores.local.file + version: v1 + metadata: + - name: secretsFile + value: /components/secrets.json + - name: nestedSeparator + value: "":"" +"); + } +} diff --git a/test/Dapr.Extensions.Configuration.Test/DaprConfigurationStoreProviderTest.cs b/test/Dapr.Extensions.Configuration.Test/DaprConfigurationStoreProviderTest.cs index 3042323e6..d3368594f 100644 --- a/test/Dapr.Extensions.Configuration.Test/DaprConfigurationStoreProviderTest.cs +++ b/test/Dapr.Extensions.Configuration.Test/DaprConfigurationStoreProviderTest.cs @@ -1,10 +1,14 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Net; +using System.Threading; using System.Threading.Tasks; using Dapr.Client; using Grpc.Net.Client; using Microsoft.Extensions.Configuration; +using Moq; +using Shouldly; using Xunit; using Autogenerated = Dapr.Client.Autogen.Grpc.v1; @@ -139,6 +143,48 @@ public async Task TestConfigurationStoreExtension_ProperlyStoresValues() Assert.Equal(item.Value, config["testKey"]); } + [Fact] + public void TestConfigurationStoreExtension_ReloadReplacesData() + { + var responses = new Queue(new[] + { + new GetConfigurationResponse( + new Dictionary + { + ["oldKey"] = new ConfigurationItem("oldValue", "v1", null) + }), + new GetConfigurationResponse( + new Dictionary + { + ["newKey"] = new ConfigurationItem("newValue", "v2", null) + }) + }); + + var daprClient = new Mock(); + daprClient + .Setup(c => c.WaitForSidecarAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + daprClient + .Setup(c => c.GetConfiguration( + "store", + It.IsAny>(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(() => responses.Dequeue()); + + var config = new ConfigurationBuilder() + .AddDaprConfigurationStore("store", new List(), daprClient.Object, TimeSpan.FromSeconds(5)) + .Build(); + + config["oldKey"].ShouldBe("oldValue"); + + config.Reload(); + + config["oldKey"].ShouldBeNull(); + config["newKey"].ShouldBe("newValue"); + } + [Fact] public async Task TestStreamingConfigurationStoreExtension_ProperlyStoresValues() { @@ -187,6 +233,255 @@ private async Task SendResponseWithConfiguration(Dictionary(); + daprClient + .Setup(c => c.WaitForSidecarAsync(It.IsAny())) + .Returns(ct => + Task.Delay(Timeout.Infinite, ct)); + + var config = new ConfigurationBuilder() + .AddDaprConfigurationStore("store", new List(), daprClient.Object, TimeSpan.FromSeconds(1), optional: true) + .Build(); + + // Build() should succeed immediately with no values + config["anyKey"].ShouldBeNull(); + } + + [Fact] + public async Task TestConfigurationStore_OptionalAndSidecarBecomesAvailable_PopulatesConfig() + { + var callCount = 0; + + var daprClient = new Mock(); + daprClient + .Setup(c => c.WaitForSidecarAsync(It.IsAny())) + .Returns(ct => + { + var current = Interlocked.Increment(ref callCount); + if (current <= 1) + { + return Task.Delay(Timeout.Infinite, ct); + } + return Task.CompletedTask; + }); + + var configResponse = new GetConfigurationResponse( + new Dictionary + { + ["testKey"] = new ConfigurationItem("testValue", "v1", null) + }); + + daprClient + .Setup(c => c.GetConfiguration( + "store", + It.IsAny>(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(configResponse); + + var config = new ConfigurationBuilder() + .AddDaprConfigurationStore("store", new List(), daprClient.Object, TimeSpan.FromMilliseconds(100), optional: true) + .Build(); + + // Initially empty + config["testKey"].ShouldBeNull(); + + // Wait for the reload token to fire, indicating background load completed + var reloaded = new TaskCompletionSource(); + config.GetReloadToken().RegisterChangeCallback(_ => reloaded.TrySetResult(true), null); + await reloaded.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + config["testKey"].ShouldBe("testValue"); + } + + [Fact] + public async Task TestConfigurationStore_OptionalBackgroundLoad_DoesNotPublishPartiallyBuiltData() + { + var firstItemEnumerated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var continueEnumeration = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var daprClient = new Mock(); + daprClient + .Setup(c => c.WaitForSidecarAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + var configResponse = new GetConfigurationResponse( + new BlockingConfigurationItems( + new Dictionary + { + ["firstKey"] = new ConfigurationItem("firstValue", "v1", null), + ["secondKey"] = new ConfigurationItem("secondValue", "v1", null) + }, + firstItemEnumerated, + continueEnumeration)); + + daprClient + .Setup(c => c.GetConfiguration( + "store", + It.IsAny>(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(configResponse); + + var config = new ConfigurationBuilder() + .AddDaprConfigurationStore("store", new List(), daprClient.Object, TimeSpan.FromSeconds(5), optional: true) + .Build(); + + var reloaded = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + config.GetReloadToken().RegisterChangeCallback(_ => reloaded.TrySetResult(true), null); + + await firstItemEnumerated.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + try + { + config["firstKey"].ShouldBeNull(); + config["secondKey"].ShouldBeNull(); + } + finally + { + continueEnumeration.TrySetResult(true); + } + + await reloaded.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + config["firstKey"].ShouldBe("firstValue"); + config["secondKey"].ShouldBe("secondValue"); + } + + [Fact] + public async Task TestConfigurationStore_OptionalAndFetchThrowsTransientError_RetriesAndSucceeds() + { + var callCount = 0; + + var daprClient = new Mock(); + daprClient + .Setup(c => c.WaitForSidecarAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + var configResponse = new GetConfigurationResponse( + new Dictionary + { + ["testKey"] = new ConfigurationItem("testValue", "v1", null) + }); + + daprClient + .Setup(c => c.GetConfiguration( + "store", + It.IsAny>(), + It.IsAny>(), + It.IsAny())) + .Returns(() => + { + var current = Interlocked.Increment(ref callCount); + if (current <= 1) + { + throw new DaprException("transient error"); + } + return Task.FromResult(configResponse); + }); + + var config = new ConfigurationBuilder() + .AddDaprConfigurationStore("store", new List(), daprClient.Object, TimeSpan.FromMilliseconds(100), optional: true) + .Build(); + + // Initially empty + config["testKey"].ShouldBeNull(); + + // Wait for the reload token to fire after retry succeeds + var reloaded = new TaskCompletionSource(); + config.GetReloadToken().RegisterChangeCallback(_ => reloaded.TrySetResult(true), null); + await reloaded.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + config["testKey"].ShouldBe("testValue"); + } + + [Fact] + public async Task TestConfigurationStore_OptionalAndDisposed_ExitsCleanly() + { + var waitCalled = new TaskCompletionSource(); + + var daprClient = new Mock(); + daprClient + .Setup(c => c.WaitForSidecarAsync(It.IsAny())) + .Returns(ct => + { + waitCalled.TrySetResult(true); + return Task.Delay(Timeout.Infinite, ct); + }); + + var config = new ConfigurationBuilder() + .AddDaprConfigurationStore("store", new List(), daprClient.Object, TimeSpan.FromSeconds(1), optional: true) + .Build(); + + // Ensure background task has started + await waitCalled.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + // Dispose the provider to cancel the background task + (config as IDisposable)?.Dispose(); + + // Verify the mock was called with a token that is now cancelled + daprClient.Verify(c => c.WaitForSidecarAsync(It.IsAny()), Times.AtLeastOnce()); + config["anyKey"].ShouldBeNull(); + } + + [Fact] + public void TestStreamingConfigurationStore_OptionalAndSidecarUnavailable_ReturnsEmptyConfig() + { + var daprClient = new Mock(); + daprClient + .Setup(c => c.WaitForSidecarAsync(It.IsAny())) + .Returns(ct => + Task.Delay(Timeout.Infinite, ct)); + + var config = new ConfigurationBuilder() + .AddStreamingDaprConfigurationStore("store", new List(), daprClient.Object, TimeSpan.FromSeconds(1), optional: true) + .Build(); + + config["anyKey"].ShouldBeNull(); + } + + [Fact] + public async Task TestStreamingConfigurationStore_Disposed_DoesNotUnsubscribeOrRetry() + { + var streamBlocked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var daprClient = new Mock(); + daprClient + .Setup(c => c.WaitForSidecarAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + daprClient + .Setup(c => c.SubscribeConfiguration( + "store", + It.IsAny>(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new SubscribeConfigurationResponse(new BlockingConfigurationSource(streamBlocked))); + + var config = new ConfigurationBuilder() + .AddStreamingDaprConfigurationStore("store", new List(), daprClient.Object, TimeSpan.FromMilliseconds(100)) + .Build(); + + await streamBlocked.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + config["testKey"].ShouldBe("testValue"); + + (config as IDisposable)?.Dispose(); + await Task.Delay(TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); + + daprClient.Verify(c => c.UnsubscribeConfiguration( + "store", + "testId", + It.IsAny()), Times.Never()); + daprClient.Verify(c => c.SubscribeConfiguration( + "store", + It.IsAny>(), + It.IsAny>(), + It.IsAny()), Times.Once()); + } + private async Task SendStreamingResponseWithConfiguration(Dictionary items, TestHttpClient.Entry entry) { var streamResponse = new Autogenerated.SubscribeConfigurationResponse(); @@ -204,4 +499,87 @@ private async Task SendStreamingResponseWithConfiguration(Dictionary streamBlocked) : ConfigurationSource + { + public override string Id => "testId"; + + public override IAsyncEnumerator> GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + return new BlockingConfigurationEnumerator(streamBlocked, cancellationToken); + } + } + + private sealed class BlockingConfigurationEnumerator(TaskCompletionSource streamBlocked, CancellationToken cancellationToken) : IAsyncEnumerator> + { + private bool hasReturnedInitialValue; + + public IDictionary Current { get; } = new Dictionary + { + ["testKey"] = new("testValue", "v1", null) + }; + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + + public async ValueTask MoveNextAsync() + { + if (!hasReturnedInitialValue) + { + hasReturnedInitialValue = true; + return true; + } + + streamBlocked.TrySetResult(true); + await Task.Delay(Timeout.Infinite, cancellationToken); + return false; + } + } + + private sealed class BlockingConfigurationItems( + IReadOnlyDictionary inner, + TaskCompletionSource firstItemEnumerated, + TaskCompletionSource continueEnumeration) : IReadOnlyDictionary + { + public IEnumerable Keys => inner.Keys; + + public IEnumerable Values => inner.Values; + + public int Count => inner.Count; + + public ConfigurationItem this[string key] => inner[key]; + + public bool ContainsKey(string key) + { + return inner.ContainsKey(key); + } + + public bool TryGetValue(string key, out ConfigurationItem value) + { + return inner.TryGetValue(key, out value); + } + + public IEnumerator> GetEnumerator() + { + var shouldBlock = true; + foreach (var item in inner) + { + if (shouldBlock) + { + shouldBlock = false; + firstItemEnumerated.TrySetResult(true); + continueEnumeration.Task.GetAwaiter().GetResult(); + } + + yield return item; + } + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + } } diff --git a/test/Dapr.Extensions.Configuration.Test/DaprSecretStoreConfigurationProviderTest.cs b/test/Dapr.Extensions.Configuration.Test/DaprSecretStoreConfigurationProviderTest.cs index 53cb4fc6c..54536896e 100644 --- a/test/Dapr.Extensions.Configuration.Test/DaprSecretStoreConfigurationProviderTest.cs +++ b/test/Dapr.Extensions.Configuration.Test/DaprSecretStoreConfigurationProviderTest.cs @@ -14,6 +14,7 @@ using System; using System.Collections.Generic; using System.Net; +using System.Threading; using System.Threading.Tasks; using Dapr.Client; using Grpc.Net.Client; @@ -897,6 +898,166 @@ public void LoadSecrets_FailsIfSidecarNotAvailable() .Build()); } + [Fact] + public void LoadSecrets_OptionalAndSidecarUnavailable_ReturnsEmptyConfig() + { + var daprClient = new Mock(); + daprClient + .Setup(c => c.WaitForSidecarAsync(It.IsAny())) + .Returns(ct => + Task.Delay(Timeout.Infinite, ct)); + + var config = CreateBuilder() + .AddDaprSecretStore("store", daprClient.Object, optional: true) + .Build(); + + // Build() should succeed immediately with no values + config["anyKey"].ShouldBeNull(); + } + + [Fact] + public async Task LoadSecrets_OptionalAndSidecarBecomesAvailable_PopulatesConfig() + { + var callCount = 0; + + var daprClient = new Mock(); + daprClient + .Setup(c => c.WaitForSidecarAsync(It.IsAny())) + .Returns(ct => + { + var current = Interlocked.Increment(ref callCount); + if (current <= 1) + { + // First call: simulate sidecar not ready by waiting until timeout cancels + return Task.Delay(Timeout.Infinite, ct); + } + // Subsequent calls: sidecar is ready + return Task.CompletedTask; + }); + + daprClient + .Setup(c => c.GetBulkSecretAsync("store", null, It.IsAny())) + .ReturnsAsync(new Dictionary> + { + ["secret1"] = new Dictionary { ["secret1"] = "value1" } + }); + + var config = CreateBuilder() + .AddDaprSecretStore("store", daprClient.Object, TimeSpan.FromMilliseconds(100), optional: true) + .Build(); + + // Initially empty + config["secret1"].ShouldBeNull(); + + // Wait for the reload token to fire, indicating background load completed + var reloaded = new TaskCompletionSource(); + config.GetReloadToken().RegisterChangeCallback(_ => reloaded.TrySetResult(true), null); + await reloaded.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + config["secret1"].ShouldBe("value1"); + } + + [Fact] + public async Task LoadSecrets_OptionalWithDescriptors_PopulatesConfig() + { + var storeName = "store"; + var secretKey = "mySecret"; + var secretValue = "myValue"; + + var daprClient = new Mock(); + // Sidecar ready immediately + daprClient + .Setup(c => c.WaitForSidecarAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + daprClient + .Setup(c => c.GetSecretAsync(storeName, secretKey, It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary { { secretKey, secretValue } }); + + var secretDescriptors = new[] { new DaprSecretDescriptor(secretKey) }; + + var config = CreateBuilder() + .AddDaprSecretStore(storeName, secretDescriptors, daprClient.Object, TimeSpan.FromSeconds(1), optional: true) + .Build(); + + // Wait for the reload token to fire, indicating background load completed + var reloaded = new TaskCompletionSource(); + config.GetReloadToken().RegisterChangeCallback(_ => reloaded.TrySetResult(true), null); + await reloaded.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + config[secretKey].ShouldBe(secretValue); + } + + [Fact] + public async Task LoadSecrets_OptionalAndFetchThrowsTransientError_RetriesAndSucceeds() + { + var callCount = 0; + + var daprClient = new Mock(); + daprClient + .Setup(c => c.WaitForSidecarAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + daprClient + .Setup(c => c.GetBulkSecretAsync("store", null, It.IsAny())) + .Returns(() => + { + var current = Interlocked.Increment(ref callCount); + if (current <= 1) + { + throw new DaprException("transient error"); + } + return Task.FromResult>>( + new Dictionary> + { + ["secret1"] = new Dictionary { ["secret1"] = "value1" } + }); + }); + + var config = CreateBuilder() + .AddDaprSecretStore("store", daprClient.Object, TimeSpan.FromMilliseconds(100), optional: true) + .Build(); + + // Initially empty + config["secret1"].ShouldBeNull(); + + // Wait for the reload token to fire after retry succeeds + var reloaded = new TaskCompletionSource(); + config.GetReloadToken().RegisterChangeCallback(_ => reloaded.TrySetResult(true), null); + await reloaded.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + config["secret1"].ShouldBe("value1"); + } + + [Fact] + public async Task LoadSecrets_OptionalAndDisposed_ExitsCleanly() + { + var waitCalled = new TaskCompletionSource(); + + var daprClient = new Mock(); + daprClient + .Setup(c => c.WaitForSidecarAsync(It.IsAny())) + .Returns(ct => + { + waitCalled.TrySetResult(true); + return Task.Delay(Timeout.Infinite, ct); + }); + + var config = CreateBuilder() + .AddDaprSecretStore("store", daprClient.Object, TimeSpan.FromSeconds(1), optional: true) + .Build(); + + // Ensure background task has started + await waitCalled.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + // Dispose the provider to cancel the background task + (config as IDisposable)?.Dispose(); + + // Verify the mock was called with a token that is now cancelled + daprClient.Verify(c => c.WaitForSidecarAsync(It.IsAny()), Times.AtLeastOnce()); + config["anyKey"].ShouldBeNull(); + } + private IConfigurationBuilder CreateBuilder() { return new ConfigurationBuilder(); @@ -934,4 +1095,4 @@ private async Task SendEmptyResponse(TestHttpClient.Entry entry, HttpStatusCode var streamContent = await GrpcUtils.CreateResponseContent(response); entry.Completion.SetResult(GrpcUtils.CreateResponse(code, streamContent)); } -} \ No newline at end of file +} diff --git a/test/Dapr.IntegrationTest.Configuration/Dapr.IntegrationTest.Configuration.csproj b/test/Dapr.IntegrationTest.Configuration/Dapr.IntegrationTest.Configuration.csproj new file mode 100644 index 000000000..d48bd6fef --- /dev/null +++ b/test/Dapr.IntegrationTest.Configuration/Dapr.IntegrationTest.Configuration.csproj @@ -0,0 +1,29 @@ + + + + enable + enable + false + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + diff --git a/test/Dapr.IntegrationTest.Configuration/SecretStoreOptionalTests.cs b/test/Dapr.IntegrationTest.Configuration/SecretStoreOptionalTests.cs new file mode 100644 index 000000000..aa49772cd --- /dev/null +++ b/test/Dapr.IntegrationTest.Configuration/SecretStoreOptionalTests.cs @@ -0,0 +1,125 @@ +using Dapr.Client; +using Dapr.Extensions.Configuration; +using Dapr.Testcontainers.Common; +using Dapr.Testcontainers.Harnesses; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Primitives; + +namespace Dapr.IntegrationTest.Configuration; + +public sealed class SecretStoreOptionalTests +{ + [Fact] + public async Task ShouldPopulateSecretsWhenSidecarStartsLate() + { + var componentsDir = TestDirectoryManager.CreateTestDirectory("secretstore-components"); + + await using var environment = await DaprTestEnvironment.CreateWithPooledNetworkAsync(cancellationToken: TestContext.Current.CancellationToken); + await environment.StartAsync(TestContext.Current.CancellationToken); + + await using var harness = new DaprHarnessBuilder(componentsDir) + .WithEnvironment(environment) + .BuildSecretStore(); + + var httpPort = PortUtilities.GetAvailablePort(); + var grpcPort = PortUtilities.GetAvailablePort(); + harness.SetPorts(httpPort, grpcPort); + + // Write component files before harness init (harness also writes them, but we need them for the client) + SecretStoreHarness.WriteSecretsFile(componentsDir); + SecretStoreHarness.WriteComponentYaml(componentsDir); + + using var client = new DaprClientBuilder() + .UseHttpEndpoint($"http://localhost:{httpPort}") + .UseGrpcEndpoint($"http://localhost:{grpcPort}") + .Build(); + + var config = new ConfigurationBuilder() + .AddDaprSecretStore( + SecretStoreHarness.SecretStoreComponentName, + client, + TimeSpan.FromSeconds(2), + optional: true) + .Build(); + + // Sidecar not running yet — config should be empty. + Assert.Null(config["secret1"]); + + // Register reload callback before starting the sidecar. + var reloaded = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + ChangeToken.OnChange(config.GetReloadToken, () => reloaded.TrySetResult()); + + // Start the sidecar on the pre-assigned ports. + await harness.InitializeAsync(TestContext.Current.CancellationToken); + + // Wait for the background loader to populate config. + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await reloaded.Task.WaitAsync(timeoutCts.Token).WaitAsync(TestContext.Current.CancellationToken); + + Assert.Equal("value1", config["secret1"]); + Assert.Equal("value2", config["secret2"]); + } + + [Fact] + public async Task ShouldBlockAndPopulateSecretsWhenNotOptional() + { + var componentsDir = TestDirectoryManager.CreateTestDirectory("secretstore-components"); + + await using var environment = await DaprTestEnvironment.CreateWithPooledNetworkAsync(cancellationToken: TestContext.Current.CancellationToken); + await environment.StartAsync(TestContext.Current.CancellationToken); + + await using var harness = new DaprHarnessBuilder(componentsDir) + .WithEnvironment(environment) + .BuildSecretStore(); + + await harness.InitializeAsync(TestContext.Current.CancellationToken); + + using var client = new DaprClientBuilder() + .UseHttpEndpoint($"http://localhost:{harness.DaprHttpPort}") + .UseGrpcEndpoint($"http://localhost:{harness.DaprGrpcPort}") + .Build(); + + // optional defaults to false, so Build() blocks until sidecar responds. + var config = new ConfigurationBuilder() + .AddDaprSecretStore( + SecretStoreHarness.SecretStoreComponentName, + client, + TimeSpan.FromSeconds(30)) + .Build(); + + Assert.Equal("value1", config["secret1"]); + Assert.Equal("value2", config["secret2"]); + } + + [Fact] + public async Task ShouldExitCleanlyWhenDisposedDuringBackgroundLoad() + { + // Pick free ports — no sidecar will run on them. + var httpPort = PortUtilities.GetAvailablePort(); + var grpcPort = PortUtilities.GetAvailablePort(); + + using var client = new DaprClientBuilder() + .UseHttpEndpoint($"http://localhost:{httpPort}") + .UseGrpcEndpoint($"http://localhost:{grpcPort}") + .Build(); + + var config = new ConfigurationBuilder() + .AddDaprSecretStore( + SecretStoreHarness.SecretStoreComponentName, + client, + TimeSpan.FromMilliseconds(500), + optional: true) + .Build(); + + // Config should be empty since there's no sidecar. + Assert.Null(config["secret1"]); + + // Dispose immediately to trigger cancellation of the background task. + (config as IDisposable)?.Dispose(); + + // Allow background task to process cancellation. + await Task.Delay(200, TestContext.Current.CancellationToken); + + // Test completes within xunit default timeout — no hang. + } +}