diff --git a/CHANGELOG.md b/CHANGELOG.md index 424482b..172a592 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [Unreleased] + +### Added +- **`Cocoar.Configuration.ConfigHub`** — new opt-in ConfigHub delivery provider with bearer-authenticated JSON snapshots, ETag-based conditional refresh, SSE invalidation, reconnect reconciliation, and optional polling fallback. Static and config-aware `FromConfigHub()` rules support endpoint and token rotation without serializing delivery credentials. + +### Documentation +- Added the ConfigHub provider guide, package reference, installation paths, and an updated ConfigHub roadmap that distinguishes the available runtime provider from the separately developed management portal. + ## [6.1.1] - 2026-08-17 ### Fixed diff --git a/README.md b/README.md index 461ca00..10795bb 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,13 @@ dotnet add package Cocoar.Configuration.AspNetCore # + health endpoints, fea You only need **one** — each includes everything above it. Requires .NET 9+. +Optional provider packages add only the integration you need: + +```shell +dotnet add package Cocoar.Configuration.ConfigHub # ConfigHub delivery and live invalidation +dotnet add package Cocoar.Configuration.Http # Generic remote configuration via HTTP +``` + ## Quick Start ```csharp @@ -64,6 +71,7 @@ app.Run(); | Command Line | `.FromCommandLine("--prefix")` | Core | | Static / Observable | `.FromStaticJson()` / `.FromObservable()` | Core | | WritableStore (writable overlay) | `.FromStore()` | Core | +| ConfigHub | `.FromConfigHub(url, token)` | [ConfigHub](https://www.nuget.org/packages/Cocoar.Configuration.ConfigHub) | | HTTP | `.FromHttp(url)` | [Http](https://www.nuget.org/packages/Cocoar.Configuration.Http) | | Microsoft IConfiguration | `.FromIConfiguration(config)` | [MicrosoftAdapter](https://www.nuget.org/packages/Cocoar.Configuration.MicrosoftAdapter) | diff --git a/src/Cocoar.Configuration.ConfigHub/Cocoar.Configuration.ConfigHub.csproj b/src/Cocoar.Configuration.ConfigHub/Cocoar.Configuration.ConfigHub.csproj new file mode 100644 index 0000000..2df817d --- /dev/null +++ b/src/Cocoar.Configuration.ConfigHub/Cocoar.Configuration.ConfigHub.csproj @@ -0,0 +1,11 @@ + + + true + ConfigHub provider for Cocoar.Configuration with authenticated snapshots, ETag validation, Server-Sent Events invalidation, and polling fallback. + configuration;confighub;remote-config;sse;server-sent-events;etag;distributed;reactive + + + + + + diff --git a/src/Cocoar.Configuration.ConfigHub/ConfigHubProvider.cs b/src/Cocoar.Configuration.ConfigHub/ConfigHubProvider.cs new file mode 100644 index 0000000..d77750b --- /dev/null +++ b/src/Cocoar.Configuration.ConfigHub/ConfigHubProvider.cs @@ -0,0 +1,340 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using Cocoar.Configuration.Providers.Abstractions; + +namespace Cocoar.Configuration.ConfigHub; + +/// +/// Loads authoritative JSON snapshots over HTTP and uses SSE only as an invalidation channel. +/// +public sealed class ConfigHubProvider + : ConfigurationProvider, IDisposable +{ + private static readonly TimeSpan InitialReconnectDelay = TimeSpan.FromSeconds(1); + private static readonly TimeSpan MaxReconnectDelay = TimeSpan.FromSeconds(30); + + private readonly HttpClient _client; + private readonly CancellationTokenSource _lifetime = new(); + private int _disposed; + + /// + /// Creates a provider with the specified connection-level options. + /// + /// Connection-level polling, timeout, and transport options. + public ConfigHubProvider(ConfigHubProviderOptions options) + : base(options ?? throw new ArgumentNullException(nameof(options))) + { + _client = options.Handler is null + ? new HttpClient() + : new HttpClient(options.Handler, disposeHandler: false); + } + + /// + public override async Task FetchConfigurationBytesAsync( + ConfigHubProviderQueryOptions query, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(query); + + var bytes = await FetchSnapshotAsync(query, useCacheValidator: false, ct).ConfigureAwait(false); + return bytes ?? throw new InvalidOperationException( + "ConfigHub returned no content for an unconditional snapshot request."); + } + + /// + public override IObservable ChangesAsBytes(ConfigHubProviderQueryOptions query) + { + ArgumentNullException.ThrowIfNull(query); + return new ChangeObservable(this, query); + } + + /// + /// Stops active subscriptions and disposes provider-owned HTTP resources. + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _lifetime.Cancel(); + _client.Dispose(); + _lifetime.Dispose(); + } + + private async Task FetchSnapshotAsync( + ConfigHubProviderQueryOptions query, + bool useCacheValidator, + CancellationToken ct) + { + await query.RefreshGate.WaitAsync(ct).ConfigureAwait(false); + try + { + using var request = CreateRequest(query, "application/json"); + + var entityTag = useCacheValidator ? query.GetEntityTag(query.Url) : null; + if (entityTag is not null && EntityTagHeaderValue.TryParse(entityTag, out var parsedEntityTag)) + { + request.Headers.IfNoneMatch.Add(parsedEntityTag); + } + + using var response = await _client.SendAsync(request, ct).ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.NotModified) + { + if (response.Headers.ETag is { } refreshedEntityTag) + { + query.SetEntityTag(query.Url, refreshedEntityTag.ToString()); + } + + return null; + } + + response.EnsureSuccessStatusCode(); + var bytes = await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); + query.SetEntityTag(query.Url, response.Headers.ETag?.ToString()); + return bytes; + } + finally + { + query.RefreshGate.Release(); + } + } + + private static HttpRequestMessage CreateRequest(ConfigHubProviderQueryOptions query, string accept) + { + var request = new HttpRequestMessage(HttpMethod.Get, query.Url); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(accept)); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", query.DeliveryToken); + return request; + } + + private sealed class ChangeObservable(ConfigHubProvider provider, ConfigHubProviderQueryOptions query) + : IObservable + { + public IDisposable Subscribe(IObserver observer) + { + ArgumentNullException.ThrowIfNull(observer); + + var cts = CancellationTokenSource.CreateLinkedTokenSource(provider._lifetime.Token); + var sink = new SerializedObserver(observer); + _ = Task.Run(() => RunAsync(sink, cts.Token), CancellationToken.None); + return new Subscription(cts); + } + + private async Task RunAsync(SerializedObserver observer, CancellationToken ct) + { + var tasks = new List + { + RunSseLoopAsync(observer, ct), + }; + + if (provider.ProviderOptions.FallbackPollInterval is { } interval) + { + tasks.Add(RunPollingLoopAsync(observer, interval, ct)); + } + + try + { + await Task.WhenAll(tasks).ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + } + catch (Exception ex) + { + observer.Error(ex); + } + } + + private async Task RunSseLoopAsync(SerializedObserver observer, CancellationToken ct) + { + var reconnectDelay = InitialReconnectDelay; + + while (!ct.IsCancellationRequested) + { + try + { + await ConnectAndReadAsync(observer, ct).ConfigureAwait(false); + reconnectDelay = InitialReconnectDelay; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + Trace.TraceWarning( + "ConfigHub SSE connection to '{0}' failed: {1}: {2}. Retrying in {3:F1}s.", + query.Url, + ex.GetType().Name, + ex.Message, + reconnectDelay.TotalSeconds); + } + + await Task.Delay(reconnectDelay, ct).ConfigureAwait(false); + reconnectDelay = TimeSpan.FromTicks(Math.Min( + reconnectDelay.Ticks * 2, + MaxReconnectDelay.Ticks)); + } + } + + private async Task ConnectAndReadAsync(SerializedObserver observer, CancellationToken ct) + { + using var request = ConfigHubProvider.CreateRequest(query, "text/event-stream"); + using var response = await provider._client.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + // The stream never owns configuration state. This reconciliation closes the gap left by + // events that may have been published while the connection was down. + await FetchAndEmitIfChangedAsync(observer, ct).ConfigureAwait(false); + + await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + using var reader = new StreamReader(stream, Encoding.UTF8); + var hasDataField = false; + + while (!ct.IsCancellationRequested) + { + var line = await ReadLineAsync(reader, ct).ConfigureAwait(false); + if (line is null) + { + return; + } + + if (line.Length == 0) + { + if (hasDataField) + { + await FetchAndEmitIfChangedAsync(observer, ct).ConfigureAwait(false); + hasDataField = false; + } + + continue; + } + + if (line[0] == ':') + { + continue; + } + + var colonIndex = line.IndexOf(':'); + var field = colonIndex >= 0 ? line[..colonIndex] : line; + if (string.Equals(field, "data", StringComparison.Ordinal)) + { + hasDataField = true; + } + } + } + + private async Task ReadLineAsync(StreamReader reader, CancellationToken ct) + { + if (provider.ProviderOptions.SseReadIdleTimeout is not { } timeout) + { + return await reader.ReadLineAsync(ct).ConfigureAwait(false); + } + + using var readCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + readCts.CancelAfter(timeout); + try + { + return await reader.ReadLineAsync(readCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + throw new TimeoutException( + $"ConfigHub SSE connection received no data for {timeout.TotalSeconds:F0} seconds."); + } + } + + private async Task RunPollingLoopAsync( + SerializedObserver observer, + TimeSpan interval, + CancellationToken ct) + { + using var timer = new PeriodicTimer(interval); + while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false)) + { + try + { + await FetchAndEmitIfChangedAsync(observer, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + Trace.TraceWarning( + "ConfigHub fallback poll for '{0}' failed: {1}: {2}.", + query.Url, + ex.GetType().Name, + ex.Message); + } + } + } + + private async Task FetchAndEmitIfChangedAsync(SerializedObserver observer, CancellationToken ct) + { + var bytes = await provider.FetchSnapshotAsync(query, useCacheValidator: true, ct) + .ConfigureAwait(false); + if (bytes is not null) + { + observer.Next(bytes); + } + } + } + + private sealed class SerializedObserver(IObserver observer) + { + private readonly Lock _gate = new(); + private bool _stopped; + + public void Next(byte[] value) + { + lock (_gate) + { + if (!_stopped) + { + observer.OnNext(value); + } + } + } + + public void Error(Exception error) + { + lock (_gate) + { + if (_stopped) + { + return; + } + + _stopped = true; + observer.OnError(error); + } + } + } + + private sealed class Subscription(CancellationTokenSource cts) : IDisposable + { + public void Dispose() + { + try + { + cts.Cancel(); + } + catch (ObjectDisposedException) + { + } + finally + { + cts.Dispose(); + } + } + } +} diff --git a/src/Cocoar.Configuration.ConfigHub/ConfigHubProviderOptions.cs b/src/Cocoar.Configuration.ConfigHub/ConfigHubProviderOptions.cs new file mode 100644 index 0000000..d9b5e5a --- /dev/null +++ b/src/Cocoar.Configuration.ConfigHub/ConfigHubProviderOptions.cs @@ -0,0 +1,68 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Cocoar.Configuration.Providers.Abstractions; + +namespace Cocoar.Configuration.ConfigHub; + +/// +/// Connection-level options for the ConfigHub configuration provider. +/// +public sealed class ConfigHubProviderOptions : IProviderConfiguration +{ + private static readonly JsonSerializerOptions ProviderKeyOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + PropertyNamingPolicy = null, + WriteIndented = false, + }; + + /// + /// Optional interval for a conditional HTTP poll that runs alongside SSE as a safety net. + /// + public TimeSpan? FallbackPollInterval { get; } + + /// + /// Optional maximum time without an SSE line before the connection is re-established. + /// ConfigHub sends keep-alive comments, so this may safely be longer than its keep-alive interval. + /// + public TimeSpan? SseReadIdleTimeout { get; } + + /// + /// Optional caller-owned handler used by the provider's HTTP client. + /// + [JsonIgnore] + public HttpMessageHandler? Handler { get; } + + /// + /// Creates connection-level options for ConfigHub delivery. + /// + /// Optional interval for conditional safety-net polling. + /// Optional maximum idle time before reconnecting the SSE stream. + /// Optional caller-owned HTTP handler. The provider does not dispose it. + public ConfigHubProviderOptions( + TimeSpan? fallbackPollInterval = null, + TimeSpan? sseReadIdleTimeout = null, + HttpMessageHandler? handler = null) + { + ValidatePositive(fallbackPollInterval, nameof(fallbackPollInterval)); + ValidatePositive(sseReadIdleTimeout, nameof(sseReadIdleTimeout)); + + FallbackPollInterval = fallbackPollInterval; + SseReadIdleTimeout = sseReadIdleTimeout; + Handler = handler; + } + + /// + public string? GenerateProviderKey() + => Handler is null + ? JsonSerializer.Serialize(this, ProviderKeyOptions) + : null; + + private static void ValidatePositive(TimeSpan? value, string parameterName) + { + if (value is { } interval && interval <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(parameterName, "The interval must be greater than zero."); + } + } +} diff --git a/src/Cocoar.Configuration.ConfigHub/ConfigHubProviderQueryOptions.cs b/src/Cocoar.Configuration.ConfigHub/ConfigHubProviderQueryOptions.cs new file mode 100644 index 0000000..d9c55b8 --- /dev/null +++ b/src/Cocoar.Configuration.ConfigHub/ConfigHubProviderQueryOptions.cs @@ -0,0 +1,97 @@ +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Serialization; +using Cocoar.Configuration.Providers.Abstractions; + +namespace Cocoar.Configuration.ConfigHub; + +/// +/// Identifies one ConfigHub delivery endpoint and its credential. +/// +public sealed class ConfigHubProviderQueryOptions : IProviderQuery +{ + private readonly Lock _validatorLock = new(); + private string? _validatorUrl; + private string? _entityTag; + + /// + /// Absolute ConfigHub delivery URL, for example https://config.example/api/config/my-app. + /// + public string Url { get; } + + /// + /// A non-secret identity used by Cocoar.Configuration to notice token rotation and rebuild the subscription. + /// + public string CredentialFingerprint { get; } + + /// + /// ConfigHub delivery token. It is deliberately excluded from query serialization and logging. + /// + [JsonIgnore] + public string DeliveryToken { get; } + + internal SemaphoreSlim RefreshGate { get; } = new(1, 1); + + /// + /// Creates a query for one ConfigHub delivery endpoint. + /// + /// Absolute HTTP or HTTPS delivery URL. + /// Bearer token issued for the delivery endpoint. + public ConfigHubProviderQueryOptions(string url, string deliveryToken) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + throw new ArgumentException("An absolute HTTP or HTTPS URL is required.", nameof(url)); + } + + if (string.IsNullOrWhiteSpace(deliveryToken) || !IsValidBearerToken(deliveryToken)) + { + throw new ArgumentException( + "A valid ConfigHub delivery token is required.", + nameof(deliveryToken)); + } + + Url = uri.ToString(); + DeliveryToken = deliveryToken; + CredentialFingerprint = Fingerprint(deliveryToken); + } + + internal string? GetEntityTag(string resolvedUrl) + { + lock (_validatorLock) + { + return string.Equals(_validatorUrl, resolvedUrl, StringComparison.Ordinal) + ? _entityTag + : null; + } + } + + internal void SetEntityTag(string resolvedUrl, string? entityTag) + { + lock (_validatorLock) + { + _validatorUrl = resolvedUrl; + _entityTag = entityTag; + } + } + + private static string Fingerprint(string value) + { + var bytes = Encoding.UTF8.GetBytes(value); + try + { + return Convert.ToHexString(SHA256.HashData(bytes)); + } + finally + { + CryptographicOperations.ZeroMemory(bytes); + } + } + + private static bool IsValidBearerToken(string value) + => AuthenticationHeaderValue.TryParse($"Bearer {value}", out var header) && + string.Equals(header.Scheme, "Bearer", StringComparison.OrdinalIgnoreCase) && + string.Equals(header.Parameter, value, StringComparison.Ordinal); +} diff --git a/src/Cocoar.Configuration.ConfigHub/ConfigHubRuleOptions.cs b/src/Cocoar.Configuration.ConfigHub/ConfigHubRuleOptions.cs new file mode 100644 index 0000000..a4d6435 --- /dev/null +++ b/src/Cocoar.Configuration.ConfigHub/ConfigHubRuleOptions.cs @@ -0,0 +1,65 @@ +using System.Text.Json.Serialization; + +namespace Cocoar.Configuration.ConfigHub; + +/// +/// Combined options for a FromConfigHub configuration rule. +/// +public sealed class ConfigHubRuleOptions +{ + /// + /// Absolute HTTP or HTTPS ConfigHub delivery URL. + /// + public string Url { get; } + + /// + /// Bearer token issued for the ConfigHub delivery endpoint. + /// + [JsonIgnore] + public string DeliveryToken { get; } + + /// + /// Optional interval for conditional safety-net polling alongside SSE. + /// + public TimeSpan? FallbackPollInterval { get; } + + /// + /// Optional maximum time without an SSE line before reconnecting. + /// + public TimeSpan? SseReadIdleTimeout { get; } + + /// + /// Optional caller-owned handler used for ConfigHub HTTP requests. + /// + [JsonIgnore] + public HttpMessageHandler? Handler { get; } + + /// + /// Creates the combined options for a ConfigHub rule. + /// + /// Absolute HTTP or HTTPS ConfigHub delivery URL. + /// Bearer token issued for the delivery endpoint. + /// Optional interval for conditional safety-net polling. + /// Optional maximum idle time before reconnecting the SSE stream. + /// Optional caller-owned HTTP handler. The provider does not dispose it. + public ConfigHubRuleOptions( + string url, + string deliveryToken, + TimeSpan? fallbackPollInterval = null, + TimeSpan? sseReadIdleTimeout = null, + HttpMessageHandler? handler = null) + { + Url = url; + DeliveryToken = deliveryToken; + FallbackPollInterval = fallbackPollInterval; + SseReadIdleTimeout = sseReadIdleTimeout; + Handler = handler; + } + + internal ConfigHubProviderOptions ToProviderOptions() => new( + FallbackPollInterval, + SseReadIdleTimeout, + Handler); + + internal ConfigHubProviderQueryOptions ToQueryOptions() => new(Url, DeliveryToken); +} diff --git a/src/Cocoar.Configuration.ConfigHub/ConfigHubRulesExtensions.cs b/src/Cocoar.Configuration.ConfigHub/ConfigHubRulesExtensions.cs new file mode 100644 index 0000000..711f02f --- /dev/null +++ b/src/Cocoar.Configuration.ConfigHub/ConfigHubRulesExtensions.cs @@ -0,0 +1,65 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; + +namespace Cocoar.Configuration.ConfigHub; + +/// +/// Adds ConfigHub delivery sources to typed configuration rules. +/// +public static class ConfigHubRulesExtensions +{ + /// + /// Adds a ConfigHub-backed configuration layer. JSON snapshots remain the source of truth; + /// SSE is used only to invalidate the current snapshot. + /// + /// The typed rule builder. + /// Absolute HTTP or HTTPS ConfigHub delivery URL. + /// Bearer token issued for the delivery endpoint. + /// Optional interval for conditional safety-net polling. + /// Optional maximum idle time before reconnecting the SSE stream. + /// Optional caller-owned HTTP handler. The provider does not dispose it. + public static ProviderRuleBuilder + FromConfigHub( + this TypedProviderBuilder builder, + string url, + string deliveryToken, + TimeSpan? fallbackPollInterval = null, + TimeSpan? sseReadIdleTimeout = null, + HttpMessageHandler? handler = null) + where T : class + { + ArgumentNullException.ThrowIfNull(builder); + + var options = new ConfigHubRuleOptions( + url, + deliveryToken, + fallbackPollInterval, + sseReadIdleTimeout, + handler); + + return new( + _ => options.ToProviderOptions(), + _ => options.ToQueryOptions(), + typeof(T)); + } + + /// + /// Adds a ConfigHub-backed configuration layer whose endpoint or token depends on earlier rules. + /// + /// The typed rule builder. + /// Builds ConfigHub options from the current configuration pass. + public static ProviderRuleBuilder + FromConfigHub( + this TypedProviderBuilder builder, + Func optionsFactory) + where T : class + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(optionsFactory); + + return new( + accessor => optionsFactory(accessor).ToProviderOptions(), + accessor => optionsFactory(accessor).ToQueryOptions(), + typeof(T)); + } +} diff --git a/src/Cocoar.Configuration.slnx b/src/Cocoar.Configuration.slnx index 57d5aaf..4c7bad0 100644 --- a/src/Cocoar.Configuration.slnx +++ b/src/Cocoar.Configuration.slnx @@ -11,6 +11,7 @@ + @@ -40,6 +41,7 @@ + diff --git a/src/tests/Cocoar.Configuration.ConfigHub.Tests/Cocoar.Configuration.ConfigHub.Tests.csproj b/src/tests/Cocoar.Configuration.ConfigHub.Tests/Cocoar.Configuration.ConfigHub.Tests.csproj new file mode 100644 index 0000000..e113ddd --- /dev/null +++ b/src/tests/Cocoar.Configuration.ConfigHub.Tests/Cocoar.Configuration.ConfigHub.Tests.csproj @@ -0,0 +1,24 @@ + + + true + net10.0 + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/src/tests/Cocoar.Configuration.ConfigHub.Tests/ConfigHubOptionsTests.cs b/src/tests/Cocoar.Configuration.ConfigHub.Tests/ConfigHubOptionsTests.cs new file mode 100644 index 0000000..cff3e4e --- /dev/null +++ b/src/tests/Cocoar.Configuration.ConfigHub.Tests/ConfigHubOptionsTests.cs @@ -0,0 +1,125 @@ +using System.Text.Json; + +namespace Cocoar.Configuration.ConfigHub.Tests; + +public sealed class ConfigHubOptionsTests +{ + [Theory] + [InlineData("config.example/api/config/orders")] + [InlineData("ftp://config.example/api/config/orders")] + [InlineData("")] + public void QueryRequiresAbsoluteHttpEndpoint(string url) + { + var exception = Assert.Throws( + () => new ConfigHubProviderQueryOptions(url, "chcfg_valid")); + + Assert.Equal("url", exception.ParamName); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public void QueryRequiresDeliveryToken(string deliveryToken) + { + var exception = Assert.Throws( + () => new ConfigHubProviderQueryOptions( + "https://config.example/api/config/orders", + deliveryToken)); + + Assert.Equal("deliveryToken", exception.ParamName); + } + + [Fact] + public void InvalidBearerTokenIsRejectedWithoutEchoingCredential() + { + const string deliveryToken = "chcfg_secret\r\nX-Injected: value"; + + var exception = Assert.Throws( + () => new ConfigHubProviderQueryOptions( + "https://config.example/api/config/orders", + deliveryToken)); + + Assert.DoesNotContain(deliveryToken, exception.ToString(), StringComparison.Ordinal); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void ProviderOptionsRequirePositiveIntervals(int milliseconds) + { + var interval = TimeSpan.FromMilliseconds(milliseconds); + + Assert.Throws( + () => new ConfigHubProviderOptions(fallbackPollInterval: interval)); + Assert.Throws( + () => new ConfigHubProviderOptions(sseReadIdleTimeout: interval)); + } + + [Fact] + public void CredentialRotationChangesQueryIdentityWithoutSerializingToken() + { + var first = new ConfigHubProviderQueryOptions( + "https://config.example/api/config/orders", + "chcfg_first"); + var rotated = new ConfigHubProviderQueryOptions( + "https://config.example/api/config/orders", + "chcfg_rotated"); + + var firstJson = JsonSerializer.Serialize(first); + var rotatedJson = JsonSerializer.Serialize(rotated); + + Assert.NotEqual(first.CredentialFingerprint, rotated.CredentialFingerprint); + Assert.NotEqual(firstJson, rotatedJson); + Assert.DoesNotContain("chcfg_first", firstJson, StringComparison.Ordinal); + Assert.DoesNotContain("chcfg_rotated", rotatedJson, StringComparison.Ordinal); + } + + [Fact] + public void RuleOptionsSerializationExcludesSecretsAndHandler() + { + using var handler = new HttpClientHandler(); + var options = new ConfigHubRuleOptions( + "https://config.example/api/config/orders", + "chcfg_secret", + handler: handler); + + var json = JsonSerializer.Serialize(options); + + Assert.DoesNotContain("chcfg_secret", json, StringComparison.Ordinal); + Assert.DoesNotContain("Handler", json, StringComparison.Ordinal); + } + + [Fact] + public void CustomHandlerDisablesProviderSharingAndRemainsCallerOwned() + { + var handler = new TrackingHandler(); + var options = new ConfigHubProviderOptions(handler: handler); + + Assert.Null(options.GenerateProviderKey()); + + using (var provider = new ConfigHubProvider(options)) + { + } + + Assert.False(handler.IsDisposed); + handler.Dispose(); + Assert.True(handler.IsDisposed); + } + + private sealed class TrackingHandler : HttpMessageHandler + { + public bool IsDisposed { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + IsDisposed = true; + base.Dispose(disposing); + } + } +} diff --git a/src/tests/Cocoar.Configuration.ConfigHub.Tests/ConfigHubProviderTests.cs b/src/tests/Cocoar.Configuration.ConfigHub.Tests/ConfigHubProviderTests.cs new file mode 100644 index 0000000..f6c2417 --- /dev/null +++ b/src/tests/Cocoar.Configuration.ConfigHub.Tests/ConfigHubProviderTests.cs @@ -0,0 +1,280 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; + +namespace Cocoar.Configuration.ConfigHub.Tests; + +public sealed class ConfigHubProviderTests +{ + [Fact] + public async Task InvalidationEventRefetchesSnapshotConditionallyAndIgnoresEventPayload() + { + var handler = new ProtocolHandler(); + using var provider = new ConfigHubProvider(new ConfigHubProviderOptions(handler: handler)); + var query = new ConfigHubProviderQueryOptions( + "https://config.example/api/config/orders", + "chcfg_secret"); + + var initial = await provider.FetchConfigurationBytesAsync(query, CancellationToken.None); + Assert.Equal("{\"version\":1}", Encoding.UTF8.GetString(initial)); + + var update = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var subscription = provider.ChangesAsBytes(query).Subscribe(new TestObserver( + bytes => update.TrySetResult(Encoding.UTF8.GetString(bytes)), + error => update.TrySetException(error))); + + var completed = await Task.WhenAny( + update.Task, + Task.Delay(TimeSpan.FromSeconds(3), CancellationToken.None)); + + Assert.Same(update.Task, completed); + Assert.Equal("{\"version\":2}", await update.Task); + subscription.Dispose(); + Assert.Equal(3, handler.SnapshotRequests); + Assert.All(handler.AuthorizationSchemes, scheme => Assert.Equal("Bearer", scheme)); + Assert.All(handler.AuthorizationParameters, token => Assert.Equal("chcfg_secret", token)); + Assert.Equal(["\"v1\"", "\"v1\""], handler.IfNoneMatchValues); + } + + [Fact] + public void QuerySerializationIdentityDoesNotExposeDeliveryToken() + { + var query = new ConfigHubProviderQueryOptions( + "https://config.example/api/config/orders", + "chcfg_do-not-serialize"); + + var json = System.Text.Json.JsonSerializer.Serialize(query); + + Assert.DoesNotContain("chcfg_do-not-serialize", json, StringComparison.Ordinal); + Assert.Contains(query.CredentialFingerprint, json, StringComparison.Ordinal); + } + + [Fact] + public async Task FallbackPollRefreshesSnapshotWhileSseConnectionRemainsOpen() + { + using var handler = new ResilienceHandler(); + using var provider = new ConfigHubProvider(new ConfigHubProviderOptions( + fallbackPollInterval: TimeSpan.FromMilliseconds(50), + handler: handler)); + var query = new ConfigHubProviderQueryOptions( + "https://config.example/api/config/orders", + "chcfg_secret"); + + await provider.FetchConfigurationBytesAsync(query, CancellationToken.None); + + var update = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var subscription = provider.ChangesAsBytes(query).Subscribe(new TestObserver( + bytes => update.TrySetResult(Encoding.UTF8.GetString(bytes)), + error => update.TrySetException(error))); + + var completed = await Task.WhenAny(update.Task, Task.Delay(TimeSpan.FromSeconds(5))); + + Assert.Same(update.Task, completed); + Assert.Equal("{\"version\":2}", await update.Task); + Assert.True(handler.SseRequests >= 1); + Assert.True(handler.ConditionalSnapshotRequests >= 2); + } + + [Fact] + public async Task IdleSseReconnectReconcilesSnapshotBeforeReadingNextEvent() + { + using var handler = new ResilienceHandler(); + using var provider = new ConfigHubProvider(new ConfigHubProviderOptions( + sseReadIdleTimeout: TimeSpan.FromMilliseconds(50), + handler: handler)); + var query = new ConfigHubProviderQueryOptions( + "https://config.example/api/config/orders", + "chcfg_secret"); + + await provider.FetchConfigurationBytesAsync(query, CancellationToken.None); + + var update = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var subscription = provider.ChangesAsBytes(query).Subscribe(new TestObserver( + bytes => update.TrySetResult(Encoding.UTF8.GetString(bytes)), + error => update.TrySetException(error))); + + var completed = await Task.WhenAny(update.Task, Task.Delay(TimeSpan.FromSeconds(5))); + + Assert.Same(update.Task, completed); + Assert.Equal("{\"version\":2}", await update.Task); + Assert.True(handler.SseRequests >= 2); + Assert.True(handler.ConditionalSnapshotRequests >= 2); + } + + private sealed class ProtocolHandler : HttpMessageHandler + { + private int _snapshotRequests; + + public int SnapshotRequests => Volatile.Read(ref _snapshotRequests); + public List AuthorizationSchemes { get; } = []; + public List AuthorizationParameters { get; } = []; + public List IfNoneMatchValues { get; } = []; + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + lock (AuthorizationSchemes) + { + AuthorizationSchemes.Add(request.Headers.Authorization?.Scheme); + AuthorizationParameters.Add(request.Headers.Authorization?.Parameter); + } + + if (request.Headers.Accept.Any(value => value.MediaType == "text/event-stream")) + { + var body = new MemoryStream(Encoding.UTF8.GetBytes( + "event: config-changed\n" + + "data: this payload is deliberately not JSON\n\n")); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(body) + { + Headers = { ContentType = new MediaTypeHeaderValue("text/event-stream") }, + }, + }); + } + + var requestNumber = Interlocked.Increment(ref _snapshotRequests); + var validator = request.Headers.IfNoneMatch.SingleOrDefault()?.ToString(); + if (validator is not null) + { + lock (IfNoneMatchValues) + { + IfNoneMatchValues.Add(validator); + } + } + + return Task.FromResult(requestNumber switch + { + 1 => JsonResponse("{\"version\":1}", "\"v1\""), + 2 => NotModified("\"v1\""), + _ => JsonResponse("{\"version\":2}", "\"v2\""), + }); + } + + private static HttpResponseMessage JsonResponse(string json, string entityTag) + { + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + response.Headers.ETag = new EntityTagHeaderValue(entityTag); + return response; + } + + private static HttpResponseMessage NotModified(string entityTag) + { + var response = new HttpResponseMessage(HttpStatusCode.NotModified); + response.Headers.ETag = new EntityTagHeaderValue(entityTag); + return response; + } + } + + private sealed class TestObserver(Action onNext, Action onError) : IObserver + { + public void OnNext(byte[] value) => onNext(value); + public void OnError(Exception error) => onError(error); + public void OnCompleted() { } + } + + private sealed class ResilienceHandler : HttpMessageHandler + { + private int _conditionalSnapshotRequests; + private int _sseRequests; + + public int ConditionalSnapshotRequests => Volatile.Read(ref _conditionalSnapshotRequests); + public int SseRequests => Volatile.Read(ref _sseRequests); + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + if (request.Headers.Accept.Any(value => value.MediaType == "text/event-stream")) + { + Interlocked.Increment(ref _sseRequests); + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(new BlockingReadStream()), + }; + response.Content.Headers.ContentType = new MediaTypeHeaderValue("text/event-stream"); + return Task.FromResult(response); + } + + var validator = request.Headers.IfNoneMatch.SingleOrDefault()?.ToString(); + if (validator is null) + { + return Task.FromResult(CreateJsonResponse("{\"version\":1}", "\"v1\"")); + } + + var conditionalRequest = Interlocked.Increment(ref _conditionalSnapshotRequests); + return Task.FromResult(conditionalRequest == 1 + ? CreateNotModifiedResponse("\"v1\"") + : CreateJsonResponse("{\"version\":2}", "\"v2\"")); + } + + private static HttpResponseMessage CreateJsonResponse(string json, string entityTag) + { + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + response.Headers.ETag = new EntityTagHeaderValue(entityTag); + return response; + } + + private static HttpResponseMessage CreateNotModifiedResponse(string entityTag) + { + var response = new HttpResponseMessage(HttpStatusCode.NotModified); + response.Headers.ETag = new EntityTagHeaderValue(entityTag); + return response; + } + } + + private sealed class BlockingReadStream : Stream + { + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + + public override async Task ReadAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return 0; + } + + public override async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return 0; + } + + public override long Seek(long offset, SeekOrigin origin) + => throw new NotSupportedException(); + + public override void SetLength(long value) + => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + } +} diff --git a/src/tests/Cocoar.Configuration.ConfigHub.Tests/DynamicFromConfigHubTests.cs b/src/tests/Cocoar.Configuration.ConfigHub.Tests/DynamicFromConfigHubTests.cs new file mode 100644 index 0000000..4f1d3d9 --- /dev/null +++ b/src/tests/Cocoar.Configuration.ConfigHub.Tests/DynamicFromConfigHubTests.cs @@ -0,0 +1,170 @@ +using System.Net; +using System.Net.Http.Headers; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers; + +namespace Cocoar.Configuration.ConfigHub.Tests; + +public sealed class DynamicFromConfigHubTests +{ + [Fact] + public async Task DynamicRuleSwitchesSnapshotUrlWhenItsSourceChanges() + { + using var source = new BehaviorSource("""{ "Region": "us" }"""); + using var handler = new RegionRoutingHandler(); + + using var manager = ConfigManager.Create(configuration => configuration + .UseConfiguration(rules => + [ + rules.For().FromObservable(source), + rules.For().FromConfigHub(accessor => new ConfigHubRuleOptions( + $"https://config.example/{accessor.GetConfig()!.Region}/config", + "chcfg_dynamic-test", + handler: handler)), + ]) + .UseDebounce(25)); + + Assert.Equal("US", manager.GetConfig()!.Value); + + source.OnNext("""{ "Region": "eu" }"""); + + var deadline = DateTime.UtcNow.AddSeconds(5); + while (DateTime.UtcNow < deadline && manager.GetConfig()!.Value != "EU") + { + await Task.Delay(25, CancellationToken.None); + } + + Assert.Equal("eu", manager.GetConfig()!.Region); + Assert.Equal("EU", manager.GetConfig()!.Value); + Assert.Contains(handler.SnapshotPaths, path => path.Contains("/eu/", StringComparison.Ordinal)); + } + + private sealed class RegionSettings + { + public string Region { get; set; } = string.Empty; + } + + private sealed class RemoteSettings + { + public string Value { get; set; } = string.Empty; + } + + private sealed class RegionRoutingHandler : HttpMessageHandler + { + private readonly Lock _gate = new(); + private readonly List _snapshotPaths = []; + + public IReadOnlyList SnapshotPaths + { + get + { + lock (_gate) + { + return _snapshotPaths.ToArray(); + } + } + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + if (request.Headers.Accept.Any(value => value.MediaType == "text/event-stream")) + { + var stream = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(": connected\n\n"), + }; + stream.Content.Headers.ContentType = new MediaTypeHeaderValue("text/event-stream"); + return Task.FromResult(stream); + } + + var path = request.RequestUri!.AbsolutePath; + lock (_gate) + { + _snapshotPaths.Add(path); + } + + var value = path.Contains("/eu/", StringComparison.Ordinal) ? "EU" : "US"; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent($$"""{ "Value": "{{value}}" }"""), + }); + } + } + + private sealed class BehaviorSource(T initialValue) : IObservable, IDisposable + { + private readonly Lock _gate = new(); + private readonly List> _observers = []; + private T _value = initialValue; + private bool _disposed; + + public IDisposable Subscribe(IObserver observer) + { + ArgumentNullException.ThrowIfNull(observer); + + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _observers.Add(observer); + observer.OnNext(_value); + } + + return new ObserverSubscription(this, observer); + } + + public void OnNext(T value) + { + IObserver[] observers; + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _value = value; + observers = _observers.ToArray(); + } + + foreach (var observer in observers) + { + observer.OnNext(value); + } + } + + public void Dispose() + { + IObserver[] observers; + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + observers = _observers.ToArray(); + _observers.Clear(); + } + + foreach (var observer in observers) + { + observer.OnCompleted(); + } + } + + private void Unsubscribe(IObserver observer) + { + lock (_gate) + { + _observers.Remove(observer); + } + } + + private sealed class ObserverSubscription( + BehaviorSource source, + IObserver observer) : IDisposable + { + public void Dispose() => source.Unsubscribe(observer); + } + } +} diff --git a/src/tests/Cocoar.Configuration.ConfigHub.Tests/RequiredConfigHubTests.cs b/src/tests/Cocoar.Configuration.ConfigHub.Tests/RequiredConfigHubTests.cs new file mode 100644 index 0000000..044344f --- /dev/null +++ b/src/tests/Cocoar.Configuration.ConfigHub.Tests/RequiredConfigHubTests.cs @@ -0,0 +1,53 @@ +using System.Net; +using System.Net.Http.Headers; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers; + +namespace Cocoar.Configuration.ConfigHub.Tests; + +public sealed class RequiredConfigHubTests +{ + [Fact] + public void RequiredRuleRejectsInvalidDeliveryCredentialsDuringStartup() + { + using var handler = new UnauthorizedHandler(); + + var exception = Assert.ThrowsAny(() => + { + using var manager = ConfigManager.Create(configuration => configuration + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("""{ "Value": "local" }"""), + rules.For().FromConfigHub( + "https://config.example/api/config/demo-app", + "chcfg_wrong", + handler: handler) + .Required(), + ])); + }); + + Assert.Contains("401", exception.ToString(), StringComparison.Ordinal); + Assert.DoesNotContain("chcfg_wrong", exception.ToString(), StringComparison.Ordinal); + Assert.Equal("Bearer", handler.Authorization?.Scheme); + Assert.Equal("chcfg_wrong", handler.Authorization?.Parameter); + } + + private sealed class RemoteSettings + { + public string Value { get; set; } = string.Empty; + } + + private sealed class UnauthorizedHandler : HttpMessageHandler + { + public AuthenticationHeaderValue? Authorization { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Authorization = request.Headers.Authorization; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized)); + } + } +} diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index 994885c..b1a470f 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -68,6 +68,7 @@ export default defineConfig({ { text: 'INI', link: '/guide/providers/ini' }, { text: 'Environment Variables', link: '/guide/providers/environment' }, { text: 'Command Line', link: '/guide/providers/command-line' }, + { text: 'ConfigHub', link: '/guide/providers/confighub' }, { text: 'HTTP Polling', link: '/guide/providers/http-polling' }, { text: 'Microsoft IConfiguration', link: '/guide/providers/microsoft-adapter' }, { text: 'Static & Observable', link: '/guide/providers/static-observable' }, diff --git a/website/changelog.md b/website/changelog.md index 6230c3f..ca6291d 100644 --- a/website/changelog.md +++ b/website/changelog.md @@ -1,5 +1,13 @@ # Changelog +## [Unreleased] + +### Added +- **`Cocoar.Configuration.ConfigHub`** — new opt-in ConfigHub delivery provider with bearer-authenticated JSON snapshots, ETag-based conditional refresh, SSE invalidation, reconnect reconciliation, and optional polling fallback. Static and config-aware `FromConfigHub()` rules support endpoint and token rotation without serializing delivery credentials. + +### Documentation +- Added the ConfigHub provider guide, package reference, installation paths, and an updated ConfigHub roadmap that distinguishes the available runtime provider from the separately developed management portal. + ## [6.1.1] — 2026-08-17 ### Fixed diff --git a/website/guide/getting-started.md b/website/guide/getting-started.md index 9b9878b..7df8222 100644 --- a/website/guide/getting-started.md +++ b/website/guide/getting-started.md @@ -19,6 +19,7 @@ You only need **one** of these — install the highest one you need. Optional packages for additional providers: ```shell +dotnet add package Cocoar.Configuration.ConfigHub # ConfigHub delivery + live invalidation dotnet add package Cocoar.Configuration.Http # Remote config via HTTP dotnet add package Cocoar.Configuration.MicrosoftAdapter # Bridge existing IConfiguration ``` diff --git a/website/guide/providers/confighub.md b/website/guide/providers/confighub.md new file mode 100644 index 0000000..a174d35 --- /dev/null +++ b/website/guide/providers/confighub.md @@ -0,0 +1,99 @@ +--- +description: Cocoar.Configuration.ConfigHub provider — authenticated snapshots, ETag validation, SSE invalidation, reconnect reconciliation, polling fallback, and dynamic endpoints +--- + +# ConfigHub Provider + +`Cocoar.Configuration.ConfigHub` connects an application to a ConfigHub delivery endpoint. It is a ConfigHub-specific integration; use the [generic HTTP provider](/guide/providers/http-polling) for other HTTP configuration services. + +```shell +dotnet add package Cocoar.Configuration.ConfigHub +``` + +```csharp +using Cocoar.Configuration.ConfigHub; + +builder.AddCocoarConfiguration(configuration => configuration + .UseConfiguration(rule => + [ + rule.For().FromFile("appsettings.json"), + rule.For().FromConfigHub( + "https://config.example/api/config/my-app", + Environment.GetEnvironmentVariable("CONFIGHUB_DELIVERY_TOKEN")!), + ])); +``` + +The local file supplies defaults. The later ConfigHub layer overrides the properties present in its snapshot and participates in the normal atomic merge and notification pipeline. + +## Delivery Protocol + +The provider treats the JSON snapshot as the only authoritative configuration: + +1. It fetches the endpoint with `Accept: application/json` and `Authorization: Bearer `. +2. It stores the response ETag and opens the same endpoint with `Accept: text/event-stream`. +3. An SSE event containing a `data` field invalidates the snapshot; the event payload itself is never parsed as configuration. +4. The provider conditionally refetches with `If-None-Match`. A `304 Not Modified` produces no configuration update. +5. Every SSE reconnect starts with a conditional refetch, closing the gap for events missed while disconnected. + +SSE reconnects use exponential backoff. A periodic conditional poll can run alongside it as an additional safety net. + +## Options + +| Option | Default | Description | +|---|---|---| +| `url` | Required | Absolute HTTP or HTTPS ConfigHub delivery URL | +| `deliveryToken` | Required | Bearer token for the delivery endpoint | +| `fallbackPollInterval` | `null` | Optional conditional poll running alongside SSE | +| `sseReadIdleTimeout` | `null` | Reconnect if neither events nor keep-alive lines arrive in this interval | +| `handler` | `null` | Optional caller-owned `HttpMessageHandler`, useful for custom transport or tests | + +Intervals must be greater than zero. The provider never disposes a supplied handler. + +## Required Configuration + +Mark the ConfigHub layer required when the application must not start without a successful initial snapshot: + +```csharp +rule.For() + .FromConfigHub(deliveryUrl, deliveryToken) + .Required() +``` + +This makes endpoint, authentication, and initial download failures fail startup through Cocoar.Configuration's normal required-rule behavior. Without `.Required()`, lower layers can continue to provide defaults according to the optional-rule policy. + +## Dynamic Endpoints and Token Rotation + +Endpoint and token can depend on configuration established by earlier rules: + +```csharp +rule => +[ + rule.For().FromEnvironment("DEPLOYMENT_"), + rule.For().FromConfigHub(accessor => + { + var deployment = accessor.GetConfig()!; + return new ConfigHubRuleOptions( + $"https://config.example/api/config/{deployment.Application}", + deployment.DeliveryToken, + fallbackPollInterval: TimeSpan.FromMinutes(5)); + }), +] +``` + +When the endpoint or token changes, Cocoar.Configuration rebuilds the query and its live subscription. The delivery token is excluded from serialized rule identity and provider diagnostics; a SHA-256 fingerprint provides change identity without serializing the credential itself. + +## Transport Customization + +Pass a caller-owned handler for mutual TLS, proxy settings, or a custom `DelegatingHandler` chain: + +```csharp +var handler = new HttpClientHandler(); +handler.ClientCertificates.Add(clientCertificate); + +rule.For().FromConfigHub( + deliveryUrl, + deliveryToken, + handler: handler) +``` + +A rule with a custom handler receives a dedicated provider instance so unrelated rules cannot accidentally share transport state. diff --git a/website/guide/providers/overview.md b/website/guide/providers/overview.md index d38f75f..c765826 100644 --- a/website/guide/providers/overview.md +++ b/website/guide/providers/overview.md @@ -34,6 +34,7 @@ On failure, providers return an empty JSON object `{}` — never null. This mean | [Static JSON](/guide/providers/static-observable#static-json) | `.FromStaticJson("{...}")` | No | Core | | [Observable](/guide/providers/static-observable#observable) | `.FromObservable(obs)` | Yes | Core | | [Writable Store](/guide/providers/writable-store) | `.FromStore()` | Yes (on write) | Core | +| [ConfigHub](/guide/providers/confighub) | `.FromConfigHub(url, token)` | SSE invalidation / polling fallback | ConfigHub | | [HTTP](/guide/providers/http-polling) | `.FromHttp(url)` | Polling / SSE / one-time | Http | | [Microsoft IConfiguration](/guide/providers/microsoft-adapter) | `.FromIConfiguration(config)` | IConfiguration reload token | MicrosoftAdapter | diff --git a/website/guide/roadmap.md b/website/guide/roadmap.md index ddb6d4a..e8cba0a 100644 --- a/website/guide/roadmap.md +++ b/website/guide/roadmap.md @@ -4,4 +4,4 @@ description: Pointer to the full roadmap — ConfigHub, cloud providers, databas # What's Next -See the full [Roadmap](/roadmap/overview) for what's planned — including ConfigHub, cloud providers, database provider, and more. +See the full [Roadmap](/roadmap/overview) for the available ConfigHub runtime provider and the planned ConfigHub portal, cloud providers, database provider, and more. diff --git a/website/reference/packages.md b/website/reference/packages.md index 4707721..1e000ca 100644 --- a/website/reference/packages.md +++ b/website/reference/packages.md @@ -1,5 +1,5 @@ --- -description: NuGet package breakdown — Abstractions, Core, DI, AspNetCore, Http, MicrosoftAdapter, WritableStore.Marten, Analyzers, Secrets CLI; dependency graph and which to install +description: NuGet package breakdown — Abstractions, Core, DI, AspNetCore, ConfigHub, Http, MicrosoftAdapter, WritableStore.Marten, Analyzers, Secrets CLI; dependency graph and which to install --- # Package Overview @@ -70,6 +70,18 @@ Remote configuration provider with support for one-time fetch, polling, and Serv ``` +### Cocoar.Configuration.ConfigHub + +ConfigHub-specific delivery provider. It loads authenticated, authoritative JSON snapshots and uses Server-Sent Events only to invalidate the current snapshot. ETag validation, reconnect reconciliation, and optional polling fallback keep instances current without trusting event payloads as configuration. + +- **Target:** .NET 9.0 / .NET 10.0 +- **Dependencies:** Cocoar.Configuration +- **Key types:** `FromConfigHub()` extension method, `ConfigHubRuleOptions` + +```xml + +``` + ### Cocoar.Configuration.MicrosoftAdapter Bridge from `Microsoft.Extensions.Configuration` sources (Azure Key Vault, custom providers, etc.) into Cocoar.Configuration. @@ -145,6 +157,7 @@ Abstractions (no deps) ▼ Core ◄──── Analyzers (build-time) │ + ├──► ConfigHub ├──► Http ├──► MicrosoftAdapter │ @@ -165,12 +178,13 @@ Each arrow means "depends on". Installing a downstream package brings all upstre | Console app or library with DI | `Cocoar.Configuration.DI` | | Library without DI | `Cocoar.Configuration` | | Interface-only dependency | `Cocoar.Configuration.Abstractions` | +| ConfigHub-managed configuration | Add `Cocoar.Configuration.ConfigHub` | | Remote config (polling / SSE) | Add `Cocoar.Configuration.Http` | | Existing `IConfiguration` sources | Add `Cocoar.Configuration.MicrosoftAdapter` | ## External Dependencies -All shipped packages have **zero non-Microsoft external dependencies**. The only third-party packages are Cocoar ecosystem libraries (`Cocoar.Capabilities`, `Cocoar.FileSystem`, `Cocoar.Json.Mutable`). +Dependencies stay local to the package that needs them. `Cocoar.Configuration.ConfigHub` and `Cocoar.Configuration.Http` add no dependency beyond the core package and the .NET BCL. Format and persistence integrations intentionally bring their documented libraries, such as YamlDotNet, Tomlyn, and Marten; the core uses Cocoar ecosystem libraries and Microsoft abstractions. `System.Reactive` is **not** a dependency — the library uses lightweight internal reactive primitives. Consumers are free to use System.Reactive on their side (the public API is `IObservable`, which is BCL). diff --git a/website/roadmap/confighub.md b/website/roadmap/confighub.md index 35ba6e4..6406c87 100644 --- a/website/roadmap/confighub.md +++ b/website/roadmap/confighub.md @@ -1,5 +1,5 @@ --- -description: ConfigHub management portal (commercial) — push config to fleets via FromConfigHub(), secret/cert lifecycle, feature flag control, health dashboard, OTLP telemetry +description: ConfigHub management portal roadmap and the available Cocoar.Configuration.ConfigHub delivery provider --- # ConfigHub @@ -41,14 +41,16 @@ Rich per-rule health snapshots, recompute timing, provider error rates, configur ConfigHub connects to your instances via the standard provider model. It's just another configuration source — the library doesn't know or care whether the bytes come from a file, HTTP endpoint, or ConfigHub: ```csharp +using Cocoar.Configuration.ConfigHub; + builder.AddCocoarConfiguration(c => c .UseConfiguration(rule => [ - rule.For().FromFile("appsettings.json"), // Local defaults - rule.For().FromConfigHub(), // Remote overrides from ConfigHub + rule.For().FromFile("appsettings.json"), + rule.For().FromConfigHub(deliveryUrl, deliveryToken), ])); ``` -The `FromConfigHub()` provider uses the existing reactive pipeline — changes pushed from ConfigHub trigger the same recompute/merge/notify cycle as a file change. No special runtime behavior. +The open-source [`Cocoar.Configuration.ConfigHub` provider](/guide/providers/confighub) is available as one dedicated opt-in package. It loads authoritative JSON snapshots, uses SSE as an invalidation signal, and feeds changes through the existing recompute/merge/notify pipeline. ### Data Flow @@ -77,6 +79,6 @@ The library does **not** phone home, require a license key, or degrade without C ## Status -ConfigHub is in the design phase. Architecture, data model, and provider protocol are being defined. A private preview is planned after the cloud providers ship. +The runtime delivery provider and its protocol integration are implemented in the open-source library. The hosted ConfigHub management portal, data model, and operational features described above remain in design; a private preview is planned after the cloud providers ship. If you're interested in early access, watch the [GitHub repository](https://github.com/cocoar-dev/Cocoar.Configuration) for announcements. diff --git a/website/roadmap/overview.md b/website/roadmap/overview.md index 3173f83..8799895 100644 --- a/website/roadmap/overview.md +++ b/website/roadmap/overview.md @@ -10,7 +10,7 @@ Cocoar.Configuration is the open-source foundation — fully functional today fo | Initiative | Status | Impact | |---|---|---| -| [ConfigHub](/roadmap/confighub) | In Design | Management portal for config, secrets, and flags at scale | +| [ConfigHub](/roadmap/confighub) | Provider available; portal in design | Management portal for config, secrets, and flags at scale | | [Cloud Providers](/roadmap/cloud-providers) | Planned | Azure Key Vault, AWS Secrets Manager | | [Database Provider](/roadmap/database-provider) | Planned | Tenant-specific config from SQL |