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). | [](src/SquidStd.Core/README.md) · [](https://www.nuget.org/packages/SquidStd.Core/) |
+| `SquidStd.Core` | Foundational contracts & utilities (config, event bus, jobs, metrics, serialization, YAML/JSON, Serilog sink). | [](src/SquidStd.Core/README.md) · [](https://www.nuget.org/packages/SquidStd.Core/) |
| `SquidStd.Abstractions` | DI registration plumbing (`ISquidStdService`, `RegisterStdService`, `RegisterConfigSection`). | [](src/SquidStd.Abstractions/README.md) · [](https://www.nuget.org/packages/SquidStd.Abstractions/) |
-| `SquidStd.Services.Core` | Concrete services: config, event bus, jobs, timer/cron scheduler, dispatcher, metrics, storage. | [](src/SquidStd.Services.Core/README.md) · [](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. | [](src/SquidStd.Services.Core/README.md) · [](https://www.nuget.org/packages/SquidStd.Services.Core/) |
| `SquidStd.AspNetCore` | ASP.NET Core host integration for the SquidStd service stack. | [](src/SquidStd.AspNetCore/README.md) · [](https://www.nuget.org/packages/SquidStd.AspNetCore/) |
| `SquidStd.Network` | TCP/UDP servers & clients, sessions, framing/middleware pipeline, span readers/writers. | [](src/SquidStd.Network/README.md) · [](https://www.nuget.org/packages/SquidStd.Network/) |
| `SquidStd.Plugin.Abstractions` | Plugin contracts (`ISquidStdPlugin`, metadata, context). | [](src/SquidStd.Plugin.Abstractions/README.md) · [](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). | [](src/SquidStd.Caching.Abstractions/README.md) · [](https://www.nuget.org/packages/SquidStd.Caching.Abstractions/) |
| `SquidStd.Caching` | In-memory cache backend (`AddInMemoryCache`). | [](src/SquidStd.Caching/README.md) · [](https://www.nuget.org/packages/SquidStd.Caching/) |
| `SquidStd.Caching.Redis` | Redis cache backend (`AddRedisCache`). | [](src/SquidStd.Caching.Redis/README.md) · [](https://www.nuget.org/packages/SquidStd.Caching.Redis/) |
+| `SquidStd.Storage.Abstractions` | Storage contracts (`IStorageService`, `IObjectStorageService`, `StorageConfig`, `ListKeysAsync`). | [](src/SquidStd.Storage.Abstractions/README.md) · [](https://www.nuget.org/packages/SquidStd.Storage.Abstractions/) |
+| `SquidStd.Storage` | Local file storage backend (`AddFileStorage`). | [](src/SquidStd.Storage/README.md) · [](https://www.nuget.org/packages/SquidStd.Storage/) |
+| `SquidStd.Storage.S3` | S3/MinIO storage backend (`AddS3Storage`). | [](src/SquidStd.Storage.S3/README.md) · [](https://www.nuget.org/packages/SquidStd.Storage.S3/) |
| `SquidStd.Scripting.Lua` | Lua scripting engine with attribute-based modules and event bridging. | [](src/SquidStd.Scripting.Lua/README.md) · [](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.Storage.Abstractions
+
+
+
+
+
+
+
+
+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.Storage.S3
+
+
+
+
+
+
+
+
+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.Storage
+
+
+
+
+
+
+
+
+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();
+ }
+}