From df6ba54ca1f2752fd3b60c99c6a26ddf97bf4196 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 19:17:00 +0200 Subject: [PATCH 01/23] feat(health): add IHealthCheck contracts, HealthCheckResult/HealthReport DTOs and options --- .../Data/Config/HealthCheckOptions.cs | 10 +++++++ .../Data/Health/HealthCheckResult.cs | 29 +++++++++++++++++++ src/SquidStd.Core/Data/Health/HealthReport.cs | 21 ++++++++++++++ .../Interfaces/Health/IHealthCheck.cs | 16 ++++++++++ .../Interfaces/Health/IHealthCheckService.cs | 12 ++++++++ .../Types/Health/HealthStatus.cs | 13 +++++++++ .../Health/HealthCheckResultTests.cs | 28 ++++++++++++++++++ 7 files changed, 129 insertions(+) create mode 100644 src/SquidStd.Core/Data/Config/HealthCheckOptions.cs create mode 100644 src/SquidStd.Core/Data/Health/HealthCheckResult.cs create mode 100644 src/SquidStd.Core/Data/Health/HealthReport.cs create mode 100644 src/SquidStd.Core/Interfaces/Health/IHealthCheck.cs create mode 100644 src/SquidStd.Core/Interfaces/Health/IHealthCheckService.cs create mode 100644 src/SquidStd.Core/Types/Health/HealthStatus.cs create mode 100644 tests/SquidStd.Tests/Health/HealthCheckResultTests.cs diff --git a/src/SquidStd.Core/Data/Config/HealthCheckOptions.cs b/src/SquidStd.Core/Data/Config/HealthCheckOptions.cs new file mode 100644 index 00000000..b344fbe1 --- /dev/null +++ b/src/SquidStd.Core/Data/Config/HealthCheckOptions.cs @@ -0,0 +1,10 @@ +namespace SquidStd.Core.Data.Config; + +/// +/// Options for the health-check aggregator. +/// +public sealed class HealthCheckOptions +{ + /// Per-check timeout. A check exceeding it is reported unhealthy. Default 5 seconds. + public TimeSpan CheckTimeout { get; set; } = TimeSpan.FromSeconds(5); +} diff --git a/src/SquidStd.Core/Data/Health/HealthCheckResult.cs b/src/SquidStd.Core/Data/Health/HealthCheckResult.cs new file mode 100644 index 00000000..344b315c --- /dev/null +++ b/src/SquidStd.Core/Data/Health/HealthCheckResult.cs @@ -0,0 +1,29 @@ +using SquidStd.Core.Types.Health; + +namespace SquidStd.Core.Data.Health; + +/// +/// Result of a single health check. is stamped by the aggregator. +/// +public sealed record HealthCheckResult +{ + /// Health status of the check. + public required HealthStatus Status { get; init; } + + /// Optional human-readable description. + public string? Description { get; init; } + + /// Optional exception captured when the check failed. + public Exception? Exception { get; init; } + + /// How long the check took. + public TimeSpan Duration { get; init; } + + /// Creates a healthy result. + public static HealthCheckResult Healthy(string? description = null) + => new() { Status = HealthStatus.Healthy, Description = description }; + + /// Creates an unhealthy result. + public static HealthCheckResult Unhealthy(string? description = null, Exception? exception = null) + => new() { Status = HealthStatus.Unhealthy, Description = description, Exception = exception }; +} diff --git a/src/SquidStd.Core/Data/Health/HealthReport.cs b/src/SquidStd.Core/Data/Health/HealthReport.cs new file mode 100644 index 00000000..93c05f8c --- /dev/null +++ b/src/SquidStd.Core/Data/Health/HealthReport.cs @@ -0,0 +1,21 @@ +using SquidStd.Core.Types.Health; + +namespace SquidStd.Core.Data.Health; + +/// +/// Aggregated result of running every registered health check. +/// +public sealed record HealthReport +{ + /// Overall status (unhealthy if any entry is unhealthy). + public required HealthStatus Status { get; init; } + + /// Per-check results keyed by check name. + public required IReadOnlyDictionary Entries { get; init; } + + /// Wall-clock time taken to run all checks. + public TimeSpan TotalDuration { get; init; } + + /// UTC timestamp when the report was produced. + public DateTime TimestampUtc { get; init; } +} diff --git a/src/SquidStd.Core/Interfaces/Health/IHealthCheck.cs b/src/SquidStd.Core/Interfaces/Health/IHealthCheck.cs new file mode 100644 index 00000000..1555dad3 --- /dev/null +++ b/src/SquidStd.Core/Interfaces/Health/IHealthCheck.cs @@ -0,0 +1,16 @@ +using SquidStd.Core.Data.Health; + +namespace SquidStd.Core.Interfaces.Health; + +/// +/// A single health check for one component. +/// +public interface IHealthCheck +{ + /// Logical check name (used as the report entry key). + string Name { get; } + + /// Runs the check. + /// Token used to cancel the check (also fires on per-check timeout). + ValueTask CheckAsync(CancellationToken cancellationToken = default); +} diff --git a/src/SquidStd.Core/Interfaces/Health/IHealthCheckService.cs b/src/SquidStd.Core/Interfaces/Health/IHealthCheckService.cs new file mode 100644 index 00000000..c04b9042 --- /dev/null +++ b/src/SquidStd.Core/Interfaces/Health/IHealthCheckService.cs @@ -0,0 +1,12 @@ +using SquidStd.Core.Data.Health; + +namespace SquidStd.Core.Interfaces.Health; + +/// +/// Runs every registered and aggregates the results. +/// +public interface IHealthCheckService +{ + /// Runs all checks and returns the aggregated report. + ValueTask CheckHealthAsync(CancellationToken cancellationToken = default); +} diff --git a/src/SquidStd.Core/Types/Health/HealthStatus.cs b/src/SquidStd.Core/Types/Health/HealthStatus.cs new file mode 100644 index 00000000..d005a10e --- /dev/null +++ b/src/SquidStd.Core/Types/Health/HealthStatus.cs @@ -0,0 +1,13 @@ +namespace SquidStd.Core.Types.Health; + +/// +/// Overall health state of a check or report. +/// +public enum HealthStatus +{ + /// The component is healthy. + Healthy, + + /// The component is unhealthy. + Unhealthy +} diff --git a/tests/SquidStd.Tests/Health/HealthCheckResultTests.cs b/tests/SquidStd.Tests/Health/HealthCheckResultTests.cs new file mode 100644 index 00000000..79eb4650 --- /dev/null +++ b/tests/SquidStd.Tests/Health/HealthCheckResultTests.cs @@ -0,0 +1,28 @@ +using SquidStd.Core.Data.Health; +using SquidStd.Core.Types.Health; + +namespace SquidStd.Tests.Health; + +public class HealthCheckResultTests +{ + [Fact] + public void Healthy_SetsStatusAndDescription() + { + var result = HealthCheckResult.Healthy("ok"); + + Assert.Equal(HealthStatus.Healthy, result.Status); + Assert.Equal("ok", result.Description); + Assert.Null(result.Exception); + } + + [Fact] + public void Unhealthy_SetsStatusDescriptionAndException() + { + var ex = new InvalidOperationException("boom"); + var result = HealthCheckResult.Unhealthy("down", ex); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.Equal("down", result.Description); + Assert.Same(ex, result.Exception); + } +} From 7df80c4b081db722451c331582366195c51db8a0 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 19:18:29 +0200 Subject: [PATCH 02/23] feat(health): add parallel HealthCheckService aggregator with per-check timeout --- .../Services/HealthCheckService.cs | 115 +++++++++++++++++ .../Health/HealthCheckServiceTests.cs | 118 ++++++++++++++++++ .../SquidStd.Tests/Support/FakeHealthCheck.cs | 40 ++++++ 3 files changed, 273 insertions(+) create mode 100644 src/SquidStd.Services.Core/Services/HealthCheckService.cs create mode 100644 tests/SquidStd.Tests/Health/HealthCheckServiceTests.cs create mode 100644 tests/SquidStd.Tests/Support/FakeHealthCheck.cs diff --git a/src/SquidStd.Services.Core/Services/HealthCheckService.cs b/src/SquidStd.Services.Core/Services/HealthCheckService.cs new file mode 100644 index 00000000..57af6f94 --- /dev/null +++ b/src/SquidStd.Services.Core/Services/HealthCheckService.cs @@ -0,0 +1,115 @@ +using System.Diagnostics; +using Serilog; +using SquidStd.Core.Data.Config; +using SquidStd.Core.Data.Health; +using SquidStd.Core.Interfaces.Health; +using SquidStd.Core.Types.Health; + +namespace SquidStd.Services.Core.Services; + +/// +/// Runs every registered in parallel with a per-check timeout and +/// exception isolation, then aggregates the results into a single . +/// +public sealed class HealthCheckService : IHealthCheckService +{ + private readonly ILogger _logger = Log.ForContext(); + private readonly IHealthCheck[] _checks; + private readonly TimeSpan _checkTimeout; + + public HealthCheckService(IEnumerable checks, HealthCheckOptions options) + { + if (options.CheckTimeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(options), "CheckTimeout must be positive."); + } + + _checks = [.. checks]; + _checkTimeout = options.CheckTimeout; + } + + /// + public async ValueTask CheckHealthAsync(CancellationToken cancellationToken = default) + { + var timestampUtc = DateTime.UtcNow; + var stopwatch = Stopwatch.StartNew(); + + if (_checks.Length == 0) + { + return new HealthReport + { + Status = HealthStatus.Healthy, + Entries = new Dictionary(StringComparer.Ordinal), + TotalDuration = stopwatch.Elapsed, + TimestampUtc = timestampUtc + }; + } + + var tasks = new Task<(string Name, HealthCheckResult Result)>[_checks.Length]; + + for (var i = 0; i < _checks.Length; i++) + { + tasks[i] = RunCheckAsync(_checks[i], cancellationToken); + } + + var results = await Task.WhenAll(tasks); + + var entries = new Dictionary(StringComparer.Ordinal); + var overall = HealthStatus.Healthy; + + foreach (var (name, result) in results) + { + var key = name; + var suffix = 2; + + while (entries.ContainsKey(key)) + { + key = $"{name}#{suffix}"; + suffix++; + _logger.Warning("Duplicate health check name '{Name}'; reported as '{Key}'", name, key); + } + + entries[key] = result; + + if (result.Status == HealthStatus.Unhealthy) + { + overall = HealthStatus.Unhealthy; + } + } + + return new HealthReport + { + Status = overall, + Entries = entries, + TotalDuration = stopwatch.Elapsed, + TimestampUtc = timestampUtc + }; + } + + private async Task<(string Name, HealthCheckResult Result)> RunCheckAsync(IHealthCheck check, CancellationToken cancellationToken) + { + var stopwatch = Stopwatch.StartNew(); + + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(_checkTimeout); + + try + { + var result = await check.CheckAsync(timeoutSource.Token); + + return (check.Name, result with { Duration = stopwatch.Elapsed }); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return (check.Name, HealthCheckResult.Unhealthy($"Timed out after {_checkTimeout}.") with { Duration = stopwatch.Elapsed }); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return (check.Name, HealthCheckResult.Unhealthy(ex.Message, ex) with { Duration = stopwatch.Elapsed }); + } + } +} diff --git a/tests/SquidStd.Tests/Health/HealthCheckServiceTests.cs b/tests/SquidStd.Tests/Health/HealthCheckServiceTests.cs new file mode 100644 index 00000000..9173b23a --- /dev/null +++ b/tests/SquidStd.Tests/Health/HealthCheckServiceTests.cs @@ -0,0 +1,118 @@ +using SquidStd.Core.Data.Config; +using SquidStd.Core.Data.Health; +using SquidStd.Core.Interfaces.Health; +using SquidStd.Core.Types.Health; +using SquidStd.Services.Core.Services; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Health; + +public class HealthCheckServiceTests +{ + private static HealthCheckService NewService(HealthCheckOptions options, params IHealthCheck[] checks) + => new(checks, options); + + private static HealthCheckOptions Options(double timeoutSeconds = 5) + => new() { CheckTimeout = TimeSpan.FromSeconds(timeoutSeconds) }; + + [Fact] + public async Task NoChecks_ReturnsHealthyEmptyReport() + { + var report = await NewService(Options()).CheckHealthAsync(); + + Assert.Equal(HealthStatus.Healthy, report.Status); + Assert.Empty(report.Entries); + } + + [Fact] + public async Task AllHealthy_ReportsHealthyWithEntries() + { + var service = NewService(Options(), new FakeHealthCheck("a"), new FakeHealthCheck("b")); + + var report = await service.CheckHealthAsync(); + + Assert.Equal(HealthStatus.Healthy, report.Status); + Assert.Equal(2, report.Entries.Count); + Assert.Equal(HealthStatus.Healthy, report.Entries["a"].Status); + } + + [Fact] + public async Task OneUnhealthy_MakesOverallUnhealthy() + { + var service = NewService( + Options(), + new FakeHealthCheck("ok"), + new FakeHealthCheck("bad", HealthCheckResult.Unhealthy("nope")) + ); + + var report = await service.CheckHealthAsync(); + + Assert.Equal(HealthStatus.Unhealthy, report.Status); + Assert.Equal(HealthStatus.Unhealthy, report.Entries["bad"].Status); + Assert.Equal(HealthStatus.Healthy, report.Entries["ok"].Status); + } + + [Fact] + public async Task CheckThatThrows_IsCapturedAsUnhealthy_AndDoesNotBreakOthers() + { + var service = NewService( + Options(), + new FakeHealthCheck("boom", throwException: new InvalidOperationException("kaboom")), + new FakeHealthCheck("ok") + ); + + var report = await service.CheckHealthAsync(); + + Assert.Equal(HealthStatus.Unhealthy, report.Status); + Assert.Equal(HealthStatus.Unhealthy, report.Entries["boom"].Status); + Assert.Equal("kaboom", report.Entries["boom"].Description); + Assert.NotNull(report.Entries["boom"].Exception); + Assert.Equal(HealthStatus.Healthy, report.Entries["ok"].Status); + } + + [Fact] + public async Task CheckThatExceedsTimeout_IsUnhealthy_OthersUnaffected() + { + var service = NewService( + Options(timeoutSeconds: 0.05), + new FakeHealthCheck("slow", delay: TimeSpan.FromSeconds(2)), + new FakeHealthCheck("fast") + ); + + var report = await service.CheckHealthAsync(); + + Assert.Equal(HealthStatus.Unhealthy, report.Entries["slow"].Status); + Assert.Contains("imed out", report.Entries["slow"].Description); + Assert.Equal(HealthStatus.Healthy, report.Entries["fast"].Status); + } + + [Fact] + public async Task DuplicateNames_AreMadeUnique() + { + var service = NewService(Options(), new FakeHealthCheck("db"), new FakeHealthCheck("db")); + + var report = await service.CheckHealthAsync(); + + Assert.Equal(2, report.Entries.Count); + Assert.True(report.Entries.ContainsKey("db")); + Assert.True(report.Entries.ContainsKey("db#2")); + } + + [Fact] + public async Task ExternalCancellation_Propagates() + { + var service = NewService(Options(), new FakeHealthCheck("slow", delay: TimeSpan.FromSeconds(2))); + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Assert.ThrowsAsync(async () => await service.CheckHealthAsync(cts.Token)); + } + + [Fact] + public void Ctor_NonPositiveTimeout_Throws() + { + Assert.Throws( + () => new HealthCheckService([], new HealthCheckOptions { CheckTimeout = TimeSpan.Zero }) + ); + } +} diff --git a/tests/SquidStd.Tests/Support/FakeHealthCheck.cs b/tests/SquidStd.Tests/Support/FakeHealthCheck.cs new file mode 100644 index 00000000..3e52efb4 --- /dev/null +++ b/tests/SquidStd.Tests/Support/FakeHealthCheck.cs @@ -0,0 +1,40 @@ +using SquidStd.Core.Data.Health; +using SquidStd.Core.Interfaces.Health; + +namespace SquidStd.Tests.Support; + +/// +/// Configurable for tests: returns a fixed result, optionally after a +/// delay, or throws a configured exception. +/// +public sealed class FakeHealthCheck : IHealthCheck +{ + private readonly HealthCheckResult? _result; + private readonly Exception? _throw; + private readonly TimeSpan _delay; + + public FakeHealthCheck(string name, HealthCheckResult? result = null, TimeSpan? delay = null, Exception? throwException = null) + { + Name = name; + _result = result; + _delay = delay ?? TimeSpan.Zero; + _throw = throwException; + } + + public string Name { get; } + + public async ValueTask CheckAsync(CancellationToken cancellationToken = default) + { + if (_delay > TimeSpan.Zero) + { + await Task.Delay(_delay, cancellationToken); + } + + if (_throw is not null) + { + throw _throw; + } + + return _result ?? HealthCheckResult.Healthy(); + } +} From a7f7db30c8f69e664daf380943ad20ec75ea0852 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 19:19:48 +0200 Subject: [PATCH 03/23] feat(health): add RegisterHealthChecksService DI extension --- .../RegisterHealthChecksServiceExtension.cs | 30 +++++++++++++++++++ .../Health/HealthCheckRegistrationTests.cs | 28 +++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 src/SquidStd.Services.Core/Extensions/RegisterHealthChecksServiceExtension.cs create mode 100644 tests/SquidStd.Tests/Health/HealthCheckRegistrationTests.cs diff --git a/src/SquidStd.Services.Core/Extensions/RegisterHealthChecksServiceExtension.cs b/src/SquidStd.Services.Core/Extensions/RegisterHealthChecksServiceExtension.cs new file mode 100644 index 00000000..b04fd4f9 --- /dev/null +++ b/src/SquidStd.Services.Core/Extensions/RegisterHealthChecksServiceExtension.cs @@ -0,0 +1,30 @@ +using DryIoc; +using SquidStd.Abstractions.Extensions.Config; +using SquidStd.Core.Data.Config; +using SquidStd.Core.Interfaces.Health; +using SquidStd.Services.Core.Services; + +namespace SquidStd.Services.Core.Extensions; + +/// +/// Extension methods for registering the health-check aggregator. +/// +public static class RegisterHealthChecksServiceExtension +{ + /// Container that receives the health-check registration. + extension(IContainer container) + { + /// + /// Registers the health-check aggregator () as a singleton. + /// Concrete checks register themselves as IHealthCheck and are collected automatically. + /// + /// The same container for chaining. + public IContainer RegisterHealthChecksService() + { + container.RegisterConfigSection("healthChecks", static () => new HealthCheckOptions(), -80); + container.Register(Reuse.Singleton); + + return container; + } + } +} diff --git a/tests/SquidStd.Tests/Health/HealthCheckRegistrationTests.cs b/tests/SquidStd.Tests/Health/HealthCheckRegistrationTests.cs new file mode 100644 index 00000000..278084d5 --- /dev/null +++ b/tests/SquidStd.Tests/Health/HealthCheckRegistrationTests.cs @@ -0,0 +1,28 @@ +using DryIoc; +using SquidStd.Core.Data.Config; +using SquidStd.Core.Interfaces.Health; +using SquidStd.Services.Core.Extensions; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Health; + +public class HealthCheckRegistrationTests +{ + [Fact] + public async Task RegisterHealthChecksService_ResolvesAndAggregatesRegisteredChecks() + { + var container = new Container(); + container.RegisterInstance(new HealthCheckOptions()); + container.RegisterInstance(new FakeHealthCheck("a"), IfAlreadyRegistered.AppendNotKeyed); + container.RegisterInstance(new FakeHealthCheck("b"), IfAlreadyRegistered.AppendNotKeyed); + + container.RegisterHealthChecksService(); + + var service = container.Resolve(); + var report = await service.CheckHealthAsync(); + + Assert.Equal(2, report.Entries.Count); + Assert.True(report.Entries.ContainsKey("a")); + Assert.True(report.Entries.ContainsKey("b")); + } +} From c0f1584aa6a4900e6d31e29e4f2b4d10f27d2c26 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 20:45:09 +0200 Subject: [PATCH 04/23] refactor(storage): extract storage contracts into SquidStd.Storage.Abstractions --- SquidStd.slnx | 1 + .../RegisterDefaultServicesExtensions.cs | 3 ++- .../Services/Storage/FileSecretStore.cs | 2 +- .../Services/Storage/FileStorageService.cs | 4 ++-- .../Services/Storage/YamlObjectStorageService.cs | 2 +- .../SquidStd.Services.Core.csproj | 1 + .../Data/Config}/StorageConfig.cs | 2 +- .../Interfaces}/IObjectStorageService.cs | 2 +- .../Interfaces}/IStorageService.cs | 2 +- .../SquidStd.Storage.Abstractions.csproj | 14 ++++++++++++++ .../Core/RegisterDefaultServicesExtensionsTests.cs | 3 ++- tests/SquidStd.Tests/Storage/StorageConfigTests.cs | 1 + 12 files changed, 28 insertions(+), 9 deletions(-) rename src/{SquidStd.Core/Data/Storage => SquidStd.Storage.Abstractions/Data/Config}/StorageConfig.cs (90%) rename src/{SquidStd.Core/Interfaces/Storage => SquidStd.Storage.Abstractions/Interfaces}/IObjectStorageService.cs (97%) rename src/{SquidStd.Core/Interfaces/Storage => SquidStd.Storage.Abstractions/Interfaces}/IStorageService.cs (97%) create mode 100644 src/SquidStd.Storage.Abstractions/SquidStd.Storage.Abstractions.csproj diff --git a/SquidStd.slnx b/SquidStd.slnx index 12acb065..a0ad3df8 100644 --- a/SquidStd.slnx +++ b/SquidStd.slnx @@ -15,6 +15,7 @@ + diff --git a/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs b/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs index 51d1d88b..38fd588c 100644 --- a/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs +++ b/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs @@ -7,6 +7,7 @@ using SquidStd.Core.Data.Jobs; using SquidStd.Core.Data.Metrics; using SquidStd.Core.Data.Storage; +using SquidStd.Storage.Abstractions.Data.Config; using SquidStd.Core.Data.Timing; using SquidStd.Core.Interfaces.Config; using SquidStd.Core.Interfaces.Events; @@ -14,7 +15,7 @@ using SquidStd.Core.Interfaces.Metrics; using SquidStd.Core.Interfaces.Secrets; using SquidStd.Core.Interfaces.Serialization; -using SquidStd.Core.Interfaces.Storage; +using SquidStd.Storage.Abstractions.Interfaces; using SquidStd.Core.Interfaces.Threading; using SquidStd.Core.Interfaces.Timing; using SquidStd.Core.Json; diff --git a/src/SquidStd.Services.Core/Services/Storage/FileSecretStore.cs b/src/SquidStd.Services.Core/Services/Storage/FileSecretStore.cs index 17a441b7..afb45c3a 100644 --- a/src/SquidStd.Services.Core/Services/Storage/FileSecretStore.cs +++ b/src/SquidStd.Services.Core/Services/Storage/FileSecretStore.cs @@ -1,7 +1,7 @@ using System.Text; using SquidStd.Core.Data.Storage; using SquidStd.Core.Interfaces.Secrets; -using SquidStd.Core.Interfaces.Storage; +using SquidStd.Storage.Abstractions.Interfaces; namespace SquidStd.Services.Core.Services.Storage; diff --git a/src/SquidStd.Services.Core/Services/Storage/FileStorageService.cs b/src/SquidStd.Services.Core/Services/Storage/FileStorageService.cs index debadf4c..68359e29 100644 --- a/src/SquidStd.Services.Core/Services/Storage/FileStorageService.cs +++ b/src/SquidStd.Services.Core/Services/Storage/FileStorageService.cs @@ -1,5 +1,5 @@ -using SquidStd.Core.Data.Storage; -using SquidStd.Core.Interfaces.Storage; +using SquidStd.Storage.Abstractions.Data.Config; +using SquidStd.Storage.Abstractions.Interfaces; using SquidStd.Services.Core.Services.Internal; namespace SquidStd.Services.Core.Services.Storage; diff --git a/src/SquidStd.Services.Core/Services/Storage/YamlObjectStorageService.cs b/src/SquidStd.Services.Core/Services/Storage/YamlObjectStorageService.cs index 63ddb76a..5b26df65 100644 --- a/src/SquidStd.Services.Core/Services/Storage/YamlObjectStorageService.cs +++ b/src/SquidStd.Services.Core/Services/Storage/YamlObjectStorageService.cs @@ -1,5 +1,5 @@ using System.Text; -using SquidStd.Core.Interfaces.Storage; +using SquidStd.Storage.Abstractions.Interfaces; using SquidStd.Core.Yaml; namespace SquidStd.Services.Core.Services.Storage; diff --git a/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj b/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj index 60a1f51f..fa69586e 100644 --- a/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj +++ b/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj @@ -10,6 +10,7 @@ + diff --git a/src/SquidStd.Core/Data/Storage/StorageConfig.cs b/src/SquidStd.Storage.Abstractions/Data/Config/StorageConfig.cs similarity index 90% rename from src/SquidStd.Core/Data/Storage/StorageConfig.cs rename to src/SquidStd.Storage.Abstractions/Data/Config/StorageConfig.cs index c866ab3f..d75be728 100644 --- a/src/SquidStd.Core/Data/Storage/StorageConfig.cs +++ b/src/SquidStd.Storage.Abstractions/Data/Config/StorageConfig.cs @@ -1,6 +1,6 @@ using SquidStd.Core.Interfaces.Config; -namespace SquidStd.Core.Data.Storage; +namespace SquidStd.Storage.Abstractions.Data.Config; /// /// Configuration for local file storage. diff --git a/src/SquidStd.Core/Interfaces/Storage/IObjectStorageService.cs b/src/SquidStd.Storage.Abstractions/Interfaces/IObjectStorageService.cs similarity index 97% rename from src/SquidStd.Core/Interfaces/Storage/IObjectStorageService.cs rename to src/SquidStd.Storage.Abstractions/Interfaces/IObjectStorageService.cs index 03647d4a..2bb73019 100644 --- a/src/SquidStd.Core/Interfaces/Storage/IObjectStorageService.cs +++ b/src/SquidStd.Storage.Abstractions/Interfaces/IObjectStorageService.cs @@ -1,4 +1,4 @@ -namespace SquidStd.Core.Interfaces.Storage; +namespace SquidStd.Storage.Abstractions.Interfaces; /// /// Stores typed objects by logical key. diff --git a/src/SquidStd.Core/Interfaces/Storage/IStorageService.cs b/src/SquidStd.Storage.Abstractions/Interfaces/IStorageService.cs similarity index 97% rename from src/SquidStd.Core/Interfaces/Storage/IStorageService.cs rename to src/SquidStd.Storage.Abstractions/Interfaces/IStorageService.cs index 50b0771b..c7820939 100644 --- a/src/SquidStd.Core/Interfaces/Storage/IStorageService.cs +++ b/src/SquidStd.Storage.Abstractions/Interfaces/IStorageService.cs @@ -1,4 +1,4 @@ -namespace SquidStd.Core.Interfaces.Storage; +namespace SquidStd.Storage.Abstractions.Interfaces; /// /// Stores binary payloads by logical key. diff --git a/src/SquidStd.Storage.Abstractions/SquidStd.Storage.Abstractions.csproj b/src/SquidStd.Storage.Abstractions/SquidStd.Storage.Abstractions.csproj new file mode 100644 index 00000000..d1a27def --- /dev/null +++ b/src/SquidStd.Storage.Abstractions/SquidStd.Storage.Abstractions.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + true + + + + + + + diff --git a/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs b/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs index 3f281b57..5d230123 100644 --- a/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs +++ b/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs @@ -5,11 +5,12 @@ using SquidStd.Core.Data.Jobs; using SquidStd.Core.Data.Metrics; using SquidStd.Core.Data.Storage; +using SquidStd.Storage.Abstractions.Data.Config; using SquidStd.Core.Data.Timing; using SquidStd.Core.Interfaces.Config; using SquidStd.Core.Interfaces.Metrics; using SquidStd.Core.Interfaces.Secrets; -using SquidStd.Core.Interfaces.Storage; +using SquidStd.Storage.Abstractions.Interfaces; using SquidStd.Services.Core.Extensions; using SquidStd.Services.Core.Services; using SquidStd.Services.Core.Services.Storage; diff --git a/tests/SquidStd.Tests/Storage/StorageConfigTests.cs b/tests/SquidStd.Tests/Storage/StorageConfigTests.cs index 78de2a4b..d663842a 100644 --- a/tests/SquidStd.Tests/Storage/StorageConfigTests.cs +++ b/tests/SquidStd.Tests/Storage/StorageConfigTests.cs @@ -1,4 +1,5 @@ using SquidStd.Core.Data.Storage; +using SquidStd.Storage.Abstractions.Data.Config; using SquidStd.Core.Interfaces.Config; namespace SquidStd.Tests.Storage; From 43de18008ffcc27a42c7a8e40cf2318eea5952a6 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 20:51:27 +0200 Subject: [PATCH 05/23] refactor(storage): move file provider into SquidStd.Storage and make storage opt-in - move FileStorageService/YamlObjectStorageService/StoragePathResolver into SquidStd.Storage - add AddFileStorage opt-in registration; drop RegisterStorageServices from core - FileSecretStore references SquidStd.Storage.Services for FileStorageService - split the mixed storage/secret test file: storage -> tests/Storage, secrets -> tests/Security - update RegisterDefaultServicesExtensionsTests for opt-in storage --- SquidStd.slnx | 1 + .../RegisterDefaultServicesExtensions.cs | 14 ---- .../Services/Storage/FileSecretStore.cs | 1 + .../SquidStd.Services.Core.csproj | 1 + .../StorageRegistrationExtensions.cs | 24 ++++++ .../Internal/StoragePathResolver.cs | 2 +- .../Services}/FileStorageService.cs | 4 +- .../Services}/YamlObjectStorageService.cs | 2 +- src/SquidStd.Storage/SquidStd.Storage.csproj | 19 +++++ .../SecretsTests.cs} | 70 +----------------- .../RegisterDefaultServicesExtensionsTests.cs | 23 +----- .../Storage/FileStorageServiceTests.cs | 74 +++++++++++++++++++ 12 files changed, 130 insertions(+), 105 deletions(-) create mode 100644 src/SquidStd.Storage/Extensions/StorageRegistrationExtensions.cs rename src/{SquidStd.Services.Core/Services => SquidStd.Storage}/Internal/StoragePathResolver.cs (97%) rename src/{SquidStd.Services.Core/Services/Storage => SquidStd.Storage/Services}/FileStorageService.cs (96%) rename src/{SquidStd.Services.Core/Services/Storage => SquidStd.Storage/Services}/YamlObjectStorageService.cs (97%) create mode 100644 src/SquidStd.Storage/SquidStd.Storage.csproj rename tests/SquidStd.Tests/{Services/Core/Storage/FileStorageServiceTests.cs => Security/SecretsTests.cs} (63%) create mode 100644 tests/SquidStd.Tests/Storage/FileStorageServiceTests.cs diff --git a/SquidStd.slnx b/SquidStd.slnx index a0ad3df8..e4ef59e6 100644 --- a/SquidStd.slnx +++ b/SquidStd.slnx @@ -16,6 +16,7 @@ + diff --git a/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs b/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs index 38fd588c..1ce76469 100644 --- a/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs +++ b/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs @@ -87,7 +87,6 @@ public IContainer RegisterCoreServices(string configName, string configDirectory container.RegisterMainThreadDispatcherService(); container.RegisterTimerWheelService(); container.RegisterMetricsCollectionService(); - container.RegisterStorageServices(); container.RegisterSecretServices(); return container; @@ -138,19 +137,6 @@ public IContainer RegisterMetricsCollectionService() return container.RegisterStdService(1000); } - /// - /// Registers default local storage services in the container. - /// - /// The same container for chaining. - public IContainer RegisterStorageServices() - { - container.RegisterConfigSection("storage", static () => new StorageConfig(), -70); - container.Register(Reuse.Singleton); - container.Register(Reuse.Singleton); - - return container; - } - /// /// Registers default encrypted local secret services in the container. /// diff --git a/src/SquidStd.Services.Core/Services/Storage/FileSecretStore.cs b/src/SquidStd.Services.Core/Services/Storage/FileSecretStore.cs index afb45c3a..ff3d3a22 100644 --- a/src/SquidStd.Services.Core/Services/Storage/FileSecretStore.cs +++ b/src/SquidStd.Services.Core/Services/Storage/FileSecretStore.cs @@ -2,6 +2,7 @@ using SquidStd.Core.Data.Storage; using SquidStd.Core.Interfaces.Secrets; using SquidStd.Storage.Abstractions.Interfaces; +using SquidStd.Storage.Services; namespace SquidStd.Services.Core.Services.Storage; diff --git a/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj b/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj index fa69586e..fec735f0 100644 --- a/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj +++ b/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj @@ -11,6 +11,7 @@ + diff --git a/src/SquidStd.Storage/Extensions/StorageRegistrationExtensions.cs b/src/SquidStd.Storage/Extensions/StorageRegistrationExtensions.cs new file mode 100644 index 00000000..271bede5 --- /dev/null +++ b/src/SquidStd.Storage/Extensions/StorageRegistrationExtensions.cs @@ -0,0 +1,24 @@ +using DryIoc; +using SquidStd.Storage.Abstractions.Data.Config; +using SquidStd.Storage.Abstractions.Interfaces; +using SquidStd.Storage.Services; + +namespace SquidStd.Storage.Extensions; + +/// +/// DryIoc registration helpers for the local file storage provider. +/// +public static class StorageRegistrationExtensions +{ + /// Registers file-backed and YAML-backed . + public static IContainer AddFileStorage(this IContainer container, StorageConfig? config = null) + { + ArgumentNullException.ThrowIfNull(container); + + container.RegisterInstance(config ?? new StorageConfig()); + container.Register(Reuse.Singleton); + container.Register(Reuse.Singleton); + + return container; + } +} diff --git a/src/SquidStd.Services.Core/Services/Internal/StoragePathResolver.cs b/src/SquidStd.Storage/Internal/StoragePathResolver.cs similarity index 97% rename from src/SquidStd.Services.Core/Services/Internal/StoragePathResolver.cs rename to src/SquidStd.Storage/Internal/StoragePathResolver.cs index 048fcd98..8e357434 100644 --- a/src/SquidStd.Services.Core/Services/Internal/StoragePathResolver.cs +++ b/src/SquidStd.Storage/Internal/StoragePathResolver.cs @@ -1,4 +1,4 @@ -namespace SquidStd.Services.Core.Services.Internal; +namespace SquidStd.Storage.Internal; /// /// Resolves logical storage keys into paths constrained to one root directory. diff --git a/src/SquidStd.Services.Core/Services/Storage/FileStorageService.cs b/src/SquidStd.Storage/Services/FileStorageService.cs similarity index 96% rename from src/SquidStd.Services.Core/Services/Storage/FileStorageService.cs rename to src/SquidStd.Storage/Services/FileStorageService.cs index 68359e29..262da220 100644 --- a/src/SquidStd.Services.Core/Services/Storage/FileStorageService.cs +++ b/src/SquidStd.Storage/Services/FileStorageService.cs @@ -1,8 +1,8 @@ using SquidStd.Storage.Abstractions.Data.Config; using SquidStd.Storage.Abstractions.Interfaces; -using SquidStd.Services.Core.Services.Internal; +using SquidStd.Storage.Internal; -namespace SquidStd.Services.Core.Services.Storage; +namespace SquidStd.Storage.Services; /// /// Local file-backed binary storage. diff --git a/src/SquidStd.Services.Core/Services/Storage/YamlObjectStorageService.cs b/src/SquidStd.Storage/Services/YamlObjectStorageService.cs similarity index 97% rename from src/SquidStd.Services.Core/Services/Storage/YamlObjectStorageService.cs rename to src/SquidStd.Storage/Services/YamlObjectStorageService.cs index 5b26df65..6812d8fb 100644 --- a/src/SquidStd.Services.Core/Services/Storage/YamlObjectStorageService.cs +++ b/src/SquidStd.Storage/Services/YamlObjectStorageService.cs @@ -2,7 +2,7 @@ using SquidStd.Storage.Abstractions.Interfaces; using SquidStd.Core.Yaml; -namespace SquidStd.Services.Core.Services.Storage; +namespace SquidStd.Storage.Services; /// /// YAML object storage built on top of binary storage. diff --git a/src/SquidStd.Storage/SquidStd.Storage.csproj b/src/SquidStd.Storage/SquidStd.Storage.csproj new file mode 100644 index 00000000..32d53f52 --- /dev/null +++ b/src/SquidStd.Storage/SquidStd.Storage.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + diff --git a/tests/SquidStd.Tests/Services/Core/Storage/FileStorageServiceTests.cs b/tests/SquidStd.Tests/Security/SecretsTests.cs similarity index 63% rename from tests/SquidStd.Tests/Services/Core/Storage/FileStorageServiceTests.cs rename to tests/SquidStd.Tests/Security/SecretsTests.cs index bb998645..1c62ecda 100644 --- a/tests/SquidStd.Tests/Services/Core/Storage/FileStorageServiceTests.cs +++ b/tests/SquidStd.Tests/Security/SecretsTests.cs @@ -7,70 +7,11 @@ using SquidStd.Services.Core.Services.Storage; using SquidStd.Tests.Support; -namespace SquidStd.Tests.Services.Core.Storage; +namespace SquidStd.Tests.Security; [Collection(SerilogEventSinkCollection.Name)] -public class FileStorageServiceTests +public class SecretsTests { - [Fact] - public async Task SaveAsync_LoadAsync_RoundTripsBytes() - { - using var temp = new TempDirectory(); - var service = new FileStorageService(new() { RootDirectory = temp.Path }); - var data = Encoding.UTF8.GetBytes("hello storage"); - - await service.SaveAsync("profiles/main.bin", data); - - var loaded = await service.LoadAsync("profiles/main.bin"); - - Assert.Equal(data, loaded); - Assert.True(await service.ExistsAsync("profiles/main.bin")); - } - - [Fact] - public async Task DeleteAsync_RemovesStoredValue() - { - using var temp = new TempDirectory(); - var service = new FileStorageService(new() { RootDirectory = temp.Path }); - await service.SaveAsync("cache/value.bin", new byte[] { 1, 2, 3 }); - - var deleted = await service.DeleteAsync("cache/value.bin"); - - Assert.True(deleted); - Assert.False(await service.ExistsAsync("cache/value.bin")); - Assert.Null(await service.LoadAsync("cache/value.bin")); - } - - [Theory, InlineData("../escape.bin"), InlineData("/absolute.bin"), InlineData("nested/../../escape.bin")] - public async Task SaveAsync_RejectsUnsafeKeys(string key) - { - using var temp = new TempDirectory(); - var service = new FileStorageService(new() { RootDirectory = temp.Path }); - - await Assert.ThrowsAsync(() => service.SaveAsync(key, new byte[] { 1 }).AsTask()); - } - - [Fact] - public async Task ObjectStorage_SaveAsync_LoadAsync_RoundTripsYamlObject() - { - using var temp = new TempDirectory(); - var storage = new FileStorageService(new() { RootDirectory = temp.Path }); - var objects = new YamlObjectStorageService(storage); - var expected = new SampleObject - { - Name = "main", - Value = 42 - }; - - await objects.SaveAsync("objects/sample.yaml", expected); - - var actual = await objects.LoadAsync("objects/sample.yaml"); - - Assert.NotNull(actual); - Assert.Equal(expected.Name, actual.Name); - Assert.Equal(expected.Value, actual.Value); - } - [Fact] public void AesGcmSecretProtector_Protect_Unprotect_RoundTripsWithoutPlaintext() { @@ -162,13 +103,6 @@ public async Task FileSecretStore_SetAsync_GetAsync_StoresEncryptedPayload() } } - private sealed class SampleObject - { - public string Name { get; set; } = string.Empty; - - public int Value { get; set; } - } - private sealed class CapturingSink : ILogEventSink { private readonly List _events = []; diff --git a/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs b/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs index 5d230123..74e259a7 100644 --- a/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs +++ b/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs @@ -166,35 +166,20 @@ public void RegisterCoreServices_RegistersMetricsCollectionService() } [Fact] - public void RegisterCoreServices_RegistersStorageAndSecretServices() + public void RegisterCoreServices_RegistersSecretServices() { using var temp = new TempDirectory(); using var container = new Container(); container.RegisterCoreServices("app", temp.Path); - Assert.True(container.IsRegistered()); - Assert.True(container.IsRegistered()); + // Storage is opt-in (AddFileStorage); core no longer registers it. + Assert.False(container.IsRegistered()); + Assert.False(container.IsRegistered()); Assert.True(container.IsRegistered()); Assert.True(container.IsRegistered()); } - [Fact] - public async Task RegisterStorageServices_RegistersFileAndYamlStorage() - { - using var temp = new TempDirectory(); - using var container = new Container(); - - container.RegisterStorageServices(); - container.RegisterConfigManagerService("app", temp.Path); - await ((ConfigManagerService)container.Resolve()).StartAsync(CancellationToken.None); - - Assert.True(container.IsRegistered()); - Assert.True(container.IsRegistered()); - Assert.IsType(container.Resolve()); - Assert.IsType(container.Resolve()); - } - [Fact] public void RegisterSecretServices_RegistersSecretProtectorAndStore() { diff --git a/tests/SquidStd.Tests/Storage/FileStorageServiceTests.cs b/tests/SquidStd.Tests/Storage/FileStorageServiceTests.cs new file mode 100644 index 00000000..4037de38 --- /dev/null +++ b/tests/SquidStd.Tests/Storage/FileStorageServiceTests.cs @@ -0,0 +1,74 @@ +using System.Text; +using SquidStd.Storage.Services; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Storage; + +public class FileStorageServiceTests +{ + [Fact] + public async Task SaveAsync_LoadAsync_RoundTripsBytes() + { + using var temp = new TempDirectory(); + var service = new FileStorageService(new() { RootDirectory = temp.Path }); + var data = Encoding.UTF8.GetBytes("hello storage"); + + await service.SaveAsync("profiles/main.bin", data); + + var loaded = await service.LoadAsync("profiles/main.bin"); + + Assert.Equal(data, loaded); + Assert.True(await service.ExistsAsync("profiles/main.bin")); + } + + [Fact] + public async Task DeleteAsync_RemovesStoredValue() + { + using var temp = new TempDirectory(); + var service = new FileStorageService(new() { RootDirectory = temp.Path }); + await service.SaveAsync("cache/value.bin", new byte[] { 1, 2, 3 }); + + var deleted = await service.DeleteAsync("cache/value.bin"); + + Assert.True(deleted); + Assert.False(await service.ExistsAsync("cache/value.bin")); + Assert.Null(await service.LoadAsync("cache/value.bin")); + } + + [Theory, InlineData("../escape.bin"), InlineData("/absolute.bin"), InlineData("nested/../../escape.bin")] + public async Task SaveAsync_RejectsUnsafeKeys(string key) + { + using var temp = new TempDirectory(); + var service = new FileStorageService(new() { RootDirectory = temp.Path }); + + await Assert.ThrowsAsync(() => service.SaveAsync(key, new byte[] { 1 }).AsTask()); + } + + [Fact] + public async Task ObjectStorage_SaveAsync_LoadAsync_RoundTripsYamlObject() + { + using var temp = new TempDirectory(); + var storage = new FileStorageService(new() { RootDirectory = temp.Path }); + var objects = new YamlObjectStorageService(storage); + var expected = new SampleObject + { + Name = "main", + Value = 42 + }; + + await objects.SaveAsync("objects/sample.yaml", expected); + + var actual = await objects.LoadAsync("objects/sample.yaml"); + + Assert.NotNull(actual); + Assert.Equal(expected.Name, actual.Name); + Assert.Equal(expected.Value, actual.Value); + } + + private sealed class SampleObject + { + public string Name { get; set; } = string.Empty; + + public int Value { get; set; } + } +} From e494c7ff8dc6655927c904375f0b6951556a849e Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 20:52:43 +0200 Subject: [PATCH 06/23] test(storage): add AddFileStorage registration test --- tests/SquidStd.Tests/SquidStd.Tests.csproj | 1 + .../Storage/StorageRegistrationTests.cs | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 tests/SquidStd.Tests/Storage/StorageRegistrationTests.cs diff --git a/tests/SquidStd.Tests/SquidStd.Tests.csproj b/tests/SquidStd.Tests/SquidStd.Tests.csproj index 6cf2d3ec..4b95e119 100644 --- a/tests/SquidStd.Tests/SquidStd.Tests.csproj +++ b/tests/SquidStd.Tests/SquidStd.Tests.csproj @@ -45,6 +45,7 @@ + diff --git a/tests/SquidStd.Tests/Storage/StorageRegistrationTests.cs b/tests/SquidStd.Tests/Storage/StorageRegistrationTests.cs new file mode 100644 index 00000000..7053e01f --- /dev/null +++ b/tests/SquidStd.Tests/Storage/StorageRegistrationTests.cs @@ -0,0 +1,28 @@ +using DryIoc; +using SquidStd.Storage.Abstractions.Data.Config; +using SquidStd.Storage.Abstractions.Interfaces; +using SquidStd.Storage.Extensions; + +namespace SquidStd.Tests.Storage; + +public class StorageRegistrationTests +{ + [Fact] + public async Task AddFileStorage_ResolvesAndRoundTrips() + { + var root = Path.Combine(Path.GetTempPath(), "squidstd-storage-" + Guid.NewGuid().ToString("N")); + var container = new Container(); + + container.AddFileStorage(new StorageConfig { RootDirectory = root }); + + var storage = container.Resolve(); + Assert.NotNull(container.Resolve()); + + await storage.SaveAsync("k", new byte[] { 1, 2, 3 }); + var loaded = await storage.LoadAsync("k"); + + Assert.Equal(new byte[] { 1, 2, 3 }, loaded); + + Directory.Delete(root, recursive: true); + } +} From 5332b4d68fcbabaa841a4a71463c8b46378963ef Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 20:54:16 +0200 Subject: [PATCH 07/23] feat(storage): add S3/MinIO storage provider and registration --- SquidStd.slnx | 1 + .../Data/Config/S3StorageOptions.cs | 25 +++ .../S3StorageRegistrationExtensions.cs | 24 +++ .../Services/S3StorageService.cs | 150 ++++++++++++++++++ .../SquidStd.Storage.S3.csproj | 19 +++ tests/SquidStd.Tests/SquidStd.Tests.csproj | 1 + 6 files changed, 220 insertions(+) create mode 100644 src/SquidStd.Storage.S3/Data/Config/S3StorageOptions.cs create mode 100644 src/SquidStd.Storage.S3/Extensions/S3StorageRegistrationExtensions.cs create mode 100644 src/SquidStd.Storage.S3/Services/S3StorageService.cs create mode 100644 src/SquidStd.Storage.S3/SquidStd.Storage.S3.csproj diff --git a/SquidStd.slnx b/SquidStd.slnx index e4ef59e6..12d68a83 100644 --- a/SquidStd.slnx +++ b/SquidStd.slnx @@ -16,6 +16,7 @@ + diff --git a/src/SquidStd.Storage.S3/Data/Config/S3StorageOptions.cs b/src/SquidStd.Storage.S3/Data/Config/S3StorageOptions.cs new file mode 100644 index 00000000..ca379c92 --- /dev/null +++ b/src/SquidStd.Storage.S3/Data/Config/S3StorageOptions.cs @@ -0,0 +1,25 @@ +namespace SquidStd.Storage.S3.Data.Config; + +/// +/// Connection options for the S3-compatible (MinIO) storage provider. +/// +public sealed class S3StorageOptions +{ + /// Endpoint host[:port], e.g. "localhost:9000". + public string Endpoint { get; init; } = string.Empty; + + /// Access key. + public string AccessKey { get; init; } = string.Empty; + + /// Secret key. + public string SecretKey { get; init; } = string.Empty; + + /// Bucket name. + public string Bucket { get; init; } = string.Empty; + + /// Whether to use TLS. Default false. + public bool UseSsl { get; init; } + + /// Optional region. + public string? Region { get; init; } +} diff --git a/src/SquidStd.Storage.S3/Extensions/S3StorageRegistrationExtensions.cs b/src/SquidStd.Storage.S3/Extensions/S3StorageRegistrationExtensions.cs new file mode 100644 index 00000000..462c574a --- /dev/null +++ b/src/SquidStd.Storage.S3/Extensions/S3StorageRegistrationExtensions.cs @@ -0,0 +1,24 @@ +using DryIoc; +using SquidStd.Storage.Abstractions.Interfaces; +using SquidStd.Storage.S3.Data.Config; +using SquidStd.Storage.S3.Services; + +namespace SquidStd.Storage.S3.Extensions; + +/// +/// DryIoc registration helpers for the S3-compatible (MinIO) storage provider. +/// +public static class S3StorageRegistrationExtensions +{ + /// Registers backed by S3/MinIO. + public static IContainer AddS3Storage(this IContainer container, S3StorageOptions options) + { + ArgumentNullException.ThrowIfNull(container); + ArgumentNullException.ThrowIfNull(options); + + container.RegisterInstance(options); + container.Register(Reuse.Singleton); + + return container; + } +} diff --git a/src/SquidStd.Storage.S3/Services/S3StorageService.cs b/src/SquidStd.Storage.S3/Services/S3StorageService.cs new file mode 100644 index 00000000..f1ac80b1 --- /dev/null +++ b/src/SquidStd.Storage.S3/Services/S3StorageService.cs @@ -0,0 +1,150 @@ +using Minio; +using Minio.DataModel.Args; +using Minio.Exceptions; +using SquidStd.Storage.Abstractions.Interfaces; +using SquidStd.Storage.S3.Data.Config; + +namespace SquidStd.Storage.S3.Services; + +/// +/// S3-compatible backed by the MinIO client. The bucket is created +/// lazily on first use. +/// +public sealed class S3StorageService : IStorageService +{ + private readonly IMinioClient _client; + private readonly string _bucket; + private readonly SemaphoreSlim _bucketLock = new(1, 1); + private bool _bucketReady; + + public S3StorageService(S3StorageOptions options) + { + ArgumentException.ThrowIfNullOrWhiteSpace(options.Endpoint); + ArgumentException.ThrowIfNullOrWhiteSpace(options.AccessKey); + ArgumentException.ThrowIfNullOrWhiteSpace(options.SecretKey); + ArgumentException.ThrowIfNullOrWhiteSpace(options.Bucket); + + var builder = new MinioClient() + .WithEndpoint(options.Endpoint) + .WithCredentials(options.AccessKey, options.SecretKey) + .WithSSL(options.UseSsl); + + if (!string.IsNullOrWhiteSpace(options.Region)) + { + builder = builder.WithRegion(options.Region); + } + + _client = builder.Build(); + _bucket = options.Bucket; + } + + /// + public async ValueTask SaveAsync(string key, ReadOnlyMemory data, CancellationToken cancellationToken = default) + { + await EnsureBucketAsync(cancellationToken); + + var bytes = data.ToArray(); + using var stream = new MemoryStream(bytes, writable: false); + + await _client.PutObjectAsync( + new PutObjectArgs() + .WithBucket(_bucket) + .WithObject(key) + .WithStreamData(stream) + .WithObjectSize(bytes.LongLength), + cancellationToken + ); + } + + /// + public async ValueTask LoadAsync(string key, CancellationToken cancellationToken = default) + { + await EnsureBucketAsync(cancellationToken); + + using var buffer = new MemoryStream(); + + try + { + await _client.GetObjectAsync( + new GetObjectArgs() + .WithBucket(_bucket) + .WithObject(key) + .WithCallbackStream(async (stream, ct) => await stream.CopyToAsync(buffer, ct)), + cancellationToken + ); + } + catch (ObjectNotFoundException) + { + return null; + } + + return buffer.ToArray(); + } + + /// + public async ValueTask ExistsAsync(string key, CancellationToken cancellationToken = default) + { + await EnsureBucketAsync(cancellationToken); + + try + { + await _client.StatObjectAsync( + new StatObjectArgs().WithBucket(_bucket).WithObject(key), + cancellationToken + ); + + return true; + } + catch (ObjectNotFoundException) + { + return false; + } + } + + /// + public async ValueTask DeleteAsync(string key, CancellationToken cancellationToken = default) + { + if (!await ExistsAsync(key, cancellationToken)) + { + return false; + } + + await _client.RemoveObjectAsync( + new RemoveObjectArgs().WithBucket(_bucket).WithObject(key), + cancellationToken + ); + + return true; + } + + private async ValueTask EnsureBucketAsync(CancellationToken cancellationToken) + { + if (_bucketReady) + { + return; + } + + await _bucketLock.WaitAsync(cancellationToken); + + try + { + if (_bucketReady) + { + return; + } + + var exists = await _client.BucketExistsAsync(new BucketExistsArgs().WithBucket(_bucket), cancellationToken); + + if (!exists) + { + await _client.MakeBucketAsync(new MakeBucketArgs().WithBucket(_bucket), cancellationToken); + } + + _bucketReady = true; + } + finally + { + _bucketLock.Release(); + } + } +} diff --git a/src/SquidStd.Storage.S3/SquidStd.Storage.S3.csproj b/src/SquidStd.Storage.S3/SquidStd.Storage.S3.csproj new file mode 100644 index 00000000..a6463d49 --- /dev/null +++ b/src/SquidStd.Storage.S3/SquidStd.Storage.S3.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + diff --git a/tests/SquidStd.Tests/SquidStd.Tests.csproj b/tests/SquidStd.Tests/SquidStd.Tests.csproj index 4b95e119..a0e2ec93 100644 --- a/tests/SquidStd.Tests/SquidStd.Tests.csproj +++ b/tests/SquidStd.Tests/SquidStd.Tests.csproj @@ -46,6 +46,7 @@ + From 6c3e6cef7d7ffacc73fb0f4d32e07d5b576a5a70 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 20:57:13 +0200 Subject: [PATCH 08/23] test(storage): add S3/MinIO integration tests with Testcontainers - MinioContainerFixture + S3StorageServiceTests (roundtrip/missing/exists-delete/ctor) - make S3StorageService IDisposable (disposes the MinIO client and bucket lock, fixes CA1001) --- .../Services/S3StorageService.cs | 15 +++- tests/SquidStd.Tests/SquidStd.Tests.csproj | 1 + .../Storage/S3/MinioContainerFixture.cs | 29 ++++++++ .../Storage/S3/S3StorageServiceTests.cs | 68 +++++++++++++++++++ 4 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 tests/SquidStd.Tests/Storage/S3/MinioContainerFixture.cs create mode 100644 tests/SquidStd.Tests/Storage/S3/S3StorageServiceTests.cs diff --git a/src/SquidStd.Storage.S3/Services/S3StorageService.cs b/src/SquidStd.Storage.S3/Services/S3StorageService.cs index f1ac80b1..49bb256d 100644 --- a/src/SquidStd.Storage.S3/Services/S3StorageService.cs +++ b/src/SquidStd.Storage.S3/Services/S3StorageService.cs @@ -10,12 +10,13 @@ namespace SquidStd.Storage.S3.Services; /// S3-compatible backed by the MinIO client. The bucket is created /// lazily on first use. /// -public sealed class S3StorageService : IStorageService +public sealed class S3StorageService : IStorageService, IDisposable { private readonly IMinioClient _client; private readonly string _bucket; private readonly SemaphoreSlim _bucketLock = new(1, 1); private bool _bucketReady; + private int _disposed; public S3StorageService(S3StorageOptions options) { @@ -147,4 +148,16 @@ private async ValueTask EnsureBucketAsync(CancellationToken cancellationToken) _bucketLock.Release(); } } + + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _client.Dispose(); + _bucketLock.Dispose(); + } } diff --git a/tests/SquidStd.Tests/SquidStd.Tests.csproj b/tests/SquidStd.Tests/SquidStd.Tests.csproj index a0e2ec93..59802f79 100644 --- a/tests/SquidStd.Tests/SquidStd.Tests.csproj +++ b/tests/SquidStd.Tests/SquidStd.Tests.csproj @@ -11,6 +11,7 @@ + diff --git a/tests/SquidStd.Tests/Storage/S3/MinioContainerFixture.cs b/tests/SquidStd.Tests/Storage/S3/MinioContainerFixture.cs new file mode 100644 index 00000000..04cbb7c1 --- /dev/null +++ b/tests/SquidStd.Tests/Storage/S3/MinioContainerFixture.cs @@ -0,0 +1,29 @@ +using Testcontainers.Minio; + +namespace SquidStd.Tests.Storage.S3; + +/// +/// Starts a MinIO container once for the whole collection and exposes its endpoint and credentials. +/// +public sealed class MinioContainerFixture : IAsyncLifetime +{ + private readonly MinioContainer _container = new MinioBuilder().Build(); + + public string Endpoint => $"{_container.Hostname}:{_container.GetMappedPublicPort(9000)}"; + + public string AccessKey => _container.GetAccessKey(); + + public string SecretKey => _container.GetSecretKey(); + + public Task InitializeAsync() + => _container.StartAsync(); + + public Task DisposeAsync() + => _container.DisposeAsync().AsTask(); +} + +[CollectionDefinition(Name)] +public sealed class MinioCollection : ICollectionFixture +{ + public const string Name = "Minio"; +} diff --git a/tests/SquidStd.Tests/Storage/S3/S3StorageServiceTests.cs b/tests/SquidStd.Tests/Storage/S3/S3StorageServiceTests.cs new file mode 100644 index 00000000..94851e24 --- /dev/null +++ b/tests/SquidStd.Tests/Storage/S3/S3StorageServiceTests.cs @@ -0,0 +1,68 @@ +using System.Text; +using SquidStd.Storage.S3.Data.Config; +using SquidStd.Storage.S3.Services; + +namespace SquidStd.Tests.Storage.S3; + +[Collection(MinioCollection.Name)] +public class S3StorageServiceTests +{ + private readonly MinioContainerFixture _fixture; + + public S3StorageServiceTests(MinioContainerFixture fixture) + { + _fixture = fixture; + } + + private S3StorageService NewService() + => new( + new S3StorageOptions + { + Endpoint = _fixture.Endpoint, + AccessKey = _fixture.AccessKey, + SecretKey = _fixture.SecretKey, + Bucket = "squidstd-tests", + UseSsl = false + } + ); + + private static string Key() => "k-" + Guid.NewGuid().ToString("N"); + + [Fact] + public async Task SaveThenLoad_RoundTrips_AndCreatesBucket() + { + var storage = NewService(); + var key = Key(); + + await storage.SaveAsync(key, Encoding.UTF8.GetBytes("hello")); + var loaded = await storage.LoadAsync(key); + + Assert.NotNull(loaded); + Assert.Equal("hello", Encoding.UTF8.GetString(loaded!)); + } + + [Fact] + public async Task Load_MissingKey_ReturnsNull() + => Assert.Null(await NewService().LoadAsync(Key())); + + [Fact] + public async Task Exists_And_Delete() + { + var storage = NewService(); + var key = Key(); + await storage.SaveAsync(key, Encoding.UTF8.GetBytes("v")); + + Assert.True(await storage.ExistsAsync(key)); + Assert.True(await storage.DeleteAsync(key)); + Assert.False(await storage.ExistsAsync(key)); + Assert.False(await storage.DeleteAsync(key)); + } + + [Fact] + public void Ctor_MissingEndpoint_Throws() + { + Assert.Throws( + () => new S3StorageService(new S3StorageOptions { AccessKey = "a", SecretKey = "b", Bucket = "c" }) + ); + } +} From f6b524b5617a9277eba77555521ac0f64cf46704 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 21:14:29 +0200 Subject: [PATCH 09/23] feat(storage): add ListKeysAsync to IStorageService/IObjectStorageService and providers --- .../Interfaces/IObjectStorageService.cs | 8 +++ .../Interfaces/IStorageService.cs | 8 +++ .../Services/S3StorageService.cs | 25 ++++++++++ .../Services/FileStorageService.cs | 36 +++++++++++++ .../Services/YamlObjectStorageService.cs | 4 ++ .../Storage/FileStorageServiceTests.cs | 50 +++++++++++++++++++ 6 files changed, 131 insertions(+) diff --git a/src/SquidStd.Storage.Abstractions/Interfaces/IObjectStorageService.cs b/src/SquidStd.Storage.Abstractions/Interfaces/IObjectStorageService.cs index 2bb73019..7977ec61 100644 --- a/src/SquidStd.Storage.Abstractions/Interfaces/IObjectStorageService.cs +++ b/src/SquidStd.Storage.Abstractions/Interfaces/IObjectStorageService.cs @@ -38,4 +38,12 @@ public interface IObjectStorageService /// Token used to cancel the operation. /// The object type. ValueTask SaveAsync(string key, T value, CancellationToken cancellationToken = default); + + /// + /// Enumerates stored keys, optionally filtered by prefix. + /// + /// Optional key prefix; null or empty returns all keys. + /// Token used to cancel the enumeration. + /// An async sequence of storage keys. + IAsyncEnumerable ListKeysAsync(string? prefix = null, CancellationToken cancellationToken = default); } diff --git a/src/SquidStd.Storage.Abstractions/Interfaces/IStorageService.cs b/src/SquidStd.Storage.Abstractions/Interfaces/IStorageService.cs index c7820939..3d3b8980 100644 --- a/src/SquidStd.Storage.Abstractions/Interfaces/IStorageService.cs +++ b/src/SquidStd.Storage.Abstractions/Interfaces/IStorageService.cs @@ -36,4 +36,12 @@ public interface IStorageService /// The payload to store. /// Token used to cancel the operation. ValueTask SaveAsync(string key, ReadOnlyMemory data, CancellationToken cancellationToken = default); + + /// + /// Enumerates stored keys, optionally filtered by prefix. + /// + /// Optional key prefix; null or empty returns all keys. + /// Token used to cancel the enumeration. + /// An async sequence of storage keys. + IAsyncEnumerable ListKeysAsync(string? prefix = null, CancellationToken cancellationToken = default); } diff --git a/src/SquidStd.Storage.S3/Services/S3StorageService.cs b/src/SquidStd.Storage.S3/Services/S3StorageService.cs index 49bb256d..aa6c550e 100644 --- a/src/SquidStd.Storage.S3/Services/S3StorageService.cs +++ b/src/SquidStd.Storage.S3/Services/S3StorageService.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using Minio; using Minio.DataModel.Args; using Minio.Exceptions; @@ -118,6 +119,30 @@ await _client.RemoveObjectAsync( return true; } + /// + public async IAsyncEnumerable ListKeysAsync( + string? prefix = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default + ) + { + await EnsureBucketAsync(cancellationToken); + + var args = new ListObjectsArgs().WithBucket(_bucket).WithRecursive(true); + + if (!string.IsNullOrEmpty(prefix)) + { + args = args.WithPrefix(prefix); + } + + await foreach (var item in _client.ListObjectsEnumAsync(args, cancellationToken)) + { + if (!item.IsDir) + { + yield return item.Key; + } + } + } + private async ValueTask EnsureBucketAsync(CancellationToken cancellationToken) { if (_bucketReady) diff --git a/src/SquidStd.Storage/Services/FileStorageService.cs b/src/SquidStd.Storage/Services/FileStorageService.cs index 262da220..8e283dd8 100644 --- a/src/SquidStd.Storage/Services/FileStorageService.cs +++ b/src/SquidStd.Storage/Services/FileStorageService.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using SquidStd.Storage.Abstractions.Data.Config; using SquidStd.Storage.Abstractions.Interfaces; using SquidStd.Storage.Internal; @@ -89,6 +90,41 @@ public async ValueTask SaveAsync( } } + /// + public async IAsyncEnumerable ListKeysAsync( + string? prefix = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default + ) + { + await Task.CompletedTask; + + if (!Directory.Exists(_rootDirectory)) + { + yield break; + } + + foreach (var file in Directory.EnumerateFiles(_rootDirectory, "*", SearchOption.AllDirectories)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var name = Path.GetFileName(file); + + if (name.StartsWith('.') && name.EndsWith(".tmp", StringComparison.Ordinal)) + { + continue; + } + + var key = Path.GetRelativePath(_rootDirectory, file).Replace('\\', '/'); + + if (!string.IsNullOrEmpty(prefix) && !key.StartsWith(prefix, StringComparison.Ordinal)) + { + continue; + } + + yield return key; + } + } + private string ResolvePath(string key) => StoragePathResolver.ResolveFilePath(_rootDirectory, key); } diff --git a/src/SquidStd.Storage/Services/YamlObjectStorageService.cs b/src/SquidStd.Storage/Services/YamlObjectStorageService.cs index 6812d8fb..edbefb2c 100644 --- a/src/SquidStd.Storage/Services/YamlObjectStorageService.cs +++ b/src/SquidStd.Storage/Services/YamlObjectStorageService.cs @@ -51,4 +51,8 @@ public async ValueTask SaveAsync(string key, T value, CancellationToken cance var yaml = YamlUtils.Serialize(value); await _storageService.SaveAsync(key, Encoding.UTF8.GetBytes(yaml), cancellationToken); } + + /// + public IAsyncEnumerable ListKeysAsync(string? prefix = null, CancellationToken cancellationToken = default) + => _storageService.ListKeysAsync(prefix, cancellationToken); } diff --git a/tests/SquidStd.Tests/Storage/FileStorageServiceTests.cs b/tests/SquidStd.Tests/Storage/FileStorageServiceTests.cs index 4037de38..36c21abe 100644 --- a/tests/SquidStd.Tests/Storage/FileStorageServiceTests.cs +++ b/tests/SquidStd.Tests/Storage/FileStorageServiceTests.cs @@ -71,4 +71,54 @@ private sealed class SampleObject public int Value { get; set; } } + + private static async Task> ToListAsync(IAsyncEnumerable source) + { + var list = new List(); + + await foreach (var item in source) + { + list.Add(item); + } + + return list; + } + + [Fact] + public async Task ListKeysAsync_ReturnsSavedKeys_AndFiltersByPrefix() + { + using var temp = new TempDirectory(); + var service = new FileStorageService(new() { RootDirectory = temp.Path }); + await service.SaveAsync("a/one.bin", new byte[] { 1 }); + await service.SaveAsync("a/two.bin", new byte[] { 2 }); + await service.SaveAsync("b/three.bin", new byte[] { 3 }); + + var all = (await ToListAsync(service.ListKeysAsync())).OrderBy(k => k, StringComparer.Ordinal).ToArray(); + var aOnly = (await ToListAsync(service.ListKeysAsync("a/"))).OrderBy(k => k, StringComparer.Ordinal).ToArray(); + + Assert.Equal(new[] { "a/one.bin", "a/two.bin", "b/three.bin" }, all); + Assert.Equal(new[] { "a/one.bin", "a/two.bin" }, aOnly); + } + + [Fact] + public async Task ListKeysAsync_EmptyStore_ReturnsEmpty() + { + using var temp = new TempDirectory(); + var service = new FileStorageService(new() { RootDirectory = temp.Path }); + + Assert.Empty(await ToListAsync(service.ListKeysAsync())); + } + + [Fact] + public async Task ObjectStorage_ListKeysAsync_ReturnsSavedKeys() + { + using var temp = new TempDirectory(); + var storage = new FileStorageService(new() { RootDirectory = temp.Path }); + var objects = new YamlObjectStorageService(storage); + await objects.SaveAsync("objects/x.yaml", new SampleObject { Name = "x", Value = 1 }); + + var keys = await ToListAsync(objects.ListKeysAsync("objects/")); + + Assert.Contains("objects/x.yaml", keys); + } } From c6ace2864c28b00013d254ec0df8799ccf9774af Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 Jun 2026 21:15:25 +0200 Subject: [PATCH 10/23] test(storage): cover S3 ListKeysAsync with a prefix --- .../Storage/S3/S3StorageServiceTests.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/SquidStd.Tests/Storage/S3/S3StorageServiceTests.cs b/tests/SquidStd.Tests/Storage/S3/S3StorageServiceTests.cs index 94851e24..8899a9a1 100644 --- a/tests/SquidStd.Tests/Storage/S3/S3StorageServiceTests.cs +++ b/tests/SquidStd.Tests/Storage/S3/S3StorageServiceTests.cs @@ -65,4 +65,24 @@ public void Ctor_MissingEndpoint_Throws() () => new S3StorageService(new S3StorageOptions { AccessKey = "a", SecretKey = "b", Bucket = "c" }) ); } + + [Fact] + public async Task ListKeysAsync_ReturnsObjectsUnderPrefix() + { + var storage = NewService(); + var prefix = "list-" + Guid.NewGuid().ToString("N") + "/"; + await storage.SaveAsync(prefix + "a", Encoding.UTF8.GetBytes("1")); + await storage.SaveAsync(prefix + "b", Encoding.UTF8.GetBytes("2")); + + var keys = new List(); + + await foreach (var key in storage.ListKeysAsync(prefix)) + { + keys.Add(key); + } + + Assert.Equal(2, keys.Count); + Assert.Contains(prefix + "a", keys); + Assert.Contains(prefix + "b", keys); + } } From 472a9ce9d114fc62ce8bab8459aaede66553c0f2 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 23 Jun 2026 09:38:40 +0200 Subject: [PATCH 11/23] docs(storage): add package READMEs and DocFX articles for the storage packages --- docs/articles/storage-abstractions.md | 1 + docs/articles/storage-s3.md | 1 + docs/articles/storage.md | 1 + src/SquidStd.Storage.Abstractions/README.md | 56 +++++++++++++++++++ src/SquidStd.Storage.S3/README.md | 62 +++++++++++++++++++++ src/SquidStd.Storage/README.md | 56 +++++++++++++++++++ 6 files changed, 177 insertions(+) create mode 100644 docs/articles/storage-abstractions.md create mode 100644 docs/articles/storage-s3.md create mode 100644 docs/articles/storage.md create mode 100644 src/SquidStd.Storage.Abstractions/README.md create mode 100644 src/SquidStd.Storage.S3/README.md create mode 100644 src/SquidStd.Storage/README.md diff --git a/docs/articles/storage-abstractions.md b/docs/articles/storage-abstractions.md new file mode 100644 index 00000000..30870216 --- /dev/null +++ b/docs/articles/storage-abstractions.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Storage.Abstractions/README.md)] diff --git a/docs/articles/storage-s3.md b/docs/articles/storage-s3.md new file mode 100644 index 00000000..8ece2fb3 --- /dev/null +++ b/docs/articles/storage-s3.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Storage.S3/README.md)] diff --git a/docs/articles/storage.md b/docs/articles/storage.md new file mode 100644 index 00000000..f69857c5 --- /dev/null +++ b/docs/articles/storage.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Storage/README.md)] diff --git a/src/SquidStd.Storage.Abstractions/README.md b/src/SquidStd.Storage.Abstractions/README.md new file mode 100644 index 00000000..c3890fe8 --- /dev/null +++ b/src/SquidStd.Storage.Abstractions/README.md @@ -0,0 +1,56 @@ +

+ SquidStd +

+ +

SquidStd.Storage.Abstractions

+ +

+ NuGet + Downloads + docs + license +

+ +Backend-agnostic storage contracts for SquidStd. It defines the binary blob store (`IStorageService`), +the typed object store (`IObjectStorageService`), key enumeration (`ListKeysAsync`), and `StorageConfig`. +Pick a backend implementation (local file or S3/MinIO) from a companion package. + +## Install + +```bash +dotnet add package SquidStd.Storage.Abstractions +``` + +## Features + +- `IStorageService` — binary blobs by key: `SaveAsync` / `LoadAsync` / `DeleteAsync` / `ExistsAsync` / `ListKeysAsync`. +- `IObjectStorageService` — typed objects by key (serialized by the provider) with the same surface plus `ListKeysAsync`. +- `ListKeysAsync(prefix?)` — streams stored keys as `IAsyncEnumerable`, optionally filtered by prefix. +- `StorageConfig` — root-directory configuration for file-backed storage. + +## Usage + +```csharp +using SquidStd.Storage.Abstractions.Interfaces; + +// Resolve IStorageService from a backend package (file or S3). +public async Task DumpKeysAsync(IStorageService storage) +{ + await foreach (var key in storage.ListKeysAsync("profiles/")) + { + Console.WriteLine(key); + } +} +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `IStorageService` | Binary blob store (save/load/delete/exists/list). | +| `IObjectStorageService` | Typed object store over a blob backend. | +| `StorageConfig` | Root directory for file storage. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/squid-std). diff --git a/src/SquidStd.Storage.S3/README.md b/src/SquidStd.Storage.S3/README.md new file mode 100644 index 00000000..8650ff8c --- /dev/null +++ b/src/SquidStd.Storage.S3/README.md @@ -0,0 +1,62 @@ +

+ SquidStd +

+ +

SquidStd.Storage.S3

+ +

+ NuGet + Downloads + docs + license +

+ +S3-compatible storage for SquidStd, backed by the MinIO .NET SDK. Implements `IStorageService` against +AWS S3, MinIO, or any S3-compatible endpoint, so the same storage API reads and writes object storage. +The bucket is created lazily on first use. Registered with a single `AddS3Storage(...)` call. + +## Install + +```bash +dotnet add package SquidStd.Storage.S3 +``` + +## Features + +- One-line registration: `container.AddS3Storage(options)`. +- `IStorageService` over `IMinioClient` (`SaveAsync` / `LoadAsync` / `DeleteAsync` / `ExistsAsync` / `ListKeysAsync`). +- Lazy bucket creation; `ListKeysAsync(prefix?)` streams object keys via the S3 list API. +- Works with AWS S3 and MinIO via `S3StorageOptions` (endpoint, credentials, bucket, TLS, region). + +## Usage + +```csharp +using DryIoc; +using SquidStd.Storage.Abstractions.Interfaces; +using SquidStd.Storage.S3.Data.Config; +using SquidStd.Storage.S3.Extensions; + +var container = new Container(); +container.AddS3Storage(new S3StorageOptions +{ + Endpoint = "localhost:9000", + AccessKey = "minioadmin", + SecretKey = "minioadmin", + Bucket = "app-data" +}); + +var storage = container.Resolve(); +await storage.SaveAsync("reports/2026.json", "{}"u8.ToArray()); +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `S3StorageRegistrationExtensions` | `AddS3Storage(...)` registration. | +| `S3StorageService` | MinIO-backed `IStorageService`. | +| `S3StorageOptions` | Endpoint, credentials, bucket, TLS, region. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/squid-std). diff --git a/src/SquidStd.Storage/README.md b/src/SquidStd.Storage/README.md new file mode 100644 index 00000000..5750ee3b --- /dev/null +++ b/src/SquidStd.Storage/README.md @@ -0,0 +1,56 @@ +

+ SquidStd +

+ +

SquidStd.Storage

+ +

+ NuGet + Downloads + docs + license +

+ +Local file storage for SquidStd. Provides a filesystem-backed `IStorageService` (atomic writes, +path-safe keys) and a YAML-backed `IObjectStorageService` that layers typed objects on top of it — +registered with a single `AddFileStorage()` call. Storage is opt-in: it is not registered by `RegisterCoreServices`. + +## Install + +```bash +dotnet add package SquidStd.Storage +``` + +## Features + +- One-line registration: `container.AddFileStorage()` (file `IStorageService` + YAML `IObjectStorageService`). +- Atomic saves (temp file + move) and keys constrained to the storage root. +- `YamlObjectStorageService` decorates `IStorageService`, serializing typed objects to YAML. +- `ListKeysAsync(prefix?)` enumerates stored keys (`/`-separated), excluding in-flight temp files. + +## Usage + +```csharp +using DryIoc; +using SquidStd.Storage.Abstractions.Data.Config; +using SquidStd.Storage.Abstractions.Interfaces; +using SquidStd.Storage.Extensions; + +var container = new Container(); +container.AddFileStorage(new StorageConfig { RootDirectory = "data" }); + +var storage = container.Resolve(); +await storage.SaveAsync("profiles/main.bin", new byte[] { 1, 2, 3 }); +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `StorageRegistrationExtensions` | `AddFileStorage(...)` registration. | +| `FileStorageService` | Filesystem-backed `IStorageService`. | +| `YamlObjectStorageService` | YAML-backed `IObjectStorageService` over a blob store. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/squid-std). From bd946eaf863412a3345ac463a5c1870c03614c65 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 23 Jun 2026 09:39:36 +0200 Subject: [PATCH 12/23] docs: add serialization, scheduler and health-checks articles --- docs/articles/health-checks.md | 29 +++++++++++++++++++++++++++++ docs/articles/scheduler.md | 29 +++++++++++++++++++++++++++++ docs/articles/serialization.md | 28 ++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 docs/articles/health-checks.md create mode 100644 docs/articles/scheduler.md create mode 100644 docs/articles/serialization.md diff --git a/docs/articles/health-checks.md b/docs/articles/health-checks.md new file mode 100644 index 00000000..a6b34a58 --- /dev/null +++ b/docs/articles/health-checks.md @@ -0,0 +1,29 @@ +# Health Checks + +The health-check aggregator (`IHealthCheckService` in `SquidStd.Core`, implemented in +`SquidStd.Services.Core`) runs every registered `IHealthCheck` and returns a single `HealthReport`. + +- Implement `IHealthCheck` (`Name` + `CheckAsync`) and register it as `IHealthCheck`. +- `CheckHealthAsync()` runs all checks **in parallel**, each with a per-check timeout and exception + isolation — a failing or timed-out check becomes an `Unhealthy` entry without breaking the others. +- The overall `HealthReport.Status` is `Unhealthy` if any check is `Unhealthy`, otherwise `Healthy`. + +```csharp +using DryIoc; +using SquidStd.Core.Data.Health; +using SquidStd.Core.Interfaces.Health; +using SquidStd.Services.Core.Extensions; + +container.RegisterHealthChecksService(); + +var health = container.Resolve(); +HealthReport report = await health.CheckHealthAsync(); + +if (report.Status == SquidStd.Core.Types.Health.HealthStatus.Unhealthy) +{ + foreach (var (name, result) in report.Entries) + { + Console.WriteLine($"{name}: {result.Status} {result.Description}"); + } +} +``` diff --git a/docs/articles/scheduler.md b/docs/articles/scheduler.md new file mode 100644 index 00000000..1d30db98 --- /dev/null +++ b/docs/articles/scheduler.md @@ -0,0 +1,29 @@ +# Scheduler (Cron) + +`ICronScheduler` (in `SquidStd.Core`, implemented in `SquidStd.Services.Core`) runs asynchronous jobs on +standard 5-field cron expressions evaluated in UTC. + +- `Schedule(name, cronExpression, handler)` → returns a job id. +- `Unschedule(jobId)` / `UnscheduleByName(name)`. +- `Jobs` — a snapshot of registered jobs (`CronJobInfo`). + +Each job is a one-shot, self-rescheduling timer on the timer wheel: when it fires, the handler is +dispatched through `IJobSystem`, and the next occurrence is registered. An occurrence is **skipped** if the +previous run of the same job is still in flight. Because the timer wheel must be advanced, the package also +provides `TimerWheelPumpService`, which pumps the wheel on a background loop. + +Register everything (after `RegisterCoreServices`) with `RegisterSchedulerServices()`: + +```csharp +using DryIoc; +using SquidStd.Core.Interfaces.Scheduling; +using SquidStd.Services.Core.Extensions; + +container.RegisterSchedulerServices(); + +var scheduler = container.Resolve(); +scheduler.Schedule("cleanup", "0 3 * * *", async ct => +{ + await DoCleanupAsync(ct); +}); +``` diff --git a/docs/articles/serialization.md b/docs/articles/serialization.md new file mode 100644 index 00000000..3913751a --- /dev/null +++ b/docs/articles/serialization.md @@ -0,0 +1,28 @@ +# Serialization + +SquidStd uses a single serialization abstraction across the framework, defined in `SquidStd.Core`: + +- `IDataSerializer` — `ReadOnlyMemory Serialize(T value)` +- `IDataDeserializer` — `T Deserialize(ReadOnlyMemory data)` + +The default implementation, `JsonDataSerializer`, uses `System.Text.Json` Web defaults and implements +both interfaces. It is registered by `RegisterCoreServices()` (via `RegisterDataSerializer()`), so both +contracts resolve to the same singleton. Messaging and caching reuse it for payload (de)serialization. + +```csharp +using DryIoc; +using SquidStd.Core.Interfaces.Serialization; +using SquidStd.Services.Core.Extensions; + +var container = new Container(); +container.RegisterDataSerializer(); + +var serializer = container.Resolve(); +var deserializer = container.Resolve(); + +var bytes = serializer.Serialize(new { Name = "squid", Port = 9000 }); +var value = deserializer.Deserialize>(bytes); +``` + +To plug in a different format, implement `IDataSerializer` / `IDataDeserializer` and register your type +before `RegisterCoreServices()` (it keeps an already-registered serializer). From 4449bd09f222959674a5da685a6d892133d9fa56 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 23 Jun 2026 09:41:05 +0200 Subject: [PATCH 13/23] docs(core): document unified serializer and fix storage/health accuracy in core READMEs --- src/SquidStd.Core/README.md | 5 +++-- src/SquidStd.Services.Core/README.md | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/SquidStd.Core/README.md b/src/SquidStd.Core/README.md index f1079ae1..d6400c49 100644 --- a/src/SquidStd.Core/README.md +++ b/src/SquidStd.Core/README.md @@ -27,8 +27,9 @@ dotnet add package SquidStd.Core - Configuration contracts: `IConfigEntry` (a YAML section) and `IConfigManagerService`. - In-process messaging: `IEventBus` with `ISyncEventListener` / `IAsyncEventListener` over `IEvent`. - Background work & timing: `IJobSystem`, `ITimerService`, `IMainThreadDispatcher`. -- Metrics & storage: `IMetricProvider`, `IStorageService`, `IObjectStorageService`, secrets contracts. -- Utilities: `YamlUtils`, `JsonUtils`, a Serilog `EventSink`, and string/env/directory extensions. +- Metrics & secrets: `IMetricProvider` and secret-protection contracts. +- Serialization: `IDataSerializer` / `IDataDeserializer` (default `JsonDataSerializer`), plus `YamlUtils` / `JsonUtils`. +- Utilities: a Serilog `EventSink`, and string/env/directory extensions. - Shared domain enums under `Types` (e.g. `LogLevelType`, `PlatformType`). ## Usage diff --git a/src/SquidStd.Services.Core/README.md b/src/SquidStd.Services.Core/README.md index 0743c528..2c814494 100644 --- a/src/SquidStd.Services.Core/README.md +++ b/src/SquidStd.Services.Core/README.md @@ -29,7 +29,8 @@ dotnet add package SquidStd.Services.Core - `JobSystemService` — background job execution; `TimerWheelService` + cron scheduling for timed work. - `MainThreadDispatcherService` — marshal work back onto a main thread. - `MetricsCollectionService` — aggregates `IMetricProvider` samples. -- File storage, object storage, and AES-GCM-protected secret store. +- `HealthCheckService` — aggregates `IHealthCheck`s into one report (`RegisterHealthChecksService`). +- AES-GCM-protected secret store. (File/object storage moved to `SquidStd.Storage`, opt-in via `AddFileStorage`.) ## Usage From 79a374dd9712c97904917e9467224b1f1b709ca9 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 23 Jun 2026 09:43:07 +0200 Subject: [PATCH 14/23] docs: list storage packages and feature articles in toc and root index --- README.md | 7 +++++-- docs/articles/toc.yml | 12 ++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 946862fb..46321e38 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,9 @@ block until cancellation for long-running hosts. | Package | Description | Links | |---------|-------------|-------| -| `SquidStd.Core` | Foundational contracts & utilities (config, event bus, jobs, metrics, storage, YAML/JSON, Serilog sink). | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Core/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Core.svg)](https://www.nuget.org/packages/SquidStd.Core/) | +| `SquidStd.Core` | Foundational contracts & utilities (config, event bus, jobs, metrics, serialization, YAML/JSON, Serilog sink). | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Core/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Core.svg)](https://www.nuget.org/packages/SquidStd.Core/) | | `SquidStd.Abstractions` | DI registration plumbing (`ISquidStdService`, `RegisterStdService`, `RegisterConfigSection`). | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Abstractions/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Abstractions.svg)](https://www.nuget.org/packages/SquidStd.Abstractions/) | -| `SquidStd.Services.Core` | Concrete services: config, event bus, jobs, timer/cron scheduler, dispatcher, metrics, storage. | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Services.Core/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Services.Core.svg)](https://www.nuget.org/packages/SquidStd.Services.Core/) | +| `SquidStd.Services.Core` | Concrete services: config, event bus, jobs, timer/cron scheduler, dispatcher, metrics, health checks, secrets. | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Services.Core/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Services.Core.svg)](https://www.nuget.org/packages/SquidStd.Services.Core/) | | `SquidStd.AspNetCore` | ASP.NET Core host integration for the SquidStd service stack. | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.AspNetCore/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.AspNetCore.svg)](https://www.nuget.org/packages/SquidStd.AspNetCore/) | | `SquidStd.Network` | TCP/UDP servers & clients, sessions, framing/middleware pipeline, span readers/writers. | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Network/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Network.svg)](https://www.nuget.org/packages/SquidStd.Network/) | | `SquidStd.Plugin.Abstractions` | Plugin contracts (`ISquidStdPlugin`, metadata, context). | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Plugin.Abstractions/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Plugin.Abstractions.svg)](https://www.nuget.org/packages/SquidStd.Plugin.Abstractions/) | @@ -82,6 +82,9 @@ block until cancellation for long-running hosts. | `SquidStd.Caching.Abstractions` | Caching contracts (`ICacheService`, `ICacheProvider`, `CacheService` facade, metrics, connection string). | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Caching.Abstractions/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Caching.Abstractions.svg)](https://www.nuget.org/packages/SquidStd.Caching.Abstractions/) | | `SquidStd.Caching` | In-memory cache backend (`AddInMemoryCache`). | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Caching/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Caching.svg)](https://www.nuget.org/packages/SquidStd.Caching/) | | `SquidStd.Caching.Redis` | Redis cache backend (`AddRedisCache`). | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Caching.Redis/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Caching.Redis.svg)](https://www.nuget.org/packages/SquidStd.Caching.Redis/) | +| `SquidStd.Storage.Abstractions` | Storage contracts (`IStorageService`, `IObjectStorageService`, `StorageConfig`, `ListKeysAsync`). | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Storage.Abstractions/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Storage.Abstractions.svg)](https://www.nuget.org/packages/SquidStd.Storage.Abstractions/) | +| `SquidStd.Storage` | Local file storage backend (`AddFileStorage`). | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Storage/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Storage.svg)](https://www.nuget.org/packages/SquidStd.Storage/) | +| `SquidStd.Storage.S3` | S3/MinIO storage backend (`AddS3Storage`). | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Storage.S3/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Storage.S3.svg)](https://www.nuget.org/packages/SquidStd.Storage.S3/) | | `SquidStd.Scripting.Lua` | Lua scripting engine with attribute-based modules and event bridging. | [![readme](https://img.shields.io/badge/readme-1390A3.svg)](src/SquidStd.Scripting.Lua/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Scripting.Lua.svg)](https://www.nuget.org/packages/SquidStd.Scripting.Lua/) | ## Architecture diff --git a/docs/articles/toc.yml b/docs/articles/toc.yml index 730dd3da..229340cd 100644 --- a/docs/articles/toc.yml +++ b/docs/articles/toc.yml @@ -28,5 +28,17 @@ href: caching.md - name: SquidStd.Caching.Redis href: caching-redis.md +- name: SquidStd.Storage.Abstractions + href: storage-abstractions.md +- name: SquidStd.Storage + href: storage.md +- name: SquidStd.Storage.S3 + href: storage-s3.md - name: SquidStd.Scripting.Lua href: scripting-lua.md +- name: Serialization + href: serialization.md +- name: Scheduler + href: scheduler.md +- name: Health Checks + href: health-checks.md From dbd47aae54df598a286f7f3b836a26ab446b43bd Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 23 Jun 2026 09:46:16 +0200 Subject: [PATCH 15/23] docs: fix root README docs link to the squid-std GitHub Pages slug --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 46321e38..a879589d 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ squid-std follows a few consistent principles across every module: ## Documentation Full API documentation is published with DocFX to GitHub Pages: -**[tgiachi.github.io/SquidStd](https://tgiachi.github.io/SquidStd/)**. Each package also ships its +**[tgiachi.github.io/squid-std](https://tgiachi.github.io/squid-std/)**. Each package also ships its own README (linked in the table above). ## Build From 7c1316c9188385adb5bfccbee41d285bd9522c39 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 23 Jun 2026 10:00:00 +0200 Subject: [PATCH 16/23] feat(aspnetcore): add SquidStdHealthCheckAdapter mapping to standard health checks --- .../Services/SquidStdHealthCheckAdapter.cs | 31 +++++++++++++++++ .../SquidStdHealthCheckAdapterTests.cs | 33 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 src/SquidStd.AspNetCore/Services/SquidStdHealthCheckAdapter.cs create mode 100644 tests/SquidStd.Tests/AspNetCore/SquidStdHealthCheckAdapterTests.cs diff --git a/src/SquidStd.AspNetCore/Services/SquidStdHealthCheckAdapter.cs b/src/SquidStd.AspNetCore/Services/SquidStdHealthCheckAdapter.cs new file mode 100644 index 00000000..a9b64d07 --- /dev/null +++ b/src/SquidStd.AspNetCore/Services/SquidStdHealthCheckAdapter.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using SquidHealthCheck = SquidStd.Core.Interfaces.Health.IHealthCheck; +using SquidHealthStatus = SquidStd.Core.Types.Health.HealthStatus; + +namespace SquidStd.AspNetCore.Services; + +/// +/// Adapts a SquidStd to the standard ASP.NET Core +/// contract. +/// +internal sealed class SquidStdHealthCheckAdapter : IHealthCheck +{ + private readonly SquidHealthCheck _check; + + public SquidStdHealthCheckAdapter(SquidHealthCheck check) + { + _check = check; + } + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default + ) + { + var result = await _check.CheckAsync(cancellationToken); + + return result.Status == SquidHealthStatus.Unhealthy + ? HealthCheckResult.Unhealthy(result.Description, result.Exception) + : HealthCheckResult.Healthy(result.Description); + } +} diff --git a/tests/SquidStd.Tests/AspNetCore/SquidStdHealthCheckAdapterTests.cs b/tests/SquidStd.Tests/AspNetCore/SquidStdHealthCheckAdapterTests.cs new file mode 100644 index 00000000..53ee2055 --- /dev/null +++ b/tests/SquidStd.Tests/AspNetCore/SquidStdHealthCheckAdapterTests.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using SquidStd.AspNetCore.Services; +using SquidStd.Tests.Support; +using SquidHealthResult = SquidStd.Core.Data.Health.HealthCheckResult; + +namespace SquidStd.Tests.AspNetCore; + +public class SquidStdHealthCheckAdapterTests +{ + [Fact] + public async Task CheckHealthAsync_MapsHealthy() + { + var adapter = new SquidStdHealthCheckAdapter(new FakeHealthCheck("ok", SquidHealthResult.Healthy("all good"))); + + var result = await adapter.CheckHealthAsync(new HealthCheckContext()); + + Assert.Equal(HealthStatus.Healthy, result.Status); + Assert.Equal("all good", result.Description); + } + + [Fact] + public async Task CheckHealthAsync_MapsUnhealthy_WithDescriptionAndException() + { + var ex = new InvalidOperationException("boom"); + var adapter = new SquidStdHealthCheckAdapter(new FakeHealthCheck("bad", SquidHealthResult.Unhealthy("down", ex))); + + var result = await adapter.CheckHealthAsync(new HealthCheckContext()); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.Equal("down", result.Description); + Assert.Same(ex, result.Exception); + } +} From 0d94095b82c2116b87a0eb526604dc77abdbd1c4 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 23 Jun 2026 10:02:36 +0200 Subject: [PATCH 17/23] feat(aspnetcore): add AddSquidStdHealthChecks to bridge health checks to ASP.NET Core --- .../SquidStdAspNetCoreBuilderExtensions.cs | 3 + .../SquidStdHealthChecksExtensions.cs | 53 ++++++++++++++ .../SquidStdHealthChecksBridgeTests.cs | 71 +++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 src/SquidStd.AspNetCore/Extensions/SquidStdHealthChecksExtensions.cs create mode 100644 tests/SquidStd.Tests/AspNetCore/SquidStdHealthChecksBridgeTests.cs diff --git a/src/SquidStd.AspNetCore/Extensions/SquidStdAspNetCoreBuilderExtensions.cs b/src/SquidStd.AspNetCore/Extensions/SquidStdAspNetCoreBuilderExtensions.cs index 8d995a7c..3e8aac31 100644 --- a/src/SquidStd.AspNetCore/Extensions/SquidStdAspNetCoreBuilderExtensions.cs +++ b/src/SquidStd.AspNetCore/Extensions/SquidStdAspNetCoreBuilderExtensions.cs @@ -48,6 +48,7 @@ public WebApplicationBuilder UseSquidStd( var container = new Container(); builder.Host.UseServiceProviderFactory(new DryIocServiceProviderFactory(container)); SquidStdBootstrap.Create(options, container); + builder.Host.Properties[ContainerPropertyKey] = container; var configuredContainer = configureContainer?.Invoke(container) ?? container; @@ -62,6 +63,8 @@ public WebApplicationBuilder UseSquidStd( } } + internal const string ContainerPropertyKey = "SquidStd:Container"; + private static void ValidateOptions(SquidStdOptions options) { ArgumentNullException.ThrowIfNull(options); diff --git a/src/SquidStd.AspNetCore/Extensions/SquidStdHealthChecksExtensions.cs b/src/SquidStd.AspNetCore/Extensions/SquidStdHealthChecksExtensions.cs new file mode 100644 index 00000000..090f6180 --- /dev/null +++ b/src/SquidStd.AspNetCore/Extensions/SquidStdHealthChecksExtensions.cs @@ -0,0 +1,53 @@ +using DryIoc; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using SquidStd.AspNetCore.Services; +using SquidHealthCheck = SquidStd.Core.Interfaces.Health.IHealthCheck; + +namespace SquidStd.AspNetCore.Extensions; + +/// +/// Extension methods that bridge SquidStd health checks into the standard ASP.NET Core health-check system. +/// +public static class SquidStdHealthChecksExtensions +{ + /// ASP.NET Core application builder. + extension(WebApplicationBuilder builder) + { + /// + /// Registers each SquidStd IHealthCheck as a standard ASP.NET Core health check (one entry + /// per check, same name). Call after UseSquidStd; expose them with the standard + /// app.MapHealthChecks(...). Check names must be unique. + /// + /// The same builder for chaining. + public WebApplicationBuilder AddSquidStdHealthChecks() + { + ArgumentNullException.ThrowIfNull(builder); + + if (!builder.Host.Properties.TryGetValue(SquidStdAspNetCoreBuilderExtensions.ContainerPropertyKey, out var value) + || value is not IContainer container) + { + throw new InvalidOperationException("Call UseSquidStd before AddSquidStdHealthChecks."); + } + + var checks = container.Resolve>(); + var healthChecks = builder.Services.AddHealthChecks(); + + foreach (var check in checks) + { + healthChecks.Add( + new HealthCheckRegistration( + check.Name, + _ => new SquidStdHealthCheckAdapter(check), + failureStatus: HealthStatus.Unhealthy, + tags: null + ) + ); + } + + return builder; + } + } +} diff --git a/tests/SquidStd.Tests/AspNetCore/SquidStdHealthChecksBridgeTests.cs b/tests/SquidStd.Tests/AspNetCore/SquidStdHealthChecksBridgeTests.cs new file mode 100644 index 00000000..1120170a --- /dev/null +++ b/tests/SquidStd.Tests/AspNetCore/SquidStdHealthChecksBridgeTests.cs @@ -0,0 +1,71 @@ +using DryIoc; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using SquidStd.AspNetCore.Extensions; +using SquidStd.Tests.Support; +using SquidHealthCheck = SquidStd.Core.Interfaces.Health.IHealthCheck; +using SquidHealthResult = SquidStd.Core.Data.Health.HealthCheckResult; + +namespace SquidStd.Tests.AspNetCore; + +public class SquidStdHealthChecksBridgeTests +{ + [Fact] + public async Task AddSquidStdHealthChecks_BridgesEachCheckToStandardReport() + { + using var temp = new TempDirectory(); + var builder = CreateBuilder(temp.Path); + builder.UseSquidStd( + options => options.ConfigName = "app", + container => + { + container.RegisterInstance(new FakeHealthCheck("alpha"), IfAlreadyRegistered.AppendNotKeyed); + container.RegisterInstance( + new FakeHealthCheck("beta", SquidHealthResult.Unhealthy("bad")), + IfAlreadyRegistered.AppendNotKeyed + ); + + return container; + } + ); + builder.AddSquidStdHealthChecks(); + + await using var app = builder.Build(); + var health = app.Services.GetRequiredService(); + var report = await health.CheckHealthAsync(); + + Assert.True(report.Entries.ContainsKey("alpha")); + Assert.True(report.Entries.ContainsKey("beta")); + Assert.Equal(HealthStatus.Healthy, report.Entries["alpha"].Status); + Assert.Equal(HealthStatus.Unhealthy, report.Entries["beta"].Status); + Assert.Equal(HealthStatus.Unhealthy, report.Status); + } + + [Fact] + public void AddSquidStdHealthChecks_WithoutUseSquidStd_Throws() + { + using var temp = new TempDirectory(); + var builder = CreateBuilder(temp.Path); + + Assert.Throws(() => builder.AddSquidStdHealthChecks()); + } + + private static WebApplicationBuilder CreateBuilder(string contentRootPath) + { + var builder = WebApplication.CreateBuilder( + new WebApplicationOptions + { + ContentRootPath = contentRootPath, + EnvironmentName = Environments.Development + } + ); + + builder.WebHost.UseTestServer(); + + return builder; + } +} From f1c2deaff670171626215e0741132e984597414d Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 23 Jun 2026 10:05:51 +0200 Subject: [PATCH 18/23] docs(aspnetcore): document the AddSquidStdHealthChecks bridge --- src/SquidStd.AspNetCore/README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/SquidStd.AspNetCore/README.md b/src/SquidStd.AspNetCore/README.md index 76de4605..e1308be0 100644 --- a/src/SquidStd.AspNetCore/README.md +++ b/src/SquidStd.AspNetCore/README.md @@ -27,6 +27,7 @@ dotnet add package SquidStd.AspNetCore - Configures DryIoc as the host's service-provider factory. - Registers `SquidStdHostedService` to start/stop SquidStd services with the host. - Optional `SquidStdOptions` configuration callback. +- `WebApplicationBuilder.AddSquidStdHealthChecks()` — bridges every SquidStd `IHealthCheck` into the standard ASP.NET Core health-check system (one entry per check), exposed via the standard `app.MapHealthChecks(...)`. ## Usage @@ -44,12 +45,30 @@ var app = builder.Build(); app.Run(); ``` +### Health checks + +Bridge your SquidStd health checks into the standard `/health` endpoint: + +```csharp +using SquidStd.AspNetCore.Extensions; +using SquidStd.Services.Core.Extensions; + +builder.UseSquidStd(options => { }, container => container.RegisterHealthChecksService()); +builder.AddSquidStdHealthChecks(); // call after UseSquidStd + +var app = builder.Build(); +app.MapHealthChecks("/health"); // standard ASP.NET Core endpoint +``` + +Each registered `IHealthCheck` appears as its own entry in the report. Check names must be unique. + ## Key types | Type | Purpose | |------|---------| | `SquidStdAspNetCoreBuilderExtensions` | `UseSquidStd(...)` builder extension. | | `SquidStdHostedService` | Hosted service bridging SquidStd service lifecycle to the host. | +| `SquidStdHealthChecksExtensions` | `AddSquidStdHealthChecks(...)` — bridge to ASP.NET Core health checks. | ## License From 0def3618618600c95c27fe1d82909f11d27c9c15 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 23 Jun 2026 10:12:25 +0200 Subject: [PATCH 19/23] Potential fix for pull request finding 'CodeQL / Missing Dispose call on local IDisposable' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/SquidStd.Storage.S3/Services/S3StorageService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SquidStd.Storage.S3/Services/S3StorageService.cs b/src/SquidStd.Storage.S3/Services/S3StorageService.cs index aa6c550e..213405b7 100644 --- a/src/SquidStd.Storage.S3/Services/S3StorageService.cs +++ b/src/SquidStd.Storage.S3/Services/S3StorageService.cs @@ -26,7 +26,7 @@ public S3StorageService(S3StorageOptions options) ArgumentException.ThrowIfNullOrWhiteSpace(options.SecretKey); ArgumentException.ThrowIfNullOrWhiteSpace(options.Bucket); - var builder = new MinioClient() + using var builder = new MinioClient() .WithEndpoint(options.Endpoint) .WithCredentials(options.AccessKey, options.SecretKey) .WithSSL(options.UseSsl); From 830e8ed4e3e39b84d71b72552bf36f9aeadcedf0 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 23 Jun 2026 10:12:39 +0200 Subject: [PATCH 20/23] Potential fix for pull request finding 'CodeQL / Constant condition' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/SquidStd.Storage.S3/Services/S3StorageService.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/SquidStd.Storage.S3/Services/S3StorageService.cs b/src/SquidStd.Storage.S3/Services/S3StorageService.cs index 213405b7..baac97d6 100644 --- a/src/SquidStd.Storage.S3/Services/S3StorageService.cs +++ b/src/SquidStd.Storage.S3/Services/S3StorageService.cs @@ -145,11 +145,6 @@ public async IAsyncEnumerable ListKeysAsync( private async ValueTask EnsureBucketAsync(CancellationToken cancellationToken) { - if (_bucketReady) - { - return; - } - await _bucketLock.WaitAsync(cancellationToken); try From 5f99b23af835ac89cb0c6f53222305045b02b52e Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 23 Jun 2026 10:12:50 +0200 Subject: [PATCH 21/23] Potential fix for pull request finding 'CodeQL / Missing Dispose call on local IDisposable' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- tests/SquidStd.Tests/Health/HealthCheckRegistrationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/SquidStd.Tests/Health/HealthCheckRegistrationTests.cs b/tests/SquidStd.Tests/Health/HealthCheckRegistrationTests.cs index 278084d5..da710608 100644 --- a/tests/SquidStd.Tests/Health/HealthCheckRegistrationTests.cs +++ b/tests/SquidStd.Tests/Health/HealthCheckRegistrationTests.cs @@ -11,7 +11,7 @@ public class HealthCheckRegistrationTests [Fact] public async Task RegisterHealthChecksService_ResolvesAndAggregatesRegisteredChecks() { - var container = new Container(); + using var container = new Container(); container.RegisterInstance(new HealthCheckOptions()); container.RegisterInstance(new FakeHealthCheck("a"), IfAlreadyRegistered.AppendNotKeyed); container.RegisterInstance(new FakeHealthCheck("b"), IfAlreadyRegistered.AppendNotKeyed); From f714e22a41a48aeffadcc60a0012254000ac8e0c Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 23 Jun 2026 10:13:00 +0200 Subject: [PATCH 22/23] Potential fix for pull request finding 'CodeQL / Missing Dispose call on local IDisposable' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- tests/SquidStd.Tests/Storage/StorageRegistrationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/SquidStd.Tests/Storage/StorageRegistrationTests.cs b/tests/SquidStd.Tests/Storage/StorageRegistrationTests.cs index 7053e01f..4bbc2ffc 100644 --- a/tests/SquidStd.Tests/Storage/StorageRegistrationTests.cs +++ b/tests/SquidStd.Tests/Storage/StorageRegistrationTests.cs @@ -11,7 +11,7 @@ public class StorageRegistrationTests public async Task AddFileStorage_ResolvesAndRoundTrips() { var root = Path.Combine(Path.GetTempPath(), "squidstd-storage-" + Guid.NewGuid().ToString("N")); - var container = new Container(); + using var container = new Container(); container.AddFileStorage(new StorageConfig { RootDirectory = root }); From 1718ba8eb437d3cd8fd963f3841438ba88e7a853 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 23 Jun 2026 10:19:55 +0200 Subject: [PATCH 23/23] fix(storage): build the MinIO client in a helper to fix CI build break The CodeQL autofix wrapped the MinioClient builder in 'using var', which broke the subsequent reassignment (CS1656). Move construction into a static CreateClient helper that returns the built client (owned by _client and disposed in Dispose), so the reassignment is valid and the IDisposable escapes the method. --- .../Services/S3StorageService.cs | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/SquidStd.Storage.S3/Services/S3StorageService.cs b/src/SquidStd.Storage.S3/Services/S3StorageService.cs index baac97d6..ce14a250 100644 --- a/src/SquidStd.Storage.S3/Services/S3StorageService.cs +++ b/src/SquidStd.Storage.S3/Services/S3StorageService.cs @@ -26,17 +26,7 @@ public S3StorageService(S3StorageOptions options) ArgumentException.ThrowIfNullOrWhiteSpace(options.SecretKey); ArgumentException.ThrowIfNullOrWhiteSpace(options.Bucket); - using var builder = new MinioClient() - .WithEndpoint(options.Endpoint) - .WithCredentials(options.AccessKey, options.SecretKey) - .WithSSL(options.UseSsl); - - if (!string.IsNullOrWhiteSpace(options.Region)) - { - builder = builder.WithRegion(options.Region); - } - - _client = builder.Build(); + _client = CreateClient(options); _bucket = options.Bucket; } @@ -143,6 +133,21 @@ public async IAsyncEnumerable ListKeysAsync( } } + private static IMinioClient CreateClient(S3StorageOptions options) + { + var minio = new MinioClient() + .WithEndpoint(options.Endpoint) + .WithCredentials(options.AccessKey, options.SecretKey) + .WithSSL(options.UseSsl); + + if (!string.IsNullOrWhiteSpace(options.Region)) + { + minio = minio.WithRegion(options.Region); + } + + return minio.Build(); + } + private async ValueTask EnsureBucketAsync(CancellationToken cancellationToken) { await _bucketLock.WaitAsync(cancellationToken);