diff --git a/.github/workflows/01-pr-validation.yml b/.github/workflows/01-pr-validation.yml index 3775458..7fc9db8 100644 --- a/.github/workflows/01-pr-validation.yml +++ b/.github/workflows/01-pr-validation.yml @@ -28,7 +28,9 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: | + 9.0.x + 10.0.x - name: Install GitVersion uses: gittools/actions/gitversion/setup@v3 diff --git a/.github/workflows/02-develop-build-alpha.yml b/.github/workflows/02-develop-build-alpha.yml index edce1d7..45c608a 100644 --- a/.github/workflows/02-develop-build-alpha.yml +++ b/.github/workflows/02-develop-build-alpha.yml @@ -28,7 +28,9 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: | + 9.0.x + 10.0.x - name: Restore dependencies run: dotnet restore @@ -59,7 +61,9 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: | + 9.0.x + 10.0.x - name: Install GitVersion uses: gittools/actions/gitversion/setup@v3 diff --git a/.github/workflows/03-publish-prerelease.yml b/.github/workflows/03-publish-prerelease.yml index 98968cb..7d17509 100644 --- a/.github/workflows/03-publish-prerelease.yml +++ b/.github/workflows/03-publish-prerelease.yml @@ -30,7 +30,9 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: | + 9.0.x + 10.0.x - name: Restore dependencies run: dotnet restore @@ -68,7 +70,9 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: | + 9.0.x + 10.0.x - name: Install GitVersion uses: gittools/actions/gitversion/setup@v3 diff --git a/.github/workflows/04-publish-stable.yml b/.github/workflows/04-publish-stable.yml index 4d8a871..e6c27b9 100644 --- a/.github/workflows/04-publish-stable.yml +++ b/.github/workflows/04-publish-stable.yml @@ -59,8 +59,8 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: | - 8.0.x 9.0.x + 10.0.x - name: Restore dependencies run: dotnet restore @@ -94,8 +94,8 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: | - 8.0.x 9.0.x + 10.0.x - name: Restore dependencies run: dotnet restore diff --git a/CHANGELOG.md b/CHANGELOG.md index d38d7c1..71728ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## [Unreleased] + +> **v6.0.0** — major release. + +### Breaking +- **Dropped .NET 8 support.** All packages now multi-target `net9.0` and `net10.0` (was `net8.0` / `net9.0`). Consumers must target .NET 9 or later. +- `Microsoft.Extensions.*` dependencies moved to the `10.0.x` line, aligned with .NET 10. `10.0.x` ships a native `net9.0` target, so .NET 9 consumers take no runtime hit. + +### Removed +- `IConfigurationAccessor.GetRequiredConfig()` / `GetRequiredConfig(Type)` — deprecated since v5; use `GetConfig()` / `GetConfig(Type)` (identical throw-on-missing behavior). +- `FromMicrosoftSource(...)` — use `FromIConfiguration(IConfiguration)`. +- `X509CertificateGenerator.GenerateAndSave(...)` — use `GenerateAndSavePfx(...)` / `GenerateAndSavePem(...)`. + +### Added +- **`Cocoar.Configuration.WritableStore.Marten`** — opt-in Marten (PostgreSQL) WritableStore backend. `MartenStoreBackend` persists overrides as one `CocoarConfigDocument` per configuration type; `FromMartenStore()` (service-backed, Layer-2) resolves the `IDocumentStore` from DI and, combined with `.TenantScoped()`, gives **database-per-tenant** configuration (each tenant's overlay in its own database). +- **WritableStore `PatchAsync`** — `IWritableStore.PatchAsync(b => b.Set(...).SetSecret(...).Reset(...))` applies any number of mutations as one atomic write and one recompute; single-value `SetAsync` / `SetSecretAsync` / `ResetAsync` delegate to it. + +### Changed +- Configuration **layer merging is now case-insensitive** on property names (via Cocoar.Json.Mutable 1.2.0), consistent with how the effective config is read back. + +### Fixed +- Resetting a secret-typed member no longer throws `NotSupportedException` (removing an override exposes no plaintext). + ## [5.1.0] - 2026-05-31 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 20e3bbd..dfca97a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ dotnet pack ./src -c Release ## Architecture Overview -**Cocoar.Configuration** is a reactive, strongly-typed configuration library for .NET 8.0+. The architecture follows a modular, capabilities-driven design. +**Cocoar.Configuration** is a reactive, strongly-typed configuration library for .NET 9.0+ (multi-targets `net9.0` and `net10.0`). The architecture follows a modular, capabilities-driven design. ### Core Components @@ -72,7 +72,7 @@ capabilityScope.Compose(this).WithPrimary(new ConcreteTypePrimary(...)); SetupDefinition.GetComposer(builder).Add(new ServiceLifetimeCapability(...)); ``` -**Zero External Dependencies** - Shipped packages have no non-Microsoft dependencies. The reactive internals (`Reactive/Internal/`) are lightweight replacements for the subset of System.Reactive the library used (Subject, BehaviorSubject, Select/Where/DistinctUntilChanged). This is intentional — do not add System.Reactive back. The public API (`IReactiveConfig : IObservable`) uses only BCL types; consumers are free to use System.Reactive on their side. Test projects still reference System.Reactive as a test dependency. +**Zero External Dependencies** - Core shipped packages have no non-Microsoft dependencies. (Opt-in integration packages are the deliberate exception: `Cocoar.Configuration.WritableStore.Marten` takes a Marten dependency. Consumers who don't reference it pay nothing.) The reactive internals (`Reactive/Internal/`) are lightweight replacements for the subset of System.Reactive the library used (Subject, BehaviorSubject, Select/Where/DistinctUntilChanged). This is intentional — do not add System.Reactive back. The public API (`IReactiveConfig : IObservable`) uses only BCL types; consumers are free to use System.Reactive on their side. Test projects still reference System.Reactive as a test dependency. **Reactive Tuples** - `IReactiveConfig<(T1, T2)>` provides atomic multi-config updates. Multiple configs always update together, preventing inconsistent state. @@ -90,6 +90,7 @@ SetupDefinition.GetComposer(builder).Add(new ServiceLifetimeCapability(...)); | `Cocoar.Configuration.AspNetCore` | ASP.NET Core integration, health endpoints, feature flag/entitlement REST endpoints (incl. per-tenant), secrets encryption-key endpoints, scoped tenant config adapter | | `Cocoar.Configuration.Http` | Remote config provider (polling, SSE, one-time fetch) | | `Cocoar.Configuration.MicrosoftAdapter` | Bridge to existing `IConfiguration` sources | +| `Cocoar.Configuration.WritableStore.Marten` | Marten (PostgreSQL) WritableStore backend (`MartenStoreBackend`, `FromMartenStore()`); tenant-aware via Marten database-per-tenant. Opt-in integration package that takes a Marten dependency. | | `Cocoar.Configuration.Analyzers` | Roslyn analyzers (COCFG001, 002, 003, 005, 006) and source generator (COCFLAG001-003). COCFG004 was removed — enforced by `where T : class` constraint instead. | | `Cocoar.Configuration.Secrets.Cli` | Global .NET tool for encrypting/decrypting secrets in config files | diff --git a/README.md b/README.md index a7e229e..461ca00 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ dotnet add package Cocoar.Configuration.DI # + Microsoft.Extensions.D dotnet add package Cocoar.Configuration.AspNetCore # + health endpoints, feature flags ``` -You only need **one** — each includes everything above it. Requires .NET 8+. +You only need **one** — each includes everything above it. Requires .NET 9+. ## Quick Start diff --git a/src/Cocoar.Configuration.Abstractions/Core/IConfigurationAccessor.cs b/src/Cocoar.Configuration.Abstractions/Core/IConfigurationAccessor.cs index 9b088b3..d7c16c4 100644 --- a/src/Cocoar.Configuration.Abstractions/Core/IConfigurationAccessor.cs +++ b/src/Cocoar.Configuration.Abstractions/Core/IConfigurationAccessor.cs @@ -31,16 +31,6 @@ public interface IConfigurationAccessor /// True if configuration exists for the type; false otherwise. bool TryGetConfig(out T? value) where T : class; - /// - /// Gets configuration, throwing if not found. - /// - /// - /// This method is deprecated. GetConfig now has the same behavior - it throws if no rule is registered. - /// - [Obsolete("Use GetConfig() instead - it now throws if no rule is registered. " + - "This method will be removed in a future version.")] - T GetRequiredConfig(); - /// /// Gets a configuration instance from the cached snapshot. /// @@ -58,16 +48,6 @@ public interface IConfigurationAccessor /// True if configuration exists for the type; false otherwise. bool TryGetConfig(Type type, out object? value); - /// - /// Gets configuration, throwing if not found. - /// - /// - /// This method is deprecated. GetConfig now has the same behavior - it throws if no rule is registered. - /// - [Obsolete("Use GetConfig(Type) instead - it now throws if no rule is registered. " + - "This method will be removed in a future version.")] - object GetRequiredConfig(Type type); - JsonElement? GetConfigAsJson(Type type); /// diff --git a/src/Cocoar.Configuration.Analyzers/README.md b/src/Cocoar.Configuration.Analyzers/README.md index 1733da9..21382b3 100644 --- a/src/Cocoar.Configuration.Analyzers/README.md +++ b/src/Cocoar.Configuration.Analyzers/README.md @@ -52,7 +52,7 @@ Validates that rules appear after their dependencies. ```csharp // ❌ Error: rule.For() - .When(accessor => accessor.GetRequiredConfig().IsEnabled), + .When(accessor => accessor.GetConfig()!.IsEnabled), rule.For().FromFile("api.json"), // COCFG002: ApiSettings not available - move this rule after ApiSettings rule ``` diff --git a/src/Cocoar.Configuration.MicrosoftAdapter/RulesExtensions.cs b/src/Cocoar.Configuration.MicrosoftAdapter/RulesExtensions.cs index 9d5b380..df64d90 100644 --- a/src/Cocoar.Configuration.MicrosoftAdapter/RulesExtensions.cs +++ b/src/Cocoar.Configuration.MicrosoftAdapter/RulesExtensions.cs @@ -26,19 +26,4 @@ public static _ => new MicrosoftConfigurationProviderQueryOptions(), typeof(T) ); - - /// - /// Creates a Microsoft configuration source rule with custom options. - /// - [Obsolete("Use FromIConfiguration(IConfiguration) instead.")] - public static - ProviderRuleBuilder FromMicrosoftSource(this TypedProviderBuilder builder, - Func optionsFactory) - where T : class - => new( - cm => optionsFactory(cm).ToProviderOptions(), - cm => optionsFactory(cm).ToQueryOptions(), - typeof(T) - ); } diff --git a/src/Cocoar.Configuration.WritableStore.Marten/Cocoar.Configuration.WritableStore.Marten.csproj b/src/Cocoar.Configuration.WritableStore.Marten/Cocoar.Configuration.WritableStore.Marten.csproj new file mode 100644 index 0000000..42d9912 --- /dev/null +++ b/src/Cocoar.Configuration.WritableStore.Marten/Cocoar.Configuration.WritableStore.Marten.csproj @@ -0,0 +1,21 @@ + + + + true + enable + enable + Marten (PostgreSQL document store) backend for the Cocoar.Configuration WritableStore. Persists writable configuration overlays as documents, with first-class support for Marten database-per-tenant multi-tenancy so each tenant's configuration lives in its own database. + configuration;marten;postgresql;postgres;writable-store;multi-tenancy;document-store;dependency-injection + + + + + + + + + + + + + diff --git a/src/Cocoar.Configuration.WritableStore.Marten/CocoarConfigDocument.cs b/src/Cocoar.Configuration.WritableStore.Marten/CocoarConfigDocument.cs new file mode 100644 index 0000000..208989d --- /dev/null +++ b/src/Cocoar.Configuration.WritableStore.Marten/CocoarConfigDocument.cs @@ -0,0 +1,26 @@ +namespace Cocoar.Configuration.WritableStore.Marten; + +/// +/// The Marten document that persists one WritableStore overlay. There is one document per configuration type: +/// is the storage key (the configuration type's full name) and is the sparse +/// overlay JSON the WritableStore reads and writes. +/// +/// +/// +/// You normally never touch this type directly — stores and loads it. It is public +/// so you can register it with Marten to control schema creation, e.g. options.Schema.For<CocoarConfigDocument>(), +/// rather than relying on Marten's runtime auto-creation. +/// +/// +/// With Marten database-per-tenant the document lives in the tenant's own database: the backend opens the session +/// for the current accessor.Tenant, so each tenant's configuration overlay is isolated by database. +/// +/// +public sealed class CocoarConfigDocument +{ + /// The storage key — the configuration type's full name (e.g. MyApp.Configuration.SmtpSettings). + public string Id { get; set; } = default!; + + /// The sparse overlay JSON (UTF-8 text) for this configuration type. Defaults to an empty object. + public string Json { get; set; } = "{}"; +} diff --git a/src/Cocoar.Configuration.WritableStore.Marten/MartenStoreBackend.cs b/src/Cocoar.Configuration.WritableStore.Marten/MartenStoreBackend.cs new file mode 100644 index 0000000..bdec85c --- /dev/null +++ b/src/Cocoar.Configuration.WritableStore.Marten/MartenStoreBackend.cs @@ -0,0 +1,69 @@ +using System.Text; +using Cocoar.Configuration.Providers; +using global::Marten; + +namespace Cocoar.Configuration.WritableStore.Marten; + +/// +/// An that persists WritableStore overlays as +/// documents in a Marten (PostgreSQL) store. One document per configuration type, keyed by the type's full name. +/// +/// +/// +/// Tenant routing. When constructed with a non-empty tenantId, every read and write opens its session +/// for that tenant. With Marten database-per-tenant (a multi-tenanted ) this routes the +/// document into the tenant's own database — so each tenant's configuration lives in its own DB. A null/blank +/// tenant uses Marten's default tenant (the single-database case). +/// +/// +/// The backend is intentionally stateless and cheap to construct: it holds the (DI-singleton) document store and a +/// tenant id, and opens a short-lived session per operation. That is exactly what the service-backed +/// FromStore rule expects, so it can re-create the backend each recompute without connection-pool churn +/// (Marten/Npgsql owns the pool). +/// +/// +public sealed class MartenStoreBackend : IStoreBackend +{ + private readonly IDocumentStore _store; + private readonly string? _tenantId; + + /// + /// Creates a backend over the given Marten , optionally bound to a tenant. + /// + /// The Marten document store, typically resolved from DI as a singleton. + /// The tenant whose database to read from / write to. null or blank uses + /// Marten's default tenant. Pass accessor.Tenant from a tenant-scoped rule. + public MartenStoreBackend(IDocumentStore store, string? tenantId = null) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + _tenantId = string.IsNullOrWhiteSpace(tenantId) ? null : tenantId; + } + + /// + public async Task ReadAsync(string key, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + await using var session = OpenQuerySession(); + var document = await session.LoadAsync(key, ct).ConfigureAwait(false); + return document?.Json is { } json ? Encoding.UTF8.GetBytes(json) : null; + } + + /// + public async Task WriteAsync(string key, byte[] data, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + ArgumentNullException.ThrowIfNull(data); + + await using var session = OpenSession(); + // Store is an upsert keyed by Id; SaveChangesAsync commits the single document in one transaction. + session.Store(new CocoarConfigDocument { Id = key, Json = Encoding.UTF8.GetString(data) }); + await session.SaveChangesAsync(ct).ConfigureAwait(false); + } + + private IQuerySession OpenQuerySession() + => _tenantId is null ? _store.QuerySession() : _store.QuerySession(_tenantId); + + private IDocumentSession OpenSession() + => _tenantId is null ? _store.LightweightSession() : _store.LightweightSession(_tenantId); +} diff --git a/src/Cocoar.Configuration.WritableStore.Marten/MartenWritableStoreExtensions.cs b/src/Cocoar.Configuration.WritableStore.Marten/MartenWritableStoreExtensions.cs new file mode 100644 index 0000000..76ca39f --- /dev/null +++ b/src/Cocoar.Configuration.WritableStore.Marten/MartenWritableStoreExtensions.cs @@ -0,0 +1,42 @@ +using Cocoar.Configuration.DI; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers; +using global::Marten; +using Microsoft.Extensions.DependencyInjection; + +namespace Cocoar.Configuration.WritableStore.Marten; + +/// +/// Service-backed (Layer-2, ADR-006) rule factory that backs a WritableStore with Marten. Valid only inside +/// UseServiceBackedConfiguration(...) — the rule stays dormant until the host starts and resolves the Marten +/// from the application container at recompute time. +/// +public static class MartenWritableStoreExtensions +{ + /// + /// Backs the configuration type with a Marten () WritableStore. The Marten + /// is resolved from DI and the current accessor.Tenant selects the tenant + /// database — combine with .TenantScoped() for per-tenant, database-per-tenant configuration: + /// + /// builder.UseServiceBackedConfiguration(rules => + /// [ + /// rules.For<TenantSettings>().FromMartenStore().TenantScoped().Build(), + /// ]); + /// + /// This also exposes the IWritableStore<TenantSettings> write facade (per tenant), since it reuses + /// the tenant-keyed WritableStore backend pipeline. + /// + /// The configuration type to populate from Marten. + /// The service-backed provider builder (from UseServiceBackedConfiguration). + public static ProviderRuleBuilder + FromMartenStore(this ServiceBackedProviderBuilder builder) + where T : class + { + ArgumentNullException.ThrowIfNull(builder); + + // Reuse the DI package's tenant-keyed, sp-gated FromStore seam: one MartenStoreBackend per tenant, each + // bound to accessor.Tenant so a write lands in that tenant's own database. + return builder.FromStore((sp, accessor) => + new MartenStoreBackend(sp.GetRequiredService(), accessor.Tenant)); + } +} diff --git a/src/Cocoar.Configuration.slnx b/src/Cocoar.Configuration.slnx index 03cdcd5..1c22214 100644 --- a/src/Cocoar.Configuration.slnx +++ b/src/Cocoar.Configuration.slnx @@ -11,6 +11,7 @@ + @@ -38,6 +39,7 @@ + diff --git a/src/Cocoar.Configuration/Core/ConfigManager.cs b/src/Cocoar.Configuration/Core/ConfigManager.cs index 22ff400..d3641e3 100644 --- a/src/Cocoar.Configuration/Core/ConfigManager.cs +++ b/src/Cocoar.Configuration/Core/ConfigManager.cs @@ -242,21 +242,12 @@ public bool TryGetConfig(out T? value) where T : class return _accessor.TryGetConfig(out value); } -#pragma warning disable CS0618 // Type or member is obsolete - public T GetRequiredConfig() => _accessor.GetRequiredConfig(); -#pragma warning restore CS0618 - /// public object GetConfig(Type type) => _accessor.GetConfig(type); /// public bool TryGetConfig(Type type, out object? value) => _accessor.TryGetConfig(type, out value); -#pragma warning disable CS0618 // Type or member is obsolete - /// - public object GetRequiredConfig(Type type) => _accessor.GetRequiredConfig(type); -#pragma warning restore CS0618 - /// /// Gets the current configuration snapshot for the specified type serialized as a . /// Returns null if no rule is registered for the type. diff --git a/src/Cocoar.Configuration/Core/ConfigurationAccessor.cs b/src/Cocoar.Configuration/Core/ConfigurationAccessor.cs index 48ef26e..30c1786 100644 --- a/src/Cocoar.Configuration/Core/ConfigurationAccessor.cs +++ b/src/Cocoar.Configuration/Core/ConfigurationAccessor.cs @@ -186,16 +186,6 @@ public bool TryGetConfig(out T? value) where T : class } } - /// - /// Gets configuration, throwing if not found. - /// - /// - /// This method is deprecated. GetConfig now has the same behavior - it throws if no rule is registered. - /// - [Obsolete("Use GetConfig() instead - it now throws if no rule is registered. " + - "This method will be removed in a future version.")] - public T GetRequiredConfig() => (T)GetConfig(typeof(T)); - /// /// Gets a configuration instance from the cached snapshot. /// During recompute, falls back to on-demand deserialization. @@ -284,15 +274,5 @@ public bool TryGetConfig(Type type, out object? value) } } - /// - /// Gets configuration, throwing if not found. - /// - /// - /// This method is deprecated. GetConfig now has the same behavior - it throws if no rule is registered. - /// - [Obsolete("Use GetConfig(Type) instead - it now throws if no rule is registered. " + - "This method will be removed in a future version.")] - public object GetRequiredConfig(Type type) => GetConfig(type); - public JsonElement? GetConfigAsJson(Type type) => _state.GetConfigurationAsJson(type); } diff --git a/src/Cocoar.Configuration/Providers/CommandLineProvider/README.md b/src/Cocoar.Configuration/Providers/CommandLineProvider/README.md index 7bebefa..59735bd 100644 --- a/src/Cocoar.Configuration/Providers/CommandLineProvider/README.md +++ b/src/Cocoar.Configuration/Providers/CommandLineProvider/README.md @@ -135,7 +135,7 @@ builder.Services.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ rule.For().FromCommandLine(accessor => { - var tenant = accessor.GetRequiredConfig(); + var tenant = accessor.GetConfig()!; return new CommandLineRuleOptions { Prefix = $"{tenant.Name}_", diff --git a/src/Cocoar.Configuration/Secrets/X509Encryption/X509CertificateGenerator.cs b/src/Cocoar.Configuration/Secrets/X509Encryption/X509CertificateGenerator.cs index 1544043..16a79e3 100644 --- a/src/Cocoar.Configuration/Secrets/X509Encryption/X509CertificateGenerator.cs +++ b/src/Cocoar.Configuration/Secrets/X509Encryption/X509CertificateGenerator.cs @@ -151,28 +151,6 @@ public static X509Certificate2 GenerateAndSavePem( } } - /// - /// Generates a self-signed certificate and saves it as a PFX file. - /// - /// Path where the certificate will be saved. - /// Password for PFX file. - /// Certificate subject (e.g., "CN=MyApp"). - /// Validity period in years (default: 1). - /// RSA key size in bits: 2048, 3072, or 4096 (default: 2048). - /// If true, overwrites existing files; otherwise throws if files exist. - /// The generated certificate. - [Obsolete("Use GenerateAndSavePfx or GenerateAndSavePem instead.")] - public static X509Certificate2 GenerateAndSave( - string outputPath, - string password, - string subject, - int validYears = 1, - int keySize = 2048, - bool overwrite = false) - { - return GenerateAndSavePfx(outputPath, password, subject, validYears, keySize, overwrite); - } - /// /// Converts a certificate from PFX to PEM format. /// diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 963e516..5198f72 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ - net8.0;net9.0 + net9.0;net10.0 latest enable enable diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index e244eec..c63a46d 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -6,31 +6,38 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + all @@ -41,6 +48,6 @@ - + diff --git a/src/Examples/SecretsBasicExample/Program.cs b/src/Examples/SecretsBasicExample/Program.cs index 8b2f613..0babe69 100644 --- a/src/Examples/SecretsBasicExample/Program.cs +++ b/src/Examples/SecretsBasicExample/Program.cs @@ -30,7 +30,7 @@ static void Main() var certPath = Path.Combine(Path.GetTempPath(), "cocoar-secrets-demo.pfx"); // Explicit certificate generation (password-less) - X509CertificateGenerator.GenerateAndSave( + X509CertificateGenerator.GenerateAndSavePfx( certPath, null, // Password-less certificate "CN=Dev Secrets", diff --git a/src/Examples/SecretsCertificateExample/Program.cs b/src/Examples/SecretsCertificateExample/Program.cs index 4f6bad7..ca1b547 100644 --- a/src/Examples/SecretsCertificateExample/Program.cs +++ b/src/Examples/SecretsCertificateExample/Program.cs @@ -37,7 +37,7 @@ static void RunDevelopmentScenario() // Generate a password-less self-signed certificate for development explicitly var devCertPath = Path.Combine(Path.GetTempPath(), "cocoar-dev-demo.pfx"); - X509CertificateGenerator.GenerateAndSave( + X509CertificateGenerator.GenerateAndSavePfx( devCertPath, null, // Password-less certificate "CN=Dev Secrets", @@ -85,7 +85,7 @@ static void RunProductionScenario() // In real production, you'd use: .UseCertificateFromFile("certs/prod.pfx") var prodCertPath = Path.Combine(Path.GetTempPath(), "cocoar-prod-demo.pfx"); - X509CertificateGenerator.GenerateAndSave( + X509CertificateGenerator.GenerateAndSavePfx( prodCertPath, null, // Password-less certificate "CN=Production Secrets", diff --git a/src/global.json b/src/global.json index 93681ff..69e101b 100644 --- a/src/global.json +++ b/src/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "9.0.0", + "version": "10.0.100", "rollForward": "latestMinor", "allowPrerelease": false } diff --git a/src/tests/Cocoar.Configuration.Analyzers.Tests/Cocoar.Configuration.Analyzers.Tests.csproj b/src/tests/Cocoar.Configuration.Analyzers.Tests/Cocoar.Configuration.Analyzers.Tests.csproj index 6d08b4d..4e9b899 100644 --- a/src/tests/Cocoar.Configuration.Analyzers.Tests/Cocoar.Configuration.Analyzers.Tests.csproj +++ b/src/tests/Cocoar.Configuration.Analyzers.Tests/Cocoar.Configuration.Analyzers.Tests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 latest enable false diff --git a/src/tests/Cocoar.Configuration.AspNetCore.Tests/Cocoar.Configuration.AspNetCore.Tests.csproj b/src/tests/Cocoar.Configuration.AspNetCore.Tests/Cocoar.Configuration.AspNetCore.Tests.csproj index 8574d71..301c311 100644 --- a/src/tests/Cocoar.Configuration.AspNetCore.Tests/Cocoar.Configuration.AspNetCore.Tests.csproj +++ b/src/tests/Cocoar.Configuration.AspNetCore.Tests/Cocoar.Configuration.AspNetCore.Tests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable true diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Cocoar.Configuration.Core.Tests.csproj b/src/tests/Cocoar.Configuration.Core.Tests/Cocoar.Configuration.Core.Tests.csproj index 505bf1b..109e60f 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Cocoar.Configuration.Core.Tests.csproj +++ b/src/tests/Cocoar.Configuration.Core.Tests/Cocoar.Configuration.Core.Tests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable true diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Core/SecretsFluentApiTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Core/SecretsFluentApiTests.cs index e7ca022..033c191 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Core/SecretsFluentApiTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Core/SecretsFluentApiTests.cs @@ -22,7 +22,7 @@ public void Secrets_AcceptsFluentConfiguration() try { // Generate certificate explicitly - X509CertificateGenerator.GenerateAndSave( + X509CertificateGenerator.GenerateAndSavePfx( pfxPath, null, // Password-less certificate "CN=Test Certificate", @@ -58,7 +58,7 @@ public void Secrets_AllowsFluentProtectorConfiguration() try { // Generate certificate explicitly - X509CertificateGenerator.GenerateAndSave( + X509CertificateGenerator.GenerateAndSavePfx( pfxPath, null, // Password-less certificate "CN=Test Certificate", @@ -106,14 +106,14 @@ public void Secrets_EachConfigManagerGetsIsolatedRuntime() try { // Generate certificates explicitly - X509CertificateGenerator.GenerateAndSave( + X509CertificateGenerator.GenerateAndSavePfx( pfxPath1, null, // Password-less certificate "CN=Test Certificate 1", validYears: 1, keySize: 2048); - X509CertificateGenerator.GenerateAndSave( + X509CertificateGenerator.GenerateAndSavePfx( pfxPath2, null, // Password-less certificate "CN=Test Certificate 2", diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Integration/CaseInsensitiveLayerMergeTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Integration/CaseInsensitiveLayerMergeTests.cs new file mode 100644 index 0000000..098619b --- /dev/null +++ b/src/tests/Cocoar.Configuration.Core.Tests/Integration/CaseInsensitiveLayerMergeTests.cs @@ -0,0 +1,101 @@ +namespace Cocoar.Configuration.Core.Tests.Integration; + +/// +/// Pins the observable behavior of the case-insensitive layer merge (ConfigMergeOptions.CaseInsensitive), +/// added in PR #50 and first shipping in v6.0.0. Verifies it before release — especially the dictionary case. +/// +public class CaseInsensitiveLayerMergeTests +{ + public sealed class ProbeConfig + { + public int Port { get; set; } + public string? Name { get; set; } + public Dictionary Map { get; set; } = new(); + } + + [Fact] + [Trait("Type", "Unit")] + public void PocoProperty_CaseVariantAcrossLayers_HigherLayerWins_AndOtherKeysSurvive() + { + using var manager = ConfigManager.Create(c => c + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("""{ "port": 25, "name": "base" }"""), + rules.For().FromStaticJson("""{ "Port": 587 }"""), + ])); + + var cfg = manager.GetConfig()!; + + Assert.Equal(587, cfg.Port); // "Port" overrides "port" (case-insensitive) + Assert.Equal("base", cfg.Name); // untouched lower-layer key survives (deep merge) + } + + [Fact] + [Trait("Type", "Unit")] + public void Value_CasingIsNeverTouched() + { + using var manager = ConfigManager.Create(c => c + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("""{ "Name": "Hello-WORLD" }"""), + ])); + + Assert.Equal("Hello-WORLD", manager.GetConfig()!.Name); + } + + [Fact] + [Trait("Type", "Unit")] + public void Dictionary_DistinctKeysAcrossLayers_DeepMergeKeepsBoth() + { + using var manager = ConfigManager.Create(c => c + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("""{ "Map": { "Foo": "1" } }"""), + rules.For().FromStaticJson("""{ "Map": { "Bar": "2" } }"""), + ])); + + var map = manager.GetConfig()!.Map; + + // Reveals whether nested objects deep-merge (Foo + Bar) or the higher layer replaces (Bar only). + Assert.Equal(2, map.Count); + } + + [Fact] + [Trait("Type", "Unit")] + public void Dictionary_CaseDistinctKeysAcrossLayers_RevealsCollapseBehavior() + { + using var manager = ConfigManager.Create(c => c + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("""{ "Map": { "X-Trace": "a" } }"""), + rules.For().FromStaticJson("""{ "Map": { "x-trace": "b" } }"""), + ])); + + var map = manager.GetConfig()!.Map; + + // CONFIRMED: the case-insensitive merge collapses case-distinct dictionary keys across layers into one. + // The result is a MIX: the higher layer wins the VALUE ("b"), but the LOWER layer's KEY casing + // ("X-Trace") is retained — so the consumer sees neither layer's entry verbatim. + Assert.Equal(1, map.Count); + Assert.Equal("b", map.Single().Value); + Assert.Equal("X-Trace", map.Single().Key); + } + + [Fact] + [Trait("Type", "Unit")] + public void Dictionary_CaseDistinctKeysWithinOneLayer_RevealsParseBehavior() + { + using var manager = ConfigManager.Create(c => c + .UseConfiguration(rules => + [ + rules.For().FromStaticJson("""{ "Map": { "Key": "1", "key": "2" } }"""), + ])); + + var map = manager.GetConfig()!.Map; + + // CONFIRMED (the asymmetry): a SINGLE layer PRESERVES case-distinct dictionary keys (ordinal JSON parse), + // even though the cross-layer MERGE above collapses them. Same data, different result depending on + // whether it arrives in one layer or is split across layers. + Assert.Equal(2, map.Count); + } +} diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Integration/InterfaceDeserializationTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Integration/InterfaceDeserializationTests.cs index 40fe41f..55c9f44 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Integration/InterfaceDeserializationTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Integration/InterfaceDeserializationTests.cs @@ -56,7 +56,7 @@ public void Should_Deserialize_Interface_Property_From_StaticJson() ])); // Act - var config = configManager.GetRequiredConfig(); + var config = configManager.GetConfig()!; // Assert Assert.NotNull(config); @@ -89,7 +89,7 @@ public void Should_Deserialize_Interface_Property_From_Environment_Variables() ])); // Act - var config = configManager.GetRequiredConfig(); + var config = configManager.GetConfig()!; // Assert Assert.NotNull(config); @@ -157,7 +157,7 @@ public void Should_Handle_Multiple_Interface_Mappings() ])); // Act - var config = configManager.GetRequiredConfig(); + var config = configManager.GetConfig()!; // Assert Assert.NotNull(config.Logging); @@ -227,7 +227,7 @@ public void Should_Handle_Nested_Interface_Properties() ])); // Act - var config = configManager.GetRequiredConfig(); + var config = configManager.GetConfig()!; // Assert Assert.NotNull(config); @@ -313,7 +313,7 @@ public void Should_Handle_Deeply_Nested_Interface_Properties() ])); // Act - var config = configManager.GetRequiredConfig(); + var config = configManager.GetConfig()!; // Assert Assert.NotNull(config.Logging); diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Managers/ConfigManagerIsolationTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Managers/ConfigManagerIsolationTests.cs index ef3bd0a..90e535e 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Managers/ConfigManagerIsolationTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Managers/ConfigManagerIsolationTests.cs @@ -210,7 +210,7 @@ public void GetRequiredConfig_WithValidConfiguration_ShouldReturnValue() var configManager = ConfigManager.Create(c => c.UseConfiguration(rules).UseLogger(NullLogger.Instance)); TrackForDisposal(configManager); - var result = configManager.GetRequiredConfig(); + var result = configManager.GetConfig()!; Assert.NotNull(result); Assert.Equal(expectedConfig.ConnectionString, result.ConnectionString); @@ -233,7 +233,7 @@ public void GetRequiredConfig_WithNonExistentType_ShouldThrowInvalidOperationExc #pragma warning disable CS0618 // Type or member is obsolete var exception = Assert.Throws(() => - configManager.GetRequiredConfig()); + configManager.GetConfig()!); #pragma warning restore CS0618 Assert.Contains("ApiConfig", exception.Message); diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Testing/CocoarTestConfigurationTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Testing/CocoarTestConfigurationTests.cs index 5a1003a..6d44efa 100644 --- a/src/tests/Cocoar.Configuration.Core.Tests/Testing/CocoarTestConfigurationTests.cs +++ b/src/tests/Cocoar.Configuration.Core.Tests/Testing/CocoarTestConfigurationTests.cs @@ -427,7 +427,7 @@ public void ConfigManager_AppliesTestOverrides_ReplaceMode() }) ])); - var config = configManager.GetRequiredConfig(); + var config = configManager.GetConfig()!; // Assert - Test overrides are used Assert.Equal("test-connection", config.Connection); @@ -454,7 +454,7 @@ public void ConfigManager_AppliesTestOverrides_AppendMode() }) ])); - var config = configManager.GetRequiredConfig(); + var config = configManager.GetConfig()!; // Assert - Test override merged (last-write-wins) Assert.Equal(999, config.MaxConnections); @@ -474,7 +474,7 @@ public void ConfigManager_WorksNormally_WhenNoTestOverride() }) ])); - var config = configManager.GetRequiredConfig(); + var config = configManager.GetConfig()!; // Assert - Normal behavior Assert.Equal("normal-connection", config.Connection); @@ -504,7 +504,7 @@ public void ConfigManager_AppliesContextFromApply() }) ])); - var config = configManager.GetRequiredConfig(); + var config = configManager.GetConfig()!; // Assert Assert.Equal("applied-connection", config.Connection); @@ -751,7 +751,7 @@ public void ConfigManager_AppliesTestSetupOverrides_FromAppliedContext() // Assert Assert.True(testSetupCalled); - var config = configManager.GetRequiredConfig(); + var config = configManager.GetConfig()!; Assert.Equal("test", config.Value); } diff --git a/src/tests/Cocoar.Configuration.DI.Tests/Cocoar.Configuration.DI.Tests.csproj b/src/tests/Cocoar.Configuration.DI.Tests/Cocoar.Configuration.DI.Tests.csproj index 4056fab..2a13c6a 100644 --- a/src/tests/Cocoar.Configuration.DI.Tests/Cocoar.Configuration.DI.Tests.csproj +++ b/src/tests/Cocoar.Configuration.DI.Tests/Cocoar.Configuration.DI.Tests.csproj @@ -1,6 +1,6 @@ - net9.0 + net10.0 enable enable true diff --git a/src/tests/Cocoar.Configuration.Flags.Tests/Cocoar.Configuration.Flags.Tests.csproj b/src/tests/Cocoar.Configuration.Flags.Tests/Cocoar.Configuration.Flags.Tests.csproj index dc66e7b..654bc81 100644 --- a/src/tests/Cocoar.Configuration.Flags.Tests/Cocoar.Configuration.Flags.Tests.csproj +++ b/src/tests/Cocoar.Configuration.Flags.Tests/Cocoar.Configuration.Flags.Tests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable true diff --git a/src/tests/Cocoar.Configuration.MultiTenant.Tests/Cocoar.Configuration.MultiTenant.Tests.csproj b/src/tests/Cocoar.Configuration.MultiTenant.Tests/Cocoar.Configuration.MultiTenant.Tests.csproj index 593bba7..7de5aab 100644 --- a/src/tests/Cocoar.Configuration.MultiTenant.Tests/Cocoar.Configuration.MultiTenant.Tests.csproj +++ b/src/tests/Cocoar.Configuration.MultiTenant.Tests/Cocoar.Configuration.MultiTenant.Tests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable true diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/Cocoar.Configuration.Providers.Tests.csproj b/src/tests/Cocoar.Configuration.Providers.Tests/Cocoar.Configuration.Providers.Tests.csproj index 9f0d413..e2b7eab 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/Cocoar.Configuration.Providers.Tests.csproj +++ b/src/tests/Cocoar.Configuration.Providers.Tests/Cocoar.Configuration.Providers.Tests.csproj @@ -1,6 +1,6 @@ - net9.0 + net10.0 enable enable Cocoar.Configuration.Providers.Tests diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/Http/HttpProviderSmokeTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/Http/HttpProviderSmokeTests.cs index 4cecf56..d3c43e6 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/Http/HttpProviderSmokeTests.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/Http/HttpProviderSmokeTests.cs @@ -51,14 +51,12 @@ public async Task ConfigManager_Recompute_OnChange_Required() var services = new ServiceCollection(); services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [ // Provide base settings with Url via in-memory Microsoft IConfigurationSource (adapter) - rules.For().FromMicrosoftSource(cm => new( + rules.For().FromIConfiguration( new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary { ["Remote:Url"] = "https://example.com/api/config" }) - .Sources[0], - configurationPrefix: "Remote" - )), + .AddInMemoryCollection(new Dictionary { ["Url"] = "https://example.com/api/config" }) + .Build()), rules.For().FromHttp(configManager => new( - url: configManager.GetRequiredConfig().Url, + url: configManager.GetConfig()!.Url, // Give CI plenty of time; we will actively wait for the change pollInterval: TimeSpan.FromMilliseconds(50), handler: handler diff --git a/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateExpirationTests.cs b/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateExpirationTests.cs index 8a756ac..36f4f00 100644 --- a/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateExpirationTests.cs +++ b/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateExpirationTests.cs @@ -28,7 +28,7 @@ public void LoadCertificate_ValidCert_LoadsSuccessfully() { // Generate certificate valid for 1 year (minimum) var certPath = Path.Combine(_tempPath, "valid.pfx"); - X509CertificateGenerator.GenerateAndSave(certPath, _password, "CN=Valid", validYears: 1, keySize: 2048, overwrite: true); + X509CertificateGenerator.GenerateAndSavePfx(certPath, _password, "CN=Valid", validYears: 1, keySize: 2048, overwrite: true); // Redirect console output to capture any messages using var sw = new StringWriter(); @@ -61,7 +61,7 @@ public void LoadCertificate_NotExpiring_NoWarning() { // Generate certificate valid for 1 year var certPath = Path.Combine(_tempPath, "valid.pfx"); - X509CertificateGenerator.GenerateAndSave(certPath, _password, "CN=Valid", validYears: 1, keySize: 2048, overwrite: true); + X509CertificateGenerator.GenerateAndSavePfx(certPath, _password, "CN=Valid", validYears: 1, keySize: 2048, overwrite: true); // Redirect console output to verify no warning using var sw = new StringWriter(); diff --git a/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateFolderTests.cs b/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateFolderTests.cs index 24ff4ee..0aac760 100644 --- a/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateFolderTests.cs +++ b/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateFolderTests.cs @@ -29,7 +29,7 @@ public void Dispose() private X509Certificate2 GenerateTestCert(string path, string subject) { - X509CertificateGenerator.GenerateAndSave(path, _password, subject, validYears: 1, keySize: 2048, overwrite: true); + X509CertificateGenerator.GenerateAndSavePfx(path, _password, subject, validYears: 1, keySize: 2048, overwrite: true); return CertificateHelper.LoadFromFile(path, _password); } diff --git a/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateOrderingTests.cs b/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateOrderingTests.cs index 98dea5b..630b056 100644 --- a/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateOrderingTests.cs +++ b/src/tests/Cocoar.Configuration.Secrets.Tests/CertificateOrderingTests.cs @@ -32,9 +32,9 @@ public void DefaultOrdering_DescendingAlphabetical() var cert02Path = Path.Combine(_tempBasePath, "02-middle.pfx"); var cert03Path = Path.Combine(_tempBasePath, "03-new.pfx"); - var cert01 = X509CertificateGenerator.GenerateAndSave(cert01Path, _password, "CN=Cert01"); - var cert02 = X509CertificateGenerator.GenerateAndSave(cert02Path, _password, "CN=Cert02"); - var cert03 = X509CertificateGenerator.GenerateAndSave(cert03Path, _password, "CN=Cert03"); + var cert01 = X509CertificateGenerator.GenerateAndSavePfx(cert01Path, _password, "CN=Cert01"); + var cert02 = X509CertificateGenerator.GenerateAndSavePfx(cert02Path, _password, "CN=Cert02"); + var cert03 = X509CertificateGenerator.GenerateAndSavePfx(cert03Path, _password, "CN=Cert03"); try { @@ -61,11 +61,11 @@ public void CustomComparer_LastWriteTimeDescending() var cert02Path = Path.Combine(_tempBasePath, "cert-b.pfx"); var cert03Path = Path.Combine(_tempBasePath, "cert-c.pfx"); - var cert01 = X509CertificateGenerator.GenerateAndSave(cert01Path, _password, "CN=Cert01"); + var cert01 = X509CertificateGenerator.GenerateAndSavePfx(cert01Path, _password, "CN=Cert01"); Thread.Sleep(100); - var cert02 = X509CertificateGenerator.GenerateAndSave(cert02Path, _password, "CN=Cert02"); + var cert02 = X509CertificateGenerator.GenerateAndSavePfx(cert02Path, _password, "CN=Cert02"); Thread.Sleep(100); - var cert03 = X509CertificateGenerator.GenerateAndSave(cert03Path, _password, "CN=Cert03"); + var cert03 = X509CertificateGenerator.GenerateAndSavePfx(cert03Path, _password, "CN=Cert03"); try { @@ -95,9 +95,9 @@ public void CustomComparer_NumericSuffixDescending() var cert02Path = Path.Combine(_tempBasePath, "cert.02.pfx"); var cert03Path = Path.Combine(_tempBasePath, "cert.03.pfx"); - var cert01 = X509CertificateGenerator.GenerateAndSave(cert01Path, _password, "CN=Cert01"); - var cert02 = X509CertificateGenerator.GenerateAndSave(cert02Path, _password, "CN=Cert02"); - var cert03 = X509CertificateGenerator.GenerateAndSave(cert03Path, _password, "CN=Cert03"); + var cert01 = X509CertificateGenerator.GenerateAndSavePfx(cert01Path, _password, "CN=Cert01"); + var cert02 = X509CertificateGenerator.GenerateAndSavePfx(cert02Path, _password, "CN=Cert02"); + var cert03 = X509CertificateGenerator.GenerateAndSavePfx(cert03Path, _password, "CN=Cert03"); try { @@ -140,9 +140,9 @@ public void CustomComparer_ByFileSize_LargestFirst() var mediumCertPath = Path.Combine(_tempBasePath, "medium.pfx"); var largeCertPath = Path.Combine(_tempBasePath, "large.pfx"); - var smallCert = X509CertificateGenerator.GenerateAndSave(smallCertPath, _password, "CN=Small", keySize: 2048); - var mediumCert = X509CertificateGenerator.GenerateAndSave(mediumCertPath, _password, "CN=Medium", keySize: 3072); - var largeCert = X509CertificateGenerator.GenerateAndSave(largeCertPath, _password, "CN=Large", keySize: 4096); + var smallCert = X509CertificateGenerator.GenerateAndSavePfx(smallCertPath, _password, "CN=Small", keySize: 2048); + var mediumCert = X509CertificateGenerator.GenerateAndSavePfx(mediumCertPath, _password, "CN=Medium", keySize: 3072); + var largeCert = X509CertificateGenerator.GenerateAndSavePfx(largeCertPath, _password, "CN=Large", keySize: 4096); try { diff --git a/src/tests/Cocoar.Configuration.Secrets.Tests/Cocoar.Configuration.Secrets.Tests.csproj b/src/tests/Cocoar.Configuration.Secrets.Tests/Cocoar.Configuration.Secrets.Tests.csproj index f12c6c2..a4585d6 100644 --- a/src/tests/Cocoar.Configuration.Secrets.Tests/Cocoar.Configuration.Secrets.Tests.csproj +++ b/src/tests/Cocoar.Configuration.Secrets.Tests/Cocoar.Configuration.Secrets.Tests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable false diff --git a/src/tests/Cocoar.Configuration.ServiceBacked.Tests/Cocoar.Configuration.ServiceBacked.Tests.csproj b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/Cocoar.Configuration.ServiceBacked.Tests.csproj index 0b5035a..c2acca8 100644 --- a/src/tests/Cocoar.Configuration.ServiceBacked.Tests/Cocoar.Configuration.ServiceBacked.Tests.csproj +++ b/src/tests/Cocoar.Configuration.ServiceBacked.Tests/Cocoar.Configuration.ServiceBacked.Tests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable true diff --git a/src/tests/Cocoar.Configuration.WritableStore.Marten.Tests/Cocoar.Configuration.WritableStore.Marten.Tests.csproj b/src/tests/Cocoar.Configuration.WritableStore.Marten.Tests/Cocoar.Configuration.WritableStore.Marten.Tests.csproj new file mode 100644 index 0000000..c298f52 --- /dev/null +++ b/src/tests/Cocoar.Configuration.WritableStore.Marten.Tests/Cocoar.Configuration.WritableStore.Marten.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + diff --git a/src/tests/Cocoar.Configuration.WritableStore.Marten.Tests/MartenStoreBackendTests.cs b/src/tests/Cocoar.Configuration.WritableStore.Marten.Tests/MartenStoreBackendTests.cs new file mode 100644 index 0000000..b3fc78b --- /dev/null +++ b/src/tests/Cocoar.Configuration.WritableStore.Marten.Tests/MartenStoreBackendTests.cs @@ -0,0 +1,81 @@ +using System.Text; +using global::Marten; + +namespace Cocoar.Configuration.WritableStore.Marten.Tests; + +/// +/// Integration tests for against a real PostgreSQL instance. They self-skip +/// when Docker is unavailable (see ), so the default suite stays green everywhere. +/// +[Trait("Type", "Integration")] +public sealed class MartenStoreBackendTests(PostgresFixture pg) : IClassFixture +{ + [SkippableFact] + public async Task Write_then_read_round_trips_the_overlay_bytes() + { + Skip.IfNot(pg.Available, pg.SkipReason ?? "Docker not available"); + await using var store = SingleTenantStore(pg.DefaultConnectionString); + var backend = new MartenStoreBackend(store); + + const string key = "MyApp.Settings.SmtpSettings"; + var data = """{"Smtp":{"Port":587}}"""u8.ToArray(); + + await backend.WriteAsync(key, data); + var read = await backend.ReadAsync(key); + + Assert.NotNull(read); + Assert.Equal(Encoding.UTF8.GetString(data), Encoding.UTF8.GetString(read)); + } + + [SkippableFact] + public async Task Read_of_a_missing_key_returns_null() + { + Skip.IfNot(pg.Available, pg.SkipReason ?? "Docker not available"); + await using var store = SingleTenantStore(pg.DefaultConnectionString); + var backend = new MartenStoreBackend(store); + + var read = await backend.ReadAsync("Never.Written.Key"); + + Assert.Null(read); + } + + [SkippableFact] + public async Task Writes_are_isolated_per_tenant_database() + { + Skip.IfNot(pg.Available, pg.SkipReason ?? "Docker not available"); + await using var store = MultiTenantStore(); + var backendA = new MartenStoreBackend(store, PostgresFixture.TenantA); + var backendB = new MartenStoreBackend(store, PostgresFixture.TenantB); + + const string key = "MyApp.Settings.TenantSettings"; + var dataA = """{"Theme":"dark"}"""u8.ToArray(); + + await backendA.WriteAsync(key, dataA); + + // Same key, different tenant database -> invisible to tenant B (database-per-tenant isolation). + Assert.Null(await backendB.ReadAsync(key)); + + // Tenant A reads back its own write. + var readA = await backendA.ReadAsync(key); + Assert.NotNull(readA); + Assert.Equal(Encoding.UTF8.GetString(dataA), Encoding.UTF8.GetString(readA)); + } + + private static IDocumentStore SingleTenantStore(string connectionString) + => DocumentStore.For(opts => + { + opts.Connection(connectionString); + opts.RegisterDocumentType(); + }); + + private IDocumentStore MultiTenantStore() + => DocumentStore.For(opts => + { + opts.MultiTenantedDatabases(x => + { + x.AddSingleTenantDatabase(pg.ConnectionStringForTenantA, PostgresFixture.TenantA); + x.AddSingleTenantDatabase(pg.ConnectionStringForTenantB, PostgresFixture.TenantB); + }); + opts.RegisterDocumentType(); + }); +} diff --git a/src/tests/Cocoar.Configuration.WritableStore.Marten.Tests/PostgresFixture.cs b/src/tests/Cocoar.Configuration.WritableStore.Marten.Tests/PostgresFixture.cs new file mode 100644 index 0000000..d600032 --- /dev/null +++ b/src/tests/Cocoar.Configuration.WritableStore.Marten.Tests/PostgresFixture.cs @@ -0,0 +1,79 @@ +using Testcontainers.PostgreSql; + +namespace Cocoar.Configuration.WritableStore.Marten.Tests; + +/// +/// Spins up a throwaway PostgreSQL container for the Marten backend tests and creates one database per test +/// tenant (database-per-tenant). If Docker is not reachable, stays false and the tests +/// skip themselves instead of failing — so the default suite stays green on machines/CI without Docker. +/// +public sealed class PostgresFixture : IAsyncLifetime +{ + public const string TenantA = "tenant-a"; + public const string TenantB = "tenant-b"; + private const string TenantADatabase = "tenant_a"; + private const string TenantBDatabase = "tenant_b"; + + // Built inside InitializeAsync, NOT in a field initializer: PostgreSqlBuilder.Build() validates Docker + // availability and throws DockerUnavailableException when no Docker daemon is reachable (e.g. GitHub + // macOS runners). Constructing it inside the try lets that throw be caught so the tests skip, not fail. + private PostgreSqlContainer? _container; + + /// True once the container is up and the tenant databases exist. + public bool Available { get; private set; } + + /// Why the tests are skipped (Docker not available), or null when is true. + public string? SkipReason { get; private set; } + + public async Task InitializeAsync() + { + try + { + _container = new PostgreSqlBuilder() + .WithDatabase("postgres") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + + await _container.StartAsync(); + + foreach (var database in new[] { TenantADatabase, TenantBDatabase }) + { + var result = await _container.ExecAsync(["psql", "-U", "postgres", "-c", $"CREATE DATABASE {database};"]); + if (result.ExitCode != 0) + { + throw new InvalidOperationException( + $"Failed to create database '{database}': {result.Stderr}"); + } + } + + Available = true; + } + catch (Exception ex) + { + Available = false; + SkipReason = $"Docker / PostgreSQL not available: {ex.Message}"; + } + } + + /// Connection string to the default (single-tenant) database. Only valid when . + public string DefaultConnectionString => _container!.GetConnectionString(); + + /// Connection string to a specific tenant's database (database-per-tenant). + public string ConnectionStringForTenantA => ConnectionStringFor(TenantADatabase); + + /// Connection string to a specific tenant's database (database-per-tenant). + public string ConnectionStringForTenantB => ConnectionStringFor(TenantBDatabase); + + private string ConnectionStringFor(string database) + => _container!.GetConnectionString() + .Replace("Database=postgres", $"Database={database}", StringComparison.OrdinalIgnoreCase); + + public async Task DisposeAsync() + { + if (_container is not null) + { + await _container.DisposeAsync(); + } + } +} diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index a41d564..2878011 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -68,6 +68,7 @@ export default defineConfig({ { text: 'Microsoft IConfiguration', link: '/guide/providers/microsoft-adapter' }, { text: 'Static & Observable', link: '/guide/providers/static-observable' }, { text: 'Writable Store', link: '/guide/providers/writable-store' }, + { text: 'Marten Store', link: '/guide/providers/marten-store' }, { text: 'Custom Providers ', link: '/guide/providers/custom' }, ], }, diff --git a/website/changelog.md b/website/changelog.md index abc84ee..e02018f 100644 --- a/website/changelog.md +++ b/website/changelog.md @@ -2,8 +2,23 @@ ## [Unreleased] +> **v6.0.0** — major release. The headline change is the move off .NET 8. + +### Breaking +- **Dropped .NET 8 support.** All packages now multi-target `net9.0` and `net10.0` (was `net8.0` / `net9.0`). Consumers must target .NET 9 or later. +- `Microsoft.Extensions.*` dependencies moved to the `10.0.x` line, aligned with .NET 10. `10.0.x` ships a native `net9.0` target, so .NET 9 consumers take no runtime hit. + +### Removed +- `IConfigurationAccessor.GetRequiredConfig()` / `GetRequiredConfig(Type)` — deprecated since v5; use `GetConfig()` / `GetConfig(Type)` (identical throw-on-missing behavior). +- `FromMicrosoftSource(...)` — use `FromIConfiguration(IConfiguration)`. +- `X509CertificateGenerator.GenerateAndSave(...)` — use `GenerateAndSavePfx(...)` / `GenerateAndSavePem(...)`. + ### Added +**Marten store backend** (`Cocoar.Configuration.WritableStore.Marten`) +- New opt-in package: `MartenStoreBackend` persists WritableStore overrides in [Marten](https://martendb.io/) (PostgreSQL) — one `CocoarConfigDocument` per configuration type. +- `FromMartenStore()` service-backed (Layer-2) rule extension resolves the `IDocumentStore` from DI. Combine with `.TenantScoped()` for **database-per-tenant** configuration: each tenant's overlay lives in its own database (Marten multi-tenancy), selected from `accessor.Tenant`. + **WritableStore — batch writes (`PatchAsync`)** - `IWritableStore.PatchAsync(b => b.Set(...).SetSecret(...).Reset(...))` applies any number of mutations as **one** atomic write and **one** recompute — a form save no longer fires one recompute (and one backend round-trip) per field. The single-value `SetAsync` / `SetSecretAsync` / `ResetAsync` now delegate to it. - Async overload `PatchAsync(async b => …)` for when gathering values is asynchronous (e.g. encrypting a `SecretEnvelope` inline). diff --git a/website/guide/configuration/required-optional.md b/website/guide/configuration/required-optional.md index dc34948..8127806 100644 --- a/website/guide/configuration/required-optional.md +++ b/website/guide/configuration/required-optional.md @@ -87,10 +87,6 @@ rule.For().FromFile("premium.json") .When(accessor => accessor.GetConfig()!.IsPremium) ``` -:::warning GetRequiredConfig is deprecated -`GetRequiredConfig()` still exists but is deprecated — it has identical behavior to `GetConfig()`. Use `GetConfig()` in all new code. -::: - ## Startup vs Runtime Behavior | Scenario | Startup | Runtime Recompute | diff --git a/website/guide/di/lifetimes.md b/website/guide/di/lifetimes.md index c338f8c..f833ca6 100644 --- a/website/guide/di/lifetimes.md +++ b/website/guide/di/lifetimes.md @@ -89,7 +89,7 @@ Same as the default — useful for being explicit or overriding an interface's l ## Keyed Services -Register the same configuration type under different keys (requires .NET 8+): +Register the same configuration type under different keys (keyed services, .NET 9+): ```csharp setup.ConcreteType().AsSingleton("primary"), diff --git a/website/guide/providers/marten-store.md b/website/guide/providers/marten-store.md new file mode 100644 index 0000000..c1a1034 --- /dev/null +++ b/website/guide/providers/marten-store.md @@ -0,0 +1,64 @@ +# Marten Store + +`Cocoar.Configuration.WritableStore.Marten` is a ready-made [Writable Store](/guide/providers/writable-store) backend that persists overrides in [Marten](https://martendb.io/) (a PostgreSQL document store). Its headline feature is **tenant-aware, database-per-tenant** storage: with Marten multi-tenancy, each tenant's configuration overlay lives in that tenant's own database. + +```shell +dotnet add package Cocoar.Configuration.WritableStore.Marten +``` + +It is an opt-in integration package — it intentionally takes a Marten dependency. Consumers who don't reference it pay nothing. + +## Why it is service-backed + +The backend needs a Marten `IDocumentStore`, which lives in the DI container — so the rule must resolve it *after* the container is built. That is exactly what [service-backed (Layer-2) configuration](/guide/di/service-backed) is for. Author the rule inside `UseServiceBackedConfiguration`, where `FromMartenStore()` is available: + +```csharp +builder.AddCocoarConfiguration(c => c + .UseServiceBackedConfiguration(rules => + [ + rules.For().FromMartenStore().TenantScoped().Build(), + ])); +``` + +`FromMartenStore()` resolves the `IDocumentStore` from DI and uses the current tenant (`accessor.Tenant`) to select the tenant database. The rule stays dormant until the host starts; the document store is never touched before the container exists. + +Because it reuses the writable-store pipeline, you also get the `IWritableStore` write façade (per tenant) for writing overrides at runtime. + +## Tenant-aware, database-per-tenant + +Configure Marten with database-per-tenant multi-tenancy as you normally would (`MultiTenantedDatabases` / `AddSingleTenantDatabase`), then combine `FromMartenStore()` with `.TenantScoped()`: + +```csharp +services.AddMarten(opts => +{ + opts.MultiTenantedDatabases(x => + { + x.AddSingleTenantDatabase(contosoConnectionString, "contoso"); + x.AddSingleTenantDatabase(globexConnectionString, "globex"); + }); + opts.RegisterDocumentType(); +}); +``` + +At recompute time, the backend opens its Marten session for `accessor.Tenant`, so a write for tenant `contoso` lands in Contoso's database and is invisible to `globex`. Each tenant pipeline keeps its own writable store, so overlays never alias across tenants. See [Multi-Tenancy](/guide/multi-tenancy/overview) for how tenant pipelines are built and consumed. + +A `null`/blank tenant uses Marten's default tenant — the single-database case, when you use `FromMartenStore()` without `.TenantScoped()`. + +## Storage model + +Overrides are stored as one [`CocoarConfigDocument`](https://github.com/cocoar-dev/cocoar.configuration/tree/develop/src/Cocoar.Configuration.WritableStore.Marten) per configuration type: + +- `Id` — the storage key (the configuration type's full name, e.g. `MyApp.Settings.TenantSettings`). +- `Json` — the sparse overlay JSON the writable store reads and writes. + +Register the document type with Marten (`RegisterDocumentType()`) so its table is created in each tenant database, or rely on Marten's runtime auto-creation. + +## Reactivity and HA + +A write is reactive **within the writing process**: it signals the provider's change observable and the pipeline recomputes, so every `IReactiveConfig` view on that instance updates. In a multi-instance (HA) deployment all pointing at the same database, a write on instance A does **not** automatically propagate to B/C — cross-instance reactivity needs a database notification (e.g. PostgreSQL `LISTEN/NOTIFY`) routed into the provider's change stream. That is a separate, additive enhancement and is not part of this backend. + +## See also + +- [Writable Store](/guide/providers/writable-store) — the override-layer concept and write API this builds on. +- [Service-Backed Configuration](/guide/di/service-backed) — why DB-backed rules are Layer-2. +- [Multi-Tenancy](/guide/multi-tenancy/overview) — per-tenant pipelines. diff --git a/website/guide/providers/writable-store.md b/website/guide/providers/writable-store.md index da4953e..95472ab 100644 --- a/website/guide/providers/writable-store.md +++ b/website/guide/providers/writable-store.md @@ -227,6 +227,8 @@ rules.For().FromStore((accessor, current) => `ReadAsync` returns empty `{}` when nothing is stored (consistent with the [provider contract](/guide/providers/overview#the-provider-contract)), so an unwritten overlay is an invisible layer. +For a ready-made PostgreSQL backend — including **tenant-aware, database-per-tenant** storage where each tenant's configuration lives in its own database — see the [Marten Store](/guide/providers/marten-store) package. + ## How it works ``` diff --git a/website/reference/packages.md b/website/reference/packages.md index 2d2c008..12440ed 100644 --- a/website/reference/packages.md +++ b/website/reference/packages.md @@ -6,48 +6,48 @@ Lightweight interfaces for decoupled architecture. Reference this from libraries that need to accept configuration without depending on the full implementation. -- **Target:** .NET 8.0 / .NET 9.0 +- **Target:** .NET 9.0 / .NET 10.0 - **Dependencies:** None - **Key types:** `IConfigurationAccessor`, `IReactiveConfig`, `ISecret`, `SecretLease` ```xml - + ``` ### Cocoar.Configuration The core library. Includes providers, reactive engine, secrets, feature flags, and entitlements. -- **Target:** .NET 8.0 / .NET 9.0 +- **Target:** .NET 9.0 / .NET 10.0 - **Dependencies:** Cocoar.Configuration.Abstractions, Cocoar.Configuration.Analyzers (build-time), Cocoar.Capabilities, Cocoar.FileSystem, Cocoar.Json.Mutable, Microsoft.Extensions.Logging.Abstractions - **Key types:** `ConfigManager`, `Secret`, `IFeatureFlags`, `IEntitlements`, `FeatureFlag`, `Entitlement` ```xml - + ``` ### Cocoar.Configuration.DI Microsoft.Extensions.DependencyInjection integration. -- **Target:** .NET 8.0 / .NET 9.0 +- **Target:** .NET 9.0 / .NET 10.0 - **Dependencies:** Cocoar.Configuration, Cocoar.Capabilities - **Key types:** `AddCocoarConfiguration()` extension method ```xml - + ``` ### Cocoar.Configuration.AspNetCore ASP.NET Core integration — includes DI and adds health checks, feature flag/entitlement REST endpoints. -- **Target:** .NET 8.0 / .NET 9.0 +- **Target:** .NET 9.0 / .NET 10.0 - **Dependencies:** Cocoar.Configuration, Cocoar.Configuration.DI, Microsoft.AspNetCore.App (FrameworkReference) - **Key types:** `AddCocoarConfigurationHealthCheck()`, `MapFeatureFlagEndpoints()`, `MapEntitlementEndpoints()` ```xml - + ``` ::: tip @@ -58,24 +58,36 @@ AspNetCore includes DI, which includes Core — you only need one `PackageRefere Remote configuration provider with support for one-time fetch, polling, and Server-Sent Events (SSE). Separate package to avoid forcing an HTTP dependency on all consumers. -- **Target:** .NET 8.0 / .NET 9.0 +- **Target:** .NET 9.0 / .NET 10.0 - **Dependencies:** Cocoar.Configuration - **Key types:** `FromHttp()` extension method, `HttpRuleOptions` ```xml - + ``` ### Cocoar.Configuration.MicrosoftAdapter Bridge from `Microsoft.Extensions.Configuration` sources (Azure Key Vault, custom providers, etc.) into Cocoar.Configuration. -- **Target:** .NET 8.0 / .NET 9.0 +- **Target:** .NET 9.0 / .NET 10.0 - **Dependencies:** Cocoar.Configuration, Microsoft.Extensions.Configuration.* - **Key types:** `FromIConfiguration()` extension method ```xml - + +``` + +### Cocoar.Configuration.WritableStore.Marten + +Marten (PostgreSQL document store) backend for the WritableStore. Persists writable configuration overlays as documents, with first-class support for Marten database-per-tenant multi-tenancy so each tenant's configuration lives in its own database. Opt-in package — it intentionally takes a Marten dependency; consumers who don't reference it pay nothing. + +- **Target:** .NET 9.0 / .NET 10.0 +- **Dependencies:** Cocoar.Configuration.DI, Marten +- **Key types:** `MartenStoreBackend`, `CocoarConfigDocument`, `FromMartenStore()` extension method + +```xml + ``` ### Cocoar.Configuration.Analyzers