diff --git a/README.md b/README.md index 946862fb..a879589d 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 @@ -102,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 diff --git a/SquidStd.slnx b/SquidStd.slnx index 12acb065..12d68a83 100644 --- a/SquidStd.slnx +++ b/SquidStd.slnx @@ -15,6 +15,9 @@ + + + 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). 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/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 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/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 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/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/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.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/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs b/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs index 51d1d88b..1ce76469 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; @@ -86,7 +87,6 @@ public IContainer RegisterCoreServices(string configName, string configDirectory container.RegisterMainThreadDispatcherService(); container.RegisterTimerWheelService(); container.RegisterMetricsCollectionService(); - container.RegisterStorageServices(); container.RegisterSecretServices(); return container; @@ -137,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/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/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 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/src/SquidStd.Services.Core/Services/Storage/FileSecretStore.cs b/src/SquidStd.Services.Core/Services/Storage/FileSecretStore.cs index 17a441b7..ff3d3a22 100644 --- a/src/SquidStd.Services.Core/Services/Storage/FileSecretStore.cs +++ b/src/SquidStd.Services.Core/Services/Storage/FileSecretStore.cs @@ -1,7 +1,8 @@ using System.Text; using SquidStd.Core.Data.Storage; using SquidStd.Core.Interfaces.Secrets; -using SquidStd.Core.Interfaces.Storage; +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 60a1f51f..fec735f0 100644 --- a/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj +++ b/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj @@ -10,6 +10,8 @@ + + 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 77% rename from src/SquidStd.Core/Interfaces/Storage/IObjectStorageService.cs rename to src/SquidStd.Storage.Abstractions/Interfaces/IObjectStorageService.cs index 03647d4a..7977ec61 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. @@ -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.Core/Interfaces/Storage/IStorageService.cs b/src/SquidStd.Storage.Abstractions/Interfaces/IStorageService.cs similarity index 76% rename from src/SquidStd.Core/Interfaces/Storage/IStorageService.cs rename to src/SquidStd.Storage.Abstractions/Interfaces/IStorageService.cs index 50b0771b..3d3b8980 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. @@ -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.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.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/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/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.S3/Services/S3StorageService.cs b/src/SquidStd.Storage.S3/Services/S3StorageService.cs new file mode 100644 index 00000000..ce14a250 --- /dev/null +++ b/src/SquidStd.Storage.S3/Services/S3StorageService.cs @@ -0,0 +1,188 @@ +using System.Runtime.CompilerServices; +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, 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) + { + ArgumentException.ThrowIfNullOrWhiteSpace(options.Endpoint); + ArgumentException.ThrowIfNullOrWhiteSpace(options.AccessKey); + ArgumentException.ThrowIfNullOrWhiteSpace(options.SecretKey); + ArgumentException.ThrowIfNullOrWhiteSpace(options.Bucket); + + _client = CreateClient(options); + _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; + } + + /// + 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 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); + + 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(); + } + } + + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _client.Dispose(); + _bucketLock.Dispose(); + } +} 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/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.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). diff --git a/src/SquidStd.Services.Core/Services/Storage/FileStorageService.cs b/src/SquidStd.Storage/Services/FileStorageService.cs similarity index 67% rename from src/SquidStd.Services.Core/Services/Storage/FileStorageService.cs rename to src/SquidStd.Storage/Services/FileStorageService.cs index debadf4c..8e283dd8 100644 --- a/src/SquidStd.Services.Core/Services/Storage/FileStorageService.cs +++ b/src/SquidStd.Storage/Services/FileStorageService.cs @@ -1,8 +1,9 @@ -using SquidStd.Core.Data.Storage; -using SquidStd.Core.Interfaces.Storage; -using SquidStd.Services.Core.Services.Internal; +using System.Runtime.CompilerServices; +using SquidStd.Storage.Abstractions.Data.Config; +using SquidStd.Storage.Abstractions.Interfaces; +using SquidStd.Storage.Internal; -namespace SquidStd.Services.Core.Services.Storage; +namespace SquidStd.Storage.Services; /// /// Local file-backed binary storage. @@ -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.Services.Core/Services/Storage/YamlObjectStorageService.cs b/src/SquidStd.Storage/Services/YamlObjectStorageService.cs similarity index 84% rename from src/SquidStd.Services.Core/Services/Storage/YamlObjectStorageService.cs rename to src/SquidStd.Storage/Services/YamlObjectStorageService.cs index 63ddb76a..edbefb2c 100644 --- a/src/SquidStd.Services.Core/Services/Storage/YamlObjectStorageService.cs +++ b/src/SquidStd.Storage/Services/YamlObjectStorageService.cs @@ -1,8 +1,8 @@ using System.Text; -using SquidStd.Core.Interfaces.Storage; +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. @@ -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/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/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); + } +} 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; + } +} diff --git a/tests/SquidStd.Tests/Health/HealthCheckRegistrationTests.cs b/tests/SquidStd.Tests/Health/HealthCheckRegistrationTests.cs new file mode 100644 index 00000000..da710608 --- /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() + { + using 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")); + } +} 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); + } +} 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/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 3f281b57..74e259a7 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; @@ -165,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/SquidStd.Tests.csproj b/tests/SquidStd.Tests/SquidStd.Tests.csproj index 6cf2d3ec..59802f79 100644 --- a/tests/SquidStd.Tests/SquidStd.Tests.csproj +++ b/tests/SquidStd.Tests/SquidStd.Tests.csproj @@ -11,6 +11,7 @@ + @@ -45,6 +46,8 @@ + + diff --git a/tests/SquidStd.Tests/Storage/FileStorageServiceTests.cs b/tests/SquidStd.Tests/Storage/FileStorageServiceTests.cs new file mode 100644 index 00000000..36c21abe --- /dev/null +++ b/tests/SquidStd.Tests/Storage/FileStorageServiceTests.cs @@ -0,0 +1,124 @@ +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; } + } + + 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); + } +} 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..8899a9a1 --- /dev/null +++ b/tests/SquidStd.Tests/Storage/S3/S3StorageServiceTests.cs @@ -0,0 +1,88 @@ +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" }) + ); + } + + [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); + } +} 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; diff --git a/tests/SquidStd.Tests/Storage/StorageRegistrationTests.cs b/tests/SquidStd.Tests/Storage/StorageRegistrationTests.cs new file mode 100644 index 00000000..4bbc2ffc --- /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")); + using 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); + } +} 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(); + } +}