diff --git a/docs/articles/concepts/bootstrap-lifecycle.md b/docs/articles/concepts/bootstrap-lifecycle.md index 36d2d8a9..ffe02dc6 100644 --- a/docs/articles/concepts/bootstrap-lifecycle.md +++ b/docs/articles/concepts/bootstrap-lifecycle.md @@ -31,6 +31,24 @@ bootstrap.ConfigureServices(container => See [dependency injection](dependency-injection.md) for the container and the `AddXxx` / `RegisterXxx` pattern. +## OnConfigLoaded + +After the configuration is loaded - and before the logger is built and services start - the +bootstrap applies any typed config hooks. Use them to inspect or override loaded sections at +startup; changes are in-memory only: + +```csharp +var bootstrap = SquidStdBootstrap.Create(o => o.ConfigName = "myapp"); +bootstrap.ConfigureServices(c => c.RegisterCoreServices()); +bootstrap.OnConfigLoaded(o => o.MinimumLevel = LogLevelType.Debug); + +await bootstrap.StartAsync(); +``` + +The effective order is: load configuration, apply config hooks, configure the logger, start +services. Hooks are re-applied on every configuration load, so overrides are never lost. See +[Inspecting and overriding loaded configuration](../guides/configuration.md#inspecting-and-overriding-loaded-configuration) for more examples. + ## Migrating to 0.15: explicit core services Up to 0.14, `SquidStdBootstrap.Create` registered every core service on creation. From 0.15 the bootstrap registers only the configuration core - `DirectoriesConfig`, the `logger` config section and the config manager. The remaining core services (JSON serializer, event bus, job system, main-thread dispatcher, timer wheel, metrics collection, secrets) are opted into with the parameterless `RegisterCoreServices()`: @@ -53,7 +71,7 @@ builder.UseSquidStd(options => options.ConfigName = "myapp", c => c.RegisterCore ## Start and stop over ISquidStdService -Services implementing `ISquidStdService` participate in the lifecycle. On `StartAsync` they are started in registration order; on `StopAsync` they are stopped in reverse order, so dependencies remain available while their dependents shut down. +Services implementing `ISquidStdService` participate in the lifecycle. On `StartAsync` the configuration is loaded and the config hooks are applied, then services are started in registration order; on `StopAsync` they are stopped in reverse order, so dependencies remain available while their dependents shut down. The bootstrap logs its whole lifecycle: a startup banner with the application name and version (set `SquidStdOptions.AppName`; it defaults to the entry assembly name and is attached to every event as the `Application` / `ApplicationVersion` properties), a registration summary (per-registration detail at Debug), one line per service started with its duration, and the shutdown sequence. A service that fails to stop is logged as a warning and the remaining services are still stopped. Extra Serilog sinks can be plugged by registering `ILogEventSink` instances in the container before start. diff --git a/docs/articles/guides/configuration.md b/docs/articles/guides/configuration.md index 092468f5..e4045833 100644 --- a/docs/articles/guides/configuration.md +++ b/docs/articles/guides/configuration.md @@ -43,3 +43,39 @@ myService: endpoint: "https://$API_HOST" retries: 5 ``` + +## Inspecting and overriding loaded configuration + +The config manager loads sections transparently, but nothing stays hidden: + +```csharp +var config = bootstrap.Resolve(); +Console.WriteLine(config.Compose()); // full current configuration as YAML +var logger = config.GetConfig(); // one typed section +``` + +To inspect or tweak a section at startup - before the logger and the services consume it - +register a typed hook on the bootstrap. Hooks run after every configuration load and mutate +the section in memory only: the YAML file is never rewritten (call `Save()` explicitly if you +want to persist). + +```csharp +// Tune a section at startup +bootstrap.OnConfigLoaded(o => o.MinimumLevel = LogLevelType.Debug); + +// Override from the environment (WorkersConfig comes with SquidStd.Workers) +bootstrap.OnConfigLoaded(w => +{ + if (int.TryParse(Environment.GetEnvironmentVariable("WORKERS_MAX"), out var max)) + { + w.MaxConcurrency = max; + } +}); + +// Inspect a loaded section before services start +bootstrap.OnConfigLoaded(s => Console.WriteLine($"storage root: {s.RootDirectory}")); +``` + +Hooks compose: register as many as you need, also on the same section - they run in +registration order. Registering a hook for a type that is not a config section fails at +startup with a clear error; registering one after the bootstrap has started throws. diff --git a/docs/tutorials/getting-started.md b/docs/tutorials/getting-started.md index 19eb7dbe..4383905f 100644 --- a/docs/tutorials/getting-started.md +++ b/docs/tutorials/getting-started.md @@ -48,7 +48,8 @@ The host starts, logs the service lifecycle, and waits until you press Ctrl+C. ## How it works `SquidStdBootstrap` is the composition root: it builds the container, registers the configuration core, loads -the config sections, and orchestrates the `ISquidStdService` lifecycle. The core services come from the +the config sections, and orchestrates the `ISquidStdService` lifecycle. Need to inspect or tweak a loaded +value before services start? See [Inspecting and overriding loaded configuration](../articles/guides/configuration.md#inspecting-and-overriding-loaded-configuration). The core services come from the explicit `RegisterCoreServices()` call in `ConfigureServices`, and every other SquidStd module plugs into the same container through its `Add…` extension methods. diff --git a/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs b/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs index 3a573dab..bb40bc0e 100644 --- a/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs +++ b/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs @@ -32,6 +32,17 @@ public interface ISquidStdBootstrap : IAsyncDisposable /// The same bootstrap instance for chaining. ISquidStdBootstrap ConfigureServices(Func configure); + /// + /// Registers a callback invoked after the configuration is loaded (and re-applied on every + /// reload performed through the bootstrap) and before the logger and services consume it. + /// The callback receives the section singleton and may inspect or mutate it; changes are + /// in-memory only - the YAML file is not rewritten. + /// + /// The configuration section type. + /// Callback that receives the loaded section. + /// The same bootstrap for chaining. + ISquidStdBootstrap OnConfigLoaded(Action configure) where TConfig : class; + /// /// Resolves a service from the owned dependency injection container. /// diff --git a/src/SquidStd.Core/Interfaces/Config/IConfigManagerService.cs b/src/SquidStd.Core/Interfaces/Config/IConfigManagerService.cs index 0ae4a387..cefb6ed6 100644 --- a/src/SquidStd.Core/Interfaces/Config/IConfigManagerService.cs +++ b/src/SquidStd.Core/Interfaces/Config/IConfigManagerService.cs @@ -25,6 +25,12 @@ public interface IConfigManagerService /// IReadOnlyCollection Entries { get; } + /// + /// Raised after every successful , once the section instances are + /// registered into DI. + /// + event Action? ConfigLoaded; + /// /// Composes the currently loaded sections into YAML. /// diff --git a/src/SquidStd.Services.Core/README.md b/src/SquidStd.Services.Core/README.md index 9dcc584c..cd44befc 100644 --- a/src/SquidStd.Services.Core/README.md +++ b/src/SquidStd.Services.Core/README.md @@ -39,6 +39,13 @@ bootstrap.ConfigureServices(c => c.RegisterCoreServices()); await bootstrap.StartAsync(); ``` +Need to inspect or override a loaded config section before services start? Register a typed hook +(in-memory only - the YAML file is untouched): + +```csharp +bootstrap.OnConfigLoaded(o => o.MinimumLevel = LogLevelType.Debug); +``` + ### Command dispatch ```csharp diff --git a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs index a932295c..aa290178 100644 --- a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs +++ b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs @@ -23,9 +23,11 @@ namespace SquidStd.Services.Core.Services.Bootstrap; /// public sealed class SquidStdBootstrap : ISquidStdBootstrap { + private readonly List<(Type ConfigType, Action Apply)> _configHooks = []; private readonly bool _ownsContainer; private readonly List _startedServices = []; private readonly Lock _syncRoot = new(); + private bool _configHooksSubscribed; private int _disposed; private bool _loggerConfigured; private BootstrapStateType _state; @@ -91,6 +93,25 @@ public ISquidStdBootstrap ConfigureServices(Func configu : this; } + /// + public ISquidStdBootstrap OnConfigLoaded(Action configure) where TConfig : class + { + ArgumentNullException.ThrowIfNull(configure); + ThrowIfDisposed(); + + lock (_syncRoot) + { + if (_state != BootstrapStateType.Created) + { + throw new InvalidOperationException("Config hooks cannot be registered after bootstrap start."); + } + } + + _configHooks.Add((typeof(TConfig), instance => configure((TConfig)instance))); + + return this; + } + /// public async ValueTask DisposeAsync() { @@ -135,7 +156,15 @@ public void ConfigureLogging() return; } - Container.Resolve().Load(); + var configManager = Container.Resolve(); + + if (!_configHooksSubscribed) + { + configManager.ConfigLoaded += ApplyConfigHooks; + _configHooksSubscribed = true; + } + + configManager.Load(); ConfigureLogger(); } @@ -382,6 +411,33 @@ private void ConfigureLogger() _loggerConfigured = true; } + private void ApplyConfigHooks() + { + if (_configHooks.Count == 0) + { + return; + } + + List sections = Container.IsRegistered>() + ? Container.Resolve>() + : []; + var logger = Log.ForContext(); + + foreach (var (configType, apply) in _configHooks) + { + if (!sections.Any(section => section.ConfigType == configType)) + { + throw new InvalidOperationException( + $"No config section registered for type '{configType.Name}'. " + + "Register it with RegisterConfigSection before using OnConfigLoaded." + ); + } + + apply(Container.Resolve(configType)); + logger.Debug("Applied config hook to {Section:l}", configType.Name); + } + } + private void LogRegistrations(ILogger logger, ServiceRegistrationData[] lifecycleRegistrations) { List sections = Container.IsRegistered>() diff --git a/src/SquidStd.Services.Core/Services/ConfigManagerService.cs b/src/SquidStd.Services.Core/Services/ConfigManagerService.cs index 09ac24d7..7c95fa75 100644 --- a/src/SquidStd.Services.Core/Services/ConfigManagerService.cs +++ b/src/SquidStd.Services.Core/Services/ConfigManagerService.cs @@ -29,6 +29,9 @@ public sealed class ConfigManagerService : IConfigManagerService, ISquidStdServi /// public IReadOnlyCollection Entries => GetEntries(); + /// + public event Action? ConfigLoaded; + /// /// Initializes the config manager service. /// @@ -80,6 +83,8 @@ public void Load() { Save(); } + + ConfigLoaded?.Invoke(); } /// diff --git a/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs b/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs index e400a581..86ec7438 100644 --- a/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs +++ b/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs @@ -29,6 +29,13 @@ public ISquidStdBootstrap ConfigureServices(Func configu : throw new InvalidOperationException("ConfigureServices must return the bootstrap container instance."); } + public ISquidStdBootstrap OnConfigLoaded(Action configure) where TConfig : class + { + ArgumentNullException.ThrowIfNull(configure); + + return this; + } + public ValueTask DisposeAsync() { Container.Dispose(); diff --git a/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapConfigHooksTests.cs b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapConfigHooksTests.cs new file mode 100644 index 00000000..8a90f354 --- /dev/null +++ b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapConfigHooksTests.cs @@ -0,0 +1,183 @@ +using SquidStd.Abstractions.Extensions.Config; +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Core.Interfaces.Config; +using SquidStd.Core.Types; +using SquidStd.Services.Core.Services.Bootstrap; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Bootstrap; + +[Collection(SerilogEventSinkCollection.Name)] +public class SquidStdBootstrapConfigHooksTests +{ + [Fact] + public async Task OnConfigLoaded_MutatesSection_VisibleAfterStart() + { + using var temp = new TempDirectory(); + await using var bootstrap = NewBootstrap(temp.Path); + + bootstrap.ConfigureServices( + container => container.RegisterConfigSection("fakeSection", static () => new FakeSectionConfig(), 0) + ); + bootstrap.OnConfigLoaded(fake => fake.Limit = 42); + + await bootstrap.StartAsync(CancellationToken.None); + + try + { + var config = bootstrap.Resolve().GetConfig(); + + Assert.Equal(42, config.Limit); + } + finally + { + await bootstrap.StopAsync(CancellationToken.None); + } + } + + [Fact] + public async Task OnConfigLoaded_SurvivesTheSecondLoad() + { + using var temp = new TempDirectory(); + await using var bootstrap = NewBootstrap(temp.Path); + var invocations = 0; + + bootstrap.ConfigureServices( + container => container.RegisterConfigSection("fakeSection", static () => new FakeSectionConfig(), 0) + ); + bootstrap.OnConfigLoaded( + fake => + { + invocations++; + fake.Limit = 42; + } + ); + + await bootstrap.StartAsync(CancellationToken.None); + + try + { + var config = bootstrap.Resolve().GetConfig(); + + Assert.True(invocations >= 2, $"Expected the hook to run on every load, but it ran {invocations} time(s)."); + Assert.Equal(42, config.Limit); + } + finally + { + await bootstrap.StopAsync(CancellationToken.None); + } + } + + [Fact] + public async Task OnConfigLoaded_LoggerSection_AffectsLoggerConfiguration() + { + using var temp = new TempDirectory(); + await using var bootstrap = NewBootstrap(temp.Path); + + bootstrap.OnConfigLoaded(options => options.MinimumLevel = LogLevelType.None); + + bootstrap.ConfigureLogging(); + + var config = bootstrap.Resolve().GetConfig(); + + Assert.Equal(LogLevelType.None, config.MinimumLevel); + } + + [Fact] + public async Task OnConfigLoaded_MultipleHooksSameType_RunInOrder() + { + using var temp = new TempDirectory(); + await using var bootstrap = NewBootstrap(temp.Path); + + bootstrap.ConfigureServices( + container => container.RegisterConfigSection("fakeSection", static () => new FakeSectionConfig(), 0) + ); + bootstrap.OnConfigLoaded(fake => fake.Name = "first"); + bootstrap.OnConfigLoaded(fake => fake.Name += "+second"); + + await bootstrap.StartAsync(CancellationToken.None); + + try + { + var config = bootstrap.Resolve().GetConfig(); + + Assert.Equal("first+second", config.Name); + } + finally + { + await bootstrap.StopAsync(CancellationToken.None); + } + } + + [Fact] + public async Task OnConfigLoaded_UnregisteredType_Throws() + { + using var temp = new TempDirectory(); + await using var bootstrap = NewBootstrap(temp.Path); + + bootstrap.OnConfigLoaded(fake => fake.Limit = 42); + + var exception = await Assert.ThrowsAsync( + () => bootstrap.StartAsync(CancellationToken.None).AsTask() + ); + + Assert.Contains(nameof(FakeSectionConfig), exception.Message); + Assert.Contains("config section", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task OnConfigLoaded_AfterStart_Throws() + { + using var temp = new TempDirectory(); + await using var bootstrap = NewBootstrap(temp.Path); + + await bootstrap.StartAsync(CancellationToken.None); + + try + { + Assert.Throws( + () => bootstrap.OnConfigLoaded(fake => fake.Limit = 42) + ); + } + finally + { + await bootstrap.StopAsync(CancellationToken.None); + } + } + + [Fact] + public async Task OnConfigLoaded_DoesNotRewriteYamlFile() + { + using var temp = new TempDirectory(); + File.WriteAllText( + temp.Combine("app.yaml"), + "fakeSection:\n Name: fromfile\n Limit: 7\n" + ); + await using var bootstrap = NewBootstrap(temp.Path); + + bootstrap.ConfigureServices( + container => container.RegisterConfigSection("fakeSection", static () => new FakeSectionConfig(), 0) + ); + bootstrap.OnConfigLoaded(fake => fake.Limit = 999); + + await bootstrap.StartAsync(CancellationToken.None); + + try + { + var config = bootstrap.Resolve().GetConfig(); + var fileContent = File.ReadAllText(temp.Combine("app.yaml")); + + Assert.Equal(999, config.Limit); + Assert.Equal("fromfile", config.Name); + Assert.Contains("Limit: 7", fileContent); + Assert.DoesNotContain("999", fileContent); + } + finally + { + await bootstrap.StopAsync(CancellationToken.None); + } + } + + private static SquidStdBootstrap NewBootstrap(string root) + => SquidStdBootstrap.Create(new SquidStdOptions { ConfigName = "app", RootDirectory = root }); +} diff --git a/tests/SquidStd.Tests/Support/FakeSectionConfig.cs b/tests/SquidStd.Tests/Support/FakeSectionConfig.cs new file mode 100644 index 00000000..b1231c6d --- /dev/null +++ b/tests/SquidStd.Tests/Support/FakeSectionConfig.cs @@ -0,0 +1,11 @@ +namespace SquidStd.Tests.Support; + +/// +/// Test-only config section for bootstrap config-hook tests. +/// +public sealed class FakeSectionConfig +{ + public string Name { get; set; } = "default"; + + public int Limit { get; set; } = 1; +}